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.
- mattergraph_sim-0.1.0/.gitignore +48 -0
- mattergraph_sim-0.1.0/PKG-INFO +66 -0
- mattergraph_sim-0.1.0/README.md +39 -0
- mattergraph_sim-0.1.0/mattergraph_sim/__init__.py +61 -0
- mattergraph_sim-0.1.0/mattergraph_sim/ase_runner.py +125 -0
- mattergraph_sim-0.1.0/mattergraph_sim/job_spec.py +68 -0
- mattergraph_sim-0.1.0/mattergraph_sim/lammps_runner.py +15 -0
- mattergraph_sim-0.1.0/mattergraph_sim/parsers/__init__.py +5 -0
- mattergraph_sim-0.1.0/mattergraph_sim/parsers/envelope.py +26 -0
- mattergraph_sim-0.1.0/mattergraph_sim/py.typed +0 -0
- mattergraph_sim-0.1.0/mattergraph_sim/qe_runner.py +15 -0
- mattergraph_sim-0.1.0/pyproject.toml +46 -0
- mattergraph_sim-0.1.0/tests/test_ase_runner.py +64 -0
- mattergraph_sim-0.1.0/tests/test_job_spec.py +23 -0
- mattergraph_sim-0.1.0/tests/test_result_parser.py +31 -0
- mattergraph_sim-0.1.0/tests/test_sim_optional_imports.py +22 -0
|
@@ -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,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
|