mattergraph-sim 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+ .venv/
9
+ venv/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .pytest_cache/
13
+ .hypothesis/
14
+ .coverage
15
+ coverage.xml
16
+ htmlcov/
17
+
18
+ # Docs build output
19
+ site/
20
+
21
+ # Env
22
+ .env
23
+ .env.local
24
+ *.local
25
+
26
+ # Node
27
+ node_modules/
28
+ apps/web/dist/
29
+ apps/web/playwright-report/
30
+ apps/web/test-results/
31
+ *.tsbuildinfo
32
+ .next/
33
+ out/
34
+
35
+ # IDE
36
+ .idea/
37
+ .vscode/
38
+ *.swp
39
+
40
+ # OS
41
+ .DS_Store
42
+
43
+ # Data artifacts (keep demo/ tracked)
44
+ data/cache/
45
+ *.sqlite3
46
+
47
+ .claude
48
+ apps/private-platform-ui/
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: mattergraph-sim
3
+ Version: 0.1.0
4
+ Summary: ASE/LAMMPS/QE simulation job specs and runners (MVP wrappers).
5
+ Project-URL: Homepage, https://github.com/cyrusmo/MatterGraph
6
+ Project-URL: Repository, https://github.com/cyrusmo/MatterGraph
7
+ Project-URL: Issues, https://github.com/cyrusmo/MatterGraph/issues
8
+ Project-URL: Changelog, https://github.com/cyrusmo/MatterGraph/blob/main/CHANGELOG.md
9
+ Author: MatterGraph contributors
10
+ License-Expression: Apache-2.0
11
+ Keywords: ase,lammps,materials-science,quantum-espresso,simulation
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
20
+ Classifier: Topic :: Scientific/Engineering :: Physics
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: ase>=3.22
23
+ Requires-Dist: mattergraph-core~=0.1.0
24
+ Requires-Dist: pydantic>=2.5
25
+ Requires-Dist: pymatgen>=2024.1.1
26
+ Description-Content-Type: text/markdown
27
+
28
+ # mattergraph-sim
29
+
30
+ Simulation job specs and runners for [MatterGraph](https://github.com/cyrusmo/MatterGraph).
31
+
32
+ Structures round-trip through pymatgen, jobs are declared as validated Pydantic specs, and runners return a structured `SimulationJob` with status, log, and result rather than raising — so a failed relaxation is data, not an exception.
33
+
34
+ ## Engines
35
+
36
+ | Engine | Status |
37
+ |---|---|
38
+ | ASE | Working. Local relaxation via the EMT empirical potential (11 elements: Ag, Al, Au, C, Cu, H, N, Ni, O, Pd, Pt). |
39
+ | LAMMPS | Stub — environment-specific, returns a structured failure |
40
+ | Quantum ESPRESSO | Stub — site-specific paths and pseudopotentials, returns a structured failure |
41
+
42
+ > **Note on scope.** EMT is a fast empirical potential with narrow element coverage; it is suitable for smoke-testing a workflow end to end, not for producing quantitative results. Universal ML interatomic potentials are the intended path to periodic-table-wide coverage.
43
+
44
+ Externally produced results can be validated with
45
+ `mattergraph_sim.parsers.parse_result_envelope`. This is result interchange only; it does not
46
+ launch, route, or coordinate simulation engines.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install mattergraph-sim
52
+ ```
53
+
54
+ ## Example
55
+
56
+ ```python
57
+ from mattergraph_sim import AseJobSpec, SimulationJob, ase_relax
58
+
59
+ job = SimulationJob(spec=AseJobSpec(fmax=0.05, max_steps=200), input_structure=structure.model_dump())
60
+ done = ase_relax(job)
61
+ print(done.status, done.result.energy if done.result else done.error)
62
+ ```
63
+
64
+ ## License
65
+
66
+ Apache-2.0
@@ -0,0 +1,39 @@
1
+ # mattergraph-sim
2
+
3
+ Simulation job specs and runners for [MatterGraph](https://github.com/cyrusmo/MatterGraph).
4
+
5
+ Structures round-trip through pymatgen, jobs are declared as validated Pydantic specs, and runners return a structured `SimulationJob` with status, log, and result rather than raising — so a failed relaxation is data, not an exception.
6
+
7
+ ## Engines
8
+
9
+ | Engine | Status |
10
+ |---|---|
11
+ | ASE | Working. Local relaxation via the EMT empirical potential (11 elements: Ag, Al, Au, C, Cu, H, N, Ni, O, Pd, Pt). |
12
+ | LAMMPS | Stub — environment-specific, returns a structured failure |
13
+ | Quantum ESPRESSO | Stub — site-specific paths and pseudopotentials, returns a structured failure |
14
+
15
+ > **Note on scope.** EMT is a fast empirical potential with narrow element coverage; it is suitable for smoke-testing a workflow end to end, not for producing quantitative results. Universal ML interatomic potentials are the intended path to periodic-table-wide coverage.
16
+
17
+ Externally produced results can be validated with
18
+ `mattergraph_sim.parsers.parse_result_envelope`. This is result interchange only; it does not
19
+ launch, route, or coordinate simulation engines.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install mattergraph-sim
25
+ ```
26
+
27
+ ## Example
28
+
29
+ ```python
30
+ from mattergraph_sim import AseJobSpec, SimulationJob, ase_relax
31
+
32
+ job = SimulationJob(spec=AseJobSpec(fmax=0.05, max_steps=200), input_structure=structure.model_dump())
33
+ done = ase_relax(job)
34
+ print(done.status, done.result.energy if done.result else done.error)
35
+ ```
36
+
37
+ ## License
38
+
39
+ Apache-2.0
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import import_module
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ if TYPE_CHECKING:
7
+ from mattergraph_sim.ase_runner import ase_relax
8
+ from mattergraph_sim.job_spec import AseJobSpec, SimulationJob, SimulationResult
9
+
10
+ _EXPORTS: dict[str, tuple[str, str, str | None]] = {
11
+ "AseJobSpec": (
12
+ "mattergraph_sim.job_spec",
13
+ "AseJobSpec",
14
+ None,
15
+ ),
16
+ "SimulationJob": (
17
+ "mattergraph_sim.job_spec",
18
+ "SimulationJob",
19
+ None,
20
+ ),
21
+ "SimulationResult": (
22
+ "mattergraph_sim.job_spec",
23
+ "SimulationResult",
24
+ None,
25
+ ),
26
+ "ase_relax": (
27
+ "mattergraph_sim.ase_runner",
28
+ "ase_relax",
29
+ (
30
+ "Install the optional `ase` dependency or run "
31
+ "`uv sync --all-packages --group dev` to use ase_relax."
32
+ ),
33
+ ),
34
+ }
35
+
36
+ __all__ = [
37
+ "AseJobSpec",
38
+ "SimulationJob",
39
+ "SimulationResult",
40
+ "ase_relax",
41
+ ]
42
+
43
+
44
+ def __getattr__(name: str) -> Any:
45
+ if name not in _EXPORTS:
46
+ msg = f"module {__name__!r} has no attribute {name!r}"
47
+ raise AttributeError(msg)
48
+ module_name, attr_name, hint = _EXPORTS[name]
49
+ try:
50
+ module = import_module(module_name)
51
+ except ImportError as e:
52
+ if hint is None:
53
+ raise
54
+ raise ImportError(hint) from e
55
+ value = getattr(module, attr_name)
56
+ globals()[name] = value
57
+ return value
58
+
59
+
60
+ def __dir__() -> list[str]:
61
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import io
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ from mattergraph.schema.structure import CrystalStructure
9
+
10
+ from mattergraph_sim.job_spec import AseJobSpec, SimulationJob, SimulationResult
11
+
12
+ EMT_SUPPORTED_SPECIES = (
13
+ "Ag",
14
+ "Al",
15
+ "Au",
16
+ "C",
17
+ "Cu",
18
+ "H",
19
+ "N",
20
+ "Ni",
21
+ "O",
22
+ "Pd",
23
+ "Pt",
24
+ )
25
+
26
+
27
+ def _load_ase() -> tuple[Any, Any, Any]:
28
+ try:
29
+ from ase.calculators.emt import EMT
30
+ from ase.optimize import BFGS
31
+ from pymatgen.io.ase import AseAtomsAdaptor
32
+ except ImportError as e:
33
+ msg = "Install the optional `ase` dependency to run local ASE relaxations."
34
+ raise ImportError(msg) from e
35
+ return EMT, BFGS, AseAtomsAdaptor
36
+
37
+
38
+ def _failure(job: SimulationJob, error: str) -> SimulationJob:
39
+ return job.model_copy(
40
+ update={
41
+ "status": "failed",
42
+ "error": error,
43
+ "log": error,
44
+ "result": None,
45
+ }
46
+ )
47
+
48
+
49
+ def _supported_species_for(calc_name: str) -> tuple[str, ...]:
50
+ if calc_name == "emt":
51
+ return EMT_SUPPORTED_SPECIES
52
+ return ()
53
+
54
+
55
+ def _unsupported_species(structure: Any, calc_name: str) -> list[str]:
56
+ supported = set(_supported_species_for(calc_name))
57
+ symbols = set(structure.composition.element_composition.as_dict())
58
+ return sorted(symbols - supported)
59
+
60
+
61
+ def ase_relax(job: SimulationJob) -> SimulationJob:
62
+ """
63
+ Local relaxation using ASE + EMT (MVP). Swap calculators for your own backend as needed.
64
+ """
65
+ EMT, BFGS, _ = _load_ase()
66
+ running = job.model_copy(update={"status": "running", "error": None})
67
+
68
+ try:
69
+ spec: AseJobSpec = running.spec
70
+ if running.kind != "relax":
71
+ return _failure(running, f"ASE runner only supports kind='relax'; got {running.kind!r}")
72
+
73
+ structure = CrystalStructure.model_validate(running.input_structure).to_pymatgen()
74
+ unsupported = _unsupported_species(structure, spec.calc_name)
75
+ if unsupported:
76
+ supported = ", ".join(_supported_species_for(spec.calc_name))
77
+ bad = ", ".join(unsupported)
78
+ return _failure(
79
+ running,
80
+ f"ASE {spec.calc_name} does not support species: {bad}. Supported species: {supported}.",
81
+ )
82
+
83
+ _, _, AseAtomsAdaptor = _load_ase()
84
+ atoms = AseAtomsAdaptor.get_atoms(structure) # type: ignore[assignment]
85
+ atoms.calc = EMT()
86
+
87
+ optimizer_log = io.StringIO()
88
+ with contextlib.redirect_stdout(optimizer_log), contextlib.redirect_stderr(optimizer_log):
89
+ opt = BFGS(atoms, logfile=optimizer_log)
90
+ converged = bool(opt.run(fmax=spec.fmax, steps=spec.max_steps))
91
+
92
+ forces = atoms.get_forces() if atoms.calc is not None else None
93
+ energy = float(atoms.get_potential_energy()) if atoms.calc is not None else None
94
+ max_force = None
95
+ if forces is not None and len(forces) > 0:
96
+ max_force = float(np.linalg.norm(forces, axis=1).max())
97
+
98
+ relaxed_structure = CrystalStructure.from_pymatgen(AseAtomsAdaptor.get_structure(atoms))
99
+ steps = int(getattr(opt, "nsteps", 0))
100
+ summary = (
101
+ f"calculator={spec.calc_name} energy={energy!s} max_force={max_force!s} "
102
+ f"steps={steps} converged={converged}"
103
+ )
104
+ raw_log = optimizer_log.getvalue().strip()
105
+ log = summary if not raw_log else f"{summary}\n{raw_log}"
106
+
107
+ return running.model_copy(
108
+ update={
109
+ "status": "completed",
110
+ "log": log,
111
+ "result": SimulationResult(
112
+ engine=spec.engine,
113
+ calculator=spec.calc_name,
114
+ converged=converged,
115
+ steps=steps,
116
+ energy=energy,
117
+ max_force=max_force,
118
+ relaxed_structure=relaxed_structure,
119
+ ),
120
+ }
121
+ )
122
+ except ImportError:
123
+ raise
124
+ except Exception as e: # noqa: BLE001
125
+ return _failure(running, f"{type(e).__name__}: {e}")
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from typing import Any, Literal
5
+
6
+ from mattergraph.schema.structure import CrystalStructure
7
+ from pydantic import BaseModel, Field, field_validator
8
+
9
+ _SUPPORTED_ASE_CALCULATORS = {"emt"}
10
+
11
+
12
+ class AseJobSpec(BaseModel):
13
+ engine: Literal["ase"] = "ase"
14
+ calc_name: str = "emt"
15
+ fmax: float = 0.05
16
+ max_steps: int = 200
17
+ n_cores: int = 1
18
+
19
+ @field_validator("calc_name")
20
+ @classmethod
21
+ def _normalize_calc_name(cls, value: str) -> str:
22
+ calc_name = value.strip().lower()
23
+ if calc_name not in _SUPPORTED_ASE_CALCULATORS:
24
+ supported = ", ".join(sorted(_SUPPORTED_ASE_CALCULATORS))
25
+ msg = f"unsupported ASE calculator {calc_name!r}; supported calculators: {supported}"
26
+ raise ValueError(msg)
27
+ return calc_name
28
+
29
+ @field_validator("fmax")
30
+ @classmethod
31
+ def _positive_fmax(cls, value: float) -> float:
32
+ if value <= 0:
33
+ msg = "fmax must be positive"
34
+ raise ValueError(msg)
35
+ return value
36
+
37
+ @field_validator("max_steps", "n_cores")
38
+ @classmethod
39
+ def _positive_ints(cls, value: int, info: Any) -> int:
40
+ if value < 1:
41
+ msg = f"{info.field_name} must be at least 1"
42
+ raise ValueError(msg)
43
+ return value
44
+
45
+
46
+ class SimulationResult(BaseModel):
47
+ engine: str
48
+ calculator: str
49
+ converged: bool | None = None
50
+ steps: int | None = None
51
+ energy: float | None = None
52
+ max_force: float | None = None
53
+ relaxed_structure: CrystalStructure | None = None
54
+
55
+
56
+ class SimulationJob(BaseModel):
57
+ job_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
58
+ spec: AseJobSpec
59
+ input_structure: dict[str, Any] = Field(
60
+ default_factory=dict,
61
+ description="Serialized :class:`CrystalStructure` or pymatgen dict",
62
+ )
63
+ kind: Literal["relax", "sc", "md"] = "relax"
64
+ status: Literal["pending", "running", "completed", "failed"] = "pending"
65
+ result_uri: str | None = None
66
+ result: SimulationResult | None = None
67
+ error: str | None = None
68
+ log: str | None = None
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from mattergraph_sim.job_spec import SimulationJob
4
+
5
+
6
+ def run_lammps(_job: SimulationJob) -> SimulationJob:
7
+ """Stub: LAMMPS integration is environment-specific. Implement where binaries exist."""
8
+ return _job.model_copy(
9
+ update={
10
+ "status": "failed",
11
+ "error": "LAMMPS runner not installed (stub).",
12
+ "log": "LAMMPS runner not installed (stub).",
13
+ "result": None,
14
+ }
15
+ )
@@ -0,0 +1,5 @@
1
+ """Validation helpers for externally produced simulation-result evidence."""
2
+
3
+ from mattergraph_sim.parsers.envelope import parse_result_envelope
4
+
5
+ __all__ = ["parse_result_envelope"]
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+ from mattergraph import SimulationResultEnvelope
8
+
9
+
10
+ def parse_result_envelope(
11
+ payload: str | bytes | Mapping[str, Any],
12
+ ) -> SimulationResultEnvelope:
13
+ """Validate an external JSON result envelope without launching an engine."""
14
+ if isinstance(payload, bytes):
15
+ payload = payload.decode("utf-8")
16
+ if isinstance(payload, str):
17
+ value = json.loads(payload)
18
+ else:
19
+ value = dict(payload)
20
+ if not isinstance(value, dict):
21
+ msg = "simulation result envelope must be a JSON object"
22
+ raise ValueError(msg)
23
+ return SimulationResultEnvelope.model_validate(value)
24
+
25
+
26
+ __all__ = ["parse_result_envelope"]
File without changes
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from mattergraph_sim.job_spec import SimulationJob
4
+
5
+
6
+ def run_quantum_espresso(_job: SimulationJob) -> SimulationJob:
7
+ """Stub: QE paths and pseudos are site-specific. Wire up in your environment."""
8
+ return _job.model_copy(
9
+ update={
10
+ "status": "failed",
11
+ "error": "QE runner not installed (stub).",
12
+ "log": "QE runner not installed (stub).",
13
+ "result": None,
14
+ }
15
+ )
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "mattergraph-sim"
3
+ version = "0.1.0"
4
+ description = "ASE/LAMMPS/QE simulation job specs and runners (MVP wrappers)."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "MatterGraph contributors" }]
9
+ keywords = ["materials-science", "simulation", "ase", "lammps", "quantum-espresso"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Science/Research",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Topic :: Scientific/Engineering :: Chemistry",
19
+ "Topic :: Scientific/Engineering :: Physics",
20
+ ]
21
+ dependencies = [
22
+ "mattergraph-core~=0.1.0",
23
+ "pymatgen>=2024.1.1",
24
+ "pydantic>=2.5",
25
+ "ase>=3.22",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/cyrusmo/MatterGraph"
30
+ Repository = "https://github.com/cyrusmo/MatterGraph"
31
+ Issues = "https://github.com/cyrusmo/MatterGraph/issues"
32
+ Changelog = "https://github.com/cyrusmo/MatterGraph/blob/main/CHANGELOG.md"
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.uv.sources]
39
+ mattergraph-core = { workspace = true }
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["mattergraph_sim"]
43
+ core-metadata-version = "2.4"
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ core-metadata-version = "2.4"
@@ -0,0 +1,64 @@
1
+ from typing import Literal
2
+
3
+ from mattergraph.schema.structure import CrystalStructure
4
+ from mattergraph_sim.ase_runner import ase_relax
5
+ from mattergraph_sim.job_spec import AseJobSpec, SimulationJob
6
+
7
+
8
+ def _job_for(
9
+ structure: CrystalStructure,
10
+ *,
11
+ kind: Literal["relax", "sc", "md"] = "relax",
12
+ ) -> SimulationJob:
13
+ return SimulationJob(
14
+ spec=AseJobSpec(),
15
+ input_structure=structure.to_json_dict(),
16
+ kind=kind,
17
+ )
18
+
19
+
20
+ def test_ase_relax_returns_structured_result_for_supported_species() -> None:
21
+ structure = CrystalStructure(
22
+ lattice=[[4.04, 0.0, 0.0], [0.0, 4.04, 0.0], [0.0, 0.0, 4.04]],
23
+ species=["Al"],
24
+ coords=[[0.0, 0.0, 0.0]],
25
+ )
26
+
27
+ out = ase_relax(_job_for(structure))
28
+
29
+ assert out.status == "completed"
30
+ assert out.error is None
31
+ assert out.result is not None
32
+ assert out.result.calculator == "emt"
33
+ assert out.result.energy is not None
34
+ assert out.result.max_force is not None
35
+ assert out.result.relaxed_structure is not None
36
+ assert "calculator=emt" in (out.log or "")
37
+
38
+
39
+ def test_ase_relax_fails_gracefully_for_unsupported_species() -> None:
40
+ structure = CrystalStructure(
41
+ lattice=[[2.8, 0.0, 0.0], [0.0, 2.8, 0.0], [0.0, 0.0, 2.8]],
42
+ species=["Fe"],
43
+ coords=[[0.0, 0.0, 0.0]],
44
+ )
45
+
46
+ out = ase_relax(_job_for(structure))
47
+
48
+ assert out.status == "failed"
49
+ assert out.result is None
50
+ assert out.error is not None
51
+ assert "does not support species: Fe" in out.error
52
+
53
+
54
+ def test_ase_relax_rejects_non_relax_jobs() -> None:
55
+ structure = CrystalStructure(
56
+ lattice=[[4.04, 0.0, 0.0], [0.0, 4.04, 0.0], [0.0, 0.0, 4.04]],
57
+ species=["Al"],
58
+ coords=[[0.0, 0.0, 0.0]],
59
+ )
60
+
61
+ out = ase_relax(_job_for(structure, kind="md"))
62
+
63
+ assert out.status == "failed"
64
+ assert out.error == "ASE runner only supports kind='relax'; got 'md'"
@@ -0,0 +1,23 @@
1
+ import pytest
2
+ from mattergraph_sim.job_spec import AseJobSpec, SimulationJob
3
+ from mattergraph_sim.lammps_runner import run_lammps
4
+
5
+
6
+ def test_simulation_job_defaults() -> None:
7
+ job = SimulationJob(spec=AseJobSpec(), input_structure={})
8
+ assert job.kind == "relax"
9
+ assert job.status == "pending"
10
+ assert job.result is None
11
+ assert job.error is None
12
+ failed = run_lammps(job)
13
+ assert failed.status == "failed"
14
+ assert failed.error == "LAMMPS runner not installed (stub)."
15
+
16
+
17
+ def test_job_spec_rejects_invalid_values() -> None:
18
+ with pytest.raises(ValueError, match="unsupported ASE calculator"):
19
+ AseJobSpec(calc_name="lj")
20
+ with pytest.raises(ValueError, match="fmax must be positive"):
21
+ AseJobSpec(fmax=0.0)
22
+ with pytest.raises(ValueError, match="max_steps must be at least 1"):
23
+ AseJobSpec(max_steps=0)
@@ -0,0 +1,31 @@
1
+ import json
2
+
3
+ import pytest
4
+ from mattergraph_sim.parsers import parse_result_envelope
5
+
6
+
7
+ def test_parse_external_result_envelope() -> None:
8
+ result = parse_result_envelope(
9
+ json.dumps(
10
+ {
11
+ "engine": "lammps",
12
+ "engine_version": "stable_29Aug2024",
13
+ "method": "external result import",
14
+ "parameters": {"units": "metal"},
15
+ "input_checksum_sha256": "a" * 64,
16
+ "output_checksum_sha256": "b" * 64,
17
+ "converged": True,
18
+ }
19
+ )
20
+ )
21
+ assert result.engine == "lammps"
22
+ assert result.converged is True
23
+
24
+
25
+ def test_parser_rejects_non_object_and_bad_checksum() -> None:
26
+ with pytest.raises(ValueError, match="JSON object"):
27
+ parse_result_envelope("[]")
28
+ with pytest.raises(ValueError, match="64-character"):
29
+ parse_result_envelope(
30
+ {"engine": "custom", "method": "import", "input_checksum_sha256": "bad"}
31
+ )
@@ -0,0 +1,22 @@
1
+ import importlib
2
+
3
+ import mattergraph_sim
4
+ import pytest
5
+
6
+
7
+ def test_ase_relax_has_helpful_optional_dependency_error(
8
+ monkeypatch: pytest.MonkeyPatch,
9
+ ) -> None:
10
+ module = importlib.reload(mattergraph_sim)
11
+ module.__dict__.pop("ase_relax", None)
12
+ real_import_module = module.import_module
13
+
14
+ def fake_import_module(name: str, package: str | None = None) -> object:
15
+ if name == "mattergraph_sim.ase_runner":
16
+ raise ImportError("No module named 'ase'")
17
+ return real_import_module(name, package)
18
+
19
+ monkeypatch.setattr(module, "import_module", fake_import_module)
20
+
21
+ with pytest.raises(ImportError, match="optional `ase`"):
22
+ _ = module.ase_relax