NequIP / structure_relaxation.py
dadadaxi's picture
Upload folder using huggingface_hub
3e02ab8 verified
Raw
History Blame Contribute Delete
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()