From ea9f006c93e5d9c8a00e7fae5b929cad9bff305a Mon Sep 17 00:00:00 2001 From: "Jeremiah J. Gassensmith" Date: Fri, 1 May 2026 16:32:06 -0500 Subject: [PATCH 1/3] Add symmetry-graft module inspection and expansion --- PLANS.md | 61 ++ docs/design/symmetry_preserving_graft.md | 77 +++ pyproject.toml | 10 + src/foldfoundry/errors.py | 8 + src/foldfoundry/io/json_io.py | 32 + src/foldfoundry/modules/discovery.py | 5 +- .../modules/symmetry_graft/__init__.py | 53 ++ .../modules/symmetry_graft/apply.py | 155 +++++ .../modules/symmetry_graft/assembly.py | 176 ++++++ src/foldfoundry/modules/symmetry_graft/cli.py | 195 ++++++ src/foldfoundry/modules/symmetry_graft/fit.py | 87 +++ .../modules/symmetry_graft/graft_models.py | 60 ++ .../modules/symmetry_graft/indexing.py | 86 +++ .../modules/symmetry_graft/models.py | 140 +++++ .../modules/symmetry_graft/plugin.py | 105 ++++ .../modules/symmetry_graft/structure_io.py | 218 +++++++ .../modules/symmetry_graft/symmetry.py | 586 ++++++++++++++++++ tests/fixtures/structures/tiny_assembly.cif | 71 +++ tests/fixtures/structures/tiny_biomt.pdb | 18 + .../structures/tiny_composite_assembly.cif | 70 +++ tests/test_assembly_expansion.py | 125 ++++ tests/test_grafting_fit.py | 175 ++++++ tests/test_module_discovery.py | 19 +- tests/test_modules_cli.py | 22 + tests/test_symmetry_inspect.py | 94 +++ uv.lock | 394 ++++++++++++ 26 files changed, 3039 insertions(+), 3 deletions(-) create mode 100644 docs/design/symmetry_preserving_graft.md create mode 100644 src/foldfoundry/io/json_io.py create mode 100644 src/foldfoundry/modules/symmetry_graft/__init__.py create mode 100644 src/foldfoundry/modules/symmetry_graft/apply.py create mode 100644 src/foldfoundry/modules/symmetry_graft/assembly.py create mode 100644 src/foldfoundry/modules/symmetry_graft/cli.py create mode 100644 src/foldfoundry/modules/symmetry_graft/fit.py create mode 100644 src/foldfoundry/modules/symmetry_graft/graft_models.py create mode 100644 src/foldfoundry/modules/symmetry_graft/indexing.py create mode 100644 src/foldfoundry/modules/symmetry_graft/models.py create mode 100644 src/foldfoundry/modules/symmetry_graft/plugin.py create mode 100644 src/foldfoundry/modules/symmetry_graft/structure_io.py create mode 100644 src/foldfoundry/modules/symmetry_graft/symmetry.py create mode 100644 tests/fixtures/structures/tiny_assembly.cif create mode 100644 tests/fixtures/structures/tiny_biomt.pdb create mode 100644 tests/fixtures/structures/tiny_composite_assembly.cif create mode 100644 tests/test_assembly_expansion.py create mode 100644 tests/test_grafting_fit.py create mode 100644 tests/test_symmetry_inspect.py diff --git a/PLANS.md b/PLANS.md index dd78c6e..8be559a 100644 --- a/PLANS.md +++ b/PLANS.md @@ -69,6 +69,67 @@ a protein construct has been chosen and a DNA expression strategy is needed. FoldFoundry's valuable near-term layer is protein construct design before structure prediction, not DNA design. +## Symmetry-Preserving Graft Module + +Long-form design document: `docs/design/symmetry_preserving_graft.md`. + +This feature is implemented as a first-party FoldForge module: +`src/foldfoundry/modules/symmetry_graft/`. + +FoldForge core owns: + +1. Module discovery and loading. +2. CLI, workflow, artifact, and report registration hooks. +3. Shared config, logging, error, and output conventions. + +The `symmetry_graft` module owns: + +1. Gemmi, NumPy, and optional Biotite dependency use. +2. PDB/mmCIF IO and normalized atom/residue indexing. +3. Biological assembly, NCS, crystal packing, and deposited-coordinate + inspection. +4. Biological assembly expansion. +5. Kabsch fitting and source-frame graft atom replacement. +6. Future graft specs, packing shells, clash/contact reports, and model-backend + handoff. + +No symmetry or grafting code should live in root `structures/` or `grafting/` +packages. No `symmetry` or `graft` command should be wired directly in root +`cli.py`; the module registers those command groups through +`ModuleContext.cli`. + +Implementation milestones: + +1. Module-host boundary. + - Keep `symmetry` and `graft` as module-owned CLI groups. + - Register `symmetry_preserving_graft`, `engineered_asu_cif`, + `engineered_assembly_cif`, `engineered_packing_shell_cif`, and + `symmetry_report_json` through module registries. + - Keep Gemmi/NumPy/Biotite out of required root runtime dependencies; expose + them through the symmetry-graft optional extra and dev/test dependencies. +2. Symmetry inspection and reporting. + - Detect mmCIF biological assemblies, PDB BIOMT, mmCIF/PDB NCS, crystal + unit-cell/space-group metadata, and deposited-coordinate fallback. + - Expose `foldforge symmetry inspect STRUCTURE --json --out report.json`. +3. Kabsch graft into source frame. + - Add anchor mapping, rigid fitting, transform application, and strict + scaffold-preservation tests without requiring any model backend. +4. Biological assembly expansion. + - Apply original biological assembly operators to source-frame atom records. + - Write a minimal mmCIF via `foldforge symmetry expand`. + - Verify deterministic fixtures and use real RCSB structures such as `2MS2` + only as smoke tests, not as structure-specific code paths. +5. Crystal packing shell. + - Generate bounded packing shells by cutoff around edited atoms. +6. Clash/contact report. + - Add intra-ASU and symmetry-mate clash/contact checks, cutpoint summaries, + graft RMSD, and structured warnings. +7. Model-backend integration. + - Add `ModelJobSpec` bundles without making AlphaFold, ColabFold, Chai, + Boltz, OpenFold, or any remote service mandatory. +8. GUI integration. + - Add a thin Swift module/controller only after the CLI-backed module works. + ### Roadmap Phases #### Phase 1: Core Engine And UI Boundary diff --git a/docs/design/symmetry_preserving_graft.md b/docs/design/symmetry_preserving_graft.md new file mode 100644 index 0000000..d757032 --- /dev/null +++ b/docs/design/symmetry_preserving_graft.md @@ -0,0 +1,77 @@ +# Symmetry-Preserving Graft Plan + +## Summary Of Current Repo + +- FoldForge is a Python 3.12 package, managed with `uv`, using Typer, Pydantic, Rich, pytest, ruff, and strict mypy. The public CLI is `foldforge`; the package is `foldfoundry`. +- Existing architecture is CLI-first with thin Swift/SwiftUI macOS GUI over CLI-equivalent workflows. There is no TypeScript/React/web UI or API server today, so Mol* belongs only in a later browser UI. +- Relevant current core modules are `models.py` for `FoldSpec`, `io/` for file helpers, `backends/` for AF3/AlphaFold Server export, `results/` for AF3 output parsing, `hpc/` for reviewable bundle workflows, and `gui/macos/` for the thin app shell. +- FoldForge now has a Python module host with `ModuleMetadata`, `ModuleContext`, a `FoldForgeModule` protocol, CLI/workflow/artifact/report registries, built-in module refs, entry-point discovery under `foldforge.modules`, failure-tolerant loading, and `foldforge modules list/info`. +- Symmetry-graft should be the first real scientific module consuming that host. Structure/mmCIF/grafting code belongs under `src/foldfoundry/modules/symmetry_graft/`, not in root `src/foldfoundry/structures/` or `src/foldfoundry/grafting/`. +- Source-backed focused tests passed: `uv run pytest tests/test_af3_results.py tests/test_af3_export.py -q`. The installed `foldforge` entry point is stale in this worktree, so implementation should verify through source or the repo's known reinstall path until launcher state is repaired. + +## Dependencies And Boundaries + +- Add `gemmi` as a symmetry-graft module dependency, not a root runtime dependency. It directly supports PDB/mmCIF coordinate files, structure hierarchy, metadata, neighbor/contact search, NCS, biological assemblies from REMARK 350/mmCIF categories, and crystallographic symmetry ([Gemmi docs](https://gemmi.readthedocs.io/en/stable/mol.html)). +- Add `numpy` as a symmetry-graft module dependency for transform matrices, Kabsch/SVD fitting, RMSD, and stable coordinate arrays. Add `scipy` only when clash/contact performance needs `cKDTree`. +- Use `biotite` as an optional independent mmCIF biological-assembly verification path, especially for `_pdbx_struct_assembly_gen`, `_pdbx_struct_oper_list`, and `atom_site`; its `get_assembly()` consumes those categories directly ([Biotite get_assembly docs](https://www.biotite-python.org/latest/apidoc/biotite.structure.io.pdbx.get_assembly.html)). +- Keep ChimeraX optional only. Keep AlphaFold, ColabFold, Chai, Boltz, and OpenFold outside the symmetry/grafting core. +- If a web UI appears later, use Mol* for browser visualization and viewer-state output; Mol* is a modern open-source web toolkit for large molecular visualization ([Mol*](https://molstar.org/)). + +## Key Implementation Changes + +- Create a first-party module at `src/foldfoundry/modules/symmetry_graft/` for structure IO, atom/residue indexing, transforms, symmetry detection, assembly expansion, packing-shell generation, graft specs, anchor mapping, Kabsch fitting, atom replacement, cutpoint checks, clash/contact analysis, report generation, and orchestration. +- Register the module from `src/foldfoundry/modules/symmetry_graft/plugin.py`. It owns `symmetry` and `graft` CLI groups through `ctx.cli.add_command_group(...)`, and owns workflow, artifact, and report metadata through the module registries. +- Do not hardcode new symmetry-graft command groups in root `src/foldfoundry/cli.py`; root CLI should only load the module host. +- Use runtime dataclasses for non-serializable handles such as `StructureHandle` carrying Gemmi objects. Use Pydantic `StrictModel` style for serializable specs/reports: `Transform`, `SymmetryContext`, `EditSpec`, `ModelJobSpec`, `GraftResult`, and `SymmetryReport`. +- Add module-owned Typer subcommands matching current CLI conventions: + - `foldforge symmetry inspect input.cif --json` + - `foldforge graft prepare input.cif --chain A --mode graft_external_domain --anchor A:10-80 --context-distance 10 --out-dir runs//graft` + - `foldforge graft apply input.cif model.cif --spec graft_spec.json --symmetry biological_assembly --assembly-id 1 --out-dir runs//graft` + - `foldforge graft analyze engineered_asu.cif --assembly engineered_assembly.cif --out foldforge_symmetry_report.json` +- Keep the macOS GUI as a later thin module that shells out to those CLI commands and displays paths, reports, and warnings. Do not add Swift structure logic. +- Do not add API routes now; none exist. If a server API is later introduced, expose the same command-shaped workflow: inspect, prepare, apply, analyze, download bundle. + +## Algorithmic Flow + +- Parse PDB/mmCIF with Gemmi, prefer mmCIF internally, call entity setup, normalize atom keys, chain IDs, residue IDs, insertion codes, altloc policy, and author-vs-label identifiers. +- Detect preservation modes: biological assemblies from mmCIF/REMARK 350, NCS from MTRIX/mmCIF NCS records, crystal packing from unit cell/space group, or deposited coordinates fallback. +- Classify equivalent chains using bounded evidence: sequence identity first, then optional backbone RMSD/environment comparison. Warn when sequence-identical chains are structurally non-equivalent. +- `prepare` extracts edited chain/local region plus nearby context and writes a local model-job bundle without choosing or requiring a modeler. +- `apply` imports modeled PDB/mmCIF, maps shared anchor atoms, computes a rigid Kabsch fit from modeled anchors to native anchors, transforms only replacement atoms, and preserves untouched scaffold coordinates exactly. +- Propagate the engineered source-frame unit through the selected original operators. For crystal packing, generate only a bounded shell within a user cutoff around edited atoms. +- Analyze heavy-atom clashes within source-frame coordinates and across mates, cutpoint geometry, anchor RMSD, minimum graft-to-mate distances, contact loss/gain, operator mapping, scaffold drift, and model confidence warnings when metadata exists. +- Write `engineered_asu.cif`, optional `engineered_assembly.cif`, optional `engineered_packing_shell.cif`, and `foldforge_symmetry_report.json`. + +## Test Plan + +- Unit tests: Kabsch transform recovery, distance preservation, anchor RMSD, scaffold coordinate invariance outside replacement regions, replacement atom transform application, cutpoint checks, clash detection with bonded-neighbor exclusions, chain-equivalence classification, and chain/operator ID collision handling. +- Symmetry tests: small mmCIF assembly fixture, PDB BIOMT fixture, MTRIX/NCS fixture when supported, and a simple crystal cell for bounded packing-shell neighbor counts. +- Integration tests: mock model output by applying a known transform to a synthetic edited chain, verify graft returns to native frame, verify assembly expansion operator count/chain mapping, and validate JSON report schema. +- Prefer semantic assertions over fragile golden atom-order snapshots: atom counts, operator IDs, RMSD thresholds, chain mapping, clash counts, and unchanged coordinate hashes for preserved scaffold atoms. +- Required repo checks after implementation: `uv run pytest`, `uv run ruff check .`, `uv run ruff format --check .`, and `uv run mypy src`. + +## Milestones + +- Milestone 1: module-owned Gemmi-backed `symmetry inspect` with JSON report for assemblies, NCS, crystal metadata, deposited fallback, warnings, and tests. +- Milestone 2: module-owned Kabsch graft into source frame for `graft_external_domain` and `replace_local_region`, preserving scaffold coordinates. +- Milestone 3: module-owned biological assembly expansion with Gemmi as primary and Biotite as independent mmCIF verification. +- Milestone 4: Bounded crystal packing shell generation by cutoff around edited atoms. +- Milestone 5: Clash/contact/cutpoint report with machine-readable `SymmetryReport`. +- Milestone 6: Model-backend integration through `ModelJobSpec` bundles and existing backend/export boundaries, without making any modeler mandatory. +- Milestone 7: Swift GUI workflow module, and only if a browser UI exists later, Mol* viewer state/export. + +## Risks And Human Review + +- Human review needed for default thresholds: anchor RMSD fail/warn, clash distance, scaffold drift, contact-loss scoring, and packing-shell cutoff. +- Altloc, insertion-code, author-vs-label residue numbering, missing atoms, and model/native residue mismatch policy should be explicit in Milestone 1. +- Crystal packing can explode in size; source-frame ASU must remain the primary artifact and shell generation must be cutoff-bounded by default. +- `replace_chain` is useful but riskier than `graft_external_domain`; default the workflow to `graft_external_domain`. +- Avoid folding these models into `FoldSpec` until the interface is proven. Treat graft specs as separate local workflow specs that can reference a source structure and model-job artifacts. + +## Recommended First PR + +- Scope: module host first, then symmetry-graft as a module. Avoid mixing host infrastructure, GUI work, HPC/browser changes, and scientific implementation in one PR. +- Add or preserve module host tests for built-in discovery, entry-point discovery, failure-tolerant loading, duplicate-name behavior, module CLI registration, and `foldforge modules list/info`. +- Add `src/foldfoundry/modules/symmetry_graft/plugin.py` and module-owned `cli.py` before adding more algorithms. Dependencies should be module metadata and optional extras/dev test dependencies, not required root runtime dependencies. +- Place structure and grafting implementation under `src/foldfoundry/modules/symmetry_graft/`. +- Output only inspect/assembly summaries in early PRs; do not call modelers, launch viewers, or touch the Swift GUI until the CLI-backed module is stable. diff --git a/pyproject.toml b/pyproject.toml index ee3947a..199d902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,13 +17,23 @@ dependencies = [ "typer>=0.12", ] +[project.optional-dependencies] +symmetry-graft = [ + "biotite>=1.6.0", + "gemmi>=0.7.0", + "numpy>=2.0", +] + [project.scripts] foldforge = "foldfoundry.launcher:main" foldfoundry = "foldfoundry.cli:main" [dependency-groups] dev = [ + "biotite>=1.6.0", + "gemmi>=0.7.0", "mypy>=1.10", + "numpy>=2.0", "pytest>=8.2", "pytest-cov>=6.0", "ruff>=0.5", diff --git a/src/foldfoundry/errors.py b/src/foldfoundry/errors.py index 96c82e2..8561798 100644 --- a/src/foldfoundry/errors.py +++ b/src/foldfoundry/errors.py @@ -30,6 +30,14 @@ class ResultParseError(FoldFoundryError): """Raised when backend results cannot be parsed.""" +class StructureError(FoldFoundryError): + """Raised when a structure file or its metadata cannot be inspected.""" + + +class GraftError(FoldFoundryError): + """Raised when a source-frame graft cannot be prepared or applied.""" + + _UNION_LABELS = {"protein", "dna", "rna", "ligand"} diff --git a/src/foldfoundry/io/json_io.py b/src/foldfoundry/io/json_io.py new file mode 100644 index 0000000..ebdbaeb --- /dev/null +++ b/src/foldfoundry/io/json_io.py @@ -0,0 +1,32 @@ +"""JSON file writing helpers.""" + +import json +from pathlib import Path +from typing import Any + +from foldfoundry.errors import ExportError + + +def write_json( + payload: Any, + output_path: Path, + *, + force: bool = False, + sort_keys: bool = False, + directory_message: str = "--out must be a file path, not a directory", + error_label: str = "could not write JSON", +) -> None: + """Write indented JSON with consistent overwrite and directory checks.""" + if output_path.exists() and output_path.is_dir(): + raise ExportError(f"{output_path}: {directory_message}") + if output_path.exists() and not force: + raise ExportError(f"{output_path}: file exists; pass --force to overwrite") + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(payload, indent=2, sort_keys=sort_keys) + "\n", + encoding="utf-8", + ) + except OSError as exc: + raise ExportError(f"{output_path}: {error_label}: {exc}") from exc diff --git a/src/foldfoundry/modules/discovery.py b/src/foldfoundry/modules/discovery.py index aedb25b..65b1d79 100644 --- a/src/foldfoundry/modules/discovery.py +++ b/src/foldfoundry/modules/discovery.py @@ -19,7 +19,10 @@ ) ENTRY_POINT_GROUP = "foldforge.modules" -BUILTIN_MODULES: tuple[str, ...] = ("foldfoundry.modules.example_hello.plugin:module",) +BUILTIN_MODULES: tuple[str, ...] = ( + "foldfoundry.modules.example_hello.plugin:module", + "foldfoundry.modules.symmetry_graft.plugin:module", +) ModuleStatus = Literal["loaded", "disabled", "failed"] ModuleSource = Literal["builtin", "entry_point", "local_path"] diff --git a/src/foldfoundry/modules/symmetry_graft/__init__.py b/src/foldfoundry/modules/symmetry_graft/__init__.py new file mode 100644 index 0000000..7e33275 --- /dev/null +++ b/src/foldfoundry/modules/symmetry_graft/__init__.py @@ -0,0 +1,53 @@ +"""First-party symmetry-preserving graft module.""" + +from foldfoundry.modules.symmetry_graft.apply import ( + atom_key, + fit_anchor_atoms, + graft_replacement_atoms, + map_anchor_atoms, + transform_atom, +) +from foldfoundry.modules.symmetry_graft.assembly import ( + expand_biological_assembly, + expand_biological_assembly_file, + select_biological_assembly_context, +) +from foldfoundry.modules.symmetry_graft.fit import ( + apply_transform, + coordinate_array, + kabsch_fit, + rmsd, +) +from foldfoundry.modules.symmetry_graft.graft_models import ( + AnchorFitResult, + EditSpec, + GraftAtomApplicationResult, +) +from foldfoundry.modules.symmetry_graft.structure_io import ( + detect_structure_format, + load_structure, + write_atom_records_mmcif, +) +from foldfoundry.modules.symmetry_graft.symmetry import inspect_structure + +__all__ = [ + "AnchorFitResult", + "EditSpec", + "GraftAtomApplicationResult", + "apply_transform", + "atom_key", + "coordinate_array", + "detect_structure_format", + "expand_biological_assembly", + "expand_biological_assembly_file", + "fit_anchor_atoms", + "graft_replacement_atoms", + "inspect_structure", + "kabsch_fit", + "load_structure", + "map_anchor_atoms", + "rmsd", + "select_biological_assembly_context", + "transform_atom", + "write_atom_records_mmcif", +] diff --git a/src/foldfoundry/modules/symmetry_graft/apply.py b/src/foldfoundry/modules/symmetry_graft/apply.py new file mode 100644 index 0000000..0b3ec57 --- /dev/null +++ b/src/foldfoundry/modules/symmetry_graft/apply.py @@ -0,0 +1,155 @@ +"""Source-frame atom mapping and replacement helpers.""" + +from collections.abc import Iterable, Sequence +from dataclasses import replace + +from foldfoundry.errors import GraftError +from foldfoundry.modules.symmetry_graft.fit import apply_transform, kabsch_fit +from foldfoundry.modules.symmetry_graft.graft_models import ( + AnchorFitResult, + AtomKey, + GraftAtomApplicationResult, +) +from foldfoundry.modules.symmetry_graft.models import AtomRecord + + +def atom_key(atom: AtomRecord) -> AtomKey: + """Return the normalized key used to pair native and modeled atoms.""" + return ( + atom.model_id, + atom.chain_id, + atom.residue_number, + atom.insertion_code, + atom.atom_name, + atom.altloc, + ) + + +def map_anchor_atoms( + native_atoms: Sequence[AtomRecord], + model_atoms: Sequence[AtomRecord], + anchor_keys: Sequence[AtomKey], +) -> tuple[tuple[AtomRecord, ...], tuple[AtomRecord, ...]]: + """Return native/model anchor atoms ordered by explicit normalized keys.""" + if len(anchor_keys) < 3: + raise GraftError("at least three anchor atom keys are required") + + native_by_key = _unique_atom_map(native_atoms, label="native") + model_by_key = _unique_atom_map(model_atoms, label="model") + missing_native = [key for key in anchor_keys if key not in native_by_key] + missing_model = [key for key in anchor_keys if key not in model_by_key] + if missing_native or missing_model: + messages: list[str] = [] + if missing_native: + messages.append( + "missing native anchor atoms: " + + ", ".join(_format_atom_key(key) for key in missing_native) + ) + if missing_model: + messages.append( + "missing model anchor atoms: " + + ", ".join(_format_atom_key(key) for key in missing_model) + ) + raise GraftError("; ".join(messages)) + + return ( + tuple(native_by_key[key] for key in anchor_keys), + tuple(model_by_key[key] for key in anchor_keys), + ) + + +def fit_anchor_atoms( + native_atoms: Sequence[AtomRecord], + model_atoms: Sequence[AtomRecord], + anchor_keys: Sequence[AtomKey], +) -> AnchorFitResult: + """Fit modeled anchor atoms onto native source-frame anchor atoms.""" + native_anchors, model_anchors = map_anchor_atoms(native_atoms, model_atoms, anchor_keys) + return kabsch_fit( + [_atom_coordinates(atom) for atom in model_anchors], + [_atom_coordinates(atom) for atom in native_anchors], + ) + + +def transform_atom(atom: AtomRecord, transform: AnchorFitResult) -> AtomRecord: + """Apply a rigid fit transform to one atom record without changing its identifier.""" + transformed = apply_transform([_atom_coordinates(atom)], transform)[0] + return replace( + atom, + x=float(transformed[0]), + y=float(transformed[1]), + z=float(transformed[2]), + ) + + +def graft_replacement_atoms( + native_atoms: Sequence[AtomRecord], + replacement_atoms: Sequence[AtomRecord], + transform: AnchorFitResult, + *, + remove_native_keys: Iterable[AtomKey] = (), +) -> tuple[tuple[AtomRecord, ...], GraftAtomApplicationResult]: + """Replace selected native atoms with transformed model atoms, preserving the scaffold.""" + native_tuple = tuple(native_atoms) + replacement_tuple = tuple(replacement_atoms) + if not replacement_tuple: + raise GraftError("at least one replacement atom is required") + + remove_keys = set(remove_native_keys) + native_keys = {atom_key(atom) for atom in native_tuple} + warnings = [ + "remove_native_keys not present in native atoms: " + + ", ".join(_format_atom_key(key) for key in sorted(missing, key=_format_atom_key)) + for missing in [remove_keys - native_keys] + if missing + ] + + preserved_atoms = tuple(atom for atom in native_tuple if atom_key(atom) not in remove_keys) + preserved_keys = {atom_key(atom) for atom in preserved_atoms} + transformed_replacements = tuple(transform_atom(atom, transform) for atom in replacement_tuple) + replacement_keys = {atom_key(atom) for atom in transformed_replacements} + collisions = preserved_keys & replacement_keys + if collisions: + warnings.append( + "replacement atom keys collide with preserved scaffold atoms: " + + ", ".join(_format_atom_key(key) for key in sorted(collisions, key=_format_atom_key)) + ) + + engineered_atoms = preserved_atoms + transformed_replacements + return engineered_atoms, GraftAtomApplicationResult( + atom_count=len(engineered_atoms), + preserved_atom_count=len(preserved_atoms), + removed_native_atom_count=len(native_tuple) - len(preserved_atoms), + inserted_atom_count=len(transformed_replacements), + warnings=warnings, + ) + + +def _unique_atom_map(atoms: Sequence[AtomRecord], *, label: str) -> dict[AtomKey, AtomRecord]: + by_key: dict[AtomKey, AtomRecord] = {} + duplicates: list[AtomKey] = [] + for atom in atoms: + key = atom_key(atom) + if key in by_key: + duplicates.append(key) + continue + by_key[key] = atom + if duplicates: + duplicate_labels = ", ".join( + _format_atom_key(key) for key in sorted(set(duplicates), key=_format_atom_key) + ) + raise GraftError(f"ambiguous {label} atom keys: " + duplicate_labels) + return by_key + + +def _atom_coordinates(atom: AtomRecord) -> tuple[float, float, float]: + return (atom.x, atom.y, atom.z) + + +def _format_atom_key(key: AtomKey) -> str: + model_id, chain_id, residue_number, insertion_code, atom_name, altloc = key + residue = "?" if residue_number is None else str(residue_number) + if insertion_code: + residue += insertion_code + suffix = f"/{altloc}" if altloc else "" + return f"{model_id}:{chain_id}:{residue}:{atom_name}{suffix}" diff --git a/src/foldfoundry/modules/symmetry_graft/assembly.py b/src/foldfoundry/modules/symmetry_graft/assembly.py new file mode 100644 index 0000000..ede07c8 --- /dev/null +++ b/src/foldfoundry/modules/symmetry_graft/assembly.py @@ -0,0 +1,176 @@ +"""Biological assembly expansion helpers.""" + +from collections.abc import Iterable, Sequence +from dataclasses import replace +from pathlib import Path + +import numpy as np + +from foldfoundry.errors import StructureError +from foldfoundry.modules.symmetry_graft.models import ( + AssemblyExpansionResult, + AtomRecord, + CoordinateFrameMetadata, + StructureHandle, + SymmetryContext, + Transform, +) +from foldfoundry.modules.symmetry_graft.structure_io import load_structure +from foldfoundry.modules.symmetry_graft.symmetry import inspect_structure + + +def expand_biological_assembly( + handle: StructureHandle, + context: SymmetryContext, +) -> tuple[tuple[AtomRecord, ...], AssemblyExpansionResult]: + """Apply biological assembly operators to source-frame atoms.""" + if context.mode != "biological_assembly": + raise StructureError("assembly expansion requires a biological_assembly context") + if not context.operators: + raise StructureError("biological assembly context has no operators") + + operators_by_id = {operator.id: operator for operator in context.operators} + source_chain_ids = _ordered_unique(atom.chain_id for atom in handle.atoms) + chain_operator_mapping = context.chain_operator_mapping or { + chain_id: [operator.id for operator in context.operators] for chain_id in source_chain_ids + } + + expanded_atoms: list[AtomRecord] = [] + chain_copy_mapping: dict[str, str] = {} + used_chain_ids: set[str] = set() + warnings: list[str] = [] + for source_chain_id in source_chain_ids: + operator_ids = chain_operator_mapping.get(source_chain_id, []) + if not operator_ids: + warnings.append(f"chain {source_chain_id} has no assembly operators; skipped") + continue + + source_atoms = [atom for atom in handle.atoms if atom.chain_id == source_chain_id] + for operator_id in operator_ids: + operator = operators_by_id.get(operator_id) + if operator is None: + warnings.append( + f"operator {operator_id} referenced for chain {source_chain_id} is missing" + ) + continue + + output_chain_id = _copy_chain_id( + source_chain_id, + operator, + used_chain_ids=used_chain_ids, + ) + chain_copy_mapping[f"{source_chain_id}:{operator_id}"] = output_chain_id + expanded_atoms.extend( + transform_atom_record(atom, operator, chain_id=output_chain_id) + for atom in source_atoms + ) + + result = AssemblyExpansionResult( + source_path=str(handle.source_path), + assembly_id=context.assembly_id, + atom_count=len(expanded_atoms), + chain_count=len({atom.chain_id for atom in expanded_atoms}), + operator_count=len(context.operators), + chain_copy_mapping=chain_copy_mapping, + warnings=warnings, + ) + return tuple(expanded_atoms), result + + +def expand_biological_assembly_file( + structure_file: Path, + *, + assembly_id: str | None = None, +) -> tuple[tuple[AtomRecord, ...], AssemblyExpansionResult, CoordinateFrameMetadata]: + """Load a structure file and expand one detected biological assembly.""" + handle = load_structure(structure_file) + report = inspect_structure(structure_file) + context = select_biological_assembly_context(report.symmetry_contexts, assembly_id=assembly_id) + expanded_atoms, result = expand_biological_assembly(handle, context) + return expanded_atoms, result, handle.coordinate_frame + + +def select_biological_assembly_context( + contexts: Sequence[SymmetryContext], + *, + assembly_id: str | None = None, +) -> SymmetryContext: + """Select a biological assembly context, requiring an ID when ambiguous.""" + biological_contexts = [context for context in contexts if context.mode == "biological_assembly"] + if not biological_contexts: + raise StructureError("no biological assembly metadata detected") + if assembly_id is not None: + for context in biological_contexts: + if context.assembly_id == assembly_id: + return context + available = ", ".join(context.assembly_id or "?" for context in biological_contexts) + raise StructureError( + f"assembly {assembly_id!r} not found; available assemblies: {available}" + ) + if len(biological_contexts) > 1: + available = ", ".join(context.assembly_id or "?" for context in biological_contexts) + raise StructureError( + f"multiple biological assemblies detected; choose --assembly-id {available}" + ) + return biological_contexts[0] + + +def transform_atom_record( + atom: AtomRecord, + transform: Transform, + *, + chain_id: str | None = None, +) -> AtomRecord: + """Apply a source metadata transform to one atom record.""" + rotation = np.asarray(transform.rotation, dtype=np.float64) + translation = np.asarray(transform.translation, dtype=np.float64) + coordinate = np.asarray([atom.x, atom.y, atom.z], dtype=np.float64) + transformed = coordinate @ rotation.T + translation + return replace( + atom, + chain_id=chain_id or atom.chain_id, + x=float(transformed[0]), + y=float(transformed[1]), + z=float(transformed[2]), + ) + + +def _copy_chain_id( + source_chain_id: str, + operator: Transform, + *, + used_chain_ids: set[str], +) -> str: + if _is_identity_transform(operator) and source_chain_id not in used_chain_ids: + used_chain_ids.add(source_chain_id) + return source_chain_id + + base = _safe_chain_id(f"{source_chain_id}_{operator.id}") + candidate = base + counter = 2 + while candidate in used_chain_ids: + candidate = _safe_chain_id(f"{base}_{counter}") + counter += 1 + used_chain_ids.add(candidate) + return candidate + + +def _is_identity_transform(transform: Transform) -> bool: + rotation = np.asarray(transform.rotation, dtype=np.float64) + translation = np.asarray(transform.translation, dtype=np.float64) + return bool(np.allclose(rotation, np.eye(3), atol=1e-8) and np.allclose(translation, 0.0)) + + +def _safe_chain_id(value: str) -> str: + safe = "".join(character if character.isalnum() else "_" for character in value).strip("_") + return safe or "copy" + + +def _ordered_unique(values: Iterable[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + if value not in seen: + seen.add(value) + result.append(value) + return result diff --git a/src/foldfoundry/modules/symmetry_graft/cli.py b/src/foldfoundry/modules/symmetry_graft/cli.py new file mode 100644 index 0000000..066cd65 --- /dev/null +++ b/src/foldfoundry/modules/symmetry_graft/cli.py @@ -0,0 +1,195 @@ +"""CLI command groups for the symmetry-graft module.""" + +import json +from pathlib import Path +from typing import Annotated, Any, NoReturn + +import typer +from rich.console import Console + +from foldfoundry.errors import ExportError, StructureError +from foldfoundry.io.json_io import write_json + +symmetry_app = typer.Typer(help="Inspect and expand structure symmetry metadata.") +graft_app = typer.Typer( + help="Prepare and apply symmetry-preserving graft workflows.", + no_args_is_help=True, +) +console = Console(soft_wrap=True) +err_console = Console(stderr=True, soft_wrap=True) + + +@symmetry_app.command("inspect") +def symmetry_inspect_command( + structure_file: Annotated[ + Path, + typer.Argument( + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Path to a PDB, .cif, or .mmcif structure file.", + ), + ], + json_output: Annotated[ + bool, + typer.Option("--json", help="Print the machine-readable inspection report as JSON."), + ] = False, + out: Annotated[ + Path | None, + typer.Option("--out", help="Optional JSON report output path."), + ] = None, + force: Annotated[ + bool, + typer.Option("--force", help="Overwrite --out if it exists."), + ] = False, +) -> None: + """Inspect biological assembly, NCS, crystal, and deposited-coordinate metadata.""" + from foldfoundry.modules.symmetry_graft.symmetry import inspect_structure + + try: + report = inspect_structure(structure_file) + except StructureError as exc: + _exit_structure_error(str(exc)) + + payload = report.model_dump(mode="json", exclude_none=True) + if out is not None: + try: + write_json( + payload, + out, + force=force, + sort_keys=True, + error_label="could not write symmetry report", + ) + except ExportError as exc: + _exit_export_error(str(exc)) + console.print(f"OK: wrote symmetry report to {out}.") + + if json_output: + console.print(json.dumps(payload, indent=2, sort_keys=True)) + return + + _print_symmetry_report_summary(report) + + +@symmetry_app.command("expand") +def symmetry_expand_command( + structure_file: Annotated[ + Path, + typer.Argument( + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Path to a PDB, .cif, or .mmcif structure file.", + ), + ], + assembly_id: Annotated[ + str | None, + typer.Option("--assembly-id", help="Biological assembly ID to expand."), + ] = None, + out: Annotated[ + Path, + typer.Option("--out", help="Output mmCIF path for the expanded biological assembly."), + ] = Path("engineered_assembly.cif"), + report_out: Annotated[ + Path | None, + typer.Option("--report-out", help="Optional JSON summary output path."), + ] = None, + json_output: Annotated[ + bool, + typer.Option("--json", help="Print the machine-readable expansion summary as JSON."), + ] = False, + force: Annotated[ + bool, + typer.Option("--force", help="Overwrite output files if they exist."), + ] = False, +) -> None: + """Expand one biological assembly and write a source-frame mmCIF artifact.""" + from foldfoundry.modules.symmetry_graft.assembly import expand_biological_assembly_file + from foldfoundry.modules.symmetry_graft.structure_io import write_atom_records_mmcif + + try: + atoms, result, coordinate_frame = expand_biological_assembly_file( + structure_file, + assembly_id=assembly_id, + ) + output_path = write_atom_records_mmcif( + atoms, + out, + coordinate_frame=coordinate_frame, + structure_name=f"foldforge_assembly_{result.assembly_id or '1'}", + force=force, + ) + except StructureError as exc: + _exit_structure_error(str(exc)) + + payload = result.model_dump(mode="json", exclude_none=True) + if report_out is not None: + try: + write_json( + payload, + report_out, + force=force, + sort_keys=True, + error_label="could not write assembly expansion report", + ) + except ExportError as exc: + _exit_export_error(str(exc)) + console.print(f"OK: wrote assembly expansion report to {report_out}.") + + console.print(f"OK: wrote biological assembly to {output_path}.") + console.print( + f"Assembly {result.assembly_id or '?'}: " + f"{result.atom_count} atoms, {result.chain_count} chains, " + f"{result.operator_count} operator(s)." + ) + for warning in result.warnings: + console.print(f"warning: {warning}") + if json_output: + console.print(json.dumps(payload, indent=2, sort_keys=True)) + + +def _print_symmetry_report_summary(report: Any) -> None: + console.print(f"Structure: {report.source_path}") + console.print(f"Format: {report.format}") + console.print( + f"Models: {report.model_count} Chains: {report.chain_count} Atoms: {report.atom_count}" + ) + if report.coordinate_frame.unit_cell: + cell = report.coordinate_frame.unit_cell + console.print( + "Unit cell: " + f"a={cell.get('a', 0.0):.3f} b={cell.get('b', 0.0):.3f} " + f"c={cell.get('c', 0.0):.3f} alpha={cell.get('alpha', 0.0):.2f} " + f"beta={cell.get('beta', 0.0):.2f} gamma={cell.get('gamma', 0.0):.2f}" + ) + if report.coordinate_frame.space_group: + console.print(f"Space group: {report.coordinate_frame.space_group}") + console.print("Chains:") + for chain in report.chains: + console.print( + f" {chain.model_id}/{chain.chain_id}: " + f"{chain.residue_count} residues, {chain.atom_count} atoms" + ) + console.print("Symmetry contexts:") + for context in report.symmetry_contexts: + label = str(context.mode) + if context.assembly_id: + label += f" {context.assembly_id}" + console.print(f" {label}: {len(context.operators)} operator(s) - {context.description}") + for warning in context.warnings: + console.print(f" warning: {warning}") + for warning in report.warnings: + console.print(f"warning: {warning}") + + +def _exit_structure_error(message: str) -> NoReturn: + err_console.print(f"structure error: {message}") + raise typer.Exit(2) + + +def _exit_export_error(message: str) -> NoReturn: + err_console.print(f"export error: {message}") + raise typer.Exit(2) diff --git a/src/foldfoundry/modules/symmetry_graft/fit.py b/src/foldfoundry/modules/symmetry_graft/fit.py new file mode 100644 index 0000000..2a66222 --- /dev/null +++ b/src/foldfoundry/modules/symmetry_graft/fit.py @@ -0,0 +1,87 @@ +"""Coordinate fitting utilities for grafting modeled atoms into source frames.""" + +from collections.abc import Sequence + +import numpy as np +import numpy.typing as npt + +from foldfoundry.errors import GraftError +from foldfoundry.modules.symmetry_graft.graft_models import AnchorFitResult + +type CoordinateArray = npt.NDArray[np.float64] +type CoordinateLike = Sequence[float] | npt.NDArray[np.float64] +type CoordinateInput = Sequence[CoordinateLike] | npt.NDArray[np.float64] + + +def coordinate_array(points: CoordinateInput) -> CoordinateArray: + """Return validated Nx3 float64 coordinates.""" + coordinates = np.asarray(points, dtype=np.float64) + if coordinates.ndim != 2 or coordinates.shape[1] != 3: + raise GraftError("coordinates must have shape Nx3") + if coordinates.shape[0] == 0: + raise GraftError("at least one coordinate is required") + if not np.isfinite(coordinates).all(): + raise GraftError("coordinates must be finite") + return coordinates + + +def kabsch_fit( + mobile_points: CoordinateInput, + target_points: CoordinateInput, +) -> AnchorFitResult: + """Fit mobile anchor coordinates onto native target coordinates with Kabsch/SVD.""" + mobile = coordinate_array(mobile_points) + target = coordinate_array(target_points) + if mobile.shape != target.shape: + raise GraftError("mobile and target coordinates must have the same shape") + if mobile.shape[0] < 3: + raise GraftError("at least three anchor atoms are required for a rigid 3D fit") + + mobile_centroid = mobile.mean(axis=0) + target_centroid = target.mean(axis=0) + mobile_centered = mobile - mobile_centroid + target_centered = target - target_centroid + covariance = mobile_centered.T @ target_centered + u_matrix, _singular_values, vt_matrix = np.linalg.svd(covariance) + rotation = vt_matrix.T @ u_matrix.T + + if float(np.linalg.det(rotation)) < 0.0: + vt_matrix[-1, :] *= -1.0 + rotation = vt_matrix.T @ u_matrix.T + + translation = target_centroid - mobile_centroid @ rotation.T + fitted = mobile @ rotation.T + translation + anchor_rmsd = rmsd(fitted, target) + return AnchorFitResult( + rotation=rotation.tolist(), + translation=translation.tolist(), + anchor_rmsd=anchor_rmsd, + anchor_count=int(mobile.shape[0]), + ) + + +def apply_transform( + points: CoordinateInput, + transform: AnchorFitResult, +) -> CoordinateArray: + """Apply a row-vector rigid transform to Nx3 coordinates.""" + coordinates = coordinate_array(points) + rotation = coordinate_array(transform.rotation) + translation = np.asarray(transform.translation, dtype=np.float64) + if translation.shape != (3,): + raise GraftError("transform translation must be a 3-vector") + transformed = coordinates @ rotation.T + translation + return transformed + + +def rmsd( + observed_points: CoordinateInput, + expected_points: CoordinateInput, +) -> float: + """Return root-mean-square distance between paired coordinates.""" + observed = coordinate_array(observed_points) + expected = coordinate_array(expected_points) + if observed.shape != expected.shape: + raise GraftError("RMSD coordinates must have the same shape") + squared_distances = np.sum((observed - expected) ** 2, axis=1) + return float(np.sqrt(np.mean(squared_distances))) diff --git a/src/foldfoundry/modules/symmetry_graft/graft_models.py b/src/foldfoundry/modules/symmetry_graft/graft_models.py new file mode 100644 index 0000000..6459a17 --- /dev/null +++ b/src/foldfoundry/modules/symmetry_graft/graft_models.py @@ -0,0 +1,60 @@ +"""Serializable grafting models for source-frame replacement workflows.""" + +from typing import Literal + +from pydantic import Field, field_validator + +from foldfoundry.models import NonEmptyStr, StrictModel + +GraftMode = Literal[ + "graft_external_domain", + "replace_local_region", + "replace_chain", + "decorate_existing_assembly", +] +AtomKey = tuple[str, str, int | None, str | None, str, str | None] + + +class AnchorFitResult(StrictModel): + """Rigid fit that maps modeled anchor atoms into native source coordinates.""" + + rotation: list[list[float]] + translation: list[float] + anchor_rmsd: float + anchor_count: int + + @field_validator("rotation") + @classmethod + def validate_rotation_shape(cls, rotation: list[list[float]]) -> list[list[float]]: + """Require a 3x3 rotation matrix.""" + if len(rotation) != 3 or any(len(row) != 3 for row in rotation): + raise ValueError("rotation must be a 3x3 matrix") + return rotation + + @field_validator("translation") + @classmethod + def validate_translation_shape(cls, translation: list[float]) -> list[float]: + """Require a 3-vector translation.""" + if len(translation) != 3: + raise ValueError("translation must be a 3-vector") + return translation + + +class EditSpec(StrictModel): + """Minimal persisted edit target spec for early grafting slices.""" + + target_chains: list[NonEmptyStr] = Field(default_factory=list) + target_residues: list[int] = Field(default_factory=list) + graft_mode: GraftMode = "graft_external_domain" + anchor_atom_keys: list[AtomKey] = Field(default_factory=list) + replacement_atom_keys: list[AtomKey] = Field(default_factory=list) + + +class GraftAtomApplicationResult(StrictModel): + """Summary for atom-record replacement into the source coordinate frame.""" + + atom_count: int + preserved_atom_count: int + removed_native_atom_count: int + inserted_atom_count: int + warnings: list[str] = Field(default_factory=list) diff --git a/src/foldfoundry/modules/symmetry_graft/indexing.py b/src/foldfoundry/modules/symmetry_graft/indexing.py new file mode 100644 index 0000000..3bd7d12 --- /dev/null +++ b/src/foldfoundry/modules/symmetry_graft/indexing.py @@ -0,0 +1,86 @@ +"""Atom and chain indexing helpers for parsed structures.""" + +from collections import defaultdict +from typing import Any + +from foldfoundry.modules.symmetry_graft.models import AtomRecord, ChainSummary + + +def index_structure_atoms(structure: Any) -> tuple[AtomRecord, ...]: + """Return normalized atom records from a Gemmi structure hierarchy.""" + records: list[AtomRecord] = [] + for model_index, model in enumerate(structure, start=1): + model_id = _model_id(model, model_index) + for chain in model: + chain_id = str(getattr(chain, "name", "") or "?") + for residue in chain: + seqid = getattr(residue, "seqid", None) + residue_number = _optional_int(getattr(seqid, "num", None)) + insertion_code = _normalize_optional(getattr(seqid, "icode", None)) + residue_name = str(getattr(residue, "name", "") or "?") + for atom in residue: + position = getattr(atom, "pos", None) + element = getattr(atom, "element", None) + records.append( + AtomRecord( + model_id=model_id, + chain_id=chain_id, + residue_name=residue_name, + residue_number=residue_number, + insertion_code=insertion_code, + atom_name=str(getattr(atom, "name", "") or "?"), + altloc=_normalize_optional(getattr(atom, "altloc", None)), + element=_normalize_optional(getattr(element, "name", None)), + x=float(getattr(position, "x", 0.0)), + y=float(getattr(position, "y", 0.0)), + z=float(getattr(position, "z", 0.0)), + ) + ) + return tuple(records) + + +def summarize_chains(atoms: tuple[AtomRecord, ...]) -> list[ChainSummary]: + """Summarize atom and residue counts per model/chain.""" + atoms_by_chain: dict[tuple[str, str], int] = defaultdict(int) + residues_by_chain: dict[tuple[str, str], set[tuple[str, str, int | None, str | None]]] = ( + defaultdict(set) + ) + for atom in atoms: + key = (atom.model_id, atom.chain_id) + atoms_by_chain[key] += 1 + residues_by_chain[key].add(atom.residue_key) + + return [ + ChainSummary( + model_id=model_id, + chain_id=chain_id, + atom_count=atoms_by_chain[(model_id, chain_id)], + residue_count=len(residues_by_chain[(model_id, chain_id)]), + ) + for model_id, chain_id in sorted(atoms_by_chain) + ] + + +def _model_id(model: Any, fallback_index: int) -> str: + name = getattr(model, "name", None) + if name is None or str(name).strip() == "": + return str(fallback_index) + return str(name) + + +def _optional_int(value: object) -> int | None: + if value is None: + return None + try: + return int(str(value)) + except (TypeError, ValueError): + return None + + +def _normalize_optional(value: object) -> str | None: + if value is None: + return None + normalized = str(value).strip() + if normalized in {"", ".", "?", "\x00"}: + return None + return normalized diff --git a/src/foldfoundry/modules/symmetry_graft/models.py b/src/foldfoundry/modules/symmetry_graft/models.py new file mode 100644 index 0000000..cefc3a8 --- /dev/null +++ b/src/foldfoundry/modules/symmetry_graft/models.py @@ -0,0 +1,140 @@ +"""Models for structure metadata and symmetry inspection.""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +from pydantic import Field, field_validator + +from foldfoundry.models import NonEmptyStr, StrictModel + +StructureFormat = Literal["mmcif", "pdb"] +TransformSource = Literal[ + "mmcif_assembly", + "biomt", + "mtrix", + "crystal_op", + "deposited", + "inferred", + "manual", +] +SymmetryMode = Literal["biological_assembly", "ncs", "crystal_packing", "deposited"] + + +class CoordinateFrameMetadata(StrictModel): + """Source coordinate-frame metadata preserved during inspection.""" + + unit_cell: dict[str, float] = Field(default_factory=dict) + space_group: str | None = None + + +class Transform(StrictModel): + """Rigid transform available from source structure metadata.""" + + id: NonEmptyStr + name: NonEmptyStr + rotation: list[list[float]] + translation: list[float] + source: TransformSource + + @field_validator("rotation") + @classmethod + def validate_rotation_shape(cls, rotation: list[list[float]]) -> list[list[float]]: + """Require a 3x3 rotation matrix.""" + if len(rotation) != 3 or any(len(row) != 3 for row in rotation): + raise ValueError("rotation must be a 3x3 matrix") + return rotation + + @field_validator("translation") + @classmethod + def validate_translation_shape(cls, translation: list[float]) -> list[float]: + """Require a 3-vector translation.""" + if len(translation) != 3: + raise ValueError("translation must be a 3-vector") + return translation + + +class ChainSummary(StrictModel): + """Chain-level counts from the deposited/source coordinates.""" + + chain_id: NonEmptyStr + model_id: NonEmptyStr + residue_count: int + atom_count: int + + +class SymmetryContext(StrictModel): + """One detected symmetry or packing preservation option.""" + + mode: SymmetryMode + assembly_id: str | None = None + description: str + operators: list[Transform] = Field(default_factory=list) + chain_operator_mapping: dict[str, list[str]] = Field(default_factory=dict) + unit_cell: dict[str, float] = Field(default_factory=dict) + space_group: str | None = None + source_metadata: dict[str, str] = Field(default_factory=dict) + warnings: list[str] = Field(default_factory=list) + + +class SymmetryInspectionReport(StrictModel): + """Machine-readable structure symmetry inspection report.""" + + schema_version: int = 1 + source_path: NonEmptyStr + format: StructureFormat + structure_name: str | None = None + model_count: int + chain_count: int + atom_count: int + chains: list[ChainSummary] + coordinate_frame: CoordinateFrameMetadata + symmetry_contexts: list[SymmetryContext] + warnings: list[str] = Field(default_factory=list) + dependency_versions: dict[str, str] = Field(default_factory=dict) + + +class AssemblyExpansionResult(StrictModel): + """Summary for a generated biological assembly.""" + + source_path: NonEmptyStr + assembly_id: str | None = None + atom_count: int + chain_count: int + operator_count: int + chain_copy_mapping: dict[str, str] + warnings: list[str] = Field(default_factory=list) + + +@dataclass(frozen=True, slots=True) +class AtomRecord: + """Normalized atom identifier and coordinate extracted from a Gemmi structure.""" + + model_id: str + chain_id: str + residue_name: str + residue_number: int | None + insertion_code: str | None + atom_name: str + altloc: str | None + element: str | None + x: float + y: float + z: float + + @property + def residue_key(self) -> tuple[str, str, int | None, str | None]: + """Return a stable model/chain/residue key.""" + return (self.model_id, self.chain_id, self.residue_number, self.insertion_code) + + +@dataclass(frozen=True, slots=True) +class StructureHandle: + """Runtime-only parsed structure handle.""" + + source_path: Path + format: StructureFormat + structure: Any + structure_name: str | None + coordinate_frame: CoordinateFrameMetadata + atoms: tuple[AtomRecord, ...] diff --git a/src/foldfoundry/modules/symmetry_graft/plugin.py b/src/foldfoundry/modules/symmetry_graft/plugin.py new file mode 100644 index 0000000..3f5bbbe --- /dev/null +++ b/src/foldfoundry/modules/symmetry_graft/plugin.py @@ -0,0 +1,105 @@ +"""Built-in FoldForge module for symmetry-preserving graft workflows.""" + +from foldfoundry.modules.base import ModuleContext, ModuleMetadata +from foldfoundry.modules.registry import ( + ArtifactDefinition, + ReportDefinition, + WorkflowDefinition, +) + +MODULE_NAME = "symmetry_graft" + + +class SymmetryGraftModule: + """Register symmetry-graft commands and metadata through the module host.""" + + metadata = ModuleMetadata( + name=MODULE_NAME, + display_name="Symmetry-Preserving Graft", + version="0.1.0", + description=( + "Model local edits and preserve biological, NCS, crystal, or deposited packing." + ), + capabilities=["cli", "workflow", "artifact", "report"], + dependencies=["gemmi", "numpy"], + optional_dependencies=["biotite"], + ) + + def register_cli(self, ctx: ModuleContext) -> None: + """Register module-owned CLI command groups.""" + from foldfoundry.modules.symmetry_graft.cli import graft_app, symmetry_app + + ctx.cli.add_command_group( + "symmetry", + symmetry_app, + help="Inspect and expand structure symmetry metadata.", + ) + ctx.cli.add_command_group( + "graft", + graft_app, + help="Prepare and apply symmetry-preserving graft workflows.", + ) + + def register_workflows(self, ctx: ModuleContext) -> None: + """Register the high-level graft workflow.""" + ctx.workflows.register( + WorkflowDefinition( + name="symmetry_preserving_graft", + module_name=MODULE_NAME, + display_name="Symmetry-Preserving Graft", + description=( + "Model a local edit, graft it back into source coordinates, " + "and propagate original symmetry or packing operators." + ), + ) + ) + + def register_artifacts(self, ctx: ModuleContext) -> None: + """Register output artifact types for symmetry-graft workflows.""" + for name, display_name, description in [ + ( + "engineered_asu_cif", + "Engineered ASU mmCIF", + "Source-frame engineered coordinates with preserved scaffold atoms.", + ), + ( + "engineered_assembly_cif", + "Engineered Assembly mmCIF", + "Biological or NCS assembly generated from original operators.", + ), + ( + "engineered_packing_shell_cif", + "Engineered Packing Shell mmCIF", + "Bounded crystallographic packing shell around edited atoms.", + ), + ( + "symmetry_report_json", + "Symmetry Report JSON", + "Machine-readable symmetry, grafting, clash, and warning report.", + ), + ]: + ctx.artifacts.register( + ArtifactDefinition( + name=name, + module_name=MODULE_NAME, + display_name=display_name, + description=description, + ) + ) + + def register_reports(self, ctx: ModuleContext) -> None: + """Register report definitions for symmetry-graft workflows.""" + ctx.reports.register( + ReportDefinition( + name="symmetry_report_json", + module_name=MODULE_NAME, + display_name="Symmetry Report JSON", + description=( + "Structured report covering detected operators, mappings, clashes, " + "cutpoints, graft RMSD, and warnings." + ), + ) + ) + + +module = SymmetryGraftModule() diff --git a/src/foldfoundry/modules/symmetry_graft/structure_io.py b/src/foldfoundry/modules/symmetry_graft/structure_io.py new file mode 100644 index 0000000..787a13b --- /dev/null +++ b/src/foldfoundry/modules/symmetry_graft/structure_io.py @@ -0,0 +1,218 @@ +"""Gemmi-backed PDB/mmCIF loading helpers.""" + +from importlib import import_module +from pathlib import Path +from typing import Any + +from foldfoundry.errors import StructureError +from foldfoundry.modules.symmetry_graft.indexing import index_structure_atoms +from foldfoundry.modules.symmetry_graft.models import ( + AtomRecord, + CoordinateFrameMetadata, + StructureFormat, + StructureHandle, +) + + +def detect_structure_format(path: Path) -> StructureFormat: + """Detect a supported coordinate file format from the file suffix.""" + suffix = path.suffix.lower() + if suffix in {".cif", ".mmcif"}: + return "mmcif" + if suffix in {".pdb", ".ent"}: + return "pdb" + raise StructureError(f"{path}: expected a PDB, .cif, or .mmcif structure file") + + +def load_structure(path: Path) -> StructureHandle: + """Load a PDB/mmCIF structure with Gemmi and preserve source-frame metadata.""" + source_path = path.expanduser().resolve() + if not source_path.exists(): + raise StructureError(f"{source_path}: file not found") + if not source_path.is_file(): + raise StructureError(f"{source_path}: expected a structure file") + + structure_format = detect_structure_format(source_path) + gemmi = require_gemmi() + try: + structure = gemmi.read_structure(str(source_path)) + except Exception as exc: # pragma: no cover - Gemmi exception types are implementation details. + raise StructureError(f"{source_path}: could not read structure: {exc}") from exc + + atoms = index_structure_atoms(structure) + return StructureHandle( + source_path=source_path, + format=structure_format, + structure=structure, + structure_name=_structure_name(structure), + coordinate_frame=_coordinate_frame_metadata(structure), + atoms=atoms, + ) + + +def load_mmcif_block(path: Path) -> Any: + """Load the sole mmCIF block for category-level metadata inspection.""" + gemmi = require_gemmi() + try: + document = gemmi.cif.read_file(str(path.expanduser().resolve())) + return document.sole_block() + except Exception as exc: # pragma: no cover - Gemmi exception types are implementation details. + raise StructureError(f"{path}: could not read mmCIF metadata: {exc}") from exc + + +def require_gemmi() -> Any: + """Import Gemmi or raise a user-facing structure error.""" + try: + return import_module("gemmi") + except ImportError as exc: + raise StructureError("Gemmi is required for structure inspection; install gemmi") from exc + + +def gemmi_version() -> str: + """Return the active Gemmi version string.""" + gemmi = require_gemmi() + return str(getattr(gemmi, "__version__", "unknown")) + + +def write_atom_records_mmcif( + atoms: tuple[AtomRecord, ...], + path: Path, + *, + coordinate_frame: CoordinateFrameMetadata, + structure_name: str = "foldforge_assembly", + force: bool = False, +) -> Path: + """Write atom records as a minimal mmCIF coordinate file.""" + if not atoms: + raise StructureError("cannot write mmCIF with no atoms") + output_path = path.expanduser().resolve() + if output_path.exists() and not force: + raise StructureError(f"{output_path}: file exists; pass --force to overwrite") + output_path.parent.mkdir(parents=True, exist_ok=True) + + gemmi = require_gemmi() + document = gemmi.cif.Document() + block = document.add_new_block(_safe_cif_block_name(structure_name)) + block.set_pair("_entry.id", structure_name) + _write_coordinate_frame_pairs(block, coordinate_frame) + loop = block.init_loop( + "_atom_site.", + [ + "group_PDB", + "id", + "type_symbol", + "label_atom_id", + "label_alt_id", + "label_comp_id", + "label_asym_id", + "label_entity_id", + "label_seq_id", + "pdbx_PDB_ins_code", + "Cartn_x", + "Cartn_y", + "Cartn_z", + "occupancy", + "B_iso_or_equiv", + "pdbx_formal_charge", + "auth_seq_id", + "auth_comp_id", + "auth_asym_id", + "auth_atom_id", + "pdbx_PDB_model_num", + ], + ) + for atom_id, atom in enumerate(atoms, start=1): + residue_number = "." if atom.residue_number is None else str(atom.residue_number) + insertion_code = atom.insertion_code or "?" + altloc = atom.altloc or "." + element = atom.element or _element_from_atom_name(atom.atom_name) + loop.add_row( + [ + "ATOM", + str(atom_id), + element, + atom.atom_name, + altloc, + atom.residue_name, + atom.chain_id, + "1", + residue_number, + insertion_code, + f"{atom.x:.3f}", + f"{atom.y:.3f}", + f"{atom.z:.3f}", + "1.00", + "0.00", + "?", + residue_number, + atom.residue_name, + atom.chain_id, + atom.atom_name, + atom.model_id, + ] + ) + document.write_file(str(output_path)) + return output_path + + +def _structure_name(structure: Any) -> str | None: + name = str(getattr(structure, "name", "") or "").strip() + return name or None + + +def _coordinate_frame_metadata(structure: Any) -> CoordinateFrameMetadata: + cell = getattr(structure, "cell", None) + unit_cell: dict[str, float] = {} + if cell is not None: + maybe_cell = { + "a": float(getattr(cell, "a", 0.0)), + "b": float(getattr(cell, "b", 0.0)), + "c": float(getattr(cell, "c", 0.0)), + "alpha": float(getattr(cell, "alpha", 0.0)), + "beta": float(getattr(cell, "beta", 0.0)), + "gamma": float(getattr(cell, "gamma", 0.0)), + } + if any(value > 0.0 for value in maybe_cell.values()): + unit_cell = maybe_cell + + space_group = str(getattr(structure, "spacegroup_hm", "") or "").strip() or None + return CoordinateFrameMetadata(unit_cell=unit_cell, space_group=space_group) + + +def _write_coordinate_frame_pairs(block: Any, coordinate_frame: CoordinateFrameMetadata) -> None: + cell = coordinate_frame.unit_cell + if cell: + block.set_pair("_cell.length_a", _format_float(cell.get("a"))) + block.set_pair("_cell.length_b", _format_float(cell.get("b"))) + block.set_pair("_cell.length_c", _format_float(cell.get("c"))) + block.set_pair("_cell.angle_alpha", _format_float(cell.get("alpha"))) + block.set_pair("_cell.angle_beta", _format_float(cell.get("beta"))) + block.set_pair("_cell.angle_gamma", _format_float(cell.get("gamma"))) + if coordinate_frame.space_group: + block.set_pair( + "_symmetry.space_group_name_H-M", + _quote_cif_value(coordinate_frame.space_group), + ) + + +def _format_float(value: float | None) -> str: + return "." if value is None else f"{value:.6g}" + + +def _safe_cif_block_name(value: str) -> str: + safe = "".join( + character if character.isalnum() or character in "_-" else "_" for character in value + ) + return safe or "foldforge_assembly" + + +def _quote_cif_value(value: str) -> str: + escaped = value.replace("'", "''") + return f"'{escaped}'" if any(character.isspace() for character in escaped) else escaped + + +def _element_from_atom_name(atom_name: str) -> str: + stripped = atom_name.strip() + if not stripped: + return "?" + return stripped[0].upper() diff --git a/src/foldfoundry/modules/symmetry_graft/symmetry.py b/src/foldfoundry/modules/symmetry_graft/symmetry.py new file mode 100644 index 0000000..da4eacc --- /dev/null +++ b/src/foldfoundry/modules/symmetry_graft/symmetry.py @@ -0,0 +1,586 @@ +"""Symmetry and packing metadata inspection.""" + +from collections.abc import Iterable +from itertools import product +from pathlib import Path +from typing import Any + +from foldfoundry import __version__ +from foldfoundry.modules.symmetry_graft.indexing import summarize_chains +from foldfoundry.modules.symmetry_graft.models import ( + SymmetryContext, + SymmetryInspectionReport, + Transform, +) +from foldfoundry.modules.symmetry_graft.structure_io import ( + gemmi_version, + load_mmcif_block, + load_structure, +) + +IDENTITY_ROTATION = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] +ZERO_TRANSLATION = [0.0, 0.0, 0.0] + + +def inspect_structure(path: Path) -> SymmetryInspectionReport: + """Inspect a PDB/mmCIF file for symmetry and packing preservation options.""" + handle = load_structure(path) + chains = summarize_chains(handle.atoms) + contexts: list[SymmetryContext] = [] + warnings: list[str] = [] + + if handle.format == "mmcif": + block = load_mmcif_block(handle.source_path) + contexts.extend(_mmcif_biological_assemblies(block)) + ncs_context = _mmcif_ncs_context(block) + if ncs_context is not None: + contexts.append(ncs_context) + crystal_context = _mmcif_crystal_context(block) + if crystal_context is not None: + contexts.append(crystal_context) + else: + lines = handle.source_path.read_text(encoding="utf-8", errors="replace").splitlines() + contexts.extend(_pdb_biomt_contexts(lines)) + ncs_context = _pdb_mtrix_context(lines) + if ncs_context is not None: + contexts.append(ncs_context) + crystal_context = _pdb_crystal_context(lines) + if crystal_context is not None: + contexts.append(crystal_context) + + if not contexts: + warnings.append("no biological assembly, NCS, or crystal symmetry metadata detected") + biological_count = sum(context.mode == "biological_assembly" for context in contexts) + if biological_count > 1: + warnings.append("multiple biological assemblies detected; require explicit assembly choice") + + contexts.append(_deposited_context()) + return SymmetryInspectionReport( + source_path=str(handle.source_path), + format=handle.format, + structure_name=handle.structure_name, + model_count=len({atom.model_id for atom in handle.atoms}), + chain_count=len({(atom.model_id, atom.chain_id) for atom in handle.atoms}), + atom_count=len(handle.atoms), + chains=chains, + coordinate_frame=handle.coordinate_frame, + symmetry_contexts=contexts, + warnings=warnings, + dependency_versions={ + "foldfoundry": __version__, + "gemmi": gemmi_version(), + }, + ) + + +def _mmcif_biological_assemblies(block: Any) -> list[SymmetryContext]: + assemblies = _category(block, "_pdbx_struct_assembly.") + assembly_gen = _category(block, "_pdbx_struct_assembly_gen.") + oper_list = _category(block, "_pdbx_struct_oper_list.") + assembly_ids = _ordered_unique( + _column(assemblies, "id") or _column(assembly_gen, "assembly_id") + ) + operator_map = _mmcif_operator_map(oper_list, source="mmcif_assembly") + contexts: list[SymmetryContext] = [] + for assembly_id in assembly_ids: + row_indices = [ + index + for index, value in enumerate(_column(assembly_gen, "assembly_id")) + if value == assembly_id + ] + chain_operator_mapping: dict[str, list[str]] = {} + expression_operator_map: dict[str, Transform] = {} + raw_expressions: list[str] = [] + warnings: list[str] = [] + for index in row_indices: + expression = _column_value(assembly_gen, "oper_expression", index) or "1" + raw_expressions.append(expression) + operators_from_expression, missing = _operators_from_expression( + expression, + operator_map, + ) + operator_ids = [operator.id for operator in operators_from_expression] + expression_operator_map.update( + {operator.id: operator for operator in operators_from_expression} + ) + chains = _split_csv(_column_value(assembly_gen, "asym_id_list", index) or "") + for chain_id in chains: + chain_operator_mapping.setdefault(chain_id, []) + chain_operator_mapping[chain_id].extend(operator_ids) + if missing: + warnings.append( + "operator matrix missing for IDs: " + ", ".join(_ordered_unique(missing)) + ) + + selected_operator_ids = _ordered_unique( + operator_id + for operator_ids in chain_operator_mapping.values() + for operator_id in operator_ids + ) + operators = [ + expression_operator_map[operator_id] + for operator_id in selected_operator_ids + if operator_id in expression_operator_map + ] + contexts.append( + SymmetryContext( + mode="biological_assembly", + assembly_id=assembly_id, + description=_assembly_description(assemblies, assembly_id), + operators=operators, + chain_operator_mapping={ + chain_id: _ordered_unique(operator_ids) + for chain_id, operator_ids in sorted(chain_operator_mapping.items()) + }, + source_metadata={ + "source": "mmcif", + "oper_expressions": ";".join(raw_expressions), + }, + warnings=warnings, + ) + ) + return contexts + + +def _mmcif_ncs_context(block: Any) -> SymmetryContext | None: + ncs_oper = _category(block, "_struct_ncs_oper.") + operators = _mmcif_operator_map(ncs_oper, source="mtrix") + if not operators: + return None + return SymmetryContext( + mode="ncs", + description="NCS operators from mmCIF _struct_ncs_oper metadata", + operators=list(operators.values()), + source_metadata={"source": "mmcif", "category": "_struct_ncs_oper"}, + ) + + +def _mmcif_crystal_context(block: Any) -> SymmetryContext | None: + unit_cell = _mmcif_unit_cell(block) + space_group = _first_present( + _find_value(block, "_space_group.name_H-M_alt"), + _find_value(block, "_space_group.name_H-M_full"), + _find_value(block, "_symmetry.space_group_name_H-M"), + ) + if not unit_cell and space_group is None: + return None + warnings = [] + if not unit_cell or space_group is None: + warnings.append("crystal packing metadata is incomplete") + return SymmetryContext( + mode="crystal_packing", + description="Crystal packing from mmCIF unit cell and space group metadata", + unit_cell=unit_cell, + space_group=space_group, + source_metadata={"source": "mmcif"}, + warnings=warnings, + ) + + +def _pdb_biomt_contexts(lines: list[str]) -> list[SymmetryContext]: + assembly_id = "1" + chain_ids: list[str] = [] + row_values: dict[tuple[str, int], tuple[list[float], float]] = {} + for line in lines: + if line.startswith("REMARK 350 BIOMOLECULE:"): + assembly_id = line.partition(":")[2].strip() or assembly_id + continue + if line.startswith("REMARK 350 APPLY THE FOLLOWING TO CHAINS:"): + chain_ids.extend(_split_csv(line.partition(":")[2])) + continue + if "BIOMT" not in line: + continue + parsed = _parse_pdb_matrix_row(line, "BIOMT") + if parsed is not None: + row_number, serial, rotation_row, translation_value = parsed + row_values[(serial, row_number)] = (rotation_row, translation_value) + + operators = _transforms_from_row_values(row_values, prefix="biomt", source="biomt") + if not operators: + return [] + operator_ids = [operator.id for operator in operators] + return [ + SymmetryContext( + mode="biological_assembly", + assembly_id=assembly_id, + description="Biological assembly from PDB REMARK 350 BIOMT records", + operators=operators, + chain_operator_mapping={ + chain_id: operator_ids for chain_id in _ordered_unique(chain_ids) + }, + source_metadata={"source": "pdb", "record": "REMARK 350 BIOMT"}, + ) + ] + + +def _pdb_mtrix_context(lines: list[str]) -> SymmetryContext | None: + row_values: dict[tuple[str, int], tuple[list[float], float]] = {} + for line in lines: + if not line.startswith("MTRIX"): + continue + parsed = _parse_pdb_matrix_row(line, "MTRIX") + if parsed is not None: + row_number, serial, rotation_row, translation_value = parsed + row_values[(serial, row_number)] = (rotation_row, translation_value) + operators = _transforms_from_row_values(row_values, prefix="mtrix", source="mtrix") + if not operators: + return None + return SymmetryContext( + mode="ncs", + description="NCS operators from PDB MTRIX records", + operators=operators, + source_metadata={"source": "pdb", "record": "MTRIX"}, + ) + + +def _pdb_crystal_context(lines: list[str]) -> SymmetryContext | None: + cryst1 = next((line for line in lines if line.startswith("CRYST1")), None) + if cryst1 is None: + return None + parsed = _parse_pdb_cryst1(cryst1) + if parsed is None: + return SymmetryContext( + mode="crystal_packing", + description="Crystal packing from PDB CRYST1 record", + source_metadata={"source": "pdb", "record": "CRYST1"}, + warnings=["CRYST1 record is incomplete"], + ) + unit_cell, space_group = parsed + return SymmetryContext( + mode="crystal_packing", + description="Crystal packing from PDB CRYST1 record", + unit_cell=unit_cell, + space_group=space_group, + source_metadata={"source": "pdb", "record": "CRYST1"}, + ) + + +def _parse_pdb_cryst1(line: str) -> tuple[dict[str, float], str | None] | None: + try: + unit_cell = { + "a": float(line[6:15]), + "b": float(line[15:24]), + "c": float(line[24:33]), + "alpha": float(line[33:40]), + "beta": float(line[40:47]), + "gamma": float(line[47:54]), + } + except ValueError: + return _parse_pdb_cryst1_split(line) + space_group = line[55:66].strip() or None + return unit_cell, space_group + + +def _parse_pdb_cryst1_split(line: str) -> tuple[dict[str, float], str | None] | None: + parts = line.split() + if len(parts) < 7: + return None + unit_cell = { + "a": _float_or_zero(parts[1]), + "b": _float_or_zero(parts[2]), + "c": _float_or_zero(parts[3]), + "alpha": _float_or_zero(parts[4]), + "beta": _float_or_zero(parts[5]), + "gamma": _float_or_zero(parts[6]), + } + space_group_parts = parts[7:] + if len(space_group_parts) > 1 and space_group_parts[-1].isdigit(): + space_group_parts = space_group_parts[:-1] + space_group = " ".join(space_group_parts) if space_group_parts else None + return unit_cell, space_group + + +def _deposited_context() -> SymmetryContext: + return SymmetryContext( + mode="deposited", + description="Use current deposited/source coordinates without symmetry expansion", + operators=[ + Transform( + id="identity", + name="identity", + rotation=IDENTITY_ROTATION, + translation=ZERO_TRANSLATION, + source="deposited", + ) + ], + source_metadata={"source": "deposited_coordinates"}, + ) + + +def _category(block: Any, prefix: str) -> dict[str, list[str]]: + try: + raw = block.find_mmcif_category(prefix) + except Exception: + return {} + category: dict[str, list[str]] = {} + for column_index, key in enumerate(raw.tags): + normalized_key = str(key) + if normalized_key.startswith(prefix): + normalized_key = normalized_key[len(prefix) :] + normalized_key = normalized_key.removeprefix("_") + if "." in normalized_key: + normalized_key = normalized_key.rsplit(".", 1)[1] + category[normalized_key] = [ + _normalize_cif_value(value) for value in raw.column(column_index) + ] + return category + + +def _column(category: dict[str, list[str]], name: str) -> list[str]: + return category.get(name, []) + + +def _column_value(category: dict[str, list[str]], name: str, index: int) -> str | None: + values = _column(category, name) + if index >= len(values): + return None + return values[index] + + +def _find_value(block: Any, name: str) -> str | None: + try: + return _normalize_cif_value(block.find_value(name)) + except Exception: + return None + + +def _normalize_cif_value(value: object) -> str: + if value is None: + return "" + text = str(value).strip() + if text in {"", ".", "?", "None"}: + return "" + return text.strip("'\"") + + +def _assembly_description(assemblies: dict[str, list[str]], assembly_id: str) -> str: + ids = _column(assemblies, "id") + for index, value in enumerate(ids): + if value != assembly_id: + continue + details = _column_value(assemblies, "details", index) + oligomer = _column_value(assemblies, "oligomeric_details", index) + count = _column_value(assemblies, "oligomeric_count", index) + parts = [part for part in (details, oligomer, f"count={count}" if count else None) if part] + if parts: + return "Biological assembly " + assembly_id + " (" + ", ".join(parts) + ")" + return "Biological assembly " + assembly_id + + +def _mmcif_operator_map( + category: dict[str, list[str]], + *, + source: str, +) -> dict[str, Transform]: + ids = _column(category, "id") + transforms: dict[str, Transform] = {} + for index, operator_id in enumerate(ids): + rotation = [ + [ + _float_or_zero(_column_value(category, f"matrix[{row}][{column}]", index)) + for column in range(1, 4) + ] + for row in range(1, 4) + ] + translation = [ + _float_or_zero(_column_value(category, f"vector[{row}]", index)) for row in range(1, 4) + ] + if rotation == [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]: + continue + transforms[operator_id] = Transform( + id=operator_id, + name=_column_value(category, "type", index) or operator_id, + rotation=rotation, + translation=translation, + source=source, # type: ignore[arg-type] + ) + return transforms + + +def _operator_ids_from_expression(expression: str) -> list[str]: + cleaned = expression.replace("(", ",").replace(")", ",") + operator_ids: list[str] = [] + for token in _split_csv(cleaned): + if "-" in token: + start, _, end = token.partition("-") + if start.isdigit() and end.isdigit(): + operator_ids.extend(str(value) for value in range(int(start), int(end) + 1)) + continue + operator_ids.append(token) + return _ordered_unique(operator_ids) + + +def _operators_from_expression( + expression: str, + operator_map: dict[str, Transform], +) -> tuple[list[Transform], list[str]]: + groups = _operator_expression_groups(expression) + operators: list[Transform] = [] + missing: list[str] = [] + for operator_ids in product(*groups): + components: list[Transform] = [] + for operator_id in operator_ids: + operator = operator_map.get(operator_id) + if operator is None: + missing.append(operator_id) + continue + components.append(operator) + if len(components) != len(operator_ids): + continue + operators.append(_compose_operator_components(components)) + return operators, _ordered_unique(missing) + + +def _operator_expression_groups(expression: str) -> list[list[str]]: + stripped = expression.strip() + if not stripped: + return [["1"]] + if "(" not in stripped: + return [_operator_ids_from_expression(stripped)] + + groups: list[list[str]] = [] + position = 0 + while position < len(stripped): + character = stripped[position] + if character in " \t,;": + position += 1 + continue + if character != "(": + next_position = stripped.find("(", position) + token = stripped[position:] if next_position == -1 else stripped[position:next_position] + groups.append(_operator_ids_from_expression(token)) + position = len(stripped) if next_position == -1 else next_position + continue + end = stripped.find(")", position + 1) + if end == -1: + groups.append(_operator_ids_from_expression(stripped[position + 1 :])) + break + groups.append(_operator_ids_from_expression(stripped[position + 1 : end])) + position = end + 1 + return [group for group in groups if group] or [["1"]] + + +def _compose_operator_components(components: list[Transform]) -> Transform: + if len(components) == 1: + return components[0] + composed = components[0] + for component in components[1:]: + composed = _compose_transforms(composed, component) + return Transform( + id="x".join(component.id for component in components), + name="composed " + " x ".join(component.id for component in components), + rotation=composed.rotation, + translation=composed.translation, + source=composed.source, + ) + + +def _compose_transforms(first: Transform, second: Transform) -> Transform: + rotation = [ + [ + sum(second.rotation[row][inner] * first.rotation[inner][column] for inner in range(3)) + for column in range(3) + ] + for row in range(3) + ] + translation = [ + sum(second.rotation[row][inner] * first.translation[inner] for inner in range(3)) + + second.translation[row] + for row in range(3) + ] + return Transform( + id=f"{first.id}x{second.id}", + name=f"{first.name} x {second.name}", + rotation=rotation, + translation=translation, + source=first.source, + ) + + +def _mmcif_unit_cell(block: Any) -> dict[str, float]: + fields = { + "a": "_cell.length_a", + "b": "_cell.length_b", + "c": "_cell.length_c", + "alpha": "_cell.angle_alpha", + "beta": "_cell.angle_beta", + "gamma": "_cell.angle_gamma", + } + values = {key: _float_or_zero(_find_value(block, field)) for key, field in fields.items()} + return values if any(value > 0.0 for value in values.values()) else {} + + +def _parse_pdb_matrix_row( + line: str, + record: str, +) -> tuple[int, str, list[float], float] | None: + marker = f"{record}" + try: + row_number = int(line[line.index(marker) + len(marker)]) + except (ValueError, IndexError): + return None + parts = line.split() + try: + marker_index = next(index for index, part in enumerate(parts) if part.startswith(record)) + serial = parts[marker_index + 1] + rotation = [float(parts[marker_index + offset]) for offset in (2, 3, 4)] + translation = float(parts[marker_index + 5]) + except (StopIteration, IndexError, ValueError): + return None + return row_number, serial, rotation, translation + + +def _transforms_from_row_values( + row_values: dict[tuple[str, int], tuple[list[float], float]], + *, + prefix: str, + source: str, +) -> list[Transform]: + serials = _ordered_unique(serial for serial, _row in row_values) + transforms: list[Transform] = [] + for serial in serials: + rows = [row_values.get((serial, row)) for row in range(1, 4)] + if any(row is None for row in rows): + continue + rotation = [row[0] for row in rows if row is not None] + translation = [row[1] for row in rows if row is not None] + transforms.append( + Transform( + id=f"{prefix}_{serial}", + name=f"{prefix.upper()} {serial}", + rotation=rotation, + translation=translation, + source=source, # type: ignore[arg-type] + ) + ) + return transforms + + +def _split_csv(value: str) -> list[str]: + return [part.strip() for part in value.replace(";", ",").split(",") if part.strip()] + + +def _ordered_unique(values: Iterable[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + if value not in seen: + seen.add(value) + result.append(value) + return result + + +def _float_or_zero(value: object) -> float: + if value is None: + return 0.0 + try: + return float(str(value)) + except (TypeError, ValueError): + return 0.0 + + +def _first_present(*values: str | None) -> str | None: + for value in values: + if value: + return value + return None diff --git a/tests/fixtures/structures/tiny_assembly.cif b/tests/fixtures/structures/tiny_assembly.cif new file mode 100644 index 0000000..9db4b9a --- /dev/null +++ b/tests/fixtures/structures/tiny_assembly.cif @@ -0,0 +1,71 @@ +data_tiny_assembly +# +_entry.id tiny_assembly +# +_cell.length_a 20.0 +_cell.length_b 20.0 +_cell.length_c 30.0 +_cell.angle_alpha 90.0 +_cell.angle_beta 90.0 +_cell.angle_gamma 120.0 +# +_symmetry.space_group_name_H-M 'P 1' +# +loop_ +_pdbx_struct_assembly.id +_pdbx_struct_assembly.details +_pdbx_struct_assembly.oligomeric_details +_pdbx_struct_assembly.oligomeric_count +1 'author_defined_assembly' dimeric 2 +# +loop_ +_pdbx_struct_assembly_gen.assembly_id +_pdbx_struct_assembly_gen.oper_expression +_pdbx_struct_assembly_gen.asym_id_list +1 '1,2' A +# +loop_ +_pdbx_struct_oper_list.id +_pdbx_struct_oper_list.type +_pdbx_struct_oper_list.matrix[1][1] +_pdbx_struct_oper_list.matrix[1][2] +_pdbx_struct_oper_list.matrix[1][3] +_pdbx_struct_oper_list.vector[1] +_pdbx_struct_oper_list.matrix[2][1] +_pdbx_struct_oper_list.matrix[2][2] +_pdbx_struct_oper_list.matrix[2][3] +_pdbx_struct_oper_list.vector[2] +_pdbx_struct_oper_list.matrix[3][1] +_pdbx_struct_oper_list.matrix[3][2] +_pdbx_struct_oper_list.matrix[3][3] +_pdbx_struct_oper_list.vector[3] +1 identity 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0 0.0 +2 twofold -1.0 0.0 0.0 0.0 0.0 -1.0 0.0 0.0 0.0 0.0 1.0 0.0 +# +loop_ +_atom_site.group_PDB +_atom_site.id +_atom_site.type_symbol +_atom_site.label_atom_id +_atom_site.label_alt_id +_atom_site.label_comp_id +_atom_site.label_asym_id +_atom_site.label_entity_id +_atom_site.label_seq_id +_atom_site.pdbx_PDB_ins_code +_atom_site.Cartn_x +_atom_site.Cartn_y +_atom_site.Cartn_z +_atom_site.occupancy +_atom_site.B_iso_or_equiv +_atom_site.pdbx_formal_charge +_atom_site.auth_seq_id +_atom_site.auth_comp_id +_atom_site.auth_asym_id +_atom_site.auth_atom_id +_atom_site.pdbx_PDB_model_num +ATOM 1 N N . GLY A 1 1 ? 0.000 0.000 0.000 1.00 20.00 ? 1 GLY A N 1 +ATOM 2 C CA . GLY A 1 1 ? 1.450 0.000 0.000 1.00 20.00 ? 1 GLY A CA 1 +ATOM 3 C C . GLY A 1 1 ? 2.000 1.250 0.000 1.00 20.00 ? 1 GLY A C 1 +ATOM 4 O O . GLY A 1 1 ? 1.500 2.300 0.000 1.00 20.00 ? 1 GLY A O 1 +# diff --git a/tests/fixtures/structures/tiny_biomt.pdb b/tests/fixtures/structures/tiny_biomt.pdb new file mode 100644 index 0000000..0cdd93c --- /dev/null +++ b/tests/fixtures/structures/tiny_biomt.pdb @@ -0,0 +1,18 @@ +CRYST1 20.000 20.000 30.000 90.00 90.00 120.00 P 1 1 +REMARK 350 BIOMOLECULE: 1 +REMARK 350 APPLY THE FOLLOWING TO CHAINS: A +REMARK 350 BIOMT1 1 1.000000 0.000000 0.000000 0.00000 +REMARK 350 BIOMT2 1 0.000000 1.000000 0.000000 0.00000 +REMARK 350 BIOMT3 1 0.000000 0.000000 1.000000 0.00000 +REMARK 350 BIOMT1 2 -1.000000 0.000000 0.000000 0.00000 +REMARK 350 BIOMT2 2 0.000000 -1.000000 0.000000 0.00000 +REMARK 350 BIOMT3 2 0.000000 0.000000 1.000000 0.00000 +MTRIX1 1 1.000000 0.000000 0.000000 0.00000 +MTRIX2 1 0.000000 1.000000 0.000000 0.00000 +MTRIX3 1 0.000000 0.000000 1.000000 0.00000 +ATOM 1 N GLY A 1 0.000 0.000 0.000 1.00 20.00 N +ATOM 2 CA GLY A 1 1.450 0.000 0.000 1.00 20.00 C +ATOM 3 C GLY A 1 2.000 1.250 0.000 1.00 20.00 C +ATOM 4 O GLY A 1 1.500 2.300 0.000 1.00 20.00 O +TER +END diff --git a/tests/fixtures/structures/tiny_composite_assembly.cif b/tests/fixtures/structures/tiny_composite_assembly.cif new file mode 100644 index 0000000..b72ee7d --- /dev/null +++ b/tests/fixtures/structures/tiny_composite_assembly.cif @@ -0,0 +1,70 @@ +data_tiny_composite_assembly +# +_entry.id tiny_composite_assembly +# +_cell.length_a 30.0 +_cell.length_b 30.0 +_cell.length_c 30.0 +_cell.angle_alpha 90.0 +_cell.angle_beta 90.0 +_cell.angle_gamma 90.0 +# +_symmetry.space_group_name_H-M 'P 1' +# +loop_ +_pdbx_struct_assembly.id +_pdbx_struct_assembly.details +_pdbx_struct_assembly.oligomeric_details +_pdbx_struct_assembly.oligomeric_count +1 'composite_operator_fixture' dimeric 2 +# +loop_ +_pdbx_struct_assembly_gen.assembly_id +_pdbx_struct_assembly_gen.oper_expression +_pdbx_struct_assembly_gen.asym_id_list +1 '(X0)(1,2)' A +# +loop_ +_pdbx_struct_oper_list.id +_pdbx_struct_oper_list.type +_pdbx_struct_oper_list.matrix[1][1] +_pdbx_struct_oper_list.matrix[1][2] +_pdbx_struct_oper_list.matrix[1][3] +_pdbx_struct_oper_list.vector[1] +_pdbx_struct_oper_list.matrix[2][1] +_pdbx_struct_oper_list.matrix[2][2] +_pdbx_struct_oper_list.matrix[2][3] +_pdbx_struct_oper_list.vector[2] +_pdbx_struct_oper_list.matrix[3][1] +_pdbx_struct_oper_list.matrix[3][2] +_pdbx_struct_oper_list.matrix[3][3] +_pdbx_struct_oper_list.vector[3] +X0 translate_x 1.0 0.0 0.0 10.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0 0.0 +1 identity 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0 0.0 +2 translate_y 1.0 0.0 0.0 0.0 0.0 1.0 0.0 5.0 0.0 0.0 1.0 0.0 +# +loop_ +_atom_site.group_PDB +_atom_site.id +_atom_site.type_symbol +_atom_site.label_atom_id +_atom_site.label_alt_id +_atom_site.label_comp_id +_atom_site.label_asym_id +_atom_site.label_entity_id +_atom_site.label_seq_id +_atom_site.pdbx_PDB_ins_code +_atom_site.Cartn_x +_atom_site.Cartn_y +_atom_site.Cartn_z +_atom_site.occupancy +_atom_site.B_iso_or_equiv +_atom_site.pdbx_formal_charge +_atom_site.auth_seq_id +_atom_site.auth_comp_id +_atom_site.auth_asym_id +_atom_site.auth_atom_id +_atom_site.pdbx_PDB_model_num +ATOM 1 N N . GLY A 1 1 ? 1.000 2.000 3.000 1.00 20.00 ? 1 GLY A N 1 +ATOM 2 C CA . GLY A 1 1 ? 2.000 2.000 3.000 1.00 20.00 ? 1 GLY A CA 1 +# diff --git a/tests/test_assembly_expansion.py b/tests/test_assembly_expansion.py new file mode 100644 index 0000000..c76fa9e --- /dev/null +++ b/tests/test_assembly_expansion.py @@ -0,0 +1,125 @@ +"""Tests for biological assembly expansion.""" + +import json +from pathlib import Path + +import numpy as np +from typer.testing import CliRunner + +from foldfoundry.cli import app +from foldfoundry.modules.symmetry_graft import ( + expand_biological_assembly, + inspect_structure, + load_structure, + write_atom_records_mmcif, +) +from foldfoundry.modules.symmetry_graft.assembly import select_biological_assembly_context + +FIXTURES = Path(__file__).parent / "fixtures" / "structures" + + +def test_expand_biological_assembly_applies_mmcif_operators() -> None: + handle = load_structure(FIXTURES / "tiny_assembly.cif") + report = inspect_structure(FIXTURES / "tiny_assembly.cif") + context = select_biological_assembly_context(report.symmetry_contexts) + + atoms, result = expand_biological_assembly(handle, context) + + assert result.assembly_id == "1" + assert result.atom_count == 8 + assert result.chain_count == 2 + assert result.operator_count == 2 + assert result.chain_copy_mapping == {"A:1": "A", "A:2": "A_2"} + assert len(atoms) == 8 + assert atoms[1].chain_id == "A" + assert atoms[5].chain_id == "A_2" + assert atoms[1].x == 1.45 + assert atoms[5].x == -1.45 + assert atoms[6].y == -1.25 + + +def test_expand_biological_assembly_handles_composite_operator_expressions() -> None: + handle = load_structure(FIXTURES / "tiny_composite_assembly.cif") + report = inspect_structure(FIXTURES / "tiny_composite_assembly.cif") + context = select_biological_assembly_context(report.symmetry_contexts) + + atoms, result = expand_biological_assembly(handle, context) + + assert [operator.id for operator in context.operators] == ["X0x1", "X0x2"] + assert result.chain_copy_mapping == {"A:X0x1": "A_X0x1", "A:X0x2": "A_X0x2"} + assert [(atom.chain_id, atom.x, atom.y, atom.z) for atom in atoms] == [ + ("A_X0x1", 11.0, 2.0, 3.0), + ("A_X0x1", 12.0, 2.0, 3.0), + ("A_X0x2", 11.0, 7.0, 3.0), + ("A_X0x2", 12.0, 7.0, 3.0), + ] + + +def test_write_expanded_assembly_as_mmcif_round_trips(tmp_path: Path) -> None: + handle = load_structure(FIXTURES / "tiny_assembly.cif") + report = inspect_structure(FIXTURES / "tiny_assembly.cif") + context = select_biological_assembly_context(report.symmetry_contexts) + atoms, _result = expand_biological_assembly(handle, context) + output_path = tmp_path / "expanded.cif" + + write_atom_records_mmcif( + atoms, + output_path, + coordinate_frame=handle.coordinate_frame, + force=True, + ) + + reloaded = load_structure(output_path) + assert len(reloaded.atoms) == 8 + assert {atom.chain_id for atom in reloaded.atoms} == {"A", "A_2"} + assert reloaded.coordinate_frame.unit_cell["a"] == 20.0 + assert reloaded.coordinate_frame.space_group == "P 1" + + +def test_biotite_get_assembly_matches_fixture_atom_count_and_coordinates() -> None: + import biotite.structure.io.pdbx as pdbx + + fixture = FIXTURES / "tiny_assembly.cif" + handle = load_structure(fixture) + report = inspect_structure(fixture) + context = select_biological_assembly_context(report.symmetry_contexts) + atoms, _result = expand_biological_assembly(handle, context) + + cif_file = pdbx.CIFFile.read(str(fixture)) + biotite_assembly = pdbx.get_assembly(cif_file, "1") + biotite_coordinates = np.asarray(biotite_assembly.coord, dtype=np.float64).reshape(-1, 3) + foldforge_coordinates = np.asarray( + [(atom.x, atom.y, atom.z) for atom in atoms], + dtype=np.float64, + ) + + assert biotite_assembly.array_length() == len(atoms) + assert np.allclose(biotite_coordinates, foldforge_coordinates) + + +def test_cli_symmetry_expand_writes_mmcif_and_report(tmp_path: Path) -> None: + output_path = tmp_path / "engineered_assembly.cif" + report_path = tmp_path / "assembly_report.json" + + result = CliRunner().invoke( + app, + [ + "symmetry", + "expand", + str(FIXTURES / "tiny_assembly.cif"), + "--assembly-id", + "1", + "--out", + str(output_path), + "--report-out", + str(report_path), + ], + ) + + assert result.exit_code == 0 + assert output_path.exists() + assert "OK: wrote biological assembly" in result.output + payload = json.loads(report_path.read_text(encoding="utf-8")) + assert payload["assembly_id"] == "1" + assert payload["atom_count"] == 8 + assert payload["chain_copy_mapping"] == {"A:1": "A", "A:2": "A_2"} diff --git a/tests/test_grafting_fit.py b/tests/test_grafting_fit.py new file mode 100644 index 0000000..00d3348 --- /dev/null +++ b/tests/test_grafting_fit.py @@ -0,0 +1,175 @@ +"""Tests for source-frame graft fitting utilities.""" + +from collections.abc import Sequence + +import numpy as np +import pytest + +from foldfoundry.errors import GraftError +from foldfoundry.modules.symmetry_graft import ( + AnchorFitResult, + apply_transform, + atom_key, + fit_anchor_atoms, + graft_replacement_atoms, + kabsch_fit, +) +from foldfoundry.modules.symmetry_graft.models import AtomRecord + + +def test_kabsch_fit_recovers_known_inverse_transform() -> None: + native = np.array( + [ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [0.0, 3.0, 0.0], + [0.0, 0.0, 4.0], + ] + ) + known = _known_transform() + mobile = apply_transform(native, known) + + fit = kabsch_fit(mobile, native) + fitted = apply_transform(mobile, fit) + + expected_inverse_rotation = np.array(known.rotation).T + expected_inverse_translation = -np.array(known.translation) @ np.array(known.rotation) + np.testing.assert_allclose(fitted, native, atol=1e-12) + np.testing.assert_allclose(fit.rotation, expected_inverse_rotation, atol=1e-12) + np.testing.assert_allclose(fit.translation, expected_inverse_translation, atol=1e-12) + assert fit.anchor_count == 4 + assert fit.anchor_rmsd < 1e-12 + + +def test_transform_application_preserves_internal_distances() -> None: + points = np.array( + [ + [-1.0, 2.0, 0.5], + [4.0, 1.0, -2.0], + [0.0, -3.0, 8.0], + [2.0, 2.0, 2.0], + ] + ) + + transformed = apply_transform(points, _known_transform()) + + original_distances = np.linalg.norm(points[:, None, :] - points[None, :, :], axis=2) + transformed_distances = np.linalg.norm( + transformed[:, None, :] - transformed[None, :, :], + axis=2, + ) + np.testing.assert_allclose(transformed_distances, original_distances, atol=1e-12) + + +def test_fit_anchor_atoms_rejects_missing_or_ambiguous_anchor() -> None: + anchor = _atom("1", "A", 1, "CA", (0.0, 0.0, 0.0)) + duplicate = _atom("1", "A", 1, "CA", (1.0, 0.0, 0.0)) + keys = [atom_key(anchor), ("1", "A", 2, None, "CA", None), ("1", "A", 3, None, "CA", None)] + + with pytest.raises(GraftError, match="ambiguous native atom keys"): + fit_anchor_atoms([anchor, duplicate], [anchor], keys) + + with pytest.raises(GraftError, match="missing native anchor atoms"): + fit_anchor_atoms([anchor], [anchor], keys) + + +def test_graft_replacement_atoms_preserves_scaffold_and_transforms_replacement() -> None: + native_anchor_coordinates = [ + (0.0, 0.0, 0.0), + (2.0, 0.0, 0.0), + (0.0, 3.0, 0.0), + (0.0, 0.0, 4.0), + ] + native_anchor_atoms = [ + _atom("1", "A", 1, atom_name, coordinate) + for atom_name, coordinate in zip( + ("N", "CA", "C", "O"), + native_anchor_coordinates, + strict=True, + ) + ] + native_scaffold_atom = _atom("1", "A", 3, "CA", (9.0, 9.0, 9.0)) + native_replaced_atom = _atom("1", "A", 2, "CA", (100.0, 100.0, 100.0)) + native_atoms = [*native_anchor_atoms, native_replaced_atom, native_scaffold_atom] + + known = _known_transform() + model_anchor_coordinates = apply_transform(native_anchor_coordinates, known) + model_anchor_atoms = [ + _atom("1", "A", 1, atom_name, coordinate) + for atom_name, coordinate in zip( + ("N", "CA", "C", "O"), + model_anchor_coordinates, + strict=True, + ) + ] + replacement_source_coordinate = np.array([[5.0, 6.0, 7.0]]) + model_replacement_coordinate = apply_transform(replacement_source_coordinate, known)[0] + model_replacement_atom = _atom("1", "A", 2, "CA", model_replacement_coordinate) + model_atoms = [*model_anchor_atoms, model_replacement_atom] + + fit = fit_anchor_atoms( + native_atoms, + model_atoms, + [atom_key(atom) for atom in native_anchor_atoms], + ) + engineered_atoms, result = graft_replacement_atoms( + native_atoms, + [model_replacement_atom], + fit, + remove_native_keys=[atom_key(native_replaced_atom)], + ) + + engineered_by_key = {atom_key(atom): atom for atom in engineered_atoms} + for native_atom in [*native_anchor_atoms, native_scaffold_atom]: + engineered_atom = engineered_by_key[atom_key(native_atom)] + assert _coordinates(engineered_atom) == _coordinates(native_atom) + transformed_replacement = engineered_by_key[atom_key(native_replaced_atom)] + np.testing.assert_allclose( + _coordinates(transformed_replacement), + replacement_source_coordinate[0], + atol=1e-12, + ) + assert result.atom_count == len(native_atoms) + assert result.preserved_atom_count == len(native_atoms) - 1 + assert result.removed_native_atom_count == 1 + assert result.inserted_atom_count == 1 + assert result.warnings == [] + + +def _known_transform() -> AnchorFitResult: + return AnchorFitResult( + rotation=[ + [0.0, -1.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + ], + translation=[10.0, -2.0, 5.0], + anchor_rmsd=0.0, + anchor_count=4, + ) + + +def _atom( + model_id: str, + chain_id: str, + residue_number: int, + atom_name: str, + coordinate: Sequence[float], +) -> AtomRecord: + return AtomRecord( + model_id=model_id, + chain_id=chain_id, + residue_name="ALA", + residue_number=residue_number, + insertion_code=None, + atom_name=atom_name, + altloc=None, + element="C", + x=float(coordinate[0]), + y=float(coordinate[1]), + z=float(coordinate[2]), + ) + + +def _coordinates(atom: AtomRecord) -> tuple[float, float, float]: + return (atom.x, atom.y, atom.z) diff --git a/tests/test_module_discovery.py b/tests/test_module_discovery.py index 1aca64f..850cbaa 100644 --- a/tests/test_module_discovery.py +++ b/tests/test_module_discovery.py @@ -24,14 +24,29 @@ def test_builtin_module_discovery_loads_example_hello() -> None: ctx = _module_context(app) loaded = load_builtin_modules(ctx) + by_name = {module.name: module for module in loaded} - assert [(module.name, module.status) for module in loaded] == [("example_hello", "loaded")] - assert loaded[0].cli_groups == ["hello-module"] + assert by_name["example_hello"].status == "loaded" + assert by_name["example_hello"].cli_groups == ["hello-module"] assert ctx.workflows.list_for_module("example_hello")[0].name == "example_hello.ping" assert ctx.artifacts.list_for_module("example_hello")[0].name == ("example_hello.ping_artifact") assert ctx.reports.list_for_module("example_hello")[0].name == ("example_hello.ping_report") +def test_builtin_module_discovery_loads_symmetry_graft() -> None: + app = typer.Typer() + ctx = _module_context(app) + + loaded = load_builtin_modules(ctx) + by_name = {module.name: module for module in loaded} + + assert by_name["symmetry_graft"].status == "loaded" + assert by_name["symmetry_graft"].cli_groups == ["graft", "symmetry"] + assert ctx.workflows.list_for_module("symmetry_graft")[0].name == ("symmetry_preserving_graft") + assert ctx.artifacts.get("engineered_asu_cif").module_name == "symmetry_graft" + assert ctx.reports.get("symmetry_report_json").module_name == "symmetry_graft" + + def test_builtin_module_cli_group_is_registered() -> None: app = typer.Typer() ctx = _module_context(app) diff --git a/tests/test_modules_cli.py b/tests/test_modules_cli.py index 280b44a..66238bb 100644 --- a/tests/test_modules_cli.py +++ b/tests/test_modules_cli.py @@ -23,6 +23,15 @@ def test_modules_list_includes_example_module_metadata() -> None: assert "hello-module" in result.output +def test_modules_list_includes_symmetry_graft_module_metadata() -> None: + result = CliRunner().invoke(app, ["modules", "list"]) + + assert result.exit_code == 0 + assert "symmetry_graft" in result.output + assert "loaded" in result.output + assert "graft, symmetry" in result.output + + def test_modules_info_includes_registrations() -> None: result = CliRunner().invoke(app, ["modules", "info", "example_hello"]) @@ -35,6 +44,19 @@ def test_modules_info_includes_registrations() -> None: assert "Reports: example_hello.ping_report" in result.output +def test_modules_info_includes_symmetry_graft_registrations() -> None: + result = CliRunner().invoke(app, ["modules", "info", "symmetry_graft"]) + + assert result.exit_code == 0 + assert "Display name: Symmetry-Preserving Graft" in result.output + assert "Dependencies: gemmi, numpy" in result.output + assert "Optional dependencies: biotite" in result.output + assert "CLI groups: graft, symmetry" in result.output + assert "Workflows: symmetry_preserving_graft" in result.output + assert "engineered_asu_cif" in result.output + assert "Reports: symmetry_report_json" in result.output + + def test_modules_info_unknown_module_fails_cleanly() -> None: result = CliRunner().invoke(app, ["modules", "info", "missing_module"]) diff --git a/tests/test_symmetry_inspect.py b/tests/test_symmetry_inspect.py new file mode 100644 index 0000000..64f0c68 --- /dev/null +++ b/tests/test_symmetry_inspect.py @@ -0,0 +1,94 @@ +"""Tests for structure symmetry inspection.""" + +import json +from pathlib import Path + +from typer.testing import CliRunner + +from foldfoundry.cli import app +from foldfoundry.modules.symmetry_graft import ( + detect_structure_format, + inspect_structure, + load_structure, +) + +FIXTURES = Path(__file__).parent / "fixtures" / "structures" + + +def test_detect_structure_format_accepts_pdb_and_mmcif() -> None: + assert detect_structure_format(FIXTURES / "tiny_assembly.cif") == "mmcif" + assert detect_structure_format(FIXTURES / "tiny_biomt.pdb") == "pdb" + + +def test_load_structure_indexes_source_frame_atoms() -> None: + handle = load_structure(FIXTURES / "tiny_assembly.cif") + + assert handle.format == "mmcif" + assert len(handle.atoms) == 4 + assert handle.atoms[0].chain_id == "A" + assert handle.atoms[0].residue_number == 1 + assert handle.coordinate_frame.unit_cell["a"] == 20.0 + + +def test_inspect_mmcif_reports_assembly_crystal_and_deposited_contexts() -> None: + report = inspect_structure(FIXTURES / "tiny_assembly.cif") + + assert report.format == "mmcif" + assert report.atom_count == 4 + assert report.chain_count == 1 + modes = [context.mode for context in report.symmetry_contexts] + assert modes == ["biological_assembly", "crystal_packing", "deposited"] + assembly = report.symmetry_contexts[0] + assert assembly.assembly_id == "1" + assert [operator.id for operator in assembly.operators] == ["1", "2"] + assert assembly.chain_operator_mapping == {"A": ["1", "2"]} + crystal = report.symmetry_contexts[1] + assert crystal.space_group == "P 1" + assert crystal.unit_cell["gamma"] == 120.0 + + +def test_inspect_pdb_reports_biomt_mtrix_crystal_and_deposited_contexts() -> None: + report = inspect_structure(FIXTURES / "tiny_biomt.pdb") + + modes = [context.mode for context in report.symmetry_contexts] + assert modes == ["biological_assembly", "ncs", "crystal_packing", "deposited"] + assembly = report.symmetry_contexts[0] + assert assembly.chain_operator_mapping == {"A": ["biomt_1", "biomt_2"]} + assert [operator.source for operator in assembly.operators] == ["biomt", "biomt"] + ncs = report.symmetry_contexts[1] + assert [operator.id for operator in ncs.operators] == ["mtrix_1"] + crystal = report.symmetry_contexts[2] + assert crystal.space_group == "P 1" + + +def test_cli_symmetry_inspect_prints_json() -> None: + result = CliRunner().invoke( + app, + ["symmetry", "inspect", str(FIXTURES / "tiny_assembly.cif"), "--json"], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["format"] == "mmcif" + assert payload["symmetry_contexts"][0]["mode"] == "biological_assembly" + assert payload["dependency_versions"]["gemmi"] + + +def test_cli_symmetry_inspect_writes_report(tmp_path: Path) -> None: + output_path = tmp_path / "symmetry_report.json" + + result = CliRunner().invoke( + app, + [ + "symmetry", + "inspect", + str(FIXTURES / "tiny_biomt.pdb"), + "--out", + str(output_path), + ], + ) + + assert result.exit_code == 0 + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["format"] == "pdb" + assert "OK: wrote symmetry report" in result.output diff --git a/uv.lock b/uv.lock index b7fbeb4..328dcaf 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,139 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "biotite" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "biotraj" }, + { name = "msgpack" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/7b/99153f7bceef01034b5f19a6b123219533132d446ffcf141dfef3e386d33/biotite-1.6.0.tar.gz", hash = "sha256:4c172f6e57521220fa0fc4899142211f6f21ba83d8f6f135d4edc68981f70e7e", size = 38514388, upload-time = "2026-01-23T12:47:38.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/4a/8ba87252ad37fd9dc3b3d2d3439b53b6a1d36f82fdd4df7927af22fe6e75/biotite-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:da0c14955e13026a84a5c29a24433116f41e469201cc9df3905e2129ac419e1c", size = 41653637, upload-time = "2026-01-23T12:46:51.794Z" }, + { url = "https://files.pythonhosted.org/packages/39/e5/92c6245023f2b8562aaeaf17c6b0763221838f661b64b4fb86e5126dec1f/biotite-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cfca16e425d3fc11b0960856556e69d777234b113eb4e6d1991b81ff52c0351c", size = 41536100, upload-time = "2026-01-23T12:46:54.863Z" }, + { url = "https://files.pythonhosted.org/packages/65/2e/2b527586d6dac0ca0cea78fc9c12d29972789b69f3c0554a1c21f567f3c6/biotite-1.6.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdc0ba7c7ba1473419cf5836398ab65f2b048d6e6f9e480ea6c361c08f511d30", size = 56620776, upload-time = "2026-01-23T12:46:58.34Z" }, + { url = "https://files.pythonhosted.org/packages/2d/39/2d6a10a341b4457f4500823e0193b92bced378356521cc53ed2832f1e04b/biotite-1.6.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:272da247b63588a1a075f1dfb2d05a3b5ae88e9cfcd0ca9625d9bd3946914c72", size = 57196435, upload-time = "2026-01-23T12:47:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/29/5fdfc843e75534e94c9529817aae30d5343b462bdb8600e390ae952a46bc/biotite-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:6a010abb462a6f2af1f2fa5e5b66a3c27bc31d0965757051cad8c83d5c2531b4", size = 41201746, upload-time = "2026-01-23T12:47:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c0/2f911876c91468e9794003724cc641f0bfe7563229f537ee6a2040b6bc6f/biotite-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69d16be1721bb65769e1daa9316580e8e7c832d487cb9e5f1ce1f3100492f966", size = 41648417, upload-time = "2026-01-23T12:47:07.791Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5b/ca8476009f5dab4efbd5e889e60b14eac4650384bf39bada08e551d97da9/biotite-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cbac31dfc484a953a0b50ad692ab558e5537a39894b126791506da2ba8e759f", size = 41531163, upload-time = "2026-01-23T12:47:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/b72f7ce4438401d9d7c6b2c46ef26e7cf1187f0ff9faf15b8444cacdc0da/biotite-1.6.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4026e034e90696780598f46fccd9e020a562983620f77c4e6f51b0805f929f38", size = 56584437, upload-time = "2026-01-23T12:47:14.53Z" }, + { url = "https://files.pythonhosted.org/packages/25/44/c8cfb570d8a131e136e3f06b9c47d4dea0b6f1ce0b34d1ce1092331e7505/biotite-1.6.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5087a40853a451abf4aedae1293bb413ef1aa4dd8287fc2b363b4a5c70c3ae55", size = 57184611, upload-time = "2026-01-23T12:47:17.642Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5a/4594df3af07446506305806f253f1432374f9f318d0225725631dacfde83/biotite-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:fe64a929d7ddede42c4c757a81ef505d5f389e788bc8b9c61c8fe605899a20a4", size = 41201315, upload-time = "2026-01-23T12:47:20.481Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a3/3b54a4fa882ff29408626c20f02c826c8b9cfcb4faa5d5aad47d1063ed7f/biotite-1.6.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:588798183172dde36de91f32d6312aae7dbb8e655e3a395c08ee1023919e43cf", size = 41646883, upload-time = "2026-01-23T12:47:23.451Z" }, + { url = "https://files.pythonhosted.org/packages/05/11/8dbd811ac907015f7e12383c0b1bcd8872e0fe9085a822cc94e6ddd882de/biotite-1.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:16ec5b541caa68cf79c17a64b26526b1884b96884a016e443da1b21d9bd849ab", size = 41531108, upload-time = "2026-01-23T12:47:26.135Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/b5358c885a9c04c3988678cb352e53edfb5cf234f8c653159d0830a58a42/biotite-1.6.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24cd941932987569be48b85219d7a97bacad865532a3e5b31998cc5f192f9fd7", size = 56511519, upload-time = "2026-01-23T12:47:29.721Z" }, + { url = "https://files.pythonhosted.org/packages/62/b7/fb3b2fa155a3a5cf00f8e970e82a7554070c59a127b326375aa36d5452db/biotite-1.6.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef558c9bb1715753acf5819593fd9f3f80bc2feaa02a7d9f0d7d8d15a284d182", size = 56926154, upload-time = "2026-01-23T12:47:32.824Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0e/f2a1f98ec994e5ee4568bad6b56e6828a5e74bd95c8a6b996156bd18d0a5/biotite-1.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:7a972c434484cab4cff2c1a6263b146c57341fdc0db12a4c387ba4f12e7b29a7", size = 41186067, upload-time = "2026-01-23T12:47:35.779Z" }, +] + +[[package]] +name = "biotraj" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/21/2287edfd0d2569639eea706e25c39e63b46a384cf1712db8ea05768317b0/biotraj-1.2.2.tar.gz", hash = "sha256:4bcba92101ed50f369cc1487fb5dfcfe1d8402ad47adaa9232b080553271663a", size = 3909030, upload-time = "2024-11-02T11:30:54.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/da/bf56c6cc27212ed2b5ad3beb1c399b74ac147236b22900a9434760ebfa27/biotraj-1.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b584c2da6d353c09f839e9b72fa243b6f45050b311cdcd5a0f3a44ced80d714e", size = 859982, upload-time = "2024-11-02T11:30:40.948Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d2/369ed44ad23f8c0464f7b80d41143d717896097b39fe3dd3e07e6d162fef/biotraj-1.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7bab65f5e3975a844c1018ea5873acd7dd21a64e8ebf0c145635c1d2fb9ef9bd", size = 833765, upload-time = "2024-11-02T11:30:42.628Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/c607b6337ddcf55cc0249527819b727ef28481d7c56ace54cca69400c2b9/biotraj-1.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b449930fa70666db0f2e7b1e5f5dad65917086adc66a70f26db38d79b0171815", size = 2239973, upload-time = "2024-11-02T11:30:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/13/56/bed88eb34acb31855e7c82a1d68e58775c037ec66ccb2bb02ff310615240/biotraj-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:3427567cc0bd0e981bf7de1a6b65baf82984171852994d304b2f316a8926db83", size = 358790, upload-time = "2024-11-02T11:30:46.19Z" }, + { url = "https://files.pythonhosted.org/packages/1f/12/8f6a2b21a8b43a2bf85352c993afecb21d40eb3d7373b6242163a93f57e0/biotraj-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31ed2a9e8a6436f5432f22883e68a36c223b98de0ab80225efbaf67da339a2b2", size = 856347, upload-time = "2024-11-02T11:30:47.431Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4d/b1e792c01e61bbfe03b290a0805f0c7bc3949bf9cd4031bcdd183d2f2524/biotraj-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6a8c84ac95bf65b73928774ec46b72d62b0f30fa12eb8c56c78ed25235f0acf8", size = 832499, upload-time = "2024-11-02T11:30:49.363Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6f/ab71525583a7824c70f71de387a1c5ceb27ddcb3fda2dacb734e5b875f14/biotraj-1.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dcfa4a4c755ddc206f81999fd47664747cd2e546e16a51d885332cd4c955f41", size = 2246545, upload-time = "2024-11-02T11:30:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9a/46d2d67b5b672d5d2ffbfb3551fc4b499b45d5edce2558e259f69b72a0d9/biotraj-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:e0ed1b586e23ad53e53fb42be8541fb964ec6c4183e6d500fef0bcc1bdcd7487", size = 359596, upload-time = "2024-11-02T11:30:52.876Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.3.3" @@ -151,9 +284,19 @@ dependencies = [ { name = "typer" }, ] +[package.optional-dependencies] +symmetry-graft = [ + { name = "biotite" }, + { name = "gemmi" }, + { name = "numpy" }, +] + [package.dev-dependencies] dev = [ + { name = "biotite" }, + { name = "gemmi" }, { name = "mypy" }, + { name = "numpy" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -165,17 +308,24 @@ packaging = [ [package.metadata] requires-dist = [ + { name = "biotite", marker = "extra == 'symmetry-graft'", specifier = ">=1.6.0" }, + { name = "gemmi", marker = "extra == 'symmetry-graft'", specifier = ">=0.7.0" }, { name = "inquirerpy", specifier = ">=0.3.4" }, + { name = "numpy", marker = "extra == 'symmetry-graft'", specifier = ">=2.0" }, { name = "playwright", specifier = ">=1.48" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "rich", specifier = ">=13.7" }, { name = "typer", specifier = ">=0.12" }, ] +provides-extras = ["symmetry-graft"] [package.metadata.requires-dev] dev = [ + { name = "biotite", specifier = ">=1.6.0" }, + { name = "gemmi", specifier = ">=0.7.0" }, { name = "mypy", specifier = ">=1.10" }, + { name = "numpy", specifier = ">=2.0" }, { name = "pytest", specifier = ">=8.2" }, { name = "pytest-cov", specifier = ">=6.0" }, { name = "ruff", specifier = ">=0.5" }, @@ -183,6 +333,42 @@ dev = [ ] packaging = [{ name = "pyinstaller", specifier = ">=6.6" }] +[[package]] +name = "gemmi" +version = "0.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/38/a79d9e672b837ceefa7da1921c33b10479362603c4c6370bc004d69558d6/gemmi-0.7.5.tar.gz", hash = "sha256:3328f26c8a8a0ef6a7fc8bb28e167818e324e4239dd4197d6b6066ae2b6315fe", size = 1523171, upload-time = "2026-03-02T08:27:31.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/72/7e33f0c1871d648088e3dd67ce47366ed942d625300f6d966730da92a7d7/gemmi-0.7.5-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:2da5d5c1d31fc8c3bffe7530c697c97f8389edff57e8b12898218c588c4f0dac", size = 2845071, upload-time = "2026-03-02T08:31:31.413Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/a6497e1c2c9bc6ed2b79e0f2d31a4ce509fd2a9eed4e4f7ac63eda8113cb/gemmi-0.7.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5682920985109c6a08616ae9aae080f8b46a9714534dc864b535e3e6d203d5b8", size = 2720607, upload-time = "2026-03-02T08:31:33.778Z" }, + { url = "https://files.pythonhosted.org/packages/df/31/704e6c7ffc251d1dc1a19a8cd2e30881a83978c6df8668ba052523fa1720/gemmi-0.7.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:255ca0b0a7f6fb0bf4322f2d69c5f94edf6e95fb801bc1d120ca8dd93b646065", size = 2624986, upload-time = "2026-03-02T08:31:35.902Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/5a431cd1ea7587408a66947384b39beb2ab2bcc1c87b7c4082f05036719f/gemmi-0.7.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:217bb9ac9da7c90704026dacfc0a0652a38f4df1e318225d8f35c75f1f8c7ebf", size = 2982717, upload-time = "2026-03-02T08:31:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/36/e0/ca646b4e22b3d6129ce56a087a9031f7a7843d47425f0adc38a7ab789b24/gemmi-0.7.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74e1b5177b626aadb819fd8168f5d6064c04a2a1e45c87f357a96d30ddafc749", size = 3146031, upload-time = "2026-03-02T08:31:39.744Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cc/47e6039859393175a9b38f9a72732c018a3052d838fecf1ff635d8b84d95/gemmi-0.7.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6d30fa7ae889149c22dbb58899e77117e6548edc6e8ccfae3b4b2a259464d2ee", size = 3505196, upload-time = "2026-03-02T08:31:41.651Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f2/53be7a4ba5816e13c39be0f728facac4bcb39cf4903ceeec54b006511c8f/gemmi-0.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:a1fdb6f72006495b5119e3a8bb5c3185efa708b785bd4a5ce4397ef7abb3fec7", size = 2270488, upload-time = "2026-03-02T08:31:43.898Z" }, + { url = "https://files.pythonhosted.org/packages/c4/80/fd758344a72ca7b5e1c5bbdc1d263f3b215d3897941b5f450380445ca0a9/gemmi-0.7.5-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:ef9b6ada1c00c6ba7c7a5b9e938cc3b45d83e775c23d12bf63b6882d5f3cdd6b", size = 2844981, upload-time = "2026-03-02T08:31:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9c/1236dd7d22ed48527286b613c84e3376ea731b65e6734b6e6a0b4d03744c/gemmi-0.7.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c7d8b08c33fe6ba375223306149092440c69cbfbd55c3d3e3436e5fb315a225d", size = 2720773, upload-time = "2026-03-02T08:31:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/ccf890f054f2fc12ff3a43a604a7a1e9f99706f057394e5c7d51c67cf6ed/gemmi-0.7.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2bd55985d7cf4403985118f677a187a3f0bb96fd314fb4582e66c2ab4a752ec", size = 2625116, upload-time = "2026-03-02T08:31:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8c/db8e79c4c744ebae1dcf25f7dbcc5d7df912cdbcdf7221e761479e8bd04b/gemmi-0.7.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:750b4d9751aaf1460ac4f0f45308ddced25f47bcf7a30355eb3b1f779f03952a", size = 2982474, upload-time = "2026-03-02T08:31:52.09Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/24c0071ad231b22dac9acf7e7e544e0b6466307a01d716c8a06363fa70a4/gemmi-0.7.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:789f0e05e8ad020c69011351c54cc1a9555f6aaf2ac18e00e5624eb5255c309d", size = 3146075, upload-time = "2026-03-02T08:31:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/08/06/da6fe6eb09e7f3e439e8c5b85908bf9539dfb7afe19bb4853f0c1fd98e4c/gemmi-0.7.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f34643c917c9ae0c26cded3044ad4634987469797188782b882cd2812c7769b1", size = 3505371, upload-time = "2026-03-02T08:31:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ab/7d7463cda94f8b68b969ea97aaad679655a0e436efd6a643e528a8de114e/gemmi-0.7.5-cp313-cp313-win_amd64.whl", hash = "sha256:ad1f72ffa24adbfaf259e11471f6f071a668667f6ca846051f3bfea024fd337d", size = 2270352, upload-time = "2026-03-02T08:31:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/07e9e47a223abd2490b14d31719d65e609932ba29355b453cfe8cd412142/gemmi-0.7.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f1e6547e3af4fa23664a2bf7775478ebc1799a914d96560140c7dff366e0cded", size = 2846225, upload-time = "2026-03-02T08:32:00.083Z" }, + { url = "https://files.pythonhosted.org/packages/06/41/4e70dea1d0311016c0b0b1c53a24a266f9f8a34c6bc1af0f17cfca20aa1d/gemmi-0.7.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5144f107f2bca479d1b8266a79649bd631ee92c5b1319b27b0279157331ebc89", size = 2721745, upload-time = "2026-03-02T08:32:01.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/27/e1859a6ef2330f83a4818d8f1bc6d1b21b681313f26d3d4bd4fc54f88335/gemmi-0.7.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea386ec725baa7253e1aa146540d4f8f8145fc32a26d0ac025be97fd7f593557", size = 2627960, upload-time = "2026-03-02T08:32:03.996Z" }, + { url = "https://files.pythonhosted.org/packages/e5/04/c5bb20d64417d20cba0105277235c51969444fa873000fbc26ac0a3fc5a8/gemmi-0.7.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdc67ad4a7fc420974ab3102f7f6ad1517fa0c3d9f2f7561e42e5f7017635242", size = 2984907, upload-time = "2026-03-02T08:32:05.73Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c7/16f0913d82bcb817e63d8a183de8a8d0379a4837629e497aa60e01e89e78/gemmi-0.7.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fef67aa026e523f43ced23afb8204b314cdf5770eaea97b4a06ac236d782cb10", size = 3149649, upload-time = "2026-03-02T08:32:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/c8/77/d26931ef5b50c69128ca04d19fdd0ce0cd3a392263cb9e4bebb4cd150459/gemmi-0.7.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e496880ef0e2f5c929302db2d5e3489c48af1b70a3653870defafb17384e64b7", size = 3507297, upload-time = "2026-03-02T08:32:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1c/a28b27effb13a381fe077ea3e3e78f6debd6315f2b3edff67bbb93d0ef51/gemmi-0.7.5-cp314-cp314-win_amd64.whl", hash = "sha256:419c36d9ea0f28dda0ff0d6db17035170d0888ca78aff82a0f9f604613aec58f", size = 2333709, upload-time = "2026-03-02T08:32:25.888Z" }, + { url = "https://files.pythonhosted.org/packages/85/41/dc47a16404a250cc8f5ff441e2746b380e15ad73aec12d359e85ada0db21/gemmi-0.7.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:647e25ce2f78c3da2577503892556d8aba0bc3014085affc82d375794b239a30", size = 2894672, upload-time = "2026-03-02T08:32:10.947Z" }, + { url = "https://files.pythonhosted.org/packages/59/24/d411c04807dcac85567852b21647d953b65e5991359ec37097aa1c6b2b55/gemmi-0.7.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e72db1f4580a24c1ff9a2426bab8b6b407ffdb394ec7417f40dfea68a3fc2505", size = 2768311, upload-time = "2026-03-02T08:32:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/da/b33ea873c2035b45318e1ed7ffb4d8d0c40c9ac99029761449bef1f226b5/gemmi-0.7.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d015f560ad436fcecb9a9577f1ad304f5a45496a95c1f28326f0277b8044337", size = 2660998, upload-time = "2026-03-02T08:32:15.142Z" }, + { url = "https://files.pythonhosted.org/packages/81/7f/c0efd1cd08d48c2a339922739b69b2244b7099d7bd6e48eba8477d5c7758/gemmi-0.7.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:667fe4c20ef52a36dad397367c282293edf9deedfee58c03da2f89bf89099589", size = 3005330, upload-time = "2026-03-02T08:32:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/4b799d2e1891fcfba02cb031ffc4b900f278040e5579c38e7d3d75d739da/gemmi-0.7.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a9b92612cb33cbd1788c7620a1445b506459f927032cde2a761b91e5754c512d", size = 3185785, upload-time = "2026-03-02T08:32:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/4889b36322255592a9aa1bb20ee847804413378521410b454cfc73698665/gemmi-0.7.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0b7ddf3f93688a8a7893858b45cb4df5ee324309939f461fc3734db575bde44c", size = 3527630, upload-time = "2026-03-02T08:32:22.528Z" }, + { url = "https://files.pythonhosted.org/packages/74/29/07cb963112e2559ef4128d9bded0c9f74db8f1ba4468555d646c99942e52/gemmi-0.7.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1aa3abfca7b65557ee1efba1247729d322c838a6c67371de6731125d2a337ed7", size = 2356795, upload-time = "2026-03-02T08:32:24.17Z" }, +] + [[package]] name = "greenlet" version = "3.4.0" @@ -230,6 +416,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, ] +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -345,6 +540,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + [[package]] name = "mypy" version = "1.20.2" @@ -397,6 +636,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + [[package]] name = "packaging" version = "26.1" @@ -710,6 +1019,21 @@ wheels = [ { 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 = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -748,6 +1072,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + [[package]] name = "setuptools" version = "82.0.1" @@ -811,6 +1196,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "wcwidth" version = "0.6.0" From 505a8740c770adaf8f8f44cc9ad965d88f4de8d4 Mon Sep 17 00:00:00 2001 From: "Jeremiah J. Gassensmith" Date: Sat, 2 May 2026 19:36:04 -0500 Subject: [PATCH 2/3] Add live RCSB symmetry smoke set --- PLANS.md | 6 +++ docs/design/symmetry_preserving_graft.md | 1 + tests/test_symmetry_graft_live.py | 53 ++++++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 tests/test_symmetry_graft_live.py diff --git a/PLANS.md b/PLANS.md index 8be559a..67c4197 100644 --- a/PLANS.md +++ b/PLANS.md @@ -111,6 +111,12 @@ Implementation milestones: - Detect mmCIF biological assemblies, PDB BIOMT, mmCIF/PDB NCS, crystal unit-cell/space-group metadata, and deposited-coordinate fallback. - Expose `foldforge symmetry inspect STRUCTURE --json --out report.json`. + - Keep opt-in real-data smoke coverage for user-supplied RCSB structures + `7LGE`, `1QBE`, and `5UU5`. As of the live smoke added here, `1QBE` is the + crystallographic positive case, while `7LGE` and `5UU5` exercise + non-crystallographic deposited-coordinate fallback. These must remain live + smoke tests, not network-dependent default unit tests or structure-specific + code paths. 3. Kabsch graft into source frame. - Add anchor mapping, rigid fitting, transform application, and strict scaffold-preservation tests without requiring any model backend. diff --git a/docs/design/symmetry_preserving_graft.md b/docs/design/symmetry_preserving_graft.md index d757032..0fcc5d9 100644 --- a/docs/design/symmetry_preserving_graft.md +++ b/docs/design/symmetry_preserving_graft.md @@ -46,6 +46,7 @@ - Unit tests: Kabsch transform recovery, distance preservation, anchor RMSD, scaffold coordinate invariance outside replacement regions, replacement atom transform application, cutpoint checks, clash detection with bonded-neighbor exclusions, chain-equivalence classification, and chain/operator ID collision handling. - Symmetry tests: small mmCIF assembly fixture, PDB BIOMT fixture, MTRIX/NCS fixture when supported, and a simple crystal cell for bounded packing-shell neighbor counts. +- Opt-in live RCSB smoke tests: download user-supplied structures `7LGE`, `1QBE`, and `5UU5` and inspect them through the module-owned code path. `1QBE` is the crystallographic positive case with unit-cell/space-group metadata; `7LGE` and `5UU5` currently exercise the non-crystallographic deposited-coordinate fallback path. These stay behind the live-test gate and must not become structure-specific production logic. - Integration tests: mock model output by applying a known transform to a synthetic edited chain, verify graft returns to native frame, verify assembly expansion operator count/chain mapping, and validate JSON report schema. - Prefer semantic assertions over fragile golden atom-order snapshots: atom counts, operator IDs, RMSD thresholds, chain mapping, clash counts, and unchanged coordinate hashes for preserved scaffold atoms. - Required repo checks after implementation: `uv run pytest`, `uv run ruff check .`, `uv run ruff format --check .`, and `uv run mypy src`. diff --git a/tests/test_symmetry_graft_live.py b/tests/test_symmetry_graft_live.py new file mode 100644 index 0000000..922091b --- /dev/null +++ b/tests/test_symmetry_graft_live.py @@ -0,0 +1,53 @@ +"""Opt-in live smoke tests for user-supplied real RCSB structures.""" + +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import urlretrieve + +import pytest + +from foldfoundry.modules.symmetry_graft import inspect_structure + +pytestmark = pytest.mark.live + +RCSB_REAL_STRUCTURE_SMOKE_CASES = ( + ("7LGE", False), + ("1QBE", True), + ("5UU5", False), +) + + +@pytest.mark.parametrize(("pdb_id", "expect_crystal_metadata"), RCSB_REAL_STRUCTURE_SMOKE_CASES) +def test_live_rcsb_structure_inspection_uses_crystal_metadata_or_fallback( + tmp_path: Path, + pdb_id: str, + expect_crystal_metadata: bool, +) -> None: + """Download a real RCSB mmCIF and verify crystal metadata or fallback behavior.""" + structure_path = tmp_path / f"{pdb_id}.cif" + _download_rcsb_mmcif(pdb_id, structure_path) + + report = inspect_structure(structure_path) + crystal_contexts = [ + context for context in report.symmetry_contexts if context.mode == "crystal_packing" + ] + + assert report.format == "mmcif" + assert report.atom_count > 0 + assert any(context.mode == "deposited" for context in report.symmetry_contexts) + if expect_crystal_metadata: + assert report.coordinate_frame.unit_cell + assert report.coordinate_frame.space_group + assert crystal_contexts + assert crystal_contexts[0].unit_cell + assert crystal_contexts[0].space_group + else: + assert not crystal_contexts + + +def _download_rcsb_mmcif(pdb_id: str, output_path: Path) -> None: + url = f"https://files.rcsb.org/download/{pdb_id}.cif" + try: + urlretrieve(url, output_path) + except (HTTPError, URLError, TimeoutError) as exc: + pytest.fail(f"RCSB live structure download failed for {pdb_id}: {exc}") From fe7a5d425c1d280ba44ed83c6afef2ade5a5f320 Mon Sep 17 00:00:00 2001 From: "Jeremiah J. Gassensmith" Date: Sun, 3 May 2026 06:36:25 -0500 Subject: [PATCH 3/3] Add 4V4M crystallographic smoke case --- PLANS.md | 10 +++++----- docs/design/symmetry_preserving_graft.md | 2 +- tests/test_symmetry_graft_live.py | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/PLANS.md b/PLANS.md index 67c4197..73fba96 100644 --- a/PLANS.md +++ b/PLANS.md @@ -112,11 +112,11 @@ Implementation milestones: unit-cell/space-group metadata, and deposited-coordinate fallback. - Expose `foldforge symmetry inspect STRUCTURE --json --out report.json`. - Keep opt-in real-data smoke coverage for user-supplied RCSB structures - `7LGE`, `1QBE`, and `5UU5`. As of the live smoke added here, `1QBE` is the - crystallographic positive case, while `7LGE` and `5UU5` exercise - non-crystallographic deposited-coordinate fallback. These must remain live - smoke tests, not network-dependent default unit tests or structure-specific - code paths. + `7LGE`, `1QBE`, `4V4M`, and `5UU5`. As of the live smoke added here, + `1QBE` and `4V4M` are crystallographic positive cases, while `7LGE` and + `5UU5` exercise non-crystallographic deposited-coordinate fallback. These + must remain live smoke tests, not network-dependent default unit tests or + structure-specific code paths. 3. Kabsch graft into source frame. - Add anchor mapping, rigid fitting, transform application, and strict scaffold-preservation tests without requiring any model backend. diff --git a/docs/design/symmetry_preserving_graft.md b/docs/design/symmetry_preserving_graft.md index 0fcc5d9..1d2fff6 100644 --- a/docs/design/symmetry_preserving_graft.md +++ b/docs/design/symmetry_preserving_graft.md @@ -46,7 +46,7 @@ - Unit tests: Kabsch transform recovery, distance preservation, anchor RMSD, scaffold coordinate invariance outside replacement regions, replacement atom transform application, cutpoint checks, clash detection with bonded-neighbor exclusions, chain-equivalence classification, and chain/operator ID collision handling. - Symmetry tests: small mmCIF assembly fixture, PDB BIOMT fixture, MTRIX/NCS fixture when supported, and a simple crystal cell for bounded packing-shell neighbor counts. -- Opt-in live RCSB smoke tests: download user-supplied structures `7LGE`, `1QBE`, and `5UU5` and inspect them through the module-owned code path. `1QBE` is the crystallographic positive case with unit-cell/space-group metadata; `7LGE` and `5UU5` currently exercise the non-crystallographic deposited-coordinate fallback path. These stay behind the live-test gate and must not become structure-specific production logic. +- Opt-in live RCSB smoke tests: download user-supplied structures `7LGE`, `1QBE`, `4V4M`, and `5UU5` and inspect them through the module-owned code path. `1QBE` and `4V4M` are crystallographic positive cases with unit-cell/space-group metadata; `7LGE` and `5UU5` currently exercise the non-crystallographic deposited-coordinate fallback path. These stay behind the live-test gate and must not become structure-specific production logic. - Integration tests: mock model output by applying a known transform to a synthetic edited chain, verify graft returns to native frame, verify assembly expansion operator count/chain mapping, and validate JSON report schema. - Prefer semantic assertions over fragile golden atom-order snapshots: atom counts, operator IDs, RMSD thresholds, chain mapping, clash counts, and unchanged coordinate hashes for preserved scaffold atoms. - Required repo checks after implementation: `uv run pytest`, `uv run ruff check .`, `uv run ruff format --check .`, and `uv run mypy src`. diff --git a/tests/test_symmetry_graft_live.py b/tests/test_symmetry_graft_live.py index 922091b..bb88667 100644 --- a/tests/test_symmetry_graft_live.py +++ b/tests/test_symmetry_graft_live.py @@ -13,6 +13,7 @@ RCSB_REAL_STRUCTURE_SMOKE_CASES = ( ("7LGE", False), ("1QBE", True), + ("4V4M", True), ("5UU5", False), )