| """Run one NequIP energy, force, and stress prediction through ASE. |
| |
| This script follows the official NequIP ASE integration style: |
| https://nequip.readthedocs.io/en/latest/integrations/ase.html |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import warnings |
| from pathlib import Path |
| from typing import Any, Dict |
|
|
| warnings.filterwarnings("ignore", category=FutureWarning, module="e3nn") |
|
|
| from ase.build import bulk |
| from ase.io import read |
|
|
| from onescience.models.nequip.model import ModelTypeNamesFromPackage |
| from onescience.models.nequip.model.nequip_models import NequIPGNNModel |
| from onescience.utils.nequip.internal.global_state import set_global_state |
| from onescience.utils.nequip import build_nequip_calculator |
|
|
|
|
| def default_paths() -> Dict[str, str | None]: |
| """Return default compiled model / checkpoint paths if env var is set.""" |
| models_dir = os.environ.get("ONESCIENCE_MODELS_DIR") |
| if not models_dir: |
| return {"compiled_model": None, "checkpoint": None} |
| nequip_dir = Path(models_dir) / "NequIP" |
| return { |
| "compiled_model": str(nequip_dir / "NequIP-OAM-L-0.1.nequip.pth"), |
| "checkpoint": None, |
| } |
|
|
|
|
| def resolve_model_paths( |
| compiled_model: str | None, checkpoint: str | None |
| ) -> Dict[str, str | None]: |
| """Prefer an explicitly selected model source over environment defaults.""" |
| if compiled_model or checkpoint: |
| return {"compiled_model": compiled_model, "checkpoint": checkpoint} |
| return default_paths() |
|
|
|
|
| def load_structure(path: str | None, index: int): |
| """Load an ASE structure or use the built-in Cu bulk example.""" |
| if path: |
| return read(path, index=index) |
| return bulk("Cu") |
|
|
|
|
| def write_workflow_result(result: Dict[str, Any], output_path: str) -> str: |
| """Write a workflow result dictionary to a JSON file.""" |
| output = Path(output_path) |
| output.parent.mkdir(parents=True, exist_ok=True) |
| with open(output, "w", encoding="utf-8") as f: |
| json.dump(result, f, indent=2, ensure_ascii=False) |
| return str(output) |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser(description=__doc__) |
| group = parser.add_mutually_exclusive_group() |
| group.add_argument( |
| "--compiled-model", |
| help="Path to a compiled NequIP model (.nequip.pth or .nequip.pt2).", |
| ) |
| group.add_argument( |
| "--checkpoint", |
| help="Path to a NequIP checkpoint (.ckpt) or packaged model (.nequip.zip).", |
| ) |
| group.add_argument( |
| "--demo", |
| action="store_true", |
| help="Use a small built-in demo model instead of a real checkpoint.", |
| ) |
| parser.add_argument( |
| "--package", |
| help=( |
| "Original .nequip.zip package for a fine-tuned checkpoint; its atom " |
| "types are read automatically." |
| ), |
| ) |
| parser.add_argument( |
| "--input", |
| help=( |
| "CIF, POSCAR, XYZ, trajectory, or another ASE-readable structure; " |
| "defaults to the built-in periodic Cu example" |
| ), |
| ) |
| parser.add_argument( |
| "--index", |
| type=int, |
| default=0, |
| help="Zero-based frame index for trajectory inputs (default: 0).", |
| ) |
| parser.add_argument("--device", default="cuda") |
| parser.add_argument("--output", default="outputs/single_point.json") |
| parser.add_argument( |
| "--model-type-names", |
| nargs="+", |
| default=["C", "H", "O", "Cu"], |
| help="Chemical species the model knows about (used for demo/checkpoint).", |
| ) |
| parser.add_argument( |
| "--r-max", |
| type=float, |
| default=4.0, |
| help="Neighbor-list cutoff in Angstrom (used for demo/checkpoint models).", |
| ) |
| args = parser.parse_args() |
|
|
| for label, path in ( |
| ("compiled model", args.compiled_model), |
| ("checkpoint", args.checkpoint), |
| ("package", args.package), |
| ): |
| if path and not Path(path).expanduser().is_file(): |
| parser.error(f"{label} not found: {path}") |
|
|
| model_paths = resolve_model_paths(args.compiled_model, args.checkpoint) |
| compiled_model = model_paths["compiled_model"] |
| checkpoint = model_paths["checkpoint"] |
| if args.package and not checkpoint: |
| parser.error("--package requires --checkpoint") |
|
|
| model_type_names = list(args.model_type_names) |
| package_for_types = args.package |
| if package_for_types is None and checkpoint and checkpoint.endswith(".nequip.zip"): |
| package_for_types = checkpoint |
| if package_for_types: |
| model_type_names = list(ModelTypeNamesFromPackage(package_for_types)) |
|
|
| atoms = load_structure(args.input, args.index) |
| calc_kwargs: Dict[str, Any] = {"device": args.device} |
|
|
| if args.demo: |
| set_global_state() |
| calc_kwargs["model"] = NequIPGNNModel( |
| seed=123, |
| model_dtype="float32", |
| type_names=model_type_names, |
| num_layers=2, |
| l_max=1, |
| num_features=32, |
| r_max=args.r_max, |
| parity=False, |
| avg_num_neighbors=10.0, |
| ) |
| elif compiled_model and Path(compiled_model).exists(): |
| calc_kwargs["compiled_model"] = compiled_model |
| elif checkpoint and Path(checkpoint).exists(): |
| calc_kwargs["checkpoint"] = checkpoint |
| calc_kwargs["model_type_names"] = model_type_names |
| else: |
| parser.error( |
| "no model found; pass --compiled-model, --checkpoint, or --demo" |
| ) |
|
|
| atoms.calc = build_nequip_calculator(**calc_kwargs) |
|
|
| result = { |
| "formula": atoms.get_chemical_formula(), |
| "natoms": len(atoms), |
| "input": str(Path(args.input).expanduser()) if args.input else None, |
| "input_index": args.index if args.input else None, |
| "input_source": args.input or "ASE bulk Cu default", |
| "compiled_model": str(Path(compiled_model).expanduser()) if compiled_model else None, |
| "checkpoint": str(Path(checkpoint).expanduser()) if checkpoint else None, |
| "package": str(Path(package_for_types).expanduser()) if package_for_types else None, |
| "pbc": atoms.pbc.tolist(), |
| "cell_angstrom": atoms.cell.array.tolist(), |
| "energy_ev": float(atoms.get_potential_energy()), |
| "forces_ev_per_angstrom": atoms.get_forces().tolist(), |
| "stress_ev_per_angstrom_cubed_voigt": atoms.get_stress().tolist(), |
| } |
| output = write_workflow_result(result, args.output) |
|
|
| print("formula:", result["formula"]) |
| print("atoms:", result["natoms"]) |
| print("energy (eV):", result["energy_ev"]) |
| print("result:", output) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|