22
33from __future__ import annotations
44
5+ import inspect
56import warnings
67from contextlib import contextmanager
78from dataclasses import dataclass , field
1314from pathlib import Path
1415from typing import TYPE_CHECKING
1516
17+ from ase .calculators .calculator import Calculator
1618from ase .units import Bohr
1719from monty .json import MontyDecoder
1820from typing_extensions import assert_never , deprecated
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:
159159class 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
415457def _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 :
0 commit comments