From c22e1c4569cd19b0e13d5a79d2d181beca4fc150 Mon Sep 17 00:00:00 2001 From: "Troy N. Smith" Date: Wed, 29 Jul 2026 23:32:59 -0400 Subject: [PATCH 1/8] SQLModel only applies to database models; temporary removal of alembic --- .claude/agents/autostorage-explorer.md | 57 +++++ .claude/agents/autostorage-release.md | 31 +++ CHANGELOG.md | 3 + alembic.ini | 154 ------------ docs/source/data-model.md | 14 -- docs/source/database.md | 3 +- docs/source/development.md | 5 - docs/source/index.md | 1 - docs/source/migrations.md | 109 --------- docs/source/quickstart.md | 10 - migrations/README | 1 - migrations/env.py | 86 ------- migrations/script.py.mako | 28 --- .../4230f63e365f_add_geometry_hash.py | 73 ------ ...4_add_null_safe_indexes_reverse_lookup_.py | 136 ----------- .../faa9f50bc029_baseline_current_schema.py | 225 ------------------ pixi.toml | 2 - pyproject.toml | 1 - src/autostorage/database.py | 20 +- src/autostorage/events.py | 13 +- src/autostorage/exc.py | 6 +- src/autostorage/merge.py | 10 +- tests/test_database.py | 13 +- tests/test_merge.py | 7 +- tests/test_migrations.py | 57 ----- 25 files changed, 124 insertions(+), 941 deletions(-) create mode 100644 .claude/agents/autostorage-explorer.md create mode 100644 .claude/agents/autostorage-release.md delete mode 100644 alembic.ini delete mode 100644 docs/source/migrations.md delete mode 100644 migrations/README delete mode 100644 migrations/env.py delete mode 100644 migrations/script.py.mako delete mode 100644 migrations/versions/4230f63e365f_add_geometry_hash.py delete mode 100644 migrations/versions/e50de3129c84_add_null_safe_indexes_reverse_lookup_.py delete mode 100644 migrations/versions/faa9f50bc029_baseline_current_schema.py delete mode 100644 tests/test_migrations.py diff --git a/.claude/agents/autostorage-explorer.md b/.claude/agents/autostorage-explorer.md new file mode 100644 index 0000000..0e61f9a --- /dev/null +++ b/.claude/agents/autostorage-explorer.md @@ -0,0 +1,57 @@ +--- +name: autostorage-explorer +description: Use to explore/investigate the autostorage codebase before planning a feature or bugfix — pre-loaded with the module map, layering rules, and known subtleties so it doesn't need to rediscover them from scratch. Read-only; reports file:line references, does not propose implementations. +tools: Read, Grep, Glob, Bash +model: haiku +--- + +You are a read-only research agent for the `autostorage` repo (a SQLModel/SQLAlchemy persistence +layer for computational chemistry workflow data, built on `automol`). Your job is to locate the +exact rows, functions, event listeners, and tests relevant to a given feature/bug description, and +report `file:line` references — not to design or write the implementation. + +## Layout + +Flat module structure under `src/autostorage/`, layered (higher depends on lower, never reverse, +enforced by import-linter): `utils` > `database` > `merge` > `events` > `models` > `types`/`exc`. + +- `models.py` — SQLModel row definitions (`GeometryRow`, `EnergyRow`/`GradientRow`/`HessianRow`, + `TrajectoryRow`, `StationaryPointRow`, `IdentityRow`/`IdentityExtraRow`, `StageRow`, `StepRow`, + `ModelRow`, `CalculationRow`, `ValidationRow`, plus link tables). Base classes: `TimestampMixin`, + `BaseRow`, `BaseResultRow`, `BaseLink`. Several rows expose a shared `find_or_create` classmethod + (get-or-insert pattern) — check there first for any "does X already exist" question. +- `events.py` — SQLAlchemy ORM event listeners: shape validation for Gradient/Hessian; geometry + order-consensus recompute (`revalidate_geometry_orders_on_insert_update`/`_on_hessian_delete` — + session-level `before_flush` listeners, not mapper events, because they mutate sibling rows that + may already be clean going into the flush); `verify_geometry_immutable_fields`; + `compute_geometry_hash`; auto-managed identity attachment (`add_inchi_identities`, + `assign_conformer_ids`); `StepRow` stage-order/TS-consistency checks. +- `database.py` — `Database`: SQLite engine/session manager. +- `merge.py` — `merge_databases`: copies one database's rows into another, deduplicating + `ModelRow`, `GeometryRow`, non-auto-managed `IdentityRow`s, `CalculationRow`, and + `StationaryPointRow` via their `find_or_create` methods. +- `types.py` — `CalcType`, `CalcStatus`, `Role`, `IndexType`, `CompressedArrayTypeDecorator`. +- `exc.py` — `ResultShapeError`, `MissingPrimaryKeyError`. +- `utils.py` — MESS input export and PES plotting. + +## Known gotchas (check these before assuming a bug is novel) + +1. **`compute_geometry_hash`** writes `target.__dict__["geometry_hash"] = ...` + + `flag_modified(...)` instead of `target.geometry_hash = ...`. Plain attribute assignment inside + a mapper event breaks under `Geometry`'s `validate_assignment=True` pydantic config — it + corrupts SQLAlchemy's flush-time identity-key bookkeeping. +2. **`before_flush` vs mapper events**: anything that needs to mutate a *different* row than the + one that triggered the change (e.g. recomputing `StationaryPointRow.is_valid` when a sibling + `HessianRow` changes) must be a session-level `before_flush` listener. A per-instance + `before_insert`/`before_update` mapper event fires too late for such a mutation to be included + in the same flush — SQLAlchemy silently drops it instead of writing it. +3. **No migrations currently**: `migrations/` and `alembic.ini` were removed; `alembic` remains + a dev dependency for when migrations are reintroduced, but there is no active migration path — + schema changes only need to work with `SQLModel.metadata.create_all`. + +## What to report + +For a given feature/bug description: the specific row classes, event listeners, and existing +tests involved, with `file:line` references, plus which layering tier(s) a change would touch (to +flag likely `pixi run imports` fallout early). Do not propose an implementation — that's a +separate planning step. diff --git a/.claude/agents/autostorage-release.md b/.claude/agents/autostorage-release.md new file mode 100644 index 0000000..9afb0f4 --- /dev/null +++ b/.claude/agents/autostorage-release.md @@ -0,0 +1,31 @@ +--- +name: autostorage-release +description: Use when preparing an autostorage version release — walks the CHANGELOG/README/docstring/pre-commit/version-bump checklist. Can edit files and run pixi commands, but must stop and confirm before any publish/push/tag step. +tools: Read, Edit, Bash, Grep, Glob +model: haiku +--- + +You are the release-prep checklist runner for the `autostorage` repo. Work through these steps in +order, reporting status after each one. Stop and ask before any step marked irreversible. + +1. Run `pixi run pre-commit` first. Fix any failures (fmt/lint/types/imports/test) before + proceeding — don't paper over a failing step. +2. Review docstrings on code touched since the last tag (`git log ..HEAD --stat` or + similar) for NumPy-convention compliance and terseness: one-line summaries where possible, no + restating what a name/type hint already conveys. +3. Update `CHANGELOG.md` via `pixi run changelog ` (wraps `keepachangelog`) so entries + match the commits since the last release. Check `git log ..HEAD --oneline` against + what's already there. +4. Update `README.md` if the public API surface changed (new/removed exports in + `src/autostorage/__init__.py`, new Pixi tasks relevant to users, etc.). +5. Bump the version via `pixi run version` (check current) and `pixi run release` (backed by + `tbump`) — confirm `pyproject.toml`, `pixi.toml`, and `src/autostorage/__init__.py`'s + `__version__` all move together after the bump. +6. Re-run `pixi run pre-commit` to confirm a clean tree. + +## Do not run without explicit user confirmation first + +`pixi run build-conda`, `pixi run publish-conda`, `pixi run publish-pypi`, +`pixi run publish-test-pypi`, or any `git push`/tag push. These are external, hard-to-reverse +actions (publishing a package, pushing to a shared branch) — surface that the checklist is ready +for them and wait for the user to say go. diff --git a/CHANGELOG.md b/CHANGELOG.md index 143bd51..831092c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Removed + +- **Alembic migrations** (`migrations/`, `alembic.ini`, `tests/test_migrations.py`, `pixi run migrate`): Removed for now; `alembic` remains a dev dependency for when migrations are reintroduced. Schema for fresh/in-memory `Database` instances is unaffected, still built via `create_all()`. ## [0.0.12] - 2026-07-23 ### Added diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index f72d5af..0000000 --- a/alembic.ini +++ /dev/null @@ -1,154 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts. -# this is typically a path given in POSIX (e.g. forward slashes) -# format, relative to the token %(here)s which refers to the location of this -# ini file -script_location = %(here)s/migrations - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s -# Or organize into date-based subdirectories (requires recursive_version_locations = true) -# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. for multiple paths, the path separator -# is defined by "path_separator" below. -prepend_sys_path = . - - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the tzdata library which can be installed by adding -# `alembic[tz]` to the pip requirements. -# string value is passed to ZoneInfo() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to /versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "path_separator" -# below. -# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions - -# path_separator; This indicates what character is used to split lists of file -# paths, including version_locations and prepend_sys_path within configparser -# files such as alembic.ini. -# The default rendered in new alembic.ini files is "os", which uses os.pathsep -# to provide os-dependent path splitting. -# -# Note that in order to support legacy alembic.ini files, this default does NOT -# take place if path_separator is not present in alembic.ini. If this -# option is omitted entirely, fallback logic is as follows: -# -# 1. Parsing of the version_locations option falls back to using the legacy -# "version_path_separator" key, which if absent then falls back to the legacy -# behavior of splitting on spaces and/or commas. -# 2. Parsing of the prepend_sys_path option falls back to the legacy -# behavior of splitting on spaces, commas, or colons. -# -# Valid values for path_separator are: -# -# path_separator = : -# path_separator = ; -# path_separator = space -# path_separator = newline -# -# Use os.pathsep. Default configuration used for new projects. -path_separator = os - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -# database URL. This is consumed by the user-maintained env.py script only. -# other means of configuring database URLs may be customized within the env.py -# file. -# -# This is a placeholder; the real target is normally supplied at runtime via the -# AUTOSTORAGE_DATABASE_URL environment variable (see migrations/env.py), since -# autostorage.Database accepts an arbitrary SQLite file path rather than having -# one fixed database. -sqlalchemy.url = sqlite:///autostorage.db - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module -# hooks = ruff -# ruff.type = module -# ruff.module = ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Alternatively, use the exec runner to execute a binary found on your PATH -# hooks = ruff -# ruff.type = exec -# ruff.executable = ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Logging configuration. This is also consumed by the user-maintained -# env.py script only. -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARNING -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARNING -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/docs/source/data-model.md b/docs/source/data-model.md index a9ccf86..0fbeb5c 100644 --- a/docs/source/data-model.md +++ b/docs/source/data-model.md @@ -98,17 +98,3 @@ why some of these are registered at the session level rather than per-model. satisfy the `stage_id1 < stage_id2` check constraint), `is_barrierless` is derived from whether `stage_id_ts` is set, and `stage1`/`stage2` are rejected if they reference a transition-state stage (and vice versa for `stage_ts`). - -## Migrations - -`migrations/` holds Alembic migrations, wired to `SQLModel.metadata`. This only applies to -evolving an *existing* on-disk database in place — fresh or in-memory `Database` instances -(including every test) get their schema from `SQLModel.metadata.create_all` directly, no -migration involved. - -Any change to a `table=True` model's columns/constraints/indexes needs a matching Alembic -revision. SQLite can't reflect the expression-based null-safe unique indexes on -`ModelRow`/`StepRow` (e.g. `unique_model_null_safe`, `unq_step_stages_null_safe`), so -`alembic revision --autogenerate` silently skips those — they must be added to new migrations -by hand if those models are ever touched again. See [Migrations](migrations.md) for the full -workflow. diff --git a/docs/source/database.md b/docs/source/database.md index e414754..f5e2eef 100644 --- a/docs/source/database.md +++ b/docs/source/database.md @@ -25,8 +25,7 @@ CASCADE` behavior the schema relies on (see [Link tables](data-model.md#link-tab on this being set. `__init__` also calls `SQLModel.metadata.create_all(self.engine)`, so a fresh or in-memory -`Database` gets its full schema immediately — no Alembic migration is involved for these cases. -See [Migrations](migrations.md) for evolving an *existing* on-disk database instead. +`Database` gets its full schema immediately. ### JSON key ordering diff --git a/docs/source/development.md b/docs/source/development.md index 2ae84b6..71eb59d 100644 --- a/docs/source/development.md +++ b/docs/source/development.md @@ -36,8 +36,6 @@ are invoked as `pixi run ` (task definitions live in `pixi.toml` under - Runs all of the above in order, then checks the working tree is clean. * - `pixi run cov-view` - Opens the HTML coverage report (`htmlcov/index.html`) in `$BROWSER`. -* - `pixi run migrate` - - Applies Alembic migrations to an existing on-disk database — see [Migrations](migrations.md). ``` A single test: @@ -60,9 +58,6 @@ pixi run -e dev pytest tests/test_models.py::test_name (`Database(":memory:")`, closed on teardown), a seeded `rng`, and baseline `model_row`/ `geometry_row`/`calculation_row`/`calc_geo_link` fixtures used across `test_models.py` and `test_database.py`. -- `tests/test_migrations.py` is a smoke test that Alembic's `upgrade head` reproduces exactly - the schema `SQLModel.metadata.create_all` would build — see - [Keeping migrations and models in sync](migrations.md#keeping-migrations-and-models-in-sync). ## Pre-commit pipeline diff --git a/docs/source/index.md b/docs/source/index.md index e51670d..b5daa58 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -18,7 +18,6 @@ quickstart data-model database events -migrations development apidocs/index ::: diff --git a/docs/source/migrations.md b/docs/source/migrations.md deleted file mode 100644 index dbbddd9..0000000 --- a/docs/source/migrations.md +++ /dev/null @@ -1,109 +0,0 @@ -# Migrations - -`autostorage` has two independent sources of schema, used in different situations: - -- **`SQLModel.metadata.create_all(engine)`** — runs automatically inside - {py:meth}`Database.__init__ ` and builds the full - current schema from the model definitions in one shot. This is what fresh and in-memory - databases use, including every `Database` instance created in the test suite. No Alembic - involvement at all. -- **Alembic migrations** (`migrations/`) — the only path for evolving an *existing* on-disk - database in place, so it picks up schema changes without losing the data already in it. - -If you're not migrating an existing database (e.g. writing a test, or working with a -scratch/in-memory `Database`), you don't need anything on this page — just change the models -and `create_all` picks it up. - -## Applying migrations - -```bash -AUTOSTORAGE_DATABASE_URL=sqlite:///path/to.db pixi run migrate -``` - -`pixi run migrate` runs `alembic upgrade head`. `migrations/env.py` reads -`AUTOSTORAGE_DATABASE_URL` from the environment and uses it to override `alembic.ini`'s -`sqlalchemy.url` at runtime, so a single Alembic setup can target any on-disk database without -editing config. If the variable isn't set, Alembic falls back to whatever's hardcoded in -`alembic.ini`. - -`env.py` also does one thing that's easy to miss: it imports `autostorage.database` purely for -its side effect of pulling in `autostorage.models` and `autostorage.events`, which is what -registers every `table=True` model onto `SQLModel.metadata`. Without that import, Alembic -would see an empty target schema. - -## Writing a new migration - -After changing a `table=True` model's columns, constraints, or indexes: - -```bash -pixi run -e dev alembic revision --autogenerate -m "describe the change" -``` - -Run this against a scratch on-disk SQLite database (not `:memory:` — Alembic needs a real file -to diff against), then **review the generated script by hand** before committing it. Alembic's -autogenerate is a reasonable first draft, not a guarantee — see the caveat below for one gap -specific to this schema. - -### SQLite can't reflect expression-based indexes - -Two constraints in `models.py` are implemented as expression-based unique `Index`es rather than -plain `UniqueConstraint`s, specifically to make them null-safe (SQL treats `NULL != NULL`, so a -plain `UniqueConstraint` lets multiple "duplicate" rows through whenever one of the constrained -columns is `NULL`): - -- `unique_model_null_safe` on `ModelRow`, over `(program, coalesce(program_version, ''), - method, coalesce(basis, ''))`. -- `unq_step_stages_null_safe` on `StepRow`, over `(stage_id1, stage_id2, coalesce(stage_id_ts, - 0))`. - -SQLite's reflection support can't see expression-based indexes, so `alembic revision ---autogenerate` silently omits them from the generated script — it isn't that it gets them -wrong, it just doesn't know they exist. If a migration touches `model` or `step`, check whether -these indexes need to be re-created by hand, following the pattern already used in -`e50de3129c84_add_null_safe_indexes_reverse_lookup_.py`: - -```python -op.create_index( - "unique_model_null_safe", - "model", - [ - "program", - sa.text("coalesce(program_version, '')"), - "method", - sa.text("coalesce(basis, '')"), - ], - unique=True, -) -``` - -Both indexes are also defense-in-depth alongside an app-level lookup that's the actual -dedup mechanism in practice — `ModelRow.find_or_create` and `StepRow.find_or_create`/`.query` -(see [Data model](data-model.md)) — so a missed index mainly risks a duplicate row slipping in -under a race, not a functional bug in normal single-writer use. - -## Migration history - -```{list-table} -:header-rows: 1 - -* - Revision - - Down revision - - Summary -* - `faa9f50bc029` - - (base) - - Baseline: the schema as of the first tracked migration. -* - `e50de3129c84` - - `faa9f50bc029` - - Adds `created_at`/`updated_at` timestamps across tables, `CalculationRow.status`/ - `error_message`, reverse-lookup indexes on several link tables, and the two null-safe - expression indexes described above. -``` - -## Keeping migrations and models in sync - -`tests/test_migrations.py` guards against migrations drifting from the current model -definitions: it applies every migration to a scratch database with `alembic upgrade head`, then -uses `alembic.autogenerate.compare_metadata` to diff the result against `SQLModel.metadata` and -asserts there's no difference. If you change a model without writing (or correctly writing) the -matching migration, this test fails — treat it as the source of truth for whether a migration -is complete, not just `alembic revision --autogenerate` running without error. diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 5b2cec0..9c626ce 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -70,16 +70,6 @@ with Database("workflow.db") as db: ... ``` -## Migrating an existing database - -Fresh or in-memory `Database` instances get their schema from `SQLModel.metadata.create_all` -automatically — no migration step needed. For an existing on-disk database, apply -[Alembic](https://alembic.sqlalchemy.org/) migrations with: - -```bash -AUTOSTORAGE_DATABASE_URL=sqlite:///path/to.db pixi run migrate -``` - See [Data model](data-model.md) for the schema this creates, [Database](database.md) for the full `Database` method surface, [Events](events.md) for the automatic validation/enrichment behavior that runs on every flush, and the {doc}`API reference ` for full details diff --git a/migrations/README b/migrations/README deleted file mode 100644 index 98e4f9c..0000000 --- a/migrations/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py deleted file mode 100644 index bd47ba2..0000000 --- a/migrations/env.py +++ /dev/null @@ -1,86 +0,0 @@ -import os -from logging.config import fileConfig - -from sqlalchemy import engine_from_config -from sqlalchemy import pool -from sqlmodel import SQLModel - -from alembic import context - -# Importing this module registers every `table=True` model (via its -# `from .events import *` / `from .models import *`) onto `SQLModel.metadata`, -# which is what makes autogenerate below able to see the schema at all. -import autostorage.database # noqa: F401 - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# Allow the target database to be selected at runtime (e.g. `AUTOSTORAGE_DATABASE_URL= -# sqlite:///path/to.db alembic upgrade head`) instead of hardcoding it in alembic.ini. -if database_url := os.environ.get("AUTOSTORAGE_DATABASE_URL"): - config.set_main_option("sqlalchemy.url", database_url) - -target_metadata = SQLModel.metadata - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako deleted file mode 100644 index 1101630..0000000 --- a/migrations/script.py.mako +++ /dev/null @@ -1,28 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - """Upgrade schema.""" - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - """Downgrade schema.""" - ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/4230f63e365f_add_geometry_hash.py b/migrations/versions/4230f63e365f_add_geometry_hash.py deleted file mode 100644 index bde7f26..0000000 --- a/migrations/versions/4230f63e365f_add_geometry_hash.py +++ /dev/null @@ -1,73 +0,0 @@ -"""add geometry hash - -Revision ID: 4230f63e365f -Revises: e50de3129c84 -Create Date: 2026-07-23 19:58:37.026493 - -""" -import json -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - -from autostorage.models import _geometry_hash -from autostorage.types import CompressedArrayTypeDecorator - - -# revision identifiers, used by Alembic. -revision: str = '4230f63e365f' -down_revision: Union[str, Sequence[str], None] = 'e50de3129c84' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema. - - Added nullable, backfilled per-row from existing `symbols`/`coordinates`/ - `charge`/`spin`, then tightened to NOT NULL + unique -- adding a NOT NULL - unique column directly against a populated table isn't possible in one - step. If the database already contains bit-identical duplicate geometries - (only possible from before this constraint existed), the final - `create_unique_constraint` below will fail; such rows must be - deduplicated (and their foreign-key references remapped, as - `autostorage.merge` does for a cross-database merge) before this - migration can complete. - """ - op.add_column( - 'geometry', - sa.Column('geometry_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - ) - - bind = op.get_bind() - decoder = CompressedArrayTypeDecorator() - rows = bind.execute( - sa.text('SELECT id, symbols, coordinates, charge, spin FROM geometry') - ).fetchall() - for row_id, symbols_json, coordinates_blob, charge, spin in rows: - symbols = json.loads(symbols_json) - coordinates = decoder.process_result_value(coordinates_blob, bind.dialect) - geometry_hash = _geometry_hash(symbols, coordinates, charge, spin) - bind.execute( - sa.text('UPDATE geometry SET geometry_hash = :hash WHERE id = :id'), - {'hash': geometry_hash, 'id': row_id}, - ) - - # SQLite can't ALTER a column's nullability or add a constraint directly; - # batch mode recreates the table under the hood. - with op.batch_alter_table('geometry') as batch_op: - batch_op.alter_column( - 'geometry_hash', - existing_type=sqlmodel.sql.sqltypes.AutoString(), - nullable=False, - ) - batch_op.create_unique_constraint('unique_geometry_hash', ['geometry_hash']) - - -def downgrade() -> None: - """Downgrade schema.""" - with op.batch_alter_table('geometry') as batch_op: - batch_op.drop_constraint('unique_geometry_hash', type_='unique') - batch_op.drop_column('geometry_hash') diff --git a/migrations/versions/e50de3129c84_add_null_safe_indexes_reverse_lookup_.py b/migrations/versions/e50de3129c84_add_null_safe_indexes_reverse_lookup_.py deleted file mode 100644 index 34a6784..0000000 --- a/migrations/versions/e50de3129c84_add_null_safe_indexes_reverse_lookup_.py +++ /dev/null @@ -1,136 +0,0 @@ -"""add null-safe indexes, reverse-lookup indexes, timestamps, and calculation status - -Revision ID: e50de3129c84 -Revises: faa9f50bc029 -Create Date: 2026-07-22 23:00:01.762231 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - - -# revision identifiers, used by Alembic. -revision: str = 'e50de3129c84' -down_revision: Union[str, Sequence[str], None] = 'faa9f50bc029' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('calculation', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('calculation', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('calculation', sa.Column('status', sa.Enum('pending', 'running', 'succeeded', 'failed', name='calcstatus'), nullable=True)) - op.add_column('calculation', sa.Column('error_message', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) - op.create_index('ix_calculation_geometry_link_calculation_id', 'calculation_geometry_link', ['calculation_id'], unique=False) - op.create_index('ix_calculation_trajectory_link_calculation_id', 'calculation_trajectory_link', ['calculation_id'], unique=False) - op.add_column('energy', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('energy', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('geometry', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('geometry', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('gradient', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('gradient', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('hessian', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('hessian', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('identity', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('identity', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('identity_extras', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('identity_extras', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.create_index(op.f('ix_identity_extras_identity_id'), 'identity_extras', ['identity_id'], unique=False) - op.add_column('model', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('model', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - # Autogenerate can't reflect SQLite expression-based indexes, so this and - # 'unq_step_stages_null_safe' below aren't detected/created automatically. - op.create_index( - 'unique_model_null_safe', - 'model', - [ - 'program', - sa.text("coalesce(program_version, '')"), - 'method', - sa.text("coalesce(basis, '')"), - ], - unique=True, - ) - op.add_column('stage', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('stage', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.create_index('ix_stationary_identity_link_identity_id', 'stationary_identity_link', ['identity_id'], unique=False) - op.add_column('stationary_point', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('stationary_point', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.create_index('ix_stationary_stage_link_stage_id', 'stationary_stage_link', ['stage_id'], unique=False) - op.add_column('step', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('step', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.create_index('ix_step_stage_id1', 'step', ['stage_id1'], unique=False) - op.create_index('ix_step_stage_id2', 'step', ['stage_id2'], unique=False) - op.create_index('ix_step_stage_id_ts', 'step', ['stage_id_ts'], unique=False) - op.create_index( - 'unq_step_stages_null_safe', - 'step', - ['stage_id1', 'stage_id2', sa.text('coalesce(stage_id_ts, 0)')], - unique=True, - ) - op.create_index('ix_step_validation_link_validation_id', 'step_validation_link', ['validation_id'], unique=False) - op.add_column('trajectory', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('trajectory', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.create_index('ix_trajectory_geometry_link_trajectory_id', 'trajectory_geometry_link', ['trajectory_id'], unique=False) - op.add_column('validation', sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.add_column('validation', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False)) - op.alter_column('validation', 'calculation_id', - existing_type=sa.INTEGER(), - nullable=False) - op.create_index(op.f('ix_validation_calculation_id'), 'validation', ['calculation_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_validation_calculation_id'), table_name='validation') - op.alter_column('validation', 'calculation_id', - existing_type=sa.INTEGER(), - nullable=True) - op.drop_column('validation', 'updated_at') - op.drop_column('validation', 'created_at') - op.drop_index('ix_trajectory_geometry_link_trajectory_id', table_name='trajectory_geometry_link') - op.drop_column('trajectory', 'updated_at') - op.drop_column('trajectory', 'created_at') - op.drop_index('ix_step_validation_link_validation_id', table_name='step_validation_link') - op.drop_index('unq_step_stages_null_safe', table_name='step') - op.drop_index('ix_step_stage_id_ts', table_name='step') - op.drop_index('ix_step_stage_id2', table_name='step') - op.drop_index('ix_step_stage_id1', table_name='step') - op.drop_column('step', 'updated_at') - op.drop_column('step', 'created_at') - op.drop_index('ix_stationary_stage_link_stage_id', table_name='stationary_stage_link') - op.drop_column('stationary_point', 'updated_at') - op.drop_column('stationary_point', 'created_at') - op.drop_index('ix_stationary_identity_link_identity_id', table_name='stationary_identity_link') - op.drop_column('stage', 'updated_at') - op.drop_column('stage', 'created_at') - op.drop_index('unique_model_null_safe', table_name='model') - op.drop_column('model', 'updated_at') - op.drop_column('model', 'created_at') - op.drop_index(op.f('ix_identity_extras_identity_id'), table_name='identity_extras') - op.drop_column('identity_extras', 'updated_at') - op.drop_column('identity_extras', 'created_at') - op.drop_column('identity', 'updated_at') - op.drop_column('identity', 'created_at') - op.drop_column('hessian', 'updated_at') - op.drop_column('hessian', 'created_at') - op.drop_column('gradient', 'updated_at') - op.drop_column('gradient', 'created_at') - op.drop_column('geometry', 'updated_at') - op.drop_column('geometry', 'created_at') - op.drop_column('energy', 'updated_at') - op.drop_column('energy', 'created_at') - op.drop_index('ix_calculation_trajectory_link_calculation_id', table_name='calculation_trajectory_link') - op.drop_index('ix_calculation_geometry_link_calculation_id', table_name='calculation_geometry_link') - op.drop_column('calculation', 'error_message') - op.drop_column('calculation', 'status') - op.drop_column('calculation', 'updated_at') - op.drop_column('calculation', 'created_at') - # ### end Alembic commands ### diff --git a/migrations/versions/faa9f50bc029_baseline_current_schema.py b/migrations/versions/faa9f50bc029_baseline_current_schema.py deleted file mode 100644 index 88d270d..0000000 --- a/migrations/versions/faa9f50bc029_baseline_current_schema.py +++ /dev/null @@ -1,225 +0,0 @@ -"""baseline: current schema - -Revision ID: faa9f50bc029 -Revises: -Create Date: 2026-07-22 22:49:12.069197 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - -import autostorage.types - - -# revision identifiers, used by Alembic. -revision: str = 'faa9f50bc029' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('geometry', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('symbols', sa.JSON(), nullable=True), - sa.Column('coordinates', autostorage.types.CompressedArrayTypeDecorator(), nullable=True), - sa.Column('charge', sa.Integer(), nullable=False), - sa.Column('spin', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('identity', - sa.Column('algorithm', sa.Enum('RDKIT_INCHI', 'RDKIT_SMILES', 'IRMSD', name='algorithm'), nullable=False), - sa.Column('value', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('kind', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('kind', 'algorithm', 'value', name='unique_identity') - ) - op.create_table('model', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('program', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('program_version', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('method', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('basis', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('program', 'program_version', 'method', 'basis', name='unique_model') - ) - op.create_table('stage', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('is_ts', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('trajectory', - sa.Column('id', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('calculation', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('model_id', sa.Integer(), nullable=False), - sa.Column('calc_type', sa.Enum('optimization', 'transition_optimization', 'conformer_search', 'scan', 'intrinsic_reaction_coordinate', 'minimum_energy_path_search', 'energy', 'gradient', 'frequency', 'thermochemistry', 'undefined', name='calctype'), nullable=True), - sa.Column('input_provenance', sa.JSON(), nullable=True), - sa.Column('output_provenance', sa.JSON(), nullable=True), - sa.ForeignKeyConstraint(['model_id'], ['model.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_calculation_model_id'), 'calculation', ['model_id'], unique=False) - op.create_table('identity_extras', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('identity_id', sa.Integer(), nullable=False), - sa.Column('attribute', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('value', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.ForeignKeyConstraint(['identity_id'], ['identity.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('step', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('stage_id1', sa.Integer(), nullable=False), - sa.Column('stage_id2', sa.Integer(), nullable=False), - sa.Column('stage_id_ts', sa.Integer(), nullable=True), - sa.Column('is_barrierless', sa.Boolean(), nullable=False), - sa.CheckConstraint('stage_id1 < stage_id2', name='chk_stage_order'), - sa.ForeignKeyConstraint(['stage_id1'], ['stage.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['stage_id2'], ['stage.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['stage_id_ts'], ['stage.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('stage_id1', 'stage_id2', 'stage_id_ts', name='unq_step_stages') - ) - op.create_table('trajectory_geometry_link', - sa.Column('geometry_id', sa.Integer(), nullable=False), - sa.Column('trajectory_id', sa.Integer(), nullable=False), - sa.Column('index', sa.JSON(), nullable=True), - sa.ForeignKeyConstraint(['geometry_id'], ['geometry.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['trajectory_id'], ['trajectory.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('geometry_id', 'trajectory_id') - ) - op.create_table('calculation_geometry_link', - sa.Column('geometry_id', sa.Integer(), nullable=False), - sa.Column('calculation_id', sa.Integer(), nullable=False), - sa.Column('role', sa.Enum('input', 'output', name='role'), nullable=True), - sa.ForeignKeyConstraint(['calculation_id'], ['calculation.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['geometry_id'], ['geometry.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('geometry_id', 'calculation_id') - ) - op.create_table('calculation_trajectory_link', - sa.Column('trajectory_id', sa.Integer(), nullable=False), - sa.Column('calculation_id', sa.Integer(), nullable=False), - sa.Column('role', sa.Enum('input', 'output', name='role'), nullable=True), - sa.ForeignKeyConstraint(['calculation_id'], ['calculation.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['trajectory_id'], ['trajectory.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('trajectory_id', 'calculation_id') - ) - op.create_table('energy', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('geometry_id', sa.Integer(), nullable=False), - sa.Column('calculation_id', sa.Integer(), nullable=False), - sa.Column('value', sa.Float(), nullable=False), - sa.ForeignKeyConstraint(['calculation_id'], ['calculation.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['geometry_id'], ['geometry.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_energy_calculation_id'), 'energy', ['calculation_id'], unique=False) - op.create_index(op.f('ix_energy_geometry_id'), 'energy', ['geometry_id'], unique=False) - op.create_table('gradient', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('geometry_id', sa.Integer(), nullable=False), - sa.Column('calculation_id', sa.Integer(), nullable=False), - sa.Column('value', autostorage.types.CompressedArrayTypeDecorator(), nullable=True), - sa.ForeignKeyConstraint(['calculation_id'], ['calculation.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['geometry_id'], ['geometry.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_gradient_calculation_id'), 'gradient', ['calculation_id'], unique=False) - op.create_index(op.f('ix_gradient_geometry_id'), 'gradient', ['geometry_id'], unique=False) - op.create_table('hessian', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('geometry_id', sa.Integer(), nullable=False), - sa.Column('calculation_id', sa.Integer(), nullable=False), - sa.Column('value', autostorage.types.CompressedArrayTypeDecorator(), nullable=True), - sa.ForeignKeyConstraint(['calculation_id'], ['calculation.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['geometry_id'], ['geometry.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_hessian_calculation_id'), 'hessian', ['calculation_id'], unique=False) - op.create_index(op.f('ix_hessian_geometry_id'), 'hessian', ['geometry_id'], unique=False) - op.create_table('stationary_point', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('geometry_id', sa.Integer(), nullable=False), - sa.Column('calculation_id', sa.Integer(), nullable=False), - sa.Column('order', sa.Integer(), nullable=False), - sa.Column('is_pseudo', sa.Boolean(), nullable=False), - sa.Column('is_valid', sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint(['calculation_id'], ['calculation.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['geometry_id'], ['geometry.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_stationary_point_calculation_id'), 'stationary_point', ['calculation_id'], unique=False) - op.create_index(op.f('ix_stationary_point_geometry_id'), 'stationary_point', ['geometry_id'], unique=False) - op.create_table('validation', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('calculation_id', sa.Integer(), nullable=True), - sa.Column('method', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('extras', sa.JSON(), nullable=True), - sa.ForeignKeyConstraint(['calculation_id'], ['calculation.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('stationary_identity_link', - sa.Column('stationary_id', sa.Integer(), nullable=False), - sa.Column('identity_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['identity_id'], ['identity.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['stationary_id'], ['stationary_point.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('stationary_id', 'identity_id') - ) - op.create_table('stationary_stage_link', - sa.Column('stationary_id', sa.Integer(), nullable=False), - sa.Column('stage_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['stage_id'], ['stage.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['stationary_id'], ['stationary_point.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('stationary_id', 'stage_id') - ) - op.create_table('step_validation_link', - sa.Column('step_id', sa.Integer(), nullable=False), - sa.Column('validation_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['step_id'], ['step.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['validation_id'], ['validation.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('step_id', 'validation_id') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('step_validation_link') - op.drop_table('stationary_stage_link') - op.drop_table('stationary_identity_link') - op.drop_table('validation') - op.drop_index(op.f('ix_stationary_point_geometry_id'), table_name='stationary_point') - op.drop_index(op.f('ix_stationary_point_calculation_id'), table_name='stationary_point') - op.drop_table('stationary_point') - op.drop_index(op.f('ix_hessian_geometry_id'), table_name='hessian') - op.drop_index(op.f('ix_hessian_calculation_id'), table_name='hessian') - op.drop_table('hessian') - op.drop_index(op.f('ix_gradient_geometry_id'), table_name='gradient') - op.drop_index(op.f('ix_gradient_calculation_id'), table_name='gradient') - op.drop_table('gradient') - op.drop_index(op.f('ix_energy_geometry_id'), table_name='energy') - op.drop_index(op.f('ix_energy_calculation_id'), table_name='energy') - op.drop_table('energy') - op.drop_table('calculation_trajectory_link') - op.drop_table('calculation_geometry_link') - op.drop_table('trajectory_geometry_link') - op.drop_table('step') - op.drop_table('identity_extras') - op.drop_index(op.f('ix_calculation_model_id'), table_name='calculation') - op.drop_table('calculation') - op.drop_table('trajectory') - op.drop_table('stage') - op.drop_table('model') - op.drop_table('identity') - op.drop_table('geometry') - # ### end Alembic commands ### diff --git a/pixi.toml b/pixi.toml index b0a2d72..1c82dcc 100644 --- a/pixi.toml +++ b/pixi.toml @@ -70,8 +70,6 @@ lint = "ruff check . --fix" types = "ty check" imports = "lint-imports" test = "pytest" -# Apply database migrations (set AUTOSTORAGE_DATABASE_URL to target a specific DB) -migrate = "alembic upgrade head" # Run pre-commit hooks (see lefthook.yaml) pre-commit = "lefthook run pre-commit --all-files" local-pre-commit = "lefthook run local-pre-commit --all-files" diff --git a/pyproject.toml b/pyproject.toml index 92a4340..043762f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,6 @@ module-name = "autostorage" exclude = [ "docs", "**/*.ipynb", - "migrations", ] [tool.ruff.lint] diff --git a/src/autostorage/database.py b/src/autostorage/database.py index 2189907..0d2de05 100644 --- a/src/autostorage/database.py +++ b/src/autostorage/database.py @@ -9,21 +9,21 @@ from typing import Self import click -from sqlalchemy import event +from sqlalchemy import Select, create_engine, event from sqlalchemy import select as sa_select from sqlalchemy.exc import MultipleResultsFound, NoResultFound -from sqlmodel import Session, SQLModel, create_engine -from sqlmodel.sql.expression import Select, SelectOfScalar +from sqlalchemy.orm import Session # Ensure all modules are loaded with the database from .events import * # noqa: F403 from .merge import MergeReport from .merge import merge_databases as _merge_databases from .models import * # noqa: F403 +from .models import SQLModel -type SelectStatement[T] = Select[T] | SelectOfScalar[T] +type SelectStatement[T] = Select[tuple[T]] -__all__ = ["Database", "Select", "SelectOfScalar", "SelectStatement"] +__all__ = ["Database", "Select", "SelectStatement"] class Database: @@ -182,13 +182,13 @@ def get[RowT: SQLModel](self, model: type[RowT], row_id: int) -> RowT: def exec_first[RowT: SQLModel](self, stmt: SelectStatement[RowT]) -> RowT | None: """Return the first match to a statement.""" with self.session() as session: - return session.exec(stmt).first() + return session.scalars(stmt).first() def exec_one[RowT: SQLModel](self, stmt: SelectStatement[RowT]) -> RowT: """Return the single match to a statement.""" with self.session() as session: try: - return session.exec(stmt).one() + return session.scalars(stmt).one() except NoResultFound as exc: msg = f"No row found matching {stmt}." raise LookupError(msg) from exc @@ -199,7 +199,7 @@ def exec_one[RowT: SQLModel](self, stmt: SelectStatement[RowT]) -> RowT: def exec_all[RowT: SQLModel](self, stmt: SelectStatement[RowT]) -> list[RowT]: """Return all matches to a statement.""" with self.session() as session: - return list(session.exec(stmt).all()) + return list(session.scalars(stmt).all()) def exists[RowT: SQLModel](self, stmt: SelectStatement[RowT]) -> bool: """Return whether any row matches a statement. @@ -208,9 +208,7 @@ def exists[RowT: SQLModel](self, stmt: SelectStatement[RowT]) -> bool: row is never materialized. """ with self.session() as session: - return bool( - session.exec(sa_select(stmt.exists())).scalar() # ty:ignore[no-matching-overload] - ) + return bool(session.scalar(sa_select(stmt.exists()))) def close(self) -> None: """Close the database connection.""" diff --git a/src/autostorage/events.py b/src/autostorage/events.py index 1401ca3..295e230 100644 --- a/src/autostorage/events.py +++ b/src/autostorage/events.py @@ -5,11 +5,10 @@ import numpy as np from automol import Algorithm, geom -from sqlalchemy import event, tuple_ +from sqlalchemy import Integer, cast, event, func, select, tuple_ from sqlalchemy.engine import Connection -from sqlalchemy.orm import Mapper, object_session +from sqlalchemy.orm import Mapper, Session, object_session from sqlalchemy.orm.attributes import flag_modified, get_history -from sqlmodel import Integer, Session, cast, func, select from .exc import DataIntegrityError, ResultShapeError from .models import ( @@ -244,7 +243,7 @@ def add_inchi_identities(session: Session, flush_context: Any, instances: Any) - stmt = select(IdentityRow).where( tuple_(IdentityRow.algorithm, IdentityRow.value).in_(inchi_lookups) # ty:ignore[invalid-argument-type] ) - existing_rows = session.exec(stmt).all() + existing_rows = session.scalars(stmt).all() identity_map = {(r.algorithm, r.value): r for r in existing_rows} @@ -330,11 +329,11 @@ def assign_conformer_ids(session: Session, flush_context: Any, instances: Any) - if next_group_id is None: # Assumes single-writer; concurrent writers rely on the DB's uniqueness # constraint to fail one session's commit instead. - current_max = session.exec( + current_max = session.scalar( select(func.max(cast(IdentityRow.value, Integer))).where( - IdentityRow.kind == Algorithm.IRMSD.kind + IdentityRow.kind == Algorithm.IRMSD.kind # ty:ignore[invalid-argument-type] ) - ).first() + ) next_group_id = (current_max or 0) + 1 else: next_group_id += 1 diff --git a/src/autostorage/exc.py b/src/autostorage/exc.py index abe56c5..2b2850e 100644 --- a/src/autostorage/exc.py +++ b/src/autostorage/exc.py @@ -2,8 +2,6 @@ from typing import Self -from sqlmodel import SQLModel - __all__ = ["DataIntegrityError", "MissingPrimaryKeyError", "ResultShapeError"] @@ -15,7 +13,7 @@ class ResultShapeError(Exception): """Raise when a result violates expected shape.""" def __init__( - self: Self, model: SQLModel, actual: tuple[int, ...], expected: tuple[int, ...] + self: Self, model: object, actual: tuple[int, ...], expected: tuple[int, ...] ) -> None: """Initialize exception.""" class_name = model.__class__.__name__ @@ -26,7 +24,7 @@ def __init__( class MissingPrimaryKeyError(Exception): """Raise when primary keys weren't provided to a query method.""" - def __init__(self: Self, rows: list[SQLModel]) -> None: + def __init__(self: Self, rows: list[object]) -> None: row_ids = [ f"{row.__class__.__name__}: {getattr(row, 'id', None)}" for row in rows ] diff --git a/src/autostorage/merge.py b/src/autostorage/merge.py index 6f22ed6..aa776fb 100644 --- a/src/autostorage/merge.py +++ b/src/autostorage/merge.py @@ -1,10 +1,11 @@ """Merge one database's contents into another, with validation at merge time.""" +from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING +from sqlalchemy import func, select from sqlalchemy import inspect as sa_inspect -from sqlmodel import SQLModel, func, select from .events import AUTO_MANAGED_IDENTITY_ALGORITHMS from .models import ( @@ -12,6 +13,7 @@ IdentityExtraRow, IdentityRow, ModelRow, + SQLModel, StationaryIdentityLink, ) @@ -238,7 +240,7 @@ def _copy_row( def _copy_table( cls: type[SQLModel], - rows: list[SQLModel], + rows: Sequence[SQLModel], *, target: "Database", id_map: dict[type[SQLModel], dict[int, int]], @@ -263,7 +265,7 @@ def _copy_table( def _table_count(db: "Database", cls: type[SQLModel]) -> int: """Return the number of rows currently in `cls`'s table.""" - return db.exec_first(select(func.count()).select_from(cls)) or 0 + return db.exec_first(select(func.count()).select_from(cls)) or 0 # ty:ignore[invalid-argument-type] def _copy_models( @@ -359,7 +361,7 @@ def _copy_identities( handled = 0 for row in rows: if row.algorithm in AUTO_MANAGED_IDENTITY_ALGORITHMS: - skipped_ids.add(row.id) + skipped_ids.add(row.id) # ty:ignore[invalid-argument-type] continue handled += 1 new_row = IdentityRow.find_or_create( diff --git a/tests/test_database.py b/tests/test_database.py index 1e03894..47200d6 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -2,9 +2,8 @@ import pytest from numpy.random import Generator -from sqlalchemy import inspect +from sqlalchemy import inspect, select from sqlalchemy.exc import IntegrityError -from sqlmodel import select from autostorage import ( CalculationGeometryLink, @@ -13,7 +12,7 @@ GeometryRow, GradientRow, ) -from autostorage.database import ModelRow, Select, SelectStatement +from autostorage.database import ModelRow, SelectStatement from autostorage.exc import ResultShapeError @@ -91,7 +90,7 @@ def test__delete(database: Database, model_row: ModelRow) -> None: @pytest.fixture def orca_model_statement() -> SelectStatement: """Fixture for Statement.""" - return Select(ModelRow).where(ModelRow.program == "ORCA") + return select(ModelRow).where(ModelRow.program == "ORCA") # ty:ignore[invalid-argument-type] def test__exec_first( @@ -139,7 +138,7 @@ def test__exists_true_and_false( database.commit() assert database.exists(orca_model_statement) is True - missing_stmt = select(ModelRow).where(ModelRow.program == "nonexistent") + missing_stmt = select(ModelRow).where(ModelRow.program == "nonexistent") # ty:ignore[invalid-argument-type] assert database.exists(missing_stmt) is False @@ -148,12 +147,12 @@ def test__select_statement_chaining(database: Database, model_row: ModelRow) -> database.add(model_row) database.commit() - stmt = select(ModelRow).where(ModelRow.program == "ORCA") + stmt = select(ModelRow).where(ModelRow.program == "ORCA") # ty:ignore[invalid-argument-type] assert database.exec_first(stmt) == model_row assert database.exec_one(stmt) == model_row assert list(database.exec_all(stmt)) - missing_stmt = select(ModelRow).where(ModelRow.program == "nonexistent") + missing_stmt = select(ModelRow).where(ModelRow.program == "nonexistent") # ty:ignore[invalid-argument-type] assert database.exec_first(missing_stmt) is None diff --git a/tests/test_merge.py b/tests/test_merge.py index 3866009..36d06c9 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -7,8 +7,7 @@ import numpy as np import pytest from automol import Algorithm -from sqlalchemy import text -from sqlmodel import SQLModel, select +from sqlalchemy import select, text from autostorage import ( CalculationRow, @@ -24,7 +23,7 @@ ) from autostorage.exc import ResultShapeError from autostorage.merge import _fk_targets, _ordered_models -from autostorage.models import StationaryIdentityLink +from autostorage.models import SQLModel, StationaryIdentityLink from autostorage.types import CalcType, CompressedArrayTypeDecorator @@ -199,7 +198,7 @@ def test__multi_tier_fk_remapping(target: Database, source: Database) -> None: merged_geometries = { stationary.geometry.coordinates.tobytes() for stage_id in (merged_step.stage_id1, merged_step.stage_id2) - for stationary in target.get(StageRow, stage_id).stationaries + for stationary in target.get(StageRow, stage_id).stationaries # ty:ignore[invalid-argument-type] } assert merged_geometries == { geometry1.coordinates.tobytes(), diff --git a/tests/test_migrations.py b/tests/test_migrations.py deleted file mode 100644 index bb01672..0000000 --- a/tests/test_migrations.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Smoke test for Alembic migrations.""" - -from pathlib import Path - -import pytest -from alembic.autogenerate import compare_metadata -from alembic.command import upgrade -from alembic.config import Config -from alembic.runtime.migration import MigrationContext -from sqlalchemy import create_engine, text -from sqlmodel import SQLModel - -import autostorage.database # noqa: F401 (registers models on SQLModel.metadata) - -REPO_ROOT = Path(__file__).resolve().parent.parent - -# Expression-based unique indexes SQLite can't reflect (see CLAUDE.md's -# migrations note); `compare_metadata` silently skips them too, so they need -# a direct `sqlite_master` check to guard against a future migration -# dropping one by accident. -_NULL_SAFE_EXPRESSION_INDEXES = ("unique_model_null_safe", "unq_step_stages_null_safe") - - -@pytest.mark.filterwarnings( - "ignore:Skipped unsupported reflection of expression-based index" - ":sqlalchemy.exc.SAWarning" -) -@pytest.mark.filterwarnings( - "ignore:autogenerate skipping metadata-specified expression-based index:UserWarning" -) -def test__migrations_upgrade_to_head_matches_current_models(tmp_path: Path) -> None: - """Test that running all migrations reproduces the schema `create_all()` builds.""" - db_path = tmp_path / "migrated.db" - config = Config(REPO_ROOT / "alembic.ini") - config.set_main_option("script_location", str(REPO_ROOT / "migrations")) - config.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") - - upgrade(config, "head") - - engine = create_engine(f"sqlite:///{db_path}") - try: - with engine.connect() as connection: - context = MigrationContext.configure(connection) - diff = compare_metadata(context, SQLModel.metadata) - - index_names = { - row[0] - for row in connection.execute( - text("SELECT name FROM sqlite_master WHERE type = 'index'") - ) - } - finally: - engine.dispose() - - assert diff == [] - for index_name in _NULL_SAFE_EXPRESSION_INDEXES: - assert index_name in index_names From 0d8709101de7b4fc65852edd13921cf37182b5a7 Mon Sep 17 00:00:00 2001 From: "Troy N. Smith" Date: Wed, 29 Jul 2026 23:36:23 -0400 Subject: [PATCH 2/8] Remove utils --- pixi.lock | 46 +- pyproject.toml | 3 - src/autostorage/__init__.py | 3 +- src/autostorage/utils.py | 945 ------------------------------------ tests/test_plot.py | 270 ----------- tests/test_utils.py | 435 ----------------- 6 files changed, 22 insertions(+), 1680 deletions(-) delete mode 100644 src/autostorage/utils.py delete mode 100644 tests/test_plot.py delete mode 100644 tests/test_utils.py diff --git a/pixi.lock b/pixi.lock index c73184d..ee6616f 100644 --- a/pixi.lock +++ b/pixi.lock @@ -169,7 +169,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda - - conda_source: autostorage[dcc1f98c] @ . + - conda_source: autostorage[5c1d8401] @ . - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/1b/291dd75b5ed494eb484801f39e6572192d322e6cd3d68fab8a6dde743c48/graphrc-1.3.7-py3-none-any.whl @@ -479,7 +479,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.6.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - - conda_source: autostorage[dcc1f98c] @ . + - conda_source: autostorage[5c1d8401] @ . - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/23/06/b4f06ca7afb5d9e942c642980c308ffcfa1fa0e8b0a3ddbec78483ef1614/keepachangelog-2.0.0-py3-none-any.whl @@ -862,7 +862,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/coverage?source=compressed-mapping + - pkg:pypi/coverage?source=hash-mapping run_exports: {} size: 402264 timestamp: 1784150199793 @@ -1021,7 +1021,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/greenlet?source=compressed-mapping + - pkg:pypi/greenlet?source=hash-mapping run_exports: {} size: 267125 timestamp: 1782524630615 @@ -2481,7 +2481,7 @@ packages: - __glibc >=2.17 license: MIT purls: - - pkg:pypi/ruff?source=compressed-mapping + - pkg:pypi/ruff?source=hash-mapping run_exports: {} size: 9333318 timestamp: 1784237314764 @@ -2520,7 +2520,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/scipy?source=compressed-mapping + - pkg:pypi/scipy?source=hash-mapping run_exports: {} size: 17171066 timestamp: 1781912954186 @@ -2912,7 +2912,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/alembic?source=compressed-mapping + - pkg:pypi/alembic?source=hash-mapping run_exports: {} size: 185355 timestamp: 1782460469542 @@ -2958,7 +2958,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/anyio?source=compressed-mapping + - pkg:pypi/anyio?source=hash-mapping run_exports: {} size: 164465 timestamp: 1783889660383 @@ -2971,7 +2971,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/argcomplete?source=compressed-mapping + - pkg:pypi/argcomplete?source=hash-mapping run_exports: {} size: 46445 timestamp: 1782888485558 @@ -2985,7 +2985,7 @@ packages: license: Apache-2.0 license_family: Apache purls: - - pkg:pypi/asttokens?source=compressed-mapping + - pkg:pypi/asttokens?source=hash-mapping run_exports: {} size: 34639 timestamp: 1783975742052 @@ -3058,8 +3058,7 @@ packages: - cached_property >=1.5.2,<1.5.3.0a0 license: BSD-3-Clause license_family: BSD - purls: - - pkg:pypi/cached-property?source=compressed-mapping + purls: [] run_exports: {} size: 6836 timestamp: 1783242914545 @@ -3361,8 +3360,7 @@ packages: - python-multipart - uvicorn-standard license: MIT - purls: - - pkg:pypi/fastapi?source=compressed-mapping + purls: [] run_exports: {} size: 4845 timestamp: 1784297339154 @@ -3379,7 +3377,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/fastapi-cli?source=compressed-mapping + - pkg:pypi/fastapi-cli?source=hash-mapping run_exports: {} size: 20791 timestamp: 1784211753962 @@ -3677,7 +3675,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/importlib-metadata?source=compressed-mapping + - pkg:pypi/importlib-metadata?source=hash-mapping run_exports: {} size: 34766 timestamp: 1779714582554 @@ -3732,7 +3730,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/ipykernel?source=compressed-mapping + - pkg:pypi/ipykernel?source=hash-mapping run_exports: {} size: 138635 timestamp: 1781101665847 @@ -4375,7 +4373,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/python-multipart?source=compressed-mapping + - pkg:pypi/python-multipart?source=hash-mapping run_exports: {} size: 38132 timestamp: 1780610429919 @@ -4434,7 +4432,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/requests?source=compressed-mapping + - pkg:pypi/requests?source=hash-mapping run_exports: {} size: 68709 timestamp: 1778851103479 @@ -4850,7 +4848,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/traitlets?source=compressed-mapping + - pkg:pypi/traitlets?source=hash-mapping run_exports: {} size: 115158 timestamp: 1780507822178 @@ -4904,7 +4902,7 @@ packages: license: PSF-2.0 license_family: PSF purls: - - pkg:pypi/typing-extensions?source=compressed-mapping + - pkg:pypi/typing-extensions?source=hash-mapping run_exports: {} size: 52631 timestamp: 1783002732887 @@ -4958,7 +4956,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/uvicorn?source=compressed-mapping + - pkg:pypi/uvicorn?source=hash-mapping run_exports: {} size: 57699 timestamp: 1783518883165 @@ -5024,15 +5022,13 @@ packages: run_exports: {} size: 24190 timestamp: 1779159948016 -- conda_source: autostorage[dcc1f98c] @ . +- conda_source: autostorage[5c1d8401] @ . variants: target_platform: noarch depends: - python >=3.12 - python * - click >=8.0 - - matplotlib-base >=3.8 - - pint >=0.25.2 - sqlmodel >=0.0.31 host_packages: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda diff --git a/pyproject.toml b/pyproject.toml index 043762f..f3bd5fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,6 @@ requires-python = ">= 3.12" dependencies = [ "automol==0.0.19", "click>=8.0", - "matplotlib>=3.8", - "pint>=0.25.2", "sqlmodel>=0.0.31", "irmsd>=0.1.1", "stereomolgraph>=0.0.22b0", @@ -70,7 +68,6 @@ exclude_type_checking_imports = true name = "Autostorage Layering" type = "layers" layers = [ - "autostorage.utils", "autostorage.database", "autostorage.merge", "autostorage.events", diff --git a/src/autostorage/__init__.py b/src/autostorage/__init__.py index 793579c..9f93412 100644 --- a/src/autostorage/__init__.py +++ b/src/autostorage/__init__.py @@ -2,7 +2,7 @@ __version__ = "0.0.12" -from . import exc, merge, types, utils +from . import exc, merge, types from .database import Database from .merge import MergeReport from .models import ( @@ -46,5 +46,4 @@ "exc", "merge", "types", - "utils", ] diff --git a/src/autostorage/utils.py b/src/autostorage/utils.py deleted file mode 100644 index a4c31e8..0000000 --- a/src/autostorage/utils.py +++ /dev/null @@ -1,945 +0,0 @@ -"""MESS input export and potential energy surface plotting.""" - -import io -from collections.abc import Sequence -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Any, cast - -from automol import geom -from matplotlib.axes import Axes -from matplotlib.backends.backend_agg import FigureCanvasAgg -from matplotlib.figure import Figure - -from .exc import MissingPrimaryKeyError -from .models import ( - EnergyRow, - GeometryRow, - HessianRow, - ModelRow, - StageRow, - StationaryPointRow, - StepRow, -) - -if TYPE_CHECKING: - from .database import Database - -# CODATA Hartree -> kcal/mol (automol.utils.constants has no molar-energy conversion). -HARTREE_TO_KCAL_PER_MOL = 627.5094740631 - -# Non-default ground-state electronic levels (energy 1/cm, degeneracy), keyed by Hill -# formula. Species not listed here default to a single level at 0 1/cm, degeneracy -# spin + 1. -_ELECTRONIC_LEVELS_BY_HILL_FORMULA: dict[str, tuple[tuple[float, int], ...]] = { - "HO": ((0.0, 2), (140.0, 2)), -} - -# Fallback for a barrierless step's TS block, which has no geometry to compute a -# symmetry number from; must be checked by hand. -_SYMMETRY_NUMBER_PLACEHOLDER = ( - "1 ! TODO(autostorage): placeholder -- " - "no TS geometry to compute a symmetry number from, verify manually" -) - -# PES plot rendering constants. Grayscale only; dashed + muted gray flags missing data. -_LEVEL_HALF_WIDTH = 0.3 -_LEVEL_COLOR = "black" -_LEVEL_LINEWIDTH = 2.5 -_CONNECTOR_LINEWIDTH = 1.25 -_FLAGGED_COLOR = "0.6" -_FLAGGED_LINESTYLE = "--" -_MISSING_ENERGY_SENTINEL_KCAL = 0.0 -_MISSING_ENERGY_SUFFIX = " (no energy data)" -_LABEL_FONTSIZE = 9 -_Y_AXIS_LABEL = "Relative Energy (kcal/mol)" -_X_AXIS_LABEL = "Reaction Coordinate" -_DEFAULT_FIGSIZE = (6.0, 4.5) - - -@dataclass(frozen=True, slots=True) -class _FragmentData: - """Resolved geometry/frequency data for one stationary point of a species.""" - - stationary: StationaryPointRow - geometry: GeometryRow - frequencies: tuple[float, ...] | None - - -@dataclass(frozen=True, slots=True) -class _SpeciesData: - """Resolved rendering data for one non-TS stage (a well or bimolecular state).""" - - stage: StageRow - label: str - name: str - zero_energy_kcal: float | None - fragments: tuple[_FragmentData, ...] - - -def _require_stage_id(stage: StageRow) -> int: - """Return `stage.id`, raising if the stage hasn't been persisted.""" - if stage.id is None: - raise MissingPrimaryKeyError([stage]) - return stage.id - - -def _collect_stages(steps: Sequence[StepRow]) -> list[StageRow]: - """Return unique stages referenced by `steps`, in first-encounter order.""" - stages: list[StageRow] = [] - seen_ids: set[int] = set() - for step in steps: - for stage in (step.stage1, step.stage2, step.stage_ts): - if stage is None: - continue - stage_id = _require_stage_id(stage) - if stage_id not in seen_ids: - seen_ids.add(stage_id) - stages.append(stage) - return stages - - -def _auto_labels(stages: Sequence[StageRow]) -> dict[int, str]: - """Assign auto-generated `W#`/`P#` labels to stages, in first-encounter order.""" - labels: dict[int, str] = {} - well_count = 0 - bimolecular_count = 0 - for stage in stages: - if stage.is_ts: - continue - stage_id = _require_stage_id(stage) - if len(stage.stationaries) == 1: - well_count += 1 - labels[stage_id] = f"W{well_count}" - else: - bimolecular_count += 1 - labels[stage_id] = f"P{bimolecular_count}" - return labels - - -def _auto_barrier_labels(steps: Sequence[StepRow]) -> list[str]: - """Assign sequential `B#` labels, one per step, in `steps` order.""" - return [f"B{i}" for i in range(1, len(steps) + 1)] - - -def _auto_name(stage: StageRow) -> str: - """Return a Hill-formula-based comment name for a stage's fragment(s).""" - fragments = sorted(stage.stationaries, key=lambda s: s.id or 0) - return " + ".join(geom.hill_formula(f.geometry) for f in fragments) - - -def _resolve_label( - stage: StageRow, auto: dict[int, str], override: dict[int, str] -) -> str: - """Return the override label for `stage`, falling back to its auto label.""" - stage_id = _require_stage_id(stage) - return override.get(stage_id, auto[stage_id]) - - -def _resolve_name(stage: StageRow, override: dict[int, str]) -> str: - """Return the override comment name for `stage`, falling back to `_auto_name`.""" - stage_id = _require_stage_id(stage) - if stage_id in override: - return override[stage_id] - return _auto_name(stage) - - -def _energy_hartree(db: "Database", geo: GeometryRow, model: ModelRow) -> float | None: - """Return the Hartree energy of `geo` at `model`, or `None` if not found.""" - if geo.id is None or model.id is None: - raise MissingPrimaryKeyError([geo, model]) - energy = EnergyRow.query(db, geo=geo, model=model) - return energy.value if energy is not None else None - - -def _relative_energy_kcal( - value_hartree: float | None, ref_hartree: float -) -> float | None: - """Convert a Hartree energy to kcal/mol relative to `ref_hartree`.""" - if value_hartree is None: - return None - return (value_hartree - ref_hartree) * HARTREE_TO_KCAL_PER_MOL - - -def _zpe_hartree(geo: GeometryRow, frequencies: tuple[float, ...] | None) -> float: - """Return the harmonic ZPE correction for `frequencies`, or 0.0 if `None`.""" - if not frequencies: - return 0.0 - return geom.harmonic_zpv(geo, hess=[], freqs=frequencies) - - -def _zpe_corrected_energy_hartree( - db: "Database", - geo: GeometryRow, - model: ModelRow, - *, - frequencies: tuple[float, ...] | None = None, -) -> float | None: - """Return the ZPE-corrected Hartree energy of `geo` at `model`. - - Falls back to the bare electronic energy if `frequencies` is `None` and - no `HessianRow` is found at `model`. Returns `None` if no `EnergyRow` is - found. - """ - value_hartree = _energy_hartree(db, geo, model) - if value_hartree is None: - return None - if frequencies is None: - hessian = HessianRow.query(db, geo=geo, model=model) - frequencies = hessian.harmonic_frequencies if hessian is not None else None - return value_hartree + _zpe_hartree(geo, frequencies) - - -def _resolve_ref_hartree( - db: "Database", ref: StationaryPointRow, model: ModelRow -) -> float: - """Return the ZPE-corrected Hartree energy of `ref`, raising if not found.""" - ref_hartree = _zpe_corrected_energy_hartree(db, ref.geometry, model) - if ref_hartree is None: - msg = ( - f"No EnergyRow found for reference geometry {ref.geometry.id} " - f"at model {model.id}." - ) - raise ValueError(msg) - return ref_hartree - - -def _electronic_levels(geo: GeometryRow) -> tuple[tuple[float, int], ...]: - """Return `(energy_cm1, degeneracy)` ground-state electronic levels for `geo`.""" - formula = geom.hill_formula(geo) - if formula in _ELECTRONIC_LEVELS_BY_HILL_FORMULA: - return _ELECTRONIC_LEVELS_BY_HILL_FORMULA[formula] - return ((0.0, geo.spin + 1),) - - -def _indent(text: str, spaces: int) -> str: - """Indent every line of `text` by `spaces` spaces.""" - prefix = " " * spaces - return "\n".join(prefix + line for line in text.splitlines()) - - -def _format_number_columns( - values: Sequence[float], *, per_line: int = 3, width: int = 10, precision: int = 2 -) -> str: - """Render `values` right-aligned in fixed-width columns, `per_line` per row.""" - lines = [] - for i in range(0, len(values), per_line): - chunk = values[i : i + per_line] - lines.append("".join(f"{v:>{width}.{precision}f}" for v in chunk)) - return "\n".join(lines) - - -def _render_geometry_block(geo: GeometryRow) -> str: - """Render a MESS `Geometry[angstrom]` block.""" - lines = [f"Geometry[angstrom] {geo.atom_count}"] - lines.extend( - f"{symbol} {x:.6f} {y:.6f} {z:.6f}" - for symbol, (x, y, z) in zip(geo.symbols, geo.coordinates, strict=True) - ) - return "\n".join(lines) - - -def _render_frequencies_block(frequencies: tuple[float, ...]) -> str: - """Render a MESS `Frequencies[1/cm]` block.""" - header = f"Frequencies[1/cm] {len(frequencies)}" - if not frequencies: - return header - return f"{header}\n{_format_number_columns(frequencies)}" - - -def _render_electronic_levels_block(geo: GeometryRow) -> str: - """Render a MESS `ElectronicLevels[1/cm]` block.""" - levels = _electronic_levels(geo) - lines = [f"ElectronicLevels[1/cm] {len(levels)}"] - lines.extend(f"{energy:.1f} {degeneracy}" for energy, degeneracy in levels) - return "\n".join(lines) - - -def _render_zero_energy_block( - energy_kcal: float | None, *, keyword: str = "ZeroEnergy" -) -> str: - """Render a MESS `ZeroEnergy`/`GroundEnergy[kcal/mol]` line.""" - if energy_kcal is None: - return ( - f"{keyword}[kcal/mol] 0.00 ! TODO(autostorage): no EnergyRow found " - "at requested model -- fill in manually" - ) - return f"{keyword}[kcal/mol] {energy_kcal:.2f}" - - -def _render_fragment_zero_energy_block() -> str: - """Render a fragment's `ZeroEnergy[1/cm]` line. - - Always 0; the enclosing `Bimolecular` block's `GroundEnergy` line carries - the pair's relative energy. - """ - return "ZeroEnergy[1/cm] 0" - - -def _render_core_rigidrotor_block(symmetry_number: int | None) -> str: - """Render a MESS `Core RigidRotor` block. - - Falls back to a flagged placeholder when `symmetry_number` is `None`. - """ - factor = ( - _SYMMETRY_NUMBER_PLACEHOLDER if symmetry_number is None else symmetry_number - ) - return f"Core RigidRotor\n SymmetryFactor {factor}\nEnd" - - -def _render_fragment_block(fragment: _FragmentData, label: str) -> str: - """Render a `Fragment` sub-block within a `Bimolecular` species.""" - parts = [ - f"Fragment {label}", - _indent("RRHO", 2), - _indent(_render_geometry_block(fragment.geometry), 4), - _indent(_render_core_rigidrotor_block(fragment.geometry.symmetry_number), 4), - ] - if fragment.frequencies: - parts.append(_indent(_render_frequencies_block(fragment.frequencies), 4)) - parts.append(_indent(_render_fragment_zero_energy_block(), 4)) - parts.append(_indent(_render_electronic_levels_block(fragment.geometry), 4)) - parts.append(_indent("End", 2)) - return "\n".join(parts) - - -def _render_species_block( - fragment: _FragmentData, zero_energy_kcal: float | None -) -> str: - """Render a well's `Species` sub-block.""" - parts = [ - "Species", - _indent("RRHO", 2), - _indent(_render_geometry_block(fragment.geometry), 4), - _indent(_render_core_rigidrotor_block(fragment.geometry.symmetry_number), 4), - ] - if fragment.frequencies: - parts.append(_indent(_render_frequencies_block(fragment.frequencies), 4)) - parts.append(_indent(_render_zero_energy_block(zero_energy_kcal), 4)) - parts.append(_indent(_render_electronic_levels_block(fragment.geometry), 4)) - parts.append(_indent("End", 2)) - return "\n".join(parts) - - -def _render_well_block(species: _SpeciesData) -> str: - """Render a `Well` block for a single-fragment stage.""" - (fragment,) = species.fragments - header = f"Well {species.label} # {species.name}" - body = _render_species_block(fragment, species.zero_energy_kcal) - return f"{header}\n{_indent(body, 2)}\nEnd" - - -def _render_bimolecular_block(species: _SpeciesData) -> str: - """Render a `Bimolecular` block for a multi-fragment stage.""" - header = f"Bimolecular {species.label} # {species.name}" - fragment_blocks = "\n".join( - _indent(_render_fragment_block(f, geom.hill_formula(f.geometry)), 2) - for f in species.fragments - ) - ground_energy = _indent( - _render_zero_energy_block(species.zero_energy_kcal, keyword="GroundEnergy"), 2 - ) - return f"{header}\n{fragment_blocks}\n{ground_energy}\nEnd" - - -def _build_fragment_data( - db: "Database", stationary: StationaryPointRow, *, model: ModelRow -) -> _FragmentData: - """Resolve geometry and frequency data for one fragment stationary point.""" - geometry = stationary.geometry - if geometry.id is None or model.id is None: - raise MissingPrimaryKeyError([geometry, model]) - hessian = HessianRow.query(db, geo=geometry, model=model) - frequencies = hessian.harmonic_frequencies if hessian is not None else None - return _FragmentData( - stationary=stationary, geometry=geometry, frequencies=frequencies - ) - - -def _build_species_data( # noqa: PLR0913 - db: "Database", - stage: StageRow, - *, - model: ModelRow, - ref_hartree: float, - label: str, - name: str, -) -> _SpeciesData: - """Resolve all rendering data for a well/bimolecular stage.""" - fragments = tuple( - _build_fragment_data(db, s, model=model) - for s in sorted(stage.stationaries, key=lambda s: s.id or 0) - ) - values_hartree = [ - _zpe_corrected_energy_hartree( - db, fragment.geometry, model, frequencies=fragment.frequencies - ) - for fragment in fragments - ] - zero_energy_kcal = ( - None - if any(value is None for value in values_hartree) - else _relative_energy_kcal(sum(values_hartree), ref_hartree) - ) - return _SpeciesData( - stage=stage, - label=label, - name=name, - zero_energy_kcal=zero_energy_kcal, - fragments=fragments, - ) - - -def _render_barrier_block( # noqa: PLR0913 - db: "Database", - step: StepRow, - barrier_label: str, - species1: _SpeciesData, - species2: _SpeciesData, - *, - model: ModelRow, - ref_hartree: float, -) -> str: - """Render a `Barrier` block for a step with a transition state.""" - ts_stage = step.stage_ts - (ts_stationary,) = ts_stage.stationaries - ts_geometry = ts_stationary.geometry - hessian = HessianRow.query(db, geo=ts_geometry, model=model) - real_frequencies = ( - tuple(f for f in hessian.harmonic_frequencies if f > 0.0) - if hessian is not None - else None - ) - ts_frequencies = hessian.harmonic_frequencies if hessian is not None else None - value_hartree = _zpe_corrected_energy_hartree( - db, ts_geometry, model, frequencies=ts_frequencies - ) - zero_energy_kcal = _relative_energy_kcal(value_hartree, ref_hartree) - - header = ( - f"Barrier {barrier_label} {species1.label} {species2.label}" - f" # {species1.name} = {species2.name}" - ) - parts = [ - _indent("RRHO", 2), - _indent(_render_geometry_block(ts_geometry), 4), - _indent(_render_core_rigidrotor_block(ts_geometry.symmetry_number), 4), - ] - if real_frequencies: - parts.append(_indent(_render_frequencies_block(real_frequencies), 4)) - parts.append(_indent(_render_zero_energy_block(zero_energy_kcal), 4)) - parts.append(_indent(_render_electronic_levels_block(ts_geometry), 4)) - body = "\n".join(parts) - return f"{header}\n{body}\nEnd" - - -def _render_barrierless_placeholder_block( - step: StepRow, - barrier_label: str, - species1: _SpeciesData, - species2: _SpeciesData, -) -> str: - """Render a placeholder `Barrier` block for a step with no transition state. - - Not directly MESS-runnable; must be completed by hand. - """ - del step # kept in the signature for symmetry with `_render_barrier_block` - energies = [ - e - for e in (species1.zero_energy_kcal, species2.zero_energy_kcal) - if e is not None - ] - zero_energy_kcal = max(energies) if energies else None - - header = ( - f"Barrier {barrier_label} {species1.label} {species2.label}" - f" # {species1.name} = {species2.name}" - ) - lines = [ - "! TODO(autostorage): barrierless step -- no transition state exists " - "in the database.", - "! Fill in a PhaseSpaceTheory/Variational flux-parameter model by hand,", - "! or replace this Barrier block with the appropriate MESS " - "barrierless-channel construct.", - _indent("RRHO", 2), - _indent("Geometry[angstrom]", 4), - _indent( - "! TODO(autostorage): no TS geometry -- supply variational " - "geometries manually", - 6, - ), - _indent(_render_core_rigidrotor_block(None), 4), - _indent("Frequencies[1/cm] 0", 4), - _indent( - "! TODO(autostorage): no TS frequencies -- supply manually or " - "replace with a Variational/PST model", - 6, - ), - _indent(_render_zero_energy_block(zero_energy_kcal), 4), - _indent("ElectronicLevels[1/cm] 1", 4), - _indent("0.0 1", 6), - ] - return f"{header}\n" + "\n".join(lines) + "\nEnd" - - -def export_mess_input( # noqa: PLR0913 - db: "Database", - steps: Sequence[StepRow], - *, - ref: StationaryPointRow, - model: ModelRow, - labels: dict[int, str] | None = None, - names: dict[int, str] | None = None, -) -> str: - """Render `steps` as MESS `Well`/`Bimolecular`/`Barrier` input blocks. - - Non-TS stages become `Well`/`Bimolecular` blocks, auto-labeled - `W1, W2, ...`/`P1, P2, ...` and named by Hill formula, unless overridden - via `labels`/`names` (keyed by `StageRow.id`). Each step becomes one - `Barrier` block, auto-labeled `B1, B2, ...`; a barrierless step instead - gets a `TODO(autostorage)`-flagged placeholder. - - Energies are the electronic energy at `model` plus the harmonic - zero-point energy from each geometry's `HessianRow` at `model` (falling - back to the bare electronic energy where no such `HessianRow` exists), - relative to `ref`. Symmetry numbers come from `GeometryRow.symmetry_ - number`, except a barrierless step's TS block, which has no geometry. - Does not emit `Model`/`EnergyRelaxation`/`CollisionFrequency` blocks; the - caller must prepend those. - - Parameters - ---------- - db - Database to query energies and Hessians from. - steps - Elementary reaction steps to include, in output order. - ref - Stationary point defining the zero of energy (0.0 kcal/mol). - model - Level of theory used for every energy and frequency lookup. - labels, optional - Override MESS labels, keyed by `StageRow.id`. - names, optional - Override comment names, keyed by `StageRow.id`. - - Returns - ------- - The full MESS input text for the given steps. - - Raises - ------ - ValueError - No `EnergyRow` found for `ref` at `model`. - - Examples - -------- - >>> import numpy as np - >>> from autostorage import ( - ... CalcType, - ... CalculationRow, - ... Database, - ... EnergyRow, - ... GeometryRow, - ... ModelRow, - ... StageRow, - ... StationaryPointRow, - ... StepRow, - ... ) - >>> db = Database(":memory:") - >>> model = ModelRow(program="ORCA", method="b3lyp") - >>> calc = CalculationRow(model=model, calc_type=CalcType.OPT) - >>> geo1 = GeometryRow( - ... symbols=["O", "H"], - ... coordinates=np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.97]]), - ... charge=0, - ... spin=1, - ... ) - >>> geo2 = GeometryRow( - ... symbols=["O", "H"], - ... coordinates=np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 1.50]]), - ... charge=0, - ... spin=1, - ... ) - >>> s1 = StationaryPointRow(geometry=geo1, calculation=calc) - >>> s2 = StationaryPointRow(geometry=geo2, calculation=calc) - >>> db.add_all([s1, s2]) - >>> db.commit() - >>> db.add_all( - ... [ - ... EnergyRow(geometry=geo1, calculation=calc, value=-75.0), - ... EnergyRow(geometry=geo2, calculation=calc, value=-74.9), - ... ] - ... ) - >>> db.commit() - >>> stage1 = StageRow(stationaries=[s1]) - >>> stage2 = StageRow(stationaries=[s2]) - >>> step = StepRow(stage1=stage1, stage2=stage2) - >>> db.add(step) - >>> db.commit() - >>> text = export_mess_input(db, [step], ref=s1, model=model) - >>> "Well W1" in text and "Well W2" in text - True - >>> "TODO(autostorage): barrierless step" in text - True - >>> db.close() - """ - labels = labels or {} - names = names or {} - - ref_hartree = _resolve_ref_hartree(db, ref, model) - - stages = _collect_stages(steps) - auto_labels = _auto_labels(stages) - - species_by_stage_id: dict[int, _SpeciesData] = {} - for stage in stages: - if stage.is_ts: - continue - species_by_stage_id[_require_stage_id(stage)] = _build_species_data( - db, - stage, - model=model, - ref_hartree=ref_hartree, - label=_resolve_label(stage, auto_labels, labels), - name=_resolve_name(stage, names), - ) - - blocks = [] - for stage in stages: - if stage.is_ts: - continue - species = species_by_stage_id[_require_stage_id(stage)] - if len(species.fragments) == 1: - blocks.append(_render_well_block(species)) - else: - blocks.append(_render_bimolecular_block(species)) - - barrier_labels = _auto_barrier_labels(steps) - for step, barrier_label in zip(steps, barrier_labels, strict=True): - species1 = species_by_stage_id[_require_stage_id(step.stage1)] - species2 = species_by_stage_id[_require_stage_id(step.stage2)] - if step.is_barrierless: - blocks.append( - _render_barrierless_placeholder_block( - step, barrier_label, species1, species2 - ) - ) - else: - blocks.append( - _render_barrier_block( - db, - step, - barrier_label, - species1, - species2, - model=model, - ref_hartree=ref_hartree, - ) - ) - - return "\n".join(blocks) + "\n" - - -@dataclass(frozen=True, slots=True) -class _LevelPlacement: - """Resolved y-position and flagged-status of one drawn level.""" - - y_kcal: float - flagged: bool - - -@dataclass(frozen=True, slots=True) -class PESPlot: - """A rendered potential energy surface diagram. - - Attributes - ---------- - figure - The rendered figure. - axes - The axes the diagram was drawn into. - """ - - figure: Figure - axes: Axes - - def _repr_png_(self) -> bytes: - """Return a PNG-encoded snapshot of `figure`, for Jupyter's rich display.""" - buffer = io.BytesIO() - self.figure.savefig(buffer, format="png", dpi=150, bbox_inches="tight") - return buffer.getvalue() - - def save(self, path: str | Path, **savefig_kwargs: Any) -> None: # noqa: ANN401 - """Save `figure` to `path`; format is inferred from its suffix.""" - savefig_kwargs.setdefault("bbox_inches", "tight") - self.figure.savefig(path, **savefig_kwargs) - - -def _draw_level( - axes: Axes, x: float, energy_kcal: float | None, *, label: str, name: str -) -> _LevelPlacement: - """Draw one flat energy-level segment (species well/bimolecular or TS peak).""" - flagged = energy_kcal is None - y = _MISSING_ENERGY_SENTINEL_KCAL if flagged else energy_kcal - color = _FLAGGED_COLOR if flagged else _LEVEL_COLOR - axes.plot( - [x - _LEVEL_HALF_WIDTH, x + _LEVEL_HALF_WIDTH], - [y, y], - color=color, - linewidth=_LEVEL_LINEWIDTH, - linestyle=_FLAGGED_LINESTYLE if flagged else "-", - solid_capstyle="butt", - zorder=3, - ) - text = f"{label}\n{name}{_MISSING_ENERGY_SUFFIX if flagged else ''}" - axes.annotate( - text, - xy=(x, y), - xytext=(0, 6), - textcoords="offset points", - ha="center", - va="bottom", - fontsize=_LABEL_FONTSIZE, - color=color, - annotation_clip=False, - ) - return _LevelPlacement(y_kcal=y, flagged=flagged) - - -def _draw_connector( # noqa: PLR0913 - axes: Axes, - x1: float, - placement1: _LevelPlacement, - x2: float, - placement2: _LevelPlacement, - *, - linestyle: str = "-", -) -> None: - """Draw a connector line between two levels' inner edges. - - Sorts by x first, since `x1 < x2` cannot be assumed. - """ - (left_x, left), (right_x, right) = sorted( - [(x1, placement1), (x2, placement2)], key=lambda pair: pair[0] - ) - flagged = left.flagged or right.flagged - axes.plot( - [left_x + _LEVEL_HALF_WIDTH, right_x - _LEVEL_HALF_WIDTH], - [left.y_kcal, right.y_kcal], - color=_FLAGGED_COLOR if flagged else _LEVEL_COLOR, - linewidth=_CONNECTOR_LINEWIDTH, - linestyle=_FLAGGED_LINESTYLE if flagged else linestyle, - zorder=2, - ) - - -def _annotate_barrier_label(axes: Axes, x: float, y: float, label: str) -> None: - """Annotate a barrierless connector's midpoint with its `B#` label.""" - axes.annotate( - label, - xy=(x, y), - xytext=(0, 6), - textcoords="offset points", - ha="center", - va="bottom", - fontsize=_LABEL_FONTSIZE, - color=_FLAGGED_COLOR, - style="italic", - annotation_clip=False, - ) - - -def plot_pes( # noqa: PLR0913 - db: "Database", - steps: Sequence[StepRow], - *, - ref: StationaryPointRow, - model: ModelRow, - labels: dict[int, str] | None = None, - names: dict[int, str] | None = None, - ax: Axes | None = None, -) -> PESPlot: - r"""Render `steps` as a potential energy surface diagram. - - Wells/bimolecular species are derived the same way as - `export_mess_input` and drawn as flat "level" segments along an - unlabeled, ordinal x-axis (first-encounter order, not necessarily - reactant-to-product). Each step becomes a peak at its transition state - (labeled `B1, B2, ...`), connected to both stages by diagonal lines; a - barrierless step instead draws one direct dashed connector with no peak. - - Energies are the electronic energy at `model` plus the harmonic - zero-point energy from each geometry's `HessianRow` at `model` (falling - back to the bare electronic energy where no such `HessianRow` exists), - relative to `ref`, in kcal/mol. A species/TS with no `EnergyRow` at - `model` is drawn at 0.0 kcal/mol in a flagged (dashed, muted gray) style - with " (no energy data)" appended to its label. - - Parameters - ---------- - db - Database to query energies from. - steps - Elementary reaction steps to include, in output order. - ref - Stationary point defining the zero of energy (0.0 kcal/mol). - model - Level of theory used for every energy lookup. - labels, optional - Override labels, keyed by `StageRow.id`. - names, optional - Override names, keyed by `StageRow.id`. - ax, optional - Axes to draw into. If `None`, a new figure/axes is created. - - Returns - ------- - Wrapper holding the rendered figure/axes. - - Raises - ------ - ValueError - No `EnergyRow` found for `ref` at `model`. - - Examples - -------- - >>> import numpy as np - >>> from autostorage import ( - ... CalcType, - ... CalculationRow, - ... Database, - ... EnergyRow, - ... GeometryRow, - ... ModelRow, - ... StageRow, - ... StationaryPointRow, - ... StepRow, - ... ) - >>> db = Database(":memory:") - >>> model = ModelRow(program="ORCA", method="b3lyp") - >>> calc = CalculationRow(model=model, calc_type=CalcType.OPT) - >>> geo1 = GeometryRow( - ... symbols=["O", "H"], - ... coordinates=np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.97]]), - ... charge=0, - ... spin=1, - ... ) - >>> geo2 = GeometryRow( - ... symbols=["O", "H"], - ... coordinates=np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 1.50]]), - ... charge=0, - ... spin=1, - ... ) - >>> s1 = StationaryPointRow(geometry=geo1, calculation=calc) - >>> s2 = StationaryPointRow(geometry=geo2, calculation=calc) - >>> db.add_all([s1, s2]) - >>> db.commit() - >>> db.add_all( - ... [ - ... EnergyRow(geometry=geo1, calculation=calc, value=-75.0), - ... EnergyRow(geometry=geo2, calculation=calc, value=-74.9), - ... ] - ... ) - >>> db.commit() - >>> stage1 = StageRow(stationaries=[s1]) - >>> stage2 = StageRow(stationaries=[s2]) - >>> step = StepRow(stage1=stage1, stage2=stage2) - >>> db.add(step) - >>> db.commit() - >>> plot = plot_pes(db, [step], ref=s1, model=model) - >>> [line.get_linestyle() for line in plot.axes.lines].count("--") - 1 - >>> sorted(text.get_text().splitlines()[0] for text in plot.axes.texts) - ['B1', 'W1', 'W2'] - >>> plot._repr_png_().startswith(b"\x89PNG") - True - >>> db.close() - """ - labels = labels or {} - names = names or {} - - ref_hartree = _resolve_ref_hartree(db, ref, model) - - stages = _collect_stages(steps) - auto_labels = _auto_labels(stages) - non_ts_stages = [stage for stage in stages if not stage.is_ts] - x_by_stage_id = { - _require_stage_id(stage): float(i) for i, stage in enumerate(non_ts_stages) - } - - species_by_stage_id: dict[int, _SpeciesData] = {} - for stage in non_ts_stages: - species_by_stage_id[_require_stage_id(stage)] = _build_species_data( - db, - stage, - model=model, - ref_hartree=ref_hartree, - label=_resolve_label(stage, auto_labels, labels), - name=_resolve_name(stage, names), - ) - - if ax is None: - figure = Figure(figsize=_DEFAULT_FIGSIZE) - FigureCanvasAgg(figure) - axes = figure.add_subplot() - else: - axes = ax - figure = cast("Figure", ax.figure) - - placements_by_stage_id: dict[int, _LevelPlacement] = {} - for stage in non_ts_stages: - stage_id = _require_stage_id(stage) - species = species_by_stage_id[stage_id] - placements_by_stage_id[stage_id] = _draw_level( - axes, - x_by_stage_id[stage_id], - species.zero_energy_kcal, - label=species.label, - name=species.name, - ) - - barrier_labels = _auto_barrier_labels(steps) - for step, barrier_label in zip(steps, barrier_labels, strict=True): - id1 = _require_stage_id(step.stage1) - id2 = _require_stage_id(step.stage2) - x1, x2 = x_by_stage_id[id1], x_by_stage_id[id2] - placement1 = placements_by_stage_id[id1] - placement2 = placements_by_stage_id[id2] - - if step.is_barrierless: - _draw_connector( - axes, x1, placement1, x2, placement2, linestyle=_FLAGGED_LINESTYLE - ) - _annotate_barrier_label( - axes, - (x1 + x2) / 2, - (placement1.y_kcal + placement2.y_kcal) / 2, - barrier_label, - ) - else: - ts_stage = step.stage_ts - (ts_stationary,) = ts_stage.stationaries - ts_value_hartree = _zpe_corrected_energy_hartree( - db, ts_stationary.geometry, model - ) - ts_energy_kcal = _relative_energy_kcal(ts_value_hartree, ref_hartree) - x_ts = (x1 + x2) / 2 - ts_placement = _draw_level( - axes, - x_ts, - ts_energy_kcal, - label=barrier_label, - name=_resolve_name(ts_stage, names), - ) - _draw_connector(axes, x1, placement1, x_ts, ts_placement) - _draw_connector(axes, x_ts, ts_placement, x2, placement2) - - axes.set_ylabel(_Y_AXIS_LABEL) - axes.set_xlabel(_X_AXIS_LABEL) - axes.set_xticks([]) - axes.spines[["top", "right", "bottom"]].set_visible(False) - axes.grid(axis="y", color="0.85", linewidth=0.8, zorder=0) - axes.set_axisbelow(True) - axes.margins(x=0.15, y=0.15) - - return PESPlot(figure=figure, axes=axes) diff --git a/tests/test_plot.py b/tests/test_plot.py deleted file mode 100644 index 892a5a6..0000000 --- a/tests/test_plot.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Autostorage PES plot tests.""" - -from pathlib import Path - -import numpy as np -import pytest -from matplotlib.axes import Axes -from matplotlib.backends.backend_agg import FigureCanvasAgg -from matplotlib.figure import Figure - -from autostorage import CalculationRow, Database, GeometryRow, StageRow, StepRow -from autostorage.utils import PESPlot, plot_pes -from tests.test_utils import _diatomic, _stationary, _with_energy - -PNG_MAGIC_BYTES = b"\x89PNG\r\n\x1a\n" - -EXPECTED_TS_PATH_LINE_COUNT = 5 # 3 levels + 2 connectors -EXPECTED_TS_PATH_TEXT_COUNT = 3 # 2 species + 1 TS peak -EXPECTED_BARRIERLESS_LINE_COUNT = 3 # 2 levels + 1 connector - - -def _new_figure_and_axes() -> tuple[Figure, Axes]: - """Build a bare Figure/Axes without touching pyplot's global state.""" - figure = Figure() - FigureCanvasAgg(figure) - return figure, figure.add_subplot() - - -def test__plot_normal_path_with_ts( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a well + bimolecular + barrier network draws all segments.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - _with_energy(database, calculation_row, geometry_row, -76.0) - - frag1_geo = GeometryRow( - symbols=["N", "H", "H"], - coordinates=np.array([[0.0, 0.0, 0.0], [0.0, 0.8, -0.5], [0.0, -0.8, -0.5]]), - charge=0, - spin=1, - ) - frag2_geo = GeometryRow( - symbols=["H", "C", "O"], - coordinates=np.array([[0.0, 1.2, 0.0], [0.0, 0.0, 0.0], [1.1, -0.6, 0.0]]), - charge=0, - spin=1, - ) - frag1 = _stationary(database, calculation_row, frag1_geo) - frag2 = _stationary(database, calculation_row, frag2_geo) - _with_energy(database, calculation_row, frag1_geo, -55.6) - _with_energy(database, calculation_row, frag2_geo, -20.3) - - ts_geo = GeometryRow( - symbols=["N", "H", "H"], - coordinates=np.array([[0.0, 0.0, 0.0], [0.0, 0.9, -0.5], [0.0, -0.9, -0.5]]), - charge=0, - spin=1, - ) - ts = _stationary(database, calculation_row, ts_geo, order=1) - _with_energy(database, calculation_row, ts_geo, -75.5) - - # All three stages are built together, right before `step`, so no intervening - # `database.commit()` (from the helpers above) flushes the session while one is - # linked via backref to a persistent stationary but not yet in the session itself. - well_stage = StageRow(stationaries=[ref]) - bimolecular_stage = StageRow(stationaries=[frag1, frag2]) - ts_stage = StageRow(stationaries=[ts], is_ts=True) - - step = StepRow(stage1=well_stage, stage2=bimolecular_stage, stage_ts=ts_stage) - database.add(step) - database.commit() - - result = plot_pes(database, [step], ref=ref, model=calculation_row.model) - - assert isinstance(result, PESPlot) - assert isinstance(result.figure, Figure) - assert isinstance(result.axes, Axes) - assert len(result.axes.lines) == EXPECTED_TS_PATH_LINE_COUNT - assert len(result.axes.texts) == EXPECTED_TS_PATH_TEXT_COUNT - assert any(t.get_text().startswith("B1") for t in result.axes.texts) - - -def test__plot_barrierless_step( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a barrierless step draws a dashed connector, no TS peak.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, geometry_row, -76.0) - _with_energy(database, calculation_row, other_geo, -1.0) - - step = StepRow( - stage1=StageRow(stationaries=[ref]), stage2=StageRow(stationaries=[other]) - ) - database.add(step) - database.commit() - assert step.is_barrierless - - result = plot_pes(database, [step], ref=ref, model=calculation_row.model) - - assert len(result.axes.lines) == EXPECTED_BARRIERLESS_LINE_COUNT - connector = result.axes.lines[-1] - assert connector.get_linestyle() == "--" - assert any(t.get_text() == "B1" for t in result.axes.texts) - - -def test__plot_missing_energy_flagged( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a species missing an EnergyRow is flagged, not raised.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, geometry_row, -76.0) - - step = StepRow( - stage1=StageRow(stationaries=[ref]), stage2=StageRow(stationaries=[other]) - ) - database.add(step) - database.commit() - - result = plot_pes(database, [step], ref=ref, model=calculation_row.model) - - flagged_lines = [line for line in result.axes.lines if line.get_linestyle() == "--"] - assert any(np.asarray(line.get_ydata())[0] == 0.0 for line in flagged_lines) - assert any(t.get_text().endswith("(no energy data)") for t in result.axes.texts) - - -def test__plot_repr_png_returns_valid_png( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that _repr_png_ returns bytes recognizable as a PNG (Jupyter hook).""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, geometry_row, -76.0) - _with_energy(database, calculation_row, other_geo, -1.0) - - step = StepRow( - stage1=StageRow(stationaries=[ref]), stage2=StageRow(stationaries=[other]) - ) - database.add(step) - database.commit() - - result = plot_pes(database, [step], ref=ref, model=calculation_row.model) - png = result._repr_png_() - - assert isinstance(png, bytes) - assert png.startswith(PNG_MAGIC_BYTES) - - -def test__plot_save_writes_png( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - tmp_path: Path, -) -> None: - """Test that save() writes a real PNG file, inferring format from suffix.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, geometry_row, -76.0) - _with_energy(database, calculation_row, other_geo, -1.0) - - step = StepRow( - stage1=StageRow(stationaries=[ref]), stage2=StageRow(stationaries=[other]) - ) - database.add(step) - database.commit() - - result = plot_pes(database, [step], ref=ref, model=calculation_row.model) - path = tmp_path / "pes.png" - result.save(path) - - assert path.exists() - assert path.read_bytes()[:8] == PNG_MAGIC_BYTES - - -def test__plot_save_writes_svg( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - tmp_path: Path, -) -> None: - """Test that save() writes a real SVG file, inferring format from suffix.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, geometry_row, -76.0) - _with_energy(database, calculation_row, other_geo, -1.0) - - step = StepRow( - stage1=StageRow(stationaries=[ref]), stage2=StageRow(stationaries=[other]) - ) - database.add(step) - database.commit() - - result = plot_pes(database, [step], ref=ref, model=calculation_row.model) - path = tmp_path / "pes.svg" - result.save(path) - - assert path.exists() - assert " None: - """Test that a caller-supplied Axes is drawn into and returned as-is.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, geometry_row, -76.0) - _with_energy(database, calculation_row, other_geo, -1.0) - - step = StepRow( - stage1=StageRow(stationaries=[ref]), stage2=StageRow(stationaries=[other]) - ) - database.add(step) - database.commit() - - figure, axes = _new_figure_and_axes() - result = plot_pes(database, [step], ref=ref, model=calculation_row.model, ax=axes) - - assert result.axes is axes - assert result.figure is figure - - -def test__plot_raises_on_missing_reference_energy( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a missing reference energy raises rather than silently defaulting.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, other_geo, -1.0) - - step = StepRow( - stage1=StageRow(stationaries=[ref]), stage2=StageRow(stationaries=[other]) - ) - database.add(step) - database.commit() - - with pytest.raises(ValueError, match="No EnergyRow found for reference"): - plot_pes(database, [step], ref=ref, model=calculation_row.model) diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index b4882df..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,435 +0,0 @@ -"""Autostorage MESS export tests.""" - -import re - -import numpy as np -import pytest -from numpy.random import Generator - -from autostorage import ( - CalculationRow, - Database, - EnergyRow, - GeometryRow, - HessianRow, - StageRow, - StationaryPointRow, - StepRow, -) -from autostorage.utils import HARTREE_TO_KCAL_PER_MOL, export_mess_input - - -def _stationary( - database: Database, - calculation: CalculationRow, - geometry: GeometryRow, - *, - order: int = 0, -) -> StationaryPointRow: - """Create, persist, and return a stationary point for `geometry`.""" - stationary = StationaryPointRow( - calculation=calculation, geometry=geometry, order=order - ) - database.add(stationary) - database.commit() - return stationary - - -def _with_energy( - database: Database, calculation: CalculationRow, geometry: GeometryRow, value: float -) -> None: - """Persist an `EnergyRow` for `geometry` at `calculation`'s model.""" - database.add(EnergyRow(geometry=geometry, calculation=calculation, value=value)) - database.commit() - - -def _diatomic(symbols: list[str], distance: float, *, spin: int = 0) -> GeometryRow: - """Build a simple two-atom `GeometryRow` along the z-axis.""" - return GeometryRow( - symbols=symbols, - coordinates=np.array([[0.0, 0.0, 0.0], [0.0, 0.0, distance]]), - charge=0, - spin=spin, - ) - - -def _species_section(text: str, label: str) -> str: - """Return the `Well`/`Bimolecular` block in `text` for the given label.""" - for keyword in ("Well", "Bimolecular"): - marker = f"{keyword} {label}" - if marker in text: - start = text.index(marker) - break - else: - msg = f"No species block found for label {label!r}." - raise AssertionError(msg) - - rest = text[start + len(marker) :] - boundaries = [ - start + len(marker) + rest.index(kw) - for kw in ("\nWell ", "\nBimolecular ", "\nBarrier ") - if kw in rest - ] - end = min(boundaries) if boundaries else len(text) - return text[start:end] - - -def _barrier_section(text: str, label: str) -> str: - """Return the `Barrier` block in `text` for the given label (to end of text).""" - marker = f"Barrier {label}" - return text[text.index(marker) :] - - -def test__export_well_bimolecular_barrier_round_trip( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a well + bimolecular + barrier network renders all block types.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - _with_energy(database, calculation_row, geometry_row, -76.0) - - frag1_geo = GeometryRow( - symbols=["N", "H", "H"], - coordinates=np.array([[0.0, 0.0, 0.0], [0.0, 0.8, -0.5], [0.0, -0.8, -0.5]]), - charge=0, - spin=1, - ) - frag2_geo = GeometryRow( - symbols=["H", "C", "O"], - coordinates=np.array([[0.0, 1.2, 0.0], [0.0, 0.0, 0.0], [1.1, -0.6, 0.0]]), - charge=0, - spin=1, - ) - frag1 = _stationary(database, calculation_row, frag1_geo) - frag2 = _stationary(database, calculation_row, frag2_geo) - _with_energy(database, calculation_row, frag1_geo, -55.6) - _with_energy(database, calculation_row, frag2_geo, -20.3) - - ts_geo = GeometryRow( - symbols=["N", "H", "H"], - coordinates=np.array([[0.0, 0.0, 0.0], [0.0, 0.9, -0.5], [0.0, -0.9, -0.5]]), - charge=0, - spin=1, - ) - ts = _stationary(database, calculation_row, ts_geo, order=1) - _with_energy(database, calculation_row, ts_geo, -75.5) - - # All three stages are built together, right before `step`, so no intervening - # `database.commit()` (from the helpers above) flushes the session while one is - # linked via backref to a persistent stationary but not yet in the session itself. - well_stage = StageRow(stationaries=[ref]) - bimolecular_stage = StageRow(stationaries=[frag1, frag2]) - ts_stage = StageRow(stationaries=[ts], is_ts=True) - - step = StepRow(stage1=well_stage, stage2=bimolecular_stage, stage_ts=ts_stage) - database.add(step) - database.commit() - - text = export_mess_input(database, [step], ref=ref, model=calculation_row.model) - - expected_fragment_count = 2 - - assert "Well W1" in text - assert "Bimolecular P1" in text - assert text.count("Fragment ") == expected_fragment_count - barrier_section = _barrier_section(text, "B1") - assert "W1" in barrier_section.splitlines()[0] - assert "P1" in barrier_section.splitlines()[0] - - -def test__export_zero_energy_relative_to_reference( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that ZeroEnergy values are computed relative to the reference energy.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - - ref_value = -76.000 - other_value = -75.950 - _with_energy(database, calculation_row, geometry_row, ref_value) - _with_energy(database, calculation_row, other_geo, other_value) - - step = StepRow( - stage1=StageRow(stationaries=[ref]), stage2=StageRow(stationaries=[other]) - ) - database.add(step) - database.commit() - - text = export_mess_input(database, [step], ref=ref, model=calculation_row.model) - - expected = (other_value - ref_value) * HARTREE_TO_KCAL_PER_MOL - ref_section = _species_section(text, "W1") - other_section = _species_section(text, "W2") - ref_match = re.search(r"ZeroEnergy\[kcal/mol\]\s+(-?\d+\.\d+)", ref_section) - other_match = re.search(r"ZeroEnergy\[kcal/mol\]\s+(-?\d+\.\d+)", other_section) - assert ref_match is not None - assert other_match is not None - assert float(ref_match.group(1)) == pytest.approx(0.0, abs=1e-2) - assert float(other_match.group(1)) == pytest.approx(expected, abs=1e-2) - - -def test__export_oh_electronic_levels_special_case( - database: Database, calculation_row: CalculationRow -) -> None: - """Test the OH spin-orbit electronic-levels special case and the fallback.""" - database.add(calculation_row) - database.commit() - - oh_geo = _diatomic(["O", "H"], 0.97, spin=1) - other_geo = _diatomic(["H", "H"], 0.74) - oh = _stationary(database, calculation_row, oh_geo) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, oh_geo, -75.7) - _with_energy(database, calculation_row, other_geo, -1.2) - - oh_stage = StageRow(stationaries=[oh]) - other_stage = StageRow(stationaries=[other]) - step = StepRow(stage1=oh_stage, stage2=other_stage) - database.add(step) - database.commit() - - assert oh_stage.id is not None - assert other_stage.id is not None - labels = {oh_stage.id: "WOH", other_stage.id: "WOTHER"} - text = export_mess_input( - database, [step], ref=oh, model=calculation_row.model, labels=labels - ) - - oh_section = _species_section(text, "WOH") - other_section = _species_section(text, "WOTHER") - - assert "ElectronicLevels[1/cm] 2" in oh_section - assert "0.0 2" in oh_section - assert "140.0 2" in oh_section - - assert "ElectronicLevels[1/cm] 1" in other_section - assert "0.0 1" in other_section - - -def test__export_barrierless_placeholder( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a barrierless step renders a flagged placeholder Barrier block.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, geometry_row, -76.0) - _with_energy(database, calculation_row, other_geo, -1.0) - - step = StepRow( - stage1=StageRow(stationaries=[ref]), stage2=StageRow(stationaries=[other]) - ) - database.add(step) - database.commit() - - assert step.is_barrierless - - text = export_mess_input(database, [step], ref=ref, model=calculation_row.model) - - barrier_section = _barrier_section(text, "B1") - assert "TODO(autostorage): barrierless step" in barrier_section - assert "Frequencies[1/cm] 0" in barrier_section - - -def test__export_symmetry_factor_computed_and_barrierless_placeholder( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that SymmetryFactor is a real computed value, except for barrierless TSs.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, geometry_row, -76.0) - _with_energy(database, calculation_row, other_geo, -1.0) - - other_stage = StageRow(stationaries=[other]) - step = StepRow(stage1=StageRow(stationaries=[ref]), stage2=other_stage) - database.add(step) - database.commit() - - text = export_mess_input(database, [step], ref=ref, model=calculation_row.model) - - other_section = _species_section(text, "W2") - assert f"SymmetryFactor {other_geo.symmetry_number}" in other_section - assert "TODO(autostorage)" not in other_section - - barrier_section = _barrier_section(text, "B1") - assert "SymmetryFactor 1 ! TODO(autostorage)" in barrier_section - - -def test__export_custom_labels_and_names_override( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that label/name overrides apply only to the given stage ids.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, geometry_row, -76.0) - _with_energy(database, calculation_row, other_geo, -1.0) - - ref_stage = StageRow(stationaries=[ref]) - step = StepRow(stage1=ref_stage, stage2=StageRow(stationaries=[other])) - database.add(step) - database.commit() - - assert ref_stage.id is not None - text = export_mess_input( - database, - [step], - ref=ref, - model=calculation_row.model, - labels={ref_stage.id: "X1"}, - names={ref_stage.id: "custom name"}, - ) - - assert "Well X1 # custom name" in text - - well_labels = re.findall(r"^Well {2}(\S+)", text, re.MULTILINE) - assert "X1" in well_labels - other_labels = [label for label in well_labels if label != "X1"] - assert len(other_labels) == 1 - assert re.fullmatch(r"W\d+", other_labels[0]) - - -def test__export_ts_excludes_imaginary_frequency( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - rng: Generator, -) -> None: - """Test that a Barrier block's Frequencies exclude the imaginary TS mode.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - - ts_geo = GeometryRow( - symbols=["H", "O", "H"], - coordinates=np.array([[0, 0, 0.9], [0, 0, 0], [0.9, 0, 0]]), - charge=0, - spin=0, - ) - ts = _stationary(database, calculation_row, ts_geo, order=1) - - n = ts_geo.atom_count - ts_hessian = HessianRow( - calculation=calculation_row, - geometry=ts_geo, - value=rng.uniform(size=(3 * n, 3 * n)), - ) - database.add(ts_hessian) - database.commit() - - _with_energy(database, calculation_row, geometry_row, -76.0) - _with_energy(database, calculation_row, other_geo, -75.9) - _with_energy(database, calculation_row, ts_geo, -75.8) - - expected_positive_count = sum(1 for f in ts_hessian.harmonic_frequencies if f > 0.0) - assert ts_hessian.order >= 1 - - step = StepRow( - stage1=StageRow(stationaries=[ref]), - stage2=StageRow(stationaries=[other]), - stage_ts=StageRow(stationaries=[ts], is_ts=True), - ) - database.add(step) - database.commit() - - text = export_mess_input(database, [step], ref=ref, model=calculation_row.model) - - barrier_section = _barrier_section(text, "B1") - match = re.search(r"Frequencies\[1/cm\]\s+(\d+)", barrier_section) - assert match is not None - assert int(match.group(1)) == expected_positive_count - - -def test__export_missing_energy_renders_todo( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a species missing an EnergyRow renders a TODO instead of raising.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, geometry_row, -76.0) - - step = StepRow( - stage1=StageRow(stationaries=[ref]), stage2=StageRow(stationaries=[other]) - ) - database.add(step) - database.commit() - - text = export_mess_input(database, [step], ref=ref, model=calculation_row.model) - - assert "TODO(autostorage): no EnergyRow found" in text - - -def test__export_raises_on_missing_reference_energy( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a missing reference energy raises rather than silently defaulting.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - other_geo = _diatomic(["H", "H"], 0.74) - other = _stationary(database, calculation_row, other_geo) - _with_energy(database, calculation_row, other_geo, -1.0) - - step = StepRow( - stage1=StageRow(stationaries=[ref]), stage2=StageRow(stationaries=[other]) - ) - database.add(step) - database.commit() - - with pytest.raises(ValueError, match="No EnergyRow found for reference"): - export_mess_input(database, [step], ref=ref, model=calculation_row.model) - - -def test__export_fragment_order_deterministic( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that bimolecular fragments render in ascending-id order, not list order.""" - database.add(calculation_row) - database.commit() - - ref = _stationary(database, calculation_row, geometry_row) - _with_energy(database, calculation_row, geometry_row, -76.0) - - frag_a_geo = _diatomic(["C", "O"], 1.13) - frag_b_geo = _diatomic(["H", "H"], 0.74) - frag_a = _stationary(database, calculation_row, frag_a_geo) - frag_b = _stationary(database, calculation_row, frag_b_geo) - _with_energy(database, calculation_row, frag_a_geo, -10.0) - _with_energy(database, calculation_row, frag_b_geo, -1.0) - - assert frag_a.id is not None - assert frag_b.id is not None - assert frag_a.id < frag_b.id - - bimolecular_stage = StageRow(stationaries=[frag_b, frag_a]) - step = StepRow(stage1=StageRow(stationaries=[ref]), stage2=bimolecular_stage) - database.add(step) - database.commit() - - text = export_mess_input(database, [step], ref=ref, model=calculation_row.model) - - assert text.index("Fragment CO") < text.index("Fragment H2") From ceff598603e2e5471e5282524577aeb7f455cb74 Mon Sep 17 00:00:00 2001 From: "Troy N. Smith" Date: Wed, 29 Jul 2026 23:48:31 -0400 Subject: [PATCH 3/8] Remove `Geometry` subclassing of `GeometryRow` --- src/autostorage/events.py | 12 +++++++----- src/autostorage/models.py | 26 ++++++++++++------------- tests/test_models.py | 41 +++++++++++++-------------------------- 3 files changed, 33 insertions(+), 46 deletions(-) diff --git a/src/autostorage/events.py b/src/autostorage/events.py index 295e230..924d9a4 100644 --- a/src/autostorage/events.py +++ b/src/autostorage/events.py @@ -51,7 +51,7 @@ def verify_gradient_shape( if geometry is None: return - expected = (3 * geometry.atom_count,) + expected = (3 * geometry.to_geometry().atom_count,) actual = np.shape(target.value) if actual != expected: @@ -70,7 +70,7 @@ def verify_hessian_shape( if geometry is None: return - expected_dim = 3 * geometry.atom_count + expected_dim = 3 * geometry.to_geometry().atom_count expected = (expected_dim, expected_dim) actual = np.shape(target.value) @@ -230,7 +230,7 @@ def add_inchi_identities(session: Session, flush_context: Any, instances: Any) - continue try: inchi = IdentityRow.from_geometry( - geo=geometry, algorithm=Algorithm.RDKIT_INCHI + geo=geometry.to_geometry(), algorithm=Algorithm.RDKIT_INCHI ) pending_items.append((obj, inchi, geometry)) inchi_lookups.append((inchi.algorithm, inchi.value)) @@ -260,7 +260,7 @@ def add_inchi_identities(session: Session, flush_context: Any, instances: Any) - try: smiles = IdentityRow.from_geometry( - geometry, algorithm=Algorithm.RDKIT_SMILES + geometry.to_geometry(), algorithm=Algorithm.RDKIT_SMILES ) smiles_extra = IdentityExtraRow( identity=inchi, attribute="smiles", value=smiles.value @@ -290,7 +290,9 @@ def _matching_conformer_identity( if not resolved_peers: return None - matches = geom.is_duplicate_conformer(geometry, [g for _, g in resolved_peers]) + matches = geom.is_duplicate_conformer( + geometry.to_geometry(), [g.to_geometry() for _, g in resolved_peers] + ) match_idx = next((i for i, m in enumerate(matches) if m), None) if match_idx is None: return None diff --git a/src/autostorage/models.py b/src/autostorage/models.py index 3663e24..81302c3 100644 --- a/src/autostorage/models.py +++ b/src/autostorage/models.py @@ -25,9 +25,6 @@ select, ) from sqlmodel.main import SQLModelConfig -from stereomolgraph.algorithms.symmetry import ( - symmetry_number as _stereo_symmetry_number, -) from autostorage.exc import MissingPrimaryKeyError @@ -167,7 +164,7 @@ def _geometry_hash( # Geometry table -class GeometryRow(BaseRow, Geometry, table=True): +class GeometryRow(BaseRow, table=True): """Molecular geometry definition and metadata. Attributes @@ -199,6 +196,7 @@ class GeometryRow(BaseRow, Geometry, table=True): __tablename__ = "geometry" __table_args__ = (UniqueConstraint("geometry_hash", name="unique_geometry_hash"),) + model_config = SQLModelConfig(arbitrary_types_allowed=True) symbols: list[str] = Field(sa_column=Column(JSON)) coordinates: FloatArray = Field(sa_column=Column(CompressedArrayTypeDecorator())) @@ -219,14 +217,14 @@ class GeometryRow(BaseRow, Geometry, table=True): back_populates="geometry" ) - @cached_property - def symmetry_number(self) -> int: - """Symmetry number from stereo-preserving graph automorphisms. - - Cached per instance since counting graph isomorphisms is expensive. - """ - graph = geom.stereo_mol_graph(self) - return _stereo_symmetry_number(graph) + def to_geometry(self) -> Geometry: + """Convert to an automol Geometry instance.""" + return Geometry( + symbols=self.symbols, + coordinates=self.coordinates, + charge=self.charge, + spin=self.spin, + ) @classmethod def find_or_create( # noqa: PLR0913 @@ -363,7 +361,9 @@ def harmonic_frequencies(self) -> tuple[float, ...]: every relevant flush) depends on it. Invalidated on `value` update by `invalidate_hessian_frequency_cache` in `events.py`. """ - freqs, _ = geom.vibrational_analysis(geo=self.geometry, hess=self.value) + freqs, _ = geom.vibrational_analysis( + geo=self.geometry.to_geometry(), hess=self.value + ) return freqs @property diff --git a/tests/test_models.py b/tests/test_models.py index 3b394e8..09d97e4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -395,7 +395,7 @@ def test__hessian_properties( database.add(geometry_row) database.add(calc_geo_link) - n = geometry_row.atom_count + n = geometry_row.to_geometry().atom_count hessian = HessianRow( calculation=calculation_row, geometry=geometry_row, @@ -423,7 +423,7 @@ def test__hessian_frequency_cache_invalidated_on_value_update( database.add(geometry_row) database.add(calc_geo_link) - n = geometry_row.atom_count + n = geometry_row.to_geometry().atom_count hessian = HessianRow( calculation=calculation_row, geometry=geometry_row, @@ -442,25 +442,6 @@ def test__hessian_frequency_cache_invalidated_on_value_update( assert hessian.harmonic_frequencies != original_frequencies -def test__geometry_symmetry_number() -> None: - """Test that symmetry_number is computed from a geometry's point group. - - Uses a properly symmetric (C2v) water geometry -- unlike `geometry_row`, - whose bond lengths/angle are arbitrary and so has no symmetry to detect -- - to guard against a broken computation silently returning 1 for everything. - """ - oh = 0.9584 - angle = np.radians(104.45) - o = np.array([0.0, 0.0, 0.0]) - h1 = np.array([0.0, oh * np.sin(angle / 2), oh * np.cos(angle / 2)]) - h2 = np.array([0.0, -oh * np.sin(angle / 2), oh * np.cos(angle / 2)]) - water = GeometryRow( - symbols=["O", "H", "H"], coordinates=np.array([o, h1, h2]), charge=0, spin=0 - ) - expected_symmetry_number = 2 - assert water.symmetry_number == expected_symmetry_number - - def test__result_query( database: Database, calculation_row: CalculationRow, @@ -474,7 +455,7 @@ def test__result_query( database.add(calc_geo_link) database.commit() - n = geometry_row.atom_count + n = geometry_row.to_geometry().atom_count hess = HessianRow( calculation=calculation_row, geometry=geometry_row, @@ -593,7 +574,7 @@ def test__stationary_order_hessian_first( database.add(calculation_row) database.add(geometry_row) - n = geometry_row.atom_count + n = geometry_row.to_geometry().atom_count hessian_row = HessianRow( calculation=calculation_row, geometry=geometry_row, @@ -627,7 +608,7 @@ def test__stationary_order_hessian_second( database.add(stationary) assert stationary.is_valid - n = geometry_row.atom_count + n = geometry_row.to_geometry().atom_count hessian_row = HessianRow( calculation=calculation_row, geometry=geometry_row, @@ -647,7 +628,7 @@ def test__hessian_delete_leaves_is_valid_correct_with_remaining_hessian( database.add(geometry_row) database.commit() - n = geometry_row.atom_count + n = geometry_row.to_geometry().atom_count hessian1 = HessianRow( calculation=calculation_row, geometry=geometry_row, @@ -680,7 +661,7 @@ def test__hessian_delete_leaves_is_valid_untouched_when_no_hessians_remain( database.add(geometry_row) database.commit() - n = geometry_row.atom_count + n = geometry_row.to_geometry().atom_count hessian = HessianRow( calculation=calculation_row, geometry=geometry_row, @@ -710,7 +691,9 @@ def test__stationary_query( stationary = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) database.add(stationary) - ident = Identity.from_geometry(geo=geometry_row, algorithm=Algorithm.RDKIT_INCHI) + ident = Identity.from_geometry( + geo=geometry_row.to_geometry(), algorithm=Algorithm.RDKIT_INCHI + ) stationary2 = StationaryPointRow.query( database, ident=ident, model=calculation_row.model ) @@ -723,7 +706,9 @@ def test__invalid_stationary_query( database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow ) -> None: """Test invalid querying of stationary points.""" - ident = Identity.from_geometry(geo=geometry_row, algorithm=Algorithm.RDKIT_INCHI) + ident = Identity.from_geometry( + geo=geometry_row.to_geometry(), algorithm=Algorithm.RDKIT_INCHI + ) with pytest.raises(MissingPrimaryKeyError): StationaryPointRow.query(database, ident=ident, model=calculation_row.model) From 4cbda8e2318730e9175f72f33c5ad1fa1d85e218 Mon Sep 17 00:00:00 2001 From: "Troy N. Smith" Date: Wed, 29 Jul 2026 23:52:42 -0400 Subject: [PATCH 4/8] Remove merge --- pixi.lock | 8 +- pyproject.toml | 5 - src/autostorage/__init__.py | 5 +- src/autostorage/database.py | 36 ---- src/autostorage/events.py | 4 +- src/autostorage/merge.py | 376 -------------------------------- tests/test_merge.py | 420 ------------------------------------ 7 files changed, 6 insertions(+), 848 deletions(-) delete mode 100644 src/autostorage/merge.py delete mode 100644 tests/test_merge.py diff --git a/pixi.lock b/pixi.lock index ee6616f..393bac6 100644 --- a/pixi.lock +++ b/pixi.lock @@ -118,7 +118,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda @@ -169,7 +168,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda - - conda_source: autostorage[5c1d8401] @ . + - conda_source: autostorage[6f9aa45d] @ . - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/1b/291dd75b5ed494eb484801f39e6572192d322e6cd3d68fab8a6dde743c48/graphrc-1.3.7-py3-none-any.whl @@ -479,7 +478,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.6.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - - conda_source: autostorage[5c1d8401] @ . + - conda_source: autostorage[6f9aa45d] @ . - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/23/06/b4f06ca7afb5d9e942c642980c308ffcfa1fa0e8b0a3ddbec78483ef1614/keepachangelog-2.0.0-py3-none-any.whl @@ -5022,13 +5021,12 @@ packages: run_exports: {} size: 24190 timestamp: 1779159948016 -- conda_source: autostorage[5c1d8401] @ . +- conda_source: autostorage[6f9aa45d] @ . variants: target_platform: noarch depends: - python >=3.12 - python * - - click >=8.0 - sqlmodel >=0.0.31 host_packages: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda diff --git a/pyproject.toml b/pyproject.toml index f3bd5fe..324694a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,15 +8,11 @@ authors = [ requires-python = ">= 3.12" dependencies = [ "automol==0.0.19", - "click>=8.0", "sqlmodel>=0.0.31", "irmsd>=0.1.1", "stereomolgraph>=0.0.22b0", ] -[project.scripts] -autostorage-merge = "autostorage.database:merge_databases" - [build-system] build-backend = "uv_build" requires = ["uv_build<0.12"] @@ -69,7 +65,6 @@ name = "Autostorage Layering" type = "layers" layers = [ "autostorage.database", - "autostorage.merge", "autostorage.events", "autostorage.models", "autostorage.types | autostorage.exc", diff --git a/src/autostorage/__init__.py b/src/autostorage/__init__.py index 9f93412..801ec8e 100644 --- a/src/autostorage/__init__.py +++ b/src/autostorage/__init__.py @@ -2,9 +2,8 @@ __version__ = "0.0.12" -from . import exc, merge, types +from . import exc, types from .database import Database -from .merge import MergeReport from .models import ( CalculationGeometryLink, CalculationRow, @@ -35,7 +34,6 @@ "HessianRow", "IdentityExtraRow", "IdentityRow", - "MergeReport", "ModelRow", "Role", "StageRow", @@ -44,6 +42,5 @@ "TrajectoryRow", "ValidationRow", "exc", - "merge", "types", ] diff --git a/src/autostorage/database.py b/src/autostorage/database.py index 0d2de05..e2709bb 100644 --- a/src/autostorage/database.py +++ b/src/autostorage/database.py @@ -8,7 +8,6 @@ from types import TracebackType from typing import Self -import click from sqlalchemy import Select, create_engine, event from sqlalchemy import select as sa_select from sqlalchemy.exc import MultipleResultsFound, NoResultFound @@ -16,8 +15,6 @@ # Ensure all modules are loaded with the database from .events import * # noqa: F403 -from .merge import MergeReport -from .merge import merge_databases as _merge_databases from .models import * # noqa: F403 from .models import SQLModel @@ -125,19 +122,6 @@ def merge[RowT: SQLModel](self, row: RowT) -> RowT: session.commit() return merged - def merge_from(self, source_db: "Database", *, commit: bool = True) -> MergeReport: - """Merge another database's contents into this one. - - Unlike `merge()` (a same-session upsert of a single row), this - copies every row from a separate `source_db` into this database, - remapping ids/foreign keys and deduplicating content-unique rows. - - See Also - -------- - autostorage.merge - """ - return _merge_databases(target=self, source=source_db, commit=commit) - def flush(self) -> None: """Flush pending changes to the database without committing. @@ -229,23 +213,3 @@ def __exit__( if exc_type is not None: self._session.rollback() self.close() - - -@click.command(name="autostorage-merge") -@click.argument("target", type=click.Path(path_type=Path)) -@click.argument("source", type=click.Path(path_type=Path)) -def merge_databases(target: Path, source: Path) -> None: - """Merge one on-disk database's contents into another, as a CLI command. - - Installed as the ``autostorage-merge`` console script (see ``[project.scripts]`` in - ``pyproject.toml``); also runnable via ``python -m autostorage.database``. - """ - with Database(target) as target_db, Database(source) as source_db: - report = _merge_databases(target=target_db, source=source_db) - - click.echo(f"Copied: {report.copied}") - click.echo(f"Reused: {report.reused}") - - -if __name__ == "__main__": - merge_databases() diff --git a/src/autostorage/events.py b/src/autostorage/events.py index 924d9a4..6ada140 100644 --- a/src/autostorage/events.py +++ b/src/autostorage/events.py @@ -209,8 +209,8 @@ def compute_geometry_hash( flag_modified(target, "geometry_hash") -# Identity algorithms managed here, so other code (e.g. `merge.py`) knows not to -# copy/attach them explicitly. +# Identity algorithms managed here, so other code knows not to copy/attach them +# explicitly. AUTO_MANAGED_IDENTITY_ALGORITHMS: frozenset[Algorithm] = frozenset( {Algorithm.RDKIT_INCHI, Algorithm.IRMSD} ) diff --git a/src/autostorage/merge.py b/src/autostorage/merge.py deleted file mode 100644 index aa776fb..0000000 --- a/src/autostorage/merge.py +++ /dev/null @@ -1,376 +0,0 @@ -"""Merge one database's contents into another, with validation at merge time.""" - -from collections.abc import Sequence -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from sqlalchemy import func, select -from sqlalchemy import inspect as sa_inspect - -from .events import AUTO_MANAGED_IDENTITY_ALGORITHMS -from .models import ( - GeometryRow, - IdentityExtraRow, - IdentityRow, - ModelRow, - SQLModel, - StationaryIdentityLink, -) - -if TYPE_CHECKING: - from .database import Database - -__all__ = ["MergeReport", "merge_databases"] - - -@dataclass(frozen=True, slots=True) -class MergeReport: - """Summary of a `merge_databases` call. - - Attributes - ---------- - copied - New rows created in the target database, by table name. - reused - Source rows deduplicated onto an existing target row, by table name - (only ``model``, ``geometry``, and ``identity`` support dedup). - """ - - copied: dict[str, int] - reused: dict[str, int] - - -def merge_databases( - target: "Database", source: "Database", *, commit: bool = True -) -> MergeReport: - """Copy `source`'s contents into `target`, validating and deduplicating. - - Rows are copied with freshly-assigned primary keys and remapped foreign - keys. `ModelRow`, `GeometryRow`, and non-auto-managed `IdentityRow`s are - deduplicated against `target`'s existing content; everything else is - copied fresh. - - InChI/conformer identities are deliberately skipped here: inserting each - source `StationaryPointRow` fresh lets `autostorage.events`'s flush - listeners regenerate and dedup them against `target`'s live state, so a - conformer shared by both databases collapses onto one identity. Every - copied row passes through normal ORM validation. - - Only `flush()` is used until the end, so an error partway through - leaves `target` unchanged. - - Parameters - ---------- - target - Database to copy rows into. - source - Database to copy rows from. Only ever read, never modified. - commit, optional - If True (default), commit once every table has copied successfully. - If False, leave flushed-but-uncommitted for the caller. - - Returns - ------- - MergeReport - Per-table counts of rows copied and reused. - """ - _check_mergeable(target, source) - - id_map: dict[type[SQLModel], dict[int, int]] = {} - copied: dict[str, int] = {} - reused: dict[str, int] = {} - skipped_identity_ids: set[int] = set() - - for cls in _ordered_models(): - if cls is ModelRow: - _copy_models( - target=target, - source=source, - id_map=id_map, - copied=copied, - reused=reused, - ) - elif cls is GeometryRow: - _copy_geometries( - target=target, - source=source, - id_map=id_map, - copied=copied, - reused=reused, - ) - elif cls is IdentityRow: - skipped_identity_ids = _copy_identities( - target=target, - source=source, - id_map=id_map, - copied=copied, - reused=reused, - ) - elif cls is IdentityExtraRow: - rows = [ - row - for row in source.exec_all(select(IdentityExtraRow)) - if row.identity_id not in skipped_identity_ids - ] - _copy_table( - IdentityExtraRow, rows, target=target, id_map=id_map, copied=copied - ) - elif cls is StationaryIdentityLink: - rows = [ - row - for row in source.exec_all(select(StationaryIdentityLink)) - if row.identity_id not in skipped_identity_ids - ] - _copy_table( - StationaryIdentityLink, - rows, - target=target, - id_map=id_map, - copied=copied, - ) - else: - _copy_table( - cls, - source.exec_all(select(cls)), - target=target, - id_map=id_map, - copied=copied, - ) - - if commit: - target.commit() - else: - target.flush() - - return MergeReport(copied=copied, reused=reused) - - -def _is_same_database(a: "Database", b: "Database") -> bool: - """Return whether `a` and `b` refer to the same underlying database. - - In-memory databases (path ``":memory:"``) never count as a match; - on-disk `Database`s match if they resolve to the same file. - """ - if a is b: - return True - if str(a.path) == ":memory:" or str(b.path) == ":memory:": - return False - return a.path.resolve() == b.path.resolve() - - -def _reflected_schema(db: "Database") -> dict[str, set[str]]: - """Return the actual on-disk table/column names for `db`.""" - inspector = sa_inspect(db.engine) - return { - table_name: {column["name"] for column in inspector.get_columns(table_name)} - for table_name in inspector.get_table_names() - } - - -def _check_mergeable(target: "Database", source: "Database") -> None: - """Reject a merge that can't safely proceed. - - Raises - ------ - ValueError - If `source` and `target` are the same database, or either is - missing an expected table/column (e.g. an outdated schema). - """ - if _is_same_database(target, source): - msg = "Cannot merge a database into itself." - raise ValueError(msg) - - expected = { - table.name: {column.name for column in table.columns} - for table in SQLModel.metadata.tables.values() - } - for db, label in ((source, "source"), (target, "target")): - actual = _reflected_schema(db) - for table_name, expected_columns in expected.items(): - if table_name not in actual: - msg = f"{label} database is missing table {table_name!r}." - raise ValueError(msg) - missing_columns = expected_columns - actual[table_name] - if missing_columns: - msg = ( - f"{label} database's {table_name!r} table is missing " - f"column(s) {sorted(missing_columns)!r}." - ) - raise ValueError(msg) - - -def _mapped_classes() -> dict[str, type[SQLModel]]: - """Map each table name to its mapped `SQLModel` class.""" - mapping: dict[str, type[SQLModel]] = {} - for mapper in SQLModel._sa_registry.mappers: # noqa: SLF001 - table = getattr(mapper.class_, "__table__", None) - if table is not None: - mapping[table.name] = mapper.class_ - return mapping - - -def _ordered_models() -> list[type[SQLModel]]: - """Return every mapped table's `SQLModel` class, in FK-safe insertion order.""" - mapping = _mapped_classes() - return [mapping[table.name] for table in SQLModel.metadata.sorted_tables] - - -def _fk_targets(cls: type[SQLModel]) -> list[tuple[str, type[SQLModel]]]: - """Return `(column name, target class)` for each foreign key column on `cls`.""" - mapping = _mapped_classes() - return [ - (column.name, mapping[fk.column.table.name]) - for column in cls.__table__.columns # ty:ignore[unresolved-attribute] - for fk in column.foreign_keys - ] - - -def _copy_row( - row: SQLModel, *, id_map: dict[type[SQLModel], dict[int, int]] -) -> SQLModel: - """Build a new, unsaved row copying `row`'s content with FKs remapped.""" - cls = type(row) - content = row.model_dump(exclude={"id", "created_at", "updated_at"}) - for column_name, target_cls in _fk_targets(cls): - old_id = content.get(column_name) - if old_id is not None: - content[column_name] = id_map[target_cls][old_id] - return cls(**content) - - -def _copy_table( - cls: type[SQLModel], - rows: Sequence[SQLModel], - *, - target: "Database", - id_map: dict[type[SQLModel], dict[int, int]], - copied: dict[str, int], -) -> None: - """Copy `rows` (all of type `cls`) into `target`, remapping FKs via `id_map`.""" - if not rows: - return - - new_rows = [_copy_row(row, id_map=id_map) for row in rows] - target.add_all(new_rows) - target.flush() - - if "id" in cls.model_fields: - id_map[cls] = { - row.id: new_row.id # ty:ignore[unresolved-attribute] - for row, new_row in zip(rows, new_rows, strict=True) - } - - copied[cls.__tablename__] = len(new_rows) # ty:ignore[invalid-assignment] - - -def _table_count(db: "Database", cls: type[SQLModel]) -> int: - """Return the number of rows currently in `cls`'s table.""" - return db.exec_first(select(func.count()).select_from(cls)) or 0 # ty:ignore[invalid-argument-type] - - -def _copy_models( - *, - target: "Database", - source: "Database", - id_map: dict[type[SQLModel], dict[int, int]], - copied: dict[str, int], - reused: dict[str, int], -) -> None: - """Find-or-create every source `ModelRow` against `target`.""" - rows = source.exec_all(select(ModelRow)) - if not rows: - return - - before = _table_count(target, ModelRow) - mapping: dict[int, int] = {} - for row in rows: - new_row = ModelRow.find_or_create( - target, - program=row.program, - method=row.method, - program_version=row.program_version, - basis=row.basis, - commit=False, - ) - mapping[row.id] = new_row.id # ty:ignore[invalid-assignment] - - id_map[ModelRow] = mapping - created = _table_count(target, ModelRow) - before - copied["model"] = created - reused["model"] = len(rows) - created - - -def _copy_geometries( - *, - target: "Database", - source: "Database", - id_map: dict[type[SQLModel], dict[int, int]], - copied: dict[str, int], - reused: dict[str, int], -) -> None: - """Find-or-create every source `GeometryRow` against `target`.""" - rows = source.exec_all(select(GeometryRow)) - if not rows: - return - - before = _table_count(target, GeometryRow) - mapping: dict[int, int] = {} - for row in rows: - new_row = GeometryRow.find_or_create( - target, - symbols=row.symbols, - coordinates=row.coordinates, - charge=row.charge, - spin=row.spin, - commit=False, - ) - mapping[row.id] = new_row.id # ty:ignore[invalid-assignment] - - id_map[GeometryRow] = mapping - created = _table_count(target, GeometryRow) - before - copied["geometry"] = created - reused["geometry"] = len(rows) - created - - -def _copy_identities( - *, - target: "Database", - source: "Database", - id_map: dict[type[SQLModel], dict[int, int]], - copied: dict[str, int], - reused: dict[str, int], -) -> set[int]: - """Find-or-create every non-auto-managed source `IdentityRow` against `target`. - - Auto-managed identities (`AUTO_MANAGED_IDENTITY_ALGORITHMS`) are left for - `autostorage.events`'s flush listeners to regenerate instead. - - Returns - ------- - set[int] - Source-side ids of skipped identities, so callers can filter out - rows referencing them (identity extras, stationary-identity links). - """ - rows = source.exec_all(select(IdentityRow)) - skipped_ids: set[int] = set() - if not rows: - return skipped_ids - - before = _table_count(target, IdentityRow) - mapping: dict[int, int] = {} - handled = 0 - for row in rows: - if row.algorithm in AUTO_MANAGED_IDENTITY_ALGORITHMS: - skipped_ids.add(row.id) # ty:ignore[invalid-argument-type] - continue - handled += 1 - new_row = IdentityRow.find_or_create( - target, algorithm=row.algorithm, value=row.value, commit=False - ) - mapping[row.id] = new_row.id # ty:ignore[invalid-assignment] - - id_map[IdentityRow] = mapping - created = _table_count(target, IdentityRow) - before - copied["identity"] = created - reused["identity"] = handled - created - return skipped_ids diff --git a/tests/test_merge.py b/tests/test_merge.py deleted file mode 100644 index 36d06c9..0000000 --- a/tests/test_merge.py +++ /dev/null @@ -1,420 +0,0 @@ -"""Tests for autostorage.merge.""" - -import sqlite3 -from collections.abc import Iterator -from pathlib import Path - -import numpy as np -import pytest -from automol import Algorithm -from sqlalchemy import select, text - -from autostorage import ( - CalculationRow, - Database, - GeometryRow, - HessianRow, - IdentityExtraRow, - IdentityRow, - ModelRow, - StageRow, - StationaryPointRow, - StepRow, -) -from autostorage.exc import ResultShapeError -from autostorage.merge import _fk_targets, _ordered_models -from autostorage.models import SQLModel, StationaryIdentityLink -from autostorage.types import CalcType, CompressedArrayTypeDecorator - - -@pytest.fixture -def target() -> Iterator[Database]: - """In-memory database fixture, playing the role of a merge's target.""" - db = Database(":memory:") - try: - yield db - finally: - db.close() - - -@pytest.fixture -def source() -> Iterator[Database]: - """In-memory database fixture, playing the role of a merge's source.""" - db = Database(":memory:") - try: - yield db - finally: - db.close() - - -def _water_geometry() -> GeometryRow: - """Build a water GeometryRow.""" - return GeometryRow( - symbols=["O", "H", "H"], - coordinates=np.array([[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.24, 0.93, 0.0]]), - charge=0, - spin=0, - ) - - -def _add_water_stationary(db: Database) -> StationaryPointRow: - """Add a model/calculation/geometry/stationary-point chain for water to `db`.""" - model = ModelRow.find_or_create(db, program="orca", method="xtb") - calculation = CalculationRow(model=model, calc_type=CalcType.OPT) - db.add(calculation) - geometry = _water_geometry() - db.add(geometry) - db.flush() - - stationary = StationaryPointRow( - calculation_id=calculation.id, geometry_id=geometry.id - ) - db.add(stationary) - db.commit() - return stationary - - -# --- Preconditions ----------------------------------------------------------- - - -def test__merge_from_self_raises(target: Database) -> None: - """Test that merging a database into itself is rejected.""" - with pytest.raises(ValueError, match="itself"): - target.merge_from(target) - - -def test__distinct_in_memory_databases_never_collide( - target: Database, source: Database -) -> None: - """Test that two distinct `:memory:` databases are never treated as the same.""" - report = target.merge_from(source) - assert report.copied == {} - - -def test__same_on_disk_file_collides(tmp_path: Path) -> None: - """Test that two `Database`s opened on the same on-disk file are rejected.""" - path = tmp_path / "shared.db" - a = Database(path) - b = Database(path) - try: - with pytest.raises(ValueError, match="itself"): - a.merge_from(b) - finally: - a.close() - b.close() - - -def test__missing_column_fails_precondition(tmp_path: Path) -> None: - """Test that a source DB missing an expected column fails before any row copies.""" - path = tmp_path / "stale.db" - Database(path).close() - - conn = sqlite3.connect(path) - conn.execute("ALTER TABLE geometry DROP COLUMN spin") - conn.commit() - conn.close() - - source_db = Database(path) - target_db = Database(":memory:") - try: - with pytest.raises(ValueError, match="spin"): - target_db.merge_from(source_db) - finally: - source_db.close() - target_db.close() - - -# --- FK remapping -------------------------------------------------------- - - -def test__multi_tier_fk_remapping(target: Database, source: Database) -> None: - """Test that FKs resolve to newly-copied rows across a full model->step chain. - - `target` is pre-populated with an unrelated model/calculation/geometry/ - stationary/stage chain of its own first, so every table's autoincrement - ids advance past 1 before the merge -- a broken remap that left a raw - source id in place could then plausibly resolve to one of these decoy - rows instead of failing outright, which the final content check catches - (the decoy geometry is a single helium atom, trivially distinguishable - from the water geometries actually being merged). - """ - decoy_model = ModelRow.find_or_create(target, program="decoy", method="decoy") - decoy_calculation = CalculationRow(model=decoy_model, calc_type=CalcType.OPT) - target.add(decoy_calculation) - decoy_geometry = GeometryRow( - symbols=["He"], coordinates=np.zeros((1, 3)), charge=0, spin=0 - ) - target.add(decoy_geometry) - target.flush() - decoy_stationary = StationaryPointRow( - calculation_id=decoy_calculation.id, geometry_id=decoy_geometry.id - ) - target.add(decoy_stationary) - target.commit() - target.add(StageRow(stationaries=[decoy_stationary])) - target.commit() - - model = ModelRow.find_or_create(source, program="orca", method="xtb") - calculation = CalculationRow(model=model, calc_type=CalcType.OPT) - source.add(calculation) - source.flush() - - geometry1 = _water_geometry() - geometry2 = GeometryRow( - symbols=["O", "H", "H"], - coordinates=np.array([[0.0, 0.0, 0.0], [1.1, 0.0, 0.0], [-0.3, 1.0, 0.0]]), - charge=0, - spin=0, - ) - source.add(geometry1) - source.add(geometry2) - source.flush() - - stationary1 = StationaryPointRow( - calculation_id=calculation.id, geometry_id=geometry1.id - ) - stationary2 = StationaryPointRow( - calculation_id=calculation.id, geometry_id=geometry2.id - ) - source.add(stationary1) - source.add(stationary2) - source.commit() - - stage1 = StageRow(stationaries=[stationary1]) - stage2 = StageRow(stationaries=[stationary2]) - source.add(stage1) - source.add(stage2) - source.commit() - - StepRow.find_or_create(source, stage1, stage2) - - target.merge_from(source) - - merged_steps = target.exec_all(select(StepRow)) - assert len(merged_steps) == 1 - (merged_step,) = merged_steps - assert merged_step.stage_id_ts is None - - merged_geometries = { - stationary.geometry.coordinates.tobytes() - for stage_id in (merged_step.stage_id1, merged_step.stage_id2) - for stationary in target.get(StageRow, stage_id).stationaries # ty:ignore[invalid-argument-type] - } - assert merged_geometries == { - geometry1.coordinates.tobytes(), - geometry2.coordinates.tobytes(), - } - - -# --- ModelRow/IdentityRow dedup ---------------------------------------------- - - -def test__model_dedup_distinguishes_null_basis( - target: Database, source: Database -) -> None: - """Test that a differing basis is kept distinct, matching basis=None is reused.""" - ModelRow.find_or_create(target, program="orca", method="xtb") - before = len(target.exec_all(select(ModelRow))) - - ModelRow.find_or_create(source, program="orca", method="xtb") - ModelRow.find_or_create(source, program="orca", method="xtb", basis="def2-svp") - - report = target.merge_from(source) - - assert report.reused["model"] == 1 - assert report.copied["model"] == 1 - assert len(target.exec_all(select(ModelRow))) == before + report.copied["model"] - - -# --- GeometryRow dedup -------------------------------------------------------- - - -def test__geometry_dedup_reuses_identical_geometry( - target: Database, source: Database -) -> None: - """Test that an identical geometry is reused, and dependents resolve to it.""" - target_geometry = _water_geometry() - target.add(target_geometry) - target.commit() - before = len(target.exec_all(select(GeometryRow))) - - _add_water_stationary(source) - - report = target.merge_from(source) - - assert report.reused["geometry"] == 1 - assert report.copied["geometry"] == 0 - assert len(target.exec_all(select(GeometryRow))) == before - - (merged_stationary,) = target.exec_all(select(StationaryPointRow)) - assert merged_stationary.geometry_id == target_geometry.id - - -# --- Identity/events interaction --------------------------------------------- - - -def test__conformer_and_inchi_collapse_across_merged_databases( - target: Database, source: Database -) -> None: - """Test that a matching conformer from source and target share one identity.""" - stationary_t = _add_water_stationary(target) - _add_water_stationary(source) - - target.merge_from(source) - - stationaries = target.exec_all(select(StationaryPointRow)) - merged_stationary = next(s for s in stationaries if s.id != stationary_t.id) - - conformer_t = stationary_t.identity(kind=Algorithm.IRMSD.kind) - conformer_merged = merged_stationary.identity(kind=Algorithm.IRMSD.kind) - assert conformer_t is not None - assert conformer_merged is not None - assert conformer_t.id == conformer_merged.id - - inchi_t = stationary_t.identity(algorithm=Algorithm.RDKIT_INCHI) - inchi_merged = merged_stationary.identity(algorithm=Algorithm.RDKIT_INCHI) - assert inchi_t is not None - assert inchi_merged is not None - assert inchi_t.id == inchi_merged.id - - -def test__smiles_extra_and_identity_links_not_duplicated( - target: Database, source: Database -) -> None: - """Test that the auto-attached SMILES extra/links aren't duplicated by a merge.""" - _add_water_stationary(target) - _add_water_stationary(source) - - target.merge_from(source) - - (extra,) = target.exec_all(select(IdentityExtraRow)) - assert extra.attribute == "smiles" - - # Each stationary point shares the same two identities (InChI + conformer) - # rather than getting its own duplicate pair. - stationaries = target.exec_all(select(StationaryPointRow)) - shared_identity_kinds = {Algorithm.RDKIT_INCHI.kind, Algorithm.IRMSD.kind} - links = target.exec_all(select(StationaryIdentityLink)) - assert len(links) == len(stationaries) * len(shared_identity_kinds) - - -def test__non_auto_managed_identity_is_explicitly_deduped( - target: Database, source: Database -) -> None: - """Test that an identity kind other than InChI/conformer is find-or-created. - - `Algorithm.RDKIT_SMILES` is never persisted as a standalone `IdentityRow` - by any event (it's only ever folded into an `IdentityExtraRow`), so this - exercises the "explicit copy" branch of identity handling directly, - using a manually-attached identity the way a caller outside the - InChI/conformer auto-generation path might. - """ - stationary_t = _add_water_stationary(target) - smiles_t = IdentityRow.find_or_create( - target, algorithm=Algorithm.RDKIT_SMILES, value="O" - ) - assert stationary_t.id is not None - assert smiles_t.id is not None - target.add( - StationaryIdentityLink(stationary_id=stationary_t.id, identity_id=smiles_t.id) - ) - target.commit() - - stationary_s = _add_water_stationary(source) - smiles_s = IdentityRow.find_or_create( - source, algorithm=Algorithm.RDKIT_SMILES, value="O" - ) - assert stationary_s.id is not None - assert smiles_s.id is not None - source.add( - StationaryIdentityLink(stationary_id=stationary_s.id, identity_id=smiles_s.id) - ) - source.commit() - - report = target.merge_from(source) - - assert report.reused["identity"] == 1 - smiles_rows = [ - identity - for identity in target.exec_all(select(IdentityRow)) - if identity.algorithm == Algorithm.RDKIT_SMILES - ] - assert len(smiles_rows) == 1 - - -# --- Atomicity ----------------------------------------------------------- - - -def test__failed_merge_rolls_back_earlier_tiers( - target: Database, source: Database -) -> None: - """Test that a validation failure partway through leaves target unchanged. - - The invalid Hessian is planted via raw SQL rather than the ORM, since - `source`'s own shape-check event would otherwise reject it immediately - -- this simulates a stale/hand-edited source file, exactly the case - merge-time validation exists to catch. - """ - ModelRow.find_or_create(source, program="new-program", method="xtb") - - model = ModelRow.find_or_create(source, program="orca", method="xtb") - calculation = CalculationRow(model=model, calc_type=CalcType.FREQUENCY) - source.add(calculation) - geometry = _water_geometry() - source.add(geometry) - source.commit() - - bad_value = CompressedArrayTypeDecorator(dtype=np.float32).process_bind_param( - np.zeros((2, 2)), None - ) - with source.engine.begin() as conn: - conn.execute( - text( - "INSERT INTO hessian (geometry_id, calculation_id, value) " - "VALUES (:geo, :calc, :value)" - ), - {"geo": geometry.id, "calc": calculation.id, "value": bad_value}, - ) - - with pytest.raises(ResultShapeError): - target.merge_from(source) - - assert target.exec_all(select(ModelRow)) == [] - assert target.exec_all(select(HessianRow)) == [] - - -def test__commit_false_leaves_merge_uncommitted( - target: Database, source: Database -) -> None: - """Test that commit=False flushes (assigning ids) but doesn't commit.""" - ModelRow.find_or_create(source, program="orca", method="xtb") - - report = target.merge_from(source, commit=False) - - assert report.copied["model"] == 1 - (model,) = target.exec_all(select(ModelRow)) - assert model.id is not None - - target._session.rollback() # noqa: SLF001 - assert target.exec_all(select(ModelRow)) == [] - - -# --- Generic ordering (private-API safety net) -------------------------- - - -def test__ordered_models_covers_every_table() -> None: - """Test that `_ordered_models` maps every table to a class. - - Guards against `SQLModel._sa_registry` (a private attribute) silently - dropping tables after a SQLModel upgrade. - """ - assert len(_ordered_models()) == len(SQLModel.metadata.tables) - - -def test__ordered_models_is_topologically_valid() -> None: - """Test that every model's FK targets appear earlier in the returned order.""" - models = _ordered_models() - position = {cls: i for i, cls in enumerate(models)} - for cls in models: - for _, target_cls in _fk_targets(cls): - assert position[target_cls] < position[cls] From 79a0059a2b0f38071d6850cf4069e24da6f2ac65 Mon Sep 17 00:00:00 2001 From: "Troy N. Smith" Date: Thu, 30 Jul 2026 00:16:07 -0400 Subject: [PATCH 5/8] Separated `models.py` into `models` module --- src/autostorage/models.py | 1263 ---------------------------- src/autostorage/models/__init__.py | 47 ++ src/autostorage/models/calc.py | 246 ++++++ src/autostorage/models/core.py | 134 +++ src/autostorage/models/data.py | 124 +++ src/autostorage/models/geom.py | 136 +++ src/autostorage/models/link.py | 243 ++++++ src/autostorage/models/rxn.py | 450 ++++++++++ src/autostorage/models/traj.py | 31 + tests/test_models.py | 2 +- 10 files changed, 1412 insertions(+), 1264 deletions(-) delete mode 100644 src/autostorage/models.py create mode 100644 src/autostorage/models/__init__.py create mode 100644 src/autostorage/models/calc.py create mode 100644 src/autostorage/models/core.py create mode 100644 src/autostorage/models/data.py create mode 100644 src/autostorage/models/geom.py create mode 100644 src/autostorage/models/link.py create mode 100644 src/autostorage/models/rxn.py create mode 100644 src/autostorage/models/traj.py diff --git a/src/autostorage/models.py b/src/autostorage/models.py deleted file mode 100644 index 81302c3..0000000 --- a/src/autostorage/models.py +++ /dev/null @@ -1,1263 +0,0 @@ -"""SQLModel row definitions for autostorage's persistence schema.""" - -import hashlib -import json -from datetime import datetime -from functools import cached_property -from typing import TYPE_CHECKING, Any, Self, dataclass_transform - -import numpy as np -from automol import Algorithm, Geometry, Identity, geom -from automol.utils.types import FloatArray -from sqlalchemy import inspect as sa_inspect -from sqlalchemy import text -from sqlmodel import ( - JSON, - CheckConstraint, - Column, - Enum, - Field, - Index, - Relationship, - SQLModel, - UniqueConstraint, - func, - select, -) -from sqlmodel.main import SQLModelConfig - -from autostorage.exc import MissingPrimaryKeyError - -from .types import CalcStatus, CalcType, CompressedArrayTypeDecorator, Role - -if TYPE_CHECKING: - from .database import Database - - -def _fk_field(target: str, *, nullable: bool = False, index: bool = True) -> Any: # noqa: ANN401 - """Build a standard foreign-key Field with ON DELETE CASCADE.""" - return Field( - default=None, - foreign_key=target, - ondelete="CASCADE", - nullable=nullable, - index=index, - ) - - -@dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) -class TimestampMixin(SQLModel): - """Mixin adding server-managed creation/update timestamps. - - Annotated as `datetime | None` since the value is unset in Python until the - database fills it in via `server_default`/`onupdate`; `nullable=False` - overrides the `NULL`-by-default column that an Optional annotation would - otherwise produce, since the DB always has a value once the row is flushed. - """ - - created_at: datetime | None = Field( - default=None, - nullable=False, - sa_column_kwargs={"server_default": func.now()}, - ) - updated_at: datetime | None = Field( - default=None, - nullable=False, - sa_column_kwargs={"server_default": func.now(), "onupdate": func.now()}, - ) - - -@dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) -class BaseRow(TimestampMixin, SQLModel): - """Base for models with a primary ID.""" - - id: int | None = Field(default=None, primary_key=True) - - -class BaseResultRow(BaseRow): - """Base for result models.""" - - geometry_id: int | None - - @classmethod - def query( - cls, - db: "Database", - *, - geo: "GeometryRow", - model: "ModelRow", - prov: dict[str, Any] | None = None, - ) -> Self | None: - """Query for result matching geometry, model, and provenance.""" - if not geo.id or not model.id: - raise MissingPrimaryKeyError([geo, model]) - - prov = prov or {} - stmt = ( - select(cls) - .join(CalculationRow) - .where( - cls.geometry_id == geo.id, - CalculationRow.model_id == model.id, - CalculationRow.input_provenance == prov, - ) - ) - return db.exec_first(stmt) - - -@dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) -class BaseLink(SQLModel): - """Base for models without a primary ID.""" - - @classmethod - def create(cls, *rows: BaseRow, **attrs: object) -> Self: - """Construct a link, matching each row to its relationship by type. - - Parameters - ---------- - *rows - The rows to link (e.g. a ``GeometryRow`` and a ``CalculationRow``), - in any order. - **attrs - Extra attributes to set on the link (e.g. ``role``). - - Returns - ------- - Self - The constructed (unsaved) link row. - """ - relationships = sa_inspect(cls, raiseerr=True).relationships - fields: dict[str, BaseRow] = {} - for row in rows: - matches = [ - rel.key - for rel in relationships - if rel.key not in fields and isinstance(row, rel.mapper.class_) - ] - if not matches: - msg = f"{cls.__name__} has no unmatched relationship for {row!r}." - raise ValueError(msg) - if len(matches) > 1: - # Ambiguous: two+ unfilled relationships share this row's type, - # so matching by type alone can't tell them apart (e.g. a link - # table with two relationships to the same row model). Raise - # rather than silently picking one by declaration order. - msg = ( - f"{cls.__name__} has multiple unmatched relationships " - f"{matches} for {row!r}; construct this link directly instead." - ) - raise ValueError(msg) - fields[matches[0]] = row - return cls(**fields, **attrs) - - -def _geometry_hash( - symbols: list[str], coordinates: FloatArray, charge: int, spin: int -) -> str: - """Compute a hash identifying bit-identical geometry content.""" - hasher = hashlib.sha256() - hasher.update(json.dumps(symbols).encode()) - hasher.update(np.asarray(coordinates, dtype=np.float64).tobytes()) - hasher.update(charge.to_bytes(8, "big", signed=True)) - hasher.update(spin.to_bytes(8, "big", signed=True)) - return hasher.hexdigest() - - -# Geometry table -class GeometryRow(BaseRow, table=True): - """Molecular geometry definition and metadata. - - Attributes - ---------- - symbols - Atomic symbols in order. - coordinates - Atomic coordinates in Angstrom. - charge - Total molecular charge. - spin - Number of unpaired electrons (2S). - geometry_hash - Content hash of `symbols`/`coordinates`/`charge`/`spin`, used to reject - exactly-duplicate geometries (see `find_or_create`). - energies - Energy results computed at this geometry. - gradients - Gradient results computed at this geometry. - hessians - Hessian results computed at this geometry. - stationary_points - Stationary points defined by this geometry. - trajectory_links - Raw link rows connecting this geometry to trajectories. - calculation_links - Raw link rows connecting this geometry to calculations. - """ - - __tablename__ = "geometry" - __table_args__ = (UniqueConstraint("geometry_hash", name="unique_geometry_hash"),) - model_config = SQLModelConfig(arbitrary_types_allowed=True) - - symbols: list[str] = Field(sa_column=Column(JSON)) - coordinates: FloatArray = Field(sa_column=Column(CompressedArrayTypeDecorator())) - charge: int - spin: int - geometry_hash: str | None = Field(default=None, nullable=False) - - energies: list["EnergyRow"] = Relationship(back_populates="geometry") - gradients: list["GradientRow"] = Relationship(back_populates="geometry") - hessians: list["HessianRow"] = Relationship(back_populates="geometry") - stationary_points: list["StationaryPointRow"] = Relationship( - back_populates="geometry" - ) - trajectory_links: list["TrajectoryGeometryLink"] = Relationship( - back_populates="geometry" - ) - calculation_links: list["CalculationGeometryLink"] = Relationship( - back_populates="geometry" - ) - - def to_geometry(self) -> Geometry: - """Convert to an automol Geometry instance.""" - return Geometry( - symbols=self.symbols, - coordinates=self.coordinates, - charge=self.charge, - spin=self.spin, - ) - - @classmethod - def find_or_create( # noqa: PLR0913 - cls, - db: "Database", - *, - symbols: list[str], - coordinates: FloatArray, - charge: int, - spin: int, - commit: bool = True, - ) -> Self: - """Return the matching geometry row, creating and saving one if absent. - - Matches on exact content via `geometry_hash`, so this only reuses - bit-identical geometries. - - Parameters - ---------- - commit, optional - If True (default), commit a newly-created row immediately. If - False, only flush it (still assigns `.id`), leaving the caller's - transaction open — for a caller staging several dedup lookups - that must succeed or fail together. - """ - geometry_hash = _geometry_hash(symbols, coordinates, charge, spin) - stmt = select(cls).where(cls.geometry_hash == geometry_hash) - existing = db.exec_first(stmt) - if existing is not None: - return existing - - row = cls(symbols=symbols, coordinates=coordinates, charge=charge, spin=spin) - db.add(row) - if commit: - db.commit() - else: - db.flush() - return row - - -# Result tables -class EnergyRow(BaseResultRow, table=True): - """Energy result for a specific geometry and calculation. - - Attributes - ---------- - geometry_id - Foreign key to the geometry this energy was evaluated at. - calculation_id - Foreign key to the calculation that produced this energy. - value - Energy value in Hartree. - geometry - Geometry this energy was evaluated at. - calculation - Calculation that produced this energy. - """ - - __tablename__ = "energy" - - geometry_id: int | None = _fk_field("geometry.id") - calculation_id: int | None = _fk_field("calculation.id") - value: float - - calculation: "CalculationRow" = Relationship() - geometry: "GeometryRow" = Relationship(back_populates="energies") - - -class GradientRow(BaseResultRow, table=True): - """Energy gradient result for a specific geometry and calculation. - - Attributes - ---------- - geometry_id - Foreign key to the geometry this gradient was evaluated at. - calculation_id - Foreign key to the calculation that produced this gradient. - value - Flattened gradient vector in Hartree/Bohr. - geometry - Geometry this gradient was evaluated at. - calculation - Calculation that produced this gradient. - """ - - __tablename__ = "gradient" - model_config = SQLModelConfig(arbitrary_types_allowed=True) - - geometry_id: int | None = _fk_field("geometry.id") - calculation_id: int | None = _fk_field("calculation.id") - value: FloatArray = Field(sa_column=Column(CompressedArrayTypeDecorator())) - - calculation: "CalculationRow" = Relationship() - geometry: "GeometryRow" = Relationship(back_populates="gradients") - - -class HessianRow(BaseResultRow, table=True): - """Hessian result for a specific geometry and calculation. - - Attributes - ---------- - geometry_id - Foreign key to the geometry this Hessian was evaluated at. - calculation_id - Foreign key to the calculation that produced this Hessian. - value - Hessian matrix in Hartree/Bohr^2. - geometry - Geometry this Hessian was evaluated at. - calculation - Calculation that produced this Hessian. - """ - - __tablename__ = "hessian" - model_config = SQLModelConfig(arbitrary_types_allowed=True) - - geometry_id: int | None = _fk_field("geometry.id") - calculation_id: int | None = _fk_field("calculation.id") - - value: np.ndarray = Field( - sa_column=Column(CompressedArrayTypeDecorator(dtype=np.float32)) - ) - - calculation: "CalculationRow" = Relationship() - geometry: "GeometryRow" = Relationship(back_populates="hessians") - - @cached_property - def harmonic_frequencies(self) -> tuple[float, ...]: - """Harmonic frequencies derived from the Hessian. - - Cached per instance, since vibrational analysis re-diagonalizes the - Hessian on every call and `.order` (used by `_recompute_geometry_ - stationary_validity` for every sibling Hessian of a geometry, on - every relevant flush) depends on it. Invalidated on `value` update - by `invalidate_hessian_frequency_cache` in `events.py`. - """ - freqs, _ = geom.vibrational_analysis( - geo=self.geometry.to_geometry(), hess=self.value - ) - return freqs - - @property - def order(self) -> int: - """Hessian order.""" - return sum(1 for f in self.harmonic_frequencies if f < 0.0) - - -# Trajectory table -class TrajectoryRow(BaseRow, table=True): - """Ordered sequence of geometries from a calculation trajectory. - - Attributes - ---------- - geometry_links - Raw link rows connecting geometries to this trajectory. - calculation_links - Raw link rows connecting calculations to this trajectory. - """ - - __tablename__ = "trajectory" - - geometry_links: list["TrajectoryGeometryLink"] = Relationship( - back_populates="trajectory" - ) - calculation_links: list["CalculationTrajectoryLink"] = Relationship( - back_populates="trajectory" - ) - - -class TrajectoryGeometryLink(BaseLink, table=True): - """Association table linking geometries to a trajectory. - - Attributes - ---------- - geometry_id - Foreign key to the linked geometry. - trajectory_id - Foreign key to the linked trajectory. - index - Position of the geometry within the trajectory. - geometry - The linked geometry. - trajectory - The linked trajectory. - """ - - __tablename__ = "trajectory_geometry_link" - __table_args__ = ( - Index("ix_trajectory_geometry_link_trajectory_id", "trajectory_id"), - ) - - geometry_id: int | None = Field( - default=None, - foreign_key="geometry.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - trajectory_id: int | None = Field( - default=None, - foreign_key="trajectory.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - index: list[int] | None = Field(default=None, sa_column=Column(JSON)) - - geometry: "GeometryRow" = Relationship(back_populates="trajectory_links") - trajectory: "TrajectoryRow" = Relationship(back_populates="geometry_links") - - -# Link tables declared here, ahead of the StationaryPointRow/IdentityRow and -# StationaryPointRow/StageRow entities they connect, because SQLModel's -# `link_model=` kwarg needs the actual class object at class-body-evaluation -# time — unlike every other cross-model reference in this file, it can't be -# satisfied by a lazily-resolved string forward ref. -class StationaryIdentityLink(BaseLink, table=True): - """Association table linking stationary points to chemical identities. - - Attributes - ---------- - stationary_id - Foreign key to the linked stationary point. - identity_id - Foreign key to the linked identity. - """ - - __tablename__ = "stationary_identity_link" - __table_args__ = (Index("ix_stationary_identity_link_identity_id", "identity_id"),) - - stationary_id: int = Field( - foreign_key="stationary_point.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - identity_id: int = Field( - foreign_key="identity.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - - -class StationaryStageLink(BaseLink, table=True): - """Association table linking stationary points to reaction stages. - - Attributes - ---------- - stationary_id - Foreign key to the linked stationary point. - stage_id - Foreign key to the linked reaction stage. - stationary - The linked stationary point. - stage - The linked reaction stage. - """ - - __tablename__ = "stationary_stage_link" - __table_args__ = (Index("ix_stationary_stage_link_stage_id", "stage_id"),) - - stationary_id: int | None = Field( - default=None, - foreign_key="stationary_point.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - stage_id: int | None = Field( - default=None, - foreign_key="stage.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - - -# Stationary point rows -class StationaryPointRow(BaseRow, table=True): - """A stationary point on a potential energy surface. - - Attributes - ---------- - geometry_id - Foreign key to the underlying molecular geometry. - calculation_id - Foreign key to the calculation that identified this point. - order - Hessian index (0 for minima, 1 for first-order saddle points). - is_pseudo - Whether this point is not a true stationary point (e.g. constrained). - is_valid - Whether `order` agrees with the consensus order of its geometry's - Hessians (see `autostorage.events.revalidate_geometry_orders_on_insert_update`). - geometry - Geometry defining the coordinates of this point. - calculation - Calculation that identified this point. - identities - Chemical identifiers (e.g. InChI, SMILES) for this point. - stages - Reaction stages this stationary point belongs to. - """ - - __tablename__ = "stationary_point" - - geometry_id: int | None = _fk_field("geometry.id") - calculation_id: int | None = _fk_field("calculation.id") - order: int = 0 - is_pseudo: bool = False - is_valid: bool = False - - geometry: "GeometryRow" = Relationship(back_populates="stationary_points") - calculation: "CalculationRow" = Relationship() - identities: list["IdentityRow"] = Relationship( - back_populates="stationary_points", link_model=StationaryIdentityLink - ) - stages: list["StageRow"] = Relationship( - back_populates="stationaries", link_model=StationaryStageLink - ) - - @classmethod - def query( - cls, - db: "Database", - *, - ident: Identity, - model: "ModelRow | None" = None, - prov: dict[Any, Any] | None = None, - calc_type: CalcType | None = None, - ) -> Self | None: - """Query for stationary point matching geometry, model, and provenance.""" - stmt = ( - select(cls) - .join( - StationaryIdentityLink, - cls.id == StationaryIdentityLink.stationary_id, # ty:ignore[invalid-argument-type] - ) - .join( - IdentityRow, - IdentityRow.id == StationaryIdentityLink.identity_id, # ty:ignore[invalid-argument-type] - ) - .where( - IdentityRow.kind == ident.kind, - IdentityRow.algorithm == ident.algorithm, - IdentityRow.value == ident.value, - ) - ) - - if model or prov or calc_type: - stmt = stmt.join( - CalculationRow, - cls.calculation_id == CalculationRow.id, # ty:ignore[invalid-argument-type] - ) - - if model: - if not model.id: - raise MissingPrimaryKeyError([model]) - stmt = stmt.where(CalculationRow.model_id == model.id) - - if prov: - stmt = stmt.where(CalculationRow.input_provenance == prov) - - if calc_type: - stmt = stmt.where(CalculationRow.calc_type == calc_type) - - return db.exec_first(stmt) - - def identity( - self, - *, - kind: str | None = None, - algorithm: Any | None = None, # noqa: ANN401 - ) -> "IdentityRow | None": - """Return the first loaded identity matching kind and/or algorithm. - - Searches `self.identities` (the already-loaded relationship list), - not the database — use `StationaryPointRow.query` for a DB lookup. - """ - return next( - ( - i - for i in self.identities - if (kind is None or i.kind == kind) - and (algorithm is None or i.algorithm == algorithm) - ), - None, - ) - - -class IdentityRow(BaseRow, Identity, table=True): - """A chemical identifier associated with one or more stationary points. - - Attributes - ---------- - kind - Category of identifier (e.g. ``stereoisomer``, ``formula``). - algorithm - Method used to generate the identifier (e.g. ``rdkit inchi``, ``rdkit smiles``). - value - The resulting identifier string. - stationary_points - Stationary points sharing this identity. - identity_extras - Additional key-value metadata attached to this identity. - """ - - __tablename__ = "identity" - __table_args__ = ( - UniqueConstraint("kind", "algorithm", "value", name="unique_identity"), - ) - - stationary_points: list["StationaryPointRow"] = Relationship( - back_populates="identities", link_model=StationaryIdentityLink - ) - identity_extras: list["IdentityExtraRow"] = Relationship(back_populates="identity") - - @classmethod - def find_or_create( - cls, - db: "Database", - *, - algorithm: Algorithm, - value: str, - commit: bool = True, - ) -> Self: - """Return the matching identity row, creating and saving one if absent. - - `kind` isn't a parameter here since it's fully determined by - `algorithm` (see `Identity.from_value`), so matching on - `(algorithm, value)` is equivalent to `unique_identity`'s full - `(kind, algorithm, value)` constraint. - - Parameters - ---------- - commit, optional - If True (default), commit a newly-created row immediately. If - False, only flush it (still assigns `.id`), leaving the caller's - transaction open — for a caller staging several dedup lookups - that must succeed or fail together. - """ - stmt = select(cls).where(cls.algorithm == algorithm, cls.value == value) - existing = db.exec_first(stmt) - if existing is not None: - return existing - - row = cls.from_value(value, algorithm=algorithm) - db.add(row) - if commit: - db.commit() - else: - db.flush() - return row - - -class IdentityExtraRow(BaseRow, table=True): - """Additional key-value metadata attached to a chemical identity. - - Attributes - ---------- - identity_id - Foreign key to the parent identity. - attribute - Name of the extra attribute. - value - Value of the extra attribute. - identity - The parent identity this extra belongs to. - """ - - __tablename__ = "identity_extras" - - identity_id: int | None = Field( - default=None, - foreign_key="identity.id", - ondelete="CASCADE", - nullable=False, - index=True, - ) - - attribute: str - value: str - - identity: "IdentityRow" = Relationship(back_populates="identity_extras") - - -# Reaction rows -class StageRow(BaseRow, table=True): - """A chemical state (reactant, product, or transition state) in a reaction. - - Attributes - ---------- - is_ts - Whether this stage represents a transition state. - stationaries - Stationary points that make up this stage. - steps - Reaction steps referencing this stage as `stage1`, `stage2`, or - `stage_ts` (read-only; derived from `StepRow`'s foreign keys). - """ - - __tablename__ = "stage" - - is_ts: bool = False - - stationaries: list["StationaryPointRow"] = Relationship( - back_populates="stages", link_model=StationaryStageLink - ) - steps: list["StepRow"] = Relationship( - sa_relationship_kwargs={ - "primaryjoin": "or_(" - "StageRow.id == StepRow.stage_id1, " - "StageRow.id == StepRow.stage_id2, " - "StageRow.id == StepRow.stage_id_ts" - ")", - "viewonly": True, - } - ) - - @classmethod - def query( - cls, - db: "Database", - stationaries: list["StationaryPointRow"], - *, - is_ts: bool = False, - ) -> Self | None: - """Query for existing stage with stationaries.""" - target_ids = [s.id for s in stationaries] - if len(target_ids) != len(stationaries): - raise MissingPrimaryKeyError(list(stationaries)) - - stmt = ( - select(cls) - .join(StationaryStageLink) - .where(cls.is_ts == is_ts) - .group_by(cls.id) # ty:ignore[invalid-argument-type] - .having( - func.count(StationaryStageLink.stationary_id) == len(target_ids), # ty:ignore[invalid-argument-type] - func.count( - func.nullif( - StationaryStageLink.stationary_id.in_(target_ids), # ty:ignore[unresolved-attribute] - False, # noqa: FBT003 - ) - ) - == len(target_ids), - ) - ) - return db.exec_first(stmt) - - @classmethod - def find_or_create( - cls, - db: "Database", - stationaries: list["StationaryPointRow"], - *, - is_ts: bool = False, - ) -> Self: - """Return the matching stage row, creating and saving one if absent. - - Note - ---- - Unlike `ModelRow`/`StepRow`, there is no DB-level uniqueness - constraint backing this dedup, so it relies entirely on - `StageRow.query`'s app-level lookup. - """ - existing = cls.query(db, stationaries, is_ts=is_ts) - if existing is not None: - return existing - - row = cls(stationaries=stationaries, is_ts=is_ts) - db.add(row) - db.commit() - return row - - -# Declared here, ahead of StepRow, for the same `link_model=` reason as -# StationaryIdentityLink/StationaryStageLink above. -class StepValidationLink(BaseLink, table=True): - """Association table linking validations to a step. - - Attributes - ---------- - step_id - Foreign key to the linked step. - validation_id - Foreign key to the linked validation. - """ - - __tablename__ = "step_validation_link" - __table_args__ = (Index("ix_step_validation_link_validation_id", "validation_id"),) - - step_id: int = Field( - foreign_key="step.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - validation_id: int = Field( - foreign_key="validation.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - - -class StepRow(BaseRow, table=True): - """An elementary reaction step connecting a reactant, transition state, and product. - - Attributes - ---------- - stage_id1, stage_id2 - Foreign keys to the step's two non-TS stages (stored with - `stage_id1 < stage_id2`). - stage_id_ts - Foreign key to the step's transition-state stage, or `None` for a - barrierless step. - is_barrierless - Whether this step proceeds without a formal transition state. - stage1, stage2 - The step's two non-TS stages. - stage_ts - The step's transition-state stage, or `None` if barrierless. - validations - Validation calculations performed on this step. - """ - - __tablename__ = "step" - __table_args__ = ( - UniqueConstraint( - "stage_id1", "stage_id2", "stage_id_ts", name="unq_step_stages" - ), - CheckConstraint("stage_id1 < stage_id2", name="chk_stage_order"), - # `unq_step_stages` doesn't catch duplicate barrierless steps (stage_id_ts - # NULL), since SQL never treats NULL as equal to itself in a unique - # constraint. This expression index closes that gap at the DB level, - # defense-in-depth alongside `StepRow.query`'s app-level lookup. - Index( - "unq_step_stages_null_safe", - "stage_id1", - "stage_id2", - text("coalesce(stage_id_ts, 0)"), - unique=True, - ), - # `stage_id1` is already covered as the leading column of the two indexes - # above, but is indexed explicitly here too for symmetry/clarity. - Index("ix_step_stage_id1", "stage_id1"), - Index("ix_step_stage_id2", "stage_id2"), - Index("ix_step_stage_id_ts", "stage_id_ts"), - ) - - stage_id1: int | None = Field( - default=None, - foreign_key="stage.id", - ondelete="CASCADE", - nullable=False, - ) - stage_id2: int | None = Field( - default=None, - foreign_key="stage.id", - ondelete="CASCADE", - nullable=False, - ) - stage_id_ts: int | None = Field( - default=None, - foreign_key="stage.id", - ondelete="CASCADE", - ) - - is_barrierless: bool = False - - validations: list["ValidationRow"] = Relationship( - back_populates="step", link_model=StepValidationLink - ) - - stage1: "StageRow" = Relationship( - sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id1]"} - ) - stage2: "StageRow" = Relationship( - sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id2]"} - ) - stage_ts: "StageRow" = Relationship( - sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id_ts]"} - ) - - @classmethod - def query( - cls, - db: "Database", - stage1: "StageRow", - stage2: "StageRow", - stage_ts: "StageRow | None" = None, - ) -> Self | None: - """Query for an existing step connecting specific stages.""" - if not stage1.id or not stage2.id or (stage_ts and not stage_ts.id): - raise MissingPrimaryKeyError( - [s for s in [stage1, stage2, stage_ts] if s is not None] - ) - - # Enforce the database CheckConstraint: stage_id1 < stage_id2 - id1, id2 = sorted([stage1.id, stage2.id]) - ts_id = stage_ts.id if stage_ts else None - - stmt = select(cls).where( - cls.stage_id1 == id1, - cls.stage_id2 == id2, - cls.stage_id_ts == ts_id, - ) - return db.exec_first(stmt) - - @classmethod - def find_or_create( - cls, - db: "Database", - stage1: "StageRow", - stage2: "StageRow", - stage_ts: "StageRow | None" = None, - ) -> Self: - """Return the matching step row, creating and saving one if absent.""" - existing = cls.query(db, stage1, stage2, stage_ts) - if existing is not None: - return existing - - row = cls(stage1=stage1, stage2=stage2, stage_ts=stage_ts) - db.add(row) - db.commit() - return row - - -# Calculation rows -class ModelRow(BaseRow, table=True): - """Calculation model specification. - - Attributes - ---------- - program - Quantum chemistry program used (psi4, ORCA, ...) - program_version - Quantum chemistry program version. - method - Computational method (B3LYP, MP2, ...) - basis - Orbital basis set. - """ - - __tablename__ = "model" - __table_args__ = ( - UniqueConstraint( - "program", - "program_version", - "method", - "basis", - name="unique_model", - ), - # `unique_model` doesn't catch duplicates when `program_version` or `basis` - # is NULL (see `find_or_create` below). This expression index closes that - # gap at the DB level, defense-in-depth alongside the app-level lookup. - Index( - "unique_model_null_safe", - "program", - text("coalesce(program_version, '')"), - "method", - text("coalesce(basis, '')"), - unique=True, - ), - ) - - program: str - program_version: str | None = None - method: str - basis: str | None = None - - @classmethod - def find_or_create( # noqa: PLR0913 - cls, - db: "Database", - *, - program: str, - method: str, - program_version: str | None = None, - basis: str | None = None, - commit: bool = True, - ) -> Self: - """Return the matching model row, creating and saving one if absent. - - ``unique_model`` doesn't catch duplicates when ``program_version`` - or ``basis`` is NULL, since SQL treats NULL as distinct from itself - in unique constraints. Callers that don't always supply both should - use this instead of constructing and adding a ``ModelRow`` directly, - to avoid silently accumulating duplicate rows for the same model. - - Parameters - ---------- - commit, optional - If True (default), commit a newly-created row immediately. If - False, only flush it (still assigns `.id`), leaving the caller's - transaction open — for a caller staging several dedup lookups - that must succeed or fail together. - """ - stmt = select(cls).where( - cls.program == program, - cls.program_version == program_version, - cls.method == method, - cls.basis == basis, - ) - existing = db.exec_first(stmt) - if existing is not None: - return existing - - row = cls( - program=program, - program_version=program_version, - method=method, - basis=basis, - ) - db.add(row) - if commit: - db.commit() - else: - db.flush() - return row - - -class CalculationRow(BaseRow, table=True): - """Quantum chemistry calculation and its associated data. - - Attributes - ---------- - model_id - Foreign key to the model used for this calculation. - calc_type - Type of calculation performed. - status - Lifecycle status of this calculation. - error_message - Error message recorded for a failed calculation, if any. - input_provenance - Metadata describing how the input was generated. - output_provenance - Metadata describing how the output was produced. - model - Model used for this calculation. - geometry_links - Raw link rows connecting geometries to this calculation. - trajectory_links - Raw link rows connecting trajectories to this calculation. - """ - - __tablename__ = "calculation" - - model_id: int | None = Field( - default=None, - foreign_key="model.id", - ondelete="CASCADE", - nullable=False, - index=True, - ) - calc_type: CalcType = Field( - sa_column=Column(Enum(CalcType, values_callable=lambda x: [e.value for e in x])) - ) - status: CalcStatus = Field( - default=CalcStatus.PENDING, - sa_column=Column( - Enum(CalcStatus, values_callable=lambda x: [e.value for e in x]) - ), - ) - error_message: str | None = Field(default=None) - # Intentionally unbounded free-form JSON; add a size/schema guardrail if - # these are ever populated from a less-trusted input path. - input_provenance: dict[str, Any] | None = Field( - default_factory=dict, sa_column=Column(JSON) - ) - output_provenance: dict[str, Any] | None = Field( - default_factory=dict, sa_column=Column(JSON) - ) - - model: "ModelRow" = Relationship() - geometry_links: list["CalculationGeometryLink"] = Relationship( - back_populates="calculation" - ) - trajectory_links: list["CalculationTrajectoryLink"] = Relationship( - back_populates="calculation" - ) - - @property - def input_geometries(self) -> list["GeometryRow"]: - """Geometries linked to this calculation with an INPUT role.""" - return [ - link.geometry for link in self.geometry_links if link.role == Role.INPUT - ] - - @property - def output_geometries(self) -> list["GeometryRow"]: - """Geometries linked to this calculation with an OUTPUT role.""" - return [ - link.geometry for link in self.geometry_links if link.role == Role.OUTPUT - ] - - @property - def input_trajectories(self) -> list["TrajectoryRow"]: - """Trajectories linked to this calculation with an INPUT role.""" - return [ - link.trajectory for link in self.trajectory_links if link.role == Role.INPUT - ] - - @property - def output_trajectories(self) -> list["TrajectoryRow"]: - """Trajectories linked to this calculation with an OUTPUT role.""" - return [ - link.trajectory - for link in self.trajectory_links - if link.role == Role.OUTPUT - ] - - -class CalculationGeometryLink(BaseLink, table=True): - """Association table linking geometries to a calculation. - - Attributes - ---------- - geometry_id - Foreign key to the linked geometry. - calculation_id - Foreign key to the linked calculation. - role - Role the geometry plays for this calculation (input/output). - geometry - The linked geometry. - calculation - The linked calculation. - """ - - __tablename__ = "calculation_geometry_link" - __table_args__ = ( - # The composite primary key only serves lookups keyed by `geometry_id` - # (its leading column); this adds a matching index for `calculation_id`. - Index("ix_calculation_geometry_link_calculation_id", "calculation_id"), - ) - - geometry_id: int | None = Field( - default=None, - foreign_key="geometry.id", - ondelete="CASCADE", - nullable=False, - primary_key=True, - ) - calculation_id: int | None = Field( - default=None, - foreign_key="calculation.id", - ondelete="CASCADE", - nullable=False, - primary_key=True, - ) - role: Role = Field( - sa_column=Column(Enum(Role, values_callable=lambda x: [e.value for e in x])) - ) - - geometry: "GeometryRow" = Relationship(back_populates="calculation_links") - calculation: "CalculationRow" = Relationship(back_populates="geometry_links") - - -class CalculationTrajectoryLink(BaseLink, table=True): - """Association table linking trajectories to a calculation. - - Attributes - ---------- - trajectory_id - Foreign key to the linked trajectory. - calculation_id - Foreign key to the linked calculation. - role - Role the trajectory plays for this calculation (input/output). - trajectory - The linked trajectory. - calculation - The linked calculation. - """ - - __tablename__ = "calculation_trajectory_link" - __table_args__ = ( - Index("ix_calculation_trajectory_link_calculation_id", "calculation_id"), - ) - - trajectory_id: int | None = Field( - default=None, - foreign_key="trajectory.id", - ondelete="CASCADE", - nullable=False, - primary_key=True, - ) - calculation_id: int | None = Field( - default=None, - foreign_key="calculation.id", - ondelete="CASCADE", - nullable=False, - primary_key=True, - ) - role: Role = Field( - sa_column=Column(Enum(Role, values_callable=lambda x: [e.value for e in x])) - ) - - trajectory: "TrajectoryRow" = Relationship(back_populates="calculation_links") - calculation: "CalculationRow" = Relationship(back_populates="trajectory_links") - - -class ValidationRow(BaseRow, table=True): - """Validation result for a specific step and calculation. - - Attributes - ---------- - calculation_id - Foreign key to the calculation that performed this validation. - method - Type of validation step (e.g., ``irc``) - extras - Additional metadata attached to this validation. - calculation - Calculation that performed this validation. - step - Reaction step this validation belongs to. - """ - - __tablename__ = "validation" - - calculation_id: int | None = _fk_field("calculation.id") - - method: str - # Intentionally unbounded free-form JSON; add a size/schema guardrail if - # this is ever populated from a less-trusted input path. - extras: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) - - calculation: "CalculationRow" = Relationship() - step: "StepRow" = Relationship( - back_populates="validations", link_model=StepValidationLink - ) diff --git a/src/autostorage/models/__init__.py b/src/autostorage/models/__init__.py new file mode 100644 index 0000000..26afaed --- /dev/null +++ b/src/autostorage/models/__init__.py @@ -0,0 +1,47 @@ +"""SQLModel row definitions for autostorage's persistence schema.""" + +from sqlmodel import SQLModel + +from .calc import CalculationRow, ModelRow, ValidationRow +from .core import BaseLink, BaseResultRow, BaseRow, TimestampMixin, _fk_field +from .data import EnergyRow, GradientRow, HessianRow +from .geom import GeometryRow, _geometry_hash +from .link import ( + CalculationGeometryLink, + CalculationTrajectoryLink, + StationaryIdentityLink, + StationaryStageLink, + StepValidationLink, + TrajectoryGeometryLink, +) +from .rxn import IdentityExtraRow, IdentityRow, StageRow, StationaryPointRow, StepRow +from .traj import TrajectoryRow + +__all__ = [ + "BaseLink", + "BaseResultRow", + "BaseRow", + "CalculationGeometryLink", + "CalculationRow", + "CalculationTrajectoryLink", + "EnergyRow", + "GeometryRow", + "GradientRow", + "HessianRow", + "IdentityExtraRow", + "IdentityRow", + "ModelRow", + "SQLModel", + "StageRow", + "StationaryIdentityLink", + "StationaryPointRow", + "StationaryStageLink", + "StepRow", + "StepValidationLink", + "TimestampMixin", + "TrajectoryGeometryLink", + "TrajectoryRow", + "ValidationRow", + "_fk_field", + "_geometry_hash", +] diff --git a/src/autostorage/models/calc.py b/src/autostorage/models/calc.py new file mode 100644 index 0000000..199e4bc --- /dev/null +++ b/src/autostorage/models/calc.py @@ -0,0 +1,246 @@ +"""Calculation-related row definitions: model, calculation, validation.""" + +from typing import TYPE_CHECKING, Any, Self + +from sqlmodel import ( + JSON, + Column, + Enum, + Field, + Index, + Relationship, + UniqueConstraint, + select, + text, +) + +from autostorage.types import CalcStatus, CalcType, Role + +from .core import BaseRow, _fk_field +from .link import StepValidationLink + +if TYPE_CHECKING: + from autostorage.database import Database + + from .geom import GeometryRow + from .link import CalculationGeometryLink, CalculationTrajectoryLink + from .rxn import StepRow + from .traj import TrajectoryRow + + +# Calculation rows +class ModelRow(BaseRow, table=True): + """Calculation model specification. + + Attributes + ---------- + program + Quantum chemistry program used (psi4, ORCA, ...) + program_version + Quantum chemistry program version. + method + Computational method (B3LYP, MP2, ...) + basis + Orbital basis set. + """ + + __tablename__ = "model" + __table_args__ = ( + UniqueConstraint( + "program", + "program_version", + "method", + "basis", + name="unique_model", + ), + # `unique_model` doesn't catch duplicates when `program_version` or `basis` + # is NULL (see `find_or_create` below). This expression index closes that + # gap at the DB level, defense-in-depth alongside the app-level lookup. + Index( + "unique_model_null_safe", + "program", + text("coalesce(program_version, '')"), + "method", + text("coalesce(basis, '')"), + unique=True, + ), + ) + + program: str + program_version: str | None = None + method: str + basis: str | None = None + + @classmethod + def find_or_create( # noqa: PLR0913 + cls, + db: "Database", + *, + program: str, + method: str, + program_version: str | None = None, + basis: str | None = None, + commit: bool = True, + ) -> Self: + """Return the matching model row, creating and saving one if absent. + + ``unique_model`` doesn't catch duplicates when ``program_version`` + or ``basis`` is NULL, since SQL treats NULL as distinct from itself + in unique constraints. Callers that don't always supply both should + use this instead of constructing and adding a ``ModelRow`` directly, + to avoid silently accumulating duplicate rows for the same model. + + Parameters + ---------- + commit, optional + If True (default), commit a newly-created row immediately. If + False, only flush it (still assigns `.id`), leaving the caller's + transaction open — for a caller staging several dedup lookups + that must succeed or fail together. + """ + stmt = select(cls).where( + cls.program == program, + cls.program_version == program_version, + cls.method == method, + cls.basis == basis, + ) + existing = db.exec_first(stmt) + if existing is not None: + return existing + + row = cls( + program=program, + program_version=program_version, + method=method, + basis=basis, + ) + db.add(row) + if commit: + db.commit() + else: + db.flush() + return row + + +class CalculationRow(BaseRow, table=True): + """Quantum chemistry calculation and its associated data. + + Attributes + ---------- + model_id + Foreign key to the model used for this calculation. + calc_type + Type of calculation performed. + status + Lifecycle status of this calculation. + error_message + Error message recorded for a failed calculation, if any. + input_provenance + Metadata describing how the input was generated. + output_provenance + Metadata describing how the output was produced. + model + Model used for this calculation. + geometry_links + Raw link rows connecting geometries to this calculation. + trajectory_links + Raw link rows connecting trajectories to this calculation. + """ + + __tablename__ = "calculation" + + model_id: int | None = Field( + default=None, + foreign_key="model.id", + ondelete="CASCADE", + nullable=False, + index=True, + ) + calc_type: CalcType = Field( + sa_column=Column(Enum(CalcType, values_callable=lambda x: [e.value for e in x])) + ) + status: CalcStatus = Field( + default=CalcStatus.PENDING, + sa_column=Column( + Enum(CalcStatus, values_callable=lambda x: [e.value for e in x]) + ), + ) + error_message: str | None = Field(default=None) + # Intentionally unbounded free-form JSON; add a size/schema guardrail if + # these are ever populated from a less-trusted input path. + input_provenance: dict[str, Any] | None = Field( + default_factory=dict, sa_column=Column(JSON) + ) + output_provenance: dict[str, Any] | None = Field( + default_factory=dict, sa_column=Column(JSON) + ) + + model: "ModelRow" = Relationship() + geometry_links: list["CalculationGeometryLink"] = Relationship( + back_populates="calculation" + ) + trajectory_links: list["CalculationTrajectoryLink"] = Relationship( + back_populates="calculation" + ) + + @property + def input_geometries(self) -> list["GeometryRow"]: + """Geometries linked to this calculation with an INPUT role.""" + return [ + link.geometry for link in self.geometry_links if link.role == Role.INPUT + ] + + @property + def output_geometries(self) -> list["GeometryRow"]: + """Geometries linked to this calculation with an OUTPUT role.""" + return [ + link.geometry for link in self.geometry_links if link.role == Role.OUTPUT + ] + + @property + def input_trajectories(self) -> list["TrajectoryRow"]: + """Trajectories linked to this calculation with an INPUT role.""" + return [ + link.trajectory for link in self.trajectory_links if link.role == Role.INPUT + ] + + @property + def output_trajectories(self) -> list["TrajectoryRow"]: + """Trajectories linked to this calculation with an OUTPUT role.""" + return [ + link.trajectory + for link in self.trajectory_links + if link.role == Role.OUTPUT + ] + + +class ValidationRow(BaseRow, table=True): + """Validation result for a specific step and calculation. + + Attributes + ---------- + calculation_id + Foreign key to the calculation that performed this validation. + method + Type of validation step (e.g., ``irc``) + extras + Additional metadata attached to this validation. + calculation + Calculation that performed this validation. + step + Reaction step this validation belongs to. + """ + + __tablename__ = "validation" + + calculation_id: int | None = _fk_field("calculation.id") + + method: str + # Intentionally unbounded free-form JSON; add a size/schema guardrail if + # this is ever populated from a less-trusted input path. + extras: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + + calculation: "CalculationRow" = Relationship() + step: "StepRow" = Relationship( + back_populates="validations", link_model=StepValidationLink + ) diff --git a/src/autostorage/models/core.py b/src/autostorage/models/core.py new file mode 100644 index 0000000..264b002 --- /dev/null +++ b/src/autostorage/models/core.py @@ -0,0 +1,134 @@ +"""Base row/link classes shared across all row modules.""" + +from datetime import datetime +from typing import TYPE_CHECKING, Any, Self, dataclass_transform + +from sqlalchemy import inspect as sa_inspect +from sqlmodel import Field, SQLModel, func, select + +from autostorage.exc import MissingPrimaryKeyError + +if TYPE_CHECKING: + from autostorage.database import Database + + from .calc import ModelRow + from .geom import GeometryRow + + +def _fk_field(target: str, *, nullable: bool = False, index: bool = True) -> Any: # noqa: ANN401 + """Build a standard foreign-key Field with ON DELETE CASCADE.""" + return Field( + default=None, + foreign_key=target, + ondelete="CASCADE", + nullable=nullable, + index=index, + ) + + +@dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) +class TimestampMixin(SQLModel): + """Mixin adding server-managed creation/update timestamps. + + Annotated as `datetime | None` since the value is unset in Python until the + database fills it in via `server_default`/`onupdate`; `nullable=False` + overrides the `NULL`-by-default column that an Optional annotation would + otherwise produce, since the DB always has a value once the row is flushed. + """ + + created_at: datetime | None = Field( + default=None, + nullable=False, + sa_column_kwargs={"server_default": func.now()}, + ) + updated_at: datetime | None = Field( + default=None, + nullable=False, + sa_column_kwargs={"server_default": func.now(), "onupdate": func.now()}, + ) + + +@dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) +class BaseRow(TimestampMixin, SQLModel): + """Base for models with a primary ID.""" + + id: int | None = Field(default=None, primary_key=True) + + +class BaseResultRow(BaseRow): + """Base for result models.""" + + geometry_id: int | None + + @classmethod + def query( + cls, + db: "Database", + *, + geo: "GeometryRow", + model: "ModelRow", + prov: dict[str, Any] | None = None, + ) -> Self | None: + """Query for result matching geometry, model, and provenance.""" + from .calc import CalculationRow # noqa: PLC0415 + + if not geo.id or not model.id: + raise MissingPrimaryKeyError([geo, model]) + + prov = prov or {} + stmt = ( + select(cls) + .join(CalculationRow) + .where( + cls.geometry_id == geo.id, + CalculationRow.model_id == model.id, + CalculationRow.input_provenance == prov, + ) + ) + return db.exec_first(stmt) + + +@dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) +class BaseLink(SQLModel): + """Base for models without a primary ID.""" + + @classmethod + def create(cls, *rows: BaseRow, **attrs: object) -> Self: + """Construct a link, matching each row to its relationship by type. + + Parameters + ---------- + *rows + The rows to link (e.g. a ``GeometryRow`` and a ``CalculationRow``), + in any order. + **attrs + Extra attributes to set on the link (e.g. ``role``). + + Returns + ------- + Self + The constructed (unsaved) link row. + """ + relationships = sa_inspect(cls, raiseerr=True).relationships + fields: dict[str, BaseRow] = {} + for row in rows: + matches = [ + rel.key + for rel in relationships + if rel.key not in fields and isinstance(row, rel.mapper.class_) + ] + if not matches: + msg = f"{cls.__name__} has no unmatched relationship for {row!r}." + raise ValueError(msg) + if len(matches) > 1: + # Ambiguous: two+ unfilled relationships share this row's type, + # so matching by type alone can't tell them apart (e.g. a link + # table with two relationships to the same row model). Raise + # rather than silently picking one by declaration order. + msg = ( + f"{cls.__name__} has multiple unmatched relationships " + f"{matches} for {row!r}; construct this link directly instead." + ) + raise ValueError(msg) + fields[matches[0]] = row + return cls(**fields, **attrs) diff --git a/src/autostorage/models/data.py b/src/autostorage/models/data.py new file mode 100644 index 0000000..90f24cc --- /dev/null +++ b/src/autostorage/models/data.py @@ -0,0 +1,124 @@ +"""Result row definitions (energy, gradient, Hessian).""" + +from functools import cached_property +from typing import TYPE_CHECKING + +import numpy as np +from automol import geom +from automol.utils.types import FloatArray +from sqlmodel import Column, Field, Relationship +from sqlmodel.main import SQLModelConfig + +from autostorage.types import CompressedArrayTypeDecorator + +from .core import BaseResultRow, _fk_field + +if TYPE_CHECKING: + from .calc import CalculationRow + from .geom import GeometryRow + + +class EnergyRow(BaseResultRow, table=True): + """Energy result for a specific geometry and calculation. + + Attributes + ---------- + geometry_id + Foreign key to the geometry this energy was evaluated at. + calculation_id + Foreign key to the calculation that produced this energy. + value + Energy value in Hartree. + geometry + Geometry this energy was evaluated at. + calculation + Calculation that produced this energy. + """ + + __tablename__ = "energy" + + geometry_id: int | None = _fk_field("geometry.id") + calculation_id: int | None = _fk_field("calculation.id") + value: float + + calculation: "CalculationRow" = Relationship() + geometry: "GeometryRow" = Relationship(back_populates="energies") + + +class GradientRow(BaseResultRow, table=True): + """Energy gradient result for a specific geometry and calculation. + + Attributes + ---------- + geometry_id + Foreign key to the geometry this gradient was evaluated at. + calculation_id + Foreign key to the calculation that produced this gradient. + value + Flattened gradient vector in Hartree/Bohr. + geometry + Geometry this gradient was evaluated at. + calculation + Calculation that produced this gradient. + """ + + __tablename__ = "gradient" + model_config = SQLModelConfig(arbitrary_types_allowed=True) + + geometry_id: int | None = _fk_field("geometry.id") + calculation_id: int | None = _fk_field("calculation.id") + value: FloatArray = Field(sa_column=Column(CompressedArrayTypeDecorator())) + + calculation: "CalculationRow" = Relationship() + geometry: "GeometryRow" = Relationship(back_populates="gradients") + + +class HessianRow(BaseResultRow, table=True): + """Hessian result for a specific geometry and calculation. + + Attributes + ---------- + geometry_id + Foreign key to the geometry this Hessian was evaluated at. + calculation_id + Foreign key to the calculation that produced this Hessian. + value + Hessian matrix in Hartree/Bohr^2. + geometry + Geometry this Hessian was evaluated at. + calculation + Calculation that produced this Hessian. + """ + + __tablename__ = "hessian" + model_config = SQLModelConfig(arbitrary_types_allowed=True) + + geometry_id: int | None = _fk_field("geometry.id") + calculation_id: int | None = _fk_field("calculation.id") + + value: np.ndarray = Field( + sa_column=Column(CompressedArrayTypeDecorator(dtype=np.float32)) + ) + + calculation: "CalculationRow" = Relationship() + geometry: "GeometryRow" = Relationship(back_populates="hessians") + + @cached_property + def harmonic_frequencies(self) -> tuple[float, ...]: + """Harmonic frequencies derived from the Hessian. + + Cached per instance, since vibrational analysis re-diagonalizes the + Hessian on every call and `.order` (used by `_recompute_geometry_ + stationary_validity` for every sibling Hessian of a geometry, on + every relevant flush) depends on it. Invalidated on `value` update + by `invalidate_hessian_frequency_cache` in `events.py`. + """ + freqs, _ = geom.vibrational_analysis( + geo=self.geometry.to_geometry(), hess=self.value + ) + return freqs + + @property + def order(self) -> int: + """Hessian order.""" + return sum(1 for f in self.harmonic_frequencies if f < 0.0) diff --git a/src/autostorage/models/geom.py b/src/autostorage/models/geom.py new file mode 100644 index 0000000..2e6637c --- /dev/null +++ b/src/autostorage/models/geom.py @@ -0,0 +1,136 @@ +"""Molecular geometry row definition.""" + +import hashlib +import json +from typing import TYPE_CHECKING, Self + +import numpy as np +from automol import Geometry +from automol.utils.types import FloatArray +from sqlmodel import JSON, Column, Field, Relationship, UniqueConstraint, select +from sqlmodel.main import SQLModelConfig + +from autostorage.types import CompressedArrayTypeDecorator + +from .core import BaseRow + +if TYPE_CHECKING: + from autostorage.database import Database + + from .data import EnergyRow, GradientRow, HessianRow + from .link import CalculationGeometryLink, TrajectoryGeometryLink + from .rxn import StationaryPointRow + + +def _geometry_hash( + symbols: list[str], coordinates: FloatArray, charge: int, spin: int +) -> str: + """Compute a hash identifying bit-identical geometry content.""" + hasher = hashlib.sha256() + hasher.update(json.dumps(symbols).encode()) + hasher.update(np.asarray(coordinates, dtype=np.float64).tobytes()) + hasher.update(charge.to_bytes(8, "big", signed=True)) + hasher.update(spin.to_bytes(8, "big", signed=True)) + return hasher.hexdigest() + + +# Geometry table +class GeometryRow(BaseRow, table=True): + """Molecular geometry definition and metadata. + + Attributes + ---------- + symbols + Atomic symbols in order. + coordinates + Atomic coordinates in Angstrom. + charge + Total molecular charge. + spin + Number of unpaired electrons (2S). + geometry_hash + Content hash of `symbols`/`coordinates`/`charge`/`spin`, used to reject + exactly-duplicate geometries (see `find_or_create`). + energies + Energy results computed at this geometry. + gradients + Gradient results computed at this geometry. + hessians + Hessian results computed at this geometry. + stationary_points + Stationary points defined by this geometry. + trajectory_links + Raw link rows connecting this geometry to trajectories. + calculation_links + Raw link rows connecting this geometry to calculations. + """ + + __tablename__ = "geometry" + __table_args__ = (UniqueConstraint("geometry_hash", name="unique_geometry_hash"),) + model_config = SQLModelConfig(arbitrary_types_allowed=True) + + symbols: list[str] = Field(sa_column=Column(JSON)) + coordinates: FloatArray = Field(sa_column=Column(CompressedArrayTypeDecorator())) + charge: int + spin: int + geometry_hash: str | None = Field(default=None, nullable=False) + + energies: list["EnergyRow"] = Relationship(back_populates="geometry") + gradients: list["GradientRow"] = Relationship(back_populates="geometry") + hessians: list["HessianRow"] = Relationship(back_populates="geometry") + stationary_points: list["StationaryPointRow"] = Relationship( + back_populates="geometry" + ) + trajectory_links: list["TrajectoryGeometryLink"] = Relationship( + back_populates="geometry" + ) + calculation_links: list["CalculationGeometryLink"] = Relationship( + back_populates="geometry" + ) + + def to_geometry(self) -> Geometry: + """Convert to an automol Geometry instance.""" + return Geometry( + symbols=self.symbols, + coordinates=self.coordinates, + charge=self.charge, + spin=self.spin, + ) + + @classmethod + def find_or_create( # noqa: PLR0913 + cls, + db: "Database", + *, + symbols: list[str], + coordinates: FloatArray, + charge: int, + spin: int, + commit: bool = True, + ) -> Self: + """Return the matching geometry row, creating and saving one if absent. + + Matches on exact content via `geometry_hash`, so this only reuses + bit-identical geometries. + + Parameters + ---------- + commit, optional + If True (default), commit a newly-created row immediately. If + False, only flush it (still assigns `.id`), leaving the caller's + transaction open — for a caller staging several dedup lookups + that must succeed or fail together. + """ + geometry_hash = _geometry_hash(symbols, coordinates, charge, spin) + stmt = select(cls).where(cls.geometry_hash == geometry_hash) + existing = db.exec_first(stmt) + if existing is not None: + return existing + + row = cls(symbols=symbols, coordinates=coordinates, charge=charge, spin=spin) + db.add(row) + if commit: + db.commit() + else: + db.flush() + return row diff --git a/src/autostorage/models/link.py b/src/autostorage/models/link.py new file mode 100644 index 0000000..0b4c046 --- /dev/null +++ b/src/autostorage/models/link.py @@ -0,0 +1,243 @@ +"""Association tables linking row entities together.""" + +from typing import TYPE_CHECKING + +from sqlmodel import JSON, Column, Enum, Field, Index, Relationship + +from autostorage.types import Role + +from .core import BaseLink + +if TYPE_CHECKING: + from .calc import CalculationRow + from .geom import GeometryRow + from .traj import TrajectoryRow + + +class TrajectoryGeometryLink(BaseLink, table=True): + """Association table linking geometries to a trajectory. + + Attributes + ---------- + geometry_id + Foreign key to the linked geometry. + trajectory_id + Foreign key to the linked trajectory. + index + Position of the geometry within the trajectory. + geometry + The linked geometry. + trajectory + The linked trajectory. + """ + + __tablename__ = "trajectory_geometry_link" + __table_args__ = ( + Index("ix_trajectory_geometry_link_trajectory_id", "trajectory_id"), + ) + + geometry_id: int | None = Field( + default=None, + foreign_key="geometry.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + trajectory_id: int | None = Field( + default=None, + foreign_key="trajectory.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + index: list[int] | None = Field(default=None, sa_column=Column(JSON)) + + geometry: "GeometryRow" = Relationship(back_populates="trajectory_links") + trajectory: "TrajectoryRow" = Relationship(back_populates="geometry_links") + + +# Link tables declared here, ahead of the StationaryPointRow/IdentityRow and +# StationaryPointRow/StageRow entities they connect, because SQLModel's +# `link_model=` kwarg needs the actual class object at class-body-evaluation +# time — unlike every other cross-model reference in this file, it can't be +# satisfied by a lazily-resolved string forward ref. +class StationaryIdentityLink(BaseLink, table=True): + """Association table linking stationary points to chemical identities. + + Attributes + ---------- + stationary_id + Foreign key to the linked stationary point. + identity_id + Foreign key to the linked identity. + """ + + __tablename__ = "stationary_identity_link" + __table_args__ = (Index("ix_stationary_identity_link_identity_id", "identity_id"),) + + stationary_id: int = Field( + foreign_key="stationary_point.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + identity_id: int = Field( + foreign_key="identity.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + + +class StationaryStageLink(BaseLink, table=True): + """Association table linking stationary points to reaction stages. + + Attributes + ---------- + stationary_id + Foreign key to the linked stationary point. + stage_id + Foreign key to the linked reaction stage. + stationary + The linked stationary point. + stage + The linked reaction stage. + """ + + __tablename__ = "stationary_stage_link" + __table_args__ = (Index("ix_stationary_stage_link_stage_id", "stage_id"),) + + stationary_id: int | None = Field( + default=None, + foreign_key="stationary_point.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + stage_id: int | None = Field( + default=None, + foreign_key="stage.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + + +# Declared here, ahead of StepRow, for the same `link_model=` reason as +# StationaryIdentityLink/StationaryStageLink above. +class StepValidationLink(BaseLink, table=True): + """Association table linking validations to a step. + + Attributes + ---------- + step_id + Foreign key to the linked step. + validation_id + Foreign key to the linked validation. + """ + + __tablename__ = "step_validation_link" + __table_args__ = (Index("ix_step_validation_link_validation_id", "validation_id"),) + + step_id: int = Field( + foreign_key="step.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + validation_id: int = Field( + foreign_key="validation.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + + +class CalculationGeometryLink(BaseLink, table=True): + """Association table linking geometries to a calculation. + + Attributes + ---------- + geometry_id + Foreign key to the linked geometry. + calculation_id + Foreign key to the linked calculation. + role + Role the geometry plays for this calculation (input/output). + geometry + The linked geometry. + calculation + The linked calculation. + """ + + __tablename__ = "calculation_geometry_link" + __table_args__ = ( + # The composite primary key only serves lookups keyed by `geometry_id` + # (its leading column); this adds a matching index for `calculation_id`. + Index("ix_calculation_geometry_link_calculation_id", "calculation_id"), + ) + + geometry_id: int | None = Field( + default=None, + foreign_key="geometry.id", + ondelete="CASCADE", + nullable=False, + primary_key=True, + ) + calculation_id: int | None = Field( + default=None, + foreign_key="calculation.id", + ondelete="CASCADE", + nullable=False, + primary_key=True, + ) + role: Role = Field( + sa_column=Column(Enum(Role, values_callable=lambda x: [e.value for e in x])) + ) + + geometry: "GeometryRow" = Relationship(back_populates="calculation_links") + calculation: "CalculationRow" = Relationship(back_populates="geometry_links") + + +class CalculationTrajectoryLink(BaseLink, table=True): + """Association table linking trajectories to a calculation. + + Attributes + ---------- + trajectory_id + Foreign key to the linked trajectory. + calculation_id + Foreign key to the linked calculation. + role + Role the trajectory plays for this calculation (input/output). + trajectory + The linked trajectory. + calculation + The linked calculation. + """ + + __tablename__ = "calculation_trajectory_link" + __table_args__ = ( + Index("ix_calculation_trajectory_link_calculation_id", "calculation_id"), + ) + + trajectory_id: int | None = Field( + default=None, + foreign_key="trajectory.id", + ondelete="CASCADE", + nullable=False, + primary_key=True, + ) + calculation_id: int | None = Field( + default=None, + foreign_key="calculation.id", + ondelete="CASCADE", + nullable=False, + primary_key=True, + ) + role: Role = Field( + sa_column=Column(Enum(Role, values_callable=lambda x: [e.value for e in x])) + ) + + trajectory: "TrajectoryRow" = Relationship(back_populates="calculation_links") + calculation: "CalculationRow" = Relationship(back_populates="trajectory_links") diff --git a/src/autostorage/models/rxn.py b/src/autostorage/models/rxn.py new file mode 100644 index 0000000..8264424 --- /dev/null +++ b/src/autostorage/models/rxn.py @@ -0,0 +1,450 @@ +"""Reaction-related row definitions: stationary points, identities, stages, steps.""" + +from typing import TYPE_CHECKING, Any, Self + +from automol import Algorithm, Identity +from sqlmodel import ( + CheckConstraint, + Field, + Index, + Relationship, + UniqueConstraint, + func, + select, + text, +) + +from autostorage.exc import MissingPrimaryKeyError +from autostorage.types import CalcType + +from .calc import CalculationRow, ModelRow +from .core import BaseRow, _fk_field +from .link import StationaryIdentityLink, StationaryStageLink, StepValidationLink + +if TYPE_CHECKING: + from autostorage.database import Database + + from .calc import ValidationRow + from .geom import GeometryRow + + +# Stationary point rows +class StationaryPointRow(BaseRow, table=True): + """A stationary point on a potential energy surface. + + Attributes + ---------- + geometry_id + Foreign key to the underlying molecular geometry. + calculation_id + Foreign key to the calculation that identified this point. + order + Hessian index (0 for minima, 1 for first-order saddle points). + is_pseudo + Whether this point is not a true stationary point (e.g. constrained). + is_valid + Whether `order` agrees with the consensus order of its geometry's + Hessians (see `autostorage.events.revalidate_geometry_orders_on_insert_update`). + geometry + Geometry defining the coordinates of this point. + calculation + Calculation that identified this point. + identities + Chemical identifiers (e.g. InChI, SMILES) for this point. + stages + Reaction stages this stationary point belongs to. + """ + + __tablename__ = "stationary_point" + + geometry_id: int | None = _fk_field("geometry.id") + calculation_id: int | None = _fk_field("calculation.id") + order: int = 0 + is_pseudo: bool = False + is_valid: bool = False + + geometry: "GeometryRow" = Relationship(back_populates="stationary_points") + calculation: "CalculationRow" = Relationship() + identities: list["IdentityRow"] = Relationship( + back_populates="stationary_points", link_model=StationaryIdentityLink + ) + stages: list["StageRow"] = Relationship( + back_populates="stationaries", link_model=StationaryStageLink + ) + + @classmethod + def query( + cls, + db: "Database", + *, + ident: Identity, + model: "ModelRow | None" = None, + prov: dict[Any, Any] | None = None, + calc_type: CalcType | None = None, + ) -> Self | None: + """Query for stationary point matching geometry, model, and provenance.""" + stmt = ( + select(cls) + .join( + StationaryIdentityLink, + cls.id == StationaryIdentityLink.stationary_id, # ty:ignore[invalid-argument-type] + ) + .join( + IdentityRow, + IdentityRow.id == StationaryIdentityLink.identity_id, # ty:ignore[invalid-argument-type] + ) + .where( + IdentityRow.kind == ident.kind, + IdentityRow.algorithm == ident.algorithm, + IdentityRow.value == ident.value, + ) + ) + + if model or prov or calc_type: + stmt = stmt.join( + CalculationRow, + cls.calculation_id == CalculationRow.id, # ty:ignore[invalid-argument-type] + ) + + if model: + if not model.id: + raise MissingPrimaryKeyError([model]) + stmt = stmt.where(CalculationRow.model_id == model.id) + + if prov: + stmt = stmt.where(CalculationRow.input_provenance == prov) + + if calc_type: + stmt = stmt.where(CalculationRow.calc_type == calc_type) + + return db.exec_first(stmt) + + def identity( + self, + *, + kind: str | None = None, + algorithm: Any | None = None, # noqa: ANN401 + ) -> "IdentityRow | None": + """Return the first loaded identity matching kind and/or algorithm. + + Searches `self.identities` (the already-loaded relationship list), + not the database — use `StationaryPointRow.query` for a DB lookup. + """ + return next( + ( + i + for i in self.identities + if (kind is None or i.kind == kind) + and (algorithm is None or i.algorithm == algorithm) + ), + None, + ) + + +class IdentityRow(BaseRow, Identity, table=True): + """A chemical identifier associated with one or more stationary points. + + Attributes + ---------- + kind + Category of identifier (e.g. ``stereoisomer``, ``formula``). + algorithm + Method used to generate the identifier (e.g. ``rdkit inchi``, ``rdkit smiles``). + value + The resulting identifier string. + stationary_points + Stationary points sharing this identity. + identity_extras + Additional key-value metadata attached to this identity. + """ + + __tablename__ = "identity" + __table_args__ = ( + UniqueConstraint("kind", "algorithm", "value", name="unique_identity"), + ) + + stationary_points: list["StationaryPointRow"] = Relationship( + back_populates="identities", link_model=StationaryIdentityLink + ) + identity_extras: list["IdentityExtraRow"] = Relationship(back_populates="identity") + + @classmethod + def find_or_create( + cls, + db: "Database", + *, + algorithm: Algorithm, + value: str, + commit: bool = True, + ) -> Self: + """Return the matching identity row, creating and saving one if absent. + + `kind` isn't a parameter here since it's fully determined by + `algorithm` (see `Identity.from_value`), so matching on + `(algorithm, value)` is equivalent to `unique_identity`'s full + `(kind, algorithm, value)` constraint. + + Parameters + ---------- + commit, optional + If True (default), commit a newly-created row immediately. If + False, only flush it (still assigns `.id`), leaving the caller's + transaction open — for a caller staging several dedup lookups + that must succeed or fail together. + """ + stmt = select(cls).where(cls.algorithm == algorithm, cls.value == value) + existing = db.exec_first(stmt) + if existing is not None: + return existing + + row = cls.from_value(value, algorithm=algorithm) + db.add(row) + if commit: + db.commit() + else: + db.flush() + return row + + +class IdentityExtraRow(BaseRow, table=True): + """Additional key-value metadata attached to a chemical identity. + + Attributes + ---------- + identity_id + Foreign key to the parent identity. + attribute + Name of the extra attribute. + value + Value of the extra attribute. + identity + The parent identity this extra belongs to. + """ + + __tablename__ = "identity_extras" + + identity_id: int | None = Field( + default=None, + foreign_key="identity.id", + ondelete="CASCADE", + nullable=False, + index=True, + ) + + attribute: str + value: str + + identity: "IdentityRow" = Relationship(back_populates="identity_extras") + + +# Reaction rows +class StageRow(BaseRow, table=True): + """A chemical state (reactant, product, or transition state) in a reaction. + + Attributes + ---------- + is_ts + Whether this stage represents a transition state. + stationaries + Stationary points that make up this stage. + steps + Reaction steps referencing this stage as `stage1`, `stage2`, or + `stage_ts` (read-only; derived from `StepRow`'s foreign keys). + """ + + __tablename__ = "stage" + + is_ts: bool = False + + stationaries: list["StationaryPointRow"] = Relationship( + back_populates="stages", link_model=StationaryStageLink + ) + steps: list["StepRow"] = Relationship( + sa_relationship_kwargs={ + "primaryjoin": "or_(" + "StageRow.id == StepRow.stage_id1, " + "StageRow.id == StepRow.stage_id2, " + "StageRow.id == StepRow.stage_id_ts" + ")", + "viewonly": True, + } + ) + + @classmethod + def query( + cls, + db: "Database", + stationaries: list["StationaryPointRow"], + *, + is_ts: bool = False, + ) -> Self | None: + """Query for existing stage with stationaries.""" + target_ids = [s.id for s in stationaries] + if len(target_ids) != len(stationaries): + raise MissingPrimaryKeyError(list(stationaries)) + + stmt = ( + select(cls) + .join(StationaryStageLink) + .where(cls.is_ts == is_ts) + .group_by(cls.id) # ty:ignore[invalid-argument-type] + .having( + func.count(StationaryStageLink.stationary_id) == len(target_ids), # ty:ignore[invalid-argument-type] + func.count( + func.nullif( + StationaryStageLink.stationary_id.in_(target_ids), # ty:ignore[unresolved-attribute] + False, # noqa: FBT003 + ) + ) + == len(target_ids), + ) + ) + return db.exec_first(stmt) + + @classmethod + def find_or_create( + cls, + db: "Database", + stationaries: list["StationaryPointRow"], + *, + is_ts: bool = False, + ) -> Self: + """Return the matching stage row, creating and saving one if absent. + + Note + ---- + Unlike `ModelRow`/`StepRow`, there is no DB-level uniqueness + constraint backing this dedup, so it relies entirely on + `StageRow.query`'s app-level lookup. + """ + existing = cls.query(db, stationaries, is_ts=is_ts) + if existing is not None: + return existing + + row = cls(stationaries=stationaries, is_ts=is_ts) + db.add(row) + db.commit() + return row + + +class StepRow(BaseRow, table=True): + """An elementary reaction step connecting a reactant, transition state, and product. + + Attributes + ---------- + stage_id1, stage_id2 + Foreign keys to the step's two non-TS stages (stored with + `stage_id1 < stage_id2`). + stage_id_ts + Foreign key to the step's transition-state stage, or `None` for a + barrierless step. + is_barrierless + Whether this step proceeds without a formal transition state. + stage1, stage2 + The step's two non-TS stages. + stage_ts + The step's transition-state stage, or `None` if barrierless. + validations + Validation calculations performed on this step. + """ + + __tablename__ = "step" + __table_args__ = ( + UniqueConstraint( + "stage_id1", "stage_id2", "stage_id_ts", name="unq_step_stages" + ), + CheckConstraint("stage_id1 < stage_id2", name="chk_stage_order"), + # `unq_step_stages` doesn't catch duplicate barrierless steps (stage_id_ts + # NULL), since SQL never treats NULL as equal to itself in a unique + # constraint. This expression index closes that gap at the DB level, + # defense-in-depth alongside `StepRow.query`'s app-level lookup. + Index( + "unq_step_stages_null_safe", + "stage_id1", + "stage_id2", + text("coalesce(stage_id_ts, 0)"), + unique=True, + ), + # `stage_id1` is already covered as the leading column of the two indexes + # above, but is indexed explicitly here too for symmetry/clarity. + Index("ix_step_stage_id1", "stage_id1"), + Index("ix_step_stage_id2", "stage_id2"), + Index("ix_step_stage_id_ts", "stage_id_ts"), + ) + + stage_id1: int | None = Field( + default=None, + foreign_key="stage.id", + ondelete="CASCADE", + nullable=False, + ) + stage_id2: int | None = Field( + default=None, + foreign_key="stage.id", + ondelete="CASCADE", + nullable=False, + ) + stage_id_ts: int | None = Field( + default=None, + foreign_key="stage.id", + ondelete="CASCADE", + ) + + is_barrierless: bool = False + + validations: list["ValidationRow"] = Relationship( + back_populates="step", link_model=StepValidationLink + ) + + stage1: "StageRow" = Relationship( + sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id1]"} + ) + stage2: "StageRow" = Relationship( + sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id2]"} + ) + stage_ts: "StageRow" = Relationship( + sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id_ts]"} + ) + + @classmethod + def query( + cls, + db: "Database", + stage1: "StageRow", + stage2: "StageRow", + stage_ts: "StageRow | None" = None, + ) -> Self | None: + """Query for an existing step connecting specific stages.""" + if not stage1.id or not stage2.id or (stage_ts and not stage_ts.id): + raise MissingPrimaryKeyError( + [s for s in [stage1, stage2, stage_ts] if s is not None] + ) + + # Enforce the database CheckConstraint: stage_id1 < stage_id2 + id1, id2 = sorted([stage1.id, stage2.id]) + ts_id = stage_ts.id if stage_ts else None + + stmt = select(cls).where( + cls.stage_id1 == id1, + cls.stage_id2 == id2, + cls.stage_id_ts == ts_id, + ) + return db.exec_first(stmt) + + @classmethod + def find_or_create( + cls, + db: "Database", + stage1: "StageRow", + stage2: "StageRow", + stage_ts: "StageRow | None" = None, + ) -> Self: + """Return the matching step row, creating and saving one if absent.""" + existing = cls.query(db, stage1, stage2, stage_ts) + if existing is not None: + return existing + + row = cls(stage1=stage1, stage2=stage2, stage_ts=stage_ts) + db.add(row) + db.commit() + return row diff --git a/src/autostorage/models/traj.py b/src/autostorage/models/traj.py new file mode 100644 index 0000000..d2a1332 --- /dev/null +++ b/src/autostorage/models/traj.py @@ -0,0 +1,31 @@ +"""Trajectory row definition.""" + +from typing import TYPE_CHECKING + +from sqlmodel import Relationship + +from .core import BaseRow + +if TYPE_CHECKING: + from .link import CalculationTrajectoryLink, TrajectoryGeometryLink + + +class TrajectoryRow(BaseRow, table=True): + """Ordered sequence of geometries from a calculation trajectory. + + Attributes + ---------- + geometry_links + Raw link rows connecting geometries to this trajectory. + calculation_links + Raw link rows connecting calculations to this trajectory. + """ + + __tablename__ = "trajectory" + + geometry_links: list["TrajectoryGeometryLink"] = Relationship( + back_populates="trajectory" + ) + calculation_links: list["CalculationTrajectoryLink"] = Relationship( + back_populates="trajectory" + ) diff --git a/tests/test_models.py b/tests/test_models.py index 09d97e4..73e75ac 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -69,7 +69,7 @@ def test__link_create_rejects_ambiguous_row_type( rel for rel in real_relationships if rel.key == "geometry" ) - with mock.patch("autostorage.models.sa_inspect") as mock_inspect: + with mock.patch("autostorage.models.core.sa_inspect") as mock_inspect: mock_inspect.return_value.relationships = [ *real_relationships, duplicate_geometry_rel, From 6b4a47b8b41fdee313fad07892d71b7291222cdb Mon Sep 17 00:00:00 2001 From: "Troy N. Smith" Date: Thu, 30 Jul 2026 00:36:30 -0400 Subject: [PATCH 6/8] Remove querying methods (temp) --- src/autostorage/exc.py | 17 +-- src/autostorage/models/calc.py | 59 +------- src/autostorage/models/core.py | 39 +----- src/autostorage/models/geom.py | 46 +------ src/autostorage/models/rxn.py | 215 ++---------------------------- tests/test_models.py | 237 +-------------------------------- 6 files changed, 21 insertions(+), 592 deletions(-) diff --git a/src/autostorage/exc.py b/src/autostorage/exc.py index 2b2850e..24ee96c 100644 --- a/src/autostorage/exc.py +++ b/src/autostorage/exc.py @@ -2,7 +2,7 @@ from typing import Self -__all__ = ["DataIntegrityError", "MissingPrimaryKeyError", "ResultShapeError"] +__all__ = ["DataIntegrityError", "ResultShapeError"] class DataIntegrityError(Exception): @@ -19,18 +19,3 @@ def __init__( class_name = model.__class__.__name__ msg = f"{class_name} shape ({actual}) does not match expected ({expected})." super().__init__(msg) - - -class MissingPrimaryKeyError(Exception): - """Raise when primary keys weren't provided to a query method.""" - - def __init__(self: Self, rows: list[object]) -> None: - row_ids = [ - f"{row.__class__.__name__}: {getattr(row, 'id', None)}" for row in rows - ] - msg = ( - f"Cannot perform operation using unpersisted database instance(s).\n" - f"Try Database.add(row) or Database.merge(row) before querying.\n" - f"({','.join(row_ids)})." - ) - super().__init__(msg) diff --git a/src/autostorage/models/calc.py b/src/autostorage/models/calc.py index 199e4bc..ed48df0 100644 --- a/src/autostorage/models/calc.py +++ b/src/autostorage/models/calc.py @@ -1,6 +1,6 @@ """Calculation-related row definitions: model, calculation, validation.""" -from typing import TYPE_CHECKING, Any, Self +from typing import TYPE_CHECKING, Any from sqlmodel import ( JSON, @@ -10,7 +10,6 @@ Index, Relationship, UniqueConstraint, - select, text, ) @@ -20,8 +19,6 @@ from .link import StepValidationLink if TYPE_CHECKING: - from autostorage.database import Database - from .geom import GeometryRow from .link import CalculationGeometryLink, CalculationTrajectoryLink from .rxn import StepRow @@ -54,8 +51,8 @@ class ModelRow(BaseRow, table=True): name="unique_model", ), # `unique_model` doesn't catch duplicates when `program_version` or `basis` - # is NULL (see `find_or_create` below). This expression index closes that - # gap at the DB level, defense-in-depth alongside the app-level lookup. + # is NULL, since SQL treats NULL as distinct from itself in unique + # constraints. This expression index closes that gap at the DB level. Index( "unique_model_null_safe", "program", @@ -71,56 +68,6 @@ class ModelRow(BaseRow, table=True): method: str basis: str | None = None - @classmethod - def find_or_create( # noqa: PLR0913 - cls, - db: "Database", - *, - program: str, - method: str, - program_version: str | None = None, - basis: str | None = None, - commit: bool = True, - ) -> Self: - """Return the matching model row, creating and saving one if absent. - - ``unique_model`` doesn't catch duplicates when ``program_version`` - or ``basis`` is NULL, since SQL treats NULL as distinct from itself - in unique constraints. Callers that don't always supply both should - use this instead of constructing and adding a ``ModelRow`` directly, - to avoid silently accumulating duplicate rows for the same model. - - Parameters - ---------- - commit, optional - If True (default), commit a newly-created row immediately. If - False, only flush it (still assigns `.id`), leaving the caller's - transaction open — for a caller staging several dedup lookups - that must succeed or fail together. - """ - stmt = select(cls).where( - cls.program == program, - cls.program_version == program_version, - cls.method == method, - cls.basis == basis, - ) - existing = db.exec_first(stmt) - if existing is not None: - return existing - - row = cls( - program=program, - program_version=program_version, - method=method, - basis=basis, - ) - db.add(row) - if commit: - db.commit() - else: - db.flush() - return row - class CalculationRow(BaseRow, table=True): """Quantum chemistry calculation and its associated data. diff --git a/src/autostorage/models/core.py b/src/autostorage/models/core.py index 264b002..1c978ca 100644 --- a/src/autostorage/models/core.py +++ b/src/autostorage/models/core.py @@ -1,18 +1,10 @@ """Base row/link classes shared across all row modules.""" from datetime import datetime -from typing import TYPE_CHECKING, Any, Self, dataclass_transform +from typing import Any, Self, dataclass_transform from sqlalchemy import inspect as sa_inspect -from sqlmodel import Field, SQLModel, func, select - -from autostorage.exc import MissingPrimaryKeyError - -if TYPE_CHECKING: - from autostorage.database import Database - - from .calc import ModelRow - from .geom import GeometryRow +from sqlmodel import Field, SQLModel, func def _fk_field(target: str, *, nullable: bool = False, index: bool = True) -> Any: # noqa: ANN401 @@ -60,33 +52,6 @@ class BaseResultRow(BaseRow): geometry_id: int | None - @classmethod - def query( - cls, - db: "Database", - *, - geo: "GeometryRow", - model: "ModelRow", - prov: dict[str, Any] | None = None, - ) -> Self | None: - """Query for result matching geometry, model, and provenance.""" - from .calc import CalculationRow # noqa: PLC0415 - - if not geo.id or not model.id: - raise MissingPrimaryKeyError([geo, model]) - - prov = prov or {} - stmt = ( - select(cls) - .join(CalculationRow) - .where( - cls.geometry_id == geo.id, - CalculationRow.model_id == model.id, - CalculationRow.input_provenance == prov, - ) - ) - return db.exec_first(stmt) - @dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) class BaseLink(SQLModel): diff --git a/src/autostorage/models/geom.py b/src/autostorage/models/geom.py index 2e6637c..87fb724 100644 --- a/src/autostorage/models/geom.py +++ b/src/autostorage/models/geom.py @@ -2,12 +2,12 @@ import hashlib import json -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING import numpy as np from automol import Geometry from automol.utils.types import FloatArray -from sqlmodel import JSON, Column, Field, Relationship, UniqueConstraint, select +from sqlmodel import JSON, Column, Field, Relationship, UniqueConstraint from sqlmodel.main import SQLModelConfig from autostorage.types import CompressedArrayTypeDecorator @@ -15,8 +15,6 @@ from .core import BaseRow if TYPE_CHECKING: - from autostorage.database import Database - from .data import EnergyRow, GradientRow, HessianRow from .link import CalculationGeometryLink, TrajectoryGeometryLink from .rxn import StationaryPointRow @@ -50,7 +48,7 @@ class GeometryRow(BaseRow, table=True): Number of unpaired electrons (2S). geometry_hash Content hash of `symbols`/`coordinates`/`charge`/`spin`, used to reject - exactly-duplicate geometries (see `find_or_create`). + exactly-duplicate geometries. energies Energy results computed at this geometry. gradients @@ -96,41 +94,3 @@ def to_geometry(self) -> Geometry: charge=self.charge, spin=self.spin, ) - - @classmethod - def find_or_create( # noqa: PLR0913 - cls, - db: "Database", - *, - symbols: list[str], - coordinates: FloatArray, - charge: int, - spin: int, - commit: bool = True, - ) -> Self: - """Return the matching geometry row, creating and saving one if absent. - - Matches on exact content via `geometry_hash`, so this only reuses - bit-identical geometries. - - Parameters - ---------- - commit, optional - If True (default), commit a newly-created row immediately. If - False, only flush it (still assigns `.id`), leaving the caller's - transaction open — for a caller staging several dedup lookups - that must succeed or fail together. - """ - geometry_hash = _geometry_hash(symbols, coordinates, charge, spin) - stmt = select(cls).where(cls.geometry_hash == geometry_hash) - existing = db.exec_first(stmt) - if existing is not None: - return existing - - row = cls(symbols=symbols, coordinates=coordinates, charge=charge, spin=spin) - db.add(row) - if commit: - db.commit() - else: - db.flush() - return row diff --git a/src/autostorage/models/rxn.py b/src/autostorage/models/rxn.py index 8264424..e030805 100644 --- a/src/autostorage/models/rxn.py +++ b/src/autostorage/models/rxn.py @@ -1,30 +1,15 @@ """Reaction-related row definitions: stationary points, identities, stages, steps.""" -from typing import TYPE_CHECKING, Any, Self - -from automol import Algorithm, Identity -from sqlmodel import ( - CheckConstraint, - Field, - Index, - Relationship, - UniqueConstraint, - func, - select, - text, -) - -from autostorage.exc import MissingPrimaryKeyError -from autostorage.types import CalcType - -from .calc import CalculationRow, ModelRow +from typing import TYPE_CHECKING, Any + +from automol import Identity +from sqlmodel import CheckConstraint, Field, Index, Relationship, UniqueConstraint, text + from .core import BaseRow, _fk_field from .link import StationaryIdentityLink, StationaryStageLink, StepValidationLink if TYPE_CHECKING: - from autostorage.database import Database - - from .calc import ValidationRow + from .calc import CalculationRow, ValidationRow from .geom import GeometryRow @@ -72,53 +57,6 @@ class StationaryPointRow(BaseRow, table=True): back_populates="stationaries", link_model=StationaryStageLink ) - @classmethod - def query( - cls, - db: "Database", - *, - ident: Identity, - model: "ModelRow | None" = None, - prov: dict[Any, Any] | None = None, - calc_type: CalcType | None = None, - ) -> Self | None: - """Query for stationary point matching geometry, model, and provenance.""" - stmt = ( - select(cls) - .join( - StationaryIdentityLink, - cls.id == StationaryIdentityLink.stationary_id, # ty:ignore[invalid-argument-type] - ) - .join( - IdentityRow, - IdentityRow.id == StationaryIdentityLink.identity_id, # ty:ignore[invalid-argument-type] - ) - .where( - IdentityRow.kind == ident.kind, - IdentityRow.algorithm == ident.algorithm, - IdentityRow.value == ident.value, - ) - ) - - if model or prov or calc_type: - stmt = stmt.join( - CalculationRow, - cls.calculation_id == CalculationRow.id, # ty:ignore[invalid-argument-type] - ) - - if model: - if not model.id: - raise MissingPrimaryKeyError([model]) - stmt = stmt.where(CalculationRow.model_id == model.id) - - if prov: - stmt = stmt.where(CalculationRow.input_provenance == prov) - - if calc_type: - stmt = stmt.where(CalculationRow.calc_type == calc_type) - - return db.exec_first(stmt) - def identity( self, *, @@ -128,7 +66,7 @@ def identity( """Return the first loaded identity matching kind and/or algorithm. Searches `self.identities` (the already-loaded relationship list), - not the database — use `StationaryPointRow.query` for a DB lookup. + not the database. """ return next( ( @@ -168,43 +106,6 @@ class IdentityRow(BaseRow, Identity, table=True): ) identity_extras: list["IdentityExtraRow"] = Relationship(back_populates="identity") - @classmethod - def find_or_create( - cls, - db: "Database", - *, - algorithm: Algorithm, - value: str, - commit: bool = True, - ) -> Self: - """Return the matching identity row, creating and saving one if absent. - - `kind` isn't a parameter here since it's fully determined by - `algorithm` (see `Identity.from_value`), so matching on - `(algorithm, value)` is equivalent to `unique_identity`'s full - `(kind, algorithm, value)` constraint. - - Parameters - ---------- - commit, optional - If True (default), commit a newly-created row immediately. If - False, only flush it (still assigns `.id`), leaving the caller's - transaction open — for a caller staging several dedup lookups - that must succeed or fail together. - """ - stmt = select(cls).where(cls.algorithm == algorithm, cls.value == value) - existing = db.exec_first(stmt) - if existing is not None: - return existing - - row = cls.from_value(value, algorithm=algorithm) - db.add(row) - if commit: - db.commit() - else: - db.flush() - return row - class IdentityExtraRow(BaseRow, table=True): """Additional key-value metadata attached to a chemical identity. @@ -270,62 +171,6 @@ class StageRow(BaseRow, table=True): } ) - @classmethod - def query( - cls, - db: "Database", - stationaries: list["StationaryPointRow"], - *, - is_ts: bool = False, - ) -> Self | None: - """Query for existing stage with stationaries.""" - target_ids = [s.id for s in stationaries] - if len(target_ids) != len(stationaries): - raise MissingPrimaryKeyError(list(stationaries)) - - stmt = ( - select(cls) - .join(StationaryStageLink) - .where(cls.is_ts == is_ts) - .group_by(cls.id) # ty:ignore[invalid-argument-type] - .having( - func.count(StationaryStageLink.stationary_id) == len(target_ids), # ty:ignore[invalid-argument-type] - func.count( - func.nullif( - StationaryStageLink.stationary_id.in_(target_ids), # ty:ignore[unresolved-attribute] - False, # noqa: FBT003 - ) - ) - == len(target_ids), - ) - ) - return db.exec_first(stmt) - - @classmethod - def find_or_create( - cls, - db: "Database", - stationaries: list["StationaryPointRow"], - *, - is_ts: bool = False, - ) -> Self: - """Return the matching stage row, creating and saving one if absent. - - Note - ---- - Unlike `ModelRow`/`StepRow`, there is no DB-level uniqueness - constraint backing this dedup, so it relies entirely on - `StageRow.query`'s app-level lookup. - """ - existing = cls.query(db, stationaries, is_ts=is_ts) - if existing is not None: - return existing - - row = cls(stationaries=stationaries, is_ts=is_ts) - db.add(row) - db.commit() - return row - class StepRow(BaseRow, table=True): """An elementary reaction step connecting a reactant, transition state, and product. @@ -356,8 +201,7 @@ class StepRow(BaseRow, table=True): CheckConstraint("stage_id1 < stage_id2", name="chk_stage_order"), # `unq_step_stages` doesn't catch duplicate barrierless steps (stage_id_ts # NULL), since SQL never treats NULL as equal to itself in a unique - # constraint. This expression index closes that gap at the DB level, - # defense-in-depth alongside `StepRow.query`'s app-level lookup. + # constraint. This expression index closes that gap at the DB level. Index( "unq_step_stages_null_safe", "stage_id1", @@ -405,46 +249,3 @@ class StepRow(BaseRow, table=True): stage_ts: "StageRow" = Relationship( sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id_ts]"} ) - - @classmethod - def query( - cls, - db: "Database", - stage1: "StageRow", - stage2: "StageRow", - stage_ts: "StageRow | None" = None, - ) -> Self | None: - """Query for an existing step connecting specific stages.""" - if not stage1.id or not stage2.id or (stage_ts and not stage_ts.id): - raise MissingPrimaryKeyError( - [s for s in [stage1, stage2, stage_ts] if s is not None] - ) - - # Enforce the database CheckConstraint: stage_id1 < stage_id2 - id1, id2 = sorted([stage1.id, stage2.id]) - ts_id = stage_ts.id if stage_ts else None - - stmt = select(cls).where( - cls.stage_id1 == id1, - cls.stage_id2 == id2, - cls.stage_id_ts == ts_id, - ) - return db.exec_first(stmt) - - @classmethod - def find_or_create( - cls, - db: "Database", - stage1: "StageRow", - stage2: "StageRow", - stage_ts: "StageRow | None" = None, - ) -> Self: - """Return the matching step row, creating and saving one if absent.""" - existing = cls.query(db, stage1, stage2, stage_ts) - if existing is not None: - return existing - - row = cls(stage1=stage1, stage2=stage2, stage_ts=stage_ts) - db.add(row) - db.commit() - return row diff --git a/tests/test_models.py b/tests/test_models.py index 73e75ac..b89884e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from automol import Algorithm, Identity +from automol import Algorithm from numpy.random import Generator from scipy.spatial.transform import Rotation from sqlalchemy import inspect as sa_inspect @@ -17,7 +17,6 @@ CalculationGeometryLink, CalculationRow, Database, - EnergyRow, GeometryRow, GradientRow, HessianRow, @@ -28,7 +27,7 @@ TrajectoryRow, ValidationRow, ) -from autostorage.exc import DataIntegrityError, MissingPrimaryKeyError, ResultShapeError +from autostorage.exc import DataIntegrityError, ResultShapeError from autostorage.models import CalculationTrajectoryLink from autostorage.types import Role @@ -110,25 +109,6 @@ def test__row_updated_at_advances_on_update(database: Database) -> None: assert row.updated_at > updated_at -def test__model_find_or_create_reuses_matching_row(database: Database) -> None: - """Test that find_or_create returns the same row for repeated calls.""" - first = ModelRow.find_or_create(database, program="orca", method="xtb") - second = ModelRow.find_or_create(database, program="orca", method="xtb") - - assert first.id is not None - assert first.id == second.id - - -def test__model_find_or_create_distinguishes_basis(database: Database) -> None: - """Test that find_or_create treats a differing basis as a distinct model.""" - no_basis = ModelRow.find_or_create(database, program="orca", method="xtb") - with_basis = ModelRow.find_or_create( - database, program="orca", method="xtb", basis="def2-svp" - ) - - assert no_basis.id != with_basis.id - - def test__model_null_safe_index_catches_duplicate(database: Database) -> None: """Test that a direct duplicate insert (bypassing find_or_create) is rejected. @@ -323,33 +303,10 @@ def test__geometry_charge_and_spin_remain_mutable( assert fetched.spin == 1 -def test__geometry_find_or_create_reuses_matching_row( - database: Database, geometry_row: GeometryRow -) -> None: - """Test that find_or_create returns the same row for repeated calls.""" - first = GeometryRow.find_or_create( - database, - symbols=list(geometry_row.symbols), - coordinates=np.array(geometry_row.coordinates), - charge=geometry_row.charge, - spin=geometry_row.spin, - ) - second = GeometryRow.find_or_create( - database, - symbols=list(geometry_row.symbols), - coordinates=np.array(geometry_row.coordinates), - charge=geometry_row.charge, - spin=geometry_row.spin, - ) - - assert first.id is not None - assert first.id == second.id - - def test__geometry_unique_hash_catches_direct_duplicate_insert( database: Database, geometry_row: GeometryRow ) -> None: - """Test that a direct duplicate insert (bypassing find_or_create) is rejected.""" + """Test that a direct duplicate insert of identical geometry content is rejected.""" duplicate = GeometryRow( symbols=list(geometry_row.symbols), coordinates=np.array(geometry_row.coordinates), @@ -442,62 +399,6 @@ def test__hessian_frequency_cache_invalidated_on_value_update( assert hessian.harmonic_frequencies != original_frequencies -def test__result_query( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - calc_geo_link: CalculationGeometryLink, - rng: Generator, -) -> None: - """Test querying of result tables.""" - database.add(calculation_row) - database.add(geometry_row) - database.add(calc_geo_link) - database.commit() - - n = geometry_row.to_geometry().atom_count - hess = HessianRow( - calculation=calculation_row, - geometry=geometry_row, - value=rng.uniform(size=(3 * n, 3 * n)), - ) - database.add(hess) - - database.commit() - - hess2 = HessianRow.query(database, geo=geometry_row, model=calculation_row.model) - assert hess2 - assert hess2.id == hess.id - - -def test__provenance_query_matches_regardless_of_dict_key_order( - database: Database, geometry_row: GeometryRow, model_row: ModelRow -) -> None: - """Test that provenance-filtered queries ignore dict key insertion order. - - SQLite JSON columns compare by exact serialized text, so two dicts built with - different key insertion order would previously fail to match even though - they're equal in Python; `Database`'s `json_serializer` canonicalizes key - order on write to fix this. - """ - calculation = CalculationRow( - model=model_row, calc_type=CalcType.ENERGY, input_provenance={"b": 1, "a": 2} - ) - database.add(calculation) - database.add(geometry_row) - database.commit() - - energy = EnergyRow(calculation=calculation, geometry=geometry_row, value=-1.0) - database.add(energy) - database.commit() - - found = EnergyRow.query( - database, geo=geometry_row, model=model_row, prov={"a": 2, "b": 1} - ) - assert found - assert found.id == energy.id - - def test__stationary_inchi( database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow ) -> None: @@ -680,140 +581,10 @@ def test__hessian_delete_leaves_is_valid_untouched_when_no_hessians_remain( assert stationary.is_valid -def test__stationary_query( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test querying of stationary points.""" - database.add(calculation_row) - database.add(geometry_row) - database.commit() - - stationary = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - database.add(stationary) - - ident = Identity.from_geometry( - geo=geometry_row.to_geometry(), algorithm=Algorithm.RDKIT_INCHI - ) - stationary2 = StationaryPointRow.query( - database, ident=ident, model=calculation_row.model - ) - - assert stationary2 - assert stationary2.id == stationary.id - - -def test__invalid_stationary_query( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test invalid querying of stationary points.""" - ident = Identity.from_geometry( - geo=geometry_row.to_geometry(), algorithm=Algorithm.RDKIT_INCHI - ) - with pytest.raises(MissingPrimaryKeyError): - StationaryPointRow.query(database, ident=ident, model=calculation_row.model) - - -def test__stage_and_step_query( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test querying of stages and steps built on the chainable Query API.""" - database.add(calculation_row) - database.add(geometry_row) - database.commit() - - stationary1 = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - stationary2 = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - database.add(stationary1) - database.add(stationary2) - database.commit() - - stage1 = StageRow(stationaries=[stationary1]) - stage2 = StageRow(stationaries=[stationary2]) - database.add(stage1) - database.add(stage2) - database.commit() - - stage_match = StageRow.query(database, [stationary1]) - assert stage_match - assert stage_match.id == stage1.id - - step = StepRow(stage1=stage1, stage2=stage2) - database.add(step) - database.commit() - - step_match = StepRow.query(database, stage1, stage2) - assert step_match - assert step_match.id == step.id - - -def test__stage_find_or_create_reuses_matching_row( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that find_or_create returns the same row for repeated calls.""" - database.add(calculation_row) - database.add(geometry_row) - database.commit() - - stationary = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - database.add(stationary) - database.commit() - - first = StageRow.find_or_create(database, [stationary]) - second = StageRow.find_or_create(database, [stationary]) - - assert first.id is not None - assert first.id == second.id - - -def test__stage_find_or_create_distinguishes_is_ts( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that find_or_create treats differing is_ts as distinct stages.""" - database.add(calculation_row) - database.add(geometry_row) - database.commit() - - stationary = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - database.add(stationary) - database.commit() - - non_ts = StageRow.find_or_create(database, [stationary], is_ts=False) - ts = StageRow.find_or_create(database, [stationary], is_ts=True) - - assert non_ts.id != ts.id - - -def test__step_find_or_create_reuses_matching_row( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that find_or_create returns the same row for repeated calls.""" - database.add(calculation_row) - database.add(geometry_row) - database.commit() - - stationary1 = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - stationary2 = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - database.add(stationary1) - database.add(stationary2) - database.commit() - - stage1 = StageRow(stationaries=[stationary1]) - stage2 = StageRow(stationaries=[stationary2]) - database.add(stage1) - database.add(stage2) - database.commit() - - first = StepRow.find_or_create(database, stage1, stage2) - second = StepRow.find_or_create(database, stage1, stage2) - - assert first.id is not None - assert first.id == second.id - - def test__step_null_safe_index_catches_barrierless_duplicate( database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow ) -> None: - """Test that a direct duplicate barrierless step (bypassing StepRow.query) fails. + """Test that a direct duplicate barrierless step insert fails. `unq_step_stages` alone doesn't catch this, since `stage_id_ts` is NULL for both rows and SQL treats NULL as distinct from itself; `unq_step_stages_null_safe` From d9f2cfc941f8a028b1ceac1d0c399146e22deb7c Mon Sep 17 00:00:00 2001 From: "Troy N. Smith" Date: Thu, 30 Jul 2026 02:49:46 -0400 Subject: [PATCH 7/8] Reorganization --- src/autostorage/models/__init__.py | 6 +- src/autostorage/models/calc.py | 24 +++++--- src/autostorage/models/core.py | 99 ------------------------------ src/autostorage/models/data.py | 15 ++--- src/autostorage/models/geom.py | 7 +-- src/autostorage/models/link.py | 16 +++-- src/autostorage/models/rxn.py | 43 ++++++++++--- src/autostorage/models/traj.py | 8 +-- src/autostorage/types.py | 13 ++++ tests/conftest.py | 4 +- tests/test_models.py | 93 ++-------------------------- 11 files changed, 95 insertions(+), 233 deletions(-) delete mode 100644 src/autostorage/models/core.py diff --git a/src/autostorage/models/__init__.py b/src/autostorage/models/__init__.py index 26afaed..4557034 100644 --- a/src/autostorage/models/__init__.py +++ b/src/autostorage/models/__init__.py @@ -2,8 +2,8 @@ from sqlmodel import SQLModel +from ..types import _fk_field from .calc import CalculationRow, ModelRow, ValidationRow -from .core import BaseLink, BaseResultRow, BaseRow, TimestampMixin, _fk_field from .data import EnergyRow, GradientRow, HessianRow from .geom import GeometryRow, _geometry_hash from .link import ( @@ -18,9 +18,6 @@ from .traj import TrajectoryRow __all__ = [ - "BaseLink", - "BaseResultRow", - "BaseRow", "CalculationGeometryLink", "CalculationRow", "CalculationTrajectoryLink", @@ -38,7 +35,6 @@ "StationaryStageLink", "StepRow", "StepValidationLink", - "TimestampMixin", "TrajectoryGeometryLink", "TrajectoryRow", "ValidationRow", diff --git a/src/autostorage/models/calc.py b/src/autostorage/models/calc.py index ed48df0..556a484 100644 --- a/src/autostorage/models/calc.py +++ b/src/autostorage/models/calc.py @@ -1,5 +1,6 @@ """Calculation-related row definitions: model, calculation, validation.""" +from datetime import datetime from typing import TYPE_CHECKING, Any from sqlmodel import ( @@ -9,13 +10,14 @@ Field, Index, Relationship, + SQLModel, UniqueConstraint, + func, text, ) -from autostorage.types import CalcStatus, CalcType, Role +from autostorage.types import CalcStatus, CalcType, Role, _fk_field -from .core import BaseRow, _fk_field from .link import StepValidationLink if TYPE_CHECKING: @@ -26,7 +28,7 @@ # Calculation rows -class ModelRow(BaseRow, table=True): +class ModelRow(SQLModel, table=True): """Calculation model specification. Attributes @@ -63,13 +65,14 @@ class ModelRow(BaseRow, table=True): ), ) + id: int | None = Field(default=None, primary_key=True) program: str program_version: str | None = None method: str basis: str | None = None -class CalculationRow(BaseRow, table=True): +class CalculationRow(SQLModel, table=True): """Quantum chemistry calculation and its associated data. Attributes @@ -96,6 +99,7 @@ class CalculationRow(BaseRow, table=True): __tablename__ = "calculation" + id: int | None = Field(default=None, primary_key=True) model_id: int | None = Field( default=None, foreign_key="model.id", @@ -112,15 +116,18 @@ class CalculationRow(BaseRow, table=True): Enum(CalcStatus, values_callable=lambda x: [e.value for e in x]) ), ) - error_message: str | None = Field(default=None) - # Intentionally unbounded free-form JSON; add a size/schema guardrail if - # these are ever populated from a less-trusted input path. input_provenance: dict[str, Any] | None = Field( default_factory=dict, sa_column=Column(JSON) ) output_provenance: dict[str, Any] | None = Field( default_factory=dict, sa_column=Column(JSON) ) + created: datetime | None = Field( + default=None, + nullable=False, + sa_column_kwargs={"server_default": func.now()}, + ) + error_message: str | None = Field(default=None) model: "ModelRow" = Relationship() geometry_links: list["CalculationGeometryLink"] = Relationship( @@ -161,7 +168,7 @@ def output_trajectories(self) -> list["TrajectoryRow"]: ] -class ValidationRow(BaseRow, table=True): +class ValidationRow(SQLModel, table=True): """Validation result for a specific step and calculation. Attributes @@ -180,6 +187,7 @@ class ValidationRow(BaseRow, table=True): __tablename__ = "validation" + id: int | None = Field(default=None, primary_key=True) calculation_id: int | None = _fk_field("calculation.id") method: str diff --git a/src/autostorage/models/core.py b/src/autostorage/models/core.py deleted file mode 100644 index 1c978ca..0000000 --- a/src/autostorage/models/core.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Base row/link classes shared across all row modules.""" - -from datetime import datetime -from typing import Any, Self, dataclass_transform - -from sqlalchemy import inspect as sa_inspect -from sqlmodel import Field, SQLModel, func - - -def _fk_field(target: str, *, nullable: bool = False, index: bool = True) -> Any: # noqa: ANN401 - """Build a standard foreign-key Field with ON DELETE CASCADE.""" - return Field( - default=None, - foreign_key=target, - ondelete="CASCADE", - nullable=nullable, - index=index, - ) - - -@dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) -class TimestampMixin(SQLModel): - """Mixin adding server-managed creation/update timestamps. - - Annotated as `datetime | None` since the value is unset in Python until the - database fills it in via `server_default`/`onupdate`; `nullable=False` - overrides the `NULL`-by-default column that an Optional annotation would - otherwise produce, since the DB always has a value once the row is flushed. - """ - - created_at: datetime | None = Field( - default=None, - nullable=False, - sa_column_kwargs={"server_default": func.now()}, - ) - updated_at: datetime | None = Field( - default=None, - nullable=False, - sa_column_kwargs={"server_default": func.now(), "onupdate": func.now()}, - ) - - -@dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) -class BaseRow(TimestampMixin, SQLModel): - """Base for models with a primary ID.""" - - id: int | None = Field(default=None, primary_key=True) - - -class BaseResultRow(BaseRow): - """Base for result models.""" - - geometry_id: int | None - - -@dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) -class BaseLink(SQLModel): - """Base for models without a primary ID.""" - - @classmethod - def create(cls, *rows: BaseRow, **attrs: object) -> Self: - """Construct a link, matching each row to its relationship by type. - - Parameters - ---------- - *rows - The rows to link (e.g. a ``GeometryRow`` and a ``CalculationRow``), - in any order. - **attrs - Extra attributes to set on the link (e.g. ``role``). - - Returns - ------- - Self - The constructed (unsaved) link row. - """ - relationships = sa_inspect(cls, raiseerr=True).relationships - fields: dict[str, BaseRow] = {} - for row in rows: - matches = [ - rel.key - for rel in relationships - if rel.key not in fields and isinstance(row, rel.mapper.class_) - ] - if not matches: - msg = f"{cls.__name__} has no unmatched relationship for {row!r}." - raise ValueError(msg) - if len(matches) > 1: - # Ambiguous: two+ unfilled relationships share this row's type, - # so matching by type alone can't tell them apart (e.g. a link - # table with two relationships to the same row model). Raise - # rather than silently picking one by declaration order. - msg = ( - f"{cls.__name__} has multiple unmatched relationships " - f"{matches} for {row!r}; construct this link directly instead." - ) - raise ValueError(msg) - fields[matches[0]] = row - return cls(**fields, **attrs) diff --git a/src/autostorage/models/data.py b/src/autostorage/models/data.py index 90f24cc..fc2762f 100644 --- a/src/autostorage/models/data.py +++ b/src/autostorage/models/data.py @@ -6,19 +6,17 @@ import numpy as np from automol import geom from automol.utils.types import FloatArray -from sqlmodel import Column, Field, Relationship +from sqlmodel import Column, Field, Relationship, SQLModel from sqlmodel.main import SQLModelConfig -from autostorage.types import CompressedArrayTypeDecorator - -from .core import BaseResultRow, _fk_field +from autostorage.types import CompressedArrayTypeDecorator, _fk_field if TYPE_CHECKING: from .calc import CalculationRow from .geom import GeometryRow -class EnergyRow(BaseResultRow, table=True): +class EnergyRow(SQLModel, table=True): """Energy result for a specific geometry and calculation. Attributes @@ -37,6 +35,7 @@ class EnergyRow(BaseResultRow, table=True): __tablename__ = "energy" + id: int | None = Field(default=None, primary_key=True) geometry_id: int | None = _fk_field("geometry.id") calculation_id: int | None = _fk_field("calculation.id") value: float @@ -45,7 +44,7 @@ class EnergyRow(BaseResultRow, table=True): geometry: "GeometryRow" = Relationship(back_populates="energies") -class GradientRow(BaseResultRow, table=True): +class GradientRow(SQLModel, table=True): """Energy gradient result for a specific geometry and calculation. Attributes @@ -65,6 +64,7 @@ class GradientRow(BaseResultRow, table=True): __tablename__ = "gradient" model_config = SQLModelConfig(arbitrary_types_allowed=True) + id: int | None = Field(default=None, primary_key=True) geometry_id: int | None = _fk_field("geometry.id") calculation_id: int | None = _fk_field("calculation.id") value: FloatArray = Field(sa_column=Column(CompressedArrayTypeDecorator())) @@ -73,7 +73,7 @@ class GradientRow(BaseResultRow, table=True): geometry: "GeometryRow" = Relationship(back_populates="gradients") -class HessianRow(BaseResultRow, table=True): +class HessianRow(SQLModel, table=True): """Hessian result for a specific geometry and calculation. Attributes @@ -93,6 +93,7 @@ class HessianRow(BaseResultRow, table=True): __tablename__ = "hessian" model_config = SQLModelConfig(arbitrary_types_allowed=True) + id: int | None = Field(default=None, primary_key=True) geometry_id: int | None = _fk_field("geometry.id") calculation_id: int | None = _fk_field("calculation.id") diff --git a/src/autostorage/models/geom.py b/src/autostorage/models/geom.py index 87fb724..2b2260b 100644 --- a/src/autostorage/models/geom.py +++ b/src/autostorage/models/geom.py @@ -7,13 +7,11 @@ import numpy as np from automol import Geometry from automol.utils.types import FloatArray -from sqlmodel import JSON, Column, Field, Relationship, UniqueConstraint +from sqlmodel import JSON, Column, Field, Relationship, SQLModel, UniqueConstraint from sqlmodel.main import SQLModelConfig from autostorage.types import CompressedArrayTypeDecorator -from .core import BaseRow - if TYPE_CHECKING: from .data import EnergyRow, GradientRow, HessianRow from .link import CalculationGeometryLink, TrajectoryGeometryLink @@ -33,7 +31,7 @@ def _geometry_hash( # Geometry table -class GeometryRow(BaseRow, table=True): +class GeometryRow(SQLModel, table=True): """Molecular geometry definition and metadata. Attributes @@ -67,6 +65,7 @@ class GeometryRow(BaseRow, table=True): __table_args__ = (UniqueConstraint("geometry_hash", name="unique_geometry_hash"),) model_config = SQLModelConfig(arbitrary_types_allowed=True) + id: int | None = Field(default=None, primary_key=True) symbols: list[str] = Field(sa_column=Column(JSON)) coordinates: FloatArray = Field(sa_column=Column(CompressedArrayTypeDecorator())) charge: int diff --git a/src/autostorage/models/link.py b/src/autostorage/models/link.py index 0b4c046..0b3c88d 100644 --- a/src/autostorage/models/link.py +++ b/src/autostorage/models/link.py @@ -2,19 +2,17 @@ from typing import TYPE_CHECKING -from sqlmodel import JSON, Column, Enum, Field, Index, Relationship +from sqlmodel import JSON, Column, Enum, Field, Index, Relationship, SQLModel from autostorage.types import Role -from .core import BaseLink - if TYPE_CHECKING: from .calc import CalculationRow from .geom import GeometryRow from .traj import TrajectoryRow -class TrajectoryGeometryLink(BaseLink, table=True): +class TrajectoryGeometryLink(SQLModel, table=True): """Association table linking geometries to a trajectory. Attributes @@ -61,7 +59,7 @@ class TrajectoryGeometryLink(BaseLink, table=True): # `link_model=` kwarg needs the actual class object at class-body-evaluation # time — unlike every other cross-model reference in this file, it can't be # satisfied by a lazily-resolved string forward ref. -class StationaryIdentityLink(BaseLink, table=True): +class StationaryIdentityLink(SQLModel, table=True): """Association table linking stationary points to chemical identities. Attributes @@ -89,7 +87,7 @@ class StationaryIdentityLink(BaseLink, table=True): ) -class StationaryStageLink(BaseLink, table=True): +class StationaryStageLink(SQLModel, table=True): """Association table linking stationary points to reaction stages. Attributes @@ -125,7 +123,7 @@ class StationaryStageLink(BaseLink, table=True): # Declared here, ahead of StepRow, for the same `link_model=` reason as # StationaryIdentityLink/StationaryStageLink above. -class StepValidationLink(BaseLink, table=True): +class StepValidationLink(SQLModel, table=True): """Association table linking validations to a step. Attributes @@ -153,7 +151,7 @@ class StepValidationLink(BaseLink, table=True): ) -class CalculationGeometryLink(BaseLink, table=True): +class CalculationGeometryLink(SQLModel, table=True): """Association table linking geometries to a calculation. Attributes @@ -199,7 +197,7 @@ class CalculationGeometryLink(BaseLink, table=True): calculation: "CalculationRow" = Relationship(back_populates="geometry_links") -class CalculationTrajectoryLink(BaseLink, table=True): +class CalculationTrajectoryLink(SQLModel, table=True): """Association table linking trajectories to a calculation. Attributes diff --git a/src/autostorage/models/rxn.py b/src/autostorage/models/rxn.py index e030805..79cf732 100644 --- a/src/autostorage/models/rxn.py +++ b/src/autostorage/models/rxn.py @@ -1,11 +1,21 @@ """Reaction-related row definitions: stationary points, identities, stages, steps.""" +from datetime import datetime from typing import TYPE_CHECKING, Any from automol import Identity -from sqlmodel import CheckConstraint, Field, Index, Relationship, UniqueConstraint, text - -from .core import BaseRow, _fk_field +from sqlmodel import ( + CheckConstraint, + Field, + Index, + Relationship, + SQLModel, + UniqueConstraint, + func, + text, +) + +from ..types import _fk_field from .link import StationaryIdentityLink, StationaryStageLink, StepValidationLink if TYPE_CHECKING: @@ -14,7 +24,7 @@ # Stationary point rows -class StationaryPointRow(BaseRow, table=True): +class StationaryPointRow(SQLModel, table=True): """A stationary point on a potential energy surface. Attributes @@ -42,6 +52,7 @@ class StationaryPointRow(BaseRow, table=True): __tablename__ = "stationary_point" + id: int | None = Field(default=None, primary_key=True) geometry_id: int | None = _fk_field("geometry.id") calculation_id: int | None = _fk_field("calculation.id") order: int = 0 @@ -57,6 +68,17 @@ class StationaryPointRow(BaseRow, table=True): back_populates="stationaries", link_model=StationaryStageLink ) + created: datetime | None = Field( + default=None, + nullable=False, + sa_column_kwargs={"server_default": func.now()}, + ) + updated: datetime | None = Field( + default=None, + nullable=False, + sa_column_kwargs={"server_default": func.now(), "onupdate": func.now()}, + ) + def identity( self, *, @@ -79,7 +101,7 @@ def identity( ) -class IdentityRow(BaseRow, Identity, table=True): +class IdentityRow(SQLModel, Identity, table=True): """A chemical identifier associated with one or more stationary points. Attributes @@ -101,13 +123,15 @@ class IdentityRow(BaseRow, Identity, table=True): UniqueConstraint("kind", "algorithm", "value", name="unique_identity"), ) + id: int | None = Field(default=None, primary_key=True) + stationary_points: list["StationaryPointRow"] = Relationship( back_populates="identities", link_model=StationaryIdentityLink ) identity_extras: list["IdentityExtraRow"] = Relationship(back_populates="identity") -class IdentityExtraRow(BaseRow, table=True): +class IdentityExtraRow(SQLModel, table=True): """Additional key-value metadata attached to a chemical identity. Attributes @@ -124,6 +148,7 @@ class IdentityExtraRow(BaseRow, table=True): __tablename__ = "identity_extras" + id: int | None = Field(default=None, primary_key=True) identity_id: int | None = Field( default=None, foreign_key="identity.id", @@ -139,7 +164,7 @@ class IdentityExtraRow(BaseRow, table=True): # Reaction rows -class StageRow(BaseRow, table=True): +class StageRow(SQLModel, table=True): """A chemical state (reactant, product, or transition state) in a reaction. Attributes @@ -155,6 +180,7 @@ class StageRow(BaseRow, table=True): __tablename__ = "stage" + id: int | None = Field(default=None, primary_key=True) is_ts: bool = False stationaries: list["StationaryPointRow"] = Relationship( @@ -172,7 +198,7 @@ class StageRow(BaseRow, table=True): ) -class StepRow(BaseRow, table=True): +class StepRow(SQLModel, table=True): """An elementary reaction step connecting a reactant, transition state, and product. Attributes @@ -216,6 +242,7 @@ class StepRow(BaseRow, table=True): Index("ix_step_stage_id_ts", "stage_id_ts"), ) + id: int | None = Field(default=None, primary_key=True) stage_id1: int | None = Field( default=None, foreign_key="stage.id", diff --git a/src/autostorage/models/traj.py b/src/autostorage/models/traj.py index d2a1332..6295670 100644 --- a/src/autostorage/models/traj.py +++ b/src/autostorage/models/traj.py @@ -2,15 +2,13 @@ from typing import TYPE_CHECKING -from sqlmodel import Relationship - -from .core import BaseRow +from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: from .link import CalculationTrajectoryLink, TrajectoryGeometryLink -class TrajectoryRow(BaseRow, table=True): +class TrajectoryRow(SQLModel, table=True): """Ordered sequence of geometries from a calculation trajectory. Attributes @@ -23,6 +21,8 @@ class TrajectoryRow(BaseRow, table=True): __tablename__ = "trajectory" + id: int | None = Field(default=None, primary_key=True) + geometry_links: list["TrajectoryGeometryLink"] = Relationship( back_populates="trajectory" ) diff --git a/src/autostorage/types.py b/src/autostorage/types.py index 9222d1a..d1cafa5 100644 --- a/src/autostorage/types.py +++ b/src/autostorage/types.py @@ -8,15 +8,28 @@ import numpy as np from sqlalchemy import LargeBinary from sqlalchemy.types import TypeDecorator +from sqlmodel import Field __all__ = [ "CalcStatus", "CalcType", "CompressedArrayTypeDecorator", "Role", + "_fk_field", ] +def _fk_field(target: str, *, nullable: bool = False, index: bool = True) -> Any: # noqa: ANN401 + """Build a standard foreign-key Field with ON DELETE CASCADE.""" + return Field( + default=None, + foreign_key=target, + ondelete="CASCADE", + nullable=nullable, + index=index, + ) + + class CompressedArrayTypeDecorator(TypeDecorator): """Stores a NumPy array as zlib-compressed binary data in the DB. diff --git a/tests/conftest.py b/tests/conftest.py index f7dd919..e0d5de6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,6 +68,6 @@ def calc_geo_link( calculation_row: CalculationRow, geometry_row: GeometryRow ) -> CalculationGeometryLink: """Fixture for CalculationGeometryLink.""" - return CalculationGeometryLink.create( - calculation_row, geometry_row, role=Role.INPUT + return CalculationGeometryLink( + calculation=calculation_row, geometry=geometry_row, role=Role.INPUT ) diff --git a/tests/test_models.py b/tests/test_models.py index b89884e..5527346 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,14 +1,10 @@ """Autostorage models tests.""" -import time -from unittest import mock - import numpy as np import pytest from automol import Algorithm from numpy.random import Generator from scipy.spatial.transform import Rotation -from sqlalchemy import inspect as sa_inspect from sqlalchemy.exc import IntegrityError from autostorage import ( @@ -32,83 +28,6 @@ from autostorage.types import Role -def test__link_create_matches_rows_by_type( - calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that link.create() matches rows to relationships regardless of order.""" - link = CalculationGeometryLink.create( - geometry_row, calculation_row, role=Role.INPUT - ) - - assert link.calculation is calculation_row - assert link.geometry is geometry_row - assert link.role == Role.INPUT - - -def test__link_create_rejects_unmatched_row( - calculation_row: CalculationRow, model_row: ModelRow -) -> None: - """Test that link.create() raises when a row has no matching relationship.""" - with pytest.raises(ValueError, match="no unmatched relationship"): - CalculationGeometryLink.create(calculation_row, model_row, role=Role.INPUT) - - -def test__link_create_rejects_ambiguous_row_type( - calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that link.create() raises when 2+ unfilled relationships share a type. - - No current `BaseLink` subclass has two relationships to the same row - type, so this patches `sa_inspect` to simulate one, guarding the - ambiguity check against silently picking a relationship by declaration - order if such a link table is ever added. - """ - real_relationships = list(sa_inspect(CalculationGeometryLink).relationships) - duplicate_geometry_rel = next( - rel for rel in real_relationships if rel.key == "geometry" - ) - - with mock.patch("autostorage.models.core.sa_inspect") as mock_inspect: - mock_inspect.return_value.relationships = [ - *real_relationships, - duplicate_geometry_rel, - ] - with pytest.raises(ValueError, match="multiple unmatched relationships"): - CalculationGeometryLink.create( - geometry_row, calculation_row, role=Role.INPUT - ) - - -def test__row_timestamps_set_on_create(database: Database) -> None: - """Test that created_at/updated_at are populated by the database on insert.""" - row = ModelRow(program="orca", method="xtb") - database.add(row) - database.commit() - - assert row.created_at is not None - assert row.updated_at is not None - - -def test__row_updated_at_advances_on_update(database: Database) -> None: - """Test that updated_at advances on a later commit while created_at doesn't.""" - row = ModelRow(program="orca", method="xtb") - database.add(row) - database.commit() - created_at, updated_at = row.created_at, row.updated_at - assert created_at is not None - assert updated_at is not None - - # SQLite's CURRENT_TIMESTAMP has one-second resolution. - time.sleep(1.1) - row.basis = "def2-svp" - database.add(row) - database.commit() - - assert row.updated_at is not None - assert row.created_at == created_at - assert row.updated_at > updated_at - - def test__model_null_safe_index_catches_duplicate(database: Database) -> None: """Test that a direct duplicate insert (bypassing find_or_create) is rejected. @@ -162,8 +81,8 @@ def test__calculation_geometry_role_properties( charge=0, spin=0, ) - output_link = CalculationGeometryLink.create( - calculation_row, output_geometry, role=Role.OUTPUT + output_link = CalculationGeometryLink( + calculation=calculation_row, geometry=output_geometry, role=Role.OUTPUT ) database.add(calculation_row) database.add(geometry_row) @@ -190,11 +109,11 @@ def test__calculation_trajectory_role_properties( database.add(output_trajectory) database.commit() - input_link = CalculationTrajectoryLink.create( - calculation_row, input_trajectory, role=Role.INPUT + input_link = CalculationTrajectoryLink( + calculation=calculation_row, trajectory=input_trajectory, role=Role.INPUT ) - output_link = CalculationTrajectoryLink.create( - calculation_row, output_trajectory, role=Role.OUTPUT + output_link = CalculationTrajectoryLink( + calculation=calculation_row, trajectory=output_trajectory, role=Role.OUTPUT ) database.add(calculation_row) database.add(input_link) From ab862a5b2409c3bbc51906c1320d6b76870aa851 Mon Sep 17 00:00:00 2001 From: "Troy N. Smith" Date: Fri, 28 Aug 2026 10:56:01 -0600 Subject: [PATCH 8/8] Simplify API for alpha release - Require users to write their own querying methods with SQLModel/SQLAlchemy --- .claude/CLAUDE.md | 111 ++ .gitignore | 8 +- CHANGELOG.md | 22 + README.md | 89 +- docs/source/data-model.md | 100 -- docs/source/database.md | 187 +-- docs/source/development.md | 120 -- docs/source/events.md | 186 --- docs/source/index.md | 14 +- docs/source/models.md | 69 + docs/source/quickstart.md | 85 +- examples/scan.py | 167 ++ examples/stationary.py | 195 +++ examples/stationary_point.py | 256 --- examples/transition.py | 212 +++ pixi.lock | 102 +- pixi.toml | 2 +- pyproject.toml | 6 +- schema/full_schema.pintora | 149 ++ schema/full_schema.svg | 1 + schema/simplified_schema.pintora | 121 ++ schema/simplified_schema.png | Bin 0 -> 563254 bytes schema/simplified_schema.svg | 1 + src/autostorage/__init__.py | 12 +- src/autostorage/database.py | 160 +- src/autostorage/events.py | 603 ++++--- src/autostorage/exc.py | 21 - src/autostorage/models.py | 809 ++++++++++ src/autostorage/models/__init__.py | 43 - src/autostorage/models/calc.py | 201 --- src/autostorage/models/data.py | 125 -- src/autostorage/models/geom.py | 95 -- src/autostorage/models/link.py | 241 --- src/autostorage/models/rxn.py | 278 ---- src/autostorage/models/traj.py | 31 - src/autostorage/types.py | 79 +- tests/conftest.py | 73 - tests/data/propyl_oxirane.xyz | 17 - tests/data/propyl_oxirane_frequencies.gz | Bin 512 -> 0 bytes tests/data/propyl_oxirane_hessian.gz | Bin 14357 -> 0 bytes tests/data/test.xyz | 12 - tests/test_database.py | 422 +++-- tests/test_events.py | 1765 +++++++++++++++++++++ tests/test_models.py | 1810 +++++++++++++--------- 44 files changed, 5393 insertions(+), 3607 deletions(-) create mode 100644 .claude/CLAUDE.md delete mode 100644 docs/source/data-model.md delete mode 100644 docs/source/development.md delete mode 100644 docs/source/events.md create mode 100644 docs/source/models.md create mode 100644 examples/scan.py create mode 100644 examples/stationary.py delete mode 100644 examples/stationary_point.py create mode 100644 examples/transition.py create mode 100644 schema/full_schema.pintora create mode 100644 schema/full_schema.svg create mode 100644 schema/simplified_schema.pintora create mode 100644 schema/simplified_schema.png create mode 100644 schema/simplified_schema.svg delete mode 100644 src/autostorage/exc.py create mode 100644 src/autostorage/models.py delete mode 100644 src/autostorage/models/__init__.py delete mode 100644 src/autostorage/models/calc.py delete mode 100644 src/autostorage/models/data.py delete mode 100644 src/autostorage/models/geom.py delete mode 100644 src/autostorage/models/link.py delete mode 100644 src/autostorage/models/rxn.py delete mode 100644 src/autostorage/models/traj.py delete mode 100644 tests/conftest.py delete mode 100644 tests/data/propyl_oxirane.xyz delete mode 100644 tests/data/propyl_oxirane_frequencies.gz delete mode 100644 tests/data/propyl_oxirane_hessian.gz delete mode 100644 tests/data/test.xyz create mode 100644 tests/test_events.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..e7933bd --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,111 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +autostorage is a SQLModel/SQLAlchemy persistence layer for computational chemistry workflow +data, built on top of `automol`. It stores molecular geometries, identities, trajectories, +stationary points, calculation results, and the calculations/steps that connect them, as a +graph of related database rows. + +## Commands + +All tasks run through Pixi (`pixi run `), defined in `pixi.toml` under `[feature.dev.tasks]`: + +- `pixi run fmt` — format with Ruff +- `pixi run lint` — lint with Ruff (`--fix`) +- `pixi run types` — static type-check with `ty` +- `pixi run imports` — enforce module layering with `lint-imports` (import-linter) +- `pixi run test` — run the full pytest suite +- `pixi run pre-commit` — run all of the above via lefthook, in order (fmt → lint → types → imports → test), then check the tree is clean +- `pixi run cov-view` — open the HTML coverage report +- `pixi run local` — toggle `pixi.toml`'s `automol` dependency between the pinned release and a + local `../automol` checkout, via the `# local:true`/`# local:false` comment markers already in + the file (see "Relationship to automol" below) +- `pixi run local-pre-commit` — a second lefthook target, distinct from `pre-commit` +- `pixi run docs-build` / `pixi run docs-view` — build/view the Sphinx docs (`feature.docs` env) + +Single test: invoke `pytest` directly inside the pixi env, e.g. +`pixi run -e dev pytest tests/test_models.py::TestGeometryRow::test_name` (tests are organized +into `Test*` classes grouped by row/function under test). + +Note: pytest is configured with `--doctest-modules`, so doctests in `src/` docstrings are +collected and run as part of the suite. Coverage must stay ≥80% (`fail_under = 80` in +`pyproject.toml`), with branch coverage enabled. + +Don't invoke bare `python`/`python3` — `automol` and other deps aren't on the system interpreter, +only inside the pixi env. Always go through `pixi run` (e.g. `pixi run -e dev pytest ...`). + +## Architecture + +### Module layering (enforced by import-linter) + +`pyproject.toml` defines a strict layer contract ("Autostorage Layering") — higher layers may +depend on lower ones, never the reverse: + +``` +autostorage.database (highest) +autostorage.events +autostorage.models +autostorage.types (lowest) +``` + +Adding an import that violates this order will fail `pixi run imports`. `autostorage` is a flat +module structure — all modules live directly in `src/autostorage/`. + +### Relationship to automol + +Row models extend automol's core data models directly rather than wrapping them: `GeometryRow` +extends `automol.Geometry`, `IdentityRow` extends `automol.Identity`. Any conversion to/from +other external formats is delegated to automol's own conversion functions rather than +reimplemented here. + +### Current module map + +- `models.py` — SQLModel row definitions, organized in sections: + - Link tables (named alphabetically by the entities they connect): `CalculationGeometryLink`, + `CalculationTrajectoryLink`, `GeometryTrajectoryLink`, `IdentityStationaryLink`, + `StageStationaryLink`, `StepValidationLink` + - Existential data rows: `GeometryRow` (extends `automol.Geometry`), `TrajectoryRow`, + `ModelRow`, `CalculationRow`, result rows (`EnergyRow`, `GradientRow`, `HessianRow`), + `ValidationRow` + - Stationary point rows: `StationaryPointRow` + - Reaction network rows: `StageRow`, `StepRow` (a step between two stages, with a barrierless + flag) + - Identity rows: `IdentityRow` (extends `automol.Identity`), `IdentityExtraRow` + +- `events.py` — SQLAlchemy ORM event listeners, by concern: + - Shape validation: `verify_gradient_shapes_before_flush`, `verify_hessian_shapes_before_flush` + - Trajectory validation: `verify_trajectory_geometry_ndim_insert` (ensures geometry index + length matches trajectory ndim) + - Auto-managed identities: `add_inchi_identities_before_flush` (attaches an InChI `IdentityRow` + to newly inserted stationary points), `add_smiles_extras_before_flush` / + `add_hill_extras_before_flush` (attach SMILES / Hill formula as `IdentityExtraRow`s once an + InChI identity is present). Private `_find_or_create_identity`/`_find_or_create_identity_extra` + helpers dedup these against existing rows and pending session inserts. + - Step validation: `sort_step_stage_ids` (auto-sorts stage_id1 < stage_id2), + `verify_step_barrierless_consistency` (verifies is_barrierless matches stage_id_ts state) + +- `database.py` — `Database`: SQLite engine/session manager. `__init__` creates the engine + (with `PRAGMA foreign_keys=ON` and a sort-keys JSON serializer) and the schema via + `SQLModel.metadata.create_all`; `session()` returns a fresh `Session` bound to that engine + (use as a context manager; nothing auto-commits); `close()` disposes the engine. + +- `types.py` — Type definitions and utilities: + - `Role` (StrEnum: INPUT/OUTPUT) — relationship between calculations and geometries/trajectories + - `CompressedArrayTypeDecorator` — SQLAlchemy `TypeDecorator` storing NumPy arrays as + zlib-compressed binary data in SQLite + - `_fk_field()` — helper for building foreign-key fields with ON DELETE CASCADE + +### Docstrings + +NumPy docstring convention (`tool.ruff.lint.pydocstyle` = `"numpy"`), and doctest examples in +docstrings are executed as tests — keep them runnable and accurate. + +### Notes + +- Minimize chat/response verbosity when performing work to reduce unnecessary token costs. +- Keep docstrings and comments minimal: one-line NumPy-style summaries where the convention + allows, no restating what a name/type hint already conveys. Reserve comments for genuinely + non-obvious invariants — most docstrings in this repo don't need that much. \ No newline at end of file diff --git a/.gitignore b/.gitignore index e196d81..9672a62 100644 --- a/.gitignore +++ b/.gitignore @@ -200,7 +200,7 @@ cython_debug/ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore # and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder -# .vscode/ +.vscode/ # Ruff stuff: .ruff_cache/ @@ -229,6 +229,6 @@ __marimo__/ # pixi build *.conda -# database schema for publication -schema/ -CLAUDE.md \ No newline at end of file +# Scratch files +.scratch/ +example*.db \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 831092c..f8307b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **`.claude/CLAUDE.md`**: Project-level guidance for Claude Code, documenting module architecture, layering rules, Pixi task commands, and autostorage-specific conventions. +- **`docs/source/models.md`**: Dedicated documentation page for the row models and their relationships. +- **Schema diagrams** (`schema/`): Added Pintora source files and rendered SVG/PNG diagrams for both simplified and full database schemas. +- **`examples/stationary.py`**: Example demonstrating stationary points, stages, and steps with H2O geometries. +- **`examples/scan.py`**: Example showing PES scan workflow with trajectory data. +- **`examples/transition.py`**: Example illustrating transition state and reaction pathway modeling. +- **`tests/test_events.py`**: Comprehensive test suite for SQLAlchemy ORM event listeners. +- **Top-level exports**: Added `CalculationTrajectoryLink`, `GeometryTrajectoryLink`, and `events` module to `autostorage.__all__`. + +### Changed + +- **Module consolidation**: Collapsed `src/autostorage/models/` directory (with separate `calc.py`, `data.py`, `geom.py`, `link.py`, `rxn.py`, `traj.py` files) into a single `src/autostorage/models.py` file — all row models, link tables, and helpers now live in one module, eliminating circular import complexity while maintaining logical organization via internal sections (link tables, existential data, stationary points, reaction network, identities). +- **Documentation reorganization**: Streamlined Sphinx docs — consolidated data model, development, and events pages into focused `models.md` and updated `database.md`; refreshed `index.md` and `quickstart.md` to reflect current architecture. +- **Examples refactor**: Replaced single `stationary_point.py` example with three focused examples (`stationary.py`, `scan.py`, `transition.py`), each demonstrating a distinct workflow pattern. +- **Test simplification**: Removed `tests/conftest.py` and test data files (`tests/data/*.xyz`, `*.gz`) — tests now use inline synthetic data instead of external fixtures. + ### Removed - **Alembic migrations** (`migrations/`, `alembic.ini`, `tests/test_migrations.py`, `pixi run migrate`): Removed for now; `alembic` remains a dev dependency for when migrations are reintroduced. Schema for fresh/in-memory `Database` instances is unaffected, still built via `create_all()`. +- **`src/autostorage/exc.py`**: Custom exception classes (`DataIntegrityError`, `ResultShapeError`) removed — validation errors now use standard Python exceptions or SQLAlchemy's built-in constraint violations. +- **`CalcType` and `CalcStatus` enums**: Removed from `types.py` and top-level exports — calculation type/status classification deferred to workflow layer or removed entirely. +- **Documentation files**: Deleted `docs/source/data-model.md`, `docs/source/development.md`, and `docs/source/events.md` (content consolidated into `models.md` and other updated docs). +- **`examples/stationary_point.py`**: Superseded by the three new focused examples. ## [0.0.12] - 2026-07-23 ### Added diff --git a/README.md b/README.md index 4115fbb..d3ad696 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Install as a [Pixi](https://pixi.sh) dependency: ```toml [dependencies] -autostorage = ">=0.0.10" +autostorage = ">=0.0.12" ``` Or with `uv`/`pip` from PyPI: @@ -31,7 +31,6 @@ Requires Python ≥3.12. ```python import numpy as np from autostorage import ( - CalcType, CalculationGeometryLink, CalculationRow, Database, @@ -44,51 +43,59 @@ from autostorage import ( # Open (or create) a SQLite database; ":memory:" also works for scratch use. db = Database("workflow.db") -# `find_or_create` dedups on (program, program_version, method, basis). -model = ModelRow.find_or_create(db, program="orca", method="b3lyp", basis="def2-svp") - -calc = CalculationRow(model=model, calc_type=CalcType.ENERGY) -geo = GeometryRow( - symbols=["H", "O", "H"], - coordinates=np.array([[0, 0, 0.8], [0, 0, 0], [0.8, 0, 0]]), - charge=0, - spin=0, -) -link = CalculationGeometryLink.create(calc, geo, role=Role.INPUT) -db.add_all([model, calc, geo, link]) -db.commit() - -# Attach a result to the geometry/calculation pair. -energy = EnergyRow(geometry=geo, calculation=calc, value=-76.02) -db.add(energy) -db.commit() - -# Look the result back up by geometry, model, and input provenance. -found = EnergyRow.query(db, geo=geo, model=model) -assert found is not None -print(found.value) +# Work within a session context. +with db.session() as session: + # Create a model specifying the calculation type, program, method, and basis. + model = ModelRow( + calc_type="energy", + program="orca", + method="b3lyp", + basis="def2-svp", + ) + + # Create a calculation using this model. + calc = CalculationRow(model=model) + + # Create a geometry. + geo = GeometryRow( + symbols=["H", "O", "H"], + coordinates=np.array([[0, 0, 0.8], [0, 0, 0], [0.8, 0, 0]]), + charge=0, + spin=0, + ) + + # Link the geometry to the calculation as an input. + link = CalculationGeometryLink( + calculation=calc, + geometry=geo, + role=Role.INPUT, + ) + + # Add all objects to the session and commit. + session.add_all([model, calc, geo, link]) + session.commit() + + # Attach an energy result to the geometry/calculation pair. + energy = EnergyRow(geometry=geo, calculation=calc, value=-76.02) + session.add(energy) + session.commit() + + # Query the result back by filtering on geometry and calculation. + found = session.query(EnergyRow).filter_by( + geometry_id=geo.id, + calculation_id=calc.id, + ).first() + assert found is not None + print(found.value) db.close() ``` -`Database` also supports the `with` statement, which rolls back on an unhandled exception and closes the connection on exit: - -```python -with Database("workflow.db") as db: - ... -``` - -Two databases can be combined with `Database.merge_from()`, which copies every row from one into the other, remapping ids/foreign keys and deduplicating content-unique rows (models, non-auto-managed identities) against the target's existing data: - -```python -with Database("combined.db") as target, Database("other.db") as other: - report = target.merge_from(other) - print(report.copied, report.reused) # per-table row counts -``` +`Database.session()` returns a standard SQLAlchemy `Session` that supports the context manager protocol. Sessions don't commit automatically — call `session.commit()` explicitly to persist changes. -For a full worked example covering geometries, trajectories, results, and identities, see [`examples/stationary_point.py`](examples/stationary_point.py). Reaction networks (`StageRow`/`StepRow`) can be exported as [MESS](https://tcg.cse.anl.gov/papr/codes/mess.html) input via `autostorage.utils.export_mess_input()`, or rendered as a potential energy surface diagram via `autostorage.utils.plot_pes()`. +For a full worked example covering geometries, trajectories, results, stationary points, stages, and steps, see [`examples/stationary.py`](examples/stationary.py). -See `CLAUDE.md` for the full module map and architecture notes. +See [CLAUDE.md](.claude/CLAUDE.md) for the full module map and architecture notes, or the [Sphinx docs](docs/source) for a rendered quickstart and API reference. ## Contributing diff --git a/docs/source/data-model.md b/docs/source/data-model.md deleted file mode 100644 index 0fbeb5c..0000000 --- a/docs/source/data-model.md +++ /dev/null @@ -1,100 +0,0 @@ -# Data model - -autostorage stores a workflow as a graph of related rows rather than a single flat table. -This page describes the module layout, the row/link types that make up the schema, and the -automatic behaviors that keep the graph consistent. - -## Module layering - -`autostorage` is a flat module structure (no sub-packages), with a strict dependency order -enforced by import-linter — higher layers may depend on lower ones, never the reverse: - -``` -autostorage.utils (highest) -autostorage.database -autostorage.events -autostorage.models -autostorage.types | autostorage.exc (lowest) -``` - -- {py:mod}`autostorage.types` — enums (`CalcType`, `CalcStatus`, `Role`) and - `CompressedArrayTypeDecorator`, which stores NumPy arrays as zlib-compressed `.npy` bytes - so gradients, Hessians, and coordinates round-trip through SQLite without precision loss. -- {py:mod}`autostorage.exc` — `ResultShapeError`, `MissingPrimaryKeyError`. -- {py:mod}`autostorage.models` — the SQLModel row/link definitions described below. -- {py:mod}`autostorage.events` — SQLAlchemy ORM event listeners that validate and enrich rows - as they flow through a session. -- {py:mod}`autostorage.database` — `Database`, the SQLite engine/session manager applications - interact with directly. - -## Relationship to automol - -Row models extend `automol`'s core data models directly rather than wrapping them: -`GeometryRow` extends `automol.Geometry`, `IdentityRow` extends `automol.Identity`. Any -conversion to/from other external formats is delegated to `automol`'s own conversion functions -rather than reimplemented here. - -## Core rows - -| Row | Represents | -| --- | --- | -| `GeometryRow` | A molecular geometry (symbols, coordinates, charge, spin). | -| `IdentityRow` | A chemical identifier (InChI, SMILES, conformer group, ...) shared across stationary points. | -| `IdentityExtraRow` | Extra key/value metadata attached to an `IdentityRow`. | -| `TrajectoryRow` | An ordered sequence of geometries (e.g. an optimization or IRC path). | -| `StationaryPointRow` | A stationary point on a PES — a geometry plus Hessian order and validity. | -| `StageRow` | A chemical state (reactant, product, or transition state) in a reaction. | -| `StepRow` | An elementary reaction step connecting two `StageRow`s, optionally via a TS stage. | -| `ModelRow` | A calculation model spec (program, program version, method, basis). | -| `CalculationRow` | A single quantum-chemistry calculation: type, status, provenance. | -| `ValidationRow` | A validation calculation (e.g. IRC) performed on a `StepRow`. | -| `EnergyRow` / `GradientRow` / `HessianRow` | Results computed at a geometry by a calculation. | - -`BaseRow` (id + `created_at`/`updated_at` timestamps) is the base for all of the above except -link tables. Result rows additionally share `BaseResultRow`, which provides the `query()` -classmethod for looking a result up by geometry, model, and input provenance. - -## Link tables - -Many-to-many and role-tagged relationships go through dedicated link rows (`BaseLink`), each -with a composite primary key and `ondelete="CASCADE"` foreign keys: - -- `CalculationGeometryLink` / `CalculationTrajectoryLink` — tag a geometry/trajectory as - `Role.INPUT` or `Role.OUTPUT` for a calculation. -- `TrajectoryGeometryLink` — orders geometries within a trajectory. -- `StationaryIdentityLink` — attaches identities to a stationary point. -- `StationaryStageLink` — groups stationary points into a `StageRow`. -- `StepValidationLink` — attaches validations to a `StepRow`. - -`BaseLink.create(*rows, **attrs)` builds a link by matching each row to the relationship whose -type it satisfies, so callers don't need to know the link's field names: - -```python -link = CalculationGeometryLink.create(calc, geo, role=Role.INPUT) -``` - -## Automatic behavior (event listeners) - -`autostorage.events` registers SQLAlchemy listeners that run on flush, so these behaviors apply -regardless of how a session is driven. See [Events](events.md) for the full detail, including -why some of these are registered at the session level rather than per-model. - -- **Shape validation** — `GradientRow.value` must be `(3 * atom_count,)` and `HessianRow.value` - must be `(3 * atom_count, 3 * atom_count)` for their linked geometry, or a - `ResultShapeError` is raised. -- **Stationary-point validity** — when a `HessianRow` is inserted, updated, or deleted, - `StationaryPointRow.is_valid` is recomputed from consensus among the geometry's Hessians - (comparing each stationary point's declared `order` to the Hessian-derived order). -- **Geometry immutability** — `symbols`/`coordinates` cannot be changed on a `GeometryRow` - after it's been inserted, since doing so would silently invalidate shape checks already run - against it. `charge`/`spin` remain mutable. -- **Automatic identity attachment** — new `StationaryPointRow`s get InChI and SMILES - `IdentityRow`s attached automatically via `automol.Algorithm`, deduplicated against existing - identity rows. -- **Conformer grouping** — new stationary points are compared against InChI peers using - `automol.geom.is_duplicate_conformer`; a match reuses the peer's conformer-group identity, - otherwise a new group id is allocated. -- **Reaction step consistency** — `StepRow.stage_id1`/`stage_id2` are kept in sorted order (to - satisfy the `stage_id1 < stage_id2` check constraint), `is_barrierless` is derived from - whether `stage_id_ts` is set, and `stage1`/`stage2` are rejected if they reference a - transition-state stage (and vice versa for `stage_ts`). diff --git a/docs/source/database.md b/docs/source/database.md index f5e2eef..198adad 100644 --- a/docs/source/database.md +++ b/docs/source/database.md @@ -1,168 +1,77 @@ # Database -{py:class}`~autostorage.database.Database` is the single entry point applications use to open -a SQLite file, get schema created, and run queries and mutations against it. This page covers -its connection semantics and the full method surface; see [Quickstart](quickstart.md) for a -minimal end-to-end example and [Data model](data-model.md) for what gets stored. +The `autostorage.database` module provides SQLite database connection management through the `Database` class. -## Opening a database +## Overview -```python -from autostorage import Database - -db = Database("workflow.db") # created if it doesn't exist -db = Database(":memory:") # scratch, in-process, gone on close() -db = Database("workflow.db", echo=True) -``` - -- `path` — a filesystem path (`str` or `Path`) or SQLite's special `:memory:` name. -- `echo` — when `True`, every SQL statement is logged to stdout (passed straight through to - SQLAlchemy's `create_engine`). +The `Database` class is a lightweight wrapper around SQLAlchemy's engine and session management, configured for SQLite with: -On every new DBAPI connection, `Database` also unconditionally issues `PRAGMA -foreign_keys=ON` — SQLite disables foreign-key enforcement by default, and the `ON DELETE -CASCADE` behavior the schema relies on (see [Link tables](data-model.md#link-tables)) depends -on this being set. +- **Foreign key enforcement** — SQLite's `PRAGMA foreign_keys=ON` is automatically enabled +- **Thread safety** — `check_same_thread=False` allows multi-threaded access +- **Canonical JSON serialization** — JSON column comparisons work regardless of dict key order +- **Automatic schema creation** — All SQLModel tables are created on initialization -`__init__` also calls `SQLModel.metadata.create_all(self.engine)`, so a fresh or in-memory -`Database` gets its full schema immediately. - -### JSON key ordering - -The engine is configured with a custom `json_serializer` that sorts dict keys -(`json.dumps(..., sort_keys=True)`). SQLite compares JSON columns as opaque text, so without -this, two Python dicts with the same key/value pairs but different insertion order would -serialize to different strings and fail to match in a query like: +## Creating a Database ```python -CalculationRow.input_provenance == {"seed": 1, "source": "orca"} -``` - -This affects any JSON-backed column: `CalculationRow.input_provenance`/`output_provenance`, -`ValidationRow.extras`, `GeometryRow.symbols`, and `TrajectoryGeometryLink.index`. +from autostorage.database import Database -## Session model and thread-safety +# Create or connect to a database +db = Database("path/to/database.db") -`Database` opens exactly one long-lived `Session` in `__init__` and reuses it for every method -call — `session()` is a context manager that yields that same session rather than creating a -fresh one per call. This matters in two ways: - -- Rows returned from a query stay attached to the session after the call returns, so - lazy-loaded relationships (e.g. `energy.geometry`, `calc.model`) keep working afterward. -- A `Database` instance is **not safe for concurrent use by multiple threads.** The engine is - created with `connect_args={"check_same_thread": False}`, but that only lifts SQLite's - restriction on using the underlying DBAPI connection from a different thread than it was - opened on (e.g. handing the whole `Database` off to a single background worker thread) — it - does not make the `Session` itself safe for concurrent access. Two threads calling methods on - the same `Database` at the same time is unsupported. - -If an operation raises inside the `session()` context manager, the session is rolled back -before the exception propagates, so a failed call doesn't leave partially-staged changes behind -for the next call. - -## Opening and closing - -```python -db = Database("workflow.db") -... -db.close() +# Enable SQL logging for debugging +db = Database("path/to/database.db", echo=True) ``` -`close()` disposes the underlying engine (and its connection pool). `Database` also supports -the context-manager protocol: +The database file is created if it doesn't exist. All tables defined in `autostorage.models` are automatically created via SQLModel metadata. -```python -with Database("workflow.db") as db: - ... -``` +## Using Sessions -On a clean exit this just calls `close()`. If the `with` block raises, the session is rolled -back first, then closed — so an exception partway through a multi-step workflow can't leave -uncommitted changes lingering in the session. - -## Writing rows - -```python -db.add(row) # stage a single row -db.add_all([row1, row2]) # stage multiple rows -db.commit() # write staged changes, commit the transaction -``` - -`add`/`add_all` only **stage** rows in the session — nothing is validated or written to the -database until the next `flush()` or `commit()`. This means integrity errors (unique constraint -violations) and the shape-validation event listeners (see [Events](events.md)) raise at that -later point, not at the `add` call site. +Sessions manage transactions and provide the query interface. Always use sessions as context managers to ensure proper cleanup: ```python -db.flush() +from autostorage.database import Database +from autostorage.models import GeometryRow + +db = Database("molecules.db") + +# Create a session context +with db.session() as session: + # Add a geometry + geom = GeometryRow(symbols=["C", "H", "H", "H", "H"], coordinates=[[0.0, 0.0, 0.0], ...]) + session.add(geom) + session.commit() # Explicitly commit changes + + # Query geometries + results = session.exec(select(GeometryRow)).all() ``` -Flushes pending changes without committing the transaction — useful for surfacing -validation/integrity errors early, or for getting DB-assigned values (like an autoincrement -`id`) onto an object before you need to reference it. Unlike `commit()`, `flush()` doesn't -trigger SQLAlchemy's `expire_on_commit` behavior, so it follows up with `session.expire_all()` -— otherwise an already-loaded object whose row was removed by a `ON DELETE CASCADE` during this -flush would read back stale (pre-deletion) data on next access instead of raising. - -```python -merged = db.merge(row) -``` +**Important**: Each call to `db.session()` creates a **new** session. Nothing is committed automatically — you must call `session.commit()` to persist changes. -`merge()` copies the state of a (possibly detached) row onto the identity-matched row already -tracked by the session — or inserts it if there is none — commits, and returns the merged -instance. Use this instead of `add()` when you're not sure whether the row is already attached -to this session's identity map (e.g. it was loaded by a different `Database`/session, or -round-tripped through serialization). +### Session Lifecycle ```python -db.delete(row) +with db.session() as session: + # Add/modify rows + session.add(row) + + # Flush to DB without committing (assigns IDs, checks constraints) + session.flush() + + # Commit the transaction + session.commit() + +# Session is automatically closed here ``` -Deletes a row and commits immediately (there's no separate staged-delete-then-flush step, since -`add`/`add_all` are the only staging-only methods). +For more details on session usage, querying, transaction control, and advanced patterns, see the [SQLAlchemy Session documentation](https://docs.sqlalchemy.org/en/21/orm/session_basics.html). -## Reading rows +## Closing the Database -By primary key: +When finished with a database, dispose of the connection pool: ```python -row = db.get(GeometryRow, 3) # raises LookupError if missing -row = db.get_or_none(GeometryRow, 3) # returns None if missing -``` - -By a `select()` statement (from `sqlmodel`, or the `SelectStatement`/`Select`/`SelectOfScalar` -aliases re-exported from `autostorage.database`): - -```python -from sqlmodel import select -from autostorage import CalcType, CalculationRow - -stmt = select(CalculationRow).where(CalculationRow.calc_type == CalcType.ENERGY) - -db.exec_first(stmt) # first match, or None -db.exec_one(stmt) # the single match; raises LookupError on zero or >1 matches -db.exec_all(stmt) # list of all matches -db.exists(stmt) # bool, via a single EXISTS subquery — never materializes a row +db.close() ``` -`exec_one` wraps SQLAlchemy's `NoResultFound`/`MultipleResultsFound` and re-raises both as -`LookupError`, so callers only need to handle one exception type regardless of which way the -query failed to return exactly one row. - -Most model classes also expose their own `query()`/`find_or_create()` classmethods built on -top of these primitives (e.g. `ModelRow.find_or_create`, `EnergyRow.query`, -`StationaryPointRow.query`) — prefer those where available, since they encode the right lookup -key for that row type. See [Data model](data-model.md) for the full list. - -## Exceptions raised through `Database` - -- `LookupError` — from `get()` (missing id) and `exec_one()` (zero or multiple matches). -- `autostorage.exc.MissingPrimaryKeyError` — raised by model `query()`/`find_or_create()` - methods when a row passed in hasn't been persisted yet (no `id`), since the query can't be - built without one. Call `db.add()`/`db.merge()` and `db.commit()`/`db.flush()` first. -- `autostorage.exc.ResultShapeError` — raised on `flush()`/`commit()` if a `GradientRow` or - `HessianRow` value doesn't match the shape implied by its geometry's atom count. See - [Events](events.md#shape-validation). -- SQLAlchemy's `IntegrityError` — for constraint violations (unique constraints, `NOT NULL`, - `CheckConstraint`s) that reach the database itself rather than being caught by an app-level - `query()`/`find_or_create()` lookup first. +This is typically unnecessary for short-lived scripts but recommended for long-running applications. diff --git a/docs/source/development.md b/docs/source/development.md deleted file mode 100644 index 71eb59d..0000000 --- a/docs/source/development.md +++ /dev/null @@ -1,120 +0,0 @@ -# Development - -## Setup - -```bash -git clone -cd autostorage -pixi run init -``` - -`pixi run init` runs `scripts/setup.sh` (direnv, git init/first commit if needed) and then -installs the [lefthook](https://github.com/evilmartians/lefthook) git hooks that run the -pre-commit pipeline described below. All other tasks in this section assume a Pixi install and -are invoked as `pixi run ` (task definitions live in `pixi.toml` under -`[feature.dev.tasks]`). - -## Everyday tasks - -```{list-table} -:header-rows: 1 - -* - Task - - What it runs -* - `pixi run fmt` - - `ruff format .` — code formatting. -* - `pixi run lint` - - `ruff check . --fix` — linting, with `select = ["ALL"]` in `pyproject.toml` (a small, - documented ignore list carves out specific rules). -* - `pixi run types` - - `ty check` — static type-checking. -* - `pixi run imports` - - `lint-imports` — enforces the [module layering](data-model.md#module-layering) contract. -* - `pixi run test` - - `pytest`, with coverage. -* - `pixi run pre-commit` - - Runs all of the above in order, then checks the working tree is clean. -* - `pixi run cov-view` - - Opens the HTML coverage report (`htmlcov/index.html`) in `$BROWSER`. -``` - -A single test: - -```bash -pixi run -e dev pytest tests/test_models.py::test_name -``` - -## Testing conventions - -- `testpaths = ["tests", "src"]` in `pyproject.toml` — pytest collects both the `tests/` - directory and `src/`. -- `--doctest-modules` is always on, so any doctest example (`>>> ...`) in a `src/` docstring is - collected and executed as a test. Keep them accurate and runnable — a stale example fails the - suite, not just the docs. -- Coverage runs in branch mode (`[tool.coverage.run] branch = true`) with a hard floor: - `fail_under = 80` in `[tool.coverage.report]`. `pixi run test` fails if coverage drops below - that, independent of whether individual tests pass. -- `tests/conftest.py` provides shared fixtures: an in-memory `database` fixture - (`Database(":memory:")`, closed on teardown), a seeded `rng`, and baseline `model_row`/ - `geometry_row`/`calculation_row`/`calc_geo_link` fixtures used across `test_models.py` and - `test_database.py`. - -## Pre-commit pipeline - -`lefthook.yaml` defines two non-parallel command sequences, run via `pixi run pre-commit` / -`pixi run local-pre-commit`: - -1. `fmt` → `lint` → `types` → `imports` → `test` → `git diff --exit-code` (fails if formatting/ - linting left the tree dirty). -2. The `local-pre-commit` variant additionally runs `pixi run local start`/`stop` around the - same sequence, for workflows that need local services up during tests. - -CI (`.github/workflows/test.yml`) runs `pixi run pre-commit` on every push/PR, then `pixi run -test` again separately to publish the coverage report. - -## Docs - -```bash -pixi run docs-build # sphinx-build docs/source docs/build -pixi run docs-view # docs-build, then open docs/build/index.html in $BROWSER -``` - -`docs-view` requires the `BROWSER` environment variable to point at a browser executable (see -`scripts/view-docs.sh`). - -Docs are built with Sphinx + [MyST](https://myst-parser.readthedocs.io/) (Markdown) + -[sphinx-autodoc2](https://sphinx-autodoc2.readthedocs.io/) for the {doc}`API reference -`, using the `pydata-sphinx-theme`. Docstrings are written in NumPy style -(`tool.ruff.lint.pydocstyle.convention = "numpy"`) but rendered through a small custom parser -(`docs/source/autodoc2_docstrings_parser.py`) that runs them through -`sphinx.ext.napoleon.docstring.NumpyDocstring` before handing off to MyST — this is what lets -`autodoc2`, which doesn't natively understand NumPy-style sections, render them correctly. -Section headings get anchor links (`myst_heading_anchors = 3` in `conf.py`), which is what the -`#section-name` links throughout this documentation rely on. - -`.github/workflows/docs.yml` runs `pixi run docs-build` and deploys `docs/build/` to GitHub -Pages on every push to `main`. - -## Release process - -Releases are driven by [tbump](https://github.com/your-tools/tbump) (`tbump.toml`), which bumps -the version string in `pyproject.toml`, `pixi.toml`, and `src/autostorage/__init__.py` in one -step, then (via its `before_commit` hooks) regenerates `CHANGELOG.md` with -[keepachangelog](https://keepachangelog.com/) and re-locks `pixi.lock`, and finally tags the -commit `v{new_version}`. - -```bash -pixi run version # print the current version -pixi run release # tbump — walks through the bump interactively -``` - -Pushing a `v*.*.*` tag triggers `.github/workflows/release.yml`, which builds both a conda -package (`pixi run build-conda`) and a PyPI package (`pixi run build-pypi`), publishes both -(`publish-conda` to Anaconda.org, `publish-pypi` to PyPI — both need their respective secrets in -CI), and creates a GitHub Release with notes extracted from `CHANGELOG.md`. - -## Coding standards - -See `CONTRIBUTING.md` for naming conventions (`Row`-suffixed SQLModel classes vs. domain -models) and cross-package conversion ownership rules shared across the `automol`/`autostorage` -suite. diff --git a/docs/source/events.md b/docs/source/events.md deleted file mode 100644 index a4d686c..0000000 --- a/docs/source/events.md +++ /dev/null @@ -1,186 +0,0 @@ -# Events (automatic behavior) - -{py:mod}`autostorage.events` registers SQLAlchemy ORM event listeners at import time. Because -`autostorage.database` imports `.events` (for its side effect of registering these listeners) -before opening any `Database`, everything on this page applies to **any** session touching -these models — not just calls made through `Database`'s own methods. - -Listeners are registered two ways, and the choice between them matters: - -- **Mapper-level** (`before_insert`/`before_update`/`before_delete` on a specific model) — runs - once per row of that type, as part of that row's own flush step. -- **Session-level** (`before_flush` on `Session`) — runs once per flush, before any mapper-level - events, with access to the whole pending change set (`session.new`/`dirty`/`deleted`). - -Three listeners below are session-level specifically because they need to **mutate a different, -already-clean object** than the one that triggered them (e.g. deleting a `HessianRow` needs to -update a `StationaryPointRow.is_valid` that isn't itself being inserted/updated/deleted). -SQLAlchemy silently drops attribute changes made to other, already-clean objects from within a -mapper-level `before_delete`/`before_insert`/`before_update` handler — those objects aren't part -of the acting object's already-computed flush plan. A session-level `before_flush` listener runs -before that plan is fixed, so changes it makes are picked up. - -## Shape validation - -```{eval-rst} -.. autofunction:: autostorage.events.verify_gradient_shape - :no-index: -.. autofunction:: autostorage.events.verify_hessian_shape - :no-index: -``` - -Both run on `before_insert`/`before_update` for their row type. `GradientRow.value` must have -shape `(3 * atom_count,)`; `HessianRow.value` must be `(3 * atom_count, 3 * atom_count)`, where -`atom_count` comes from the row's linked `GeometryRow`. A mismatch raises -{py:class}`~autostorage.exc.ResultShapeError` — which surfaces at `flush()`/`commit()` time, not -at the point the row was constructed or `add()`-ed. - -Both listeners resolve the geometry via a shared helper, `_resolve_geometry`, rather than -reading `target.geometry` directly: if a row was built with only `geometry_id=...` set (not the -`.geometry` relationship), the relationship stays unpopulated until the ORM syncs it — reading -it directly would let shape validation be silently skipped for a row that does have a geometry. -`_resolve_geometry` falls back to `session.get(GeometryRow, target.geometry_id)` in that case. -The same pattern reappears below for `StepRow`'s stage relationships -(`_resolve_stage`). - -## Stationary-point validity - -```{eval-rst} -.. autofunction:: autostorage.events.validate_geometry_orders - :no-index: -.. autofunction:: autostorage.events.revalidate_geometry_orders_on_hessian_delete - :no-index: -``` - -`StationaryPointRow.is_valid` is derived, not set directly by application code. It's recomputed -by `_recompute_geometry_stationary_validity(geometry)` whenever the set of `HessianRow`s -attached to a geometry changes: - -1. Collect the geometry's `HessianRow`s (minus any passed via `excluding=`, see below). -2. Compute each Hessian's `order` (the count of negative harmonic frequencies — see - {py:attr}`~autostorage.models.HessianRow.order`) and take the set of distinct orders. -3. If more than one distinct order is present, raise `ValueError` — the geometry's Hessians - disagree, which can't be reconciled automatically. -4. Otherwise, for every `StationaryPointRow` on that geometry, set `is_valid = (stationary.order - == expected_order)`, where `expected_order` is the one agreed-upon Hessian order. - -This recompute is wired to three triggers: - -- `validate_geometry_orders` — a mapper-level `before_insert`/`before_update` listener on both - `StationaryPointRow` and `HessianRow`, covering the common case of inserting/editing either - side. -- `revalidate_geometry_orders_on_hessian_delete` — a **session-level** `before_flush` listener - that reacts to `HessianRow` deletions. It's session-level (not a mapper `before_delete` - listener) for the reason given in the section intro: it needs to write to - `StationaryPointRow.is_valid` on objects other than the one being deleted. It passes the - about-to-be-deleted Hessians as `excluding=` to `_recompute_geometry_stationary_validity`, - since `geometry.hessians` still includes them at `before_flush` time (the `DELETE` hasn't been - issued to the database yet). - -If a geometry has no Hessians, or no `StationaryPointRow`s, the recompute is a no-op — `is_valid` -keeps whatever value it already had. - -## Geometry immutability - -```{eval-rst} -.. autofunction:: autostorage.events.verify_geometry_immutable_fields - :no-index: -``` - -Once a `GeometryRow` has been inserted, `symbols` and `coordinates` can never be changed — -attempting to modify either raises `ValueError` on the next `flush()`/`commit()`. `charge` and -`spin` remain freely mutable. This is enforced via `sqlalchemy.orm.attributes.get_history`, -which reports whether a field has pending added/deleted values since it was loaded; if the field -has never been touched, `get_history` reports no change and the update passes. - -The reason: `GradientRow`/`HessianRow` shape checks (above) and Hessian order consensus are -computed once, against the geometry as it existed when those results were saved. Silently -allowing `symbols`/`coordinates` to change afterward would invalidate checks already performed -without re-running them. - -## Automatic identity attachment - -```{eval-rst} -.. autofunction:: autostorage.events.add_inchi_identities - :no-index: -``` - -A session-level `before_flush` listener. For every new (`session.new`) `StationaryPointRow`, it: - -1. Computes an InChI `IdentityRow` from the point's geometry via - `IdentityRow.from_geometry(geo=..., algorithm=Algorithm.RDKIT_INCHI)`. If this raises - `ValueError` (e.g. `automol`/RDKit can't derive an InChI for the geometry), the point is - skipped — no identity is attached and no error propagates. -2. Batches all resulting `(algorithm, value)` pairs into a single `tuple_(...).in_(...)` query - against existing `IdentityRow`s, so N new stationary points cost one lookup query rather than - N. -3. If a matching InChI `IdentityRow` already exists, reuses it (appends the *existing* row to - `obj.identities`) instead of creating a duplicate — this is what backs the - `unique_identity` constraint on `(kind, algorithm, value)` staying meaningful in practice - rather than being hit as an integrity error. -4. Otherwise, attaches the newly-computed InChI row, and additionally derives a SMILES string - via `Algorithm.RDKIT_SMILES`. The SMILES is **not** stored as its own `IdentityRow` — it's - attached as an `IdentityExtraRow(identity=inchi, attribute="smiles", value=...)` on the InChI - identity just created. If SMILES derivation raises `ValueError`, it's silently skipped (the - InChI identity is still attached). - -## Conformer grouping - -```{eval-rst} -.. autofunction:: autostorage.events.assign_conformer_ids - :no-index: -``` - -Also a session-level `before_flush` listener, run after `add_inchi_identities` has had a chance -to populate InChI identities on new stationary points (both are registered against the same -event; SQLAlchemy invokes same-event listeners in registration order, and `events.py` defines -`add_inchi_identities` first). - -For each new `StationaryPointRow` that doesn't already have an IRMSD-kind identity -(`Algorithm.IRMSD.kind`) but does have an InChI identity: - -1. Look at the InChI identity's other attached stationary points ("peers") — points that share - the same InChI, i.e. the same constitutional/stereo identity. -2. Compare the new point's geometry against each peer's via - `automol.geom.is_duplicate_conformer`. -3. If one matches, reuse that peer's IRMSD identity (its conformer-group id) via - `_matching_conformer_identity` — same conformer, same group. -4. Otherwise, allocate a new group id: `max(existing IRMSD values cast to int) + 1`, or `1` if - none exist yet, and create a new IRMSD `IdentityRow` with that value. - -```{note} -Group-id allocation assumes single-writer semantics, consistent with `Database`'s documented -[non-thread-safety](database.md#session-model-and-thread-safety). If that assumption is -violated (e.g. two processes/threads racing against the same on-disk database), the -`unique_identity` constraint on `IdentityRow` turns a racing duplicate group id into an -`IntegrityError` at commit — rather than silently merging two distinct conformer groups under -one id. -``` - -Within a single flush, multiple new non-duplicate points increment `next_group_id` locally -(instead of re-querying `MAX(...)` for each one), so N new unrelated conformers correctly get N -distinct, consecutive group ids from one query. - -## Reaction step consistency - -```{eval-rst} -.. autofunction:: autostorage.events.verify_stage_order_and_barrierless - :no-index: -.. autofunction:: autostorage.events.verify_stage_ts_consistency - :no-index: -``` - -Both are mapper-level `before_insert`/`before_update` listeners on `StepRow`: - -- `verify_stage_order_and_barrierless` sorts `stage_id1 < stage_id2` (swapping them if - necessary) so every `StepRow` satisfies the `chk_stage_order` `CheckConstraint` regardless of - which order the caller passed `stage1`/`stage2` in, and derives - `is_barrierless = not stage_id_ts` — callers never set `is_barrierless` directly. -- `verify_stage_ts_consistency` rejects `stage1`/`stage2` if either is a transition-state stage - (`StageRow.is_ts`), and rejects `stage_ts` if it *isn't* one, raising `ValueError` on any - mismatch. - -Both resolve stages via `_resolve_stage`, the `StepRow` analogue of `_resolve_geometry` above: -if only a `stage_id*` foreign key was set (not the `stage*`/`stage_ts` relationship), it falls -back to `session.get(StageRow, stage_id)` so the check isn't skipped for a step that does -reference a stage. diff --git a/docs/source/index.md b/docs/source/index.md index b5daa58..bf94040 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,23 +1,13 @@ # autostorage -A [SQLModel](https://sqlmodel.tiangolo.com/)/SQLAlchemy persistence layer for computational -chemistry workflow data, built on top of [`automol`](https://github.com/avcopan/automol). It -stores molecular geometries, chemical identities, trajectories, stationary points, calculation -results (energies, gradients, Hessians), and the calculations/reaction steps that connect them, -as a graph of related rows in a SQLite database. +A [SQLModel](https://sqlmodel.tiangolo.com/)/[SQLAlchemy](https://www.sqlalchemy.org/) persistence layer for computational chemistry workflow data, built on top of [`automol`](https://github.com/avcopan/automol). It stores molecular geometries, chemical identities, trajectories, stationary points, calculation results (energies, gradients, Hessians), and the calculations/reaction steps that connect them, as a graph of related rows in a SQLite database. -Row models extend `automol`'s core data models directly rather than wrapping them — -`GeometryRow` extends `automol.Geometry`, `IdentityRow` extends `automol.Identity` — so any -data already expressed in `automol` types can be persisted with no conversion step. +Row models extend `automol`'s core data models directly rather than wrapping them — `GeometryRow` extends `automol.Geometry`, `IdentityRow` extends `automol.Identity` — so any data already expressed in `automol` types can be persisted with no conversion step. :::{toctree} :maxdepth: 2 :caption: Contents quickstart -data-model database -events -development -apidocs/index ::: diff --git a/docs/source/models.md b/docs/source/models.md new file mode 100644 index 0000000..3a108fb --- /dev/null +++ b/docs/source/models.md @@ -0,0 +1,69 @@ +# Data Models + +autostorage uses SQLModel/SQLAlchemy to define a relational schema for computational chemistry workflow data. The models form a graph structure where molecular geometries, calculations, stationary points, and chemical identities are linked through explicit relationship tables. + +## Model Overview + +The schema is organized into five functional groups: + +### Link Tables + +Association tables implementing many-to-many relationships between entities. + +| Model | Description | +|-------|-------------| +| `CalculationGeometryLink` | Associates geometries with calculations, tracking whether each geometry is an input or output | +| `GeometryTrajectoryLink` | Links geometries to trajectories with position indices | +| `CalculationTrajectoryLink` | Associates trajectories with calculations as inputs or outputs | +| `StageStationaryLink` | Connects stationary points to reaction stages | +| `StepValidationLink` | Links validation calculations to reaction steps | +| `IdentityStationaryLink` | Associates chemical identities with stationary points | + +### Core Data Models + +The fundamental entities representing molecular structures, calculations, and their results. + +| Model | Description | +|-------|-------------| +| `GeometryRow` | Molecular geometry with atomic symbols, coordinates, charge, and spin; extends `automol.Geometry` | +| `TrajectoryRow` | Ordered sequence of geometries from a dynamic calculation | +| `ModelRow` | Calculation model specification (program, method, basis set, keywords) | +| `CalculationRow` | Quantum chemistry calculation with provenance metadata | +| `EnergyRow` | Energy result for a geometry at a specific level of theory | +| `GradientRow` | Energy gradient (forces) for a geometry | +| `HessianRow` | Second derivative matrix (Hessian) for a geometry | +| `ValidationRow` | Validation result (e.g., IRC) for a reaction step | + +### Stationary Points + +Models representing critical points on potential energy surfaces. + +| Model | Description | +|-------|-------------| +| `StationaryPointRow` | A stationary point with Hessian index and validation status | + +### Reaction Network + +Models defining elementary reaction steps and their constituent chemical states. + +| Model | Description | +|-------|-------------| +| `StageRow` | A chemical state in a reaction (reactant, product, or transition state) | +| `StepRow` | An elementary reaction step connecting two stages via a transition state (or barrierless) | + +### Chemical Identities + +Models for chemical identification and classification. + +| Model | Description | +|-------|-------------| +| `IdentityRow` | Queryable chemical identifiers (InChI, AmChI, etc.) with kind and algorithm; extends `automol.Identity` | +| `IdentityExtraRow` | Additional *non-queryable* identities (SMILES, Hill formula, ...) attached to a primary identity | + +## Design Principles + +- **automol integration**: `GeometryRow` and `IdentityRow` extend `automol.Geometry` and `automol.Identity` directly rather than wrapping them, delegating all format conversions to automol +- **Explicit relationships**: Many-to-many relationships use dedicated link tables rather than implicit joins +- **Provenance tracking**: Calculations record both input and output provenance metadata +- **Compressed storage**: NumPy arrays (coordinates, gradients, Hessians) are stored as zlib-compressed binary data via `CompressedArrayTypeDecorator` +- **Graph structure**: The schema forms a directed graph where calculations consume and produce geometries/trajectories, geometries carry results, stationary points reference geometries, and reaction steps connect stages diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 9c626ce..b727744 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -6,7 +6,7 @@ Install as a [Pixi](https://pixi.sh) dependency: ```toml [dependencies] -autostorage = ">=0.0.10" +autostorage = ">=0.0.12" ``` Or with `uv`/`pip` from PyPI: @@ -22,7 +22,6 @@ Requires Python ≥3.12. ```python import numpy as np from autostorage import ( - CalcType, CalculationGeometryLink, CalculationRow, Database, @@ -35,42 +34,56 @@ from autostorage import ( # Open (or create) a SQLite database; ":memory:" also works for scratch use. db = Database("workflow.db") -# `find_or_create` dedups on (program, program_version, method, basis). -model = ModelRow.find_or_create(db, program="orca", method="b3lyp", basis="def2-svp") - -calc = CalculationRow(model=model, calc_type=CalcType.ENERGY) -geo = GeometryRow( - symbols=["H", "O", "H"], - coordinates=np.array([[0, 0, 0.8], [0, 0, 0], [0.8, 0, 0]]), - charge=0, - spin=0, -) -link = CalculationGeometryLink.create(calc, geo, role=Role.INPUT) -db.add_all([model, calc, geo, link]) -db.commit() - -# Attach a result to the geometry/calculation pair. -energy = EnergyRow(geometry=geo, calculation=calc, value=-76.02) -db.add(energy) -db.commit() - -# Look the result back up by geometry, model, and input provenance. -found = EnergyRow.query(db, geo=geo, model=model) -assert found is not None -print(found.value) +# Work within a session context. +with db.session() as session: + # Create a model specifying the calculation type, program, method, and basis. + model = ModelRow( + calc_type="energy", + program="orca", + method="b3lyp", + basis="def2-svp", + ) + + # Create a calculation using this model. + calc = CalculationRow(model=model) + + # Create a geometry. + geo = GeometryRow( + symbols=["H", "O", "H"], + coordinates=np.array([[0, 0, 0.8], [0, 0, 0], [0.8, 0, 0]]), + charge=0, + spin=0, + ) + + # Link the geometry to the calculation as an input. + link = CalculationGeometryLink( + calculation=calc, + geometry=geo, + role=Role.INPUT, + ) + + # Add all objects to the session and commit. + session.add_all([model, calc, geo, link]) + session.commit() + + # Attach an energy result to the geometry/calculation pair. + energy = EnergyRow(geometry=geo, calculation=calc, value=-76.02) + session.add(energy) + session.commit() + + # Query the result back by filtering on geometry and calculation. + found = session.query(EnergyRow).filter_by( + geometry_id=geo.id, + calculation_id=calc.id, + ).first() + assert found is not None + print(found.value) db.close() ``` -`Database` also supports the `with` statement, which rolls back on an unhandled exception and -closes the connection on exit: - -```python -with Database("workflow.db") as db: - ... -``` +The `Database.session()` method returns a standard SQLAlchemy `Session` that supports the +context manager protocol. Sessions automatically handle transaction management — commit +explicitly to persist changes, and unhandled exceptions trigger a rollback. -See [Data model](data-model.md) for the schema this creates, [Database](database.md) for the -full `Database` method surface, [Events](events.md) for the automatic validation/enrichment -behavior that runs on every flush, and the {doc}`API reference ` for full details -on every model and method. +See the {doc}`API reference ` for full details on every model and method. diff --git a/examples/scan.py b/examples/scan.py new file mode 100644 index 0000000..5ff5b55 --- /dev/null +++ b/examples/scan.py @@ -0,0 +1,167 @@ +"""Example demonstrating a 2D scan over both O-H bonds in H2O.""" + +import numpy as np + +from autostorage import Database +from autostorage.models import ( + CalculationGeometryLink, + CalculationRow, + CalculationTrajectoryLink, + GeometryRow, + GeometryTrajectoryLink, + ModelRow, + Role, + StageRow, + StageStationaryLink, + StationaryPointRow, + StepRow, + TrajectoryRow, +) + +# Create database +db = Database("example_scan.db", echo=True) + +with db.session() as session: + # Create a 2D scan trajectory (varying both O-H bond lengths) + # ndim=2 for a 2D scan grid + trajectory = TrajectoryRow(ndim=2) + session.add(trajectory) + session.flush() + + # Create scan model (a relaxed scan calculation) + model = ModelRow( + calc_type="scan", + program="psi4", + method="b3lyp", + basis="6-31g*", + ) + session.add(model) + session.flush() + + # Create the scan calculation + calc_scan = CalculationRow(model_id=model.id) + session.add(calc_scan) + session.flush() + + # Link the calculation to the trajectory it produced + calc_traj_link = CalculationTrajectoryLink( + calculation_id=calc_scan.id, + trajectory_id=trajectory.id, + role=Role.OUTPUT, + ) + session.add(calc_traj_link) + + # Generate a simple 3x3 grid of H2O geometries varying both O-H bonds + # Base geometry: equilibrium-ish H2O + scan_geometries = [] + r1_values = [0.90, 0.96, 1.02] # First O-H bond lengths (Angstrom) + r2_values = [0.90, 0.96, 1.02] # Second O-H bond lengths (Angstrom) + + for i, r1 in enumerate(r1_values): + for j, r2 in enumerate(r2_values): + # Create H2O geometry with varying bond lengths + # O at origin, H atoms along x and symmetric about xz-plane + geom = GeometryRow( + symbols=["O", "H", "H"], + coordinates=np.array( + [ + [0.0, 0.0, 0.0], + [ + r1 * np.cos(np.radians(52)), + r1 * np.sin(np.radians(52)), + 0.0, + ], + [ + r2 * np.cos(np.radians(52)), + -r2 * np.sin(np.radians(52)), + 0.0, + ], + ] + ), + charge=0, + spin=0, + ) + session.add(geom) + scan_geometries.append((geom, (i, j))) + + session.flush() + + # Link each geometry to the trajectory with its scan index + for geom, (i, j) in scan_geometries: + geom_traj_link = GeometryTrajectoryLink( + geometry_id=geom.id, + trajectory_id=trajectory.id, + index=(i, j), # 2D index in the scan grid + ) + session.add(geom_traj_link) + + # Also link each geometry as an output of the scan calculation + geom_calc_link = CalculationGeometryLink( + geometry_id=geom.id, + calculation_id=calc_scan.id, + role=Role.OUTPUT, + ) + session.add(geom_calc_link) + + session.flush() + + # Create pseudo stationary points for the scan endpoints and a TS + # Start point: shortest bonds (0, 0) + stat_start = StationaryPointRow( + geometry_id=scan_geometries[0][0].id, # (0, 0) + calculation_id=calc_scan.id, + order=0, + is_pseudo=True, + is_valid=False, + ) + + # End point: longest bonds (2, 2) + stat_end = StationaryPointRow( + geometry_id=scan_geometries[-1][0].id, # (2, 2) + calculation_id=calc_scan.id, + order=0, + is_pseudo=True, + is_valid=False, + ) + + # Pseudo TS: center of the scan grid (1, 1) + middle_geom = next(g for g, idx in scan_geometries if idx == (1, 1)) + stat_ts = StationaryPointRow( + geometry_id=middle_geom.id, + calculation_id=calc_scan.id, + order=1, + is_pseudo=True, + is_valid=False, + ) + + session.add_all([stat_start, stat_end, stat_ts]) + session.flush() + + # Create stages for the start, end, and TS points + stage_start = StageRow(is_ts=False) + stage_end = StageRow(is_ts=False) + stage_ts = StageRow(is_ts=True) + session.add_all([stage_start, stage_end, stage_ts]) + session.flush() + + # Link stationary points to stages + session.add_all( + [ + StageStationaryLink(stationary_id=stat_start.id, stage_id=stage_start.id), + StageStationaryLink(stationary_id=stat_end.id, stage_id=stage_end.id), + StageStationaryLink(stationary_id=stat_ts.id, stage_id=stage_ts.id), + ] + ) + session.flush() + + # Create a step connecting start and end through the pseudo TS + step = StepRow( + stage_id1=min(stage_start.id, stage_end.id), + stage_id2=max(stage_start.id, stage_end.id), + stage_id_ts=stage_ts.id, + is_barrierless=False, + ) + session.add(step) + + # Commit all changes + session.commit() diff --git a/examples/stationary.py b/examples/stationary.py new file mode 100644 index 0000000..1458e4f --- /dev/null +++ b/examples/stationary.py @@ -0,0 +1,195 @@ +"""Example demonstrating stationary points, stages, and steps.""" + +import numpy as np + +from autostorage import Database +from autostorage.models import ( + CalculationGeometryLink, + CalculationRow, + EnergyRow, + GeometryRow, + GradientRow, + HessianRow, + ModelRow, + Role, + StageRow, + StageStationaryLink, + StationaryPointRow, + StepRow, +) + +# Create database +db = Database("example_stationary.db", echo=True) + +with db.session() as session: + # Create a simple H2O geometry (reactant) + geom1 = GeometryRow( + symbols=["O", "H", "H"], + coordinates=np.array( + [ + [0.0, 0.0, 0.0], + [0.0, 0.757, 0.587], + [0.0, -0.757, 0.587], + ] + ), + charge=0, + spin=0, + ) + session.add(geom1) + + # Create a second geometry (product - slightly different H2O) + geom2 = GeometryRow( + symbols=["O", "H", "H"], + coordinates=np.array( + [ + [0.0, 0.0, 0.0], + [0.0, 0.800, 0.600], + [0.0, -0.800, 0.600], + ] + ), + charge=0, + spin=0, + ) + session.add(geom2) + session.flush() + + # Create a calculation model + model = ModelRow( + calc_type="OPT", + program="psi4", + method="b3lyp", + basis="6-31g*", + ) + session.add(model) + session.flush() + + # Create calculations that identified the stationary points + calc1 = CalculationRow(model_id=model.id) + calc2 = CalculationRow(model_id=model.id) + session.add_all([calc1, calc2]) + session.flush() + + # Link each geometry to the optimization that produced it as an output + link_geom1 = CalculationGeometryLink( + geometry_id=geom1.id, calculation_id=calc1.id, role=Role.OUTPUT + ) + link_geom2 = CalculationGeometryLink( + geometry_id=geom2.id, calculation_id=calc2.id, role=Role.OUTPUT + ) + session.add_all([link_geom1, link_geom2]) + + # Add energy results for both geometries + energy1 = EnergyRow( + geometry_id=geom1.id, + calculation_id=calc1.id, + value=-76.4268193, # Hartree + ) + energy2 = EnergyRow( + geometry_id=geom2.id, + calculation_id=calc2.id, + value=-76.4265821, # Hartree (slightly higher energy) + ) + session.add_all([energy1, energy2]) + + # Add gradient results (3 atoms x 3 coords = 9 values, flattened) + gradient1 = GradientRow( + geometry_id=geom1.id, + calculation_id=calc1.id, + value=np.array( + [ + 0.0001, + -0.0002, + 0.0003, # O atom gradient + -0.0001, + 0.0001, + -0.0002, # H1 atom gradient + 0.0000, + 0.0001, + -0.0001, # H2 atom gradient + ] + ), + ) + gradient2 = GradientRow( + geometry_id=geom2.id, + calculation_id=calc2.id, + value=np.array( + [ + 0.0002, + -0.0003, + 0.0001, + -0.0002, + 0.0002, + -0.0001, + 0.0001, + 0.0001, + 0.0000, + ] + ), + ) + session.add_all([gradient1, gradient2]) + + # Add hessian results (9x9 matrix for 3 atoms) + # Create a simple symmetric positive-definite hessian + hess1_matrix = np.random.RandomState(42).randn(9, 9) * 0.1 + hess1_matrix = (hess1_matrix + hess1_matrix.T) / 2 # Make symmetric + hess1_matrix += np.eye(9) * 2 # Make positive-definite + + hess2_matrix = np.random.RandomState(43).randn(9, 9) * 0.1 + hess2_matrix = (hess2_matrix + hess2_matrix.T) / 2 + hess2_matrix += np.eye(9) * 2 + + hessian1 = HessianRow( + geometry_id=geom1.id, + calculation_id=calc1.id, + value=hess1_matrix.astype(np.float32), + ) + hessian2 = HessianRow( + geometry_id=geom2.id, + calculation_id=calc2.id, + value=hess2_matrix.astype(np.float32), + ) + session.add_all([hessian1, hessian2]) + session.flush() + + # Create stationary points (both are minima: order=0) + stat1 = StationaryPointRow( + geometry_id=geom1.id, + calculation_id=calc1.id, + order=0, + is_pseudo=False, + is_valid=True, + ) + stat2 = StationaryPointRow( + geometry_id=geom2.id, + calculation_id=calc2.id, + order=0, + is_pseudo=False, + is_valid=True, + ) + session.add_all([stat1, stat2]) + session.flush() + + # Create stages (both are non-TS stages) + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + session.add_all([stage1, stage2]) + session.flush() + + # Link stationary points to stages + link1 = StageStationaryLink(stationary_id=stat1.id, stage_id=stage1.id) + link2 = StageStationaryLink(stationary_id=stat2.id, stage_id=stage2.id) + session.add_all([link1, link2]) + session.flush() + + # Create a barrierless step connecting the two stages + # StepRow requires stage_id1 < stage_id2 + step = StepRow( + stage_id1=min(stage1.id, stage2.id), + stage_id2=max(stage1.id, stage2.id), + stage_id_ts=None, # No transition state (barrierless) + is_barrierless=True, + ) + session.add(step) + + # Commit all changes + session.commit() diff --git a/examples/stationary_point.py b/examples/stationary_point.py deleted file mode 100644 index 6e5abd0..0000000 --- a/examples/stationary_point.py +++ /dev/null @@ -1,256 +0,0 @@ -"""End-to-end example of the non-reaction parts of the autostorage data model. - -Builds a synthetic water (H2O) optimization + frequency workflow entirely from literal -coordinates (no external file I/O), touching every row model except `StageRow`/`StepRow` -and their reaction-specific link tables. - -Run with:: - - pixi run -e dev python examples/stationary_point.py -""" - -import numpy as np -from automol import Algorithm -from numpy.random import Generator - -from autostorage import ( - CalcType, - CalculationGeometryLink, - CalculationRow, - Database, - EnergyRow, - GeometryRow, - GradientRow, - HessianRow, - IdentityRow, - ModelRow, - Role, - StationaryPointRow, - TrajectoryRow, - ValidationRow, -) -from autostorage.models import ( - CalculationTrajectoryLink, - StationaryIdentityLink, - TrajectoryGeometryLink, -) - - -def optimize_water( - db: Database, model: ModelRow -) -> tuple[CalculationRow, GeometryRow, GeometryRow]: - """Run a synthetic optimization, linking an input geometry to its output.""" - opt_calc = CalculationRow(model=model, calc_type=CalcType.OPT) - input_geo = GeometryRow( - symbols=["H", "O", "H"], - coordinates=np.array([[0, 0, 0.9], [0, 0, 0], [0.85, 0.1, 0]]), - charge=0, - spin=0, - ) - optimized_geo = GeometryRow( - symbols=["H", "O", "H"], - coordinates=np.array([[0, 0, 0.8], [0, 0, 0], [0.8, 0, 0]]), - charge=0, - spin=0, - ) - db.add_all( - [ - opt_calc, - input_geo, - optimized_geo, - CalculationGeometryLink.create(opt_calc, input_geo, role=Role.INPUT), - CalculationGeometryLink.create(opt_calc, optimized_geo, role=Role.OUTPUT), - ] - ) - db.commit() - print( - f"optimization calculation {opt_calc.id}: " - f"{len(opt_calc.input_geometries)} input, " - f"{len(opt_calc.output_geometries)} output geometry/geometries" - ) - return opt_calc, input_geo, optimized_geo - - -def mark_stationary_point( - db: Database, opt_calc: CalculationRow, optimized_geo: GeometryRow -) -> StationaryPointRow: - """Record the optimized geometry as a stationary point. - - InChI and conformer identities are attached automatically on commit (see - `autostorage.events.add_inchi_identities`/`assign_conformer_ids`) -- this - function doesn't construct those manually, only reads them back. - """ - stationary = StationaryPointRow( - calculation=opt_calc, geometry=optimized_geo, order=0 - ) - db.add(stationary) - db.commit() - - inchi = stationary.identity(kind="stereoisomer") - conformer = stationary.identity(algorithm=Algorithm.IRMSD) - assert inchi is not None - assert conformer is not None - print( - f"stationary point {stationary.id} " - f"(order={stationary.order}, is_valid={stationary.is_valid}):" - ) - print(f" auto-attached InChI: {inchi.value}") - print(f" auto-attached conformer id: {conformer.value}") - for extra in inchi.identity_extras: - print(f" auto-attached {extra.attribute}: {extra.value}") - - # Algorithms outside `events.AUTO_MANAGED_IDENTITY_ALGORITHMS` (e.g. a - # human-curated SMILES) have to be attached explicitly. - smiles = IdentityRow.find_or_create(db, algorithm=Algorithm.RDKIT_SMILES, value="O") - db.add(StationaryIdentityLink(stationary_id=stationary.id, identity_id=smiles.id)) - db.commit() - print(f" manually-attached SMILES: {smiles.value}") - - return stationary - - -def record_trajectory( - db: Database, - opt_calc: CalculationRow, - input_geo: GeometryRow, - optimized_geo: GeometryRow, -) -> None: - """Record a trajectory for the optimization path (input -> optimized).""" - trajectory = TrajectoryRow() - db.add(trajectory) - db.commit() - db.add_all( - [ - TrajectoryGeometryLink.create(input_geo, trajectory, index=[0]), - TrajectoryGeometryLink.create(optimized_geo, trajectory, index=[1]), - CalculationTrajectoryLink.create(opt_calc, trajectory, role=Role.OUTPUT), - ] - ) - db.commit() - print( - f"trajectory {trajectory.id} holds {len(trajectory.geometry_links)} geometries" - ) - - -def run_frequency_calc( - db: Database, - model: ModelRow, - optimized_geo: GeometryRow, - stationary: StationaryPointRow, - rng: Generator, -) -> None: - """Attach a Gradient and Hessian to the optimized geometry. - - `is_valid` on `stationary` is recomputed automatically whenever a Hessian is - added for its geometry (see `autostorage.events.validate_geometry_orders`), - reflecting whether the point's declared `order` agrees with the (here, - randomly generated, so not necessarily consistent) Hessian's order. - """ - freq_calc = CalculationRow(model=model, calc_type=CalcType.FREQUENCY) - db.add(freq_calc) - db.add(CalculationGeometryLink.create(freq_calc, optimized_geo, role=Role.INPUT)) - db.commit() - - n = optimized_geo.atom_count - gradient = GradientRow( - calculation=freq_calc, geometry=optimized_geo, value=np.zeros(3 * n) - ) - hessian = HessianRow( - calculation=freq_calc, - geometry=optimized_geo, - value=rng.uniform(size=(3 * n, 3 * n)), - ) - db.add_all([gradient, hessian]) - db.commit() - print( - f"frequency calculation {freq_calc.id}: " - f"{len(hessian.harmonic_frequencies)} harmonic frequencies, " - f"order={hessian.order}" - ) - - found_hessian = HessianRow.query(db, geo=optimized_geo, model=model) - assert found_hessian is not None - assert found_hessian.id == hessian.id - print(f"looked up Hessian {found_hessian.id} by geometry + model") - print( - f"stationary point {stationary.id} is_valid is now " - f"{stationary.is_valid} (declared order={stationary.order}, " - f"Hessian order={hessian.order})" - ) - - -def record_transition_point(db: Database, model: ModelRow) -> None: - """Record a distinct stationary point with order=1. - - `order` (minimum vs. saddle point) is a property of the point itself, - independent of any reaction step/stage. - """ - ts_geo = GeometryRow( - symbols=["H", "O", "H"], - coordinates=np.array([[0, 0, 1.0], [0, 0, 0], [1.0, 0.3, 0]]), - charge=0, - spin=0, - ) - ts_calc = CalculationRow(model=model, calc_type=CalcType.OPT_TS) - db.add_all( - [ - ts_calc, - ts_geo, - CalculationGeometryLink.create(ts_calc, ts_geo, role=Role.OUTPUT), - ] - ) - ts_stationary = StationaryPointRow(calculation=ts_calc, geometry=ts_geo, order=1) - db.add(ts_stationary) - db.commit() - print( - f"transition-point candidate {ts_stationary.id} " - f"recorded with order={ts_stationary.order}" - ) - - -def record_energy_and_validation( - db: Database, model: ModelRow, optimized_geo: GeometryRow -) -> None: - """Attach a single-point energy, look it up, and add a standalone validation.""" - energy_calc = CalculationRow(model=model, calc_type=CalcType.ENERGY) - db.add(energy_calc) - db.add(CalculationGeometryLink.create(energy_calc, optimized_geo, role=Role.INPUT)) - db.commit() - - energy = EnergyRow(calculation=energy_calc, geometry=optimized_geo, value=-76.02) - db.add(energy) - db.commit() - - found_energy = EnergyRow.query(db, geo=optimized_geo, model=model) - assert found_energy is not None - print(f"energy calculation {energy_calc.id}: E = {found_energy.value} Hartree") - - # A validation record, standing on its own with no reaction step attached. - validation = ValidationRow( - calculation=energy_calc, - method="geometry_check", - extras={"rmsd_to_input": 0.05}, - ) - db.add(validation) - db.commit() - print(f"validation {validation.id} ({validation.method}): {validation.extras}") - - -def main() -> None: - """Run the example workflow.""" - rng = np.random.default_rng(seed=0) - - with Database("stationary_points.db") as db: - model = ModelRow.find_or_create( - db, program="orca", method="b3lyp", basis="def2-svp" - ) - opt_calc, input_geo, optimized_geo = optimize_water(db, model) - stationary = mark_stationary_point(db, opt_calc, optimized_geo) - record_trajectory(db, opt_calc, input_geo, optimized_geo) - run_frequency_calc(db, model, optimized_geo, stationary, rng) - record_transition_point(db, model) - record_energy_and_validation(db, model, optimized_geo) - - -if __name__ == "__main__": - main() diff --git a/examples/transition.py b/examples/transition.py new file mode 100644 index 0000000..89280d2 --- /dev/null +++ b/examples/transition.py @@ -0,0 +1,212 @@ +"""Example demonstrating reaction network features: stages, steps, and validations.""" + +import numpy as np + +from autostorage import Database +from autostorage.models import ( + CalculationGeometryLink, + CalculationRow, + EnergyRow, + GeometryRow, + ModelRow, + Role, + StageRow, + StageStationaryLink, + StationaryPointRow, + StepRow, + StepValidationLink, + ValidationRow, +) + +# Create database +db = Database("example_transition.db", echo=True) + +with db.session() as session: + # Reactant: pyramidal NH3 (a minimum, order=0) + reactant_geom = GeometryRow( + symbols=["N", "H", "H", "H"], + coordinates=np.array( + [ + [0.0, 0.0, 0.3], + [0.94, 0.0, -0.1], + [-0.47, 0.814, -0.1], + [-0.47, -0.814, -0.1], + ] + ), + charge=0, + spin=0, + ) + + # Transition state: planar NH3, the point of inversion (order=1) + ts_geom = GeometryRow( + symbols=["N", "H", "H", "H"], + coordinates=np.array( + [ + [0.0, 0.0, 0.0], + [0.99, 0.0, 0.0], + [-0.495, 0.857, 0.0], + [-0.495, -0.857, 0.0], + ] + ), + charge=0, + spin=0, + ) + + # Product: mirror-image pyramidal NH3 (a minimum, degenerate with the reactant) + product_geom = GeometryRow( + symbols=["N", "H", "H", "H"], + coordinates=np.array( + [ + [0.0, 0.0, -0.3], + [0.94, 0.0, -0.1], + [-0.47, 0.814, -0.1], + [-0.47, -0.814, -0.1], + ] + ), + charge=0, + spin=0, + ) + session.add_all([reactant_geom, ts_geom, product_geom]) + session.flush() + + # Calculation model used to locate all three stationary points. + model = ModelRow(calc_type="opt", program="psi4", method="b3lyp", basis="6-31g*") + session.add(model) + session.flush() + + calc_reactant = CalculationRow(model_id=model.id) + calc_ts = CalculationRow(model_id=model.id) + calc_product = CalculationRow(model_id=model.id) + session.add_all([calc_reactant, calc_ts, calc_product]) + session.flush() + + # Link each geometry to the optimization that produced it as an output. + session.add_all( + [ + CalculationGeometryLink( + geometry_id=reactant_geom.id, + calculation_id=calc_reactant.id, + role=Role.OUTPUT, + ), + CalculationGeometryLink( + geometry_id=ts_geom.id, + calculation_id=calc_ts.id, + role=Role.OUTPUT, + ), + CalculationGeometryLink( + geometry_id=product_geom.id, + calculation_id=calc_product.id, + role=Role.OUTPUT, + ), + ] + ) + + # Energy results for each point (Hartree); the TS sits above both minima. + energy_reactant = EnergyRow( + geometry_id=reactant_geom.id, calculation_id=calc_reactant.id, value=-56.19513 + ) + energy_ts = EnergyRow( + geometry_id=ts_geom.id, calculation_id=calc_ts.id, value=-56.17021 + ) + energy_product = EnergyRow( + geometry_id=product_geom.id, calculation_id=calc_product.id, value=-56.19513 + ) + session.add_all([energy_reactant, energy_ts, energy_product]) + + # Stationary points: reactant/product are minima (order=0), the TS is a + # first-order saddle point (order=1). + stat_reactant = StationaryPointRow( + geometry_id=reactant_geom.id, calculation_id=calc_reactant.id, order=0 + ) + stat_ts = StationaryPointRow( + geometry_id=ts_geom.id, calculation_id=calc_ts.id, order=1 + ) + stat_product = StationaryPointRow( + geometry_id=product_geom.id, calculation_id=calc_product.id, order=0 + ) + session.add_all([stat_reactant, stat_ts, stat_product]) + session.flush() + + # `add_inchi_identities_before_flush` already attached an InChI identity to + # each stationary point during the flush above; reactant and product share + # the same chemical identity since this inversion is degenerate. + assert stat_reactant.identities + assert {i.value for i in stat_reactant.identities} == { + i.value for i in stat_product.identities + } + + # Stages: one per stationary point, plus the transition-state stage. + stage_reactant = StageRow(is_ts=False) + stage_product = StageRow(is_ts=False) + stage_ts = StageRow(is_ts=True) + session.add_all([stage_reactant, stage_product, stage_ts]) + session.flush() + + # Link each stationary point to its stage. + session.add_all( + [ + StageStationaryLink( + stationary_id=stat_reactant.id, stage_id=stage_reactant.id + ), + StageStationaryLink( + stationary_id=stat_product.id, stage_id=stage_product.id + ), + StageStationaryLink(stationary_id=stat_ts.id, stage_id=stage_ts.id), + ] + ) + session.flush() + + # Elementary step connecting reactant and product through the transition state. + # StepRow requires stage_id1 < stage_id2. + step = StepRow( + stage_id1=min(stage_reactant.id, stage_product.id), + stage_id2=max(stage_reactant.id, stage_product.id), + stage_id_ts=stage_ts.id, + is_barrierless=False, + ) + session.add(step) + session.flush() + + # Validate the step with an IRC calculation confirming it connects the + # reactant and product minima, then link the validation to the step. + irc_model = ModelRow( + calc_type="irc", program="psi4", method="b3lyp", basis="6-31g*" + ) + session.add(irc_model) + session.flush() + + irc_calc = CalculationRow(model_id=irc_model.id) + session.add(irc_calc) + session.flush() + + # The IRC starts from the TS and descends to the reactant and product minima. + session.add_all( + [ + CalculationGeometryLink( + geometry_id=ts_geom.id, calculation_id=irc_calc.id, role=Role.INPUT + ), + CalculationGeometryLink( + geometry_id=reactant_geom.id, + calculation_id=irc_calc.id, + role=Role.OUTPUT, + ), + CalculationGeometryLink( + geometry_id=product_geom.id, + calculation_id=irc_calc.id, + role=Role.OUTPUT, + ), + ] + ) + + irc_validation = ValidationRow( + calculation_id=irc_calc.id, + method="irc", + extras={"connects": "reactant-product"}, + ) + session.add(irc_validation) + session.flush() + + session.add(StepValidationLink(step_id=step.id, validation_id=irc_validation.id)) + + # Commit all changes + session.commit() diff --git a/pixi.lock b/pixi.lock index 393bac6..39a1f33 100644 --- a/pixi.lock +++ b/pixi.lock @@ -15,7 +15,7 @@ environments: - https://pypi.org/simple packages: linux-64: - - conda: https://conda.anaconda.org/avcopan/noarch/automol-0.0.19-pyh4616a5c_0.conda + - conda: https://conda.anaconda.org/avcopan/noarch/automol-0.0.22-pyh4616a5c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda @@ -81,6 +81,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.11.0-py313h6c470cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nauty-2.7.1-h7f98852_2.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.1-py313hf6604e3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda @@ -94,6 +95,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pycairo-1.29.0-py313h3f29d12_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pydantic-core-2.46.4-py313h843e2db_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pynauty-2.8.8.1-py313h07c4f96_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.14-h6add32d_100_cp313.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hda471dd_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda @@ -168,7 +170,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda - - conda_source: autostorage[6f9aa45d] @ . + - conda_source: autostorage[e06c168f] @ . - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/1b/291dd75b5ed494eb484801f39e6572192d322e6cd3d68fab8a6dde743c48/graphrc-1.3.7-py3-none-any.whl @@ -190,7 +192,7 @@ environments: - https://pypi.org/simple packages: linux-64: - - conda: https://conda.anaconda.org/avcopan/noarch/automol-0.0.19-pyh4616a5c_0.conda + - conda: https://conda.anaconda.org/avcopan/noarch/automol-0.0.22-pyh4616a5c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/astroid-3.3.11-py313h78bf25f_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.10.4-hb7a77c6_1.conda @@ -284,6 +286,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py313h3dea7bd_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.11.0-py313h6c470cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nauty-2.7.1-h7f98852_2.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.1-py313hf6604e3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda @@ -298,6 +301,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pycairo-1.29.0-py313h3f29d12_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pydantic-core-2.46.4-py313h843e2db_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pynauty-2.8.8.1-py313h07c4f96_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.14-h6add32d_100_cp313.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py313h3dea7bd_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hda471dd_3.conda @@ -478,7 +482,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.6.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - - conda_source: autostorage[6f9aa45d] @ . + - conda_source: autostorage[e06c168f] @ . - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/23/06/b4f06ca7afb5d9e942c642980c308ffcfa1fa0e8b0a3ddbec78483ef1614/keepachangelog-2.0.0-py3-none-any.whl @@ -496,9 +500,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f8/80/9a966abdb505c5042e14136d4fc6a0ce0a3896d77ca02ab87b288e5417db/xyzgraph-1.6.13-py3-none-any.whl packages: -- conda: https://conda.anaconda.org/avcopan/noarch/automol-0.0.19-pyh4616a5c_0.conda - sha256: 746344a1e334be0f7caa4ca6d5077c7e97b9e508555beb88bf38c78f5f936e66 - md5: a165c2c6913f554f0e0adaa2cfa052ae +- conda: https://conda.anaconda.org/avcopan/noarch/automol-0.0.22-pyh4616a5c_0.conda + sha256: 9839ad1bdd6242d7c1aa7eae8b3d0a8e0119abb9061ecc59390214cc58704484 + md5: 73e696e6d068b272e37994dbd8f1502e depends: - python >=3.12 - python * @@ -509,8 +513,8 @@ packages: - pyparsing >=3.3.2 - rdkit >=2025 - scipy >=1.13 - size: 21750 - timestamp: 1784305892476 + size: 24446 + timestamp: 1787935826323 - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda build_number: 20 sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 @@ -2015,10 +2019,21 @@ packages: license: PSF-2.0 license_family: PSF purls: - - pkg:pypi/matplotlib?source=compressed-mapping + - pkg:pypi/matplotlib?source=hash-mapping run_exports: {} size: 8974852 timestamp: 1782829528425 +- conda: https://conda.anaconda.org/conda-forge/linux-64/nauty-2.7.1-h7f98852_2.tar.bz2 + sha256: 3520ca6684b931267c074caf3ae61d3fc237c42284c6f41fc331811161c60b20 + md5: 33888e561e4aabeca26c7579275573e7 + depends: + - libgcc-ng >=9.3.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 11528033 + timestamp: 1623850946580 - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda sha256: fc89f74bbe362fb29fa3c037697a89bec140b346a2469a90f7936d1d7ea4d8a3 md5: fc21868a1a5aacc937e7a18747acb8a5 @@ -2049,7 +2064,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/numpy?source=compressed-mapping + - pkg:pypi/numpy?source=hash-mapping run_exports: weak: - numpy >=1.25,<3 @@ -2212,7 +2227,7 @@ packages: - openjpeg >=2.5.4,<3.0a0 license: HPND purls: - - pkg:pypi/pillow?source=compressed-mapping + - pkg:pypi/pillow?source=hash-mapping run_exports: {} size: 1077037 timestamp: 1782912080163 @@ -2292,6 +2307,22 @@ packages: run_exports: {} size: 1893749 timestamp: 1778084222642 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pynauty-2.8.8.1-py313h07c4f96_2.conda + sha256: f3e355dd703a3430839a3f682c8979b6d7a155146a872f075381284f9c356c44 + md5: bacc0b34e83d6ebac6cc87840333c93c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - nauty >=2.7.1,<2.7.2.0a0 + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313 + license: GPL-3.0-or-later + license_family: GPL + purls: + - pkg:pypi/pynauty?source=hash-mapping + run_exports: {} + size: 200473 + timestamp: 1756355032363 - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.14-h6add32d_100_cp313.conda build_number: 100 sha256: f2146aff59ce4b571a8f1d1acf94f9bed6cc18ab5632d7dcc940fb48ecdeef99 @@ -3069,7 +3100,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/cached-property?source=compressed-mapping + - pkg:pypi/cached-property?source=hash-mapping run_exports: {} size: 14752 timestamp: 1783242913845 @@ -3092,7 +3123,7 @@ packages: - python >=3.10 license: ISC purls: - - pkg:pypi/certifi?source=compressed-mapping + - pkg:pypi/certifi?source=hash-mapping run_exports: {} size: 133877 timestamp: 1781719949728 @@ -3104,7 +3135,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/charset-normalizer?source=compressed-mapping + - pkg:pypi/charset-normalizer?source=hash-mapping run_exports: {} size: 61418 timestamp: 1783505332569 @@ -3133,7 +3164,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/click?source=compressed-mapping + - pkg:pypi/click?source=hash-mapping run_exports: {} size: 107155 timestamp: 1783085363526 @@ -3220,7 +3251,7 @@ packages: license: BSD-2-Clause license_family: BSD purls: - - pkg:pypi/decorator?source=compressed-mapping + - pkg:pypi/decorator?source=hash-mapping run_exports: {} size: 16102 timestamp: 1779115228886 @@ -3248,7 +3279,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/distlib?source=compressed-mapping + - pkg:pypi/distlib?source=hash-mapping run_exports: {} size: 303705 timestamp: 1781320269259 @@ -3403,7 +3434,7 @@ packages: - uvicorn-standard >=0.12.0 license: MIT purls: - - pkg:pypi/fastapi?source=compressed-mapping + - pkg:pypi/fastapi?source=hash-mapping run_exports: {} size: 104185 timestamp: 1784297339154 @@ -3414,7 +3445,7 @@ packages: - python >=3.10 license: Unlicense purls: - - pkg:pypi/filelock?source=compressed-mapping + - pkg:pypi/filelock?source=hash-mapping run_exports: {} size: 74480 timestamp: 1784269447478 @@ -3556,7 +3587,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/hpack?source=compressed-mapping + - pkg:pypi/hpack?source=hash-mapping run_exports: {} size: 32884 timestamp: 1782283986153 @@ -3627,7 +3658,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/idna?source=compressed-mapping + - pkg:pypi/idna?source=hash-mapping run_exports: {} size: 163869 timestamp: 1781620148226 @@ -3754,7 +3785,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/ipython?source=compressed-mapping + - pkg:pypi/ipython?source=hash-mapping run_exports: {} size: 658801 timestamp: 1782482716877 @@ -3780,7 +3811,7 @@ packages: - python license: Apache-2.0 AND MIT purls: - - pkg:pypi/jedi?source=compressed-mapping + - pkg:pypi/jedi?source=hash-mapping run_exports: {} size: 2715215 timestamp: 1782251948616 @@ -4063,7 +4094,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/platformdirs?source=compressed-mapping + - pkg:pypi/platformdirs?source=hash-mapping run_exports: {} size: 26308 timestamp: 1779972894916 @@ -4140,7 +4171,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/pycparser?source=compressed-mapping + - pkg:pypi/pycparser?source=hash-mapping run_exports: {} size: 55886 timestamp: 1779293633166 @@ -4195,7 +4226,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/pydantic-settings?source=compressed-mapping + - pkg:pypi/pydantic-settings?source=hash-mapping run_exports: {} size: 52920 timestamp: 1781884990751 @@ -4291,7 +4322,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/pytest?source=compressed-mapping + - pkg:pypi/pytest?source=hash-mapping run_exports: {} size: 306724 timestamp: 1782127176429 @@ -4463,7 +4494,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/rich-toolkit?source=compressed-mapping + - pkg:pypi/rich-toolkit?source=hash-mapping run_exports: {} size: 34824 timestamp: 1784024058693 @@ -4513,7 +4544,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/setuptools?source=compressed-mapping + - pkg:pypi/setuptools?source=hash-mapping run_exports: {} size: 642081 timestamp: 1783619174976 @@ -4574,7 +4605,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/soupsieve?source=compressed-mapping + - pkg:pypi/soupsieve?source=hash-mapping run_exports: {} size: 38802 timestamp: 1779635534390 @@ -4807,7 +4838,7 @@ packages: - python license: MIT purls: - - pkg:pypi/tomlkit?source=compressed-mapping + - pkg:pypi/tomlkit?source=hash-mapping run_exports: {} size: 49054 timestamp: 1784287707171 @@ -5004,7 +5035,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/wcwidth?source=compressed-mapping + - pkg:pypi/wcwidth?source=hash-mapping run_exports: {} size: 132415 timestamp: 1782771807703 @@ -5017,16 +5048,17 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/zipp?source=compressed-mapping + - pkg:pypi/zipp?source=hash-mapping run_exports: {} size: 24190 timestamp: 1779159948016 -- conda_source: autostorage[6f9aa45d] @ . +- conda_source: autostorage[e06c168f] @ . variants: target_platform: noarch depends: - python >=3.12 - python * + - pynauty >=2.8.8.1 - sqlmodel >=0.0.31 host_packages: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda diff --git a/pixi.toml b/pixi.toml index 1c82dcc..c550230 100644 --- a/pixi.toml +++ b/pixi.toml @@ -6,7 +6,7 @@ preview = ["pixi-build"] [dependencies] python = ">=3.12,<3.14" autostorage = { path = "." } -automol = "==0.0.19" # local:false +automol = "==0.0.22" # local:false # automol = { path = "../automol" } # local:true matplotlib-base = ">=3.8" diff --git a/pyproject.toml b/pyproject.toml index 324694a..eeca2d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,9 +7,9 @@ authors = [ ] requires-python = ">= 3.12" dependencies = [ - "automol==0.0.19", + "automol==0.0.22", + "pynauty>=2.8.8.1", "sqlmodel>=0.0.31", - "irmsd>=0.1.1", "stereomolgraph>=0.0.22b0", ] @@ -67,7 +67,7 @@ layers = [ "autostorage.database", "autostorage.events", "autostorage.models", - "autostorage.types | autostorage.exc", + "autostorage.types", ] # Pytest configurations diff --git a/schema/full_schema.pintora b/schema/full_schema.pintora new file mode 100644 index 0000000..a6d6f4d --- /dev/null +++ b/schema/full_schema.pintora @@ -0,0 +1,149 @@ +erDiagram + @param fill #FFFFFF + @param stroke #000000 + @param labelBackground #FFFFFF + @param fontSize 24 + @param fontFamily "Georgia" + @param layoutDirection "LR" + @param entityPaddingX 30 + @param entityPaddingY 30 + @param ranksep 300 + @param edgesep 50 + + MODEL { + int id PK + string program UK + string program_version UK + string method UK + string basis UK + } + CALCULATION { + int id PK + int model_id FK + enum calc_type + json input_provenance + json output_provenance + } + GEOMETRY { + int id PK + json symbols + json coordinates + int charge + int spin + } + CALCULATION_GEOMETRY_LINK { + int geometry_id FK + int calculation_id FK + enum role + } + TRAJECTORY { + int id PK + } + CALCULATION_TRAJECTORY_LINK { + int trajectory_id FK + int calculation_id FK + enum role + } + TRAJECTORY_GEOMETRY_LINK { + int geometry_id FK + int calculation_id FK + json index + } + ENERGY { + int id PK + int geometry_id FK + int calculation_id FK + int value + } + GRADIENT { + int id PK + int geometry_id FK + int calculation_id FK + json value + } + HESSIAN { + int id PK + int geometry_id FK + int calculation_id FK + json value + } + STATIONARY_POINT { + int id PK + int geometry_id FK + int calculation_id FK + int order + bool is_pseudo + bool is_valid + } + IDENTITY { + int id PK + string kind + string algorithm + string value + } + IDENTITY_EXTRA { + int id PK + int identity_id FK + string attribute + string value + } + STATIONARY_IDENTITY_LINK { + int identity_id FK + int stationary_id FK + } + STAGE { + int id PK + bool is_ts + } + STATIONARY_STAGE_LINK { + int stationary_id FK + int stage_id FK + } + STEP { + int id PK + int stage_id1 FK + int stage_id2 FK + int stage_id_ts FK + bool is_barrierless + } + VALIDATION { + int id PK + int calculation_id FK + str method + json extras + } + STEP_VALIDATION_LINK { + int step_id FK + int validation_id FK + } + %%Calculation + MODEL }o--|| CALCULATION : specifies + CALCULATION_GEOMETRY_LINK }o--|| CALCULATION : links + CALCULATION_TRAJECTORY_LINK }o--|| CALCULATION : links + CALCULATION ||..o{ ENERGY : produces + CALCULATION ||..o{ GRADIENT : produces + CALCULATION ||..o{ HESSIAN : produces + %%Geometry + CALCULATION_GEOMETRY_LINK }o--|| GEOMETRY : links + TRAJECTORY_GEOMETRY_LINK }o--|| GEOMETRY : links + ENERGY }o..|| GEOMETRY : "evaluated from" + GRADIENT }o..|| GEOMETRY : "evaluated from" + HESSIAN }o..|| GEOMETRY : "evaluated from" + %%Trajectory + CALCULATION_TRAJECTORY_LINK }o--|| TRAJECTORY : "links" + TRAJECTORY_GEOMETRY_LINK }o--|| TRAJECTORY : "links" + %%Stationary + STATIONARY_POINT }o..|| GEOMETRY : "has structure" + STATIONARY_IDENTITY_LINK }o--|| STATIONARY_POINT : "links" + STATIONARY_STAGE_LINK }o--|| STATIONARY_POINT : "links" + %%Identity + STATIONARY_IDENTITY_LINK }o--|| IDENTITY : "links" + IDENTITY ||--o{ IDENTITY_EXTRA : "has" + %%Stage + STATIONARY_STAGE_LINK }o--|| STAGE : "links" + STAGE ||..o{ STEP : "node of" + %%Step + STEP_VALIDATION_LINK }o--|| STEP : "links" + %%Validation + STEP_VALIDATION_LINK }o--|| VALIDATION : "links" + CALCULATION ||--o{ VALIDATION : "provides" diff --git a/schema/full_schema.svg b/schema/full_schema.svg new file mode 100644 index 0000000..b5dd768 --- /dev/null +++ b/schema/full_schema.svg @@ -0,0 +1 @@ +specifieslinkslinksproducesproducesproduceslinkslinksevaluated fromevaluated fromevaluated fromlinkslinkshas structurelinkslinkslinkshaslinksnode oflinkslinksprovidesMODELPKintidUKstringprogramUKstringprogram_versionUKstringmethodUKstringbasisCALCULATIONPKintidFKintmodel_idenumcalc_typejsoninput_provenancejsonoutput_provenanceGEOMETRYPKintidjsonsymbolsjsoncoordinatesintchargeintspinCALCULATION_GEOMETRY_LINKFKintgeometry_idFKintcalculation_idenumroleTRAJECTORYPKintidCALCULATION_TRAJECTORY_LINKFKinttrajectory_idFKintcalculation_idenumroleTRAJECTORY_GEOMETRY_LINKFKintgeometry_idFKintcalculation_idjsonindexENERGYPKintidFKintgeometry_idFKintcalculation_idintvalueGRADIENTPKintidFKintgeometry_idFKintcalculation_idjsonvalueHESSIANPKintidFKintgeometry_idFKintcalculation_idjsonvalueSTATIONARY_POINTPKintidFKintgeometry_idFKintcalculation_idintorderboolis_pseudoboolis_validIDENTITYPKintidstringkindstringalgorithmstringvalueIDENTITY_EXTRAPKintidFKintidentity_idstringattributestringvalueSTATIONARY_IDENTITY_LINKFKintidentity_idFKintstationary_idSTAGEPKintidboolis_tsSTATIONARY_STAGE_LINKFKintstationary_idFKintstage_idSTEPPKintidFKintstage_id1FKintstage_id2FKintstage_id_tsboolis_barrierlessVALIDATIONPKintidFKintcalculation_idstrmethodjsonextrasSTEP_VALIDATION_LINKFKintstep_idFKintvalidation_id \ No newline at end of file diff --git a/schema/simplified_schema.pintora b/schema/simplified_schema.pintora new file mode 100644 index 0000000..5fa34de --- /dev/null +++ b/schema/simplified_schema.pintora @@ -0,0 +1,121 @@ +@pre +@style { + .calculation { + backgroundColor: #44BB99; + } + .model { + backgroundColor: #DDDDDD; + } + .geometry { + backgroundColor: #99DDFF; + } + .trajectory { + backgroundColor: #BBCC33; + } + .result { + backgroundColor: #77AADD; + } + .stationary { + backgroundColor: #EE8866; + } + .stage { + backgroundColor: #FFAABB; + } + .step { + backgroundColor: #AA4499; + } +} +@endpre + +erDiagram + %% Universal styling + @param stroke #000000 + @param labelBackground #FFFFFF + @param fontSize 36 + @param fontFamily "Georgia" + @param entityPaddingX 30 + @param entityPaddingY 30 + @param ranksep 150 + @param edgesep 175 + + %% Style binds + @bindClass entity-model model + @bindClass entity-calculation calculation + @bindClass entity-geometry geometry + @bindClass entity-trajectory trajectory + @bindClass entity-energy result + @bindClass entity-gradient result + @bindClass entity-hessian result + @bindClass entity-stationary_point stationary + @bindClass entity-identity stationary + @bindClass entity-identity_extra stationary + @bindClass entity-stage stage + @bindClass entity-step step + @bindClass entity-validation step + + %% Attributes + model { + string program + string method + string basis + } + calculation { + enum calc_type + json input_provenance + json output_provenance + } + geometry { + json symbols + blob coordinates + int charge + int spin + } + energy { + } + gradient { + } + hessian { + } + stationary_point { + int order + bool is_pseudo + } + identity { + } + identity_extra { + } + stage { + bool is_ts + } + step { + bool is_barrierless + } + validation { + } + + + %% Entity Relationships + model }o--|| calculation : "specifies" + + calculation }o--o{ geometry : "I/O" + calculation }o--o{ trajectory : "I/O" + calculation ||..o{ energy : "produces" + calculation ||..o{ gradient : "produces" + calculation ||..o{ hessian : "produces" + calculation ||..o{ stationary_point : "produces" + calculation ||..o{ validation : "performs" + + geometry }o--o{ trajectory : "member of" + energy }o..|| geometry : "from" + gradient }o..|| geometry : "from" + hessian }o..|| geometry : "from" + + stationary_point }o..|| geometry : "has" + stationary_point }o--o{ identity : "has" + stationary_point }o--o{ stage : "species of" + + identity ||--o{ identity_extra : "has" + + stage ||..o{ step : "node of" + + validation }o--o{ step : "validates" \ No newline at end of file diff --git a/schema/simplified_schema.png b/schema/simplified_schema.png new file mode 100644 index 0000000000000000000000000000000000000000..90f3407f6b4951d1506f595ce8105484c30836ce GIT binary patch literal 563254 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°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

JNqm=0Zn4RVP6G4$=-F(e> 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�q^?G?_OYhAQ)l17!yJsiB$ed61F*$#5jk>|QE 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 KqT-v2xBnggN_7hW literal 0 HcmV?d00001 diff --git a/schema/simplified_schema.svg b/schema/simplified_schema.svg new file mode 100644 index 0000000..82b6c16 --- /dev/null +++ b/schema/simplified_schema.svg @@ -0,0 +1 @@ +specifiesI/OI/Oproducesproducesproducesproducesperformsmember offromfromfromhashasspecies ofhasnode ofvalidatesmodelstringprogramstringmethodstringbasiscalculationenumcalc_typejsoninput_provenancejsonoutput_provenancegeometryjsonsymbolsblobcoordinatesintchargeintspinenergygradienthessianstationary_pointintorderboolis_pseudoidentityidentity_extrastageboolis_tsstepboolis_barrierlessvalidationtrajectory \ No newline at end of file diff --git a/src/autostorage/__init__.py b/src/autostorage/__init__.py index 801ec8e..e54fe91 100644 --- a/src/autostorage/__init__.py +++ b/src/autostorage/__init__.py @@ -2,13 +2,15 @@ __version__ = "0.0.12" -from . import exc, types +from . import events, types from .database import Database from .models import ( CalculationGeometryLink, CalculationRow, + CalculationTrajectoryLink, EnergyRow, GeometryRow, + GeometryTrajectoryLink, GradientRow, HessianRow, IdentityExtraRow, @@ -20,16 +22,16 @@ TrajectoryRow, ValidationRow, ) -from .types import CalcStatus, CalcType, Role +from .types import Role __all__ = [ - "CalcStatus", - "CalcType", "CalculationGeometryLink", "CalculationRow", + "CalculationTrajectoryLink", "Database", "EnergyRow", "GeometryRow", + "GeometryTrajectoryLink", "GradientRow", "HessianRow", "IdentityExtraRow", @@ -41,6 +43,6 @@ "StepRow", "TrajectoryRow", "ValidationRow", - "exc", + "events", "types", ] diff --git a/src/autostorage/database.py b/src/autostorage/database.py index e2709bb..3f3bfd5 100644 --- a/src/autostorage/database.py +++ b/src/autostorage/database.py @@ -1,26 +1,18 @@ -"""SQLite database connection and session management.""" +"""SQLite database connection management.""" import json -from collections.abc import Iterable, Iterator -from contextlib import contextmanager from functools import partial from pathlib import Path -from types import TracebackType -from typing import Self -from sqlalchemy import Select, create_engine, event -from sqlalchemy import select as sa_select -from sqlalchemy.exc import MultipleResultsFound, NoResultFound +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session +from sqlmodel import SQLModel # Ensure all modules are loaded with the database -from .events import * # noqa: F403 +from . import events # noqa: F401 from .models import * # noqa: F403 -from .models import SQLModel -type SelectStatement[T] = Select[tuple[T]] - -__all__ = ["Database", "Select", "SelectStatement"] +__all__ = ["Database"] class Database: @@ -33,8 +25,6 @@ class Database: Path to SQLite database file. engine SQLAlchemy engine instance. - _session - Persistent database session. """ def __init__(self, path: str | Path, *, echo: bool = False) -> None: @@ -70,146 +60,18 @@ def _set_sqlite_pragma(dbapi_connection, _connection_record) -> None: # noqa: A cursor.close() SQLModel.metadata.create_all(self.engine) - self._session: Session = Session(self.engine) - - @contextmanager - def session(self) -> Iterator[Session]: - """Yield the persistent database session. - - Note - ---- - This yields the single, long-lived `Session` created in `__init__`, - not a fresh session per call — rows returned from queries stay - attached, so lazy-loaded relationships keep working after the `with` - block exits. As a result, a `Database` instance is not safe for - concurrent use by multiple threads: `check_same_thread=False` only - allows the underlying DBAPI connection to be used from a different - thread than it was created on (e.g. a single background worker), it - does not make the `Session` itself thread-safe. - """ - try: - yield self._session - except Exception: - self._session.rollback() - raise - - def add[RowT: SQLModel](self, row: RowT) -> None: - """Add row to session. - - Note - ---- - The row is not validated or written to the database until the next `flush()` or - `commit()`. Integrity/shape errors (unique constraints, the shape event - listeners) raise there, which may be far removed from this call. - """ - with self.session() as session: - session.add(row) - - def add_all[RowT: SQLModel](self, rows: Iterable[RowT]) -> None: - """Add multiple rows to session. - - Note - ---- - Bulk `add()`. Same staging-only caveat applies. - """ - with self.session() as session: - session.add_all(rows) - - def merge[RowT: SQLModel](self, row: RowT) -> RowT: - """Merge row into current session and commit, returning the merged row.""" - with self.session() as session: - merged = session.merge(row) - session.commit() - return merged - def flush(self) -> None: - """Flush pending changes to the database without committing. + def session(self) -> Session: + """Return a fresh `Session` bound to this database's engine. Note ---- - Unlike `commit()`, this doesn't trigger SQLAlchemy's default `expire_on_commit` - behavior, so an already-loaded object whose row was removed by a DB-level - `ondelete="CASCADE"` during this flush would be read back stale. `expire_all()` - forces those objects to reload (or raise) on next access instead. + A new `Session` is created per call; use it as a context manager + (`with database.session() as session: ...`) to close it on exit. + Nothing is committed automatically — call `session.commit()` explicitly. """ - with self.session() as session: - session.flush() - session.expire_all() - - def commit(self) -> None: - """Commit database session.""" - with self.session() as session: - session.commit() - - def delete[RowT: SQLModel](self, row: RowT) -> None: - """Delete row from database.""" - with self.session() as session: - session.delete(row) - session.commit() - - def get_or_none[RowT: SQLModel]( - self, model: type[RowT], row_id: int - ) -> RowT | None: - """Get row from database, returning `None` instead of raising on a miss.""" - with self.session() as session: - return session.get(model, row_id) - - def get[RowT: SQLModel](self, model: type[RowT], row_id: int) -> RowT: - """Get row from database.""" - row = self.get_or_none(model, row_id) - if row is not None: - return row - - msg = f"{model} with {row_id = } not found." - raise LookupError(msg) - - def exec_first[RowT: SQLModel](self, stmt: SelectStatement[RowT]) -> RowT | None: - """Return the first match to a statement.""" - with self.session() as session: - return session.scalars(stmt).first() - - def exec_one[RowT: SQLModel](self, stmt: SelectStatement[RowT]) -> RowT: - """Return the single match to a statement.""" - with self.session() as session: - try: - return session.scalars(stmt).one() - except NoResultFound as exc: - msg = f"No row found matching {stmt}." - raise LookupError(msg) from exc - except MultipleResultsFound as exc: - msg = f"Multiple rows found matching {stmt}." - raise LookupError(msg) from exc - - def exec_all[RowT: SQLModel](self, stmt: SelectStatement[RowT]) -> list[RowT]: - """Return all matches to a statement.""" - with self.session() as session: - return list(session.scalars(stmt).all()) - - def exists[RowT: SQLModel](self, stmt: SelectStatement[RowT]) -> bool: - """Return whether any row matches a statement. - - Executes as a single `EXISTS` subquery instead of `exec_first`, so a matching - row is never materialized. - """ - with self.session() as session: - return bool(session.scalar(sa_select(stmt.exists()))) + return Session(self.engine) def close(self) -> None: """Close the database connection.""" self.engine.dispose() - - def __enter__(self) -> Self: - """Enter a `with Database(...) as db:` block.""" - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_value: object, - traceback: TracebackType | None, - ) -> None: - """Roll back on exception, then close the database connection.""" - del exc_value, traceback - if exc_type is not None: - self._session.rollback() - self.close() diff --git a/src/autostorage/events.py b/src/autostorage/events.py index 6ada140..a53bcf5 100644 --- a/src/autostorage/events.py +++ b/src/autostorage/events.py @@ -1,406 +1,389 @@ """SQLAlchemy ORM event listeners for validation and auto-managed identities.""" -from collections.abc import Iterable from typing import Any -import numpy as np -from automol import Algorithm, geom -from sqlalchemy import Integer, cast, event, func, select, tuple_ +from automol import Identity +from automol.ident import Algorithm +from sqlalchemy import event from sqlalchemy.engine import Connection -from sqlalchemy.orm import Mapper, Session, object_session -from sqlalchemy.orm.attributes import flag_modified, get_history +from sqlalchemy.orm import Mapper, Session -from .exc import DataIntegrityError, ResultShapeError from .models import ( GeometryRow, + GeometryTrajectoryLink, GradientRow, HessianRow, IdentityExtraRow, IdentityRow, - StageRow, StationaryPointRow, StepRow, - _geometry_hash, ) -def _resolve_geometry( - target: GradientRow | HessianRow | StationaryPointRow, -) -> GeometryRow | None: - """Return the target's geometry, resolving it via the session if unattached. - - Setting only `geometry_id` leaves `.geometry` unpopulated until the ORM syncs it. - """ - geometry = target.geometry - if geometry is None and target.geometry_id is not None: - session = object_session(target) - if session is not None: - geometry = session.get(GeometryRow, target.geometry_id) - return geometry - - -@event.listens_for(GradientRow, "before_insert") -@event.listens_for(GradientRow, "before_update") -def verify_gradient_shape( - mapper: Mapper, # noqa: ARG001 - connection: Connection, # noqa: ARG001 - target: GradientRow, -) -> None: - """Verify shape of the Gradient array before saving to the database.""" - geometry = _resolve_geometry(target) - if geometry is None: - return - - expected = (3 * geometry.to_geometry().atom_count,) - actual = np.shape(target.value) - - if actual != expected: - raise ResultShapeError(target, actual, expected) - - -@event.listens_for(HessianRow, "before_insert") -@event.listens_for(HessianRow, "before_update") -def verify_hessian_shape( - mapper: Mapper, # noqa: ARG001 +@event.listens_for(StepRow, "before_insert") +@event.listens_for(StepRow, "before_update") +def sort_step_stage_ids( + mapper: Mapper[StepRow], # noqa: ARG001 connection: Connection, # noqa: ARG001 - target: HessianRow, + target: StepRow, ) -> None: - """Verify shape of the Hessian matrix before saving to the database.""" - geometry = _resolve_geometry(target) - if geometry is None: - return - - expected_dim = 3 * geometry.to_geometry().atom_count - expected = (expected_dim, expected_dim) - actual = np.shape(target.value) + """Auto-sort stage_id1 and stage_id2 so stage_id1 < stage_id2.""" + if ( + target.stage_id1 is not None + and target.stage_id2 is not None + and target.stage_id1 > target.stage_id2 + ): + target.stage_id1, target.stage_id2 = target.stage_id2, target.stage_id1 - if actual != expected: - raise ResultShapeError(target, actual, expected) - -@event.listens_for(HessianRow, "before_update") -def invalidate_hessian_frequency_cache( - mapper: Mapper, # noqa: ARG001 +@event.listens_for(StepRow, "before_insert") +@event.listens_for(StepRow, "before_update") +def verify_step_barrierless_consistency( + mapper: Mapper[StepRow], # noqa: ARG001 connection: Connection, # noqa: ARG001 - target: HessianRow, + target: StepRow, ) -> None: - """Drop a cached `harmonic_frequencies` if `value` changed.""" - if get_history(target, "value").added: - target.__dict__.pop("harmonic_frequencies", None) + """Verify is_barrierless consistency with stage_id_ts.""" + if target.stage_id_ts is None: + if not target.is_barrierless: + msg = "Barrierless step (stage_id_ts=None) must have is_barrierless=True" + raise ValueError(msg) + elif target.is_barrierless: + msg = ( + "Step with transition state (stage_id_ts!=None) must have " + "is_barrierless=False" + ) + raise ValueError(msg) -def _recompute_geometry_stationary_validity( - geometry: GeometryRow, *, excluding: Iterable[HessianRow] = () +@event.listens_for(Session, "before_flush") +def verify_gradient_shapes_before_flush( + session: Session, + flush_context: Any, # noqa: ARG001, ANN401 + instances: Any, # noqa: ARG001, ANN401 ) -> None: - """Recompute `StationaryPointRow.is_valid` for a geometry from its Hessians. + """Verify gradient shapes match 3 * natoms for all gradients being flushed.""" + for obj in list(session.new) + list(session.dirty): + if not isinstance(obj, GradientRow): + continue - `excluding` skips Hessians pending deletion (still in `geometry.hessians` at - `before_flush` time since the DELETE hasn't been issued yet). - """ - excluded_ids = {id(h) for h in excluding} - hessians = [h for h in geometry.hessians if id(h) not in excluded_ids] - if not hessians: - return + if obj.geometry_id is None: + continue - # A shape-invalid `value` (still pending its own `verify_hessian_shape` - # before_insert check later in this same flush) can't have its order computed; - # skip it here rather than raising `vibrational_analysis`'s raw ValueError. - orders: set[int] = set() - for h in hessians: - try: - orders.add(h.order) - except ValueError: + # Load the geometry to get natoms + geometry_row = session.get(GeometryRow, obj.geometry_id) + if geometry_row is None: continue - if len(orders) > 1: - msg = f"Geometry Hessians do not agree on order. {orders = }." - raise DataIntegrityError(msg) - if orders and geometry.stationary_points: - expected_order = orders.pop() - for stationary in geometry.stationary_points: - stationary.is_valid = stationary.order == expected_order + natoms = len(geometry_row.symbols) + expected_shape = (3 * natoms,) + actual_shape = obj.value.shape + + if actual_shape != expected_shape: + msg = ( + f"Gradient shape {actual_shape} does not match expected " + f"shape {expected_shape} for geometry with {natoms} atoms" + ) + raise ValueError(msg) @event.listens_for(Session, "before_flush") -def revalidate_geometry_orders_on_insert_update( +def verify_hessian_shapes_before_flush( session: Session, - flush_context: Any, # noqa: ANN401, ARG001 - instances: Any, # noqa: ANN401, ARG001 + flush_context: Any, # noqa: ARG001, ANN401 + instances: Any, # noqa: ARG001, ANN401 ) -> None: - """Recompute order consensus for a geometry when a Stationary/Hessian changes. - - A session-level `before_flush` listener, not a per-instance `before_insert`/ - `before_update` mapper event: the recompute below mutates sibling - `StationaryPointRow`s that may already be clean going into this flush, and a - mapper event fires too late in the flush cycle for that mutation to be - included — SQLAlchemy silently drops it (the "Attribute history events... will - not result in database updates" warning) instead of writing it. - """ - candidates = ( - obj - for obj in session.new | session.dirty - if isinstance(obj, StationaryPointRow | HessianRow) - ) - geometries: dict[int, GeometryRow] = {} - for obj in candidates: - geometry = _resolve_geometry(obj) - if geometry is not None: - geometries[id(geometry)] = geometry + """Verify Hessian shapes match (3 * natoms, 3 * natoms) for all Hessians.""" + for obj in list(session.new) + list(session.dirty): + if not isinstance(obj, HessianRow): + continue - for geometry in geometries.values(): - _recompute_geometry_stationary_validity(geometry) + if obj.geometry_id is None: + continue + + # Load the geometry to get natoms + geometry_row = session.get(GeometryRow, obj.geometry_id) + if geometry_row is None: + continue + + natoms = len(geometry_row.symbols) + expected_shape = (3 * natoms, 3 * natoms) + actual_shape = obj.value.shape + + if actual_shape != expected_shape: + msg = ( + f"Hessian shape {actual_shape} does not match expected " + f"shape {expected_shape} for geometry with {natoms} atoms" + ) + raise ValueError(msg) @event.listens_for(Session, "before_flush") -def revalidate_geometry_orders_on_hessian_delete( +def verify_valid_stationary_has_hessian( session: Session, - flush_context: Any, # noqa: ANN401, ARG001 - instances: Any, # noqa: ANN401, ARG001 + flush_context: Any, # noqa: ARG001, ANN401 + instances: Any, # noqa: ARG001, ANN401 ) -> None: - """Recompute order consensus for a geometry when one of its Hessians is deleted.""" - deleted_hessians = [obj for obj in session.deleted if isinstance(obj, HessianRow)] - if not deleted_hessians: - return + """Verify that stationary points marked as valid have an associated Hessian.""" + for obj in list(session.new) + list(session.dirty): + if not isinstance(obj, StationaryPointRow): + continue - geometries = {h.geometry_id: h.geometry for h in deleted_hessians if h.geometry} - for geometry in geometries.values(): - excluding = [h for h in deleted_hessians if h.geometry_id == geometry.id] - _recompute_geometry_stationary_validity(geometry, excluding=excluding) + if not obj.is_valid: + continue + if obj.geometry_id is None: + continue -_IMMUTABLE_GEOMETRY_FIELDS = ("symbols", "coordinates") + # Load the geometry to check for hessians + geometry_row = session.get(GeometryRow, obj.geometry_id) + if geometry_row is None: + continue + + if not geometry_row.hessians: + msg = ( + f"StationaryPointRow cannot be marked as valid without a Hessian " + f"attached to its geometry (geometry_id={obj.geometry_id})" + ) + raise ValueError(msg) -@event.listens_for(GeometryRow, "before_update") -def verify_geometry_immutable_fields( - mapper: Mapper, # noqa: ARG001 +@event.listens_for(GeometryTrajectoryLink, "before_insert") +@event.listens_for(GeometryTrajectoryLink, "before_update") +def verify_trajectory_geometry_ndim_insert( + mapper: Mapper[GeometryTrajectoryLink], # noqa: ARG001 connection: Connection, # noqa: ARG001 - target: GeometryRow, + target: GeometryTrajectoryLink, ) -> None: - """Reject changes to `symbols`/`coordinates` on an already-persisted geometry. + """Ensure linked geometry's index length matches trajectory ndim.""" + if target.trajectory is None: + return - Otherwise an in-place edit could invalidate Gradient/Hessian shape checks - already run against it. - """ - for attr in _IMMUTABLE_GEOMETRY_FIELDS: - history = get_history(target, attr) - if history.added or history.deleted: - msg = f"GeometryRow.{attr} cannot be changed after insert." - raise DataIntegrityError(msg) + traj_ndim = target.trajectory.ndim + index_len = len(target.index) if target.index is not None else None + if index_len is not None and traj_ndim is not None and index_len != traj_ndim: + msg = ( + f"Geometry index length {index_len} does not match " + f"trajectory ndim {traj_ndim}" + ) + raise ValueError(msg) -@event.listens_for(GeometryRow, "before_insert") -@event.listens_for(GeometryRow, "before_update") -def compute_geometry_hash( - mapper: Mapper, # noqa: ARG001 - connection: Connection, # noqa: ARG001 - target: GeometryRow, -) -> None: - """Populate `geometry_hash` from bit-identical geometry fields before saving. + if traj_ndim is None and index_len is not None: + target.trajectory.ndim = index_len + elif index_len is None and traj_ndim is not None: + msg = f"Geometry index is missing but trajectory ndim is {traj_ndim}" + raise ValueError(msg) - Written via `target.__dict__[...]` + `flag_modified`, not `target.geometry_hash = - ...`: `Geometry`'s `validate_assignment=True` pydantic config corrupts SQLAlchemy's - flush-time identity-key bookkeeping when a plain attribute assignment happens - inside a mapper event, breaking every GeometryRow insert. + +def _find_or_create_identity(session: Session, identity: Identity) -> IdentityRow: + """Find or create an IdentityRow for the given Identity. + + Checks both the database and pending session inserts. """ - target.__dict__["geometry_hash"] = _geometry_hash( - target.symbols, target.coordinates, target.charge, target.spin + # First check the database + existing = ( + session.query(IdentityRow) + .filter_by( + kind=identity.kind, + algorithm=str(identity.algorithm), + value=identity.value, + ) + .first() ) - flag_modified(target, "geometry_hash") + # If not in database, check session.new for pending inserts + if existing is None: + for new_obj in session.new: + if ( + isinstance(new_obj, IdentityRow) + and new_obj.kind == identity.kind + and new_obj.algorithm == str(identity.algorithm) + and new_obj.value == identity.value + ): + existing = new_obj + break + + if existing is None: + # Create new identity row + new_identity = IdentityRow( + kind=identity.kind, + algorithm=str(identity.algorithm), + value=identity.value, + ) + session.add(new_identity) + return new_identity -# Identity algorithms managed here, so other code knows not to copy/attach them -# explicitly. -AUTO_MANAGED_IDENTITY_ALGORITHMS: frozenset[Algorithm] = frozenset( - {Algorithm.RDKIT_INCHI, Algorithm.IRMSD} -) + return existing @event.listens_for(Session, "before_flush") -def add_inchi_identities(session: Session, flush_context: Any, instances: Any) -> None: # noqa: ANN401, ARG001 - """Attach InChI and SMILES identities to new stationary point rows before flush.""" - pending_items = [] - inchi_lookups = [] - +def add_inchi_identities_before_flush( + session: Session, + flush_context: Any, # noqa: ARG001, ANN401 + instances: Any, # noqa: ARG001, ANN401 +) -> None: + """Automatically attach InChI identities to newly inserted stationary points.""" for obj in session.new: if not isinstance(obj, StationaryPointRow): continue - geometry = _resolve_geometry(obj) - if geometry is None: - continue - try: - inchi = IdentityRow.from_geometry( - geo=geometry.to_geometry(), algorithm=Algorithm.RDKIT_INCHI - ) - pending_items.append((obj, inchi, geometry)) - inchi_lookups.append((inchi.algorithm, inchi.value)) - except ValueError: - continue - - if not pending_items: - return - - stmt = select(IdentityRow).where( - tuple_(IdentityRow.algorithm, IdentityRow.value).in_(inchi_lookups) # ty:ignore[invalid-argument-type] - ) - existing_rows = session.scalars(stmt).all() - - identity_map = {(r.algorithm, r.value): r for r in existing_rows} - for obj, inchi, geometry in pending_items: - lookup_key = (inchi.algorithm, inchi.value) - existing = identity_map.get(lookup_key) - - if existing: - obj.identities.append(existing) + if obj.geometry_id is None or obj.identities: continue - obj.identities.append(inchi) - identity_map[lookup_key] = inchi - - try: - smiles = IdentityRow.from_geometry( - geometry.to_geometry(), algorithm=Algorithm.RDKIT_SMILES - ) - smiles_extra = IdentityExtraRow( - identity=inchi, attribute="smiles", value=smiles.value - ) - - session.add(smiles_extra) - - except ValueError: + # Load the geometry + geometry_row = session.get(GeometryRow, obj.geometry_id) + if geometry_row is None: continue + # Generate InChI identity from geometry + identity = Identity.from_geometry(geometry_row, algorithm=Algorithm.RDKIT_INCHI) -def _matching_conformer_identity( - obj: StationaryPointRow, inchi: IdentityRow -) -> IdentityRow | None: - """Find the conformer identity of a geometric duplicate among InChI matches.""" - peers = [c for c in inchi.stationary_points if c is not obj] - if not peers: - return None + # Find or create the identity row + identity_row = _find_or_create_identity(session, identity) - geometry = _resolve_geometry(obj) - if geometry is None: - return None + # Link the identity to the stationary point + if identity_row not in obj.identities: + obj.identities.append(identity_row) - # Peers may have only `geometry_id` set (e.g. a bulk loader); resolve via session. - resolved_peers = [(c, _resolve_geometry(c)) for c in peers] - resolved_peers = [(c, g) for c, g in resolved_peers if g is not None] - if not resolved_peers: - return None - matches = geom.is_duplicate_conformer( - geometry.to_geometry(), [g.to_geometry() for _, g in resolved_peers] - ) - match_idx = next((i for i, m in enumerate(matches) if m), None) - if match_idx is None: - return None +def _find_or_create_identity_extra( + session: Session, identity: IdentityRow, attribute: str, value: str +) -> IdentityExtraRow | None: + """Find or create an IdentityExtraRow. - matched_peer, _ = resolved_peers[match_idx] - return matched_peer.identity(kind=Algorithm.IRMSD.kind) + Checks both the database and pending session inserts. Returns None if the + extra already exists. + """ + # Check if the identity has an ID (already in database) + if identity.id is not None: + existing = ( + session.query(IdentityExtraRow) + .filter_by( + identity_id=identity.id, + attribute=attribute, + value=value, + ) + .first() + ) + if existing is not None: + return None # Already exists in database + + # Check session.new for pending inserts (both for this identity and in general) + for new_obj in session.new: + if ( + isinstance(new_obj, IdentityExtraRow) + and (new_obj.identity is identity or new_obj.identity_id == identity.id) + and new_obj.attribute == attribute + and new_obj.value == value + ): + return None # Already pending insertion + + # Create new extra, using the identity relationship + return IdentityExtraRow( + identity=identity, + attribute=attribute, + value=value, + ) @event.listens_for(Session, "before_flush") -def assign_conformer_ids(session: Session, flush_context: Any, instances: Any) -> None: # noqa: ANN401, ARG001 - """Assign a shared conformer-group identity to duplicate stationary points.""" - pending_items: list[tuple[StationaryPointRow, IdentityRow]] = [] - +def add_smiles_extras_before_flush( + session: Session, + flush_context: Any, # noqa: ARG001, ANN401 + instances: Any, # noqa: ARG001, ANN401 +) -> None: + """Automatically attach SMILES as IdentityExtraRow to stationary points.""" for obj in session.new: if not isinstance(obj, StationaryPointRow): continue - if obj.identity(kind=Algorithm.IRMSD.kind) is not None: - continue - inchi = obj.identity(algorithm=Algorithm.RDKIT_INCHI) - if inchi is not None: - pending_items.append((obj, inchi)) - - if not pending_items: - return - - next_group_id: int | None = None - - for obj, inchi in pending_items: - match_ident = _matching_conformer_identity(obj, inchi) + if obj.geometry_id is None or not obj.identities: + continue - if match_ident is not None: - obj.identities.append(match_ident) + # Load the geometry + geometry_row = session.get(GeometryRow, obj.geometry_id) + if geometry_row is None: continue - if next_group_id is None: - # Assumes single-writer; concurrent writers rely on the DB's uniqueness - # constraint to fail one session's commit instead. - current_max = session.scalar( - select(func.max(cast(IdentityRow.value, Integer))).where( - IdentityRow.kind == Algorithm.IRMSD.kind # ty:ignore[invalid-argument-type] - ) + # Generate SMILES from geometry + try: + smiles_identity = Identity.from_geometry( + geometry_row, algorithm=Algorithm.RDKIT_SMILES ) - next_group_id = (current_max or 0) + 1 - else: - next_group_id += 1 + smiles_value = smiles_identity.value + except Exception: # noqa: BLE001, S112 + # Skip if SMILES generation fails (e.g., invalid structure) + continue - conformer = IdentityRow.from_value( - str(next_group_id), algorithm=Algorithm.IRMSD + # Get the InChI identity (should exist from add_inchi_identities_before_flush) + inchi_identity = next( + ( + ident + for ident in obj.identities + if ident.algorithm == str(Algorithm.RDKIT_INCHI) + ), + None, ) - obj.identities.append(conformer) + if inchi_identity is None: + continue -@event.listens_for(StepRow, "before_insert") -@event.listens_for(StepRow, "before_update") -def verify_stage_order_and_barrierless( - mapper: Mapper, # noqa: ARG001 - connection: Connection, # noqa: ARG001 - target: StepRow, -) -> None: - """Verify order of stage ids in a `StepRow` and whether it's barrierless.""" - stg_id1 = target.stage_id1 or target.stage1.id - stg_id2 = target.stage_id2 or target.stage2.id + # Find or create the SMILES extra + smiles_extra = _find_or_create_identity_extra( + session, inchi_identity, "rdkit_smiles", smiles_value + ) - if not stg_id1 or not stg_id2: - msg = "Cannot sort stage IDs; IDs aren't assigned to stages." - raise DataIntegrityError(msg) + if smiles_extra is not None: + session.add(smiles_extra) - if stg_id1 > stg_id2: - target.stage_id1, target.stage_id2 = stg_id2, stg_id1 - target.is_barrierless = not target.stage_id_ts +@event.listens_for(Session, "before_flush") +def add_hill_extras_before_flush( + session: Session, + flush_context: Any, # noqa: ARG001, ANN401 + instances: Any, # noqa: ARG001, ANN401 +) -> None: + """Automatically attach Hill formula as IdentityExtraRow to stationary points.""" + for obj in session.new: + if not isinstance(obj, StationaryPointRow): + continue + + if obj.geometry_id is None or not obj.identities: + continue + # Load the geometry + geometry_row = session.get(GeometryRow, obj.geometry_id) + if geometry_row is None: + continue -def _resolve_stage(target: StepRow, id_attr: str, rel_attr: str) -> StageRow | None: - """Return one of a `StepRow`'s stages, resolving via session if unattached. + # Generate Hill formula from geometry + try: + hill_identity = Identity.from_geometry( + geometry_row, algorithm=Algorithm.HILL_FORMULA + ) + hill_value = hill_identity.value + except Exception: # noqa: BLE001, S112 + # Skip if Hill formula generation fails (e.g., invalid structure) + continue - Mirrors `_resolve_geometry`. - """ - stage = getattr(target, rel_attr) - if stage is None: - stage_id = getattr(target, id_attr) - if stage_id is not None: - session = object_session(target) - if session is not None: - stage = session.get(StageRow, stage_id) - return stage + # Get the InChI identity (should exist from add_inchi_identities_before_flush) + inchi_identity = next( + ( + ident + for ident in obj.identities + if ident.algorithm == str(Algorithm.RDKIT_INCHI) + ), + None, + ) + if inchi_identity is None: + continue -@event.listens_for(StepRow, "before_insert") -@event.listens_for(StepRow, "before_update") -def verify_stage_ts_consistency( - mapper: Mapper, # noqa: ARG001 - connection: Connection, # noqa: ARG001 - target: StepRow, -) -> None: - """Verify `is_ts` agreement between a `StepRow` and its linked stages. + # Find or create the Hill formula extra + hill_extra = _find_or_create_identity_extra( + session, inchi_identity, "hill_formula", hill_value + ) - `stage1`/`stage2` must not be transition-state stages. When `stage_id_ts` is set, - the referenced stage must be one. - """ - stage1 = _resolve_stage(target, "stage_id1", "stage1") - stage2 = _resolve_stage(target, "stage_id2", "stage2") - stage_ts = _resolve_stage(target, "stage_id_ts", "stage_ts") - - if (stage1 is not None and stage1.is_ts) or (stage2 is not None and stage2.is_ts): - msg = "Step's stage1/stage2 cannot be a transition-state stage." - raise DataIntegrityError(msg) - if stage_ts is not None and not stage_ts.is_ts: - msg = "Step's stage_ts must reference a transition-state stage." - raise DataIntegrityError(msg) + if hill_extra is not None: + session.add(hill_extra) diff --git a/src/autostorage/exc.py b/src/autostorage/exc.py deleted file mode 100644 index 24ee96c..0000000 --- a/src/autostorage/exc.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Autostorage exceptions.""" - -from typing import Self - -__all__ = ["DataIntegrityError", "ResultShapeError"] - - -class DataIntegrityError(Exception): - """Raise when an ORM event detects a data integrity violation.""" - - -class ResultShapeError(Exception): - """Raise when a result violates expected shape.""" - - def __init__( - self: Self, model: object, actual: tuple[int, ...], expected: tuple[int, ...] - ) -> None: - """Initialize exception.""" - class_name = model.__class__.__name__ - msg = f"{class_name} shape ({actual}) does not match expected ({expected})." - super().__init__(msg) diff --git a/src/autostorage/models.py b/src/autostorage/models.py new file mode 100644 index 0000000..c9696c0 --- /dev/null +++ b/src/autostorage/models.py @@ -0,0 +1,809 @@ +"""SQLModel row definitions for autostorage's schema.""" + +from typing import Any + +import numpy as np +from automol import Geometry, Identity +from automol.utils.types import FloatArray +from pydantic import field_validator +from sqlmodel import ( + JSON, + CheckConstraint, + Column, + Enum, + Field, + Index, + Relationship, + SQLModel, + UniqueConstraint, + text, +) +from sqlmodel.main import SQLModelConfig + +from .types import CompressedArrayTypeDecorator, Role, _fk_field + + +# 0. Link rows +# NOTE: Link tables are named by the two entities they connect, in alphabetical order. +class CalculationGeometryLink(SQLModel, table=True): + """Association table linking geometries to a calculation. + + Attributes + ---------- + geometry_id + Foreign key to the linked geometry. + calculation_id + Foreign key to the linked calculation. + role + Role the geometry plays for this calculation (input/output). + geometry + The linked geometry (back-populated from `GeometryRow.calculation_links`). + calculation + The linked calculation (back-populated from `CalculationRow.geometry_links`). + """ + + __tablename__ = "calculation_geometry_link" + __table_args__ = ( + # The composite primary key only serves lookups keyed by `geometry_id` + # (its leading column); this adds a matching index for `calculation_id`. + Index("ix_calculation_geometry_link_calculation_id", "calculation_id"), + ) + + geometry_id: int | None = Field( + default=None, + foreign_key="geometry.id", + ondelete="CASCADE", + nullable=False, + primary_key=True, + ) + calculation_id: int | None = Field( + default=None, + foreign_key="calculation.id", + ondelete="CASCADE", + nullable=False, + primary_key=True, + ) + role: Role = Field( + sa_column=Column(Enum(Role, values_callable=lambda x: [e.value for e in x])) + ) + + geometry: "GeometryRow" = Relationship(back_populates="calculation_links") + calculation: "CalculationRow" = Relationship(back_populates="geometry_links") + + +class GeometryTrajectoryLink(SQLModel, table=True): + """Association table linking geometries to a trajectory. + + Attributes + ---------- + geometry_id + Foreign key to the linked geometry. + trajectory_id + Foreign key to the linked trajectory. + index + Position of the geometry within the trajectory. + geometry + The linked geometry (back-populated from `GeometryRow.trajectory_links`). + trajectory + The linked trajectory (back-populated from `TrajectoryRow.geometry_links`). + """ + + __tablename__ = "geometry_trajectory_link" + __table_args__ = ( + Index("ix_geometry_trajectory_link_trajectory_id", "trajectory_id"), + ) + + geometry_id: int | None = Field( + default=None, + foreign_key="geometry.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + trajectory_id: int | None = Field( + default=None, + foreign_key="trajectory.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + index: list[int] | None = Field(default=None, sa_column=Column(JSON)) + + geometry: "GeometryRow" = Relationship(back_populates="trajectory_links") + trajectory: "TrajectoryRow" = Relationship(back_populates="geometry_links") + + +class CalculationTrajectoryLink(SQLModel, table=True): + """Association table linking trajectories to a calculation. + + Attributes + ---------- + trajectory_id + Foreign key to the linked trajectory. + calculation_id + Foreign key to the linked calculation. + role + Role the trajectory plays for this calculation (input/output). + trajectory + The linked trajectory (back-populated from `TrajectoryRow.calculation_links`). + calculation + The linked calculation (back-populated from `CalculationRow.trajectory_links`). + """ + + __tablename__ = "calculation_trajectory_link" + __table_args__ = ( + Index("ix_calculation_trajectory_link_calculation_id", "calculation_id"), + ) + + trajectory_id: int | None = Field( + default=None, + foreign_key="trajectory.id", + ondelete="CASCADE", + nullable=False, + primary_key=True, + ) + calculation_id: int | None = Field( + default=None, + foreign_key="calculation.id", + ondelete="CASCADE", + nullable=False, + primary_key=True, + ) + role: Role = Field( + sa_column=Column(Enum(Role, values_callable=lambda x: [e.value for e in x])) + ) + + trajectory: "TrajectoryRow" = Relationship(back_populates="calculation_links") + calculation: "CalculationRow" = Relationship(back_populates="trajectory_links") + + +class StageStationaryLink(SQLModel, table=True): + """Association table linking stationary points to reaction stages. + + Attributes + ---------- + stationary_id + Foreign key to the linked stationary point. + stage_id + Foreign key to the linked reaction stage. + """ + + __tablename__ = "stage_stationary_link" + __table_args__ = (Index("ix_stage_stationary_link_stage_id", "stage_id"),) + + stationary_id: int | None = Field( + default=None, + foreign_key="stationary_point.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + stage_id: int | None = Field( + default=None, + foreign_key="stage.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + + +class StepValidationLink(SQLModel, table=True): + """Association table linking validations to a step. + + Attributes + ---------- + step_id + Foreign key to the linked step. + validation_id + Foreign key to the linked validation. + + Notes + ----- + Relationships are managed bidirectionally via `ValidationRow.step` and + `StepRow.validations` using this table's `link_model`. + """ + + __tablename__ = "step_validation_link" + __table_args__ = (Index("ix_step_validation_link_validation_id", "validation_id"),) + + step_id: int = Field( + foreign_key="step.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + validation_id: int = Field( + foreign_key="validation.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + + +class IdentityStationaryLink(SQLModel, table=True): + """Association table linking chemical identities to stationary points. + + Attributes + ---------- + stationary_id + Foreign key to the linked stationary point. + identity_id + Foreign key to the linked chemical identity. + + Notes + ----- + Relationships are managed bidirectionally via `StationaryPointRow.identities` + and `IdentityRow.stationary_points` using this table's `link_model`. + """ + + __tablename__ = "identity_stationary_link" + __table_args__ = (Index("ix_identity_stationary_link_identity_id", "identity_id"),) + + stationary_id: int = Field( + foreign_key="stationary_point.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + identity_id: int = Field( + foreign_key="identity.id", + primary_key=True, + ondelete="CASCADE", + nullable=False, + ) + + +# 1. Existential data rows +class GeometryRow(SQLModel, Geometry, table=True): + """Molecular geometry definition and metadata. + + Attributes + ---------- + id + Primary key. + symbols + Atomic symbols in order. + coordinates + Atomic coordinates in Angstrom. + charge + Total molecular charge. + spin + Number of unpaired electrons (2S). + energies + Energy results computed at this geometry. + gradients + Gradient results computed at this geometry. + hessians + Hessian results computed at this geometry. + stationary_points + Stationary points defined by this geometry. + trajectory_links + Raw link rows connecting this geometry to trajectories. + calculation_links + Raw link rows connecting this geometry to calculations. + """ + + __tablename__ = "geometry" + model_config = SQLModelConfig(arbitrary_types_allowed=True) + + id: int | None = Field(default=None, primary_key=True) + symbols: list[str] = Field(sa_column=Column(JSON)) + coordinates: FloatArray = Field(sa_column=Column(CompressedArrayTypeDecorator())) + charge: int + spin: int + + energies: list["EnergyRow"] = Relationship(back_populates="geometry") + gradients: list["GradientRow"] = Relationship(back_populates="geometry") + hessians: list["HessianRow"] = Relationship(back_populates="geometry") + stationary_points: list["StationaryPointRow"] = Relationship( + back_populates="geometry" + ) + trajectory_links: list["GeometryTrajectoryLink"] = Relationship( + back_populates="geometry" + ) + calculation_links: list["CalculationGeometryLink"] = Relationship( + back_populates="geometry" + ) + + @field_validator("coordinates", mode="before") + @classmethod + def _validate_coordinates(cls, value: list[list[float]] | FloatArray) -> FloatArray: + """Convert list to numpy array if needed.""" + if isinstance(value, list): + return np.asarray(value, dtype=float) + return value + + +class TrajectoryRow(SQLModel, table=True): + """Ordered sequence of geometries from a calculation trajectory. + + Attributes + ---------- + id + Primary key. + geometry_links + Raw link rows connecting geometries to this trajectory. + calculation_links + Raw link rows connecting calculations to this trajectory. + """ + + __tablename__ = "trajectory" + + id: int | None = Field(default=None, primary_key=True) + ndim: int | None = Field(default=None, nullable=True) + + geometry_links: list["GeometryTrajectoryLink"] = Relationship( + back_populates="trajectory" + ) + calculation_links: list["CalculationTrajectoryLink"] = Relationship( + back_populates="trajectory" + ) + + +class ModelRow(SQLModel, table=True): + """Calculation model specification. + + Attributes + ---------- + id + Primary key. + calc_type + Type of calculation (energy, gradient, hessian, etc.). + program + Quantum chemistry program used (psi4, ORCA, ...) + program_version + Quantum chemistry program version. + method + Computational method (B3LYP, MP2, ...) + basis + Orbital basis set. + keywords + Additional keywords and options for the calculation. + calculations + Calculations performed using this model. + """ + + __tablename__ = "model" + + id: int | None = Field(default=None, primary_key=True) + calc_type: str + program: str + program_version: str | None = None + method: str + basis: str | None = None + keywords: dict[str, Any] | None = Field( + default_factory=dict, sa_column=Column(JSON) + ) + + calculations: list["CalculationRow"] = Relationship(back_populates="model") + + +class CalculationRow(SQLModel, table=True): + """Quantum chemistry calculation and its associated data. + + Attributes + ---------- + id + Primary key. + model_id + Foreign key to the model used for this calculation. + input_provenance + Metadata describing how the input was generated. + output_provenance + Metadata describing how the output was produced. + model + Model used for this calculation. + geometry_links + Raw link rows connecting geometries to this calculation. + trajectory_links + Raw link rows connecting trajectories to this calculation. + energies + Energy results produced by this calculation. + gradients + Gradient results produced by this calculation. + hessians + Hessian results produced by this calculation. + validations + Validation results performed by this calculation. + stationary_points + Stationary points identified by this calculation. + """ + + __tablename__ = "calculation" + + id: int | None = Field(default=None, primary_key=True) + model_id: int | None = Field( + default=None, + foreign_key="model.id", + ondelete="CASCADE", + nullable=False, + index=True, + ) + input_provenance: dict[str, Any] | None = Field( + default_factory=dict, sa_column=Column(JSON) + ) + output_provenance: dict[str, Any] | None = Field( + default_factory=dict, sa_column=Column(JSON) + ) + + model: "ModelRow" = Relationship(back_populates="calculations") + energies: list["EnergyRow"] = Relationship(back_populates="calculation") + gradients: list["GradientRow"] = Relationship(back_populates="calculation") + hessians: list["HessianRow"] = Relationship(back_populates="calculation") + validations: list["ValidationRow"] = Relationship(back_populates="calculation") + stationary_points: list["StationaryPointRow"] = Relationship( + back_populates="calculation" + ) + geometry_links: list["CalculationGeometryLink"] = Relationship( + back_populates="calculation" + ) + trajectory_links: list["CalculationTrajectoryLink"] = Relationship( + back_populates="calculation" + ) + + +class EnergyRow(SQLModel, table=True): + """Energy result for a specific geometry and calculation. + + Attributes + ---------- + id + Primary key. + geometry_id + Foreign key to the geometry this energy was evaluated at. + calculation_id + Foreign key to the calculation that produced this energy. + value + Energy value in Hartree. + geometry + Geometry this energy was evaluated at. + calculation + Calculation that produced this energy. + """ + + __tablename__ = "energy" + + id: int | None = Field(default=None, primary_key=True) + geometry_id: int | None = _fk_field("geometry.id") + calculation_id: int | None = _fk_field("calculation.id") + value: float + + calculation: "CalculationRow" = Relationship(back_populates="energies") + geometry: "GeometryRow" = Relationship(back_populates="energies") + + +class GradientRow(SQLModel, table=True): + """Energy gradient result for a specific geometry and calculation. + + Attributes + ---------- + id + Primary key. + geometry_id + Foreign key to the geometry this gradient was evaluated at. + calculation_id + Foreign key to the calculation that produced this gradient. + value + Flattened gradient vector in Hartree/Bohr. + geometry + Geometry this gradient was evaluated at. + calculation + Calculation that produced this gradient. + """ + + __tablename__ = "gradient" + model_config = SQLModelConfig(arbitrary_types_allowed=True) + + id: int | None = Field(default=None, primary_key=True) + geometry_id: int | None = _fk_field("geometry.id") + calculation_id: int | None = _fk_field("calculation.id") + value: FloatArray = Field(sa_column=Column(CompressedArrayTypeDecorator())) + + calculation: "CalculationRow" = Relationship(back_populates="gradients") + geometry: "GeometryRow" = Relationship(back_populates="gradients") + + +class HessianRow(SQLModel, table=True): + """Hessian result for a specific geometry and calculation. + + Attributes + ---------- + id + Primary key. + geometry_id + Foreign key to the geometry this Hessian was evaluated at. + calculation_id + Foreign key to the calculation that produced this Hessian. + value + Hessian matrix in Hartree/Bohr^2. + geometry + Geometry this Hessian was evaluated at. + calculation + Calculation that produced this Hessian. + """ + + __tablename__ = "hessian" + model_config = SQLModelConfig(arbitrary_types_allowed=True) + + id: int | None = Field(default=None, primary_key=True) + geometry_id: int | None = _fk_field("geometry.id") + calculation_id: int | None = _fk_field("calculation.id") + + value: np.ndarray = Field( + sa_column=Column(CompressedArrayTypeDecorator(dtype=np.float32)) + ) + + calculation: "CalculationRow" = Relationship(back_populates="hessians") + geometry: "GeometryRow" = Relationship(back_populates="hessians") + + +class ValidationRow(SQLModel, table=True): + """Validation result for a specific step and calculation. + + Attributes + ---------- + id + Primary key. + calculation_id + Foreign key to the calculation that performed this validation. + method + Type of validation step (e.g., ``irc``) + extras + Additional metadata attached to this validation. + calculation + Calculation that performed this validation. + step + Reaction step this validation belongs to. + """ + + __tablename__ = "validation" + + id: int | None = Field(default=None, primary_key=True) + calculation_id: int | None = _fk_field("calculation.id") + + method: str + extras: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + + calculation: "CalculationRow" = Relationship(back_populates="validations") + step: "StepRow" = Relationship( + back_populates="validations", link_model=StepValidationLink + ) + + +# 2. Stationary point rows +class StationaryPointRow(SQLModel, table=True): + """A stationary point on a potential energy surface. + + Attributes + ---------- + id + Primary key. + geometry_id + Foreign key to the underlying molecular geometry. + calculation_id + Foreign key to the calculation that identified this point. + order + Hessian index (0 for minima, 1 for first-order saddle points). + is_pseudo + Whether this point is not a true stationary point (e.g. constrained). + is_valid + Whether `order` agrees with the consensus Hessian order of its geometry. + geometry + Geometry defining the coordinates of this point. + calculation + Calculation that identified this point. + identities + Chemical identifiers (e.g. InChI, SMILES) for this point. + stages + Reaction stages this stationary point belongs to. + """ + + __tablename__ = "stationary_point" + + id: int | None = Field(default=None, primary_key=True) + geometry_id: int | None = _fk_field("geometry.id") + calculation_id: int | None = _fk_field("calculation.id") + order: int = 0 + is_pseudo: bool = False + is_valid: bool = False + + geometry: "GeometryRow" = Relationship(back_populates="stationary_points") + calculation: "CalculationRow" = Relationship(back_populates="stationary_points") + identities: list["IdentityRow"] = Relationship( + back_populates="stationary_points", link_model=IdentityStationaryLink + ) + stages: list["StageRow"] = Relationship( + back_populates="stationaries", link_model=StageStationaryLink + ) + + +# 3. Reaction network rows +class StageRow(SQLModel, table=True): + """A chemical state (reactant, product, or transition state) in a reaction. + + Attributes + ---------- + id + Primary key. + is_ts + Whether this stage represents a transition state. + stationaries + Stationary points that make up this stage (bidirectional via + `link_model=StageStationaryLink`). + steps + Reaction steps referencing this stage as `stage1`, `stage2`, or + `stage_ts`. Read-only view derived from `StepRow`'s foreign keys; + use `stage1`, `stage2`, `stage_ts` relationships on `StepRow` for + writing. + """ + + __tablename__ = "stage" + + id: int | None = Field(default=None, primary_key=True) + is_ts: bool = False + + stationaries: list["StationaryPointRow"] = Relationship( + back_populates="stages", link_model=StageStationaryLink + ) + steps: list["StepRow"] = Relationship( + sa_relationship_kwargs={ + "primaryjoin": "or_(" + "StageRow.id == StepRow.stage_id1, " + "StageRow.id == StepRow.stage_id2, " + "StageRow.id == StepRow.stage_id_ts" + ")", + "viewonly": True, + } + ) + + +class StepRow(SQLModel, table=True): + """An elementary reaction step connecting a reactant, transition state, and product. + + Attributes + ---------- + id + Primary key. + stage_id1, stage_id2 + Foreign keys to the step's two non-TS stages (stored with + `stage_id1 < stage_id2`). + stage_id_ts + Foreign key to the step's transition-state stage, or `None` for a + barrierless step. + is_barrierless + Whether this step proceeds without a formal transition state. + stage1 + The step's first non-TS stage (reactant or product). + stage2 + The step's second non-TS stage (reactant or product). + stage_ts + The step's transition-state stage, or `None` if barrierless. + Note: not back-populated from StageRow.steps (which is read-only). + validations + Validation calculations performed on this step. + """ + + __tablename__ = "step" + __table_args__ = ( + UniqueConstraint( + "stage_id1", "stage_id2", "stage_id_ts", name="unq_step_stages" + ), + CheckConstraint("stage_id1 < stage_id2", name="chk_stage_order"), + # `unq_step_stages` doesn't catch duplicate barrierless steps (stage_id_ts + # NULL), since SQL never treats NULL as equal to itself in a unique + # constraint. This expression index closes that gap at the DB level. + Index( + "unq_step_stages_null_safe", + "stage_id1", + "stage_id2", + text("coalesce(stage_id_ts, 0)"), + unique=True, + ), + # `stage_id1` is already covered as the leading column of the two indexes + # above, but is indexed explicitly here too for symmetry/clarity. + Index("ix_step_stage_id1", "stage_id1"), + Index("ix_step_stage_id2", "stage_id2"), + Index("ix_step_stage_id_ts", "stage_id_ts"), + ) + + id: int | None = Field(default=None, primary_key=True) + stage_id1: int | None = Field( + default=None, + foreign_key="stage.id", + ondelete="CASCADE", + nullable=False, + ) + stage_id2: int | None = Field( + default=None, + foreign_key="stage.id", + ondelete="CASCADE", + nullable=False, + ) + stage_id_ts: int | None = Field( + default=None, + foreign_key="stage.id", + ondelete="CASCADE", + ) + + is_barrierless: bool = False + + validations: list["ValidationRow"] = Relationship( + back_populates="step", link_model=StepValidationLink + ) + + stage1: "StageRow" = Relationship( + sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id1]"} + ) + stage2: "StageRow" = Relationship( + sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id2]"} + ) + stage_ts: "StageRow" = Relationship( + sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id_ts]"} + ) + + +# 4. Identity rows +class IdentityRow(SQLModel, Identity, table=True): + """A chemical identifier associated with one or more stationary points. + + Attributes + ---------- + id + Primary key. + kind + Category of identifier (e.g. ``stereoisomer``, ``formula``). + algorithm + Method used to generate the identifier (e.g. ``rdkit inchi``, ``rdkit smiles``). + value + The resulting identifier string. + stationary_points + Stationary points sharing this identity. + identity_extras + Additional key-value metadata attached to this identity. + """ + + __tablename__ = "identity" + __table_args__ = ( + UniqueConstraint("kind", "algorithm", "value", name="unique_identity"), + ) + + id: int | None = Field(default=None, primary_key=True) + + stationary_points: list["StationaryPointRow"] = Relationship( + back_populates="identities", link_model=IdentityStationaryLink + ) + identity_extras: list["IdentityExtraRow"] = Relationship(back_populates="identity") + + +class IdentityExtraRow(SQLModel, table=True): + """Additional key-value metadata attached to a chemical identity. + + Attributes + ---------- + id + Primary key. + identity_id + Foreign key to the parent identity. + attribute + Name of the extra attribute. + value + Value of the extra attribute. + identity + The parent identity this extra belongs to. + """ + + __tablename__ = "identity_extras" + + id: int | None = Field(default=None, primary_key=True) + identity_id: int | None = Field( + default=None, + foreign_key="identity.id", + ondelete="CASCADE", + nullable=False, + index=True, + ) + + attribute: str + value: str + + identity: "IdentityRow" = Relationship(back_populates="identity_extras") diff --git a/src/autostorage/models/__init__.py b/src/autostorage/models/__init__.py deleted file mode 100644 index 4557034..0000000 --- a/src/autostorage/models/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -"""SQLModel row definitions for autostorage's persistence schema.""" - -from sqlmodel import SQLModel - -from ..types import _fk_field -from .calc import CalculationRow, ModelRow, ValidationRow -from .data import EnergyRow, GradientRow, HessianRow -from .geom import GeometryRow, _geometry_hash -from .link import ( - CalculationGeometryLink, - CalculationTrajectoryLink, - StationaryIdentityLink, - StationaryStageLink, - StepValidationLink, - TrajectoryGeometryLink, -) -from .rxn import IdentityExtraRow, IdentityRow, StageRow, StationaryPointRow, StepRow -from .traj import TrajectoryRow - -__all__ = [ - "CalculationGeometryLink", - "CalculationRow", - "CalculationTrajectoryLink", - "EnergyRow", - "GeometryRow", - "GradientRow", - "HessianRow", - "IdentityExtraRow", - "IdentityRow", - "ModelRow", - "SQLModel", - "StageRow", - "StationaryIdentityLink", - "StationaryPointRow", - "StationaryStageLink", - "StepRow", - "StepValidationLink", - "TrajectoryGeometryLink", - "TrajectoryRow", - "ValidationRow", - "_fk_field", - "_geometry_hash", -] diff --git a/src/autostorage/models/calc.py b/src/autostorage/models/calc.py deleted file mode 100644 index 556a484..0000000 --- a/src/autostorage/models/calc.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Calculation-related row definitions: model, calculation, validation.""" - -from datetime import datetime -from typing import TYPE_CHECKING, Any - -from sqlmodel import ( - JSON, - Column, - Enum, - Field, - Index, - Relationship, - SQLModel, - UniqueConstraint, - func, - text, -) - -from autostorage.types import CalcStatus, CalcType, Role, _fk_field - -from .link import StepValidationLink - -if TYPE_CHECKING: - from .geom import GeometryRow - from .link import CalculationGeometryLink, CalculationTrajectoryLink - from .rxn import StepRow - from .traj import TrajectoryRow - - -# Calculation rows -class ModelRow(SQLModel, table=True): - """Calculation model specification. - - Attributes - ---------- - program - Quantum chemistry program used (psi4, ORCA, ...) - program_version - Quantum chemistry program version. - method - Computational method (B3LYP, MP2, ...) - basis - Orbital basis set. - """ - - __tablename__ = "model" - __table_args__ = ( - UniqueConstraint( - "program", - "program_version", - "method", - "basis", - name="unique_model", - ), - # `unique_model` doesn't catch duplicates when `program_version` or `basis` - # is NULL, since SQL treats NULL as distinct from itself in unique - # constraints. This expression index closes that gap at the DB level. - Index( - "unique_model_null_safe", - "program", - text("coalesce(program_version, '')"), - "method", - text("coalesce(basis, '')"), - unique=True, - ), - ) - - id: int | None = Field(default=None, primary_key=True) - program: str - program_version: str | None = None - method: str - basis: str | None = None - - -class CalculationRow(SQLModel, table=True): - """Quantum chemistry calculation and its associated data. - - Attributes - ---------- - model_id - Foreign key to the model used for this calculation. - calc_type - Type of calculation performed. - status - Lifecycle status of this calculation. - error_message - Error message recorded for a failed calculation, if any. - input_provenance - Metadata describing how the input was generated. - output_provenance - Metadata describing how the output was produced. - model - Model used for this calculation. - geometry_links - Raw link rows connecting geometries to this calculation. - trajectory_links - Raw link rows connecting trajectories to this calculation. - """ - - __tablename__ = "calculation" - - id: int | None = Field(default=None, primary_key=True) - model_id: int | None = Field( - default=None, - foreign_key="model.id", - ondelete="CASCADE", - nullable=False, - index=True, - ) - calc_type: CalcType = Field( - sa_column=Column(Enum(CalcType, values_callable=lambda x: [e.value for e in x])) - ) - status: CalcStatus = Field( - default=CalcStatus.PENDING, - sa_column=Column( - Enum(CalcStatus, values_callable=lambda x: [e.value for e in x]) - ), - ) - input_provenance: dict[str, Any] | None = Field( - default_factory=dict, sa_column=Column(JSON) - ) - output_provenance: dict[str, Any] | None = Field( - default_factory=dict, sa_column=Column(JSON) - ) - created: datetime | None = Field( - default=None, - nullable=False, - sa_column_kwargs={"server_default": func.now()}, - ) - error_message: str | None = Field(default=None) - - model: "ModelRow" = Relationship() - geometry_links: list["CalculationGeometryLink"] = Relationship( - back_populates="calculation" - ) - trajectory_links: list["CalculationTrajectoryLink"] = Relationship( - back_populates="calculation" - ) - - @property - def input_geometries(self) -> list["GeometryRow"]: - """Geometries linked to this calculation with an INPUT role.""" - return [ - link.geometry for link in self.geometry_links if link.role == Role.INPUT - ] - - @property - def output_geometries(self) -> list["GeometryRow"]: - """Geometries linked to this calculation with an OUTPUT role.""" - return [ - link.geometry for link in self.geometry_links if link.role == Role.OUTPUT - ] - - @property - def input_trajectories(self) -> list["TrajectoryRow"]: - """Trajectories linked to this calculation with an INPUT role.""" - return [ - link.trajectory for link in self.trajectory_links if link.role == Role.INPUT - ] - - @property - def output_trajectories(self) -> list["TrajectoryRow"]: - """Trajectories linked to this calculation with an OUTPUT role.""" - return [ - link.trajectory - for link in self.trajectory_links - if link.role == Role.OUTPUT - ] - - -class ValidationRow(SQLModel, table=True): - """Validation result for a specific step and calculation. - - Attributes - ---------- - calculation_id - Foreign key to the calculation that performed this validation. - method - Type of validation step (e.g., ``irc``) - extras - Additional metadata attached to this validation. - calculation - Calculation that performed this validation. - step - Reaction step this validation belongs to. - """ - - __tablename__ = "validation" - - id: int | None = Field(default=None, primary_key=True) - calculation_id: int | None = _fk_field("calculation.id") - - method: str - # Intentionally unbounded free-form JSON; add a size/schema guardrail if - # this is ever populated from a less-trusted input path. - extras: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) - - calculation: "CalculationRow" = Relationship() - step: "StepRow" = Relationship( - back_populates="validations", link_model=StepValidationLink - ) diff --git a/src/autostorage/models/data.py b/src/autostorage/models/data.py deleted file mode 100644 index fc2762f..0000000 --- a/src/autostorage/models/data.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Result row definitions (energy, gradient, Hessian).""" - -from functools import cached_property -from typing import TYPE_CHECKING - -import numpy as np -from automol import geom -from automol.utils.types import FloatArray -from sqlmodel import Column, Field, Relationship, SQLModel -from sqlmodel.main import SQLModelConfig - -from autostorage.types import CompressedArrayTypeDecorator, _fk_field - -if TYPE_CHECKING: - from .calc import CalculationRow - from .geom import GeometryRow - - -class EnergyRow(SQLModel, table=True): - """Energy result for a specific geometry and calculation. - - Attributes - ---------- - geometry_id - Foreign key to the geometry this energy was evaluated at. - calculation_id - Foreign key to the calculation that produced this energy. - value - Energy value in Hartree. - geometry - Geometry this energy was evaluated at. - calculation - Calculation that produced this energy. - """ - - __tablename__ = "energy" - - id: int | None = Field(default=None, primary_key=True) - geometry_id: int | None = _fk_field("geometry.id") - calculation_id: int | None = _fk_field("calculation.id") - value: float - - calculation: "CalculationRow" = Relationship() - geometry: "GeometryRow" = Relationship(back_populates="energies") - - -class GradientRow(SQLModel, table=True): - """Energy gradient result for a specific geometry and calculation. - - Attributes - ---------- - geometry_id - Foreign key to the geometry this gradient was evaluated at. - calculation_id - Foreign key to the calculation that produced this gradient. - value - Flattened gradient vector in Hartree/Bohr. - geometry - Geometry this gradient was evaluated at. - calculation - Calculation that produced this gradient. - """ - - __tablename__ = "gradient" - model_config = SQLModelConfig(arbitrary_types_allowed=True) - - id: int | None = Field(default=None, primary_key=True) - geometry_id: int | None = _fk_field("geometry.id") - calculation_id: int | None = _fk_field("calculation.id") - value: FloatArray = Field(sa_column=Column(CompressedArrayTypeDecorator())) - - calculation: "CalculationRow" = Relationship() - geometry: "GeometryRow" = Relationship(back_populates="gradients") - - -class HessianRow(SQLModel, table=True): - """Hessian result for a specific geometry and calculation. - - Attributes - ---------- - geometry_id - Foreign key to the geometry this Hessian was evaluated at. - calculation_id - Foreign key to the calculation that produced this Hessian. - value - Hessian matrix in Hartree/Bohr^2. - geometry - Geometry this Hessian was evaluated at. - calculation - Calculation that produced this Hessian. - """ - - __tablename__ = "hessian" - model_config = SQLModelConfig(arbitrary_types_allowed=True) - - id: int | None = Field(default=None, primary_key=True) - geometry_id: int | None = _fk_field("geometry.id") - calculation_id: int | None = _fk_field("calculation.id") - - value: np.ndarray = Field( - sa_column=Column(CompressedArrayTypeDecorator(dtype=np.float32)) - ) - - calculation: "CalculationRow" = Relationship() - geometry: "GeometryRow" = Relationship(back_populates="hessians") - - @cached_property - def harmonic_frequencies(self) -> tuple[float, ...]: - """Harmonic frequencies derived from the Hessian. - - Cached per instance, since vibrational analysis re-diagonalizes the - Hessian on every call and `.order` (used by `_recompute_geometry_ - stationary_validity` for every sibling Hessian of a geometry, on - every relevant flush) depends on it. Invalidated on `value` update - by `invalidate_hessian_frequency_cache` in `events.py`. - """ - freqs, _ = geom.vibrational_analysis( - geo=self.geometry.to_geometry(), hess=self.value - ) - return freqs - - @property - def order(self) -> int: - """Hessian order.""" - return sum(1 for f in self.harmonic_frequencies if f < 0.0) diff --git a/src/autostorage/models/geom.py b/src/autostorage/models/geom.py deleted file mode 100644 index 2b2260b..0000000 --- a/src/autostorage/models/geom.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Molecular geometry row definition.""" - -import hashlib -import json -from typing import TYPE_CHECKING - -import numpy as np -from automol import Geometry -from automol.utils.types import FloatArray -from sqlmodel import JSON, Column, Field, Relationship, SQLModel, UniqueConstraint -from sqlmodel.main import SQLModelConfig - -from autostorage.types import CompressedArrayTypeDecorator - -if TYPE_CHECKING: - from .data import EnergyRow, GradientRow, HessianRow - from .link import CalculationGeometryLink, TrajectoryGeometryLink - from .rxn import StationaryPointRow - - -def _geometry_hash( - symbols: list[str], coordinates: FloatArray, charge: int, spin: int -) -> str: - """Compute a hash identifying bit-identical geometry content.""" - hasher = hashlib.sha256() - hasher.update(json.dumps(symbols).encode()) - hasher.update(np.asarray(coordinates, dtype=np.float64).tobytes()) - hasher.update(charge.to_bytes(8, "big", signed=True)) - hasher.update(spin.to_bytes(8, "big", signed=True)) - return hasher.hexdigest() - - -# Geometry table -class GeometryRow(SQLModel, table=True): - """Molecular geometry definition and metadata. - - Attributes - ---------- - symbols - Atomic symbols in order. - coordinates - Atomic coordinates in Angstrom. - charge - Total molecular charge. - spin - Number of unpaired electrons (2S). - geometry_hash - Content hash of `symbols`/`coordinates`/`charge`/`spin`, used to reject - exactly-duplicate geometries. - energies - Energy results computed at this geometry. - gradients - Gradient results computed at this geometry. - hessians - Hessian results computed at this geometry. - stationary_points - Stationary points defined by this geometry. - trajectory_links - Raw link rows connecting this geometry to trajectories. - calculation_links - Raw link rows connecting this geometry to calculations. - """ - - __tablename__ = "geometry" - __table_args__ = (UniqueConstraint("geometry_hash", name="unique_geometry_hash"),) - model_config = SQLModelConfig(arbitrary_types_allowed=True) - - id: int | None = Field(default=None, primary_key=True) - symbols: list[str] = Field(sa_column=Column(JSON)) - coordinates: FloatArray = Field(sa_column=Column(CompressedArrayTypeDecorator())) - charge: int - spin: int - geometry_hash: str | None = Field(default=None, nullable=False) - - energies: list["EnergyRow"] = Relationship(back_populates="geometry") - gradients: list["GradientRow"] = Relationship(back_populates="geometry") - hessians: list["HessianRow"] = Relationship(back_populates="geometry") - stationary_points: list["StationaryPointRow"] = Relationship( - back_populates="geometry" - ) - trajectory_links: list["TrajectoryGeometryLink"] = Relationship( - back_populates="geometry" - ) - calculation_links: list["CalculationGeometryLink"] = Relationship( - back_populates="geometry" - ) - - def to_geometry(self) -> Geometry: - """Convert to an automol Geometry instance.""" - return Geometry( - symbols=self.symbols, - coordinates=self.coordinates, - charge=self.charge, - spin=self.spin, - ) diff --git a/src/autostorage/models/link.py b/src/autostorage/models/link.py deleted file mode 100644 index 0b3c88d..0000000 --- a/src/autostorage/models/link.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Association tables linking row entities together.""" - -from typing import TYPE_CHECKING - -from sqlmodel import JSON, Column, Enum, Field, Index, Relationship, SQLModel - -from autostorage.types import Role - -if TYPE_CHECKING: - from .calc import CalculationRow - from .geom import GeometryRow - from .traj import TrajectoryRow - - -class TrajectoryGeometryLink(SQLModel, table=True): - """Association table linking geometries to a trajectory. - - Attributes - ---------- - geometry_id - Foreign key to the linked geometry. - trajectory_id - Foreign key to the linked trajectory. - index - Position of the geometry within the trajectory. - geometry - The linked geometry. - trajectory - The linked trajectory. - """ - - __tablename__ = "trajectory_geometry_link" - __table_args__ = ( - Index("ix_trajectory_geometry_link_trajectory_id", "trajectory_id"), - ) - - geometry_id: int | None = Field( - default=None, - foreign_key="geometry.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - trajectory_id: int | None = Field( - default=None, - foreign_key="trajectory.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - index: list[int] | None = Field(default=None, sa_column=Column(JSON)) - - geometry: "GeometryRow" = Relationship(back_populates="trajectory_links") - trajectory: "TrajectoryRow" = Relationship(back_populates="geometry_links") - - -# Link tables declared here, ahead of the StationaryPointRow/IdentityRow and -# StationaryPointRow/StageRow entities they connect, because SQLModel's -# `link_model=` kwarg needs the actual class object at class-body-evaluation -# time — unlike every other cross-model reference in this file, it can't be -# satisfied by a lazily-resolved string forward ref. -class StationaryIdentityLink(SQLModel, table=True): - """Association table linking stationary points to chemical identities. - - Attributes - ---------- - stationary_id - Foreign key to the linked stationary point. - identity_id - Foreign key to the linked identity. - """ - - __tablename__ = "stationary_identity_link" - __table_args__ = (Index("ix_stationary_identity_link_identity_id", "identity_id"),) - - stationary_id: int = Field( - foreign_key="stationary_point.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - identity_id: int = Field( - foreign_key="identity.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - - -class StationaryStageLink(SQLModel, table=True): - """Association table linking stationary points to reaction stages. - - Attributes - ---------- - stationary_id - Foreign key to the linked stationary point. - stage_id - Foreign key to the linked reaction stage. - stationary - The linked stationary point. - stage - The linked reaction stage. - """ - - __tablename__ = "stationary_stage_link" - __table_args__ = (Index("ix_stationary_stage_link_stage_id", "stage_id"),) - - stationary_id: int | None = Field( - default=None, - foreign_key="stationary_point.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - stage_id: int | None = Field( - default=None, - foreign_key="stage.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - - -# Declared here, ahead of StepRow, for the same `link_model=` reason as -# StationaryIdentityLink/StationaryStageLink above. -class StepValidationLink(SQLModel, table=True): - """Association table linking validations to a step. - - Attributes - ---------- - step_id - Foreign key to the linked step. - validation_id - Foreign key to the linked validation. - """ - - __tablename__ = "step_validation_link" - __table_args__ = (Index("ix_step_validation_link_validation_id", "validation_id"),) - - step_id: int = Field( - foreign_key="step.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - validation_id: int = Field( - foreign_key="validation.id", - primary_key=True, - ondelete="CASCADE", - nullable=False, - ) - - -class CalculationGeometryLink(SQLModel, table=True): - """Association table linking geometries to a calculation. - - Attributes - ---------- - geometry_id - Foreign key to the linked geometry. - calculation_id - Foreign key to the linked calculation. - role - Role the geometry plays for this calculation (input/output). - geometry - The linked geometry. - calculation - The linked calculation. - """ - - __tablename__ = "calculation_geometry_link" - __table_args__ = ( - # The composite primary key only serves lookups keyed by `geometry_id` - # (its leading column); this adds a matching index for `calculation_id`. - Index("ix_calculation_geometry_link_calculation_id", "calculation_id"), - ) - - geometry_id: int | None = Field( - default=None, - foreign_key="geometry.id", - ondelete="CASCADE", - nullable=False, - primary_key=True, - ) - calculation_id: int | None = Field( - default=None, - foreign_key="calculation.id", - ondelete="CASCADE", - nullable=False, - primary_key=True, - ) - role: Role = Field( - sa_column=Column(Enum(Role, values_callable=lambda x: [e.value for e in x])) - ) - - geometry: "GeometryRow" = Relationship(back_populates="calculation_links") - calculation: "CalculationRow" = Relationship(back_populates="geometry_links") - - -class CalculationTrajectoryLink(SQLModel, table=True): - """Association table linking trajectories to a calculation. - - Attributes - ---------- - trajectory_id - Foreign key to the linked trajectory. - calculation_id - Foreign key to the linked calculation. - role - Role the trajectory plays for this calculation (input/output). - trajectory - The linked trajectory. - calculation - The linked calculation. - """ - - __tablename__ = "calculation_trajectory_link" - __table_args__ = ( - Index("ix_calculation_trajectory_link_calculation_id", "calculation_id"), - ) - - trajectory_id: int | None = Field( - default=None, - foreign_key="trajectory.id", - ondelete="CASCADE", - nullable=False, - primary_key=True, - ) - calculation_id: int | None = Field( - default=None, - foreign_key="calculation.id", - ondelete="CASCADE", - nullable=False, - primary_key=True, - ) - role: Role = Field( - sa_column=Column(Enum(Role, values_callable=lambda x: [e.value for e in x])) - ) - - trajectory: "TrajectoryRow" = Relationship(back_populates="calculation_links") - calculation: "CalculationRow" = Relationship(back_populates="trajectory_links") diff --git a/src/autostorage/models/rxn.py b/src/autostorage/models/rxn.py deleted file mode 100644 index 79cf732..0000000 --- a/src/autostorage/models/rxn.py +++ /dev/null @@ -1,278 +0,0 @@ -"""Reaction-related row definitions: stationary points, identities, stages, steps.""" - -from datetime import datetime -from typing import TYPE_CHECKING, Any - -from automol import Identity -from sqlmodel import ( - CheckConstraint, - Field, - Index, - Relationship, - SQLModel, - UniqueConstraint, - func, - text, -) - -from ..types import _fk_field -from .link import StationaryIdentityLink, StationaryStageLink, StepValidationLink - -if TYPE_CHECKING: - from .calc import CalculationRow, ValidationRow - from .geom import GeometryRow - - -# Stationary point rows -class StationaryPointRow(SQLModel, table=True): - """A stationary point on a potential energy surface. - - Attributes - ---------- - geometry_id - Foreign key to the underlying molecular geometry. - calculation_id - Foreign key to the calculation that identified this point. - order - Hessian index (0 for minima, 1 for first-order saddle points). - is_pseudo - Whether this point is not a true stationary point (e.g. constrained). - is_valid - Whether `order` agrees with the consensus order of its geometry's - Hessians (see `autostorage.events.revalidate_geometry_orders_on_insert_update`). - geometry - Geometry defining the coordinates of this point. - calculation - Calculation that identified this point. - identities - Chemical identifiers (e.g. InChI, SMILES) for this point. - stages - Reaction stages this stationary point belongs to. - """ - - __tablename__ = "stationary_point" - - id: int | None = Field(default=None, primary_key=True) - geometry_id: int | None = _fk_field("geometry.id") - calculation_id: int | None = _fk_field("calculation.id") - order: int = 0 - is_pseudo: bool = False - is_valid: bool = False - - geometry: "GeometryRow" = Relationship(back_populates="stationary_points") - calculation: "CalculationRow" = Relationship() - identities: list["IdentityRow"] = Relationship( - back_populates="stationary_points", link_model=StationaryIdentityLink - ) - stages: list["StageRow"] = Relationship( - back_populates="stationaries", link_model=StationaryStageLink - ) - - created: datetime | None = Field( - default=None, - nullable=False, - sa_column_kwargs={"server_default": func.now()}, - ) - updated: datetime | None = Field( - default=None, - nullable=False, - sa_column_kwargs={"server_default": func.now(), "onupdate": func.now()}, - ) - - def identity( - self, - *, - kind: str | None = None, - algorithm: Any | None = None, # noqa: ANN401 - ) -> "IdentityRow | None": - """Return the first loaded identity matching kind and/or algorithm. - - Searches `self.identities` (the already-loaded relationship list), - not the database. - """ - return next( - ( - i - for i in self.identities - if (kind is None or i.kind == kind) - and (algorithm is None or i.algorithm == algorithm) - ), - None, - ) - - -class IdentityRow(SQLModel, Identity, table=True): - """A chemical identifier associated with one or more stationary points. - - Attributes - ---------- - kind - Category of identifier (e.g. ``stereoisomer``, ``formula``). - algorithm - Method used to generate the identifier (e.g. ``rdkit inchi``, ``rdkit smiles``). - value - The resulting identifier string. - stationary_points - Stationary points sharing this identity. - identity_extras - Additional key-value metadata attached to this identity. - """ - - __tablename__ = "identity" - __table_args__ = ( - UniqueConstraint("kind", "algorithm", "value", name="unique_identity"), - ) - - id: int | None = Field(default=None, primary_key=True) - - stationary_points: list["StationaryPointRow"] = Relationship( - back_populates="identities", link_model=StationaryIdentityLink - ) - identity_extras: list["IdentityExtraRow"] = Relationship(back_populates="identity") - - -class IdentityExtraRow(SQLModel, table=True): - """Additional key-value metadata attached to a chemical identity. - - Attributes - ---------- - identity_id - Foreign key to the parent identity. - attribute - Name of the extra attribute. - value - Value of the extra attribute. - identity - The parent identity this extra belongs to. - """ - - __tablename__ = "identity_extras" - - id: int | None = Field(default=None, primary_key=True) - identity_id: int | None = Field( - default=None, - foreign_key="identity.id", - ondelete="CASCADE", - nullable=False, - index=True, - ) - - attribute: str - value: str - - identity: "IdentityRow" = Relationship(back_populates="identity_extras") - - -# Reaction rows -class StageRow(SQLModel, table=True): - """A chemical state (reactant, product, or transition state) in a reaction. - - Attributes - ---------- - is_ts - Whether this stage represents a transition state. - stationaries - Stationary points that make up this stage. - steps - Reaction steps referencing this stage as `stage1`, `stage2`, or - `stage_ts` (read-only; derived from `StepRow`'s foreign keys). - """ - - __tablename__ = "stage" - - id: int | None = Field(default=None, primary_key=True) - is_ts: bool = False - - stationaries: list["StationaryPointRow"] = Relationship( - back_populates="stages", link_model=StationaryStageLink - ) - steps: list["StepRow"] = Relationship( - sa_relationship_kwargs={ - "primaryjoin": "or_(" - "StageRow.id == StepRow.stage_id1, " - "StageRow.id == StepRow.stage_id2, " - "StageRow.id == StepRow.stage_id_ts" - ")", - "viewonly": True, - } - ) - - -class StepRow(SQLModel, table=True): - """An elementary reaction step connecting a reactant, transition state, and product. - - Attributes - ---------- - stage_id1, stage_id2 - Foreign keys to the step's two non-TS stages (stored with - `stage_id1 < stage_id2`). - stage_id_ts - Foreign key to the step's transition-state stage, or `None` for a - barrierless step. - is_barrierless - Whether this step proceeds without a formal transition state. - stage1, stage2 - The step's two non-TS stages. - stage_ts - The step's transition-state stage, or `None` if barrierless. - validations - Validation calculations performed on this step. - """ - - __tablename__ = "step" - __table_args__ = ( - UniqueConstraint( - "stage_id1", "stage_id2", "stage_id_ts", name="unq_step_stages" - ), - CheckConstraint("stage_id1 < stage_id2", name="chk_stage_order"), - # `unq_step_stages` doesn't catch duplicate barrierless steps (stage_id_ts - # NULL), since SQL never treats NULL as equal to itself in a unique - # constraint. This expression index closes that gap at the DB level. - Index( - "unq_step_stages_null_safe", - "stage_id1", - "stage_id2", - text("coalesce(stage_id_ts, 0)"), - unique=True, - ), - # `stage_id1` is already covered as the leading column of the two indexes - # above, but is indexed explicitly here too for symmetry/clarity. - Index("ix_step_stage_id1", "stage_id1"), - Index("ix_step_stage_id2", "stage_id2"), - Index("ix_step_stage_id_ts", "stage_id_ts"), - ) - - id: int | None = Field(default=None, primary_key=True) - stage_id1: int | None = Field( - default=None, - foreign_key="stage.id", - ondelete="CASCADE", - nullable=False, - ) - stage_id2: int | None = Field( - default=None, - foreign_key="stage.id", - ondelete="CASCADE", - nullable=False, - ) - stage_id_ts: int | None = Field( - default=None, - foreign_key="stage.id", - ondelete="CASCADE", - ) - - is_barrierless: bool = False - - validations: list["ValidationRow"] = Relationship( - back_populates="step", link_model=StepValidationLink - ) - - stage1: "StageRow" = Relationship( - sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id1]"} - ) - stage2: "StageRow" = Relationship( - sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id2]"} - ) - stage_ts: "StageRow" = Relationship( - sa_relationship_kwargs={"foreign_keys": "[StepRow.stage_id_ts]"} - ) diff --git a/src/autostorage/models/traj.py b/src/autostorage/models/traj.py deleted file mode 100644 index 6295670..0000000 --- a/src/autostorage/models/traj.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Trajectory row definition.""" - -from typing import TYPE_CHECKING - -from sqlmodel import Field, Relationship, SQLModel - -if TYPE_CHECKING: - from .link import CalculationTrajectoryLink, TrajectoryGeometryLink - - -class TrajectoryRow(SQLModel, table=True): - """Ordered sequence of geometries from a calculation trajectory. - - Attributes - ---------- - geometry_links - Raw link rows connecting geometries to this trajectory. - calculation_links - Raw link rows connecting calculations to this trajectory. - """ - - __tablename__ = "trajectory" - - id: int | None = Field(default=None, primary_key=True) - - geometry_links: list["TrajectoryGeometryLink"] = Relationship( - back_populates="trajectory" - ) - calculation_links: list["CalculationTrajectoryLink"] = Relationship( - back_populates="trajectory" - ) diff --git a/src/autostorage/types.py b/src/autostorage/types.py index d1cafa5..e37fcda 100644 --- a/src/autostorage/types.py +++ b/src/autostorage/types.py @@ -10,13 +10,7 @@ from sqlalchemy.types import TypeDecorator from sqlmodel import Field -__all__ = [ - "CalcStatus", - "CalcType", - "CompressedArrayTypeDecorator", - "Role", - "_fk_field", -] +__all__ = ["CompressedArrayTypeDecorator", "Role"] def _fk_field(target: str, *, nullable: bool = False, index: bool = True) -> Any: # noqa: ANN401 @@ -68,74 +62,3 @@ class Role(StrEnum): INPUT = "input" OUTPUT = "output" - - -class CalcType(StrEnum): - """Primary calculation types. - - Attributes - ---------- - OPT - Geometry optimization to find a local minimum on the PES. - OPT_TS - Saddle-point geometry optimization to locate a transition state structure. - CONFORMER - Conformational search/sampling to identify low-energy spatial arrangements. - SCAN - PES scan across user-defined geometric coordinates. - IRC - Intrinsic Reaction Coordinate mapping minimum energy pathway from TS to - its connected reactants and products. - MEP - Minimum Energy Path multi-image chain searches (e.g., Nudged Elastic Band, - String Methods) to discover reaction trajectories and TS guesses. - ENERGY - Single-point electronic energy evaluation at a fixed molecular geometry. - GRADIENT - Nuclear gradient evaluation to compute forces acting on the atoms. - FREQUENCY - Vibrational frequency analysis to verify stationary point order and compute - a Hessian/zero-point energy. - THERMO - Statistical mechanics/thermochemical parsing to determine enthalpy (`H`), - entropy (`S`), and Gibbs free energy (`G`). - UNDEFINED - Placeholder for generic or unclassified calculation types. - """ - - # Structural exploration - OPT = "optimization" - OPT_TS = "transition_optimization" - CONFORMER = "conformer_search" - # Path generation - SCAN = "scan" - IRC = "intrinsic_reaction_coordinate" - MEP = "minimum_energy_path_search" - # Properties - ENERGY = "energy" - GRADIENT = "gradient" - FREQUENCY = "frequency" - THERMO = "thermochemistry" - # Fallback - UNDEFINED = "undefined" - - -class CalcStatus(StrEnum): - """Lifecycle status of a calculation. - - Attributes - ---------- - PENDING - Queued but not yet started. - RUNNING - Currently executing. - SUCCEEDED - Completed successfully. - FAILED - Terminated with an error. - """ - - PENDING = "pending" - RUNNING = "running" - SUCCEEDED = "succeeded" - FAILED = "failed" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index e0d5de6..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Autostorage test fixtures.""" - -from collections.abc import Iterator - -import numpy as np -import pytest -from numpy.random import Generator - -from autostorage import ( - CalcType, - CalculationGeometryLink, - CalculationRow, - Database, - GeometryRow, - ModelRow, -) -from autostorage.types import Role - - -@pytest.fixture -def rng() -> Generator: - """Fixture for numpy rng.""" - return np.random.default_rng(seed=679) - - -@pytest.fixture -def database() -> Iterator[Database]: - """In-memory database fixture.""" - db = Database(":memory:") - - try: - yield db - - finally: - db.close() - - -@pytest.fixture -def model_row() -> ModelRow: - """Fixture for ModelRow.""" - return ModelRow( - program="ORCA", - program_version="6.1.1", - method="b3lyp", - basis="def2-SVP", - ) - - -@pytest.fixture -def geometry_row() -> GeometryRow: - """Fixture for GeometryRow.""" - return GeometryRow( - symbols=["H", "O", "H"], - coordinates=np.array([[0, 0, 0.8], [0, 0, 0], [0.8, 0, 0]]), - charge=0, - spin=0, - ) - - -@pytest.fixture -def calculation_row(model_row: ModelRow) -> CalculationRow: - """Fixture for CalculationRow.""" - return CalculationRow(model=model_row, calc_type=CalcType.UNDEFINED) - - -@pytest.fixture -def calc_geo_link( - calculation_row: CalculationRow, geometry_row: GeometryRow -) -> CalculationGeometryLink: - """Fixture for CalculationGeometryLink.""" - return CalculationGeometryLink( - calculation=calculation_row, geometry=geometry_row, role=Role.INPUT - ) diff --git a/tests/data/propyl_oxirane.xyz b/tests/data/propyl_oxirane.xyz deleted file mode 100644 index eaf8d47..0000000 --- a/tests/data/propyl_oxirane.xyz +++ /dev/null @@ -1,17 +0,0 @@ -15 -Coordinates from ORCA-job freq E -271.046152099005 - C -1.96724663066026 0.80558754753944 -0.26984185877179 - C -1.29117726250631 -0.36918071893157 0.34150331476840 - C -0.04004321603558 -0.80422018365461 -0.42635994313307 - C 1.03214563151731 0.24863206570885 -0.38677606439848 - O 2.34959758787060 -0.17269436687856 -0.68722367928961 - C 2.07345817914486 0.23122270310282 0.64338371469441 - H -2.06876374685845 0.86895595068965 -1.34453061196824 - H -2.52840627498046 1.50521946095658 0.33021365027168 - H -1.98290858765521 -1.22174853490473 0.38141017294270 - H -1.02615105441698 -0.15208251033875 1.37942492009934 - H 0.36599658366260 -1.72653521419848 -0.00740760059068 - H -0.30318264941652 -1.01073961341585 -1.46734173779766 - H 0.76769398303030 1.21217226524433 -0.81453115079735 - H 2.54681052278233 1.15523188532636 0.95299265615060 - H 2.07217694452179 -0.56982074624546 1.37508422781971 diff --git a/tests/data/propyl_oxirane_frequencies.gz b/tests/data/propyl_oxirane_frequencies.gz deleted file mode 100644 index d525ed50379c8124b098c2d4a994d4121bd0bafb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 512 zcmV+b0{{IViwFqwM>A>y|8R0|aCvNBZ+K~PVQyt#W^!e5b!Bd2X=QT&#FN{tWEV_0b40(_yf|kIJrF%kkI>pIdK%jswo-ic)T)g#xYoR$jIn(A_QRYio|+A z#$)XT-Z^I_EOKh5UO>w>st{9^p}=1d1NQoA|LMp#$9TG|fP9}m5g*`Kpp}S_Rb6+Z zZcu8J(l~lqa^}q;F&!-hQk!+U^MZnEgPfO)*xq@8CZ}<>YO~R8ACU6Mtxx?Lm{4b4 zz@-w$jwGAQ=ofU#aZ`s=-IqbHTzRk|Jzgtj^EI)uai)RFvZdL^4gAs+=m4TeMKe|E z4dSvYZ^~7eIyrp266o--ZFbuEs(Ao(KQuGC38|{lRm1dh6PS?vyT>=Di>A4*&rF{{sLu6-R_+1ONb6 C0sPJY diff --git a/tests/data/propyl_oxirane_hessian.gz b/tests/data/propyl_oxirane_hessian.gz deleted file mode 100644 index d3703c8c959a1de0d6fffa14059e69f815f91637..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14357 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 None: - """Test add to database.""" - database.add(model_row) - database.commit() - - assert model_row.id - - -def test__invalid_add(database: Database, model_row: ModelRow) -> None: - """Test invalid add to database.""" - model_row2 = model_row.model_copy(deep=True) - - database.add(model_row) - database.commit() - - # Violate ModelRow's (program, program_version, method, basis) unique constraint - database.add(model_row2) - with pytest.raises(IntegrityError): - database.commit() - - -def test__get(database: Database, model_row: ModelRow) -> None: - """Test get from database.""" - database.add(model_row) - database.commit() - assert model_row.id - - match = database.get(ModelRow, model_row.id) - assert match == model_row -def test__invalid_get(database: Database) -> None: - """Test invalid get from database.""" - with pytest.raises(LookupError): - database.get(ModelRow, 679) - - -def test__get_or_none_returns_row_or_none( - database: Database, model_row: ModelRow -) -> None: - """Test get_or_none returns the row on a hit and None on a miss.""" - database.add(model_row) - database.commit() - assert model_row.id - - assert database.get_or_none(ModelRow, model_row.id) == model_row - assert database.get_or_none(ModelRow, 679) is None - - -def test__add_all(database: Database) -> None: - """Test add_all stages multiple rows for the next flush/commit.""" - rows = [ModelRow(program="orca", method="xtb", basis=f"basis{i}") for i in range(3)] - database.add_all(rows) - database.commit() - - assert all(row.id is not None for row in rows) - assert len({row.id for row in rows}) == len(rows) - - -def test__delete(database: Database, model_row: ModelRow) -> None: - """Test delete from database.""" - database.add(model_row) - database.commit() - assert model_row.id - - database.delete(model_row) - database.commit() - with pytest.raises(LookupError, match=r"with row_id = 1 not found."): - database.get(ModelRow, model_row.id) +@pytest.fixture +def db_path() -> Generator[Path, None, None]: + """Create a temporary database path for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "test.db" @pytest.fixture -def orca_model_statement() -> SelectStatement: - """Fixture for Statement.""" - return select(ModelRow).where(ModelRow.program == "ORCA") # ty:ignore[invalid-argument-type] - - -def test__exec_first( - database: Database, - model_row: ModelRow, - orca_model_statement: SelectStatement, -) -> None: - """Test exec first from database.""" - database.add(model_row) - match = database.exec_first(orca_model_statement) - assert match - - -def test__exec_one( - database: Database, model_row: ModelRow, orca_model_statement: SelectStatement -) -> None: - """Test exec one from database.""" - database.add(model_row) - match = database.exec_one(orca_model_statement) - assert match - - -def test__invalid_exec_one( - database: Database, orca_model_statement: SelectStatement -) -> None: - """Test delete and invalid exec one from database.""" - with pytest.raises(LookupError): - database.exec_one(orca_model_statement) - - -def test__exec_all( - database: Database, model_row: ModelRow, orca_model_statement: SelectStatement -) -> None: - """Test exec all from database.""" - database.add(model_row) - for match in database.exec_all(orca_model_statement): - assert match - - -def test__exists_true_and_false( - database: Database, model_row: ModelRow, orca_model_statement: SelectStatement -) -> None: - """Test exists() returns True for a match and False otherwise.""" - database.add(model_row) - database.commit() - - assert database.exists(orca_model_statement) is True - missing_stmt = select(ModelRow).where(ModelRow.program == "nonexistent") # ty:ignore[invalid-argument-type] - assert database.exists(missing_stmt) is False - - -def test__select_statement_chaining(database: Database, model_row: ModelRow) -> None: - """Test that native SQLModel statement chaining works through exec_*.""" - database.add(model_row) - database.commit() - - stmt = select(ModelRow).where(ModelRow.program == "ORCA") # ty:ignore[invalid-argument-type] - assert database.exec_first(stmt) == model_row - assert database.exec_one(stmt) == model_row - assert list(database.exec_all(stmt)) - - missing_stmt = select(ModelRow).where(ModelRow.program == "nonexistent") # ty:ignore[invalid-argument-type] - assert database.exec_first(missing_stmt) is None - - -def test__select_statement_offset_and_distinct(database: Database) -> None: - """Test offset and distinct on a plain select() statement.""" - rows = [ - ModelRow(program="ORCA", method="b3lyp", basis=f"basis{i}") for i in range(3) - ] - for row in rows: - database.add(row) - database.commit() - - ordered_stmt = select(ModelRow).order_by(ModelRow.basis).offset(1) - ordered = list(database.exec_all(ordered_stmt)) - assert [r.basis for r in ordered] == ["basis1", "basis2"] - - distinct_stmt = select(ModelRow).distinct() - programs = list(database.exec_all(distinct_stmt)) - assert {p.program for p in programs} == {"ORCA"} - - -def test__merge_commits(database: Database, model_row: ModelRow) -> None: - """Test that merge() commits immediately.""" - merged = database.merge(model_row) - assert merged.id - - # A rollback after merge() must not undo it, since merge() already committed. - database._session.rollback() # noqa: SLF001 - assert database.get(ModelRow, merged.id) == merged - - -def test__session_rolls_back_on_generic_error( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - calc_geo_link: CalculationGeometryLink, - rng: Generator, -) -> None: - """A non-IntegrityError failure rolls back, leaving the session usable.""" - database.add(calculation_row) - database.add(geometry_row) - database.add(calc_geo_link) - database.add( - GradientRow( - calculation=calculation_row, - geometry=geometry_row, - value=rng.uniform(size=2), - ) - ) - - with pytest.raises(ResultShapeError): - database.commit() - - # The session must still be usable for subsequent, unrelated operations. - unrelated = ModelRow(program="ORCA", method="b3lyp") - database.add(unrelated) - database.commit() - assert unrelated.id - - -def test__link_table_reverse_lookup_indexes_exist(database: Database) -> None: - """Test that the trailing column of each composite-PK link table is indexed. - - The composite primary key on each of these tables only serves lookups keyed - by its leading column; each also needs its own index for the other direction. - """ - expected = { - "calculation_geometry_link": "calculation_id", - "calculation_trajectory_link": "calculation_id", - "trajectory_geometry_link": "trajectory_id", - "stationary_identity_link": "identity_id", - "stationary_stage_link": "stage_id", - "step_validation_link": "validation_id", - } - inspector = inspect(database.engine) - for table, column in expected.items(): - indexed_columns = { - name for idx in inspector.get_indexes(table) for name in idx["column_names"] - } - assert column in indexed_columns +def database(db_path: Path) -> Generator[Database, None, None]: + """Create a Database instance for testing.""" + db = Database(db_path) + yield db + db.close() + + +class TestDatabaseInit: + """Tests for Database initialization.""" + + def test_init_with_string_path(self, db_path: Path) -> None: + """Database can be initialized with a string path.""" + db = Database(str(db_path)) + assert db.path == db_path + assert db.engine is not None + db.close() + + def test_init_with_path_object(self, db_path: Path) -> None: + """Database can be initialized with a Path object.""" + db = Database(db_path) + assert db.path == db_path + assert db.engine is not None + db.close() + + def test_init_creates_schema(self, db_path: Path) -> None: + """Database initialization creates all tables.""" + db = Database(db_path) + # Check that tables exist by attempting to create a session and query + with db.session() as session: + # This should not raise an error if schema is created + assert session.is_active + db.close() + + def test_init_with_echo_false(self, db_path: Path) -> None: + """Database can be initialized with echo=False.""" + db = Database(db_path, echo=False) + assert db.engine.echo is False + db.close() + + def test_init_with_echo_true(self, db_path: Path) -> None: + """Database can be initialized with echo=True.""" + db = Database(db_path, echo=True) + assert db.engine.echo is True + db.close() + + def test_path_attribute(self, database: Database, db_path: Path) -> None: + """Database stores path as Path object.""" + assert isinstance(database.path, Path) + assert database.path == db_path + + def test_engine_attribute(self, database: Database) -> None: + """Database creates a SQLAlchemy engine.""" + assert database.engine is not None + assert "sqlite" in str(database.engine.url) + + +class TestDatabaseSession: + """Tests for Database.session() method.""" + + def test_session_returns_session_instance(self, database: Database) -> None: + """session() returns a SQLAlchemy Session.""" + sess = database.session() + assert isinstance(sess, Session) + sess.close() + + def test_session_is_bound_to_engine(self, database: Database) -> None: + """Session is bound to the database engine.""" + sess = database.session() + assert sess.get_bind() == database.engine + sess.close() + + def test_session_as_context_manager(self, database: Database) -> None: + """Session can be used as a context manager.""" + with database.session() as sess: + assert isinstance(sess, Session) + assert sess.is_active + + def test_multiple_sessions(self, database: Database) -> None: + """Multiple sessions can be created from same database.""" + sess1 = database.session() + sess2 = database.session() + assert sess1 is not sess2 + assert sess1.get_bind() == sess2.get_bind() + sess1.close() + sess2.close() + + def test_session_context_manager_rollback(self, database: Database) -> None: + """Session exits context manager cleanly.""" + sess = database.session() + with sess: + pass + # Session may still exist but transaction should be complete + assert sess is not None + + +class TestDatabaseClose: + """Tests for Database.close() method.""" + + def test_close_disposes_engine(self, db_path: Path) -> None: + """close() disposes the engine.""" + db = Database(db_path) + db.close() + # After dispose, new connections should be created fresh + # We can verify this indirectly by creating a new session + # (which would fail if engine was truly destroyed) + assert db.engine is not None + + def test_can_create_session_after_close(self, db_path: Path) -> None: + """A new session can be created after close() due to engine re-pooling.""" + db = Database(db_path) + db.close() + # Pool should be reset but engine still functional + sess = db.session() + assert isinstance(sess, Session) + sess.close() + + +class TestDatabaseIntegration: + """Integration tests for database operations.""" + + def test_insert_and_query_row(self, database: Database) -> None: + """Can insert and query a row from the database.""" + with database.session() as session: + # Create an identity row + identity = IdentityRow( + algorithm="RDKIT_INCHI", + kind="inchi", + value="InChI=1S/CH4/h1H4", + ) + session.add(identity) + session.commit() + + # Query it back + result = session.query(IdentityRow).filter_by(kind="inchi").first() + assert result is not None + assert result.value == "InChI=1S/CH4/h1H4" + + def test_json_serializer_sorts_keys(self, database: Database) -> None: + """JSON serializer sorts keys for consistent output.""" + with database.session() as session: + # Create a model first (required for CalculationRow) + model = ModelRow(calc_type="energy", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + # Create input_provenance with unordered keys + provenance = {"z_key": "z", "a_key": "a", "m_key": "m"} + + calc = CalculationRow( + model_id=model.id, + calc_type="energy", + input_provenance=provenance, + ) + session.add(calc) + session.commit() + + # Retrieve and verify keys are consistent + result = session.query(CalculationRow).first() + assert result is not None + # The serializer should have sorted keys during storage + assert result.input_provenance == provenance + + def test_foreign_keys_enabled(self, database: Database) -> None: + """Foreign key constraints are enforced.""" + with database.session() as session: + # Try to create a link with non-existent geometry_id + link = CalculationGeometryLink( + calculation_id=9999, # Non-existent + geometry_id=9999, # Non-existent + role="input", + ) + session.add(link) + # Foreign key constraint should prevent commit + with pytest.raises(IntegrityError): + session.commit() + + def test_concurrent_session_access(self, database: Database) -> None: + """Multiple concurrent sessions can access the database.""" + with database.session() as sess1, database.session() as sess2: + # Both sessions should be active simultaneously + assert sess1.is_active + assert sess2.is_active + # Both should access the same database + assert sess1.get_bind() == sess2.get_bind() diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..3214799 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,1765 @@ +"""Tests for SQLAlchemy ORM event listeners.""" + +import tempfile +from collections.abc import Callable, Generator +from pathlib import Path + +import numpy as np +import pytest +from sqlalchemy.exc import IntegrityError + +from autostorage.database import Database +from autostorage.models import ( + CalculationRow, + GeometryRow, + GeometryTrajectoryLink, + GradientRow, + HessianRow, + IdentityRow, + ModelRow, + StageRow, + StationaryPointRow, + StepRow, + TrajectoryRow, +) + +# Test data constants +NDIM_2 = 2 +NDIM_3 = 3 +EXPECTED_IDENTITY_COUNT_TWO = 2 +EXPECTED_EXTRAS_COUNT = 2 +NATOMS_THREE = 3 +NATOMS_TWO = 2 + + +@pytest.fixture +def db_path() -> Generator[Path, None, None]: + """Create a temporary database path for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "test.db" + + +@pytest.fixture +def database(db_path: Path) -> Generator[Database, None, None]: + """Create a Database instance for testing.""" + db = Database(db_path) + yield db + db.close() + + +@pytest.fixture +def make_model_gradient() -> Callable[[], ModelRow]: + """Create factory for gradient calculation ModelRow.""" + + def _make() -> ModelRow: + return ModelRow(calc_type="gradient", program="psi4", method="B3LYP") + + return _make + + +@pytest.fixture +def make_model_frequency() -> Callable[[], ModelRow]: + """Create factory for frequency calculation ModelRow.""" + + def _make() -> ModelRow: + return ModelRow(calc_type="frequency", program="psi4", method="B3LYP") + + return _make + + +@pytest.fixture +def make_model_opt() -> Callable[[], ModelRow]: + """Create factory for optimization calculation ModelRow.""" + + def _make() -> ModelRow: + return ModelRow(calc_type="opt", program="psi4", method="B3LYP") + + return _make + + +@pytest.fixture +def make_calculation() -> Callable[[int], CalculationRow]: + """Create factory for CalculationRow with empty provenance.""" + + def _make(model_id: int) -> CalculationRow: + return CalculationRow( + model_id=model_id, input_provenance={}, output_provenance={} + ) + + return _make + + +@pytest.fixture +def make_geometry_2atom() -> Callable[[], GeometryRow]: + """Create factory for 2-atom geometry (C, H).""" + + def _make() -> GeometryRow: + return GeometryRow( + symbols=["C", "H"], + coordinates=[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + charge=0, + spin=0, + ) + + return _make + + +@pytest.fixture +def make_geometry_3atom() -> Callable[[], GeometryRow]: + """Create factory for 3-atom geometry (C, H, H).""" + + def _make() -> GeometryRow: + return GeometryRow( + symbols=["C", "H", "H"], + coordinates=[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + charge=0, + spin=0, + ) + + return _make + + +@pytest.fixture +def make_geometry_5atom() -> Callable[[], GeometryRow]: + """Create factory for 5-atom geometry (C, H, H, H, H).""" + + def _make() -> GeometryRow: + return GeometryRow( + symbols=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [-1.0, 0.0, 0.0], + ], + charge=0, + spin=0, + ) + + return _make + + +class TestSortStepStageIds: + """Tests for sort_step_stage_ids event listener.""" + + def test_stage_ids_sorted_on_insert(self, database: Database) -> None: + """stage_id1 and stage_id2 are auto-sorted on insert.""" + with database.session() as session: + # Create two stages (stage1 gets smaller id) + stage_small = StageRow(is_ts=False) + stage_large = StageRow(is_ts=False) + session.add_all([stage_small, stage_large]) + session.flush() + + # stage_large.id should be > stage_small.id + assert stage_large.id is not None + assert stage_small.id is not None + assert stage_large.id > stage_small.id + + # Create step with reversed stage IDs (large first, then small) + step = StepRow( + stage_id1=stage_large.id, + stage_id2=stage_small.id, + is_barrierless=True, + ) + session.add(step) + session.flush() + + # Verify IDs were auto-sorted (should be small < large) + assert step.stage_id1 is not None + assert step.stage_id2 is not None + assert step.stage_id1 < step.stage_id2 + assert step.stage_id1 == stage_small.id + assert step.stage_id2 == stage_large.id + + def test_stage_ids_sorted_on_update(self, database: Database) -> None: + """stage_id1 and stage_id2 are auto-sorted on update.""" + with database.session() as session: + # Create three stages + stage_a = StageRow(is_ts=False) + stage_b = StageRow(is_ts=False) + stage_c = StageRow(is_ts=False) + session.add_all([stage_a, stage_b, stage_c]) + session.flush() + + # Ensure A < B < C + assert stage_a.id is not None + assert stage_b.id is not None + assert stage_c.id is not None + assert stage_a.id < stage_b.id < stage_c.id + + # Create step with correct order (A, B) + step = StepRow( + stage_id1=stage_a.id, + stage_id2=stage_b.id, + is_barrierless=True, + ) + session.add(step) + session.flush() + + # Update with reversed order (C, A) -> should become (A, C) + step.stage_id1 = stage_c.id + step.stage_id2 = stage_a.id + session.flush() + + # Verify IDs were auto-sorted to (A, C) + assert step.stage_id1 is not None + assert step.stage_id2 is not None + assert step.stage_id1 < step.stage_id2 + assert step.stage_id1 == stage_a.id + assert step.stage_id2 == stage_c.id + + def test_equal_stage_ids_unchanged(self, database: Database) -> None: + """If stage_id1 == stage_id2, they remain unchanged.""" + with database.session() as session: + # Create a single stage + stage = StageRow(is_ts=False) + session.add(stage) + session.flush() + + # Create step with equal IDs (will fail constraint but that's OK) + step = StepRow( + stage_id1=stage.id, + stage_id2=stage.id, + is_barrierless=True, + ) + session.add(step) + + # The constraint check happens during commit + with pytest.raises(IntegrityError): + session.commit() + + def test_sorting_three_stage_ids(self, database: Database) -> None: + """Sorting works with three different stages.""" + with database.session() as session: + # Create three stages + stage_x = StageRow(is_ts=False) + stage_y = StageRow(is_ts=False) + stage_z = StageRow(is_ts=False) + session.add_all([stage_x, stage_y, stage_z]) + session.flush() + + # X < Y < Z + assert stage_x.id is not None + assert stage_y.id is not None + assert stage_z.id is not None + + # Create step with reversed order (Z, X) + step = StepRow( + stage_id1=stage_z.id, + stage_id2=stage_x.id, + is_barrierless=True, + ) + session.add(step) + session.flush() + + # Verify they were sorted to (X, Z) + assert step.stage_id1 is not None + assert step.stage_id2 is not None + assert step.stage_id1 < step.stage_id2 + assert step.stage_id1 == stage_x.id + assert step.stage_id2 == stage_z.id + + +class TestVerifyStepBarrierlessConsistency: + """Tests for verify_step_barrierless_consistency event listener.""" + + def test_barrierless_requires_no_ts(self, database: Database) -> None: + """Barrierless step (stage_id_ts=None) must have is_barrierless=True.""" + with database.session() as session: + # Create two stages + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + session.add_all([stage1, stage2]) + session.flush() + + # Try to create barrierless step without is_barrierless=True + step = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + stage_id_ts=None, + is_barrierless=False, # Invalid + ) + session.add(step) + + with pytest.raises(ValueError, match="must have is_barrierless=True"): + session.flush() + + def test_non_barrierless_requires_ts(self, database: Database) -> None: + """Non-barrierless step must have stage_id_ts!=None.""" + with database.session() as session: + # Create three stages (two regular, one TS) + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + session.add_all([stage1, stage2]) + session.flush() + + # Try to create non-barrierless step without transition state + step = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + stage_id_ts=None, + is_barrierless=False, + ) + session.add(step) + + with pytest.raises(ValueError, match="must have is_barrierless=True"): + session.flush() + + def test_barrierless_step_valid(self, database: Database) -> None: + """Barrierless step with is_barrierless=True and stage_id_ts=None is valid.""" + with database.session() as session: + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + session.add_all([stage1, stage2]) + session.flush() + + step = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + stage_id_ts=None, + is_barrierless=True, + ) + session.add(step) + session.flush() + + # Should succeed + assert step.stage_id_ts is None + assert step.is_barrierless is True + + def test_step_with_ts_valid(self, database: Database) -> None: + """Step with transition state and is_barrierless=False is valid.""" + with database.session() as session: + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + stage_ts = StageRow(is_ts=True) + session.add_all([stage1, stage2, stage_ts]) + session.flush() + + step = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + stage_id_ts=stage_ts.id, + is_barrierless=False, + ) + session.add(step) + session.flush() + + # Should succeed + assert step.stage_id_ts == stage_ts.id + assert step.is_barrierless is False + + def test_step_with_ts_requires_is_barrierless_false( + self, database: Database + ) -> None: + """Step with stage_id_ts!=None must have is_barrierless=False.""" + with database.session() as session: + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + stage_ts = StageRow(is_ts=True) + session.add_all([stage1, stage2, stage_ts]) + session.flush() + + # Try to create step with TS but is_barrierless=True (invalid) + step = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + stage_id_ts=stage_ts.id, + is_barrierless=True, # Invalid with TS present + ) + session.add(step) + + with pytest.raises(ValueError, match="must have is_barrierless=False"): + session.flush() + + def test_barrierless_consistency_on_update(self, database: Database) -> None: + """Barrierless consistency is checked on update.""" + with database.session() as session: + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + session.add_all([stage1, stage2]) + session.flush() + + # Create a valid barrierless step + step = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + stage_id_ts=None, + is_barrierless=True, + ) + session.add(step) + session.flush() + + # Try to update to invalid state + step.is_barrierless = False + + with pytest.raises(ValueError, match="must have is_barrierless=True"): + session.flush() + + +class TestVerifyGradientShape: + """Tests for verify_gradient_shape event listener.""" + + def test_valid_gradient_shape_on_insert( + self, + database: Database, + make_model_gradient: Callable[[], ModelRow], + make_geometry_3atom: Callable[[], GeometryRow], + ) -> None: + """Gradient with correct shape (3 * natoms,) is accepted on insert.""" + with database.session() as session: + model = make_model_gradient() + session.add(model) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + geom = make_geometry_3atom() + session.add(geom) + session.flush() + + # Create gradient with correct shape (3 * 3 = 9 elements) + gradient = GradientRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]), + ) + gradient.geometry = geom + session.add(gradient) + session.flush() + + assert gradient.value.shape == (9,) + assert len(gradient.geometry.symbols) == NATOMS_THREE + + def test_invalid_gradient_shape_on_insert( + self, + database: Database, + make_model_gradient: Callable[[], ModelRow], + make_geometry_3atom: Callable[[], GeometryRow], + ) -> None: + """Gradient with incorrect shape raises ValueError on insert.""" + with database.session() as session: + model = make_model_gradient() + session.add(model) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + geom = make_geometry_3atom() + session.add(geom) + session.flush() + + # Create gradient with incorrect shape (only 6 elements instead of 9) + gradient = GradientRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]), + ) + gradient.geometry = geom + session.add(gradient) + + with pytest.raises(ValueError, match="does not match expected"): + session.flush() + + def test_valid_gradient_shape_on_update( + self, + database: Database, + make_model_gradient: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Gradient shape is validated on update.""" + with database.session() as session: + model = make_model_gradient() + session.add(model) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + geom = make_geometry_2atom() + session.add(geom) + session.flush() + + # Create gradient with correct shape (3 * 2 = 6 elements) + gradient = GradientRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]), + ) + gradient.geometry = geom + session.add(gradient) + session.flush() + + # Update to new valid values (same shape) + gradient.value = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + session.flush() + + assert gradient.value.shape == (6,) + + def test_invalid_gradient_shape_on_update( + self, + database: Database, + make_model_gradient: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Updating gradient to incorrect shape raises ValueError.""" + with database.session() as session: + model = make_model_gradient() + session.add(model) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + geom = make_geometry_2atom() + session.add(geom) + session.flush() + + # Create gradient with correct shape (3 * 2 = 6 elements) + gradient = GradientRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]), + ) + gradient.geometry = geom + session.add(gradient) + session.flush() + + # Update to invalid shape + gradient.value = np.array([1.0, 2.0, 3.0]) + + with pytest.raises(ValueError, match="does not match expected"): + session.flush() + + def test_none_geometry_skipped( + self, database: Database, make_model_gradient: Callable[[], ModelRow] + ) -> None: + """Gradient with None geometry is skipped by validation.""" + with database.session() as session: + model = make_model_gradient() + session.add(model) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create gradient with no geometry relationship loaded + gradient = GradientRow( + geometry_id=None, + calculation_id=calc.id, + value=np.array([0.1, 0.2, 0.3]), + ) + session.add(gradient) + + # Event should handle None geometry gracefully + # (the insert will fail on FK constraint, but event shouldn't crash) + + +class TestVerifyHessianShape: + """Tests for verify_hessian_shape event listener.""" + + def test_valid_hessian_shape_on_insert( + self, + database: Database, + make_model_frequency: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Hessian with correct shape (3*natoms, 3*natoms) is accepted on insert.""" + with database.session() as session: + model = make_model_frequency() + geom = make_geometry_2atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create Hessian with correct shape (6x6 for 2 atoms) + rng = np.random.default_rng() + hessian = HessianRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=rng.random((6, 6), dtype=np.float32), + ) + hessian.geometry = geom + session.add(hessian) + session.flush() + + assert hessian.value.shape == (6, 6) + assert len(hessian.geometry.symbols) == NATOMS_TWO + + def test_invalid_hessian_shape_on_insert( + self, + database: Database, + make_model_frequency: Callable[[], ModelRow], + make_geometry_3atom: Callable[[], GeometryRow], + ) -> None: + """Hessian with incorrect shape raises ValueError on insert.""" + with database.session() as session: + model = make_model_frequency() + geom = make_geometry_3atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create Hessian with incorrect shape (6x6 instead of 9x9) + rng = np.random.default_rng() + hessian = HessianRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=rng.random((6, 6), dtype=np.float32), + ) + hessian.geometry = geom + session.add(hessian) + + with pytest.raises(ValueError, match="does not match expected"): + session.flush() + + def test_hessian_wrong_first_dimension( + self, + database: Database, + make_model_frequency: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Hessian with wrong first dimension raises ValueError.""" + with database.session() as session: + model = make_model_frequency() + geom = make_geometry_2atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create Hessian with wrong first dimension (5x6 instead of 6x6) + rng = np.random.default_rng() + hessian = HessianRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=rng.random((5, 6), dtype=np.float32), + ) + hessian.geometry = geom + session.add(hessian) + + with pytest.raises(ValueError, match="does not match expected"): + session.flush() + + def test_hessian_wrong_second_dimension( + self, + database: Database, + make_model_frequency: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Hessian with wrong second dimension raises ValueError.""" + with database.session() as session: + model = make_model_frequency() + geom = make_geometry_2atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create Hessian with wrong second dimension (6x5 instead of 6x6) + rng = np.random.default_rng() + hessian = HessianRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=rng.random((6, 5), dtype=np.float32), + ) + hessian.geometry = geom + session.add(hessian) + + with pytest.raises(ValueError, match="does not match expected"): + session.flush() + + def test_hessian_1d_array_rejected( + self, + database: Database, + make_model_frequency: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Hessian with 1D array (wrong dimensionality) raises ValueError.""" + with database.session() as session: + model = make_model_frequency() + geom = make_geometry_2atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create Hessian with 1D array (flattened, wrong dimensionality) + rng = np.random.default_rng() + hessian = HessianRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=rng.random(36, dtype=np.float32), + ) + hessian.geometry = geom + session.add(hessian) + + with pytest.raises(ValueError, match="does not match expected"): + session.flush() + + def test_valid_hessian_shape_on_update( + self, + database: Database, + make_model_frequency: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Hessian shape is validated on update.""" + with database.session() as session: + model = make_model_frequency() + geom = make_geometry_2atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create Hessian with correct shape (6x6) + rng = np.random.default_rng() + hessian = HessianRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=rng.random((6, 6), dtype=np.float32), + ) + hessian.geometry = geom + session.add(hessian) + session.flush() + + # Update to new valid values (same shape) + hessian.value = np.eye(6, dtype=np.float32) + session.flush() + + assert hessian.value.shape == (6, 6) + + def test_invalid_hessian_shape_on_update( + self, + database: Database, + make_model_frequency: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Updating Hessian to incorrect shape raises ValueError.""" + with database.session() as session: + model = make_model_frequency() + geom = make_geometry_2atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create Hessian with correct shape (6x6) + rng = np.random.default_rng() + hessian = HessianRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=rng.random((6, 6), dtype=np.float32), + ) + hessian.geometry = geom + session.add(hessian) + session.flush() + + # Update to invalid shape (3x3) + hessian.value = rng.random((3, 3), dtype=np.float32) + + with pytest.raises(ValueError, match="does not match expected"): + session.flush() + + def test_none_geometry_skipped( + self, database: Database, make_model_frequency: Callable[[], ModelRow] + ) -> None: + """Hessian with None geometry is skipped by validation.""" + with database.session() as session: + model = make_model_frequency() + session.add(model) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create Hessian with no geometry relationship loaded + rng = np.random.default_rng() + hessian = HessianRow( + geometry_id=None, + calculation_id=calc.id, + value=rng.random((6, 6), dtype=np.float32), + ) + session.add(hessian) + + # Event should handle None geometry gracefully + # (the insert will fail on FK constraint, but event shouldn't crash) + + +class TestVerifyTrajectoryGeometryNdim: + """Tests for verify_trajectory_geometry_ndim_insert event listener.""" + + def test_matching_index_and_ndim( + self, database: Database, make_geometry_5atom: Callable[[], GeometryRow] + ) -> None: + """Index length matching ndim is accepted.""" + with database.session() as session: + geom = make_geometry_5atom() + traj = TrajectoryRow(ndim=NDIM_2) + session.add_all([geom, traj]) + session.flush() + + link = GeometryTrajectoryLink( + geometry_id=geom.id, + trajectory_id=traj.id, + index=[0, 1], # length matches ndim + ) + link.trajectory = traj + session.add(link) + session.flush() + + assert link.index == [0, 1] + assert link.trajectory.ndim == NDIM_2 + + def test_mismatched_index_and_ndim_raises( + self, database: Database, make_geometry_5atom: Callable[[], GeometryRow] + ) -> None: + """Index length not matching ndim raises ValueError.""" + with database.session() as session: + geom = make_geometry_5atom() + traj = TrajectoryRow(ndim=NDIM_3) + session.add_all([geom, traj]) + session.flush() + + link = GeometryTrajectoryLink( + geometry_id=geom.id, + trajectory_id=traj.id, + index=[0, 1], # length doesn't match ndim + ) + link.trajectory = traj + session.add(link) + + with pytest.raises(ValueError, match="does not match"): + session.flush() + + def test_index_infers_ndim( + self, database: Database, make_geometry_5atom: Callable[[], GeometryRow] + ) -> None: + """Index length infers trajectory ndim if ndim is None.""" + with database.session() as session: + geom = make_geometry_5atom() + traj = TrajectoryRow(ndim=None) + session.add_all([geom, traj]) + session.flush() + + link = GeometryTrajectoryLink( + geometry_id=geom.id, + trajectory_id=traj.id, + index=[0, 1, 2], # length 3 + ) + link.trajectory = traj + session.add(link) + session.flush() + + assert link.trajectory.ndim == NDIM_3 + + def test_missing_index_with_set_ndim_raises( + self, database: Database, make_geometry_5atom: Callable[[], GeometryRow] + ) -> None: + """Missing index when ndim is set raises ValueError.""" + with database.session() as session: + geom = make_geometry_5atom() + traj = TrajectoryRow(ndim=NDIM_2) + session.add_all([geom, traj]) + session.flush() + + link = GeometryTrajectoryLink( + geometry_id=geom.id, + trajectory_id=traj.id, + index=None, # Missing index + ) + link.trajectory = traj + session.add(link) + + with pytest.raises(ValueError, match="index is missing"): + session.flush() + + def test_none_trajectory_skipped(self, database: Database) -> None: + """Link with None trajectory is skipped gracefully.""" + with database.session() as session: + link = GeometryTrajectoryLink( + geometry_id=1, # Will be invalid but event shouldn't crash + trajectory_id=None, + index=None, + ) + session.add(link) + + # Event should handle None trajectory gracefully + # (the insert will fail on FK constraint, but event shouldn't crash) + + def test_index_none_and_ndim_none( + self, database: Database, make_geometry_5atom: Callable[[], GeometryRow] + ) -> None: + """Both index and ndim None is allowed.""" + with database.session() as session: + geom = make_geometry_5atom() + traj = TrajectoryRow(ndim=None) + session.add_all([geom, traj]) + session.flush() + + link = GeometryTrajectoryLink( + geometry_id=geom.id, + trajectory_id=traj.id, + index=None, + ) + link.trajectory = traj + session.add(link) + session.flush() + + assert link.index is None + assert link.trajectory.ndim is None + + def test_geometry_ndim_update(self, database: Database) -> None: + """Trajectory ndim is updated on link insert if previously None.""" + with database.session() as session: + geom = GeometryRow( + symbols=["C"], + coordinates=[[0.0, 0.0, 0.0]], + charge=0, + spin=0, + ) + traj = TrajectoryRow(ndim=None) + session.add_all([geom, traj]) + session.flush() + + # First link infers ndim + link1 = GeometryTrajectoryLink( + geometry_id=geom.id, + trajectory_id=traj.id, + index=[0, 1], + ) + link1.trajectory = traj + session.add(link1) + session.flush() + + assert traj.ndim == NDIM_2 + + # Second link with same trajectory and matching index works + geom2 = GeometryRow( + symbols=["C"], + coordinates=[[1.0, 0.0, 0.0]], + charge=0, + spin=0, + ) + session.add(geom2) + session.flush() + + link2 = GeometryTrajectoryLink( + geometry_id=geom2.id, + trajectory_id=traj.id, + index=[1, 2], # matches ndim + ) + link2.trajectory = traj + session.add(link2) + session.flush() + + assert link2.index == [1, 2] + + +class TestAddInchiIdentity: + """Tests for add_inchi_identity event listener.""" + + def test_inchi_identity_added_on_insert( + self, database: Database, make_model_opt: Callable[[], ModelRow] + ) -> None: + """InChI identity is automatically attached to a new stationary point.""" + with database.session() as session: + model = make_model_opt() + session.add(model) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C", "O"], + coordinates=[[0.0, 0.0, 0.0], [1.5, 0.0, 0.0]], + charge=0, + spin=1, + ) + session.add(geom) + session.flush() + + stat_point = StationaryPointRow( + geometry_id=geom.id, calculation_id=calc.id, order=0 + ) + session.add(stat_point) + session.flush() + + assert len(stat_point.identities) == 1 + identity = stat_point.identities[0] + assert identity.kind == "stereoisomer" + assert identity.algorithm == "rdkit inchi" + assert identity.value.startswith("InChI=") + + def test_existing_inchi_identity_reused( + self, database: Database, make_model_opt: Callable[[], ModelRow] + ) -> None: + """Existing InChI identity is reused for duplicate geometries.""" + with database.session() as session: + model = make_model_opt() + session.add(model) + session.flush() + + calc1 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + calc2 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add_all([calc1, calc2]) + session.flush() + + # Create two identical geometries + geom1 = GeometryRow( + symbols=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [-1.0, 0.0, 0.0], + ], + charge=0, + spin=0, + ) + geom2 = GeometryRow( + symbols=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [-1.0, 0.0, 0.0], + ], + charge=0, + spin=0, + ) + session.add_all([geom1, geom2]) + session.flush() + + stat1 = StationaryPointRow( + geometry_id=geom1.id, calculation_id=calc1.id, order=0 + ) + stat2 = StationaryPointRow( + geometry_id=geom2.id, calculation_id=calc2.id, order=0 + ) + session.add_all([stat1, stat2]) + session.flush() + + assert len(stat1.identities) == 1 + assert len(stat2.identities) == 1 + assert stat1.identities[0].id == stat2.identities[0].id + assert stat1.identities[0].value == stat2.identities[0].value + + identity_count = session.query(IdentityRow).count() + assert identity_count == 1 + + def test_different_geometries_create_different_identities( + self, database: Database, make_model_opt: Callable[[], ModelRow] + ) -> None: + """Different geometries create different InChI identities.""" + with database.session() as session: + model = make_model_opt() + session.add(model) + session.flush() + + calc1 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + calc2 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add_all([calc1, calc2]) + session.flush() + + geom1 = GeometryRow( + symbols=["C", "O"], + coordinates=[[0.0, 0.0, 0.0], [1.5, 0.0, 0.0]], + charge=0, + spin=1, + ) + geom2 = GeometryRow( + symbols=["C", "C"], + coordinates=[[0.0, 0.0, 0.0], [1.5, 0.0, 0.0]], + charge=0, + spin=0, + ) + session.add_all([geom1, geom2]) + session.flush() + + stat1 = StationaryPointRow( + geometry_id=geom1.id, calculation_id=calc1.id, order=0 + ) + stat2 = StationaryPointRow( + geometry_id=geom2.id, calculation_id=calc2.id, order=0 + ) + session.add_all([stat1, stat2]) + session.flush() + + assert len(stat1.identities) == 1 + assert len(stat2.identities) == 1 + assert stat1.identities[0].id != stat2.identities[0].id + assert stat1.identities[0].value != stat2.identities[0].value + + identity_count = session.query(IdentityRow).count() + assert identity_count == EXPECTED_IDENTITY_COUNT_TWO + + +class TestAddSmilesExtras: + """Tests for add_smiles_extras_before_flush event listener.""" + + def test_smiles_extra_added_on_insert( + self, database: Database, make_model_opt: Callable[[], ModelRow] + ) -> None: + """SMILES is automatically attached as IdentityExtraRow to stationary point.""" + with database.session() as session: + model = make_model_opt() + session.add(model) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [-1.0, 0.0, 0.0], + ], + charge=0, + spin=0, + ) + session.add(geom) + session.flush() + + stat_point = StationaryPointRow( + geometry_id=geom.id, calculation_id=calc.id, order=0 + ) + session.add(stat_point) + session.flush() + + # Should have one InChI identity with one SMILES extra + assert len(stat_point.identities) == 1 + identity = stat_point.identities[0] + assert identity.algorithm == "rdkit inchi" + + # Reload to get the identity_extras relationship populated + session.expire_all() + identity = session.get(IdentityRow, identity.id) + assert identity is not None + assert len(identity.identity_extras) == EXPECTED_EXTRAS_COUNT + + # Check for SMILES extra + extras_by_attr = { + extra.attribute: extra.value for extra in identity.identity_extras + } + assert "rdkit_smiles" in extras_by_attr + assert extras_by_attr["rdkit_smiles"] == "C" # Methane SMILES + + def test_duplicate_smiles_not_created( + self, database: Database, make_model_opt: Callable[[], ModelRow] + ) -> None: + """Duplicate SMILES are not created for the same geometry.""" + with database.session() as session: + model = make_model_opt() + session.add(model) + session.flush() + + calc1 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + calc2 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add_all([calc1, calc2]) + session.flush() + + # Create two identical geometries + geom1 = GeometryRow( + symbols=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [-1.0, 0.0, 0.0], + ], + charge=0, + spin=0, + ) + geom2 = GeometryRow( + symbols=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [-1.0, 0.0, 0.0], + ], + charge=0, + spin=0, + ) + session.add_all([geom1, geom2]) + session.flush() + + stat1 = StationaryPointRow( + geometry_id=geom1.id, calculation_id=calc1.id, order=0 + ) + stat2 = StationaryPointRow( + geometry_id=geom2.id, calculation_id=calc2.id, order=0 + ) + session.add_all([stat1, stat2]) + session.flush() + + # Both should share the same identity (InChI) + assert stat1.identities[0].id == stat2.identities[0].id + + # Should have two extras (SMILES + Hill) for the shared identity + session.expire_all() + identity = session.get(IdentityRow, stat1.identities[0].id) + assert identity is not None + assert len(identity.identity_extras) == EXPECTED_EXTRAS_COUNT + + # Check for SMILES extra + extras_by_attr = { + extra.attribute: extra.value for extra in identity.identity_extras + } + assert "rdkit_smiles" in extras_by_attr + assert extras_by_attr["rdkit_smiles"] == "C" + + def test_different_smiles_for_different_geometries( + self, database: Database, make_model_opt: Callable[[], ModelRow] + ) -> None: + """Different geometries create different SMILES extras.""" + with database.session() as session: + model = make_model_opt() + session.add(model) + session.flush() + + calc1 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + calc2 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add_all([calc1, calc2]) + session.flush() + + # Methane + geom1 = GeometryRow( + symbols=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [-1.0, 0.0, 0.0], + ], + charge=0, + spin=0, + ) + # Ethane + geom2 = GeometryRow( + symbols=["C", "C"], + coordinates=[[0.0, 0.0, 0.0], [1.5, 0.0, 0.0]], + charge=0, + spin=0, + ) + session.add_all([geom1, geom2]) + session.flush() + + stat1 = StationaryPointRow( + geometry_id=geom1.id, calculation_id=calc1.id, order=0 + ) + stat2 = StationaryPointRow( + geometry_id=geom2.id, calculation_id=calc2.id, order=0 + ) + session.add_all([stat1, stat2]) + session.flush() + + # Should have different identities + assert stat1.identities[0].id != stat2.identities[0].id + + # Each identity should have two extras (SMILES + Hill) + session.expire_all() + identity1 = session.get(IdentityRow, stat1.identities[0].id) + identity2 = session.get(IdentityRow, stat2.identities[0].id) + assert identity1 is not None + assert identity2 is not None + + assert len(identity1.identity_extras) == EXPECTED_EXTRAS_COUNT + extras1_by_attr = { + extra.attribute: extra.value for extra in identity1.identity_extras + } + assert "rdkit_smiles" in extras1_by_attr + assert extras1_by_attr["rdkit_smiles"] == "C" + + assert len(identity2.identity_extras) == EXPECTED_EXTRAS_COUNT + extras2_by_attr = { + extra.attribute: extra.value for extra in identity2.identity_extras + } + assert "rdkit_smiles" in extras2_by_attr + # Ethane SMILES should be different from methane + assert extras2_by_attr["rdkit_smiles"] != "C" + + +class TestAddHillExtras: + """Tests for add_hill_extras_before_flush event listener.""" + + def test_hill_extra_added_on_insert( + self, database: Database, make_model_opt: Callable[[], ModelRow] + ) -> None: + """Hill formula is attached as IdentityExtraRow to stationary point.""" + with database.session() as session: + model = make_model_opt() + session.add(model) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [-1.0, 0.0, 0.0], + ], + charge=0, + spin=0, + ) + session.add(geom) + session.flush() + + stat_point = StationaryPointRow( + geometry_id=geom.id, calculation_id=calc.id, order=0 + ) + session.add(stat_point) + session.flush() + + # Should have one InChI identity with two extras (SMILES + Hill formula) + assert len(stat_point.identities) == 1 + identity = stat_point.identities[0] + assert identity.algorithm == "rdkit inchi" + + # Reload to get the identity_extras relationship populated + session.expire_all() + identity = session.get(IdentityRow, identity.id) + assert identity is not None + assert len(identity.identity_extras) == EXPECTED_EXTRAS_COUNT + + # Check for both SMILES and Hill formula extras + extras_by_attr = { + extra.attribute: extra.value for extra in identity.identity_extras + } + assert "rdkit_smiles" in extras_by_attr + assert extras_by_attr["rdkit_smiles"] == "C" # Methane SMILES + assert "hill_formula" in extras_by_attr + assert extras_by_attr["hill_formula"] == "CH4" # Methane Hill formula + + def test_duplicate_hill_not_created( + self, database: Database, make_model_opt: Callable[[], ModelRow] + ) -> None: + """Duplicate Hill formulas are not created for the same geometry.""" + with database.session() as session: + model = make_model_opt() + session.add(model) + session.flush() + + calc1 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + calc2 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add_all([calc1, calc2]) + session.flush() + + # Create two identical geometries + geom1 = GeometryRow( + symbols=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [-1.0, 0.0, 0.0], + ], + charge=0, + spin=0, + ) + geom2 = GeometryRow( + symbols=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [-1.0, 0.0, 0.0], + ], + charge=0, + spin=0, + ) + session.add_all([geom1, geom2]) + session.flush() + + stat1 = StationaryPointRow( + geometry_id=geom1.id, calculation_id=calc1.id, order=0 + ) + stat2 = StationaryPointRow( + geometry_id=geom2.id, calculation_id=calc2.id, order=0 + ) + session.add_all([stat1, stat2]) + session.flush() + + # Both should share the same identity (InChI) + assert stat1.identities[0].id == stat2.identities[0].id + + # Should have two extras (SMILES + Hill) for the shared identity + session.expire_all() + identity = session.get(IdentityRow, stat1.identities[0].id) + assert identity is not None + assert len(identity.identity_extras) == EXPECTED_EXTRAS_COUNT + + # Check for both SMILES and Hill formula extras + extras_by_attr = { + extra.attribute: extra.value for extra in identity.identity_extras + } + assert "rdkit_smiles" in extras_by_attr + assert extras_by_attr["rdkit_smiles"] == "C" + assert "hill_formula" in extras_by_attr + assert extras_by_attr["hill_formula"] == "CH4" + + def test_different_hill_for_different_geometries( + self, database: Database, make_model_opt: Callable[[], ModelRow] + ) -> None: + """Different geometries create different Hill formula extras.""" + with database.session() as session: + model = make_model_opt() + session.add(model) + session.flush() + + calc1 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + calc2 = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add_all([calc1, calc2]) + session.flush() + + # Methane + geom1 = GeometryRow( + symbols=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [-1.0, 0.0, 0.0], + ], + charge=0, + spin=0, + ) + # Ethane + geom2 = GeometryRow( + symbols=["C", "C"], + coordinates=[[0.0, 0.0, 0.0], [1.5, 0.0, 0.0]], + charge=0, + spin=0, + ) + session.add_all([geom1, geom2]) + session.flush() + + stat1 = StationaryPointRow( + geometry_id=geom1.id, calculation_id=calc1.id, order=0 + ) + stat2 = StationaryPointRow( + geometry_id=geom2.id, calculation_id=calc2.id, order=0 + ) + session.add_all([stat1, stat2]) + session.flush() + + # Should have different identities + assert stat1.identities[0].id != stat2.identities[0].id + + # Each identity should have two extras (SMILES + Hill) + session.expire_all() + identity1 = session.get(IdentityRow, stat1.identities[0].id) + identity2 = session.get(IdentityRow, stat2.identities[0].id) + assert identity1 is not None + assert identity2 is not None + + assert len(identity1.identity_extras) == EXPECTED_EXTRAS_COUNT + extras1_by_attr = { + extra.attribute: extra.value for extra in identity1.identity_extras + } + assert "rdkit_smiles" in extras1_by_attr + assert extras1_by_attr["rdkit_smiles"] == "C" + assert "hill_formula" in extras1_by_attr + assert extras1_by_attr["hill_formula"] == "CH4" + + assert len(identity2.identity_extras) == EXPECTED_EXTRAS_COUNT + extras2_by_attr = { + extra.attribute: extra.value for extra in identity2.identity_extras + } + assert "rdkit_smiles" in extras2_by_attr + # Ethane SMILES should be different from methane + assert extras2_by_attr["rdkit_smiles"] != "C" + assert "hill_formula" in extras2_by_attr + # Ethane Hill formula should be different from methane + assert extras2_by_attr["hill_formula"] != "CH4" + + +class TestVerifyValidStationaryHasHessian: + """Tests for verify_valid_stationary_has_hessian event listener.""" + + def test_valid_stationary_with_hessian_accepted( + self, + database: Database, + make_model_frequency: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Stationary point marked valid with a Hessian is accepted.""" + with database.session() as session: + model = make_model_frequency() + geom = make_geometry_2atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Add a Hessian to the geometry + rng = np.random.default_rng() + hessian = HessianRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=rng.random((6, 6), dtype=np.float32), + ) + session.add(hessian) + session.flush() + + # Create a valid stationary point - should succeed + stat = StationaryPointRow( + geometry_id=geom.id, + calculation_id=calc.id, + order=0, + is_valid=True, + ) + session.add(stat) + session.flush() + + assert stat.is_valid is True + assert len(geom.hessians) == 1 + + def test_valid_stationary_without_hessian_rejected( + self, + database: Database, + make_model_opt: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Stationary point marked valid without a Hessian is rejected.""" + with database.session() as session: + model = make_model_opt() + geom = make_geometry_2atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create a valid stationary point without Hessian - should fail + stat = StationaryPointRow( + geometry_id=geom.id, + calculation_id=calc.id, + order=0, + is_valid=True, + ) + session.add(stat) + + with pytest.raises(ValueError, match="cannot be marked as valid"): + session.flush() + + def test_invalid_stationary_without_hessian_accepted( + self, + database: Database, + make_model_opt: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Stationary point not marked valid can exist without a Hessian.""" + with database.session() as session: + model = make_model_opt() + geom = make_geometry_2atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create stationary point with is_valid=False - should succeed + stat = StationaryPointRow( + geometry_id=geom.id, + calculation_id=calc.id, + order=0, + is_valid=False, + ) + session.add(stat) + session.flush() + + assert stat.is_valid is False + + def test_valid_stationary_update_to_valid_without_hessian_rejected( + self, + database: Database, + make_model_opt: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Updating stationary to valid without Hessian is rejected.""" + with database.session() as session: + model = make_model_opt() + geom = make_geometry_2atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create invalid stationary point + stat = StationaryPointRow( + geometry_id=geom.id, + calculation_id=calc.id, + order=0, + is_valid=False, + ) + session.add(stat) + session.flush() + + # Try to update to valid without Hessian - should fail + stat.is_valid = True + + with pytest.raises(ValueError, match="cannot be marked as valid"): + session.flush() + + def test_update_to_valid_with_hessian_accepted( + self, + database: Database, + make_model_frequency: Callable[[], ModelRow], + make_geometry_2atom: Callable[[], GeometryRow], + ) -> None: + """Updating stationary to valid with Hessian is accepted.""" + with database.session() as session: + model = make_model_frequency() + geom = make_geometry_2atom() + session.add_all([model, geom]) + session.flush() + + calc = CalculationRow( + model_id=model.id, input_provenance={}, output_provenance={} + ) + session.add(calc) + session.flush() + + # Create invalid stationary point + stat = StationaryPointRow( + geometry_id=geom.id, + calculation_id=calc.id, + order=0, + is_valid=False, + ) + session.add(stat) + session.flush() + + # Add a Hessian + rng = np.random.default_rng() + hessian = HessianRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=rng.random((6, 6), dtype=np.float32), + ) + session.add(hessian) + session.flush() + + # Update to valid with Hessian present - should succeed + stat.is_valid = True + session.flush() + + assert stat.is_valid is True diff --git a/tests/test_models.py b/tests/test_models.py index 5527346..0e328f5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,792 +1,1058 @@ -"""Autostorage models tests.""" +"""Models module tests.""" + +import tempfile +from collections.abc import Generator +from pathlib import Path import numpy as np import pytest -from automol import Algorithm -from numpy.random import Generator -from scipy.spatial.transform import Rotation from sqlalchemy.exc import IntegrityError -from autostorage import ( - CalcStatus, - CalcType, +from autostorage.database import Database +from autostorage.models import ( CalculationGeometryLink, CalculationRow, - Database, + CalculationTrajectoryLink, + EnergyRow, GeometryRow, + GeometryTrajectoryLink, GradientRow, HessianRow, + IdentityExtraRow, + IdentityRow, + IdentityStationaryLink, ModelRow, StageRow, + StageStationaryLink, StationaryPointRow, StepRow, + StepValidationLink, TrajectoryRow, ValidationRow, ) -from autostorage.exc import DataIntegrityError, ResultShapeError -from autostorage.models import CalculationTrajectoryLink from autostorage.types import Role -def test__model_null_safe_index_catches_duplicate(database: Database) -> None: - """Test that a direct duplicate insert (bypassing find_or_create) is rejected. - - `unique_model` alone doesn't catch this, since SQL treats NULL as distinct - from itself; `unique_model_null_safe` is the defense-in-depth index that does. - """ - database.add(ModelRow(program="orca", method="xtb")) - database.add(ModelRow(program="orca", method="xtb")) - - with pytest.raises(IntegrityError): - database.commit() - - -def test__calculation_default_status_is_pending(model_row: ModelRow) -> None: - """Test that a bare CalculationRow defaults to PENDING with no error message.""" - calculation = CalculationRow(model=model_row, calc_type=CalcType.UNDEFINED) - - assert calculation.status == CalcStatus.PENDING - assert calculation.error_message is None - - -def test__calculation_status_transitions( - database: Database, model_row: ModelRow -) -> None: - """Test that status/error_message round-trip through the database.""" - calculation = CalculationRow( - model=model_row, - calc_type=CalcType.UNDEFINED, - status=CalcStatus.FAILED, - error_message="boom", - ) - database.add(calculation) - database.commit() - assert calculation.id is not None - - fetched = database.get(CalculationRow, calculation.id) - assert fetched.status == CalcStatus.FAILED - assert fetched.error_message == "boom" - - -def test__calculation_geometry_role_properties( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - calc_geo_link: CalculationGeometryLink, -) -> None: - """Test that input_geometries/output_geometries filter links by role.""" - output_geometry = GeometryRow( - symbols=["H", "O", "H"], - coordinates=np.array([[0, 0, 0.9], [0, 0, 0], [0.9, 0, 0]]), - charge=0, - spin=0, - ) - output_link = CalculationGeometryLink( - calculation=calculation_row, geometry=output_geometry, role=Role.OUTPUT - ) - database.add(calculation_row) - database.add(geometry_row) - database.add(calc_geo_link) - database.add(output_geometry) - database.add(output_link) - database.commit() - - assert calculation_row.input_geometries == [geometry_row] - assert calculation_row.output_geometries == [output_geometry] - - -def test__calculation_trajectory_role_properties( - database: Database, calculation_row: CalculationRow -) -> None: - """Test that input_trajectories/output_trajectories filter links by role.""" - # Committed one at a time: TrajectoryRow has no non-base columns, and - # SQLite's batched multi-row insert can't apply the created_at - # server_default to two such rows in a single flush. - input_trajectory = TrajectoryRow() - database.add(input_trajectory) - database.commit() - output_trajectory = TrajectoryRow() - database.add(output_trajectory) - database.commit() - - input_link = CalculationTrajectoryLink( - calculation=calculation_row, trajectory=input_trajectory, role=Role.INPUT - ) - output_link = CalculationTrajectoryLink( - calculation=calculation_row, trajectory=output_trajectory, role=Role.OUTPUT - ) - database.add(calculation_row) - database.add(input_link) - database.add(output_link) - database.commit() - - assert calculation_row.input_trajectories == [input_trajectory] - assert calculation_row.output_trajectories == [output_trajectory] - - -def test__validation_requires_calculation(database: Database) -> None: - """Test that a ValidationRow without a calculation is rejected.""" - database.add(ValidationRow(method="irc")) - - with pytest.raises(IntegrityError): - database.commit() - - -def test__gradient_shape( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - calc_geo_link: CalculationGeometryLink, - rng: Generator, -) -> None: - """Test gradient shape is validated before committing to database.""" - database.add(calculation_row) - database.add(geometry_row) - database.add(calc_geo_link) - - gradient = GradientRow( - calculation=calculation_row, - geometry=geometry_row, - value=rng.uniform(size=2), - ) - database.add(gradient) - with pytest.raises(ResultShapeError): - database.commit() - - -def test__hessian_shape( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - calc_geo_link: CalculationGeometryLink, - rng: Generator, -) -> None: - """Test hessian shape is validated before committing to database.""" - database.add(calculation_row) - database.add(geometry_row) - database.add(calc_geo_link) - database.commit() - - hess = HessianRow( - calculation=calculation_row, - geometry=geometry_row, - value=rng.uniform(size=(3, 2)), - ) - database.add(hess) - - with pytest.raises(ResultShapeError): - database.commit() - - -def test__geometry_symbols_immutable_after_insert( - database: Database, geometry_row: GeometryRow -) -> None: - """Test that mutating symbols after insert is rejected.""" - database.add(geometry_row) - database.commit() - - geometry_row.symbols = ["H", "O", "O"] - database.add(geometry_row) - with pytest.raises(DataIntegrityError, match="symbols"): - database.commit() - - -def test__geometry_coordinates_immutable_after_insert( - database: Database, geometry_row: GeometryRow -) -> None: - """Test that mutating coordinates after insert is rejected.""" - database.add(geometry_row) - database.commit() - - geometry_row.coordinates = np.array(geometry_row.coordinates) + 0.1 - database.add(geometry_row) - with pytest.raises(DataIntegrityError, match="coordinates"): - database.commit() - - -def test__geometry_charge_and_spin_remain_mutable( - database: Database, geometry_row: GeometryRow -) -> None: - """Test that charge/spin can still be updated after insert.""" - database.add(geometry_row) - database.commit() - assert geometry_row.id - - geometry_row.charge = 1 - geometry_row.spin = 1 - database.add(geometry_row) - database.commit() - - fetched = database.get(GeometryRow, geometry_row.id) - assert fetched.charge == 1 - assert fetched.spin == 1 - - -def test__geometry_unique_hash_catches_direct_duplicate_insert( - database: Database, geometry_row: GeometryRow -) -> None: - """Test that a direct duplicate insert of identical geometry content is rejected.""" - duplicate = GeometryRow( - symbols=list(geometry_row.symbols), - coordinates=np.array(geometry_row.coordinates), - charge=geometry_row.charge, - spin=geometry_row.spin, - ) - database.add(geometry_row) - database.add(duplicate) - - with pytest.raises(IntegrityError): - database.commit() - - -def test__geometry_near_duplicate_is_not_deduped( - database: Database, geometry_row: GeometryRow, rng: Generator -) -> None: - """Test that a rotated/translated/jittered near-duplicate is a distinct row. - - `geometry_hash` only catches bit-identical content; chemically-equivalent - but numerically distinct conformers are handled separately (and more - coarsely) by `events.py`'s InChI/conformer identity matching. - """ - near_duplicate = _jittered_copy(geometry_row, rng) - - database.add(geometry_row) - database.add(near_duplicate) - database.commit() - - assert geometry_row.id is not None - assert near_duplicate.id is not None - assert geometry_row.id != near_duplicate.id - - -def test__hessian_properties( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - calc_geo_link: CalculationGeometryLink, - rng: Generator, -) -> None: - """Test hessian harmonic frequencies and order properties.""" - database.add(calculation_row) - database.add(geometry_row) - database.add(calc_geo_link) - - n = geometry_row.to_geometry().atom_count - hessian = HessianRow( - calculation=calculation_row, - geometry=geometry_row, - value=rng.uniform(size=(3 * n, 3 * n)), - ) - assert hessian.harmonic_frequencies - assert hessian.order - - -def test__hessian_frequency_cache_invalidated_on_value_update( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - calc_geo_link: CalculationGeometryLink, - rng: Generator, -) -> None: - """Test that updating `value` invalidates the cached harmonic frequencies. - - `harmonic_frequencies` is a `functools.cached_property`; `Session.commit()` - doesn't clear cached-property entries (only mapped attributes), so this - guards against `invalidate_hessian_frequency_cache` regressing and leaving - stale frequencies/order behind after an in-place `value` update. - """ - database.add(calculation_row) - database.add(geometry_row) - database.add(calc_geo_link) - - n = geometry_row.to_geometry().atom_count - hessian = HessianRow( - calculation=calculation_row, - geometry=geometry_row, - value=rng.uniform(size=(3 * n, 3 * n)), - ) - database.add(hessian) - database.commit() - - original_frequencies = hessian.harmonic_frequencies - assert "harmonic_frequencies" in hessian.__dict__ - - hessian.value = rng.uniform(size=(3 * n, 3 * n)) - database.add(hessian) - database.commit() - - assert hessian.harmonic_frequencies != original_frequencies - - -def test__stationary_inchi( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test InChI is attached before committing to database.""" - database.add(calculation_row) - database.add(geometry_row) - - stationary = StationaryPointRow( - calculation=calculation_row, geometry=geometry_row, order=0 - ) - database.add(stationary) - database.commit() - - assert stationary.identities[0].value == "InChI=1S/H2O/h1H2" - - -def test__stationary_inchi_resolves_unattached_geometry_id( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test InChI/conformer identities attach when only `geometry_id` is set. - - Regression test: `add_inchi_identities`/`assign_conformer_ids` must - resolve the geometry via the session (like the shape/order validators - do), rather than reading the `.geometry` relationship directly, which - stays unpopulated until the ORM syncs it. - """ - database.add(calculation_row) - database.add(geometry_row) - database.flush() - - stationary = StationaryPointRow( - calculation_id=calculation_row.id, geometry_id=geometry_row.id, order=0 - ) - database.add(stationary) - database.commit() - - assert stationary.identities[0].value == "InChI=1S/H2O/h1H2" - assert stationary.identity(algorithm=Algorithm.IRMSD) is not None - - -def test__stationary_identity_matches_by_kind_and_algorithm( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test identity() lookup by kind, algorithm, both, and no match.""" - database.add(calculation_row) - database.add(geometry_row) - - stationary = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - database.add(stationary) - database.commit() - - inchi = stationary.identity(kind="stereoisomer") - assert inchi - assert inchi.value == "InChI=1S/H2O/h1H2" - - conformer = stationary.identity(algorithm=Algorithm.IRMSD) - assert conformer - assert conformer.kind == "conformer" - - assert ( - stationary.identity(kind="stereoisomer", algorithm=Algorithm.RDKIT_INCHI) - is inchi - ) - assert stationary.identity(kind="nonexistent") is None - - -def test__stationary_order_hessian_first( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test stationary point order is validated when geometry Hessian is present. - - Corrects a valid StationaryPointRow marked as invalid. - """ - database.add(calculation_row) - database.add(geometry_row) - - n = geometry_row.to_geometry().atom_count - hessian_row = HessianRow( - calculation=calculation_row, - geometry=geometry_row, - value=np.zeros((3 * n, 3 * n)), - ) - database.add(hessian_row) - - stationary = StationaryPointRow( - calculation=calculation_row, geometry=geometry_row, order=0, is_valid=False - ) - database.add(stationary) - assert not stationary.is_valid - - database.commit() - assert stationary.is_valid - - -def test__stationary_order_hessian_second( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test stationary point order is validated when geometry Hessian is present. - - Corrects an invalid StationaryPointRow marked as valid. - """ - database.add(calculation_row) - database.add(geometry_row) - - stationary = StationaryPointRow( - calculation=calculation_row, geometry=geometry_row, order=1, is_valid=True - ) - database.add(stationary) - assert stationary.is_valid - - n = geometry_row.to_geometry().atom_count - hessian_row = HessianRow( - calculation=calculation_row, - geometry=geometry_row, - value=np.zeros((3 * n, 3 * n)), - ) - database.add(hessian_row) - - database.commit() - assert not stationary.is_valid - - -def test__hessian_delete_leaves_is_valid_correct_with_remaining_hessian( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that deleting one of two agreeing Hessians keeps is_valid correct.""" - database.add(calculation_row) - database.add(geometry_row) - database.commit() - - n = geometry_row.to_geometry().atom_count - hessian1 = HessianRow( - calculation=calculation_row, - geometry=geometry_row, - value=np.zeros((3 * n, 3 * n)), - ) - hessian2 = HessianRow( - calculation=calculation_row, - geometry=geometry_row, - value=np.zeros((3 * n, 3 * n)), - ) - database.add(hessian1) - database.add(hessian2) - - stationary = StationaryPointRow( - calculation=calculation_row, geometry=geometry_row, order=0 - ) - database.add(stationary) - database.commit() - assert stationary.is_valid - - database.delete(hessian1) - assert stationary.is_valid - - -def test__hessian_delete_leaves_is_valid_untouched_when_no_hessians_remain( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that deleting the last Hessian doesn't reset is_valid to False.""" - database.add(calculation_row) - database.add(geometry_row) - database.commit() - - n = geometry_row.to_geometry().atom_count - hessian = HessianRow( - calculation=calculation_row, - geometry=geometry_row, - value=np.zeros((3 * n, 3 * n)), - ) - database.add(hessian) - - stationary = StationaryPointRow( - calculation=calculation_row, geometry=geometry_row, order=0 - ) - database.add(stationary) - database.commit() - assert stationary.is_valid - - database.delete(hessian) - assert stationary.is_valid - - -def test__step_null_safe_index_catches_barrierless_duplicate( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a direct duplicate barrierless step insert fails. - - `unq_step_stages` alone doesn't catch this, since `stage_id_ts` is NULL for - both rows and SQL treats NULL as distinct from itself; `unq_step_stages_null_safe` - is the defense-in-depth index that does. - """ - database.add(calculation_row) - database.add(geometry_row) - database.commit() - - stationary1 = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - stationary2 = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - database.add(stationary1) - database.add(stationary2) - database.commit() - - stage1 = StageRow(stationaries=[stationary1]) - stage2 = StageRow(stationaries=[stationary2]) - database.add(stage1) - database.add(stage2) - database.commit() - - database.add(StepRow(stage1=stage1, stage2=stage2)) - database.add(StepRow(stage1=stage1, stage2=stage2)) - - with pytest.raises(IntegrityError): - database.commit() - - -def test__step_rejects_ts_stage_as_stage1_or_stage2( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a TS stage cannot be used as stage1/stage2.""" - database.add(calculation_row) - database.add(geometry_row) - database.commit() - - stationary1 = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - stationary2 = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - database.add(stationary1) - database.add(stationary2) - database.commit() - - stage_ts = StageRow(stationaries=[stationary1], is_ts=True) - stage2 = StageRow(stationaries=[stationary2]) - database.add(stage_ts) - database.add(stage2) - database.commit() - - database.add(StepRow(stage1=stage_ts, stage2=stage2)) - with pytest.raises(DataIntegrityError, match="transition-state"): - database.commit() - - -def test__step_rejects_non_ts_stage_as_stage_ts( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a non-TS stage cannot be used as stage_ts.""" - database.add(calculation_row) - database.add(geometry_row) - database.commit() - - stationaries = [ - StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - for _ in range(3) - ] - for stationary in stationaries: - database.add(stationary) - database.commit() - - stage1, stage2, stage3 = (StageRow(stationaries=[s]) for s in stationaries) - database.add(stage1) - database.add(stage2) - database.add(stage3) - database.commit() - - database.add(StepRow(stage1=stage1, stage2=stage2, stage_ts=stage3)) - with pytest.raises(DataIntegrityError, match="stage_ts"): - database.commit() - - -def test__step_accepts_consistent_ts_configuration( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that a step with a genuine TS stage commits without error.""" - database.add(calculation_row) - database.add(geometry_row) - database.commit() - - stationaries = [ - StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - for _ in range(3) - ] - for stationary in stationaries: - database.add(stationary) - database.commit() - - stage1 = StageRow(stationaries=[stationaries[0]]) - stage2 = StageRow(stationaries=[stationaries[1]]) - stage_ts = StageRow(stationaries=[stationaries[2]], is_ts=True) - database.add(stage1) - database.add(stage2) - database.add(stage_ts) - database.commit() - - step = StepRow(stage1=stage1, stage2=stage2, stage_ts=stage_ts) - database.add(step) - database.commit() - - assert step.id is not None - assert not step.is_barrierless - - -def _hooh_geometry_row(dihedral_deg: float) -> GeometryRow: - """Build an HOOH GeometryRow at a given H-O-O-H dihedral angle.""" - roo, roh, hoo_ang = 1.45, 0.97, np.radians(100.0) - dih = np.radians(dihedral_deg) - o1 = np.array([0, 0, 0]) - o2 = np.array([roo, 0, 0]) - h1 = o1 + roh * np.array([-np.cos(hoo_ang), np.sin(hoo_ang), 0]) - base = roh * np.array([np.cos(hoo_ang), np.sin(hoo_ang), 0]) - rot = np.array( - [ - [1, 0, 0], - [0, np.cos(dih), -np.sin(dih)], - [0, np.sin(dih), np.cos(dih)], - ] - ) - h2 = o2 + rot @ base - coordinates = np.array([h1, o1, o2, h2]) - return GeometryRow( - symbols=["H", "O", "O", "H"], coordinates=coordinates, charge=0, spin=0 - ) - - -def _jittered_copy(geometry_row: GeometryRow, rng: Generator) -> GeometryRow: - """Build a small-noise, rotated, translated copy of a geometry.""" - coordinates = np.array(geometry_row.coordinates) - coordinates = coordinates + rng.normal(scale=0.01, size=coordinates.shape) - rot = Rotation.from_euler("xyz", [30, 20, 10], degrees=True) - coordinates = rot.apply(coordinates) + np.array([3.0, 3.0, 3.0]) - return GeometryRow( - symbols=list(geometry_row.symbols), - coordinates=coordinates, - charge=geometry_row.charge, - spin=geometry_row.spin, - ) - - -def test__conformer_identity_merge_on_duplicate_geometry( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - rng: Generator, -) -> None: - """Test that near-identical geometries share one conformer identity.""" - duplicate_geometry = _jittered_copy(geometry_row, rng) - - database.add(calculation_row) - database.add(geometry_row) - database.add(duplicate_geometry) - - stationary1 = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - stationary2 = StationaryPointRow( - calculation=calculation_row, geometry=duplicate_geometry - ) - database.add(stationary1) - database.add(stationary2) - database.commit() - - conformer1 = next(i for i in stationary1.identities if i.kind == "conformer") - conformer2 = next(i for i in stationary2.identities if i.kind == "conformer") - assert conformer1.id == conformer2.id - - -def test__conformer_identity_merge_with_unattached_geometry_ids( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - rng: Generator, -) -> None: - """Test conformer dedup when peers are built with only `geometry_id` set. - - Regression test: `_matching_conformer_identity` must resolve each InChI - peer's geometry the same way it resolves the target row's own geometry - (via the session, for a row with only `geometry_id` set), rather than - reading a peer's `.geometry` relationship directly, which stays - unpopulated until the ORM syncs it — as is the case for rows built by a - bulk loader like a database merge rather than via `.geometry=`. - """ - duplicate_geometry = _jittered_copy(geometry_row, rng) - - database.add(calculation_row) - database.add(geometry_row) - database.add(duplicate_geometry) - database.flush() - - stationary1 = StationaryPointRow( - calculation_id=calculation_row.id, geometry_id=geometry_row.id - ) - stationary2 = StationaryPointRow( - calculation_id=calculation_row.id, geometry_id=duplicate_geometry.id - ) - database.add(stationary1) - database.add(stationary2) - database.commit() - - conformer1 = next(i for i in stationary1.identities if i.kind == "conformer") - conformer2 = next(i for i in stationary2.identities if i.kind == "conformer") - assert conformer1.id == conformer2.id - - -def test__conformer_identity_split_on_distinct_conformer( - database: Database, calculation_row: CalculationRow -) -> None: - """Test that geometrically distinct conformers of the same species split.""" - anti = _hooh_geometry_row(180) - gauche = _hooh_geometry_row(60) - - database.add(calculation_row) - database.add(anti) - database.add(gauche) - - stationary1 = StationaryPointRow(calculation=calculation_row, geometry=anti) - stationary2 = StationaryPointRow(calculation=calculation_row, geometry=gauche) - database.add(stationary1) - database.add(stationary2) - database.commit() - - conformer1 = next(i for i in stationary1.identities if i.kind == "conformer") - conformer2 = next(i for i in stationary2.identities if i.kind == "conformer") - assert conformer1.value != conformer2.value - - -def test__conformer_group_id_increments_across_distinct_species_in_one_flush( - database: Database, - calculation_row: CalculationRow, - geometry_row: GeometryRow, - rng: Generator, -) -> None: - """Test that one flush assigns distinct group ids to distinct species.""" - duplicate_geometry = _jittered_copy(geometry_row, rng) - hooh_geometry = _hooh_geometry_row(180) - - database.add(calculation_row) - database.add(geometry_row) - database.add(duplicate_geometry) - database.add(hooh_geometry) - - water1 = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - water2 = StationaryPointRow( - calculation=calculation_row, geometry=duplicate_geometry - ) - hooh = StationaryPointRow(calculation=calculation_row, geometry=hooh_geometry) - database.add(water1) - database.add(water2) - database.add(hooh) - database.commit() - - water1_conf = next(i for i in water1.identities if i.kind == "conformer") - water2_conf = next(i for i in water2.identities if i.kind == "conformer") - hooh_conf = next(i for i in hooh.identities if i.kind == "conformer") - - assert water1_conf.id == water2_conf.id - assert hooh_conf.id != water1_conf.id - - -def test__conformer_identity_idempotent_on_second_flush( - database: Database, calculation_row: CalculationRow, geometry_row: GeometryRow -) -> None: - """Test that re-flushing an already-committed stationary point is idempotent.""" - database.add(calculation_row) - database.add(geometry_row) - - stationary = StationaryPointRow(calculation=calculation_row, geometry=geometry_row) - database.add(stationary) - database.commit() - - conformer_id = next(i for i in stationary.identities if i.kind == "conformer").id - - stationary.order = 1 - database.add(stationary) - database.commit() - - conformer_identities = [i for i in stationary.identities if i.kind == "conformer"] - assert len(conformer_identities) == 1 - assert conformer_identities[0].id == conformer_id +@pytest.fixture +def db_path() -> Generator[Path, None, None]: + """Create a temporary database path for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "test.db" + + +@pytest.fixture +def database(db_path: Path) -> Generator[Database, None, None]: + """Create a Database instance for testing.""" + db = Database(db_path) + yield db + db.close() + + +class TestGeometryRow: + """Tests for GeometryRow model.""" + + def test_create_geometry_with_list_coordinates(self, database: Database) -> None: + """GeometryRow can be created with list coordinates.""" + with database.session() as session: + geom = GeometryRow( + symbols=["C", "H"], + coordinates=[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + charge=0, + spin=0, + ) + session.add(geom) + session.commit() + + assert geom.id is not None + assert isinstance(geom.coordinates, np.ndarray) + assert geom.coordinates.shape == (2, 3) + + def test_create_geometry_with_numpy_coordinates(self, database: Database) -> None: + """GeometryRow can be created with numpy array coordinates.""" + with database.session() as session: + coords = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + geom = GeometryRow(symbols=["C", "H"], coordinates=coords, charge=0, spin=0) + session.add(geom) + session.commit() + + assert geom.id is not None + assert isinstance(geom.coordinates, np.ndarray) + np.testing.assert_array_equal(geom.coordinates, coords) + + def test_geometry_symbols_stored_as_json(self, database: Database) -> None: + """GeometryRow symbols are stored and retrieved correctly.""" + with database.session() as session: + geom = GeometryRow( + symbols=["C", "O", "H"], + coordinates=[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + charge=0, + spin=0, + ) + session.add(geom) + session.commit() + + result = session.query(GeometryRow).filter_by(id=geom.id).first() + assert result is not None + assert result.symbols == ["C", "O", "H"] + + def test_geometry_charge_and_spin(self, database: Database) -> None: + """GeometryRow stores charge and spin correctly.""" + with database.session() as session: + geom = GeometryRow( + symbols=["C"], + coordinates=[[0.0, 0.0, 0.0]], + charge=1, + spin=1, + ) + session.add(geom) + session.commit() + + assert geom.charge == 1 + assert geom.spin == 1 + + def test_geometry_relationships(self, database: Database) -> None: + """GeometryRow relationships are initially empty.""" + with database.session() as session: + geom = GeometryRow( + symbols=["C"], + coordinates=[[0.0, 0.0, 0.0]], + charge=0, + spin=0, + ) + session.add(geom) + session.commit() + + assert geom.energies == [] + assert geom.gradients == [] + assert geom.hessians == [] + assert geom.stationary_points == [] + assert geom.trajectory_links == [] + assert geom.calculation_links == [] + + +class TestTrajectoryRow: + """Tests for TrajectoryRow model.""" + + def test_create_trajectory_with_ndim(self, database: Database) -> None: + """TrajectoryRow can be created with ndim specified.""" + with database.session() as session: + traj = TrajectoryRow(ndim=3) + session.add(traj) + session.commit() + + assert traj.id is not None + assert traj.ndim == 3 # noqa: PLR2004 + + def test_create_trajectory_without_ndim(self, database: Database) -> None: + """TrajectoryRow can be created without ndim.""" + with database.session() as session: + traj = TrajectoryRow(ndim=None) + session.add(traj) + session.commit() + + assert traj.id is not None + assert traj.ndim is None + + def test_trajectory_relationships(self, database: Database) -> None: + """TrajectoryRow relationships are initially empty.""" + with database.session() as session: + traj = TrajectoryRow(ndim=2) + session.add(traj) + session.commit() + + assert traj.geometry_links == [] + assert traj.calculation_links == [] + + +class TestModelRow: + """Tests for ModelRow model.""" + + def test_create_model_minimal(self, database: Database) -> None: + """ModelRow can be created with minimal required fields.""" + with database.session() as session: + model = ModelRow(calc_type="energy", program="psi4", method="B3LYP") + session.add(model) + session.commit() + + assert model.id is not None + assert model.calc_type == "energy" + assert model.program == "psi4" + assert model.method == "B3LYP" + assert model.basis is None + assert model.program_version is None + assert model.keywords == {} + + def test_create_model_complete(self, database: Database) -> None: + """ModelRow can be created with all fields specified.""" + with database.session() as session: + model = ModelRow( + calc_type="gradient", + program="orca", + program_version="5.0.3", + method="MP2", + basis="cc-pvdz", + keywords={"convergence": "tight", "scf_type": "df"}, + ) + session.add(model) + session.commit() + + assert model.id is not None + assert model.program_version == "5.0.3" + assert model.basis == "cc-pvdz" + assert model.keywords == {"convergence": "tight", "scf_type": "df"} + + def test_model_keywords_default_empty_dict(self, database: Database) -> None: + """ModelRow keywords default to empty dict.""" + with database.session() as session: + model = ModelRow(calc_type="opt", program="gaussian", method="HF") + session.add(model) + session.commit() + + assert model.keywords == {} + + +class TestCalculationRow: + """Tests for CalculationRow model.""" + + def test_create_calculation(self, database: Database) -> None: + """CalculationRow can be created with a model reference.""" + with database.session() as session: + model = ModelRow(calc_type="energy", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow( + model_id=model.id, + input_provenance={"source": "test"}, + output_provenance={"status": "success"}, + ) + session.add(calc) + session.commit() + + assert calc.id is not None + assert calc.model_id == model.id + assert calc.input_provenance == {"source": "test"} + assert calc.output_provenance == {"status": "success"} + + def test_calculation_relationships(self, database: Database) -> None: + """CalculationRow relationships are initially empty.""" + with database.session() as session: + model = ModelRow(calc_type="energy", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.commit() + + assert calc.energies == [] + assert calc.gradients == [] + assert calc.hessians == [] + assert calc.validations == [] + assert calc.stationary_points == [] + assert calc.geometry_links == [] + assert calc.trajectory_links == [] + + def test_calculation_model_relationship(self, database: Database) -> None: + """CalculationRow.model relationship works correctly.""" + with database.session() as session: + model = ModelRow(calc_type="energy", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.commit() + + assert calc.model is not None + assert calc.model.id == model.id + assert calc.model.method == "B3LYP" + + def test_calculation_requires_model(self, database: Database) -> None: + """CalculationRow requires a valid model_id.""" + with database.session() as session: + calc = CalculationRow(model_id=9999) + session.add(calc) + + with pytest.raises(IntegrityError): + session.commit() + + +class TestResultRows: + """Tests for result row models (Energy, Gradient, Hessian).""" + + def test_create_energy_row(self, database: Database) -> None: + """EnergyRow can be created with geometry and calculation.""" + with database.session() as session: + model = ModelRow(calc_type="energy", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C"], coordinates=[[0.0, 0.0, 0.0]], charge=0, spin=0 + ) + session.add(geom) + session.flush() + + energy = EnergyRow( + geometry_id=geom.id, calculation_id=calc.id, value=-37.8422 + ) + session.add(energy) + session.commit() + + assert energy.id is not None + assert energy.value == -37.8422 # noqa: PLR2004 + assert energy.geometry_id == geom.id + assert energy.calculation_id == calc.id + + def test_create_gradient_row(self, database: Database) -> None: + """GradientRow can be created with numpy array.""" + with database.session() as session: + model = ModelRow(calc_type="gradient", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C", "H"], + coordinates=[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + charge=0, + spin=0, + ) + session.add(geom) + session.flush() + + grad_value = np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + gradient = GradientRow( + geometry_id=geom.id, calculation_id=calc.id, value=grad_value + ) + session.add(gradient) + session.commit() + + assert gradient.id is not None + np.testing.assert_array_equal(gradient.value, grad_value) + + def test_create_hessian_row(self, database: Database) -> None: + """HessianRow can be created with 2D numpy array.""" + with database.session() as session: + model = ModelRow(calc_type="frequency", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C"], + coordinates=[[0.0, 0.0, 0.0]], + charge=0, + spin=0, + ) + session.add(geom) + session.flush() + + hess_value = np.eye(3, dtype=np.float32) + hessian = HessianRow( + geometry_id=geom.id, calculation_id=calc.id, value=hess_value + ) + session.add(hessian) + session.commit() + + assert hessian.id is not None + np.testing.assert_array_equal(hessian.value, hess_value) + + def test_result_relationships(self, database: Database) -> None: + """Result rows have correct relationships to geometry and calculation.""" + with database.session() as session: + model = ModelRow(calc_type="energy", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C"], coordinates=[[0.0, 0.0, 0.0]], charge=0, spin=0 + ) + session.add(geom) + session.flush() + + energy = EnergyRow( + geometry_id=geom.id, calculation_id=calc.id, value=-37.8422 + ) + session.add(energy) + session.commit() + + assert energy.geometry is not None + assert energy.geometry.id == geom.id + assert energy.calculation is not None + assert energy.calculation.id == calc.id + + +class TestValidationRow: + """Tests for ValidationRow model.""" + + def test_create_validation(self, database: Database) -> None: + """ValidationRow can be created with calculation reference.""" + with database.session() as session: + model = ModelRow(calc_type="irc", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + validation = ValidationRow( + calculation_id=calc.id, + method="irc", + extras={"convergence": "tight"}, + ) + session.add(validation) + session.commit() + + assert validation.id is not None + assert validation.method == "irc" + assert validation.extras == {"convergence": "tight"} + + def test_validation_extras_default(self, database: Database) -> None: + """ValidationRow extras default to empty dict.""" + with database.session() as session: + model = ModelRow(calc_type="irc", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + validation = ValidationRow(calculation_id=calc.id, method="irc") + session.add(validation) + session.commit() + + assert validation.extras == {} + + +class TestStationaryPointRow: + """Tests for StationaryPointRow model.""" + + def test_create_stationary_point(self, database: Database) -> None: + """StationaryPointRow can be created with default values.""" + with database.session() as session: + model = ModelRow(calc_type="opt", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C"], coordinates=[[0.0, 0.0, 0.0]], charge=0, spin=0 + ) + session.add(geom) + session.flush() + + stat_pt = StationaryPointRow(geometry_id=geom.id, calculation_id=calc.id) + session.add(stat_pt) + session.commit() + + assert stat_pt.id is not None + assert stat_pt.order == 0 + assert stat_pt.is_pseudo is False + assert stat_pt.is_valid is False + + def test_create_transition_state(self, database: Database) -> None: + """StationaryPointRow can represent a transition state (order=1).""" + with database.session() as session: + model = ModelRow(calc_type="opt_ts", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C"], coordinates=[[0.0, 0.0, 0.0]], charge=0, spin=0 + ) + session.add(geom) + session.flush() + + # Add Hessian to make stationary point valid + rng = np.random.default_rng() + hessian = HessianRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=rng.random((3, 3), dtype=np.float32), + ) + session.add(hessian) + session.flush() + + stat_pt = StationaryPointRow( + geometry_id=geom.id, calculation_id=calc.id, order=1, is_valid=True + ) + session.add(stat_pt) + session.commit() + + assert stat_pt.order == 1 + assert stat_pt.is_valid is True + + def test_stationary_point_relationships(self, database: Database) -> None: + """StationaryPointRow relationships work correctly.""" + with database.session() as session: + model = ModelRow(calc_type="opt", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C"], coordinates=[[0.0, 0.0, 0.0]], charge=0, spin=0 + ) + session.add(geom) + session.flush() + + stat_pt = StationaryPointRow(geometry_id=geom.id, calculation_id=calc.id) + session.add(stat_pt) + session.commit() + + assert stat_pt.geometry is not None + assert stat_pt.geometry.id == geom.id + assert stat_pt.calculation is not None + assert stat_pt.calculation.id == calc.id + # Note: auto-generated InChI identity from event listener + assert len(stat_pt.identities) >= 1 + assert stat_pt.stages == [] + + +class TestStageRow: + """Tests for StageRow model.""" + + def test_create_stage_non_ts(self, database: Database) -> None: + """StageRow can be created as a non-TS stage.""" + with database.session() as session: + stage = StageRow(is_ts=False) + session.add(stage) + session.commit() + + assert stage.id is not None + assert stage.is_ts is False + + def test_create_stage_ts(self, database: Database) -> None: + """StageRow can be created as a TS stage.""" + with database.session() as session: + stage = StageRow(is_ts=True) + session.add(stage) + session.commit() + + assert stage.id is not None + assert stage.is_ts is True + + def test_stage_relationships(self, database: Database) -> None: + """StageRow relationships are initially empty.""" + with database.session() as session: + stage = StageRow(is_ts=False) + session.add(stage) + session.commit() + + assert stage.stationaries == [] + assert stage.steps == [] + + +class TestStepRow: + """Tests for StepRow model.""" + + def test_create_barrierless_step(self, database: Database) -> None: + """StepRow can be created as a barrierless step.""" + with database.session() as session: + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + session.add_all([stage1, stage2]) + session.flush() + + step = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + stage_id_ts=None, + is_barrierless=True, + ) + session.add(step) + session.commit() + + assert step.id is not None + assert step.stage_id_ts is None + assert step.is_barrierless is True + + def test_create_step_with_ts(self, database: Database) -> None: + """StepRow can be created with a transition state.""" + with database.session() as session: + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + stage_ts = StageRow(is_ts=True) + session.add_all([stage1, stage2, stage_ts]) + session.flush() + + step = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + stage_id_ts=stage_ts.id, + is_barrierless=False, + ) + session.add(step) + session.commit() + + assert step.id is not None + assert step.stage_id_ts == stage_ts.id + assert step.is_barrierless is False + + def test_step_unique_constraint(self, database: Database) -> None: + """StepRow enforces unique constraint on stage IDs.""" + with database.session() as session: + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + stage_ts = StageRow(is_ts=True) + session.add_all([stage1, stage2, stage_ts]) + session.flush() + + step1 = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + stage_id_ts=stage_ts.id, + is_barrierless=False, + ) + session.add(step1) + session.flush() + + # Try to create duplicate step + step2 = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + stage_id_ts=stage_ts.id, + is_barrierless=False, + ) + session.add(step2) + + with pytest.raises(IntegrityError): + session.commit() + + def test_step_stage_relationships(self, database: Database) -> None: + """StepRow stage relationships work correctly.""" + with database.session() as session: + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + stage_ts = StageRow(is_ts=True) + session.add_all([stage1, stage2, stage_ts]) + session.flush() + + step = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + stage_id_ts=stage_ts.id, + is_barrierless=False, + ) + session.add(step) + session.commit() + + assert step.stage1 is not None + assert step.stage1.id == stage1.id + assert step.stage2 is not None + assert step.stage2.id == stage2.id + assert step.stage_ts is not None + assert step.stage_ts.id == stage_ts.id + + +class TestIdentityRow: + """Tests for IdentityRow model.""" + + def test_create_identity(self, database: Database) -> None: + """IdentityRow can be created with kind, algorithm, and value.""" + with database.session() as session: + identity = IdentityRow( + kind="stereoisomer", + algorithm="rdkit inchi", + value="InChI=1S/CH4/h1H4", + ) + session.add(identity) + session.commit() + + assert identity.id is not None + assert identity.kind == "stereoisomer" + assert identity.algorithm == "rdkit inchi" + assert identity.value == "InChI=1S/CH4/h1H4" + + def test_identity_unique_constraint(self, database: Database) -> None: + """IdentityRow enforces unique constraint on kind, algorithm, value.""" + with database.session() as session: + identity1 = IdentityRow( + kind="stereoisomer", + algorithm="rdkit inchi", + value="InChI=1S/CH4/h1H4", + ) + session.add(identity1) + session.flush() + + # Try to create duplicate identity + identity2 = IdentityRow( + kind="stereoisomer", + algorithm="rdkit inchi", + value="InChI=1S/CH4/h1H4", + ) + session.add(identity2) + + with pytest.raises(IntegrityError): + session.commit() + + def test_identity_relationships(self, database: Database) -> None: + """IdentityRow relationships are initially empty.""" + with database.session() as session: + identity = IdentityRow( + kind="stereoisomer", algorithm="rdkit inchi", value="InChI=1S/CH4/h1H4" + ) + session.add(identity) + session.commit() + + assert identity.stationary_points == [] + assert identity.identity_extras == [] + + +class TestIdentityExtraRow: + """Tests for IdentityExtraRow model.""" + + def test_create_identity_extra(self, database: Database) -> None: + """IdentityExtraRow can be created with attribute and value.""" + with database.session() as session: + identity = IdentityRow( + kind="stereoisomer", + algorithm="rdkit inchi", + value="InChI=1S/CH4/h1H4", + ) + session.add(identity) + session.flush() + + extra = IdentityExtraRow( + identity_id=identity.id, + attribute="molecular_weight", + value="16.04", + ) + session.add(extra) + session.commit() + + assert extra.id is not None + assert extra.attribute == "molecular_weight" + assert extra.value == "16.04" + + def test_identity_extra_relationship(self, database: Database) -> None: + """IdentityExtraRow.identity relationship works correctly.""" + with database.session() as session: + identity = IdentityRow( + kind="stereoisomer", algorithm="rdkit inchi", value="InChI=1S/CH4/h1H4" + ) + session.add(identity) + session.flush() + + extra = IdentityExtraRow( + identity_id=identity.id, attribute="molecular_weight", value="16.04" + ) + session.add(extra) + session.commit() + + assert extra.identity is not None + assert extra.identity.id == identity.id + assert extra.identity.value == "InChI=1S/CH4/h1H4" + + +class TestLinkModels: + """Tests for link/association table models.""" + + def test_calculation_geometry_link(self, database: Database) -> None: + """CalculationGeometryLink can be created with role.""" + with database.session() as session: + model = ModelRow(calc_type="energy", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C"], coordinates=[[0.0, 0.0, 0.0]], charge=0, spin=0 + ) + session.add(geom) + session.flush() + + link = CalculationGeometryLink( + calculation_id=calc.id, geometry_id=geom.id, role=Role.INPUT + ) + session.add(link) + session.commit() + + assert link.role == Role.INPUT + assert link.geometry_id == geom.id + assert link.calculation_id == calc.id + + def test_geometry_trajectory_link(self, database: Database) -> None: + """GeometryTrajectoryLink can be created with index.""" + with database.session() as session: + geom = GeometryRow( + symbols=["C"], coordinates=[[0.0, 0.0, 0.0]], charge=0, spin=0 + ) + traj = TrajectoryRow(ndim=2) + session.add_all([geom, traj]) + session.flush() + + link = GeometryTrajectoryLink( + geometry_id=geom.id, trajectory_id=traj.id, index=[0, 0] + ) + session.add(link) + session.commit() + + assert link.index == [0, 0] + + def test_calculation_trajectory_link(self, database: Database) -> None: + """CalculationTrajectoryLink can be created.""" + with database.session() as session: + model = ModelRow(calc_type="irc", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + traj = TrajectoryRow(ndim=1) + session.add_all([calc, traj]) + session.flush() + + link = CalculationTrajectoryLink( + calculation_id=calc.id, trajectory_id=traj.id, role="output" + ) + session.add(link) + session.commit() + + assert link.role == "output" + + def test_stage_stationary_link(self, database: Database) -> None: + """StageStationaryLink can be created.""" + with database.session() as session: + model = ModelRow(calc_type="opt", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C"], coordinates=[[0.0, 0.0, 0.0]], charge=0, spin=0 + ) + session.add(geom) + session.flush() + + stat_pt = StationaryPointRow(geometry_id=geom.id, calculation_id=calc.id) + stage = StageRow(is_ts=False) + session.add_all([stat_pt, stage]) + session.flush() + + link = StageStationaryLink(stationary_id=stat_pt.id, stage_id=stage.id) + session.add(link) + session.commit() + + assert link.stationary_id == stat_pt.id + assert link.stage_id == stage.id + + def test_step_validation_link(self, database: Database) -> None: + """StepValidationLink can be created.""" + with database.session() as session: + stage1 = StageRow(is_ts=False) + stage2 = StageRow(is_ts=False) + session.add_all([stage1, stage2]) + session.flush() + + step = StepRow( + stage_id1=stage1.id, + stage_id2=stage2.id, + is_barrierless=True, + ) + session.add(step) + session.flush() + + model = ModelRow(calc_type="irc", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + validation = ValidationRow(calculation_id=calc.id, method="irc") + session.add(validation) + session.flush() + + assert step.id is not None + assert validation.id is not None + link = StepValidationLink(step_id=step.id, validation_id=validation.id) + session.add(link) + session.commit() + + assert link.step_id == step.id + assert link.validation_id == validation.id + + def test_identity_stationary_link(self, database: Database) -> None: + """IdentityStationaryLink can be created.""" + with database.session() as session: + model = ModelRow(calc_type="opt", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C"], coordinates=[[0.0, 0.0, 0.0]], charge=0, spin=0 + ) + session.add(geom) + session.flush() + + stat_pt = StationaryPointRow(geometry_id=geom.id, calculation_id=calc.id) + identity = IdentityRow( + kind="stereoisomer", algorithm="rdkit smiles", value="C" + ) + session.add_all([stat_pt, identity]) + session.flush() + + assert stat_pt.id is not None + assert identity.id is not None + link = IdentityStationaryLink( + stationary_id=stat_pt.id, identity_id=identity.id + ) + session.add(link) + session.commit() + + assert link.stationary_id == stat_pt.id + assert link.identity_id == identity.id + + +class TestModelIntegration: + """Integration tests for model interactions.""" + + def test_geometry_with_multiple_results(self, database: Database) -> None: + """Geometry can have multiple result types attached.""" + with database.session() as session: + model = ModelRow(calc_type="frequency", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C", "H"], + coordinates=[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + charge=0, + spin=0, + ) + session.add(geom) + session.flush() + + energy = EnergyRow( + geometry_id=geom.id, calculation_id=calc.id, value=-37.8422 + ) + gradient = GradientRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]), + ) + hessian = HessianRow( + geometry_id=geom.id, + calculation_id=calc.id, + value=np.eye(6, dtype=np.float32), + ) + session.add_all([energy, gradient, hessian]) + session.commit() + + assert len(geom.energies) == 1 + assert len(geom.gradients) == 1 + assert len(geom.hessians) == 1 + + def test_calculation_with_multiple_geometries(self, database: Database) -> None: + """Calculation can be linked to multiple geometries.""" + with database.session() as session: + model = ModelRow(calc_type="opt", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom1 = GeometryRow( + symbols=["C"], coordinates=[[0.0, 0.0, 0.0]], charge=0, spin=0 + ) + geom2 = GeometryRow( + symbols=["C"], coordinates=[[0.1, 0.0, 0.0]], charge=0, spin=0 + ) + session.add_all([geom1, geom2]) + session.flush() + + link1 = CalculationGeometryLink( + calculation_id=calc.id, geometry_id=geom1.id, role=Role.INPUT + ) + link2 = CalculationGeometryLink( + calculation_id=calc.id, geometry_id=geom2.id, role=Role.OUTPUT + ) + session.add_all([link1, link2]) + session.commit() + + assert len(calc.geometry_links) == 2 # noqa: PLR2004 + + def test_stationary_point_with_identity(self, database: Database) -> None: + """Stationary point can be linked to an identity.""" + with database.session() as session: + model = ModelRow(calc_type="opt", program="psi4", method="B3LYP") + session.add(model) + session.flush() + + calc = CalculationRow(model_id=model.id) + session.add(calc) + session.flush() + + geom = GeometryRow( + symbols=["C"], coordinates=[[0.0, 0.0, 0.0]], charge=0, spin=0 + ) + session.add(geom) + session.flush() + + stat_pt = StationaryPointRow(geometry_id=geom.id, calculation_id=calc.id) + identity = IdentityRow( + kind="stereoisomer", algorithm="rdkit smiles", value="C" + ) + session.add_all([stat_pt, identity]) + session.flush() + + assert stat_pt.id is not None + assert identity.id is not None + link = IdentityStationaryLink( + stationary_id=stat_pt.id, identity_id=identity.id + ) + session.add(link) + session.commit() + + # Test bidirectional relationship + # Note: stat_pt will have auto-generated identities from event listener + # plus the manually linked one + assert len(stat_pt.identities) >= 2 # noqa: PLR2004 + assert identity.id in [i.id for i in stat_pt.identities] + assert len(identity.stationary_points) == 1 + assert identity.stationary_points[0].id == stat_pt.id