Skip to content

Commit 8e6ac7d

Browse files
Forcefield roundtrip when specifying monty dict (#1461)
* fix roundtrip for monty-style dict calculator_meta * sillybilly * install check * switch to mace to avoid import resolution * more deser for legacy docs + regression test * consistency in monty keys * pin pyfhiaims pending resolution of parsing breaking changes
1 parent 44fd9b1 commit 8e6ac7d

5 files changed

Lines changed: 192 additions & 36 deletions

File tree

docs/user/codes/forcefields.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,28 @@ Support is provided for the following models, which can be selected using `atoma
4848

4949
## Using custom forcefields by dictionary
5050

51-
`force_field_name` also accepts a MSONable dictionary for specifying a custom ASE calculator class or function [^calculator-meta-type-annotation].
52-
For example, a `Job` created with the following code snippet instantiates `chgnet.model.dynamics.CHGNetCalculator` as the ASE calculator:
51+
`force_field_name` also accepts an import-like string, or MSONable dictionary to specify a custom ASE calculator class or function [^calculator-meta-type-annotation].
52+
For example, a `Job` created with the either of the following two code snippets instantiates a `chgnet.model.dynamics.CHGNetCalculator` as the ASE calculator.
5353
```python
54+
# simple import string
5455
job = ForceFieldStaticMaker(
55-
force_field_name={
56+
calculator_meta="chgnet.model.dynamics.CHGNetCalculator",
57+
).make(structure)
58+
```
59+
60+
or using `force_field_name` when
61+
62+
```python
63+
# monty MSONable style
64+
job = ForceFieldStaticMaker(
65+
calculator_meta={
5666
"@module": "chgnet.model.dynamics",
5767
"@callable": "CHGNetCalculator",
5868
}
5969
).make(structure)
6070
```
71+
Note that one can also specify `force_field_name = {"@module": ...,"@callable": ...}` in the second example for backwards compatibility.
72+
However, this may not be preserved in future versions, and `calculator_meta` is preferred.
6173

6274
[^calculator-meta-type-annotation]: In this context, the type annotation of the decoded dict should be either `Type[Calculator]` or `Callable[..., Calculator]`, where `Calculator` is from `ase.calculators.calculator`.
6375

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ abinit = [
4343
"abipy>=0.9.3",
4444
"netCDF4<1.7.5", # TODO: latest NetCDF missing 3.12 support: https://github.com/Unidata/netcdf4-python/issues/1461
4545
]
46-
aims = ["pymatgen-io-aims>=0.0.5", "pymatgen>=2025.10.7"]
46+
aims = ["pymatgen-io-aims>=0.0.5", "pymatgen>=2025.10.7","pyfhiaims<0.1.0"]
4747
amset = ["amset>=0.4.15", "pydash"]
4848
cclib = ["cclib>=1.8.1"]
4949
mp = ["mp-api>=0.37.5"]

src/atomate2/forcefields/schemas.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def from_ase_compatible_result(
9595
ase_calculator_name: str,
9696
result: AseResult,
9797
steps: int,
98-
calculator_meta: MLFF | dict | None = None,
98+
calculator_meta: str | MLFF | dict | None = None,
9999
relax_kwargs: dict = None,
100100
optimizer_kwargs: dict = None,
101101
fix_symmetry: bool = False,
@@ -123,7 +123,7 @@ def from_ase_compatible_result(
123123
Whether to fix the symmetry of the ions during relaxation.
124124
symprec : float
125125
Tolerance for symmetry finding in case of fix_symmetry.
126-
calculator_meta : Optional, MLFF or dict or None
126+
calculator_meta : Optional, str, MLFF, dict, or None
127127
Metadata about the calculator used.
128128
steps : int
129129
Maximum number of ionic steps allowed during relaxation.

src/atomate2/forcefields/utils.py

Lines changed: 89 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import inspect
56
import warnings
67
from contextlib import contextmanager
78
from dataclasses import dataclass, field
@@ -13,6 +14,7 @@
1314
from pathlib import Path
1415
from typing import TYPE_CHECKING
1516

17+
from ase.calculators.calculator import Calculator
1618
from ase.units import Bohr
1719
from monty.json import MontyDecoder
1820
from typing_extensions import assert_never, deprecated
@@ -26,8 +28,6 @@
2628
except ImportError:
2729
torch_dtype = str
2830

29-
from ase.calculators.calculator import Calculator
30-
3131
from atomate2.ase.schemas import AseResult
3232

3333
_FORCEFIELD_DATA_OBJECTS = ["trajectory", "ionic_steps"]
@@ -159,42 +159,82 @@ def _get_formatted_ff_name(force_field_name: str | MLFF) -> str:
159159
class ForceFieldMixin:
160160
"""Mix-in class for force-fields.
161161
162-
Attributes
163-
----------
164-
force_field_name : str or MLFF
165-
Name of the forcefield which will be
166-
correctly deserialized/standardized if the forcefield is
167-
a known `MLFF`.
168-
calculator_meta : MLFF or dict
169-
Actual metadata to instantiate the ASE calculator.
170-
calculator_kwargs : dict = field(default_factory=dict)
171-
Keyword arguments that will get passed to the ASE calculator.
172-
task_document_kwargs: dict = field(default_factory=dict)
173-
Additional keyword args passed to :obj:`.ForceFieldTaskDocument()
174-
or another final document schema.
162+
All basic forcefield jobs should inherit from this class
163+
to easily access `ase_calculator`.
175164
"""
176165

177166
force_field_name: str | MLFF | dict = MLFF.Forcefield
178-
calculator_meta: MLFF | dict = field(init=False)
179-
calculator_kwargs: dict = field(default_factory=dict)
180-
task_document_kwargs: dict = field(default_factory=dict)
167+
calculator_meta: str | MLFF | dict | None = None
168+
calculator_kwargs: dict[str, Any] = field(default_factory=dict)
169+
task_document_kwargs: dict[str, Any] = field(default_factory=dict)
181170

182171
def __post_init__(self) -> None:
183-
"""Ensure that force_field_name is correctly assigned."""
172+
"""Validate input data types.
173+
174+
Attributes
175+
----------
176+
force_field_name : str, MLFF, or dict
177+
If a str or MLFF: Name of the forcefield which will be
178+
correctly deserialized/standardized if the forcefield is
179+
a known `MLFF`.
180+
If a dict, a monty-style dict.
181+
182+
calculator_meta : MLFF, str, or dict
183+
Actual metadata to instantiate the ASE calculator.
184+
If a MLFF, that default interface in `ase_calculator` will be used.
185+
If an import-style str or monty-style dict, the calculator will
186+
be dynamically loaded.
187+
188+
calculator_kwargs : dict = {}
189+
Keyword arguments that will get passed to the ASE calculator.
190+
191+
task_document_kwargs: dict = {}
192+
Additional keyword args passed to :obj:`.ForceFieldTaskDocument()
193+
or another final document schema.
194+
"""
184195
if hasattr(super(), "__post_init__"):
185196
super().__post_init__() # type: ignore[misc]
186197

198+
mlff: MLFF = MLFF.Forcefield # Fallback to placeholder
187199
if isinstance(self.force_field_name, dict):
188-
mlff = MLFF.Forcefield # Fallback to placeholder
189-
self.calculator_meta = self.force_field_name.copy()
200+
calculator_meta: str | dict[str, Any] | MLFF = self.force_field_name.copy()
201+
202+
elif (
203+
(
204+
inspect.isclass(self.force_field_name)
205+
and issubclass(self.force_field_name, Calculator)
206+
)
207+
or isinstance(self.force_field_name, Calculator)
208+
or inspect.isfunction(self.force_field_name) # for mace_mp specifically
209+
):
210+
# can happen with deserialization of legacy documents from JSON
211+
calculator_meta = ".".join(
212+
getattr(self.force_field_name, k) for k in ("__module__", "__name__")
213+
)
214+
190215
else:
191216
mlff = _get_standardized_mlff(self.force_field_name)
192-
self.calculator_meta = mlff
217+
# On round-trip deserialization, `calculator_meta` will be a dict
218+
# of the calculator information
219+
calculator_meta = self.calculator_meta or mlff
220+
221+
# avoids unintentional deserialization from monty on round-trip
222+
if isinstance(calculator_meta, dict):
223+
# Should always be @callable but being safe here to be sure
224+
cls_key = next(k for k in ("@callable", "@class") if k in calculator_meta)
225+
self.calculator_meta: str | MLFF = ".".join(
226+
calculator_meta[k] for k in ("@module", cls_key)
227+
)
228+
else:
229+
try:
230+
self.calculator_meta = _get_standardized_mlff(calculator_meta)
231+
except ValueError:
232+
self.calculator_meta = calculator_meta
193233

194234
self.force_field_name: str = str(mlff) # Narrow-down type for mypy
195235

196236
# Pad calculator_kwargs with default values, but permit user to override them
197-
self.calculator_kwargs = {
237+
self.calculator_kwargs: dict[str, Any] = {
198238
**_DEFAULT_CALCULATOR_KWARGS.get(mlff, {}),
199239
**self.calculator_kwargs,
200240
}
@@ -228,7 +268,7 @@ def ase_calculator_name(self) -> str:
228268
"""The name of the ASE calculator for schemas."""
229269
if isinstance(self.calculator_meta, MLFF):
230270
return str(self.force_field_name)
231-
if isinstance(self.calculator_meta, dict):
271+
if isinstance(self.calculator_meta, str | dict):
232272
calc_cls = _load_calc_cls(self.calculator_meta)
233273
return calc_cls.__name__
234274
assert_never(self.calculator_meta)
@@ -402,7 +442,9 @@ def ase_calculator(
402442
**{k: v for k, v in kwargs.items() if k != "predict_unit"},
403443
)
404444

405-
elif isinstance(calculator_meta, dict):
445+
elif isinstance(calculator_meta, dict) or (
446+
isinstance(calculator_meta, str) and calculator_meta.count(".") >= 1
447+
):
406448
calc_cls = _load_calc_cls(calculator_meta)
407449
calculator = calc_cls(**kwargs)
408450

@@ -413,8 +455,25 @@ def ase_calculator(
413455

414456

415457
def _load_calc_cls(
416-
calculator_meta: dict,
458+
calculator_meta: str | dict,
417459
) -> type[Calculator] | Callable[..., Calculator]:
460+
"""Load an ASE calculator using monty or importlib.
461+
462+
Parameters
463+
----------
464+
calculator_meta : str or dict
465+
If a str, should be a dot-separated import string:
466+
"chgnet.model.dynamics.CHGNetCalculator"
467+
If a dict, should be a monty-style JSONable dict:
468+
{"@module": "chgnet.model.dynamics", "@callable": "CHGNetCalculator"}
469+
470+
Returns
471+
-------
472+
ase Calculator
473+
"""
474+
if isinstance(calculator_meta, str):
475+
module, klass = calculator_meta.rsplit(".", 1)
476+
return getattr(import_module(module), klass)
418477
return MontyDecoder().process_decoded(calculator_meta)
419478

420479

@@ -434,12 +493,12 @@ def revert_default_dtype() -> Generator[None]:
434493
torch.set_default_dtype(orig)
435494

436495

437-
def _get_pkg_name(calculator_meta: MLFF | dict[str, Any]) -> str | None:
496+
def _get_pkg_name(calculator_meta: MLFF | str | dict[str, Any]) -> str | None:
438497
"""Get the package name for a given force field.
439498
440499
Parameters
441500
----------
442-
calculator_meta : MLFF or JSONable dict
501+
calculator_meta : MLFF, import-style str, or JSONable dict
443502
The calculator metadata used to load the calculator,
444503
or an MLFF enum.
445504
@@ -480,13 +539,13 @@ def _get_pkg_name(calculator_meta: MLFF | dict[str, Any]) -> str | None:
480539
case _:
481540
ff_pkg = None
482541
return ff_pkg
483-
if isinstance(calculator_meta, dict):
542+
if isinstance(calculator_meta, str | dict):
484543
calc_cls = _load_calc_cls(calculator_meta)
485544
return calc_cls.__module__.split(".", 1)[0]
486545
assert_never(calculator_meta)
487546

488547

489-
def _get_pkg_version(calculator_meta: MLFF | dict[str, Any]) -> str | None:
548+
def _get_pkg_version(calculator_meta: str | dict[str, Any] | MLFF) -> str | None:
490549
"""Try to establish the imported version of a forcefield python package."""
491550
if isinstance(pkg_name := _get_pkg_name(calculator_meta), str):
492551
try:

tests/forcefields/test_jobs.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,3 +893,88 @@ def test_ext_load_static_maker(si_structure: Structure):
893893

894894
assert output1.forcefield_name == "mace_mp"
895895
assert output1.forcefield_version == get_imported_version("mace_torch")
896+
897+
898+
@pytest.mark.skipif(not mlff_is_installed("MACE"), reason="MACE is not installed")
899+
@pytest.mark.parametrize("as_str", [True, False])
900+
def test_roundtrip(si_structure: Structure, as_str: bool):
901+
902+
import json
903+
904+
from ase.calculators.calculator import Calculator
905+
from mace.calculators import MACECalculator
906+
from monty.json import MontyDecoder, MontyEncoder
907+
908+
import_str = "mace.calculators.mace_mp"
909+
module, klass = import_str.rsplit(".", 1)
910+
911+
# If using an import string, one must specify this through `calculator_meta`
912+
# If using a monty-style dict, one can use either `calculator_meta` (preferred)
913+
# or `force_field_name` (for backwards compatibility)
914+
valid_kwargs = ["calculator_meta"] + ([] if as_str else ["force_field_name"])
915+
916+
for calc_kwarg in valid_kwargs:
917+
job = ForceFieldRelaxMaker(
918+
**{
919+
calc_kwarg: (
920+
import_str if as_str else {"@module": module, "@callable": klass}
921+
)
922+
},
923+
calculator_kwargs={"model": "medium"},
924+
).make(si_structure)
925+
926+
roundtrip_job = MontyDecoder().decode(json.dumps(job, cls=MontyEncoder))
927+
928+
for j in (job, roundtrip_job):
929+
assert j.maker.calculator_meta == import_str
930+
assert j.maker.force_field_name == str(MLFF.Forcefield)
931+
assert j.maker.mlff == MLFF.Forcefield
932+
assert isinstance(j.maker.calculator, MACECalculator)
933+
assert isinstance(j.maker.calculator, Calculator)
934+
935+
936+
@pytest.mark.skipif(not mlff_is_installed("MACE"), reason="MACE is not installed")
937+
@pytest.mark.parametrize(
938+
"import_str",
939+
[
940+
"mace.calculators.foundations_models.mace_mp",
941+
"mace.calculators.mace.MACECalculator",
942+
],
943+
)
944+
def test_roundtrip_legacy(si_structure: Structure, import_str: str):
945+
# Test backwards compatibility. Legacy docs can contain dict for
946+
# `force_field_name` which will be deserialized by monty into an ase
947+
# `Calculator`. `ForceFieldMixin` needs to handle this and
948+
# narrow types correctly
949+
950+
import json
951+
952+
from ase.calculators.calculator import Calculator
953+
from mace.calculators import MACECalculator
954+
from mace.calculators.foundations_models import download_mace_mp_checkpoint
955+
from monty.json import MontyDecoder, MontyEncoder
956+
957+
module, klass = import_str.rsplit(".", 1)
958+
959+
job = ForceFieldRelaxMaker(
960+
force_field_name={"@module": module, "@callable": klass},
961+
calculator_kwargs=(
962+
{"model": "medium"}
963+
if klass == "mace_mp"
964+
else {"model_paths": download_mace_mp_checkpoint("medium")}
965+
),
966+
).make(si_structure)
967+
968+
job_dct = json.loads(MontyEncoder().encode(job))
969+
job_dct["function"]["@bound"]["force_field_name"] = {
970+
"@module": module,
971+
"@callable": klass,
972+
}
973+
job_dct["function"]["@bound"].pop("calculator_meta")
974+
975+
deser = MontyDecoder().process_decoded(job_dct)
976+
assert deser.maker.calculator_meta == import_str
977+
assert deser.maker.force_field_name == str(MLFF.Forcefield)
978+
assert deser.maker.mlff == MLFF.Forcefield
979+
assert isinstance(deser.maker.calculator, MACECalculator)
980+
assert isinstance(deser.maker.calculator, Calculator)

0 commit comments

Comments
 (0)