From 848411d6ba0bb332e6f355d16f59c951f28f06af Mon Sep 17 00:00:00 2001 From: Anton Morgunov Date: Thu, 12 Mar 2026 18:31:13 -0400 Subject: [PATCH 1/3] DOCS: introduce zensicla docs site --- .github/workflows/docs.yml | 30 +++ .gitignore | 7 +- docs/concepts.md | 142 +++++++++++++ docs/developers/parsers.md | 275 ++++++++++++++++++++++++ docs/developers/schema-versioning.md | 138 ++++++++++++ docs/developers/testing.md | 237 +++++++++++++++++++++ docs/guides/agentic.md | 214 +++++++++++++++++++ docs/guides/geometry.md | 221 +++++++++++++++++++ docs/guides/inputs.md | 282 +++++++++++++++++++++++++ docs/guides/parsing.md | 304 +++++++++++++++++++++++++++ docs/guides/slurm.md | 186 ++++++++++++++++ docs/index.md | 131 ++++++++++++ docs/quick-start.md | 175 +++++++++++++++ pyproject.toml | 1 + uv.lock | 109 ++++++++++ zensical.toml | 85 ++++++++ 16 files changed, 2534 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/concepts.md create mode 100644 docs/developers/parsers.md create mode 100644 docs/developers/schema-versioning.md create mode 100644 docs/developers/testing.md create mode 100644 docs/guides/agentic.md create mode 100644 docs/guides/geometry.md create mode 100644 docs/guides/inputs.md create mode 100644 docs/guides/parsing.md create mode 100644 docs/guides/slurm.md create mode 100644 docs/index.md create mode 100644 docs/quick-start.md create mode 100644 zensical.toml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..cec6afd --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,30 @@ +name: Documentation + +on: + push: + branches: [master, main] + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/configure-pages@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - run: pip install zensical + - run: zensical build --clean + - uses: actions/upload-pages-artifact@v4 + with: + path: site + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.gitignore b/.gitignore index 3ea595f..bb095b2 100644 --- a/.gitignore +++ b/.gitignore @@ -12,12 +12,13 @@ wheels/ _prompts/ collectors/ -docs/ .coverage .ignore ex-*.md h2o_calc_spec.json -*.md -!calcflow.md + *.csv + +# docs build output +site/ diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..1f059fe --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,142 @@ +--- +icon: lucide/lightbulb +--- + +# Concepts + +CalcFlow is built around three ideas: *immutability*, *composition*, and *separation of concerns*. Understanding these makes the API feel inevitable rather than arbitrary. + +!!! abstract "Core idea" + **A quantum chemistry result is a fact, not a variable.** Once a calculation has run, its output is fixed. CalcFlow models this directly: every parsed result and every calculation spec is a frozen, immutable object. You don't update results — you derive new ones. + +## Immutable Data Models + +All data models in CalcFlow are standard-library `dataclasses` with `frozen=True`. This means: + +- **No mutation**: once created, no field can be changed. +- **Hashable and safe**: immutable objects can be used as dict keys, stored in sets, passed across threads without copying. +- **Explicit derivation**: to "change" a spec, you call a setter that returns a *new* instance via `dataclasses.replace()`. + +```python +calc = CalculationInput(charge=0, spin_multiplicity=1, task="energy", + level_of_theory="B3LYP", basis_set="def2-SVP") + +# This does NOT modify calc — it returns a new object +calc_tddft = calc.set_tddft(nroots=5, singlets=True, triplets=False) + +assert calc.tddft is None # original unchanged +assert calc_tddft.tddft is not None +``` + +This design makes it trivial to branch a spec: + +```python +base = CalculationInput(charge=0, spin_multiplicity=1, task="energy", + level_of_theory="wB97X-D3", basis_set="def2-TZVP") + +in_vacuum = base +in_solvent = base.set_solvation(model="smd", solvent="water") +with_tddft = base.set_tddft(nroots=10, singlets=True, triplets=False) +``` + +All three objects share the same immutable base — there's no risk of one accidentally mutating another's settings. + +## The Parser Architecture + +Parsing a quantum chemistry output file is not a monolithic task. An output file is a sequence of *blocks* — each block has a distinctive header line, a specific format, and a well-defined set of data it contributes to the result. + +CalcFlow uses the **strategy pattern** to model this. + +```mermaid +flowchart LR + text["output text"] --> iter["PeekableIterator"] + iter --> core["core_parse()"] + core --> registry["BlockParser registry"] + registry --> p1["ScfParser"] + registry --> p2["OrbitalsParser"] + registry --> p3["TddftParser"] + registry --> p4["..."] + p1 & p2 & p3 & p4 --> state["ParseState\n(mutable scratchpad)"] + state --> result["CalculationResult\n(frozen)"] +``` + +### The three actors + +1. **`core_parse()`** — the engine. It iterates over lines using a `PeekableIterator`, and for each line, asks every registered `BlockParser` whether it handles that line. + +2. **`BlockParser`** — a protocol with two methods: + - `matches(line, state) -> bool` — a fast, stateless check. Does this parser handle this line? Must also check completion flags to avoid parsing the same block twice. + - `parse(iterator, start_line, state) -> None` — called when `matches` returns `True`. Consumes lines from the iterator and writes results into `state`. + +3. **`ParseState`** — the single mutable scratchpad that all parsers write into. When parsing is complete, it's converted to an immutable `CalculationResult`. + +### Why this matters + +Adding support for a new output block — say, a new charge method or a new excited-state property — requires writing one new `BlockParser` class and registering it. Nothing else changes. The core engine, the state object, and every other parser are untouched. + +See [Writing Block Parsers](developers/parsers.md) for the full recipe. + +## The Fluent Input API + +`CalculationInput` is CalcFlow's answer to the question: *how do you specify a quantum chemistry calculation without learning program-specific keywords?* + +The answer is a composable, self-validating Python object. You describe *what* you want — method, basis, task, solvation, excited states — and the program-specific builders translate that spec into valid input syntax. + +```python +calc = ( + CalculationInput( + charge=0, + spin_multiplicity=1, + task="geometry", + level_of_theory="PBE0", + basis_set="def2-TZVP", + ) + .set_optimization(calc_hess_initial=True) + .run_frequency_after_opt() + .set_solvation(model="cpcm", solvent="acetonitrile") + .set_cores(16) +) + +qchem = calc.export("qchem", geom) +orca = calc.export("orca", geom) +``` + +Validation happens at construction time — not at export time. If you pass incompatible settings (e.g. MOM without `unrestricted=True`), you get an error immediately, not when the job is submitted. + +## Zero Dependencies + +The core `calcflow` package has **no runtime dependencies**. No numpy, no pandas, no pydantic. This is a deliberate constraint. + +The rationale: a library for I/O should be installable in any environment, including minimal HPC environments, CI containers, and scripts that run alongside QC programs. A hard numpy dependency would break this. + +The `postprocess` module (spectrum broadening) requires numpy, but it's gated behind an optional extra: + +```bash +pip install "calcflow[numpy]" +``` + +## Self-Documenting API + +CalcFlow is designed to be usable by LLMs and by users who haven't read the full documentation. Both `CalculationInput` and `CalculationResult` expose a runtime API reference generated from the source code: + +```python +# Full setter catalogue with signatures and docstrings +print(CalculationInput.get_api_docs()) + +# Structural field map with types and units +from calcflow.common.results import CalculationResult +print(CalculationResult.get_api_docs()) +``` + +These methods use introspection — they always reflect the actual current state of the code, not a separately maintained documentation string. + +## Schema Versioning + +Serialized `CalculationInput` and `CalculationResult` objects include two version fields: + +- **`calcflow_version`** — the semver string of the package that produced the dump. For provenance only. +- **`schema_version`** — an integer that tracks structural compatibility. This is the one that drives migration logic. + +When you call `CalculationResult.from_dict(data)`, CalcFlow checks `data["schema_version"]` and runs sequential migration steps to bring old dumps up to the current schema. This means a result serialized two versions ago is still loadable today. + +See [Schema Versioning](developers/schema-versioning.md) for the full rules on when to bump and how to write migrations. diff --git a/docs/developers/parsers.md b/docs/developers/parsers.md new file mode 100644 index 0000000..e9ac526 --- /dev/null +++ b/docs/developers/parsers.md @@ -0,0 +1,275 @@ +--- +icon: lucide/code-xml +--- + +# Writing Block Parsers + +CalcFlow's parser architecture is designed to be surgical: adding support for a new output block requires writing one new class and registering it. Nothing else changes. + +## Architecture Overview + +```mermaid +flowchart LR + text["output text"] --> iter["PeekableIterator"] + iter --> core["core_parse()"] + core -->|"for each line"| registry["BlockParser registry\n(ordered list)"] + registry -->|"matches?"| parser["BlockParser"] + parser -->|"parse()"| state["ParseState"] + state -->|"to_calculation_result()"| result["CalculationResult"] +``` + +The three actors: + +1. **`core_parse(text, registry)`** — iterates lines, checks each against the registry, dispatches to the first matching parser. +2. **`BlockParser`** — a protocol with two methods: `matches()` and `parse()`. +3. **`ParseState`** — a single mutable scratchpad; converted to a frozen `CalculationResult` when parsing is complete. + +## The BlockParser Protocol + +```python +from calcflow.io.core import BlockParser +from calcflow.io.peekable import PeekableIterator +from calcflow.io.state import ParseState + +class MyParser: + def matches(self, line: str, state: ParseState) -> bool: + ... + + def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: + ... +``` + +### `matches(line, state) -> bool` + +- **Must be fast** — called on every line. +- **Must not mutate state** — side effects in `matches()` will cause subtle bugs. +- **Must check completion flags** — if your parser sets `state.parsed_scf = True`, check that flag first to avoid parsing the same block twice. + +```python +def matches(self, line: str, state: ParseState) -> bool: + return not state.parsed_mulliken and "Mulliken charges:" in line +``` + +### `parse(iterator, start_line, state) -> None` + +- Receives the `PeekableIterator` positioned *after* the matching line was consumed. +- `start_line` is the line that matched — useful if it contains data you need. +- Consumes lines from the iterator and writes results into `state`. +- **Must set its completion flag** at the end (e.g. `state.parsed_mulliken = True`). +- **Must use `iterator.push_back(line)` if it reads past the block end** — never buffer lines in state. + +## PeekableIterator + +`PeekableIterator` wraps a line iterator with look-ahead and push-back: + +```python +from calcflow.io.peekable import PeekableIterator + +# peek at the next line without consuming it +line = iterator.peek() + +# push a line back (use when you've over-read) +iterator.push_back(line) + +# consume lines while a condition holds +lines = iterator.take_while(lambda l: l.strip() != "") + +# consume lines until a sentinel is found (sentinel is NOT consumed) +lines = iterator.take_until(lambda l: "END" in l) + +# skip N lines +iterator.skip(2) +``` + +### The over-reading pattern + +Some blocks end when a new block begins, meaning you detect the end by reading the first line of the *next* block. Push that line back so the core engine can dispatch it: + +```python +def parse(self, iterator, start_line, state): + for line in iterator: + if self._is_end_sentinel(line): + iterator.push_back(line) # let the engine handle this line + break + # process line... + state.parsed_this_block = True +``` + +## ParseState + +`ParseState` (`calcflow/io/state.py`) is the mutable scratchpad during parsing. It has: + +- **Result fields**: `final_energy`, `scf`, `orbitals`, `tddft`, etc. — written by parsers. +- **Control flags**: `parsed_scf`, `parsed_tddft_tda`, `parsed_mulliken`, etc. — set by parsers to prevent duplicate parsing, checked in `matches()`. +- **`to_calculation_result()`** — converts the mutable state to a frozen `CalculationResult`. + +Look at the existing `ParseState` fields before adding new ones to understand the established naming conventions. + +## Step-by-Step: Adding a New Parser + +### 1. Identify the block markers + +Find the distinctive start and end lines in the output file. Look for: + +- A unique header string that only appears once per calculation +- Whether the block ends with a blank line, a specific sentinel, or a new block header + +### 2. Create a model in `common/models.py` + +```python +# calcflow/common/models.py +from dataclasses import dataclass +from calcflow.common.models import FrozenModel + +@dataclass(frozen=True) +class MyNewResult(FrozenModel): + value: float + unit: str +``` + +If the result belongs on `CalculationResult`, add it as a field there too. + +### 3. Add a field and flag to `ParseState` + +```python +# calcflow/io/state.py +class ParseState: + # ... existing fields ... + my_new_result: MyNewResult | None = None + parsed_my_new_block: bool = False +``` + +### 4. Implement the BlockParser + +Create a new file (e.g. `calcflow/io/qchem/blocks/my_block.py`): + +```python +from calcflow.io.core import BlockParser +from calcflow.io.peekable import PeekableIterator +from calcflow.io.state import ParseState +from calcflow.common.models import MyNewResult +from calcflow.common.exceptions import ParsingError + + +class MyBlockParser: + def matches(self, line: str, state: ParseState) -> bool: + return not state.parsed_my_new_block and "My Block Header" in line + + def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: + value = None + for line in iterator: + if not line.strip(): + break # blank line ends the block + if "Value:" in line: + try: + value = float(line.split()[-1]) + except ValueError as e: + raise ParsingError(f"Could not parse value: {line!r}") from e + + if value is None: + raise ParsingError("My block was matched but no value was found") + + state.my_new_result = MyNewResult(value=value, unit="Hartree") + state.parsed_my_new_block = True +``` + +### 5. Register the parser + +Add an instance to the parser registry in the program's `parser.py`: + +```python +# calcflow/io/qchem/parser.py +from calcflow.io.qchem.blocks.my_block import MyBlockParser + +PARSER_REGISTRY = [ + # ... existing parsers ... + MyBlockParser(), +] +``` + +**Order matters.** Place parsers earlier in the list if they need to run before others. In practice, termination and metadata parsers go last; block-specific parsers go roughly in the order the blocks appear in the output. + +### 6. Wire the result into `to_calculation_result()` + +In `ParseState.to_calculation_result()`, include the new field: + +```python +return CalculationResult( + # ... existing fields ... + my_new_result=self.my_new_result, +) +``` + +And add the corresponding field to `CalculationResult` in `calcflow/common/results.py`. + +### 7. Write a contract test + +A contract test verifies that the parser produces the correct *structure* from a minimal fixture — not numerical precision, just field presence and types: + +```python +# tests/io/qchem/qchem_parsers/test_qchem_my_block.py +import pytest +from calcflow import parse_qchem_output + +FIXTURE = """\ + My Block Header + Value: -1.234567890 + +""" + +@pytest.mark.contract +def test_my_block_parses_value(): + result = parse_qchem_output(FIXTURE) + assert result.my_new_result is not None + assert isinstance(result.my_new_result.value, float) + +@pytest.mark.contract +def test_my_block_value_is_negative(): + result = parse_qchem_output(FIXTURE) + assert result.my_new_result.value < 0 +``` + +Keep the fixture minimal — just enough lines to trigger the parser and provide the data it needs. + +## Worked Example: MullikenChargesParser + +The Mulliken parser in `calcflow/io/qchem/blocks/charges.py` illustrates the standard pattern: + +```python +class MullikenParser: + def matches(self, line: str, state: ParseState) -> bool: + return not state.parsed_mulliken and "Mulliken charges:" in line # (1)! + + def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: + charges: dict[int, float] = {} + spins: dict[int, float] = {} + + # skip the " Atom Charge Spin" header line + next(iterator) + + for line in iterator.take_until(lambda l: "Sum of Mulliken charges" in l): # (2)! + parts = line.split() + if len(parts) >= 3: + atom_idx = int(parts[0]) - 1 # convert to 0-based # (3)! + charges[atom_idx] = float(parts[2]) + if len(parts) >= 4: + spins[atom_idx] = float(parts[3]) + + state.atomic_charges.append( + AtomicCharges(method="Mulliken", charges=charges, spins=spins or None) + ) + state.parsed_mulliken = True # (4)! +``` + +1. Guard with `not state.parsed_mulliken` — prevents re-parsing if the block appears multiple times. +2. `take_until` consumes lines up to (but not including) the summary line, so the summary line remains in the iterator. +3. Q-Chem uses 1-based atom numbering — always subtract 1 when writing to state. +4. Set the completion flag unconditionally, even if no charges were found — this prevents infinite loops. + +## Common Pitfalls + +- **Forgetting the completion flag**: the parser will re-run every time it sees the header line. +- **Mutating state in `matches()`**: creates order-dependent bugs that are hard to track down. +- **Not pushing back over-read lines**: the next block's header will be silently swallowed. +- **Using `buffered_line` on state**: the correct mechanism is `iterator.push_back()`. The state object has no `buffered_line` field. +- **Raising bare `Exception`**: always raise `ParsingError` from `calcflow.common.exceptions` so callers can catch CalcFlow-specific errors. diff --git a/docs/developers/schema-versioning.md b/docs/developers/schema-versioning.md new file mode 100644 index 0000000..df14685 --- /dev/null +++ b/docs/developers/schema-versioning.md @@ -0,0 +1,138 @@ +--- +icon: lucide/git-branch +--- + +# Schema Versioning + +CalcFlow serializes `CalculationInput` and `CalculationResult` to JSON. Schema versioning ensures that JSON produced by an older version of CalcFlow can still be loaded by a newer version. + +## Two Version Fields + +Every serialized object includes two version fields: + +```json +{ + "calcflow_version": "0.5.0", + "schema_version": 2, + ... +} +``` + +| Field | Type | Purpose | +| :--- | :--- | :--- | +| `calcflow_version` | string (semver) | Provenance — which version of the library produced this dump. Never used in loading logic. | +| `schema_version` | integer | Structural compatibility — drives migration logic on load. | + +**These are independent.** A patch release that fixes a parser bug does not touch `schema_version`. A minor release that renames a field does. + +## Where the Constants Live + +```python +# calcflow/common/results.py +RESULT_SCHEMA_VERSION: int = 2 + +# calcflow/common/input.py +INPUT_SCHEMA_VERSION: int = 1 +``` + +Each model tracks its own version independently. A change to `CalculationResult`'s schema does not require bumping `INPUT_SCHEMA_VERSION`. + +## When to Bump `schema_version` + +**Bump** when an old dump would fail to load correctly with the new code: + +- Renaming a field +- Removing a field +- Restructuring a nested object (e.g. changing a flat dict to a nested object) +- Changing a field's type (e.g. `float` to `str`) +- Adding a **required** field (one with no default) + +**Do not bump** when old dumps remain loadable without changes: + +- Adding an optional field with a default value +- Fixing a parser bug (the schema structure is unchanged) +- Changing a default value +- Renaming an internal variable that doesn't appear in serialized output + +!!! tip "The rule of thumb" + If `from_dict(old_dump)` would raise an error or silently produce wrong data, bump `schema_version`. If it would work correctly, don't. + +## Writing a Migration + +Migrations live in the `_migrate()` class method of each model. They are sequential: v1→v2, then v2→v3, and so on. Never skip a step. + +```python +# calcflow/common/results.py + +RESULT_SCHEMA_VERSION = 3 + +@classmethod +def _migrate(cls, data: dict) -> dict: + version = data.get("schema_version", 1) + + if version < 2: + # v1 -> v2: geometry fields changed from bare atom lists to Geometry objects + for key in ("input_geometry", "final_geometry"): + if isinstance(data.get(key), list): + data[key] = {"comment": "", "atoms": data[key]} + version = 2 + + if version < 3: + # v2 -> v3: renamed "atomic_charges" list items from "method_name" to "method" + for charge_dict in data.get("atomic_charges", []): + if "method_name" in charge_dict: + charge_dict["method"] = charge_dict.pop("method_name") + version = 3 + + data["schema_version"] = version + return data +``` + +The migration is called by `from_dict()` before deserializing: + +```python +@classmethod +def from_dict(cls, data: dict) -> "CalculationResult": + data = cls._migrate(dict(data)) # shallow copy before mutating + # ... deserialize fields ... +``` + +## Detecting Forward Incompatibility + +If a dump has a `schema_version` higher than the current `RESULT_SCHEMA_VERSION`, the code cannot safely load it — it was produced by a newer version of CalcFlow that added fields this version doesn't know about. + +```python +stored_version = data.get("schema_version", 1) +if stored_version > RESULT_SCHEMA_VERSION: + raise ConfigurationError( + f"Cannot load result: schema version {stored_version} is newer than " + f"this version of CalcFlow supports ({RESULT_SCHEMA_VERSION}). " + f"Please upgrade calcflow." + ) +``` + +## Example: Adding an Optional Field (No Bump Needed) + +Adding `timing: TimingResults | None = None` to `CalculationResult`: + +1. Add the field with a default of `None`. +2. Old dumps that don't include `timing` will deserialize with `timing=None` — correct behavior. +3. No migration step needed. +4. **Do not bump `schema_version`.** + +## Example: Renaming a Field (Bump Required) + +Renaming `CalculationResult.scf_results` to `CalculationResult.scf`: + +1. Bump `RESULT_SCHEMA_VERSION` from 2 to 3. +2. Add a migration step in `_migrate()`: + + ```python + if version < 3: + if "scf_results" in data: + data["scf"] = data.pop("scf_results") + version = 3 + ``` + +3. Update `to_dict()` to use the new key `"scf"`. +4. Old dumps with `"scf_results"` will be transparently migrated. diff --git a/docs/developers/testing.md b/docs/developers/testing.md new file mode 100644 index 0000000..1a5374a --- /dev/null +++ b/docs/developers/testing.md @@ -0,0 +1,237 @@ +--- +icon: lucide/flask-conical +--- + +# Testing + +CalcFlow uses a four-level testing hierarchy aligned with the four sources of complexity in the codebase: pure logic, parser contracts, component integration, and numerical regression. + +## The Four Levels + +| Marker | Scope | Speed | Asserts | +| :--- | :--- | :--- | :--- | +| `unit` | Single function or method | <1 ms | Correctness of pure logic | +| `contract` | Single `BlockParser` + minimal fixture | <10 ms | Data *structure* (not precision) | +| `integration` | Multiple components, real output files | 10–500 ms | Functional correctness | +| `regression` | Same scope as integration | Slow | Exact numerical values | + +### Unit tests + +Test individual functions in isolation. No file I/O, no parsers, no external state. Input and expected output are constructed inline. + +```python +@pytest.mark.unit +def test_atom_validates_symbol(): + from calcflow.common.models import Atom + with pytest.raises(ValueError): + Atom(symbol="Xx", x=0.0, y=0.0, z=0.0) +``` + +### Contract tests + +Verify that a `BlockParser` produces the correct data *structure* from a minimal fixture. The fixture is the smallest possible snippet of output text that exercises the parser — not a real output file. + +```python +MULLIKEN_FIXTURE = """\ + Mulliken charges: + 1 + 1 O -0.8476 + 2 H 0.4238 + 3 H 0.4238 + Sum of Mulliken charges = 0.00000 +""" + +@pytest.mark.contract +def test_mulliken_charges_parsed(): + result = parse_qchem_output(MULLIKEN_FIXTURE) + mulliken = result.get_charges("Mulliken") + assert mulliken is not None + assert len(mulliken.charges) == 3 + assert isinstance(mulliken.charges[0], float) +``` + +Contract tests are the workhorses of the test suite. Write one for every `BlockParser` you add. + +### Integration tests + +Test the full parse pipeline against real output files from `tests/testing_data/`. Assert on functional correctness — the right fields are populated, the right states are found — but do not assert on exact floating-point values. + +```python +@pytest.mark.integration +def test_qchem_tddft_sp_parses_excited_states(testing_data_path): + text = (testing_data_path / "qchem" / "h2o" / "tddft_sp.out").read_text() + result = parse_qchem_output(text) + assert result.termination_status == "NORMAL" + assert result.tddft is not None + assert len(result.tddft.tddft_states) > 0 + assert result.tddft.tddft_states[0].excitation_energy_ev > 0 +``` + +### Regression tests + +Same scope as integration, but assert on exact values using `pytest.approx`. These tests catch unintended numerical changes — a parser refactor that accidentally changes a parsed energy value. + +```python +@pytest.mark.regression +def test_qchem_tddft_sp_first_state_energy(testing_data_path): + text = (testing_data_path / "qchem" / "h2o" / "tddft_sp.out").read_text() + result = parse_qchem_output(text) + state = result.tddft.tddft_states[0] + assert state.excitation_energy_ev == pytest.approx(7.654, abs=1e-3) + assert state.oscillator_strength == pytest.approx(0.0821, abs=1e-4) +``` + +## Directory Structure + +The test directory mirrors the source layout: + +``` +tests/ + conftest.py # testing_data_path fixture (shared) + smoke_test.py # end-to-end smoke tests + + common/ + test_api_docs.py + test_input_serialization.py + test_results_serialization.py + + geometry/ + test_static.py + test_topology.py + test_trajectory.py + test_annotated.py + + io/ + test_peekable.py + orca/ + orca_builders/ + orca_parsers/ + qchem/ + qchem_builders/ + qchem_parsers/ + tddft/ + adc/ + + postprocess/ + test_postprocess.py + + testing_data/ + geometries/ # .xyz files + orca/h2o/ # .out files + qchem/h2o/ # .out files +``` + +Every test file lives in a directory that mirrors its corresponding source module. Parser tests live under `io//qchem_parsers/`, builder tests under `io//_builders/`. + +## Fixtures + +### `testing_data_path` + +Defined in `tests/conftest.py`. Returns a `Path` to `tests/testing_data/`: + +```python +@pytest.fixture +def testing_data_path(): + return Path(__file__).parent / "testing_data" +``` + +Use it in integration and regression tests: + +```python +def test_something(testing_data_path): + text = (testing_data_path / "qchem" / "h2o" / "sp.out").read_text() +``` + +### Shared builder fixtures + +For builder tests, define common fixtures in a `conftest.py` at the appropriate level: + +```python +@pytest.fixture +def water_geometry(): + return Geometry.from_lines([ + "O 0.000000 0.000000 0.119748", + "H 0.000000 0.756950 -0.478993", + "H 0.000000 -0.756950 -0.478993", + ], source="water") + +@pytest.fixture +def minimal_spec(): + return CalculationInput( + charge=0, spin_multiplicity=1, + task="energy", level_of_theory="B3LYP", basis_set="def2-SVP" + ) +``` + +## Testing Input Builders + +Builder tests require a different strategy from parser tests. The output is structured text — exact string comparison is brittle because whitespace and keyword ordering may change without affecting correctness. + +**The correct approach**: assert on *semantics*, not on exact text. + +=== "Do this" + + ```python + @pytest.mark.contract + def test_tddft_keywords_present(minimal_spec, water_geometry): + calc = minimal_spec.set_tddft(nroots=5, singlets=True, triplets=False) + output = calc.export("qchem", water_geometry) + + assert "cis_n_roots" in output.lower() + assert "5" in output + assert "$molecule" in output + assert "$end" in output + ``` + +=== "Don't do this" + + ```python + # Brittle — breaks on any formatting change + @pytest.mark.contract + def test_tddft_exact_output(minimal_spec, water_geometry): + calc = minimal_spec.set_tddft(nroots=5, singlets=True, triplets=False) + output = calc.export("qchem", water_geometry) + assert output == EXPECTED_EXACT_STRING + ``` + +For regression tests on builders, the most robust approach is **round-tripping**: generate the input, parse it back into structured data, and assert on the parsed values. + +## Running Tests + +```bash +# All tests +uv run pytest + +# By marker +uv run pytest -m unit +uv run pytest -m contract +uv run pytest -m "integration or regression" + +# Single file +uv run pytest tests/io/qchem/qchem_parsers/test_qchem_sp_scf.py -v + +# Single test +uv run pytest tests/io/qchem/qchem_parsers/test_qchem_sp_scf.py::test_scf_converged -v + +# With coverage +uv run pytest --cov=calcflow --cov-report=term-missing +``` + +## The Testing Pyramid + +Aim for this shape: + +``` + ▲ + / \ + / \ regression (few, expensive) + /─────\ + / \ integration (moderate) + /─────────\ + / \ contract (many, fast) + /─────────────\ + / \ unit (most, fastest) +/─────────────────\ +``` + +Most of the safety net comes from unit and contract tests. Integration and regression tests are the final check that everything works end-to-end with real data. diff --git a/docs/guides/agentic.md b/docs/guides/agentic.md new file mode 100644 index 0000000..61f1f19 --- /dev/null +++ b/docs/guides/agentic.md @@ -0,0 +1,214 @@ +--- +icon: lucide/bot +--- + +# Agentic & LLM Workflows + +CalcFlow is designed to drop into agentic workflows. Zero dependencies, a self-documenting API, and `uv`-based instant execution mean an LLM agent can parse and analyze quantum chemistry output files without any pre-existing environment setup. + +## Why It Works in Agentic Contexts + +Three properties make CalcFlow unusually well-suited for tool use: + +1. **Zero dependencies** — `uv run --with calcflow` installs and runs in seconds, in any environment, without version conflicts. +2. **Self-documenting API** — call `CalculationResult.get_schema()` or `CalculationInput.get_quick_ref()` at runtime to get a complete, always-current field reference. The agent doesn't need to read source code. +3. **JSON-first results** — every parsed result serializes to JSON via `.to_json()`, making it trivial to pass structured data between agent steps. + +## The CalcFlow OpenCode Agent + +The repo ships a purpose-built [opencode](https://opencode.ai) agent at [`calcflow.md`](https://github.com/ischemist/project-prometheus/blob/master/calcflow.md) in the project root. Install it with a single command: + +```bash +curl -fsSL https://raw.githubusercontent.com/ischemist/project-prometheus/master/calcflow.md \ + -o ~/.config/opencode/agents/calcflow.md +``` + +Once installed, open opencode in any directory containing `.out` files or `.xyz` geometries and the CalcFlow agent is available. + +### What the agent does + +The agent is configured to: + +- **Never read files directly** — all file inspection happens through `calcflow` via the shell tool (`cat`, `grep`, `head` are disabled). This keeps context tight and avoids raw-text hallucinations. +- **Use `uv run --with calcflow`** for every operation — no assumptions about the local environment. +- **Navigate the API incrementally** — quick ref first, full method docs only when needed, never dumping the entire schema upfront. +- **Speak in one-liners** — short, focused scripts that do one thing. + +## `uv run --with calcflow` + +The universal entry point — no installation, no virtual environment management: + +```bash +# Parse an output file and print key results +uv run --with calcflow python -c " +from calcflow.io.qchem import parse_qchem_output +from pathlib import Path +r = parse_qchem_output(Path('calc.out').read_text()) +print('status:', r.termination_status) +print('energy:', r.final_energy, 'Hartree') +if r.tddft and r.tddft.tda_states: + for s in r.tddft.tda_states[:5]: + print(f'S{s.state_number}: {s.excitation_energy_ev:.3f} eV f={s.oscillator_strength}') +" +``` + +If CalcFlow is already installed in the project's virtualenv, drop the `--with calcflow`: + +```bash +uv run python -c "..." +``` + +## API Navigation + +The agent (and you) can explore the API at runtime — without reading source code. + +**Quick reference (usually enough):** +```bash +uv run --with calcflow python -c " +from calcflow.common.input import CalculationInput +print(CalculationInput.get_quick_ref()) +" +``` + +**Full docs for one specific method:** +```bash +uv run --with calcflow python -c " +from calcflow.common.input import CalculationInput +print(CalculationInput.get_method_docs('set_tddft')) +" +``` + +**Result field schema (for parsing questions):** +```bash +uv run --with calcflow python -c " +from calcflow.common.results import CalculationResult +print(CalculationResult.get_schema()) +" +``` + +!!! tip "One targeted call beats one 600-line blob" + Prefer `get_quick_ref()` or `get_method_docs('set_tddft')` over `get_api_docs()` unless you need the full catalogue. Agents work better with focused context. + +## Common One-Liners + +**Parse ORCA:** +```bash +uv run --with calcflow python -c " +from calcflow.io.orca import parse_orca_output +from pathlib import Path +r = parse_orca_output(Path('calc.out').read_text()) +print('status:', r.termination_status, '| energy:', r.final_energy) +" +``` + +**Parse and save to JSON:** +```bash +uv run --with calcflow python -c " +from calcflow.io.qchem import parse_qchem_output +from pathlib import Path +r = parse_qchem_output(Path('calc.out').read_text()) +Path('result.json').write_text(r.to_json()) +print('saved result.json') +" +``` + +**Load a saved JSON result:** +```bash +uv run --with calcflow python -c " +from calcflow.common.results import CalculationResult +from pathlib import Path +r = CalculationResult.from_json(Path('result.json').read_text()) +print('energy:', r.final_energy) +" +``` + +**Load a gzip-compressed result (`.json.gz`):** +```bash +uv run --with calcflow python -c " +import gzip, json +from calcflow.common.results import CalculationResult +from pathlib import Path +raw = json.loads(gzip.decompress(Path('result.json.gz').read_bytes())) +r = CalculationResult.from_dict(raw) +print('status:', r.termination_status, '| energy:', r.final_energy) +" +``` + +**Multi-job output (MOM, XAS):** +```bash +uv run --with calcflow python -c " +from calcflow.io.qchem import parse_qchem_multi_job_output +from pathlib import Path +jobs = parse_qchem_multi_job_output(Path('calc.out').read_text()) +for i, r in enumerate(jobs): + print(f'job {i+1}: {r.termination_status} energy={r.final_energy}') +" +``` + +**Build and export a Q-Chem input:** +```bash +uv run --with calcflow python -c " +from calcflow import CalculationInput, Geometry +from pathlib import Path +geom = Geometry.from_xyz_file('molecule.xyz') +calc = ( + CalculationInput(charge=0, spin_multiplicity=1, task='energy', + level_of_theory='wB97X-D3', basis_set='def2-tzvp', n_cores=16) + .set_tddft(nroots=10) + .set_solvation('smd', 'water') +) +Path('calc.in').write_text(calc.export('qchem', geom)) +Path('calc_spec.json').write_text(calc.to_json()) +print('wrote calc.in and calc_spec.json') +" +``` + +**Spatial analysis — excited-state hole localization:** +```bash +uv run --with calcflow python -c " +from calcflow import AnnotatedGeometry, build_bond_graph, find_aromatic_atoms +from calcflow.io.qchem import parse_qchem_output +from pathlib import Path +r = parse_qchem_output(Path('calc.out').read_text()) +ag = AnnotatedGeometry.from_result(r) +graph = build_bond_graph(ag.geometry.atoms) +aromatic = find_aromatic_atoms(ag.geometry.atoms, graph) +if s1 := ag.get_unrelaxed_state(1): + for atom in s1: + if atom.hole_population and atom.hole_population > 0.5: + print(f'S1 hole localized on {atom.symbol}{atom.index}') +" +``` + +## Multi-Step Workflows + +CalcFlow's JSON roundtrip makes it natural to chain agent steps without re-parsing: + +``` +parse gs_sp.out → result.json + ↓ +agent reads result.json, decides on excited-state geometry optimization + ↓ +generate s1_opt.inp + s1_opt_spec.json + ↓ +submit to cluster, wait + ↓ +parse s1_opt.out → s1_result.json + ↓ +agent analyzes S1 geometry, builds emission calculation + ↓ +... +``` + +Each step stores its output as JSON. The agent can pick up at any point — even across sessions — by loading a previous `result.json` instead of re-parsing the raw output. + +## Units and Conventions + +| Quantity | Unit | +| :--- | :--- | +| Energies | **Hartree** (fields ending `_ev` are eV; `_kcal_mol` are kcal/mol) | +| Distances, exciton sizes | **Ångström** | +| Dipole / transition moments | **Debye** | +| Time | **seconds** | +| Atom indices | **0-based** | +| TDDFT state numbers | **1-based** | diff --git a/docs/guides/geometry.md b/docs/guides/geometry.md new file mode 100644 index 0000000..c56264f --- /dev/null +++ b/docs/guides/geometry.md @@ -0,0 +1,221 @@ +--- +icon: lucide/atom +--- + +# Geometry and Topology + +CalcFlow provides a layered geometry API: a simple `Geometry` for XYZ data, a `Trajectory` for multi-frame sequences, and an `AnnotatedGeometry` that co-locates atom coordinates with calculation results. + +## Geometry + +`Geometry` is an immutable collection of atoms with a comment line — the canonical representation of a molecular structure. + +### Loading from file + +```python +from calcflow import Geometry + +geom = Geometry.from_xyz_file("molecule.xyz") + +print(geom.num_atoms) # int +print(geom.unique_elements) # set[str] — e.g. {"C", "H", "O"} +``` + +### Loading from strings + +```python +lines = [ + "O 0.000000 0.000000 0.119748", + "H 0.000000 0.756950 -0.478993", + "H 0.000000 -0.756950 -0.478993", +] +geom = Geometry.from_lines(lines, source="water") +``` + +### Accessing atoms + +```python +for atom in geom.atoms: + print(f"{atom.symbol:2s} {atom.x:10.6f} {atom.y:10.6f} {atom.z:10.6f}") +``` + +`Atom` fields: `symbol` (str), `x`, `y`, `z` (float). + +### Exporting + +```python +xyz_str = geom.to_xyz_str() +geom.to_xyz_file("output.xyz") + +# Just the atom coordinate block (no count line or comment) +block = geom.get_coordinate_block() +``` + +### Energy from comment line + +ORCA writes the energy into the comment line of optimized geometry XYZ files. CalcFlow parses this automatically: + +```python +geom = Geometry.from_xyz_file("orca_opt.xyz") +print(geom.energy) # float | None +``` + +## Trajectory + +`Trajectory` wraps a sequence of `Geometry` frames from a multi-frame XYZ file (e.g. an optimization trajectory). + +```python +from calcflow import Trajectory + +traj = Trajectory.from_xyz_file("optimization.xyz") + +print(len(traj)) # number of frames +first = traj[0] # Geometry +last = traj[-1] # Geometry + +for frame in traj: + print(frame.energy) # energy per frame if present in comment line +``` + +All frames in a `Trajectory` must have the same number and order of atoms — this is validated on construction. + +## AnnotatedGeometry + +`AnnotatedGeometry` is a read-only view that pairs a `Geometry` with a `CalculationResult`. It gives atom-centric access to charges, spin densities, and excited-state populations without duplicating data. + +```python +from calcflow import parse_qchem_output, AnnotatedGeometry + +result = parse_qchem_output(open("calculation.out").read()) +ag = AnnotatedGeometry.from_result(result) +``` + +### Per-atom ground-state properties + +```python +for i, atom in enumerate(ag): + print(f"Atom {i} ({atom.symbol}):") + print(f" Mulliken charge: {atom.charges.get('Mulliken'):+.4f}") + print(f" Hirshfeld charge: {atom.charges.get('Hirshfeld'):+.4f}") + if atom.spins: + print(f" Mulliken spin: {atom.spins.get('Mulliken'):+.4f}") +``` + +`atom.charges` and `atom.spins` are dicts keyed by method name (`"Mulliken"`, `"Hirshfeld"`, `"CM5"`, `"Loewdin"`). + +### Using the input geometry + +By default `AnnotatedGeometry.from_result()` uses the final (optimized) geometry. To use the input geometry instead: + +```python +ag = AnnotatedGeometry.from_result(result, use_input_geometry=True) +``` + +### Excited-state views + +For TDDFT results, you can get a per-state view that exposes hole/electron populations and transition charges per atom: + +```python +sv = ag.get_transition_state(state_number=1) # AnnotatedStateView | None +if sv: + for i, atom in enumerate(sv): + print(f"Atom {i} ({atom.symbol}):") + print(f" hole population: {atom.hole_population:.4f}") + print(f" electron population: {atom.electron_population:.4f}") + print(f" transition charge: {atom.transition_charge:.4f}") +``` + +For unrelaxed difference density matrix data: + +```python +sv = ag.get_unrelaxed_state(state_number=1) +if sv: + for i, atom in enumerate(sv): + print(f"Atom {i}: Δq = {atom.delta_charge:.4f}") +``` + +For ADC states: + +```python +sv = ag.get_adc_state(state_number=1) +``` + +## AnnotatedTrajectory + +`AnnotatedTrajectory` is a sequence of `AnnotatedGeometry` frames — useful for analyzing a geometry optimization in terms of charges or orbital populations at each step. + +```python +from calcflow import AnnotatedTrajectory + +# Build from a list of CalculationResult objects (one per frame) +at = AnnotatedTrajectory.from_results(results) + +for frame in at: + atom0 = frame[0] + print(atom0.charges.get("Mulliken")) +``` + +## Topology + +CalcFlow includes bond detection, molecule identification, and aromaticity analysis based on covalent radii from Pyykko (2009). + +```python +from calcflow import build_bond_graph, detect_molecules, classify_all_bonds, find_aromatic_atoms + +atoms = geom.atoms +``` + +### Bond graph + +```python +bond_graph = build_bond_graph(atoms, tolerance=0.4) +# bond_graph[i] = list of atom indices bonded to atom i +``` + +### Molecule detection + +Useful for multi-component systems (e.g. solute + counter-ions): + +```python +molecules = detect_molecules(atoms, tolerance=0.4) +# molecules = list of frozensets, each containing the atom indices of one molecule + +for i, mol in enumerate(molecules): + symbols = [atoms[j].symbol for j in sorted(mol)] + print(f"Fragment {i}: {''.join(symbols)}") +``` + +### Bond classification + +```python +bonds = classify_all_bonds(atoms, tolerance=0.4) +# bonds = dict mapping (i, j) tuple -> "single" | "double" | "triple" | "aromatic" + +for (i, j), order in bonds.items(): + print(f"Atoms {i}-{j}: {order}") +``` + +### Aromatic atoms + +```python +aromatic = find_aromatic_atoms(atoms, bond_graph) +# aromatic = set of atom indices participating in aromatic rings +``` + +## Element Data + +CalcFlow bundles a complete periodic table with atomic masses, van der Waals radii, and Pyykko covalent radii: + +```python +from calcflow.constants.ptable import ELEMENT_DATA + +carbon = ELEMENT_DATA["C"] +print(carbon.atomic_number) # 6 +print(carbon.atomic_mass) # 12.011 +print(carbon.atomic_radius_vdw_pm) # 170 +print(carbon.covalent_radius_single_pm) # 75 +print(carbon.covalent_radius_double_pm) # 67 +print(carbon.covalent_radius_triple_pm) # 60 +``` + +Covers all 118 elements. diff --git a/docs/guides/inputs.md b/docs/guides/inputs.md new file mode 100644 index 0000000..6600392 --- /dev/null +++ b/docs/guides/inputs.md @@ -0,0 +1,282 @@ +--- +icon: lucide/file-pen +--- + +# Building Calculation Inputs + +`CalculationInput` is CalcFlow's unified interface for specifying quantum chemistry calculations. You describe *what* you want — method, basis, task, solvation, excited states — and the program-specific builders translate that into valid input syntax for Q-Chem or ORCA. + +## Construction + +`CalculationInput` requires four fields: + +```python +from calcflow import CalculationInput + +calc = CalculationInput( + charge=0, + spin_multiplicity=1, + task="energy", # "energy" | "geometry" | "frequency" + level_of_theory="wB97X-D3", + basis_set="def2-TZVP", +) +``` + +Every other field is optional and defaults to a sensible value. Use the fluent setter methods to build up the spec incrementally. + +!!! tip "Fluent setters return new instances" + Every `.set_*()` method returns a **new** `CalculationInput`. The original is never modified. This means you can safely branch a base spec into multiple variants. + +## Level of Theory and Basis Set + +```python +calc = calc.set_level_of_theory("PBE0") +calc = calc.set_basis_set("def2-TZVP") +``` + +### Element-specific basis sets + +Pass a dict to assign different basis sets to different elements (Q-Chem only): + +```python +calc = calc.set_basis({"C": "def2-TZVP", "H": "def2-SVP", "O": "def2-TZVP"}) +``` + +## Task Types + +| Task | Description | +| :--- | :--- | +| `"energy"` | Single-point energy | +| `"geometry"` | Geometry optimization | +| `"frequency"` | Harmonic frequency analysis | + +```python +calc = calc.set_task("geometry") +``` + +### Geometry optimization settings + +```python +calc = calc.set_optimization( + calc_hess_initial=True, # compute Hessian at start + recalc_hess_freq=5, # recalculate every N steps +) +``` + +### Frequency after optimization + +To append a frequency calculation immediately after a geometry optimization (a common workflow), use: + +```python +calc = calc.run_frequency_after_opt() +``` + +## Spin and Charge + +```python +calc = CalculationInput( + charge=-1, + spin_multiplicity=2, # 2 = doublet (one unpaired electron) + task="energy", + level_of_theory="B3LYP", + basis_set="def2-SVP", + unrestricted=True, # UKS/UHF — required for open-shell +) +``` + +or via setter: + +```python +calc = calc.set_unrestricted(True) +``` + +## Compute Resources + +```python +calc = ( + calc + .set_cores(16) + .set_memory_per_core(4000) # MB per core +) +``` + +For Q-Chem's `MEM_TOTAL` keyword (total memory rather than per-core): + +```python +calc = calc.set_total_memory(32000) # 32 GB total +``` + +## TDDFT + +```python +calc = calc.set_tddft( + nroots=15, # number of excited states to compute + singlets=True, + triplets=False, + use_tda=False, # True for TDA (Tamm-Dancoff approximation) +) +``` + +### Excited-state geometry optimization + +To optimize the geometry on a specific excited state: + +```python +calc = calc.set_tddft(nroots=5, singlets=True, triplets=False, state_to_optimize=1) +calc = calc.set_task("geometry") +``` + +## Solvation + +### Named solvent (SMD, CPCM) + +```python +calc = calc.set_solvation(model="smd", solvent="water") +calc = calc.set_solvation(model="cpcm", solvent="acetonitrile") +``` + +Supported models: `"smd"`, `"pcm"`, `"cpcm"`, `"isosvp"`, `"cosmo"`. + +Common solvent names: `"water"`, `"acetonitrile"`, `"methanol"`, `"ethanol"`, `"dichloromethane"`, `"thf"`, `"toluene"`, `"acetone"`, `"dmso"`. + +### Custom dielectric (PCM) + +```python +calc = calc.set_custom_solvation( + model="pcm", + dielectric=78.39, # static dielectric + optical_dielectric=1.78, # optical (high-frequency) dielectric +) +``` + +## Atomic Charge Analysis + +```python +calc = calc.set_charges( + mulliken=True, + hirshfeld=True, + cm5=True, # implies hirshfeld=True + hirshiter=False, # Hirshfeld-I (iterative) +) +``` + +!!! info "CM5 implies Hirshfeld" + Setting `cm5=True` automatically enables Hirshfeld charges, since CM5 is derived from the Hirshfeld population analysis. + +## SCF Convergence + +```python +calc = calc.set_scf( + algorithm="diis", # "diis" | "gdm" | "rca" | "rca_diis" + max_cycles=200, + convergence=8, # convergence threshold: 10^-N +) +``` + +## Maximum Overlap Method (MOM) + +MOM is used in Q-Chem to target specific electronic configurations — most commonly for ΔSCF excited-state calculations. + +```python +calc = CalculationInput( + charge=0, + spin_multiplicity=1, + task="energy", + level_of_theory="wB97X-D3", + basis_set="def2-TZVP", + unrestricted=True, +) + +calc = calc.set_mom( + transition="HOMO->LUMO", # symbolic transition + method="imom", # "mom" or "imom" +) +``` + +### Transition syntax + +| Syntax | Meaning | +| :--- | :--- | +| `"HOMO->LUMO"` | Promote from HOMO to LUMO | +| `"HOMO-1->LUMO+2"` | Relative orbital indices | +| `"HOMO->vac"` | Ionization (remove from HOMO) | +| `"HOMO_A->LUMO_A"` | Spin-specific (alpha) | +| `"HOMO_B->LUMO_B"` | Spin-specific (beta) | +| `"HOMO->LUMO; HOMO-1->LUMO+1"` | Multiple transitions (semicolon-separated) | + +### Two-job MOM structure + +MOM in Q-Chem uses a two-job structure: the first job computes the ground-state reference, and the second job runs with MOM constraints. CalcFlow generates both jobs from a single spec: + +```python +calc = calc.set_mom( + transition="HOMO->LUMO", + method="imom", + job2_charge=0, + job2_spin_multiplicity=1, +) +input_text = calc.export("qchem", geom) # contains @@@-separated jobs +``` + +## RI Approximations (ORCA) + +```python +calc = calc.enable_ri_for_orca( + approx="rijcosx", # "ri" | "rijcosx" | "rijonx" + aux_basis="def2/J", +) +``` + +## Program-Specific Escape Hatch + +For options not covered by the standard API, use `set_options()`: + +```python +calc = calc.set_options( + parallelism="mpi", # Q-Chem: use MPI instead of OpenMP + maxiter=500, # passed through to the program +) +``` + +These are forwarded to the builder as `program_options` and translated into program-specific keywords where known. + +## Exporting + +```python +from calcflow import Geometry + +geom = Geometry.from_xyz_file("molecule.xyz") + +qchem_input = calc.export("qchem", geom) +orca_input = calc.export("orca", geom) + +with open("molecule.inp", "w") as f: + f.write(qchem_input) +``` + +## Serialization + +Save a spec to JSON for reproducibility and reload it later: + +```python +# Save +with open("calc_spec.json", "w") as f: + f.write(calc.to_json()) + +# Reload +from calcflow.common.input import CalculationInput +calc2 = CalculationInput.from_json(open("calc_spec.json").read()) +``` + +## Runtime API Reference + +```python +# Full method catalogue with signatures and docstrings +print(CalculationInput.get_api_docs()) + +# Compact quick-reference +print(CalculationInput.get_quick_ref()) + +# Documentation for a specific method +print(CalculationInput.get_method_docs("set_tddft")) +``` diff --git a/docs/guides/parsing.md b/docs/guides/parsing.md new file mode 100644 index 0000000..26af13d --- /dev/null +++ b/docs/guides/parsing.md @@ -0,0 +1,304 @@ +--- +icon: lucide/file-search +--- + +# Parsing Output Files + +CalcFlow parses quantum chemistry output files into structured, immutable `CalculationResult` objects. This guide covers every result field, from basic energies to excited-state density matrices. + +## The Parsers + +=== "Q-Chem" + + ```python + from calcflow import parse_qchem_output + + result = parse_qchem_output(open("calculation.out").read()) + ``` + +=== "ORCA" + + ```python + from calcflow import parse_orca_output + + result = parse_orca_output(open("calculation.out").read()) + ``` + +Both functions return a `CalculationResult`. The raw output text is stored on `result.raw_output` but is excluded from JSON serialization. + +### Multi-job Q-Chem outputs + +Q-Chem can concatenate multiple jobs into a single output file (e.g. a two-job MOM calculation). Use the multi-job parser to get a list of results: + +```python +from calcflow import parse_qchem_multi_job_output + +results = parse_qchem_multi_job_output(open("mom_calc.out").read()) +gs_result = results[0] # ground state job +es_result = results[1] # excited state job +``` + +## Checking Termination + +Always check `termination_status` before trusting the results: + +```python +if result.termination_status != "NORMAL": + raise RuntimeError(f"Calculation failed: {result.termination_status}") +``` + +| Status | Meaning | +| :--- | :--- | +| `"NORMAL"` | Calculation completed successfully | +| `"ERROR"` | Program reported an error | +| `"UNKNOWN"` | Termination could not be determined from the output | + +## Energetics + +```python +result.final_energy # float | None — total energy in Hartree +result.nuclear_repulsion_energy # float | None — nuclear repulsion in Hartree +result.dispersion # DispersionCorrection | None +result.smd # SmdResults | None +``` + +### Dispersion correction + +```python +if result.dispersion: + print(result.dispersion.energy_hartree) # DFT-D correction in Hartree + print(result.dispersion.method) # e.g. "D3BJ" +``` + +### SMD solvation + +```python +if result.smd: + print(result.smd.total_energy_kcal_mol) # total solvation free energy + print(result.smd.electrostatic_kcal_mol) # electrostatic component + print(result.smd.non_electrostatic_kcal_mol) +``` + +## SCF Results + +```python +scf = result.scf +if scf: + print(scf.converged) # bool + print(scf.energy) # float — final SCF energy in Hartree + print(scf.n_iterations) # int + + # Per-iteration history + for i, it in enumerate(scf.iterations): + print(f" iter {i+1}: {it.energy:.10f} Hartree delta={it.delta_energy:.2e}") +``` + +### SCF energy components + +Q-Chem outputs include a breakdown of the SCF energy: + +```python +if scf.components: + c = scf.components + print(c.nuclear_repulsion) # Hartree + print(c.one_electron) # Hartree + print(c.coulomb) # Hartree + print(c.exchange_correlation) # Hartree +``` + +## Molecular Orbitals + +```python +orbs = result.orbitals +if orbs: + # All alpha (or closed-shell) orbitals + for orb in orbs.alpha_orbitals: + occ = "occ" if orb.occupied else "virt" + print(f" MO {orb.index}: {orb.energy:.4f} Hartree [{occ}]") + + # HOMO and LUMO + homo = next(o for o in reversed(orbs.alpha_orbitals) if o.occupied) + lumo = next(o for o in orbs.alpha_orbitals if not o.occupied) + gap = lumo.energy - homo.energy + print(f"HOMO-LUMO gap: {gap * 27.211:.2f} eV") + + # Unrestricted: beta orbitals + if orbs.beta_orbitals: + for orb in orbs.beta_orbitals: + print(f" beta MO {orb.index}: {orb.energy:.4f} Hartree") +``` + +## Atomic Charges + +Multiple charge methods can be present in a single result. Use `get_charges()` to retrieve by method name: + +```python +mulliken = result.get_charges("Mulliken") +hirshfeld = result.get_charges("Hirshfeld") +cm5 = result.get_charges("CM5") +loewdin = result.get_charges("Loewdin") # ORCA + +if mulliken: + # charges is a dict: atom_index (0-based) -> charge value + for idx, charge in mulliken.charges.items(): + print(f" atom {idx}: {charge:+.4f}") + + # spin densities (unrestricted calculations) + if mulliken.spins: + for idx, spin in mulliken.spins.items(): + print(f" atom {idx} spin: {spin:+.4f}") +``` + +!!! info "Atom indexing" + Charge and spin dicts use **0-based** integer keys, consistent with Python conventions. Q-Chem output uses 1-based numbering — CalcFlow converts on ingestion. + +## Multipole Moments + +```python +mp = result.multipole +if mp: + dx, dy, dz = mp.dipole.x, mp.dipole.y, mp.dipole.z + total = (dx**2 + dy**2 + dz**2) ** 0.5 + print(f"Dipole moment: {total:.4f} Debye") +``` + +Available moments: `dipole`, `quadrupole`, `octopole`, `hexadecapole`. + +## TDDFT / TDA Excited States + +```python +tddft = result.tddft +if tddft: + # TDA states (Tamm-Dancoff approximation) + for state in tddft.tda_states: + print(f"TDA S{state.state_number}: {state.excitation_energy_ev:.3f} eV" + f" f={state.oscillator_strength:.4f}") + + # Full TDDFT states + for state in tddft.tddft_states: + ev = state.excitation_energy_ev + nm = 1239.8 / ev + print(f"S{state.state_number}: {ev:.3f} eV ({nm:.0f} nm)" + f" f={state.oscillator_strength:.4f}") + + # Orbital transitions for a specific state + state = tddft.tddft_states[0] + for trans in state.transitions: + print(f" {trans.from_orbital} -> {trans.to_orbital} coeff={trans.coefficient:.4f}") +``` + +### Excited-state geometry optimization + +When a TDDFT geometry optimization is run, `state.total_energy_au` gives the total energy of the excited state on the optimized geometry. + +### Natural Transition Orbitals + +```python +if tddft.nto_analyses: + for nto in tddft.nto_analyses: + print(f"State {nto.state_number}: NTO analysis available") +``` + +### Transition density matrix analysis + +```python +if tddft.transition_density_matrices: + for tdm in tddft.transition_density_matrices: + print(f"State {tdm.state_number}:") + print(f" hole-electron separation: {tdm.exciton.rhe:.3f} Å") + print(f" CT number: {tdm.exciton.ct_number:.4f}") +``` + +### Unrelaxed difference density matrix analysis + +```python +if tddft.unrelaxed_density_matrices: + for udm in tddft.unrelaxed_density_matrices: + print(f"State {udm.state_number}:") + if udm.mulliken: + print(f" hole/electron Mulliken charges available") +``` + +## ADC Excited States + +CalcFlow parses ADC(2) outputs from Q-Chem: + +```python +adc = result.adc +if adc: + print(f"Method: {adc.method}") # e.g. "ADC(2)" + + # Ground state (HF + MP2) + gs = adc.ground_state + print(f"HF energy: {gs.hf_energy:.6f} Hartree") + print(f"MP2 energy: {gs.mp2_energy:.6f} Hartree") + + # Excited states + for state in adc.excited_states: + print(f"State {state.state_number} ({state.multiplicity}):") + print(f" {state.excitation_energy_ev:.3f} eV" + f" OPA f={state.opa_oscillator_strength:.4f}") + if state.tpa_cross_section is not None: + print(f" TPA cross section: {state.tpa_cross_section:.2f} GM") +``` + +## Geometry + +```python +# Geometry used as input for this calculation +if result.input_geometry: + print(result.input_geometry.to_xyz_str()) + +# Final optimized geometry (geometry optimization jobs) +if result.final_geometry: + result.final_geometry.to_xyz_file("optimized.xyz") + print(f"Optimized energy: {result.final_geometry.energy}") +``` + +## Timing + +```python +if result.timing: + for module, duration in result.timing.modules.items(): + print(f"{module}: {duration:.1f} s") +``` + +## Serialization + +```python +# Serialize — raw_output is excluded, schema_version is included +json_str = result.to_json() + +# Reload — works across calcflow versions (with migrations) +from calcflow.common.results import CalculationResult +result2 = CalculationResult.from_json(json_str) + +# Dict form +data = result.to_dict() +result3 = CalculationResult.from_dict(data) +``` + +!!! warning "raw_output is excluded" + The full output text (`result.raw_output`) is intentionally excluded from JSON serialization to keep files compact. If you need the raw output, save it separately alongside the JSON. + +## Spectrum Broadening + +For UV/Vis spectrum simulation, install the numpy extra and use the `postprocess` module: + +```python +from calcflow import parse_qchem_output, spectrum_from_excited_states, make_energy_grid +import numpy as np + +result = parse_qchem_output(open("tddft.out").read()) +states = result.tddft.tddft_states + +grid = make_energy_grid( + energies=[s.excitation_energy_ev for s in states], + padding=1.0, n_points=2000 +) +spectrum = spectrum_from_excited_states(states, grid, fwhm=0.3) # eV FWHM + +np.savetxt("spectrum.dat", np.column_stack([grid, spectrum])) +``` + +ADC OPA and TPA spectra are also available via `opa_spectrum_from_adc_states` and `tpa_spectrum_from_adc_states`. diff --git a/docs/guides/slurm.md b/docs/guides/slurm.md new file mode 100644 index 0000000..ddf6a65 --- /dev/null +++ b/docs/guides/slurm.md @@ -0,0 +1,186 @@ +--- +icon: lucide/server +--- + +# SLURM Job Scripts + +`SlurmJob` generates complete SLURM submission scripts for running Q-Chem or ORCA jobs on HPC clusters. It uses the same `CalculationInput` spec to derive program-specific directives — no need to manually translate core counts or memory limits into `#SBATCH` lines. + +## The SlurmJob Object + +`SlurmJob` is a frozen dataclass with a fluent setter API, just like `CalculationInput`. + +```python +from calcflow.slurm import SlurmJob + +job = SlurmJob( + job_name="h2o_tddft", + time="04:00:00", # wall time — HH:MM:SS + n_cores=16, + memory_mb=64000, # total node memory in MB + partition="cpu", + account="mygroup", +) +``` + +Required fields: `job_name`, `time`, `n_cores`, `memory_mb`, `partition`. +Optional fields: `constraint`, `account`, `queue`, `modules`. + +## Adding Environment Modules + +Most HPC clusters use environment modules to load software. Specify them as a list: + +```python +job = job.add_modules(["qchem/6.2", "intel/2023"]) +``` + +`.add_modules()` appends to the existing module list — useful for building up from a base job: + +```python +base_job = SlurmJob( + job_name="base", + time="02:00:00", + n_cores=8, + memory_mb=32000, + partition="cpu", + modules=["intel/2023", "openmpi/4.1"], +) + +qchem_job = base_job.add_modules(["qchem/6.2"]) +orca_job = base_job.add_modules(["orca/5.0"]) +``` + +## Generating a Script + +Pass a `CalculationInput` and the target program to `.export()`: + +```python +from calcflow import CalculationInput, Geometry + +calc = CalculationInput( + charge=0, + spin_multiplicity=1, + task="energy", + level_of_theory="wB97X-D3", + basis_set="def2-TZVP", +).set_tddft(nroots=10, singlets=True, triplets=False).set_cores(16) + +geom = Geometry.from_xyz_file("molecule.xyz") + +script = job.export( + calculation=calc, + program="qchem", + input_filename="molecule.inp", + output_filename="molecule.out", +) + +print(script) +``` + +The generated script includes: + +- `#SBATCH` directives derived from the `SlurmJob` fields +- `module load` commands for each entry in `modules` +- The program-specific launch command (e.g. `qchem -np 16 molecule.inp molecule.out`) +- Any program-specific directives the builder exposes (e.g. OpenMP thread settings) + +## Full Example: Q-Chem TDDFT Workflow + +```python +from calcflow import CalculationInput, Geometry +from calcflow.slurm import SlurmJob + +geom = Geometry.from_xyz_file("meoq.xyz") + +# Calculation spec +calc = ( + CalculationInput( + charge=0, + spin_multiplicity=1, + task="energy", + level_of_theory="wB97X-D3", + basis_set="def2-TZVP", + ) + .set_tddft(nroots=15, singlets=True, triplets=False) + .set_solvation(model="smd", solvent="water") + .set_charges(mulliken=True, hirshfeld=True, cm5=True) + .set_cores(32) + .set_memory_per_core(3000) +) + +# SLURM job +job = SlurmJob( + job_name="meoq_tddft", + time="08:00:00", + n_cores=32, + memory_mb=128000, + partition="cpu", + account="chemistry", + modules=["qchem/6.2"], +) + +# Generate input and submission script +input_text = calc.export("qchem", geom) +script = job.export(calc, "qchem", "meoq.inp", "meoq.out") + +with open("meoq.inp", "w") as f: + f.write(input_text) + +with open("submit.sh", "w") as f: + f.write(script) +``` + +Submit with `sbatch submit.sh`. + +## ORCA Example + +```python +orca_calc = ( + CalculationInput( + charge=0, + spin_multiplicity=1, + task="energy", + level_of_theory="wB97X-D3", + basis_set="def2-TZVP", + ) + .set_tddft(nroots=10, singlets=True, triplets=False) + .set_cores(8) +) + +orca_job = SlurmJob( + job_name="meoq_orca", + time="04:00:00", + n_cores=8, + memory_mb=32000, + partition="cpu", + modules=["orca/5.0.4", "openmpi/4.1"], +) + +input_text = orca_calc.export("orca", geom) +script = orca_job.export(orca_calc, "orca", "meoq.inp", "meoq.out") +``` + +## Saving the Spec for Reproducibility + +```python +# Save the calculation spec alongside the job script +with open("calc_spec.json", "w") as f: + f.write(calc.to_json()) +``` + +Reload later with `CalculationInput.from_json(open("calc_spec.json").read())` — so you always know exactly what was run. + +## Partition and Constraint + +For clusters with heterogeneous partitions: + +```python +job = SlurmJob( + job_name="gpu_tddft", + time="02:00:00", + n_cores=8, + memory_mb=32000, + partition="gpu", + constraint="a100", # node feature constraint +) +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f40962b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,131 @@ +--- +icon: lucide/home +--- + +# CalcFlow + +**A zero-dependency Python library for quantum chemistry calculation I/O.** + +[![PyPI](https://img.shields.io/pypi/v/calcflow)](https://pypi.org/project/calcflow/) +[![Python](https://img.shields.io/pypi/pyversions/calcflow)](https://pypi.org/project/calcflow/) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) + +**CalcFlow** bridges the gap between quantum chemistry programs and Python workflows. It parses output files from Q-Chem and ORCA into structured, immutable data models, and generates program-ready input files through a fluent, composable API — with zero external dependencies in the core package. + +## The Problem + +Quantum chemistry workflows share three recurring frustrations: + +1. **Fragile parsing**: output formats vary across program versions, leading to brittle `grep`-and-`awk` pipelines that break silently. +2. **Scattered input generation**: each program has its own keyword syntax; constructing inputs programmatically means writing program-specific string templates. +3. **No structured data**: results live in text files, not in objects with types, units, and field names — making downstream analysis error-prone. + +**CalcFlow solves this.** + +## Key Features + +- **Immutable data models**: every parsed result is a frozen dataclass — no mutation, no surprises, safe to pass around and serialize. +- **Fluent input API**: build a `CalculationInput` with method chaining, then `.export("qchem", geom)` or `.export("orca", geom)` — same spec, two programs. +- **Strategy-pattern parsers**: a registry of `BlockParser` objects handles each output section independently; adding support for new blocks is surgical, not global. +- **Version-aware parsing**: Q-Chem 5.4, 6.2, and 6.3 output format differences are handled transparently through versioned regex patterns. +- **Self-documenting API**: call `CalculationInput.get_api_docs()` or `CalculationResult.get_api_docs()` at runtime to get a full, always-current reference. +- **JSON roundtrip**: serialize any result or input spec to JSON and reload it later — with schema migration so old dumps stay readable. +- **Annotated geometry**: co-locate atom coordinates with charges, spin densities, and excited-state populations through a read-only `AnnotatedGeometry` view. +- **Agent-ready**: drop into LLM workflows with `uv run --with calcflow` — no setup, no conflicts; generate a `calcflow.md` rules file for plug-and-play tool use. + +## Installation + +=== "uv (recommended)" + + ```bash + uv add calcflow + ``` + +=== "pip" + + ```bash + pip install calcflow + ``` + +For spectrum broadening utilities (Lorentzian broadening, `postprocess` module), install the optional numpy extra: + +=== "uv" + + ```bash + uv add "calcflow[numpy]" + ``` + +=== "pip" + + ```bash + pip install "calcflow[numpy]" + ``` + +## Supported Programs + +| Program | Input generation | Output parsing | +| :--- | :---: | :---: | +| Q-Chem 5.4 | ✓ | ✓ | +| Q-Chem 6.2 | ✓ | ✓ | +| Q-Chem 6.3 | — | ✓ | +| ORCA | ✓ | ✓ | + +## Supported Properties + +| Category | Properties | +| :--- | :--- | +| **Energetics** | Final energy, SCF energy, nuclear repulsion, dispersion correction, SMD solvation energy | +| **Wavefunction** | SCF iterations & convergence, energy components, molecular orbitals (alpha/beta) | +| **Geometry** | Input geometry, optimized geometry, trajectory | +| **Excited states** | TDDFT (TDA/RPA), ADC(2) — energies, oscillator strengths, orbital transitions | +| **Density analysis** | NTOs, transition density matrices, unrelaxed difference density matrices, exciton descriptors | +| **Atomic charges** | Mulliken, Hirshfeld, CM5, Loewdin, Hirshfeld-I | +| **Multipole moments** | Dipole through hexadecapole | +| **Timing** | Per-module wall and CPU times | + +## Quick Example + +```python +from calcflow import parse_qchem_output + +result = parse_qchem_output(open("calculation.out").read()) + +print(result.termination_status) # "NORMAL" +print(result.final_energy) # -76.4234... (Hartree) +print(result.scf.converged) # True + +# Excited states +for state in result.tddft.tddft_states: + print(f"S{state.state_number}: {state.excitation_energy_ev:.2f} eV f={state.oscillator_strength:.4f}") +``` + +## In an Agent or LLM Workflow? + +No setup needed. Parse any output file with a single one-liner: + +```bash +uv run --with calcflow python -c " +from calcflow.io.qchem import parse_qchem_output +from pathlib import Path +r = parse_qchem_output(Path('calc.out').read_text()) +print(r.termination_status, r.final_energy) +" +``` + +A purpose-built [opencode](https://opencode.ai) agent ships with the repo. Install it with: + +```bash +curl -fsSL https://raw.githubusercontent.com/ischemist/project-prometheus/master/calcflow.md \ + -o ~/.config/opencode/agents/calcflow.md +``` + +[**Agentic & LLM Workflows**](guides/agentic.md) — `uv run --with calcflow`, the opencode agent, and multi-step workflow examples. + +## Getting Started + +[**Quick Start**](quick-start.md) — parse your first output and build your first input in five minutes. + +[**Concepts**](concepts.md) — understand the design philosophy behind the immutable model, the parser architecture, and the fluent API. + +[**Guides**](guides/parsing.md) — detailed walkthroughs of every feature. diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 0000000..cf56170 --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,175 @@ +--- +icon: lucide/rocket +--- + +# Quick Start + +Install CalcFlow, parse your first output file, and build your first input — in about five minutes. + +!!! tip "What you'll learn" + - Installing CalcFlow with `uv` or `pip` + - Parsing a Q-Chem or ORCA output file into a structured result object + - Extracting energies, charges, and excited states + - Building a calculation input with the fluent API + - Exporting inputs to Q-Chem and ORCA format + +## 1. Install + +=== "uv (recommended)" + + ```bash + uv add calcflow + ``` + +=== "pip" + + ```bash + pip install calcflow + ``` + +## 2. Parse an Output File + +CalcFlow exposes a single parse function per program. Pass it the raw output text — it returns an immutable `CalculationResult`. + +=== "Q-Chem" + + ```python + from calcflow import parse_qchem_output + + text = open("h2o_sp.out").read() + result = parse_qchem_output(text) + ``` + +=== "ORCA" + + ```python + from calcflow import parse_orca_output + + text = open("h2o_sp.out").read() + result = parse_orca_output(text) + ``` + +### Check termination + +Always check whether the calculation finished normally before reading results: + +```python +if result.termination_status != "NORMAL": + raise RuntimeError(f"Calculation did not converge: {result.termination_status}") +``` + +### Extract the final energy + +```python +print(result.final_energy) # -76.4234... Hartree +``` + +### Extract charges + +```python +mulliken = result.get_charges("Mulliken") +if mulliken: + for atom_idx, charge in mulliken.charges.items(): + print(f"Atom {atom_idx}: {charge:+.4f}") +``` + +### Extract excited states (TDDFT) + +```python +if result.tddft: + for state in result.tddft.tddft_states: + ev = state.excitation_energy_ev + nm = 1239.8 / ev # convert to wavelength + f = state.oscillator_strength + print(f"S{state.state_number}: {ev:.2f} eV ({nm:.0f} nm) f={f:.4f}") +``` + +!!! info "Units" + All energies are in **Hartree** unless otherwise noted. Excitation energies on `ExcitedState` are also available in eV via `excitation_energy_ev`. + +## 3. Serialize and Reload + +Parsed results can be saved to JSON and reloaded without re-parsing: + +```python +# Save +json_str = result.to_json() +with open("result.json", "w") as f: + f.write(json_str) + +# Reload (note: raw_output is excluded from serialization) +from calcflow.common.results import CalculationResult + +result2 = CalculationResult.from_json(open("result.json").read()) +print(result2.final_energy) +``` + +## 4. Build a Calculation Input + +`CalculationInput` is a frozen dataclass with a fluent setter API. Every setter returns a new instance — the original is never mutated. + +```python +from calcflow import CalculationInput, Geometry + +# Load geometry from an XYZ file +geom = Geometry.from_xyz_file("h2o.xyz") + +# Build the calculation spec +calc = ( + CalculationInput( + charge=0, + spin_multiplicity=1, + task="energy", + level_of_theory="wB97X-D3", + basis_set="def2-TZVP", + ) + .set_tddft(nroots=10, singlets=True, triplets=False) + .set_solvation(model="smd", solvent="water") + .set_cores(8) + .set_memory_per_core(4000) +) +``` + +### Export to Q-Chem or ORCA + +```python +qchem_input = calc.export("qchem", geom) +orca_input = calc.export("orca", geom) + +print(qchem_input) +``` + +The same `CalculationInput` object produces valid input for both programs. + +### Save the spec for reproducibility + +```python +with open("calc_spec.json", "w") as f: + f.write(calc.to_json()) +``` + +## 5. Discover the API at Runtime + +Both `CalculationInput` and `CalculationResult` are self-documenting: + +```python +# Full method catalogue for CalculationInput +print(CalculationInput.get_api_docs()) + +# Structural field map with types and units for CalculationResult +from calcflow.common.results import CalculationResult +print(CalculationResult.get_api_docs()) +``` + +!!! success "You're all set" + You've parsed an output, extracted results, built an input, and exported it — the full CalcFlow workflow. Explore the guides to go deeper. + +## Next Steps + +**[Parsing Guide](guides/parsing.md)** — every result field explained, with multi-job parsing, ADC, and spectrum broadening. + +**[Inputs Guide](guides/inputs.md)** — the full fluent API: TDDFT, solvation, MOM, geometry optimization, charge analysis. + +**[Geometry Guide](guides/geometry.md)** — `Geometry`, `Trajectory`, `AnnotatedGeometry`, and topology tools. + +**[Concepts](concepts.md)** — the design philosophy behind CalcFlow's immutable models and parser architecture. diff --git a/pyproject.toml b/pyproject.toml index bfb9f49..ec8af01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dev = [ "pytest-cov>=7.0.0", "ruff>=0.14.2", "ty>=0.0.1a24", + "zensical>=0.0.26", ] [tool.ty.src] diff --git a/uv.lock b/uv.lock index 76e545b..6bd90b7 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,7 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, { name = "ty" }, + { name = "zensical" }, ] [package.metadata] @@ -33,6 +34,19 @@ dev = [ { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "ruff", specifier = ">=0.14.2" }, { name = "ty", specifier = ">=0.0.1a24" }, + { name = "zensical", specifier = ">=0.0.26" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -105,6 +119,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, ] +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -114,6 +137,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "numpy" version = "2.4.3" @@ -217,6 +249,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -247,6 +292,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.14.2" @@ -296,3 +377,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/16/e246795ed66ff8ee1a47497019f86ea1b4fb238bfca3068f2e08c52ef03b/ty-0.0.22-py3-none-win_amd64.whl", hash = "sha256:c216f750769ac9f3e9e61feabf3fd44c0697dce762bdcd105443d47e1a81c2b9", size = 10709350, upload-time = "2026-03-12T17:40:40.82Z" }, { url = "https://files.pythonhosted.org/packages/3b/a4/5aafcebc4f597164381b0a82e7a8780d8f9f52df3884b16909a76282a0da/ty-0.0.22-py3-none-win_arm64.whl", hash = "sha256:49795260b9b9e3d6f04424f8ddb34907fac88c33a91b83478a74cead5dde567f", size = 10137248, upload-time = "2026-03-12T17:40:09.244Z" }, ] + +[[package]] +name = "zensical" +version = "0.0.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/1f/0a0b1ce8e0553a9dabaedc736d0f34b11fc33d71ff46bce44d674996d41f/zensical-0.0.26.tar.gz", hash = "sha256:f4d9c8403df25fbb3d6dd9577122dc2f23c73a2d16ab778bb7d40370dd71e987", size = 3841473, upload-time = "2026-03-11T09:51:38.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/58/fa3d9538ff1ea8cf4a193edbf47254f374fa7983fcfa876bb4336d72c53a/zensical-0.0.26-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7823b25afe7d36099253aa59d643abaac940f80fd015d4a37954210c87d3da56", size = 12263607, upload-time = "2026-03-11T09:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/44a3b21bd3569b9cad203364d73a956768d28a879e4c2be91bd889f74d2c/zensical-0.0.26-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c0254814382cdd3769bc7689180d09bf41de8879871dd736dc52d5f141e8ada7", size = 12144562, upload-time = "2026-03-11T09:50:53.685Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/31b9885745b3e7ef23a3ae7f175b879807288d11b3fb7e2d3c119c916258/zensical-0.0.26-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c8e601b2bbd239e564b04cf235eefb9777e7dfc7e1857b8871d6cdcfb577aa0", size = 12506728, upload-time = "2026-03-11T09:50:57.775Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/f5291e2c47076474f181f6eef35ef0428117d3f192da4358c0511e2ce09e/zensical-0.0.26-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2dc43c7e6c25d9724fc0450f0273ca4e5e2506eeb7f89f52f1405a592896ca3b", size = 12454975, upload-time = "2026-03-11T09:51:01.514Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/61cac4f2ebad31dab768eb02753ffde9e56d4d34b8f876b949bf516fbd50/zensical-0.0.26-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24ed236d1254cc474c19227eaa3670a1ccf921af53134ec5542b05853bdcd59c", size = 12791930, upload-time = "2026-03-11T09:51:05.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/86/51995d1ed2dd6ad8a1a70bcdf3c5eb16b50e62ea70e638d454a6b9061c4d/zensical-0.0.26-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1110147710d1dd025d932c4a7eada836bdf079c91b70fb0ae5b202e14b094617", size = 12548166, upload-time = "2026-03-11T09:51:09.218Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/decbafdbfc77170cbc3851464632390846e9aaf45e743c8dd5a24d5673e9/zensical-0.0.26-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7d21596a785428cdebc20859bd94a05334abe14ad24f1bb9cd80d19219e3c220", size = 12682103, upload-time = "2026-03-11T09:51:12.68Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/391d2d08dde621177da069a796a886b549fefb15734aeeb6e696af99b662/zensical-0.0.26-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:680a3c7bb71499b4da784d6072e44b3d7b8c0df3ce9bbd9974e24bd8058c2736", size = 12724219, upload-time = "2026-03-11T09:51:17.32Z" }, + { url = "https://files.pythonhosted.org/packages/80/2a/21b40c5c40a67da8a841f278d61dbd8d5e035e489de6fe1cef5f4e211b4f/zensical-0.0.26-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:e3294a79f98218b6fc2219232e166aa0932ae4dad58f6c8dbc0dbe0ecbff9c25", size = 12862117, upload-time = "2026-03-11T09:51:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/51/76/e1910d6d75d207654c867b8efbda6822dedda9fed3601bf4a864a1f4fe26/zensical-0.0.26-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:630229587df1fb47be184a4a69d0772ce59a44cd2c481ae9f7e8852fffaff11e", size = 12815714, upload-time = "2026-03-11T09:51:26.24Z" }, + { url = "https://files.pythonhosted.org/packages/f4/eb/34b042542cd949192535f8bac172d33b3da5a0ec0853eed008a6ad3242e3/zensical-0.0.26-cp310-abi3-win32.whl", hash = "sha256:e0756581541aad2e63dd8b4abae47e6ff12229a474b4eede5b4da5cc183c5101", size = 11856425, upload-time = "2026-03-11T09:51:31.087Z" }, + { url = "https://files.pythonhosted.org/packages/92/a5/30f6a88bb125c2bbeae3ae80a0812131614ab30e9b0b199d75d4199e5b66/zensical-0.0.26-cp310-abi3-win_amd64.whl", hash = "sha256:9ca07f5c75b5eac4d273d887100bbccd6eb8ba4959c904e2ab61971a0017c172", size = 12059895, upload-time = "2026-03-11T09:51:35.226Z" }, +] diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000..d83b5b8 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,85 @@ +# ============================================================================ +# +# CalcFlow Docs — zensical.toml +# +# ============================================================================ + +[project] +site_name = "CalcFlow Docs" +site_description = "CalcFlow is a zero-dependency Python library for quantum chemistry calculation I/O — parsing outputs and generating inputs for Q-Chem and ORCA." +site_author = "isChemist Group" +site_url = "https://calcflow.ischemist.com/" +repo_url = "https://github.com/ischemist/project-prometheus" +repo_name = "ischemist/project-prometheus" +copyright = """ +CC-BY © 2025 isChemist Group +""" +nav = [ + { "Home" = "index.md" }, + { "Quick Start" = "quick-start.md" }, + { "Concepts" = "concepts.md" }, + { "Guides" = [ + "guides/parsing.md", + "guides/inputs.md", + "guides/geometry.md", + "guides/slurm.md", + "guides/agentic.md", + ] }, + { "Developers" = [ + "developers/parsers.md", + "developers/testing.md", + "developers/schema-versioning.md", + ] } +] + +# extra_css = ["stylesheets/extra.css"] +# extra_javascript = ["javascripts/extra.js"] + +[project.theme] +# variant = "classic" +# custom_dir = "overrides" +# favicon = "images/favicon.png" +language = "en" +features = [ + "announce.dismiss", + "content.code.annotate", + "content.code.copy", + "content.code.select", + "content.footnote.tooltips", + "content.tabs.link", + "content.tooltips", + "navigation.footer", + "navigation.indexes", + "navigation.instant", + "navigation.instant.prefetch", + "navigation.path", + "navigation.sections", + "navigation.top", + "navigation.tracking", + "search.highlight", +] + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +toggle.icon = "lucide/moon" +toggle.name = "Switch to system preference" + +[project.theme.font] +text = "Geist" +code = "Geist Mono" + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/ischemist/project-prometheus" From 8d9cad828b4d00d8af86a9b809b5e37a0addac7f Mon Sep 17 00:00:00 2001 From: Anton Morgunov Date: Thu, 12 Mar 2026 18:53:09 -0400 Subject: [PATCH 2/3] DOCS: update docs --- docs/concepts.md | 62 +++++--------- docs/developers/parsers.md | 3 +- docs/developers/schema-versioning.md | 9 -- docs/developers/testing.md | 2 +- docs/guides/agentic.md | 122 ++++++++++++++++----------- docs/guides/geometry.md | 23 +++-- docs/guides/inputs.md | 11 ++- docs/guides/parsing.md | 37 ++++---- docs/guides/slurm.md | 4 +- docs/index.md | 58 +++---------- docs/quick-start.md | 60 ++++--------- 11 files changed, 157 insertions(+), 234 deletions(-) diff --git a/docs/concepts.md b/docs/concepts.md index 1f059fe..75f1e15 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -4,31 +4,23 @@ icon: lucide/lightbulb # Concepts -CalcFlow is built around three ideas: *immutability*, *composition*, and *separation of concerns*. Understanding these makes the API feel inevitable rather than arbitrary. - -!!! abstract "Core idea" - **A quantum chemistry result is a fact, not a variable.** Once a calculation has run, its output is fixed. CalcFlow models this directly: every parsed result and every calculation spec is a frozen, immutable object. You don't update results — you derive new ones. +CalcFlow is built around three ideas: *immutability*, *composition*, and *separation of concerns*. ## Immutable Data Models -All data models in CalcFlow are standard-library `dataclasses` with `frozen=True`. This means: - -- **No mutation**: once created, no field can be changed. -- **Hashable and safe**: immutable objects can be used as dict keys, stored in sets, passed across threads without copying. -- **Explicit derivation**: to "change" a spec, you call a setter that returns a *new* instance via `dataclasses.replace()`. +All data models are standard-library `dataclasses` with `frozen=True`. Once created, no field can be changed. To "modify" a spec you call a setter that returns a new instance via `dataclasses.replace()`. ```python calc = CalculationInput(charge=0, spin_multiplicity=1, task="energy", level_of_theory="B3LYP", basis_set="def2-SVP") -# This does NOT modify calc — it returns a new object calc_tddft = calc.set_tddft(nroots=5, singlets=True, triplets=False) -assert calc.tddft is None # original unchanged +assert calc.tddft is None assert calc_tddft.tddft is not None ``` -This design makes it trivial to branch a spec: +Branching a spec is cheap and safe: ```python base = CalculationInput(charge=0, spin_multiplicity=1, task="energy", @@ -39,13 +31,11 @@ in_solvent = base.set_solvation(model="smd", solvent="water") with_tddft = base.set_tddft(nroots=10, singlets=True, triplets=False) ``` -All three objects share the same immutable base — there's no risk of one accidentally mutating another's settings. +All three share the same immutable base — no risk of one accidentally affecting another. ## The Parser Architecture -Parsing a quantum chemistry output file is not a monolithic task. An output file is a sequence of *blocks* — each block has a distinctive header line, a specific format, and a well-defined set of data it contributes to the result. - -CalcFlow uses the **strategy pattern** to model this. +An output file is a sequence of *blocks*, each with a distinctive header, a specific format, and a well-defined set of data. CalcFlow uses the **strategy pattern**: a registry of `BlockParser` objects, each responsible for one block type. ```mermaid flowchart LR @@ -60,27 +50,19 @@ flowchart LR state --> result["CalculationResult\n(frozen)"] ``` -### The three actors - -1. **`core_parse()`** — the engine. It iterates over lines using a `PeekableIterator`, and for each line, asks every registered `BlockParser` whether it handles that line. +**`core_parse()`** iterates lines and for each line asks every registered `BlockParser` whether it handles that line. -2. **`BlockParser`** — a protocol with two methods: - - `matches(line, state) -> bool` — a fast, stateless check. Does this parser handle this line? Must also check completion flags to avoid parsing the same block twice. - - `parse(iterator, start_line, state) -> None` — called when `matches` returns `True`. Consumes lines from the iterator and writes results into `state`. +**`BlockParser`** has two methods: `matches(line, state)` — a fast, stateless check — and `parse(iterator, start_line, state)` — which consumes lines and writes into state. -3. **`ParseState`** — the single mutable scratchpad that all parsers write into. When parsing is complete, it's converted to an immutable `CalculationResult`. +**`ParseState`** is the single mutable scratchpad all parsers write into. When parsing is complete it's converted to an immutable `CalculationResult`. -### Why this matters - -Adding support for a new output block — say, a new charge method or a new excited-state property — requires writing one new `BlockParser` class and registering it. Nothing else changes. The core engine, the state object, and every other parser are untouched. +Adding support for a new output block means writing one new `BlockParser` class and registering it. The core engine and every other parser are untouched. See [Writing Block Parsers](developers/parsers.md) for the full recipe. ## The Fluent Input API -`CalculationInput` is CalcFlow's answer to the question: *how do you specify a quantum chemistry calculation without learning program-specific keywords?* - -The answer is a composable, self-validating Python object. You describe *what* you want — method, basis, task, solvation, excited states — and the program-specific builders translate that spec into valid input syntax. +`CalculationInput` describes *what* you want — method, basis, task, solvation, excited states — and program-specific builders translate that into valid input syntax for Q-Chem or ORCA. ```python calc = ( @@ -101,15 +83,13 @@ qchem = calc.export("qchem", geom) orca = calc.export("orca", geom) ``` -Validation happens at construction time — not at export time. If you pass incompatible settings (e.g. MOM without `unrestricted=True`), you get an error immediately, not when the job is submitted. +Validation happens at construction time — incompatible settings raise immediately, not when the job is submitted. ## Zero Dependencies -The core `calcflow` package has **no runtime dependencies**. No numpy, no pandas, no pydantic. This is a deliberate constraint. - -The rationale: a library for I/O should be installable in any environment, including minimal HPC environments, CI containers, and scripts that run alongside QC programs. A hard numpy dependency would break this. +The core `calcflow` package has no runtime dependencies. This is intentional — a library for I/O should be installable anywhere: HPC clusters, CI containers, scripts running alongside QC programs. A hard numpy dependency would break that. -The `postprocess` module (spectrum broadening) requires numpy, but it's gated behind an optional extra: +Spectrum broadening (`postprocess`) requires numpy, gated behind an optional extra: ```bash pip install "calcflow[numpy]" @@ -117,26 +97,24 @@ pip install "calcflow[numpy]" ## Self-Documenting API -CalcFlow is designed to be usable by LLMs and by users who haven't read the full documentation. Both `CalculationInput` and `CalculationResult` expose a runtime API reference generated from the source code: +Both `CalculationInput` and `CalculationResult` expose a runtime API reference generated from the source code: ```python -# Full setter catalogue with signatures and docstrings print(CalculationInput.get_api_docs()) -# Structural field map with types and units from calcflow.common.results import CalculationResult print(CalculationResult.get_api_docs()) ``` -These methods use introspection — they always reflect the actual current state of the code, not a separately maintained documentation string. +These use introspection and always reflect the actual current state of the code. ## Schema Versioning -Serialized `CalculationInput` and `CalculationResult` objects include two version fields: +Serialized objects include two version fields: - **`calcflow_version`** — the semver string of the package that produced the dump. For provenance only. -- **`schema_version`** — an integer that tracks structural compatibility. This is the one that drives migration logic. +- **`schema_version`** — an integer that tracks structural compatibility and drives migration logic. -When you call `CalculationResult.from_dict(data)`, CalcFlow checks `data["schema_version"]` and runs sequential migration steps to bring old dumps up to the current schema. This means a result serialized two versions ago is still loadable today. +When you call `CalculationResult.from_dict(data)`, CalcFlow checks `schema_version` and runs sequential migration steps to bring old dumps up to the current schema. -See [Schema Versioning](developers/schema-versioning.md) for the full rules on when to bump and how to write migrations. +See [Schema Versioning](developers/schema-versioning.md) for the full rules. diff --git a/docs/developers/parsers.md b/docs/developers/parsers.md index e9ac526..2f641ca 100644 --- a/docs/developers/parsers.md +++ b/docs/developers/parsers.md @@ -270,6 +270,5 @@ class MullikenParser: - **Forgetting the completion flag**: the parser will re-run every time it sees the header line. - **Mutating state in `matches()`**: creates order-dependent bugs that are hard to track down. -- **Not pushing back over-read lines**: the next block's header will be silently swallowed. -- **Using `buffered_line` on state**: the correct mechanism is `iterator.push_back()`. The state object has no `buffered_line` field. +- **Not pushing back over-read lines**: the next block's header will be silently swallowed. Use `iterator.push_back(line)` — don't try to stash lines on `ParseState` manually. - **Raising bare `Exception`**: always raise `ParsingError` from `calcflow.common.exceptions` so callers can catch CalcFlow-specific errors. diff --git a/docs/developers/schema-versioning.md b/docs/developers/schema-versioning.md index df14685..e52f9fa 100644 --- a/docs/developers/schema-versioning.md +++ b/docs/developers/schema-versioning.md @@ -111,15 +111,6 @@ if stored_version > RESULT_SCHEMA_VERSION: ) ``` -## Example: Adding an Optional Field (No Bump Needed) - -Adding `timing: TimingResults | None = None` to `CalculationResult`: - -1. Add the field with a default of `None`. -2. Old dumps that don't include `timing` will deserialize with `timing=None` — correct behavior. -3. No migration step needed. -4. **Do not bump `schema_version`.** - ## Example: Renaming a Field (Bump Required) Renaming `CalculationResult.scf_results` to `CalculationResult.scf`: diff --git a/docs/developers/testing.md b/docs/developers/testing.md index 1a5374a..2431f51 100644 --- a/docs/developers/testing.md +++ b/docs/developers/testing.md @@ -4,7 +4,7 @@ icon: lucide/flask-conical # Testing -CalcFlow uses a four-level testing hierarchy aligned with the four sources of complexity in the codebase: pure logic, parser contracts, component integration, and numerical regression. +CalcFlow uses four test levels, each targeting a distinct layer of complexity: pure logic, parser contracts, component integration, and numerical regression. ## The Four Levels diff --git a/docs/guides/agentic.md b/docs/guides/agentic.md index 61f1f19..e7044b9 100644 --- a/docs/guides/agentic.md +++ b/docs/guides/agentic.md @@ -4,42 +4,29 @@ icon: lucide/bot # Agentic & LLM Workflows -CalcFlow is designed to drop into agentic workflows. Zero dependencies, a self-documenting API, and `uv`-based instant execution mean an LLM agent can parse and analyze quantum chemistry output files without any pre-existing environment setup. - -## Why It Works in Agentic Contexts - -Three properties make CalcFlow unusually well-suited for tool use: - -1. **Zero dependencies** — `uv run --with calcflow` installs and runs in seconds, in any environment, without version conflicts. -2. **Self-documenting API** — call `CalculationResult.get_schema()` or `CalculationInput.get_quick_ref()` at runtime to get a complete, always-current field reference. The agent doesn't need to read source code. -3. **JSON-first results** — every parsed result serializes to JSON via `.to_json()`, making it trivial to pass structured data between agent steps. - ## The CalcFlow OpenCode Agent -The repo ships a purpose-built [opencode](https://opencode.ai) agent at [`calcflow.md`](https://github.com/ischemist/project-prometheus/blob/master/calcflow.md) in the project root. Install it with a single command: +The repo ships a purpose-built [opencode](https://opencode.ai) agent at [`calcflow.md`](https://github.com/ischemist/project-prometheus/blob/master/calcflow.md). Install it with: ```bash curl -fsSL https://raw.githubusercontent.com/ischemist/project-prometheus/master/calcflow.md \ -o ~/.config/opencode/agents/calcflow.md ``` -Once installed, open opencode in any directory containing `.out` files or `.xyz` geometries and the CalcFlow agent is available. +Open opencode in any directory containing `.out` files or `.xyz` geometries and the CalcFlow agent is available. ### What the agent does -The agent is configured to: - -- **Never read files directly** — all file inspection happens through `calcflow` via the shell tool (`cat`, `grep`, `head` are disabled). This keeps context tight and avoids raw-text hallucinations. -- **Use `uv run --with calcflow`** for every operation — no assumptions about the local environment. -- **Navigate the API incrementally** — quick ref first, full method docs only when needed, never dumping the entire schema upfront. -- **Speak in one-liners** — short, focused scripts that do one thing. +- **Never reads files directly** — all file inspection goes through `calcflow` via the shell tool. This keeps context tight and avoids raw-text hallucinations. +- **Uses `uv run --with calcflow`** for every operation — no assumptions about the local environment. +- **Navigates the API incrementally** — quick ref first, full method docs only when needed. +- **Works in short, focused scripts** — one task per shell call. ## `uv run --with calcflow` -The universal entry point — no installation, no virtual environment management: +No installation, no virtual environment management. Works anywhere `uv` is installed: ```bash -# Parse an output file and print key results uv run --with calcflow python -c " from calcflow.io.qchem import parse_qchem_output from pathlib import Path @@ -52,7 +39,7 @@ if r.tddft and r.tddft.tda_states: " ``` -If CalcFlow is already installed in the project's virtualenv, drop the `--with calcflow`: +If CalcFlow is already installed in the project virtualenv, drop `--with calcflow`: ```bash uv run python -c "..." @@ -60,7 +47,7 @@ uv run python -c "..." ## API Navigation -The agent (and you) can explore the API at runtime — without reading source code. +Explore the API at runtime — no source code reading needed. **Quick reference (usually enough):** ```bash @@ -78,7 +65,7 @@ print(CalculationInput.get_method_docs('set_tddft')) " ``` -**Result field schema (for parsing questions):** +**Result field schema:** ```bash uv run --with calcflow python -c " from calcflow.common.results import CalculationResult @@ -86,8 +73,7 @@ print(CalculationResult.get_schema()) " ``` -!!! tip "One targeted call beats one 600-line blob" - Prefer `get_quick_ref()` or `get_method_docs('set_tddft')` over `get_api_docs()` unless you need the full catalogue. Agents work better with focused context. +Prefer `get_quick_ref()` or `get_method_docs('set_tddft')` over `get_api_docs()` unless you need the full catalogue. A focused 30-line reference beats a 600-line blob. ## Common One-Liners @@ -163,44 +149,84 @@ print('wrote calc.in and calc_spec.json') " ``` -**Spatial analysis — excited-state hole localization:** +**Excited-state hole localization (unrelaxed DM):** ```bash uv run --with calcflow python -c " -from calcflow import AnnotatedGeometry, build_bond_graph, find_aromatic_atoms +from calcflow import AnnotatedGeometry from calcflow.io.qchem import parse_qchem_output from pathlib import Path r = parse_qchem_output(Path('calc.out').read_text()) ag = AnnotatedGeometry.from_result(r) -graph = build_bond_graph(ag.geometry.atoms) -aromatic = find_aromatic_atoms(ag.geometry.atoms, graph) -if s1 := ag.get_unrelaxed_state(1): +s1 = ag.get_unrelaxed_state(1) +if s1: for atom in s1: - if atom.hole_population and atom.hole_population > 0.5: - print(f'S1 hole localized on {atom.symbol}{atom.index}') + if atom.hole_population and atom.hole_population > 0.1: + print(f'S1 hole on {atom.symbol}{atom.index}: {atom.hole_population:.3f}') " ``` -## Multi-Step Workflows +## Multi-Step Workflow Example -CalcFlow's JSON roundtrip makes it natural to chain agent steps without re-parsing: +CalcFlow's JSON roundtrip lets agent steps pick up where the last one left off — no re-parsing, no re-running. +A typical absorption → excited-state geometry → emission workflow: + +**Step 1 — Parse the ground-state single point, save to JSON:** +```bash +uv run --with calcflow python -c " +from calcflow.io.qchem import parse_qchem_output +from pathlib import Path +r = parse_qchem_output(Path('gs_sp.out').read_text()) +assert r.termination_status == 'NORMAL' +Path('gs_sp.json').write_text(r.to_json()) +# print S1 for reference +s1 = r.tddft.tddft_states[0] +print(f'S1 absorption: {s1.excitation_energy_ev:.3f} eV f={s1.oscillator_strength:.4f}') +" ``` -parse gs_sp.out → result.json - ↓ -agent reads result.json, decides on excited-state geometry optimization - ↓ -generate s1_opt.inp + s1_opt_spec.json - ↓ -submit to cluster, wait - ↓ -parse s1_opt.out → s1_result.json - ↓ -agent analyzes S1 geometry, builds emission calculation - ↓ -... + +**Step 2 — Build the S1 geometry optimization input:** +```bash +uv run --with calcflow python -c " +from calcflow import CalculationInput, Geometry +from calcflow.common.results import CalculationResult +from pathlib import Path + +gs = CalculationResult.from_json(Path('gs_sp.json').read_text()) +geom = gs.input_geometry # Geometry object from the parsed result + +calc = ( + CalculationInput(charge=0, spin_multiplicity=1, task='geometry', + level_of_theory='wB97X-D3', basis_set='def2-TZVP', n_cores=32) + .set_tddft(nroots=5, singlets=True, triplets=False, state_to_optimize=1) + .set_solvation('smd', 'water') +) +Path('s1_opt.in').write_text(calc.export('qchem', geom)) +Path('s1_opt_spec.json').write_text(calc.to_json()) +print('wrote s1_opt.in') +" +``` + +**Step 3 — Parse the S1 optimized geometry, compute emission energy:** +```bash +uv run --with calcflow python -c " +from calcflow.io.qchem import parse_qchem_output +from calcflow.common.results import CalculationResult +from pathlib import Path + +gs = CalculationResult.from_json(Path('gs_sp.json').read_text()) +s1 = parse_qchem_output(Path('s1_opt.out').read_text()) +assert s1.termination_status == 'NORMAL' +Path('s1_opt.json').write_text(s1.to_json()) + +gs_energy = gs.final_energy +s1_energy = s1.final_energy +emission_ev = (s1_energy - gs_energy) * 27.211 # Hartree to eV +print(f'S1 emission energy: {emission_ev:.3f} eV ({1239.8 / emission_ev:.0f} nm)') +" ``` -Each step stores its output as JSON. The agent can pick up at any point — even across sessions — by loading a previous `result.json` instead of re-parsing the raw output. +Each step is self-contained. If a job fails or you want to branch — try a different functional, check a different state — reload from the JSON and go. ## Units and Conventions diff --git a/docs/guides/geometry.md b/docs/guides/geometry.md index c56264f..1c6e61d 100644 --- a/docs/guides/geometry.md +++ b/docs/guides/geometry.md @@ -142,17 +142,16 @@ sv = ag.get_adc_state(state_number=1) ## AnnotatedTrajectory -`AnnotatedTrajectory` is a sequence of `AnnotatedGeometry` frames — useful for analyzing a geometry optimization in terms of charges or orbital populations at each step. +`AnnotatedTrajectory` is a sequence of `AnnotatedGeometry` frames — useful for tracking how charges or populations evolve along a geometry optimization when you have a separate `CalculationResult` per step. ```python from calcflow import AnnotatedTrajectory -# Build from a list of CalculationResult objects (one per frame) +# results: list[CalculationResult], one per optimization step at = AnnotatedTrajectory.from_results(results) for frame in at: - atom0 = frame[0] - print(atom0.charges.get("Mulliken")) + print(frame[0].charges.get("Mulliken")) # Mulliken charge on atom 0 at this step ``` ## Topology @@ -204,18 +203,16 @@ aromatic = find_aromatic_atoms(atoms, bond_graph) ## Element Data -CalcFlow bundles a complete periodic table with atomic masses, van der Waals radii, and Pyykko covalent radii: +CalcFlow bundles a periodic table (all 118 elements) with atomic masses, van der Waals radii, and Pyykko covalent radii. The topology functions use this internally, but you can access it directly: ```python from calcflow.constants.ptable import ELEMENT_DATA carbon = ELEMENT_DATA["C"] -print(carbon.atomic_number) # 6 -print(carbon.atomic_mass) # 12.011 -print(carbon.atomic_radius_vdw_pm) # 170 -print(carbon.covalent_radius_single_pm) # 75 -print(carbon.covalent_radius_double_pm) # 67 -print(carbon.covalent_radius_triple_pm) # 60 +carbon.atomic_number # 6 +carbon.atomic_mass # 12.011 +carbon.atomic_radius_vdw_pm # van der Waals radius in pm +carbon.covalent_radius_single_pm # single-bond covalent radius (Pyykko 2009) +carbon.covalent_radius_double_pm +carbon.covalent_radius_triple_pm ``` - -Covers all 118 elements. diff --git a/docs/guides/inputs.md b/docs/guides/inputs.md index 6600392..ec93e15 100644 --- a/docs/guides/inputs.md +++ b/docs/guides/inputs.md @@ -4,7 +4,7 @@ icon: lucide/file-pen # Building Calculation Inputs -`CalculationInput` is CalcFlow's unified interface for specifying quantum chemistry calculations. You describe *what* you want — method, basis, task, solvation, excited states — and the program-specific builders translate that into valid input syntax for Q-Chem or ORCA. +`CalculationInput` describes *what* you want — method, basis, task, solvation, excited states — and the program-specific builders translate that into valid input syntax for Q-Chem or ORCA. ## Construction @@ -227,18 +227,17 @@ calc = calc.enable_ri_for_orca( ) ``` -## Program-Specific Escape Hatch +## Program-Specific Options -For options not covered by the standard API, use `set_options()`: +For options not covered by the standard API, use `set_options()`. Keys are stored in `program_options` and translated by the builder: ```python calc = calc.set_options( - parallelism="mpi", # Q-Chem: use MPI instead of OpenMP - maxiter=500, # passed through to the program + parallelism="mpi", # Q-Chem: MPI instead of OpenMP (default: "openmp") ) ``` -These are forwarded to the builder as `program_options` and translated into program-specific keywords where known. +Note: `enable_ri_for_orca()` and some other setters are thin wrappers around `set_options()` internally. ## Exporting diff --git a/docs/guides/parsing.md b/docs/guides/parsing.md index 26af13d..38464a7 100644 --- a/docs/guides/parsing.md +++ b/docs/guides/parsing.md @@ -4,7 +4,7 @@ icon: lucide/file-search # Parsing Output Files -CalcFlow parses quantum chemistry output files into structured, immutable `CalculationResult` objects. This guide covers every result field, from basic energies to excited-state density matrices. +CalcFlow parses quantum chemistry output files into structured, immutable `CalculationResult` objects. ## The Parsers @@ -24,11 +24,11 @@ CalcFlow parses quantum chemistry output files into structured, immutable `Calcu result = parse_orca_output(open("calculation.out").read()) ``` -Both functions return a `CalculationResult`. The raw output text is stored on `result.raw_output` but is excluded from JSON serialization. +The raw output text is stored on `result.raw_output` but is excluded from JSON serialization. ### Multi-job Q-Chem outputs -Q-Chem can concatenate multiple jobs into a single output file (e.g. a two-job MOM calculation). Use the multi-job parser to get a list of results: +Q-Chem concatenates multiple jobs into a single output file (e.g. a two-job MOM calculation). Use the multi-job parser to get a list of results: ```python from calcflow import parse_qchem_multi_job_output @@ -57,7 +57,7 @@ if result.termination_status != "NORMAL": ```python result.final_energy # float | None — total energy in Hartree -result.nuclear_repulsion_energy # float | None — nuclear repulsion in Hartree +result.nuclear_repulsion_energy # float | None — Hartree result.dispersion # DispersionCorrection | None result.smd # SmdResults | None ``` @@ -74,8 +74,8 @@ if result.dispersion: ```python if result.smd: - print(result.smd.total_energy_kcal_mol) # total solvation free energy - print(result.smd.electrostatic_kcal_mol) # electrostatic component + print(result.smd.total_energy_kcal_mol) + print(result.smd.electrostatic_kcal_mol) print(result.smd.non_electrostatic_kcal_mol) ``` @@ -87,15 +87,18 @@ if scf: print(scf.converged) # bool print(scf.energy) # float — final SCF energy in Hartree print(scf.n_iterations) # int +``` + +The per-iteration history is useful for diagnosing convergence problems: - # Per-iteration history +```python for i, it in enumerate(scf.iterations): print(f" iter {i+1}: {it.energy:.10f} Hartree delta={it.delta_energy:.2e}") ``` ### SCF energy components -Q-Chem outputs include a breakdown of the SCF energy: +Q-Chem outputs include a breakdown: ```python if scf.components: @@ -111,12 +114,10 @@ if scf.components: ```python orbs = result.orbitals if orbs: - # All alpha (or closed-shell) orbitals for orb in orbs.alpha_orbitals: occ = "occ" if orb.occupied else "virt" print(f" MO {orb.index}: {orb.energy:.4f} Hartree [{occ}]") - # HOMO and LUMO homo = next(o for o in reversed(orbs.alpha_orbitals) if o.occupied) lumo = next(o for o in orbs.alpha_orbitals if not o.occupied) gap = lumo.energy - homo.energy @@ -149,8 +150,7 @@ if mulliken: print(f" atom {idx} spin: {spin:+.4f}") ``` -!!! info "Atom indexing" - Charge and spin dicts use **0-based** integer keys, consistent with Python conventions. Q-Chem output uses 1-based numbering — CalcFlow converts on ingestion. +Atom indices are 0-based throughout CalcFlow. Q-Chem output uses 1-based numbering — CalcFlow converts on ingestion. ## Multipole Moments @@ -187,8 +187,6 @@ if tddft: print(f" {trans.from_orbital} -> {trans.to_orbital} coeff={trans.coefficient:.4f}") ``` -### Excited-state geometry optimization - When a TDDFT geometry optimization is run, `state.total_energy_au` gives the total energy of the excited state on the optimized geometry. ### Natural Transition Orbitals @@ -228,12 +226,10 @@ adc = result.adc if adc: print(f"Method: {adc.method}") # e.g. "ADC(2)" - # Ground state (HF + MP2) gs = adc.ground_state print(f"HF energy: {gs.hf_energy:.6f} Hartree") print(f"MP2 energy: {gs.mp2_energy:.6f} Hartree") - # Excited states for state in adc.excited_states: print(f"State {state.state_number} ({state.multiplicity}):") print(f" {state.excitation_energy_ev:.3f} eV" @@ -245,11 +241,9 @@ if adc: ## Geometry ```python -# Geometry used as input for this calculation if result.input_geometry: print(result.input_geometry.to_xyz_str()) -# Final optimized geometry (geometry optimization jobs) if result.final_geometry: result.final_geometry.to_xyz_file("optimized.xyz") print(f"Optimized energy: {result.final_geometry.energy}") @@ -266,7 +260,7 @@ if result.timing: ## Serialization ```python -# Serialize — raw_output is excluded, schema_version is included +# Serialize — raw_output excluded, schema_version included json_str = result.to_json() # Reload — works across calcflow versions (with migrations) @@ -278,8 +272,7 @@ data = result.to_dict() result3 = CalculationResult.from_dict(data) ``` -!!! warning "raw_output is excluded" - The full output text (`result.raw_output`) is intentionally excluded from JSON serialization to keep files compact. If you need the raw output, save it separately alongside the JSON. +The full output text (`result.raw_output`) is intentionally excluded from JSON. Save it separately if you need it. ## Spectrum Broadening @@ -301,4 +294,4 @@ spectrum = spectrum_from_excited_states(states, grid, fwhm=0.3) # eV FWHM np.savetxt("spectrum.dat", np.column_stack([grid, spectrum])) ``` -ADC OPA and TPA spectra are also available via `opa_spectrum_from_adc_states` and `tpa_spectrum_from_adc_states`. +ADC OPA and TPA spectra: `opa_spectrum_from_adc_states` and `tpa_spectrum_from_adc_states`. diff --git a/docs/guides/slurm.md b/docs/guides/slurm.md index ddf6a65..47084e5 100644 --- a/docs/guides/slurm.md +++ b/docs/guides/slurm.md @@ -4,7 +4,7 @@ icon: lucide/server # SLURM Job Scripts -`SlurmJob` generates complete SLURM submission scripts for running Q-Chem or ORCA jobs on HPC clusters. It uses the same `CalculationInput` spec to derive program-specific directives — no need to manually translate core counts or memory limits into `#SBATCH` lines. +`SlurmJob` generates complete SLURM submission scripts for Q-Chem or ORCA jobs. Core counts, memory limits, and program-specific launch commands are derived from the `CalculationInput` spec automatically. ## The SlurmJob Object @@ -130,8 +130,6 @@ with open("submit.sh", "w") as f: f.write(script) ``` -Submit with `sbatch submit.sh`. - ## ORCA Example ```python diff --git a/docs/index.md b/docs/index.md index f40962b..e2c7506 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,28 +11,17 @@ icon: lucide/home [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -**CalcFlow** bridges the gap between quantum chemistry programs and Python workflows. It parses output files from Q-Chem and ORCA into structured, immutable data models, and generates program-ready input files through a fluent, composable API — with zero external dependencies in the core package. +**CalcFlow** parses Q-Chem and ORCA output files into structured, immutable Python objects, and generates valid input files for both programs from a single shared spec. No external dependencies in the core package. -## The Problem +## Features -Quantum chemistry workflows share three recurring frustrations: - -1. **Fragile parsing**: output formats vary across program versions, leading to brittle `grep`-and-`awk` pipelines that break silently. -2. **Scattered input generation**: each program has its own keyword syntax; constructing inputs programmatically means writing program-specific string templates. -3. **No structured data**: results live in text files, not in objects with types, units, and field names — making downstream analysis error-prone. - -**CalcFlow solves this.** - -## Key Features - -- **Immutable data models**: every parsed result is a frozen dataclass — no mutation, no surprises, safe to pass around and serialize. -- **Fluent input API**: build a `CalculationInput` with method chaining, then `.export("qchem", geom)` or `.export("orca", geom)` — same spec, two programs. -- **Strategy-pattern parsers**: a registry of `BlockParser` objects handles each output section independently; adding support for new blocks is surgical, not global. -- **Version-aware parsing**: Q-Chem 5.4, 6.2, and 6.3 output format differences are handled transparently through versioned regex patterns. -- **Self-documenting API**: call `CalculationInput.get_api_docs()` or `CalculationResult.get_api_docs()` at runtime to get a full, always-current reference. -- **JSON roundtrip**: serialize any result or input spec to JSON and reload it later — with schema migration so old dumps stay readable. -- **Annotated geometry**: co-locate atom coordinates with charges, spin densities, and excited-state populations through a read-only `AnnotatedGeometry` view. -- **Agent-ready**: drop into LLM workflows with `uv run --with calcflow` — no setup, no conflicts; generate a `calcflow.md` rules file for plug-and-play tool use. +- **Immutable results** — every parsed result is a frozen dataclass. Safe to pass around, serialize, use as a dict key. +- **Fluent input API** — build a `CalculationInput` with method chaining, then `.export("qchem", geom)` or `.export("orca", geom)` — same spec, two programs. +- **Versioned parsing** — Q-Chem 5.4, 6.2, and 6.3 output format differences handled transparently. +- **JSON roundtrip** — serialize any result or input to JSON; schema migrations keep old dumps readable across versions. +- **Annotated geometry** — co-locate atom coordinates with charges, spin densities, and excited-state populations via `AnnotatedGeometry`. +- **Self-documenting** — call `CalculationInput.get_api_docs()` or `CalculationResult.get_api_docs()` at runtime for an always-current reference. +- **Agent-ready** — works with `uv run --with calcflow` in any environment; an [opencode](https://opencode.ai) agent ships with the repo. ## Installation @@ -48,7 +37,7 @@ Quantum chemistry workflows share three recurring frustrations: pip install calcflow ``` -For spectrum broadening utilities (Lorentzian broadening, `postprocess` module), install the optional numpy extra: +For spectrum broadening (`postprocess` module), install the optional numpy extra: === "uv" @@ -95,37 +84,16 @@ print(result.termination_status) # "NORMAL" print(result.final_energy) # -76.4234... (Hartree) print(result.scf.converged) # True -# Excited states for state in result.tddft.tddft_states: print(f"S{state.state_number}: {state.excitation_energy_ev:.2f} eV f={state.oscillator_strength:.4f}") ``` -## In an Agent or LLM Workflow? - -No setup needed. Parse any output file with a single one-liner: - -```bash -uv run --with calcflow python -c " -from calcflow.io.qchem import parse_qchem_output -from pathlib import Path -r = parse_qchem_output(Path('calc.out').read_text()) -print(r.termination_status, r.final_energy) -" -``` - -A purpose-built [opencode](https://opencode.ai) agent ships with the repo. Install it with: - -```bash -curl -fsSL https://raw.githubusercontent.com/ischemist/project-prometheus/master/calcflow.md \ - -o ~/.config/opencode/agents/calcflow.md -``` - -[**Agentic & LLM Workflows**](guides/agentic.md) — `uv run --with calcflow`, the opencode agent, and multi-step workflow examples. - ## Getting Started [**Quick Start**](quick-start.md) — parse your first output and build your first input in five minutes. -[**Concepts**](concepts.md) — understand the design philosophy behind the immutable model, the parser architecture, and the fluent API. +[**Concepts**](concepts.md) — the design philosophy: immutable models, the parser architecture, the fluent API. [**Guides**](guides/parsing.md) — detailed walkthroughs of every feature. + +[**Agentic Workflows**](guides/agentic.md) — `uv run --with calcflow`, the opencode agent, multi-step workflow examples. diff --git a/docs/quick-start.md b/docs/quick-start.md index cf56170..2e8b2d9 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -4,15 +4,6 @@ icon: lucide/rocket # Quick Start -Install CalcFlow, parse your first output file, and build your first input — in about five minutes. - -!!! tip "What you'll learn" - - Installing CalcFlow with `uv` or `pip` - - Parsing a Q-Chem or ORCA output file into a structured result object - - Extracting energies, charges, and excited states - - Building a calculation input with the fluent API - - Exporting inputs to Q-Chem and ORCA format - ## 1. Install === "uv (recommended)" @@ -29,15 +20,14 @@ Install CalcFlow, parse your first output file, and build your first input — i ## 2. Parse an Output File -CalcFlow exposes a single parse function per program. Pass it the raw output text — it returns an immutable `CalculationResult`. +CalcFlow exposes one parse function per program. Pass it the raw output text — it returns an immutable `CalculationResult`. === "Q-Chem" ```python from calcflow import parse_qchem_output - text = open("h2o_sp.out").read() - result = parse_qchem_output(text) + result = parse_qchem_output(open("h2o_sp.out").read()) ``` === "ORCA" @@ -45,26 +35,23 @@ CalcFlow exposes a single parse function per program. Pass it the raw output tex ```python from calcflow import parse_orca_output - text = open("h2o_sp.out").read() - result = parse_orca_output(text) + result = parse_orca_output(open("h2o_sp.out").read()) ``` -### Check termination - -Always check whether the calculation finished normally before reading results: +Check termination before trusting the results: ```python if result.termination_status != "NORMAL": - raise RuntimeError(f"Calculation did not converge: {result.termination_status}") + raise RuntimeError(f"Calculation failed: {result.termination_status}") ``` -### Extract the final energy +Extract the final energy: ```python print(result.final_energy) # -76.4234... Hartree ``` -### Extract charges +Extract charges: ```python mulliken = result.get_charges("Mulliken") @@ -73,31 +60,27 @@ if mulliken: print(f"Atom {atom_idx}: {charge:+.4f}") ``` -### Extract excited states (TDDFT) +Extract excited states: ```python if result.tddft: for state in result.tddft.tddft_states: ev = state.excitation_energy_ev - nm = 1239.8 / ev # convert to wavelength + nm = 1239.8 / ev f = state.oscillator_strength print(f"S{state.state_number}: {ev:.2f} eV ({nm:.0f} nm) f={f:.4f}") ``` -!!! info "Units" - All energies are in **Hartree** unless otherwise noted. Excitation energies on `ExcitedState` are also available in eV via `excitation_energy_ev`. +Energies are in **Hartree** unless the field name says otherwise (`_ev`, `_kcal_mol`). ## 3. Serialize and Reload -Parsed results can be saved to JSON and reloaded without re-parsing: - ```python -# Save -json_str = result.to_json() +# Save — raw_output is excluded to keep the file compact with open("result.json", "w") as f: - f.write(json_str) + f.write(result.to_json()) -# Reload (note: raw_output is excluded from serialization) +# Reload from calcflow.common.results import CalculationResult result2 = CalculationResult.from_json(open("result.json").read()) @@ -106,15 +89,13 @@ print(result2.final_energy) ## 4. Build a Calculation Input -`CalculationInput` is a frozen dataclass with a fluent setter API. Every setter returns a new instance — the original is never mutated. +`CalculationInput` is a frozen dataclass. Every setter returns a new instance — the original is never mutated. ```python from calcflow import CalculationInput, Geometry -# Load geometry from an XYZ file geom = Geometry.from_xyz_file("h2o.xyz") -# Build the calculation spec calc = ( CalculationInput( charge=0, @@ -130,7 +111,7 @@ calc = ( ) ``` -### Export to Q-Chem or ORCA +Export to Q-Chem or ORCA: ```python qchem_input = calc.export("qchem", geom) @@ -139,9 +120,7 @@ orca_input = calc.export("orca", geom) print(qchem_input) ``` -The same `CalculationInput` object produces valid input for both programs. - -### Save the spec for reproducibility +Save the spec for reproducibility: ```python with open("calc_spec.json", "w") as f: @@ -150,20 +129,15 @@ with open("calc_spec.json", "w") as f: ## 5. Discover the API at Runtime -Both `CalculationInput` and `CalculationResult` are self-documenting: - ```python # Full method catalogue for CalculationInput print(CalculationInput.get_api_docs()) -# Structural field map with types and units for CalculationResult +# Field map with types and units for CalculationResult from calcflow.common.results import CalculationResult print(CalculationResult.get_api_docs()) ``` -!!! success "You're all set" - You've parsed an output, extracted results, built an input, and exported it — the full CalcFlow workflow. Explore the guides to go deeper. - ## Next Steps **[Parsing Guide](guides/parsing.md)** — every result field explained, with multi-job parsing, ADC, and spectrum broadening. From ce12b42ae37e8a936b6e49cc42431560cd1cbd6b Mon Sep 17 00:00:00 2001 From: Anton Morgunov Date: Thu, 12 Mar 2026 19:01:24 -0400 Subject: [PATCH 3/3] =?UTF-8?q?DOCS:=20clarity=20pass=20=E2=80=94=20cut=20?= =?UTF-8?q?LLM=20boilerplate,=20fix=20open()=20=E2=86=92=20Path,=20add=20C?= =?UTF-8?q?I=20concurrency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docs.yml | 4 ++++ docs/guides/geometry.md | 3 ++- docs/guides/inputs.md | 13 +++++++------ docs/guides/parsing.md | 12 ++++++++---- docs/guides/slurm.md | 16 +++++++--------- docs/index.md | 3 ++- docs/quick-start.md | 19 ++++++++++--------- 7 files changed, 40 insertions(+), 30 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cec6afd..f4ec32c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,6 +4,10 @@ on: push: branches: [master, main] +concurrency: + group: pages + cancel-in-progress: false + permissions: contents: read pages: write diff --git a/docs/guides/geometry.md b/docs/guides/geometry.md index 1c6e61d..2104ac4 100644 --- a/docs/guides/geometry.md +++ b/docs/guides/geometry.md @@ -84,9 +84,10 @@ All frames in a `Trajectory` must have the same number and order of atoms — th `AnnotatedGeometry` is a read-only view that pairs a `Geometry` with a `CalculationResult`. It gives atom-centric access to charges, spin densities, and excited-state populations without duplicating data. ```python +from pathlib import Path from calcflow import parse_qchem_output, AnnotatedGeometry -result = parse_qchem_output(open("calculation.out").read()) +result = parse_qchem_output(Path("calculation.out").read_text()) ag = AnnotatedGeometry.from_result(result) ``` diff --git a/docs/guides/inputs.md b/docs/guides/inputs.md index ec93e15..f903fa1 100644 --- a/docs/guides/inputs.md +++ b/docs/guides/inputs.md @@ -242,6 +242,7 @@ Note: `enable_ri_for_orca()` and some other setters are thin wrappers around `se ## Exporting ```python +from pathlib import Path from calcflow import Geometry geom = Geometry.from_xyz_file("molecule.xyz") @@ -249,8 +250,7 @@ geom = Geometry.from_xyz_file("molecule.xyz") qchem_input = calc.export("qchem", geom) orca_input = calc.export("orca", geom) -with open("molecule.inp", "w") as f: - f.write(qchem_input) +Path("molecule.inp").write_text(qchem_input) ``` ## Serialization @@ -258,13 +258,14 @@ with open("molecule.inp", "w") as f: Save a spec to JSON for reproducibility and reload it later: ```python +from pathlib import Path +from calcflow.common.input import CalculationInput + # Save -with open("calc_spec.json", "w") as f: - f.write(calc.to_json()) +Path("calc_spec.json").write_text(calc.to_json()) # Reload -from calcflow.common.input import CalculationInput -calc2 = CalculationInput.from_json(open("calc_spec.json").read()) +calc2 = CalculationInput.from_json(Path("calc_spec.json").read_text()) ``` ## Runtime API Reference diff --git a/docs/guides/parsing.md b/docs/guides/parsing.md index 38464a7..081ad7d 100644 --- a/docs/guides/parsing.md +++ b/docs/guides/parsing.md @@ -11,17 +11,19 @@ CalcFlow parses quantum chemistry output files into structured, immutable `Calcu === "Q-Chem" ```python + from pathlib import Path from calcflow import parse_qchem_output - result = parse_qchem_output(open("calculation.out").read()) + result = parse_qchem_output(Path("calculation.out").read_text()) ``` === "ORCA" ```python + from pathlib import Path from calcflow import parse_orca_output - result = parse_orca_output(open("calculation.out").read()) + result = parse_orca_output(Path("calculation.out").read_text()) ``` The raw output text is stored on `result.raw_output` but is excluded from JSON serialization. @@ -31,9 +33,10 @@ The raw output text is stored on `result.raw_output` but is excluded from JSON s Q-Chem concatenates multiple jobs into a single output file (e.g. a two-job MOM calculation). Use the multi-job parser to get a list of results: ```python +from pathlib import Path from calcflow import parse_qchem_multi_job_output -results = parse_qchem_multi_job_output(open("mom_calc.out").read()) +results = parse_qchem_multi_job_output(Path("mom_calc.out").read_text()) gs_result = results[0] # ground state job es_result = results[1] # excited state job ``` @@ -279,10 +282,11 @@ The full output text (`result.raw_output`) is intentionally excluded from JSON. For UV/Vis spectrum simulation, install the numpy extra and use the `postprocess` module: ```python +from pathlib import Path from calcflow import parse_qchem_output, spectrum_from_excited_states, make_energy_grid import numpy as np -result = parse_qchem_output(open("tddft.out").read()) +result = parse_qchem_output(Path("tddft.out").read_text()) states = result.tddft.tddft_states grid = make_energy_grid( diff --git a/docs/guides/slurm.md b/docs/guides/slurm.md index 47084e5..076b0a8 100644 --- a/docs/guides/slurm.md +++ b/docs/guides/slurm.md @@ -87,6 +87,7 @@ The generated script includes: ## Full Example: Q-Chem TDDFT Workflow ```python +from pathlib import Path from calcflow import CalculationInput, Geometry from calcflow.slurm import SlurmJob @@ -123,11 +124,8 @@ job = SlurmJob( input_text = calc.export("qchem", geom) script = job.export(calc, "qchem", "meoq.inp", "meoq.out") -with open("meoq.inp", "w") as f: - f.write(input_text) - -with open("submit.sh", "w") as f: - f.write(script) +Path("meoq.inp").write_text(input_text) +Path("submit.sh").write_text(script) ``` ## ORCA Example @@ -161,12 +159,12 @@ script = orca_job.export(orca_calc, "orca", "meoq.inp", "meoq.out") ## Saving the Spec for Reproducibility ```python -# Save the calculation spec alongside the job script -with open("calc_spec.json", "w") as f: - f.write(calc.to_json()) +from pathlib import Path + +Path("calc_spec.json").write_text(calc.to_json()) ``` -Reload later with `CalculationInput.from_json(open("calc_spec.json").read())` — so you always know exactly what was run. +Reload later with `CalculationInput.from_json(Path("calc_spec.json").read_text())` — so you always know exactly what was run. ## Partition and Constraint diff --git a/docs/index.md b/docs/index.md index e2c7506..3e07a1d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -76,9 +76,10 @@ For spectrum broadening (`postprocess` module), install the optional numpy extra ## Quick Example ```python +from pathlib import Path from calcflow import parse_qchem_output -result = parse_qchem_output(open("calculation.out").read()) +result = parse_qchem_output(Path("calculation.out").read_text()) print(result.termination_status) # "NORMAL" print(result.final_energy) # -76.4234... (Hartree) diff --git a/docs/quick-start.md b/docs/quick-start.md index 2e8b2d9..33960a8 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -25,17 +25,19 @@ CalcFlow exposes one parse function per program. Pass it the raw output text — === "Q-Chem" ```python + from pathlib import Path from calcflow import parse_qchem_output - result = parse_qchem_output(open("h2o_sp.out").read()) + result = parse_qchem_output(Path("h2o_sp.out").read_text()) ``` === "ORCA" ```python + from pathlib import Path from calcflow import parse_orca_output - result = parse_orca_output(open("h2o_sp.out").read()) + result = parse_orca_output(Path("h2o_sp.out").read_text()) ``` Check termination before trusting the results: @@ -76,14 +78,14 @@ Energies are in **Hartree** unless the field name says otherwise (`_ev`, `_kcal_ ## 3. Serialize and Reload ```python +from pathlib import Path +from calcflow.common.results import CalculationResult + # Save — raw_output is excluded to keep the file compact -with open("result.json", "w") as f: - f.write(result.to_json()) +Path("result.json").write_text(result.to_json()) # Reload -from calcflow.common.results import CalculationResult - -result2 = CalculationResult.from_json(open("result.json").read()) +result2 = CalculationResult.from_json(Path("result.json").read_text()) print(result2.final_energy) ``` @@ -123,8 +125,7 @@ print(qchem_input) Save the spec for reproducibility: ```python -with open("calc_spec.json", "w") as f: - f.write(calc.to_json()) +Path("calc_spec.json").write_text(calc.to_json()) ``` ## 5. Discover the API at Runtime