chemgraph-loop / src /chemgraph /tools /ase_core.py
rockyaaos's picture
ChemGraph Loop: guarded real-agent API (EMT/TBLite single-point energy)
c509967 verified
Raw
History Blame Contribute Delete
29.4 kB
"""Core simulation functions — the single source of truth.
Every callable here is a plain Python function (no LangChain ``@tool``,
no MCP ``@mcp.tool``, no Parsl ``@python_app``). Framework-specific
wrappers in ``ase_tools.py``, ``mcp_tools.py``, and ``parsl_tools.py``
simply delegate to these functions.
"""
from __future__ import annotations
import glob
import hashlib
import json
import os
import shutil
import tempfile
import time
from pathlib import Path
from typing import List, Optional
import numpy as np
from chemgraph.schemas.atomsdata import AtomsData
from chemgraph.schemas.ase_input import ASEInputSchema, ASEOutputSchema
# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------
def _resolve_path(path: str) -> str:
"""Resolve a path relative to ``CHEMGRAPH_LOG_DIR`` when appropriate.
Parameters
----------
path : str
Absolute or relative file path.
Returns
-------
str
Resolved path.
"""
log_dir = os.environ.get("CHEMGRAPH_LOG_DIR")
if log_dir and not os.path.isabs(path):
os.makedirs(log_dir, exist_ok=True)
return os.path.join(log_dir, path)
return path
def _ase_cache_enabled() -> bool:
"""Return whether run_ase result caching is enabled."""
value = os.environ.get("CHEMGRAPH_ASE_CACHE", "1").strip().lower()
return value not in {"0", "false", "no", "off"}
def _ase_cache_dir() -> str:
"""Return the cache directory for ASE result payloads."""
configured = os.environ.get("CHEMGRAPH_ASE_CACHE_DIR")
if configured:
return configured
log_dir = os.environ.get("CHEMGRAPH_LOG_DIR") or os.getcwd()
log_path = Path(log_dir)
if log_path.name.startswith("query_"):
log_path = log_path.parent
return os.path.join(str(log_path), ".ase_cache")
def _file_sha256(path: str) -> str:
"""Return a SHA256 digest for a file."""
digest = hashlib.sha256()
with open(path, "rb") as rf:
for chunk in iter(lambda: rf.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _ase_cache_key(params: ASEInputSchema, input_structure_file: str) -> str:
"""Build a content-based cache key for an ASE calculation."""
try:
payload = params.model_dump(mode="json")
except Exception:
payload = params.model_dump()
payload.pop("input_structure_file", None)
payload.pop("output_results_file", None)
payload["input_structure_sha256"] = _file_sha256(input_structure_file)
encoded = json.dumps(payload, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _ase_cache_paths(cache_key: str) -> tuple[str, str]:
cache_dir = _ase_cache_dir()
return (
os.path.join(cache_dir, f"{cache_key}.return.json"),
os.path.join(cache_dir, f"{cache_key}.output.json"),
)
def _load_ase_cache(cache_key: str, output_results_file: str) -> Optional[dict]:
"""Load a cached ASE result and materialize the requested output JSON."""
if not _ase_cache_enabled():
return None
return_path, output_path = _ase_cache_paths(cache_key)
if not (os.path.isfile(return_path) and os.path.isfile(output_path)):
return None
os.makedirs(os.path.dirname(output_results_file) or ".", exist_ok=True)
shutil.copy2(output_path, output_results_file)
with open(return_path, "r", encoding="utf-8") as rf:
cached = json.load(rf)
cached = _compact_ase_return_payload(cached)
cached["cache_hit"] = True
if isinstance(cached.get("message"), str):
cached["message"] = f"{cached['message']} Loaded from ChemGraph ASE cache."
return cached
def _store_ase_cache(
cache_key: str,
output_results_file: str,
result: dict,
) -> dict:
"""Persist a successful ASE result in the local cache."""
if not _ase_cache_enabled() or result.get("status") != "success":
return result
result = _compact_ase_return_payload(result)
if not os.path.isfile(output_results_file):
return result
return_path, output_path = _ase_cache_paths(cache_key)
os.makedirs(os.path.dirname(return_path), exist_ok=True)
result_to_store = dict(result)
result_to_store.pop("cache_hit", None)
with open(return_path, "w", encoding="utf-8") as wf:
json.dump(result_to_store, wf, indent=2, default=str)
shutil.copy2(output_results_file, output_path)
return result
def _compact_ase_return_payload(result: dict) -> dict:
"""Keep tool-return payloads small while preserving full output files."""
if not isinstance(result, dict):
return result
if not isinstance(result.get("result"), dict):
return result
compact = dict(result)
compact_result = dict(result["result"])
compact_result.pop("ir_data", None)
compact["result"] = compact_result
return compact
# ---------------------------------------------------------------------------
# AtomsData <-> ASE Atoms conversions
# ---------------------------------------------------------------------------
def atoms_to_atomsdata(atoms) -> AtomsData:
"""Convert an ASE ``Atoms`` object to :class:`AtomsData`.
Parameters
----------
atoms : ase.Atoms
ASE Atoms object.
Returns
-------
AtomsData
"""
return AtomsData(
numbers=atoms.numbers.tolist(),
positions=atoms.positions.tolist(),
cell=atoms.cell.tolist(),
pbc=atoms.pbc.tolist(),
)
def atomsdata_to_atoms(atomsdata: AtomsData):
"""Convert :class:`AtomsData` to an ASE ``Atoms`` object.
Parameters
----------
atomsdata : AtomsData
Returns
-------
ase.Atoms
"""
from ase import Atoms
return Atoms(
numbers=atomsdata.numbers,
positions=atomsdata.positions,
cell=atomsdata.cell,
pbc=atomsdata.pbc,
)
# ---------------------------------------------------------------------------
# Molecular property helpers
# ---------------------------------------------------------------------------
def is_linear_molecule(atomsdata: AtomsData, tol: float = 1e-3) -> bool:
"""Determine whether a molecule is linear.
Parameters
----------
atomsdata : AtomsData
Molecular structure.
tol : float, optional
Tolerance for the second singular value ratio, by default 1e-3.
Returns
-------
bool
``True`` if the molecule is linear.
"""
coords = np.array(atomsdata.positions)
centered = coords - np.mean(coords, axis=0)
_, s, _ = np.linalg.svd(centered)
if s[0] == 0:
return False # degenerate — all atoms at one point
return (s[1] / s[0]) < tol
def get_symmetry_number(atomsdata: AtomsData) -> int:
"""Return the rotational symmetry number using Pymatgen.
Parameters
----------
atomsdata : AtomsData
Returns
-------
int
"""
from pymatgen.symmetry.analyzer import PointGroupAnalyzer
from ase import Atoms
from pymatgen.io.ase import AseAtomsAdaptor
atoms = Atoms(
numbers=atomsdata.numbers,
positions=atomsdata.positions,
cell=atomsdata.cell,
pbc=atomsdata.pbc,
)
aaa = AseAtomsAdaptor()
molecule = aaa.get_molecule(atoms)
pga = PointGroupAnalyzer(molecule)
return pga.get_rotational_symmetry_number()
# ---------------------------------------------------------------------------
# Calculator loading
# ---------------------------------------------------------------------------
def load_calculator(calculator: dict) -> tuple[object, dict, object]:
"""Instantiate an ASE calculator from a config dictionary.
Parameters
----------
calculator : dict
Must contain a ``"calculator_type"`` key.
Returns
-------
tuple[object, dict, object]
``(ase_calculator, extra_info, calc_schema_instance)``
Raises
------
ValueError
If the calculator type is unsupported.
"""
calc_type = calculator["calculator_type"].lower()
if "emt" in calc_type:
from chemgraph.schemas.calculators.emt_calc import EMTCalc
calc = EMTCalc(**calculator)
elif "tblite" in calc_type or "xtb" in calc_type:
from chemgraph.schemas.calculators.tblite_calc import TBLiteCalc
calc = TBLiteCalc(**calculator)
elif "orca" in calc_type:
from chemgraph.schemas.calculators.orca_calc import OrcaCalc
calc = OrcaCalc(**calculator)
elif "nwchem" in calc_type:
from chemgraph.schemas.calculators.nwchem_calc import NWChemCalc
calc = NWChemCalc(**calculator)
elif "fairchem" in calc_type:
from chemgraph.schemas.calculators.fairchem_calc import FAIRChemCalc
calc = FAIRChemCalc(**calculator)
elif "mace" in calc_type:
from chemgraph.schemas.calculators.mace_calc import MaceCalc
calc = MaceCalc(**calculator)
elif "aimnet2" in calc_type:
from chemgraph.schemas.calculators.aimnet2_calc import AIMNET2Calc
calc = AIMNET2Calc(**calculator)
else:
raise ValueError(
f"Unsupported calculator: {calculator}. "
"Available calculators are EMT, TBLite (GFN2-xTB, GFN1-xTB), "
"Orca, NWChem, FAIRChem, MACE, or AIMNET2."
)
extra_info: dict = {}
if hasattr(calc, "get_atoms_properties"):
extra_info = calc.get_atoms_properties()
return calc.get_calculator(), extra_info, calc
# ---------------------------------------------------------------------------
# Misc helpers (kept for backward compat / UI)
# ---------------------------------------------------------------------------
def extract_ase_atoms_from_tool_result(tool_result: dict):
"""Extract ``(atomic_numbers, positions)`` from a tool-result dict.
Returns ``(None, None)`` if extraction fails.
Parameters
----------
tool_result : dict
Tool result that may contain atom numbers and positions.
Returns
-------
tuple
``(atomic_numbers, positions)`` or ``(None, None)``.
"""
for keyset in ({"numbers", "positions"}, {"atomic_numbers", "positions"}):
if keyset.issubset(tool_result.keys()):
return tool_result[keyset.pop()], tool_result["positions"]
if "atoms" in tool_result:
atoms_data = tool_result["atoms"]
if {"numbers", "positions"}.issubset(atoms_data):
return atoms_data["numbers"], atoms_data["positions"]
return None, None
def create_ase_atoms(atomic_numbers, positions):
"""Create an ASE ``Atoms`` object from atomic numbers and positions.
Parameters
----------
atomic_numbers : sequence
Atomic numbers for each atom.
positions : sequence
Cartesian coordinates for each atom.
Returns
-------
ase.Atoms or None
Constructed atoms object, or ``None`` if construction fails.
"""
from ase import Atoms
try:
return Atoms(numbers=atomic_numbers, positions=positions)
except Exception as e:
print(f"Error creating ASE Atoms object: {e}")
return None
def create_xyz_string(atomic_numbers, positions) -> Optional[str]:
"""Create an XYZ-format string from atomic numbers and positions.
Parameters
----------
atomic_numbers : sequence
Atomic numbers for each atom.
positions : sequence
Cartesian coordinates for each atom.
Returns
-------
str or None
XYZ-format structure text, or ``None`` if conversion fails.
"""
from ase import Atoms
try:
atoms = Atoms(numbers=atomic_numbers, positions=positions)
xyz_lines = [str(len(atoms)), "Generated by ChemGraph"]
for symbol, pos in zip(atoms.get_chemical_symbols(), atoms.positions):
xyz_lines.append(
f"{symbol:2s} {pos[0]:12.6f} {pos[1]:12.6f} {pos[2]:12.6f}"
)
return "\n".join(xyz_lines)
except Exception as e:
print(f"Error creating XYZ string: {e}")
return None
# ---------------------------------------------------------------------------
# Unified ASE simulation core
# ---------------------------------------------------------------------------
def run_ase_core(params: ASEInputSchema) -> dict:
"""Run an ASE simulation — the single implementation for all call methods.
This function implements energy, dipole, optimization, vibrational,
thermochemistry, and IR calculations. Framework-specific wrappers
(LangChain ``@tool``, MCP ``@mcp.tool``, Parsl) delegate here.
Parameters
----------
params : ASEInputSchema
Fully validated simulation input.
Returns
-------
dict
Minimal result payload (status, message, key numbers).
"""
from ase.io import read
from ase.optimize import BFGS, LBFGS, GPMin, FIRE, MDMin
# ---- unpack params ----
try:
calculator = params.calculator.model_dump()
except Exception as e:
return {
"status": "failure",
"error_type": "ValidationError",
"message": f"Missing calculator parameter for the simulation. Raised exception: {e}",
}
start_time = time.time()
input_structure_file = _resolve_path(params.input_structure_file)
output_results_file = _resolve_path(params.output_results_file)
optimizer = params.optimizer
fmax = params.fmax
steps = params.steps
driver = params.driver
temperature = params.temperature
pressure = params.pressure
if driver == "thermo" and temperature is None:
temperature = 298.15
params = params.model_copy(update={"temperature": temperature})
# ---- input validation ----
if not os.path.isfile(input_structure_file):
return {
"status": "failure",
"error_type": "FileNotFoundError",
"message": f"Input structure file {input_structure_file} does not exist.",
}
if not output_results_file.endswith(".json"):
return {
"status": "failure",
"error_type": "ValueError",
"message": f"Output results file must end with '.json', got: {params.output_results_file}",
}
cache_key = _ase_cache_key(params, input_structure_file)
cached_result = _load_ase_cache(cache_key, output_results_file)
if cached_result is not None:
return cached_result
calc, system_info, calc_model = load_calculator(calculator)
if calc is None:
return {
"status": "failure",
"error_type": "ValueError",
"message": (
f"Unsupported calculator: {calculator}. Available calculators are "
"MACE (mace_mp, mace_off, mace_anicc), EMT, TBLite (GFN2-xTB, GFN1-xTB), NWChem and Orca"
),
}
try:
atoms = read(input_structure_file)
except Exception as e:
return {
"status": "failure",
"error_type": type(e).__name__,
"message": f"Cannot read {input_structure_file} using ASE. Exception from ASE: {e}",
}
atoms.info.update(system_info)
atoms.calc = calc
# ------------------------------------------------------------------
# Driver: energy / dipole (single-point, no optimization)
# ------------------------------------------------------------------
if driver in ("energy", "dipole"):
energy = atoms.get_potential_energy()
final_structure = atoms_to_atomsdata(atoms)
dipole: List[Optional[float]] = [None, None, None]
if driver == "dipole":
try:
dipole = [round(x, 4) for x in atoms.get_dipole_moment()]
except Exception:
pass
end_time = time.time()
wall_time = end_time - start_time
simulation_output = ASEOutputSchema(
input_structure_file=input_structure_file,
converged=True,
final_structure=final_structure,
simulation_input=params,
success=True,
dipole_value=dipole,
single_point_energy=energy,
wall_time=wall_time,
)
with open(output_results_file, "w", encoding="utf-8") as wf:
wf.write(simulation_output.model_dump_json(indent=4))
if driver == "energy":
return _store_ase_cache(cache_key, output_results_file, {
"status": "success",
"message": f"Simulation completed. Results saved to {os.path.abspath(output_results_file)}",
"single_point_energy": energy,
"unit": "eV",
})
else: # dipole
return _store_ase_cache(cache_key, output_results_file, {
"status": "success",
"message": f"Simulation completed. Results saved to {os.path.abspath(output_results_file)}",
"dipole_moment": dipole,
"dipole_unit": "e * Angstrom",
})
# ------------------------------------------------------------------
# Drivers that require optimization: opt / vib / thermo / ir
# ------------------------------------------------------------------
OPTIMIZERS = {
"bfgs": BFGS,
"lbfgs": LBFGS,
"gpmin": GPMin,
"fire": FIRE,
"mdmin": MDMin,
}
try:
optimizer_class = OPTIMIZERS.get(optimizer.lower())
if optimizer_class is None:
raise ValueError(f"Unsupported optimizer: {optimizer}")
if len(atoms) > 1:
dyn = optimizer_class(atoms)
converged = dyn.run(fmax=fmax, steps=steps)
else:
converged = True
single_point_energy = float(atoms.get_potential_energy())
final_structure = AtomsData(
numbers=atoms.numbers,
positions=atoms.positions,
cell=atoms.cell,
pbc=atoms.pbc,
)
thermo_data: dict = {}
vib_data: dict = {}
ir_data: dict = {}
# --------------------------------------------------------------
# Vibrational / thermo / IR analysis
# --------------------------------------------------------------
if driver in {"vib", "thermo", "ir"}:
from ase.vibrations import Vibrations
from ase import units
ir_plot_path: Optional[str] = None
mol_stem = (
Path(input_structure_file).stem if input_structure_file else "mol"
)
with tempfile.TemporaryDirectory(
prefix=f"chemgraph_vib_{mol_stem}_"
) as tmpdir:
vib_name = os.path.join(tmpdir, "vib")
vib = Vibrations(atoms, name=vib_name)
vib.clean()
vib.run()
vib_data = {
"energies": [],
"energy_unit": "meV",
"frequencies": [],
"frequency_unit": "cm-1",
}
energies = vib.get_energies()
for _idx, e in enumerate(energies):
is_imag = abs(e.imag) > 1e-8
e_val = e.imag if is_imag else e.real
energy_meV = 1e3 * e_val
freq_cm1 = e_val / units.invcm
suffix = "i" if is_imag else ""
vib_data["energies"].append(f"{energy_meV}{suffix}")
vib_data["frequencies"].append(f"{freq_cm1}{suffix}")
# Write frequencies CSV
freq_file_path = _resolve_path(f"frequencies_{mol_stem}.csv")
freq_file = Path(freq_file_path)
if freq_file.exists():
freq_file.unlink()
with freq_file.open("w", encoding="utf-8") as f:
for i, freq in enumerate(vib_data["frequencies"], start=0):
f.write(f"{mol_stem}_vib.{i}.traj,{freq}\n")
# Write normal-mode .traj files, then copy out of tmpdir
for i in range(len(energies)):
vib.write_mode(n=i, kT=units.kB * 300, nimages=30)
traj_dest_dir = _resolve_path("")
if traj_dest_dir:
os.makedirs(traj_dest_dir, exist_ok=True)
for traj_file in glob.glob(os.path.join(tmpdir, "vib.*.traj")):
dest_name = f"{mol_stem}_{Path(traj_file).name}"
dest_path = (
os.path.join(traj_dest_dir, dest_name)
if traj_dest_dir
else dest_name
)
shutil.copy2(traj_file, dest_path)
# ---- IR ----
if driver == "ir":
from ase.vibrations import Infrared
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
ir_data["spectrum_frequencies"] = []
ir_data["spectrum_frequencies_units"] = "cm-1"
ir_data["spectrum_intensities"] = []
ir_data["spectrum_intensities_units"] = "D/Å^2 amu^-1"
ir_name = os.path.join(tmpdir, "ir")
ir = Infrared(atoms, name=ir_name)
ir.clean()
ir.run()
IR_SPECTRUM_START = 500
IR_SPECTRUM_END = 4000
freq_intensity = ir.get_spectrum(
start=IR_SPECTRUM_START, end=IR_SPECTRUM_END
)
mode_frequencies = [float(value) for value in ir.get_frequencies().real]
mode_intensities = [float(value) for value in ir.intensities]
ir_data["mode_frequencies"] = mode_frequencies
ir_data["mode_frequencies_units"] = "cm-1"
ir_data["mode_intensities"] = mode_intensities
ir_data["mode_intensities_units"] = "D/Å^2 amu^-1"
ir_data["spectrum_frequencies"] = [
float(value) for value in freq_intensity[0]
]
ir_data["spectrum_intensities"] = [
float(value) for value in freq_intensity[1]
]
fig, ax = plt.subplots()
ax.plot(freq_intensity[0], freq_intensity[1])
ax.set_xlabel("Frequency (cm⁻¹)")
ax.set_ylabel("Intensity (a.u.)")
ax.set_title("Infrared Spectrum")
ax.grid(True)
ir_plot_path = _resolve_path(f"ir_spectrum_{mol_stem}.png")
fig.savefig(ir_plot_path, format="png", dpi=300)
plt.close(fig)
ir_data["IR Plot"] = f"Saved to {os.path.abspath(ir_plot_path)}"
ir_spectrum = {
"frequency_cm1": [str(value) for value in mode_frequencies],
"intensity": [str(value) for value in mode_intensities],
"plot": os.path.abspath(ir_plot_path),
}
ir_data["Normal mode data"] = (
f"Normal modes saved as individual .traj files with prefix {mol_stem}_"
)
# ---- Thermochemistry ----
if driver == "thermo":
if len(atoms) == 1:
thermo_data = {
"enthalpy": single_point_energy,
"entropy": 0.0,
"gibbs_free_energy": single_point_energy,
"unit": "eV",
}
else:
from ase.thermochemistry import IdealGasThermo
linear = is_linear_molecule(final_structure)
geometry = "linear" if linear else "nonlinear"
symmetrynumber = get_symmetry_number(final_structure)
# IdealGasThermo expects total spin S; calculators expose
# multiplicity (2S+1) via get_multiplicity() when supported.
multiplicity = (
getattr(calc_model, "get_multiplicity", lambda: None)()
or 1
)
spin_S = (multiplicity - 1) / 2.0
thermo = IdealGasThermo(
vib_energies=energies,
potentialenergy=single_point_energy,
atoms=atoms,
geometry=geometry,
symmetrynumber=symmetrynumber,
spin=spin_S,
)
thermo_data = {
"enthalpy": float(
thermo.get_enthalpy(temperature=temperature)
),
"entropy": float(
thermo.get_entropy(
temperature=temperature, pressure=pressure
)
),
"gibbs_free_energy": float(
thermo.get_gibbs_energy(
temperature=temperature, pressure=pressure
)
),
"unit": "eV",
}
# ---- serialise full output ----
end_time = time.time()
wall_time = end_time - start_time
simulation_output = ASEOutputSchema(
input_structure_file=input_structure_file,
converged=converged,
final_structure=final_structure,
simulation_input=params,
vibrational_frequencies=vib_data,
thermochemistry=thermo_data,
success=True,
ir_data=ir_data,
single_point_energy=single_point_energy,
wall_time=wall_time,
)
with open(output_results_file, "w", encoding="utf-8") as wf:
wf.write(simulation_output.model_dump_json(indent=4))
# ---- minimal return payload ----
abs_output = os.path.abspath(output_results_file)
if driver == "opt":
return _store_ase_cache(cache_key, output_results_file, {
"status": "success",
"message": f"Simulation completed. Results saved to {abs_output}",
"single_point_energy": single_point_energy,
"unit": "eV",
})
elif driver == "vib":
return _store_ase_cache(cache_key, output_results_file, {
"status": "success",
"result": {"vibrational_frequencies": vib_data},
"message": (
"Vibrational analysis completed; frequencies returned. "
f"Full results (structure, vibrations and metadata) saved to {abs_output}."
),
})
elif driver == "thermo":
return _store_ase_cache(cache_key, output_results_file, {
"status": "success",
"result": {"thermochemistry": thermo_data},
"message": (
"Thermochemistry computed and returned. "
f"Full results (structure, vibrations, thermochemistry and metadata) saved to {abs_output}"
),
})
elif driver == "ir":
return _store_ase_cache(cache_key, output_results_file, {
"status": "success",
"result": {
"vibrational_frequencies": vib_data,
"ir_spectrum": ir_spectrum,
},
"message": (
"Infrared computed and returned. "
f"Full results (structure, vibrations, IR arrays and metadata) saved to {abs_output}. "
f"IR plot saved to {os.path.abspath(ir_plot_path) if ir_plot_path else 'N/A'}. "
"Normal modes saved as individual .traj files"
),
})
except Exception as e:
return {
"status": "failure",
"error_type": type(e).__name__,
"message": str(e),
}
# ---------------------------------------------------------------------------
# JSON result loader
# ---------------------------------------------------------------------------
def extract_output_json_core(json_file: str) -> dict:
"""Load simulation results from a JSON file produced by ``run_ase_core``.
Parameters
----------
json_file : str
Path to the JSON file containing ASE simulation results.
Returns
-------
dict
Parsed results from the JSON file as a Python dictionary.
Raises
------
FileNotFoundError
If the specified file does not exist.
json.JSONDecodeError
If the file is not valid JSON.
"""
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
return data