Download structure_relaxation.py from OneScience-Group/NequIP: direct link, hf CLI and curl.
- Browser
- Download file 9.47 kB
-
https://huggingface.co/OneScience-Group/NequIP/resolve/main/structure_relaxation.py
- Command line
-
hf download hf://OneScience-Group/NequIP/structure_relaxation.py
-
curl -L -o structure_relaxation.py https://huggingface.co/OneScience-Group/NequIP/resolve/main/structure_relaxation.py
9.47 kB
| """Relax an ASE structure with a compiled NequIP model. | |
| This follows the official NequIP ASE relaxation example: it supports atomic | |
| and cell relaxation, tracks forces at every ionic step, and aborts exploding | |
| relaxations before they can hang indefinitely. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| import torch | |
| from ase import Atoms | |
| from ase.build import bulk | |
| from ase.filters import ExpCellFilter, FrechetCellFilter | |
| from ase.io import read, write | |
| import ase.optimize as opt | |
| from onescience.utils.nequip.integrations.ase import NequIPCalculator | |
| OPTIMIZERS = { | |
| "BFGS": opt.BFGS, | |
| "BFGSLineSearch": opt.BFGSLineSearch, | |
| "FIRE": opt.FIRE, | |
| "FIRE2": opt.FIRE2, | |
| "GOQN": opt.GoodOldQuasiNewton, | |
| "GPMin": opt.GPMin, | |
| "LBFGS": opt.LBFGS, | |
| "LBFGSLineSearch": opt.LBFGSLineSearch, | |
| } | |
| CELL_FILTERS = { | |
| "exp": ExpCellFilter, | |
| "frechet": FrechetCellFilter, | |
| } | |
| def default_compiled_model() -> str | None: | |
| models_dir = os.environ.get("ONESCIENCE_MODELS_DIR") | |
| if not models_dir: | |
| return None | |
| return str(Path(models_dir) / "NequIP" / "NequIP-OAM-L-0.1.nequip.pth") | |
| def load_structure( | |
| input_path: str | None, | |
| index: int, | |
| element: str, | |
| crystal_structure: str, | |
| lattice_constant: float, | |
| displacement: float, | |
| ) -> tuple[Atoms, str]: | |
| if input_path: | |
| path = Path(input_path).expanduser().resolve() | |
| if not path.is_file(): | |
| raise FileNotFoundError(f"input structure not found: {path}") | |
| return read(path, index=index), f"{path}[{index}]" | |
| atoms = bulk( | |
| element, | |
| crystalstructure=crystal_structure, | |
| a=lattice_constant, | |
| cubic=True, | |
| ) | |
| if displacement: | |
| atoms.positions[0, 0] += displacement | |
| return atoms, ( | |
| f"ASE bulk {element} {crystal_structure}, a={lattice_constant} Angstrom, " | |
| f"atom-0 displacement={displacement} Angstrom" | |
| ) | |
| def _max_vector_norm(values: np.ndarray) -> float: | |
| array = np.asarray(values) | |
| if array.size == 0: | |
| return 0.0 | |
| return float(np.linalg.norm(array.reshape(-1, 3), axis=1).max()) | |
| def relaxation_snapshot(atoms: Atoms, target: Any, step: int) -> dict[str, Any]: | |
| forces = atoms.get_forces() | |
| optimizer_forces = target.get_forces() | |
| stress = atoms.get_stress() | |
| return { | |
| "step": step, | |
| "energy_ev": float(atoms.get_potential_energy()), | |
| "energy_ev_per_atom": float(atoms.get_potential_energy() / len(atoms)), | |
| "volume_angstrom3": float(atoms.get_volume()), | |
| "max_atomic_force_ev_per_angstrom": _max_vector_norm(forces), | |
| "max_optimizer_force": _max_vector_norm(optimizer_forces), | |
| "stress_ev_per_angstrom3_voigt": np.asarray(stress).tolist(), | |
| "max_abs_stress_ev_per_angstrom3": float(np.abs(stress).max()), | |
| } | |
| def relax_structure( | |
| atoms: Atoms, | |
| *, | |
| optimizer_name: str, | |
| cell_filter_name: str, | |
| fixed_cell: bool, | |
| fmax: float, | |
| steps: int, | |
| force_limit: float, | |
| logfile: Path, | |
| trajectory: Path, | |
| ) -> tuple[bool, int, list[dict[str, Any]]]: | |
| if not fixed_cell and not atoms.pbc.all(): | |
| raise ValueError("cell relaxation requires periodic boundaries; use --fixed-cell") | |
| target = atoms if fixed_cell else CELL_FILTERS[cell_filter_name](atoms) | |
| optimizer_cls = OPTIMIZERS[optimizer_name] | |
| history: list[dict[str, Any]] = [] | |
| converged = False | |
| with optimizer_cls( | |
| target, | |
| logfile=str(logfile), | |
| trajectory=str(trajectory), | |
| ) as optimizer: | |
| for converged in optimizer.irun(fmax=fmax, steps=steps): | |
| snapshot = relaxation_snapshot(atoms, target, optimizer.nsteps) | |
| history.append(snapshot) | |
| if max( | |
| snapshot["max_atomic_force_ev_per_angstrom"], | |
| snapshot["max_optimizer_force"], | |
| ) > force_limit: | |
| raise RuntimeError( | |
| f"relaxation force exceeded safety limit {force_limit:g}" | |
| ) | |
| return bool(converged), int(optimizer.nsteps), history | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--compiled-model", default=default_compiled_model()) | |
| parser.add_argument( | |
| "--input", | |
| help="CIF, POSCAR, XYZ, trajectory, or another ASE-readable structure", | |
| ) | |
| parser.add_argument("--index", type=int, default=0) | |
| parser.add_argument("--device", default="cuda") | |
| parser.add_argument("--optimizer", choices=sorted(OPTIMIZERS), default="GOQN") | |
| parser.add_argument( | |
| "--cell-filter", choices=sorted(CELL_FILTERS), default="frechet" | |
| ) | |
| parser.add_argument( | |
| "--fixed-cell", | |
| action="store_true", | |
| help="relax atomic positions only; the default also relaxes the cell", | |
| ) | |
| parser.add_argument("--fmax", type=float, default=0.05) | |
| parser.add_argument("--steps", type=int, default=500) | |
| parser.add_argument("--force-limit", type=float, default=1.0e6) | |
| parser.add_argument("--element", default="Si") | |
| parser.add_argument("--crystal-structure", default="diamond") | |
| parser.add_argument("--lattice-constant", type=float, default=5.65) | |
| parser.add_argument("--displacement", type=float, default=0.08) | |
| parser.add_argument("--output-dir", default="outputs/structure_relaxation") | |
| parser.add_argument("--output-structure", default="relaxed.cif") | |
| parser.add_argument("--result", default="result.json") | |
| parser.add_argument("--trajectory", default="relax.traj") | |
| parser.add_argument("--log", default="relax.log") | |
| args = parser.parse_args() | |
| if not args.compiled_model: | |
| parser.error("--compiled-model is required when ONESCIENCE_MODELS_DIR is unset") | |
| compiled_model = Path(args.compiled_model).expanduser().resolve() | |
| if not compiled_model.is_file(): | |
| parser.error(f"compiled model not found: {compiled_model}") | |
| if args.fmax <= 0: | |
| parser.error("--fmax must be positive") | |
| if args.steps < 1: | |
| parser.error("--steps must be positive") | |
| if args.force_limit <= 0: | |
| parser.error("--force-limit must be positive") | |
| output_dir = Path(args.output_dir).expanduser().resolve() | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| output_structure = output_dir / args.output_structure | |
| result_path = output_dir / args.result | |
| trajectory_path = output_dir / args.trajectory | |
| log_path = output_dir / args.log | |
| try: | |
| atoms, input_source = load_structure( | |
| args.input, | |
| args.index, | |
| args.element, | |
| args.crystal_structure, | |
| args.lattice_constant, | |
| args.displacement, | |
| ) | |
| except (FileNotFoundError, IndexError, ValueError) as error: | |
| parser.error(str(error)) | |
| if len(atoms) == 0: | |
| parser.error("input structure has no atoms") | |
| species = sorted(set(atoms.get_chemical_symbols())) | |
| atoms.calc = NequIPCalculator.from_compiled_model( | |
| compile_path=str(compiled_model), | |
| chemical_species_to_atom_type_map={symbol: symbol for symbol in species}, | |
| device=args.device, | |
| ) | |
| try: | |
| converged, nsteps, history = relax_structure( | |
| atoms, | |
| optimizer_name=args.optimizer, | |
| cell_filter_name=args.cell_filter, | |
| fixed_cell=args.fixed_cell, | |
| fmax=args.fmax, | |
| steps=args.steps, | |
| force_limit=args.force_limit, | |
| logfile=log_path, | |
| trajectory=trajectory_path, | |
| ) | |
| except ValueError as error: | |
| parser.error(str(error)) | |
| write(output_structure, atoms) | |
| result = { | |
| "compiled_model": str(compiled_model), | |
| "device": args.device, | |
| "device_name": torch.cuda.get_device_name(0) | |
| if args.device.startswith("cuda") and torch.cuda.is_available() | |
| else "cpu", | |
| "input_source": input_source, | |
| "formula": atoms.get_chemical_formula(), | |
| "num_atoms": len(atoms), | |
| "chemical_species_to_atom_type_map": { | |
| symbol: symbol for symbol in species | |
| }, | |
| "optimizer": args.optimizer, | |
| "cell_filter": None if args.fixed_cell else args.cell_filter, | |
| "fixed_cell": args.fixed_cell, | |
| "fmax_ev_per_angstrom": args.fmax, | |
| "max_steps": args.steps, | |
| "force_safety_limit": args.force_limit, | |
| "converged": converged, | |
| "steps": nsteps, | |
| "initial": history[0], | |
| "final": history[-1], | |
| "energy_change_ev": history[-1]["energy_ev"] - history[0]["energy_ev"], | |
| "volume_change_angstrom3": ( | |
| history[-1]["volume_angstrom3"] - history[0]["volume_angstrom3"] | |
| ), | |
| "history": history, | |
| "relaxed_structure": str(output_structure), | |
| "trajectory": str(trajectory_path), | |
| "log": str(log_path), | |
| } | |
| result_path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") | |
| print("formula:", result["formula"]) | |
| print("atoms:", result["num_atoms"]) | |
| print("converged:", converged) | |
| print("steps:", nsteps) | |
| print("initial energy (eV):", result["initial"]["energy_ev"]) | |
| print("final energy (eV):", result["final"]["energy_ev"]) | |
| print( | |
| "final max force (eV/Angstrom):", | |
| result["final"]["max_atomic_force_ev_per_angstrom"], | |
| ) | |
| print("result:", result_path) | |
| if __name__ == "__main__": | |
| main() | |