"""Compute the ASE energy-volume curve from the official NequIP example.""" from __future__ import annotations import argparse import json import os from pathlib import Path import numpy as np import torch from ase.build import bulk from onescience.utils.nequip.integrations.ase import NequIPCalculator 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 main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--compiled-model", default=default_compiled_model()) parser.add_argument("--device", default="cuda") parser.add_argument("--element", default="Si") parser.add_argument("--crystal-structure", default="diamond") parser.add_argument("--lattice-constant", type=float, default=5.43) parser.add_argument("--supercell", type=int, default=3) parser.add_argument("--scale-min", type=float, default=0.95) parser.add_argument("--scale-max", type=float, default=1.05) parser.add_argument("--num-points", type=int, default=10) parser.add_argument("--output", default="outputs/energy_volume.json") parser.add_argument("--plot", default="outputs/energy_volume.png") 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.num_points < 2: parser.error("--num-points must be at least 2") if args.supercell < 1: parser.error("--supercell must be positive") calculator = NequIPCalculator.from_compiled_model( compile_path=str(compiled_model), chemical_species_to_atom_type_map={args.element: args.element}, device=args.device, ) points = [] for scale in np.linspace(args.scale_min, args.scale_max, args.num_points): atoms = bulk( args.element, crystalstructure=args.crystal_structure, a=args.lattice_constant * float(scale), cubic=True, ) atoms *= (args.supercell,) * 3 atoms.calc = calculator energy = float(atoms.get_potential_energy()) forces = atoms.get_forces() points.append( { "scale": float(scale), "volume_angstrom3": float(atoms.get_volume()), "energy_ev": energy, "energy_ev_per_atom": energy / len(atoms), "max_force_ev_per_angstrom": float( np.linalg.norm(forces, axis=1).max() ), } ) energies = np.asarray([point["energy_ev"] for point in points]) volumes = np.asarray([point["volume_angstrom3"] for point in points]) minimum_index = int(np.argmin(energies)) 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", "element": args.element, "crystal_structure": args.crystal_structure, "base_lattice_constant_angstrom": args.lattice_constant, "supercell": [args.supercell] * 3, "num_atoms": len(atoms), "points": points, "sampled_minimum": points[minimum_index], } output_path = Path(args.output).expanduser().resolve() output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") if args.plot: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt plot_path = Path(args.plot).expanduser().resolve() plot_path.parent.mkdir(parents=True, exist_ok=True) plt.figure(figsize=(8, 6)) plt.plot(volumes, energies, marker="o", label="E-V Curve") plt.xlabel("Volume (Angstrom^3)", fontsize=14) plt.ylabel("Energy (eV)", fontsize=14) plt.title(f"Energy-Volume Curve for Cubic {args.element}", fontsize=16) plt.legend(fontsize=12) plt.grid() plt.tight_layout() plt.savefig(plot_path, dpi=160) plt.close() result["plot"] = str(plot_path) output_path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") print(f"points: {len(points)}") print(f"atoms per point: {result['num_atoms']}") print(f"sampled minimum: {result['sampled_minimum']}") print(f"result: {output_path}") if __name__ == "__main__": main()