diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml new file mode 100644 index 0000000..ee22c2f --- /dev/null +++ b/.github/workflows/regression.yml @@ -0,0 +1,42 @@ +# Detects whether a pull request changes FSM numerical output (structures / +# energies) versus the target branch, as opposed to a pure efficiency change. +# Both versions run on the same runner, so the comparison is tight. +name: Output Regression + +on: + pull_request: + +jobs: + regression: + runs-on: ubuntu-latest + steps: + # Candidate (the PR branch) — also provides the stable fingerprint/compare scripts. + - name: Checkout PR + uses: actions/checkout@v4 + with: + path: pr + + # Baseline (the branch the PR targets, e.g. main). + - name: Checkout base + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + path: base + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + # Install runtime dependencies once; mlfsm itself is imported from each + # checkout's src/ via --src, so no per-branch reinstall is required. + - name: Install dependencies + run: pip install ./pr + + - name: Fingerprint baseline (${{ github.base_ref }}) + run: python pr/tests/regression/fingerprint.py --src base/src --out base.json + + - name: Fingerprint PR + run: python pr/tests/regression/fingerprint.py --src pr/src --out pr.json + + - name: Compare + run: python pr/tests/regression/compare.py base.json pr.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0cee2..9c9723f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- Removed torchmd custom calculator from examples and corresponding checkpoint file +- Update tests setup, implemented unit tests, regression CI tests, integration tests ## [1.0.1] - 2026 ### Added diff --git a/devtools/check_regression.sh b/devtools/check_regression.sh new file mode 100755 index 0000000..3b9218c --- /dev/null +++ b/devtools/check_regression.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# +# Check whether the current working tree changes FSM numerical output relative to +# a baseline ref (default: main). Runs the same fingerprint script against the +# baseline's source and the current source, then compares structures/energies. +# +# Usage: +# devtools/check_regression.sh [BASE_REF] +# pixi run -e dev regression +# +# Requires the mlfsm runtime dependencies to be installed in the active +# environment (ase, numpy, scipy, geometric, networkx). The package itself is +# imported directly from each checkout's src/ via PYTHONPATH, so no reinstall is +# needed. +set -euo pipefail + +BASE_REF="${1:-main}" +ROOT="$(git rev-parse --show-toplevel)" +TMP="$(mktemp -d)" +BASE_WT="$TMP/base" + +cleanup() { + git -C "$ROOT" worktree remove --force "$BASE_WT" 2>/dev/null || true + rm -rf "$TMP" +} +trap cleanup EXIT + +echo "Creating worktree for baseline ref '$BASE_REF'..." +git -C "$ROOT" worktree add -q --detach "$BASE_WT" "$BASE_REF" + +echo "Fingerprinting baseline ($BASE_REF)..." +python "$ROOT/tests/regression/fingerprint.py" --src "$BASE_WT/src" --out "$TMP/base.json" + +echo "Fingerprinting working tree..." +python "$ROOT/tests/regression/fingerprint.py" --src "$ROOT/src" --out "$TMP/pr.json" + +echo "Comparing..." +python "$ROOT/tests/regression/compare.py" "$TMP/base.json" "$TMP/pr.json" diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..dbe794e --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Example scripts for running the Freezing String Method.""" diff --git a/examples/custom_calculator_torchmd.py b/examples/custom_calculator_torchmd.py deleted file mode 100644 index f342e9e..0000000 --- a/examples/custom_calculator_torchmd.py +++ /dev/null @@ -1,58 +0,0 @@ -"""TorchMD-Net-based ASE calculator for ML-FSM.""" - -from typing import Any, ClassVar - -import torch -from ase import Atoms -from ase.calculators.calculator import Calculator, all_changes -from torchmdnet.models.model import load_model # type: ignore [import-not-found] - - -class TMDCalculator(Calculator): - """ASE-compatible calculator using a pretrained TorchMD-Net model.""" - - implemented_properties: ClassVar[list[str]] = ["energy", "forces"] # type: ignore [misc] - - def __init__(self, **kwargs): - """Initialize the calculator and load the TorchMD-Net model.""" - super().__init__(self, **kwargs) - checkpoint = "./pre_trained_gnns/epoch359_tensornet_spice.ckpt" - self.model = load_model(checkpoint, derivative=True, remove_ref_energy=False) - self.z = None - self.batch = None - - def calculate( # type: ignore [override] - self, - atoms: Atoms, - properties: list[str] | None = None, - system_changes: list[Any] | None = all_changes, - ): - """ - Compute energy and forces for the given atoms using TorchMD-Net. - - Parameters - ---------- - atoms : ASE Atoms object - The molecular structure to evaluate. - properties : list of str, optional - Desired properties (default: ["energy", "forces"]). - system_changes : list, optional - System changes triggering recalculation. - """ - properties = properties or ["energy", "forces"] - - Calculator.calculate(self, atoms, properties, system_changes) - positions = atoms.get_positions() - self.pos = torch.from_numpy(positions).float().reshape(-1, 3) - - if self.z is None: - self.z = torch.from_numpy(atoms.numbers).long() - self.batch = torch.zeros(len(atoms.numbers), dtype=torch.long) - - energy, forces = self.model(self.z, self.pos, self.batch) - energy = energy.item() - forces = forces.detach().numpy() - self.results = { - "energy": energy, - "forces": forces, - } diff --git a/examples/fsm_example.py b/examples/fsm_example.py index fb01ce3..136946f 100644 --- a/examples/fsm_example.py +++ b/examples/fsm_example.py @@ -154,10 +154,6 @@ def parse_indices(text): dev = "cuda" if torch.cuda.is_available() else "cpu" predictor = pretrained_mlip.get_predict_unit("uma-s-1", device=dev) calc = FAIRChemCalculator(predictor, task_name="omol") - elif calculator == "torchmd": - from custom_calculator_torchmd import TMDCalculator - - calc = TMDCalculator() elif calculator == "aimnet2": from aimnet2calc import AIMNet2ASE # type: ignore [import-not-found] diff --git a/examples/pre_trained_gnns/epoch359_tensornet_spice.ckpt b/examples/pre_trained_gnns/epoch359_tensornet_spice.ckpt deleted file mode 100644 index 66b7a9d..0000000 Binary files a/examples/pre_trained_gnns/epoch359_tensornet_spice.ckpt and /dev/null differ diff --git a/pixi.lock b/pixi.lock index 8d861d8..cbd14b6 100644 --- a/pixi.lock +++ b/pixi.lock @@ -3181,7 +3181,7 @@ packages: - pypi: ./ name: mlfsm version: 1.0.1 - sha256: 446283e45fb3467758f9b167aca33e2404394e89a4ab796446cdb1c2bc3db8d6 + sha256: 51f22a90a389be84157e47da13dca7c02e21a44f8c6cafcd12cda0c8351cf0b1 requires_dist: - numpy>=1.26 - ase>=3.22 diff --git a/pyproject.toml b/pyproject.toml index 249bb3e..9539de9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ lint = { cmd="ruff check . --fix" } types = { cmd="mypy ." } test = { cmd="pytest" } coverage = { cmd = "pytest --cov=src/mlfsm --cov-report=xml" } +regression = { cmd = "bash devtools/check_regression.sh" } docs = { cmd="python -m sphinx docs/source docs/build" } all = { depends-on = ["fmt", "lint", "types", "test", "docs"] } @@ -103,6 +104,12 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401", "F403"] +# Tests favor terseness: skip docstring, magic-value, type-checking-block, and +# mutable-class-attr rules that add noise without value in test code. +"tests/*" = ["D", "PLR2004", "TC001", "TC002", "TC003", "RUF012"] +# Regression scripts import mlfsm only after manipulating sys.path (--src), so +# imports are intentionally function-local (PLC0415). +"tests/regression/*" = ["D", "PLR2004", "PLC0415", "TC001", "TC002", "TC003"] [tool.ruff.lint.pydocstyle] convention = "numpy" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ed91c70 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +"""Shared pytest fixtures and configuration for the ML-FSM test suite.""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +import pytest + +# Make run_fsm importable in-process +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from examples.fsm_example import run_fsm # noqa: E402 (re-exported for tests) + +__all__ = ["run_fsm"] + +EXAMPLE_REACTION = REPO_ROOT / "examples" / "data" / "06_diels_alder" +REACTION_INPUTS = ("initial.xyz", "chg", "mult") + + +@pytest.fixture +def reaction_dir(tmp_path: Path) -> Path: + """Copy the Diels-Alder reaction inputs into an isolated tmp directory. + + run_fsm writes its output subdirectory into the reaction directory, so the + copy keeps the example tree clean across test runs. + """ + dst = tmp_path / "diels_alder" + dst.mkdir() + for name in REACTION_INPUTS: + shutil.copy(EXAMPLE_REACTION / name, dst / name) + return dst diff --git a/tests/regression/__init__.py b/tests/regression/__init__.py new file mode 100644 index 0000000..20116a9 --- /dev/null +++ b/tests/regression/__init__.py @@ -0,0 +1 @@ +"""Compare-against-baseline regression check (run via scripts/check_regression.sh).""" diff --git a/tests/regression/compare.py b/tests/regression/compare.py new file mode 100755 index 0000000..3f9e0ba --- /dev/null +++ b/tests/regression/compare.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +"""Compare two FSM fingerprints (see fingerprint.py) and report output changes. + +The FSM is deterministic, so for a given input the final string is fully +determined by the source code. A change either leaves the output identical or +it changes the structures/energies — there is no "efficiency-only" middle +ground, since an identical path implies an identical amount of work. This +script therefore reports a single thing: did the FSM output change? + +Because the baseline and candidate are produced on the same machine, the +comparison is tight (no cross-platform float drift to absorb). + +Usage +----- + python tests/regression/compare.py base.json pr.json +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# Same-runner determinism means repeated runs are effectively bit-identical; +# this tolerance only guards against trivial float non-associativity. +COORD_ATOL = 1e-8 +ENERGY_ATOL = 1e-8 + +INTERPS = ["cart", "lst", "ric"] + + +def _max_abs_diff(a: list, b: list) -> float: + """Maximum absolute elementwise difference between two nested numeric lists.""" + import numpy as np + + return float(np.max(np.abs(np.asarray(a, dtype=float) - np.asarray(b, dtype=float)))) + + +def _compare_one(interp: str, base: dict, cand: dict) -> list[str]: + """Return the list of output changes for one interpolation style.""" + if base["symbols"] != cand["symbols"]: + return [f"[{interp}] atom symbols changed"] + + n_base, n_cand = len(base["coords"]), len(cand["coords"]) + if n_base != n_cand: + return [f"[{interp}] node count changed: base={n_base} candidate={n_cand}"] + + failures: list[str] = [] + coord_diff = _max_abs_diff(base["coords"], cand["coords"]) + energy_diff = _max_abs_diff(base["energies"], cand["energies"]) + if coord_diff > COORD_ATOL: + failures.append(f"[{interp}] coordinates changed: max |Δ| = {coord_diff:.3e} Å (tol {COORD_ATOL:.0e})") + if energy_diff > ENERGY_ATOL: + failures.append(f"[{interp}] energies changed: max |Δ| = {energy_diff:.3e} eV (tol {ENERGY_ATOL:.0e})") + return failures + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("base", type=Path, help="Baseline fingerprint JSON (e.g. from main).") + parser.add_argument("candidate", type=Path, help="Candidate fingerprint JSON (e.g. from the PR).") + args = parser.parse_args() + + base = json.loads(args.base.read_text())["results"] + cand = json.loads(args.candidate.read_text())["results"] + + failures: list[str] = [] + for interp in INTERPS: + if interp not in base or interp not in cand: + failures.append(f"[{interp}] missing from one fingerprint") + continue + failures += _compare_one(interp, base[interp], cand[interp]) + + if failures: + print("FSM OUTPUT CHANGED relative to baseline:") + for f in failures: + print(f" FAIL {f}") + print("\nIf this change is intended, this is expected — review the differences above.") + return 1 + + print("FSM output matches baseline (structures and energies unchanged).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/regression/fingerprint.py b/tests/regression/fingerprint.py new file mode 100755 index 0000000..0a606ae --- /dev/null +++ b/tests/regression/fingerprint.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python +"""Produce a compact numerical fingerprint of FSM output for regression checks. + +This script runs a small, deterministic Freezing String Method calculation with +the ASE EMT calculator for each interpolation style and records the final +optimized string (per-node coordinates and energies). + +It is deliberately driver-stable: it imports only the core ``mlfsm`` API and can +be pointed at an arbitrary checkout's source tree via ``--src``. The +compare-against-baseline workflow runs this *same* script against both the base +branch and the PR branch (see ``tests/regression/compare.py`` and +``.github/workflows/regression.yml``), so any difference in the recorded +structures/energies is attributable to the code change under review. + +Usage +----- + python tests/regression/fingerprint.py --src path/to/src --out out.json +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +INTERPS = ["cart", "lst", "ric"] + +# Small, fast, deterministic run parameters (a few seconds per style with EMT). +NNODES_MIN = 5 +NINTERP = 20 +MAXITER = 1 +MAXLS = 3 +DMAX = 0.05 + +HERE = Path(__file__).resolve().parent +DEFAULT_REACTION = HERE.parent.parent / "examples" / "data" / "06_diels_alder" + + +def _fingerprint_one(reaction_dir: Path, interp: str) -> dict: + """Run the FSM for a single interpolation style and return its fingerprint.""" + from ase.calculators.emt import EMT + + from mlfsm.cos import FreezingString + from mlfsm.opt import CartesianOptimizer + from mlfsm.utils import load_xyz + + reactant, product = load_xyz(reaction_dir) + calc = EMT() + reactant.calc = calc + product.calc = calc + + string = FreezingString(reactant, product, nnodes_min=NNODES_MIN, interp_method=interp, ninterp=NINTERP) + optimizer = CartesianOptimizer(calc, "L-BFGS-B", MAXITER, MAXLS, DMAX) + + while string.growing: + string.grow() + string.optimize(optimizer) + + path = string.r_string + string.p_string[::-1] + energies = string.r_energy + string.p_energy[::-1] + return { + "symbols": path[0].get_chemical_symbols(), + "coords": [atoms.get_positions().tolist() for atoms in path], + "energies": list(energies), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--src", + type=Path, + default=None, + help="mlfsm source tree to import (prepended to sys.path).", + ) + parser.add_argument("--out", type=Path, required=True, help="Where to write the fingerprint JSON.") + parser.add_argument("--reaction", type=Path, default=DEFAULT_REACTION, help="Reaction input directory.") + args = parser.parse_args() + + if args.src is not None: + sys.path.insert(0, str(args.src.resolve())) + + result = {interp: _fingerprint_one(args.reaction, interp) for interp in INTERPS} + + import mlfsm + + payload = {"mlfsm_version": getattr(mlfsm, "__version__", "unknown"), "results": result} + args.out.write_text(json.dumps(payload, indent=2)) + print(f"Wrote fingerprint for mlfsm {payload['mlfsm_version']} -> {args.out}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_cartesian.py b/tests/test_cartesian.py index 37d1e97..6e27add 100644 --- a/tests/test_cartesian.py +++ b/tests/test_cartesian.py @@ -1,51 +1,27 @@ -"""Test FSM script on a sample reaction using EMT calculator.""" +"""End-to-end FSM runs driven by the Cartesian optimizer (EMT).""" -import os -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path - - -def test_fsm_script_diels_alder() -> None: - """Run fsm_example.py on the Hexadiene example with CG and LST using the EMT calculator.""" - example_dir = Path("examples/data/07_hexadiene") - script_path = Path("examples/fsm_example.py") - - with tempfile.TemporaryDirectory() as tmpdir: - # Copy the example into a temporary directory - rxn_dir = Path(tmpdir) / "07_hexadiene" - shutil.copytree(example_dir, rxn_dir) +from __future__ import annotations - env = os.environ.copy() - env["PYTHONPATH"] = os.getcwd() - # Run the FSM script - - result = subprocess.run( - [ - sys.executable, - str(script_path), - str(rxn_dir), - "--calculator", - "emt", - "--interp", - "lst", - "--method", - "CG", - "--suffix", - "test_fsm_script", - ], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - - print("STDOUT:", result.stdout) - print("STDERR:", result.stderr) +from pathlib import Path - # Check that the script completed without error - assert result.returncode == 0 - assert "Gradient calls:" in result.stdout +import pytest + +from tests.conftest import run_fsm + + +@pytest.mark.parametrize(("interp", "method"), [("cart", "L-BFGS-B"), ("lst", "CG")]) +def test_fsm_cartesian_optimizer(reaction_dir: Path, interp: str, method: str) -> None: + """run_fsm completes with the Cartesian optimizer for cart/L-BFGS-B and lst/CG.""" + suffix = f"{interp}_{method}" + run_fsm( + reaction_dir, + calculator="emt", + interp=interp, + method=method, + nnodes_min=5, + ninterp=20, + suffix=suffix, + ) + outdir = next(reaction_dir.glob(f"fsm_interp_*_{suffix}")) + assert (outdir / "fsm.out").is_file() + assert "Total gradient calls" in (outdir / "fsm.out").read_text() diff --git a/tests/test_coords.py b/tests/test_coords.py new file mode 100644 index 0000000..21606c5 --- /dev/null +++ b/tests/test_coords.py @@ -0,0 +1,47 @@ +"""Failure-mode tests for the RIC -> Cartesian back-transformation (coords.x). + +The iterative B-matrix back-transformation is the fragile numerical kernel of the +RIC path: on a hard target it may not converge. ``MAX_ITERATIONS`` is monkeypatched +down so a normal Diels-Alder reactant->product step exhausts the iteration budget, +exercising both the raising and the fallback branches. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from ase import Atoms + +from mlfsm import coords as coords_mod +from mlfsm.coords import Redundant +from mlfsm.utils import load_xyz +from tests.conftest import EXAMPLE_REACTION + + +@pytest.fixture +def endpoints() -> tuple[Atoms, Atoms]: + return load_xyz(EXAMPLE_REACTION) + + +def test_backtransform_raises_on_nonconvergence( + endpoints: tuple[Atoms, Atoms], monkeypatch: pytest.MonkeyPatch +) -> None: + reactant, product = endpoints + monkeypatch.setattr(coords_mod, "MAX_ITERATIONS", 1) + coords = Redundant(reactant, product, raise_on_backtransf_fail=True) + qtarget = coords.q(product.get_positions()) + with pytest.raises(RuntimeError, match="did not converge"): + coords.x(reactant.get_positions(), qtarget) + + +def test_backtransform_returns_backup_when_not_raising( + endpoints: tuple[Atoms, Atoms], monkeypatch: pytest.MonkeyPatch +) -> None: + reactant, product = endpoints + monkeypatch.setattr(coords_mod, "MAX_ITERATIONS", 1) + coords = Redundant(reactant, product, raise_on_backtransf_fail=False) + qtarget = coords.q(product.get_positions()) + xyz = coords.x(reactant.get_positions(), qtarget) + # Instead of raising, x returns the best (backup) geometry it found. + assert xyz.shape == reactant.get_positions().shape + assert np.all(np.isfinite(xyz)) diff --git a/tests/test_cos.py b/tests/test_cos.py new file mode 100644 index 0000000..be150dc --- /dev/null +++ b/tests/test_cos.py @@ -0,0 +1,30 @@ +"""Unit tests for FreezingString construction and interpolation dispatch.""" + +from __future__ import annotations + +import numpy as np +import pytest +from ase import Atoms + +from mlfsm.cos import FreezingString +from mlfsm.utils import load_xyz +from tests.conftest import EXAMPLE_REACTION + + +@pytest.fixture +def endpoints() -> tuple[Atoms, Atoms]: + return load_xyz(EXAMPLE_REACTION) + + +def test_bad_interp_method_raises(endpoints: tuple[Atoms, Atoms]) -> None: + reactant, product = endpoints + with pytest.raises(ValueError, match="Check interpolation method"): + FreezingString(reactant, product, interp_method="bogus") + + +def test_explicit_stepsize_sets_cartesian_distance(endpoints: tuple[Atoms, Atoms]) -> None: + reactant, product = endpoints + string = FreezingString(reactant, product, interp_method="cart", ninterp=10, stepsize=0.5) + assert string.use_cartesian_distance is True + assert np.isclose(string.stepsize, 0.5) + assert string.nnodes_min == int(string.dist / 0.5) diff --git a/tests/test_interp.py b/tests/test_interp.py new file mode 100644 index 0000000..1ddaaf8 --- /dev/null +++ b/tests/test_interp.py @@ -0,0 +1,75 @@ +"""Unit tests for the interpolation schemes in mlfsm.interp.""" + +from __future__ import annotations + +import numpy as np +import pytest +from ase import Atoms + +from mlfsm.interp import RIC, Linear +from mlfsm.utils import load_xyz +from tests.conftest import EXAMPLE_REACTION + + +@pytest.fixture +def endpoints() -> tuple[Atoms, Atoms]: + return load_xyz(EXAMPLE_REACTION) + + +def test_linear_interpolate_shape_and_endpoints() -> None: + a1 = Atoms("H2", positions=[[0, 0, 0], [1, 0, 0]]) + a2 = Atoms("H2", positions=[[0, 0, 0], [2, 0, 0]]) + ninterp = 10 + path = Linear(a1, a2, ninterp=ninterp).interpolate() + + assert path.shape == (ninterp, 2 * 3) + assert path.dtype == np.float32 + # First and last frames reproduce the endpoint geometries. + assert np.allclose(path[0], a1.get_positions().flatten(), atol=1e-6) + assert np.allclose(path[-1], a2.get_positions().flatten(), atol=1e-6) + + +def test_ric_interpolate_return_q_endpoints(endpoints: tuple[Atoms, Atoms]) -> None: + reactant, product = endpoints + ric = RIC(reactant, product, ninterp=8, return_q=True) + qpath = ric.interpolate() + + n_ics = len(ric.coords.keys) + assert qpath.shape == (8, n_ics) + # up to the torsion ±pi wrapping applied inside interpolate(). + q1 = ric.coords.q(reactant.get_positions()) + q2 = ric.coords.q(product.get_positions()) + non_tors = [i for i, name in enumerate(ric.coords.keys) if "tors" not in name] + assert np.allclose(qpath[0][non_tors], q1[non_tors], atol=1e-5) + assert np.allclose(qpath[-1][non_tors], q2[non_tors], atol=1e-5) + + +def test_ric_interpolate_cartesian_shape(endpoints: tuple[Atoms, Atoms]) -> None: + reactant, product = endpoints + path = RIC(reactant, product, ninterp=6).interpolate() + assert path.shape == (6, len(reactant), 3) + assert path.dtype == np.float32 + + +def _hooh(phi_deg: float) -> Atoms: + """Hydrogen peroxide (H-O-O-H) with the H-O-O-H dihedral set to ``phi_deg``.""" + phi = np.radians(phi_deg) + o1 = np.array([0.0, 0.0, 0.0]) + o2 = np.array([1.46, 0.0, 0.0]) + h1 = o1 + np.array([-0.5, 0.8, 0.0]) + h2 = o2 + np.array([0.5, 0.8 * np.cos(phi), 0.8 * np.sin(phi)]) + return Atoms("HOOH", positions=[h1, o1, o2, h2]) + + +def test_ric_interpolate_wraps_torsion_across_pi() -> None: + # Endpoints straddle the ±pi branch cut (+170 deg -> -170 deg). The physical + # change is +20 deg; without periodicity wrapping the linear q-path would take + # the ~340 deg long way around instead of the short arc. + ric = RIC(_hooh(170.0), _hooh(190.0), ninterp=8, return_q=True) + qpath = ric.interpolate() + + tors = [i for i, name in enumerate(ric.coords.keys) if "tors" in name] + assert tors, "expected H-O-O-H to define a torsion coordinate" + for i in tors: + total = abs(qpath[-1, i] - qpath[0, i]) + assert 0 < total < np.pi diff --git a/tests/test_opt.py b/tests/test_opt.py new file mode 100644 index 0000000..1bed415 --- /dev/null +++ b/tests/test_opt.py @@ -0,0 +1,39 @@ +"""Tests for the node optimizers in mlfsm.opt.""" + +from __future__ import annotations + +import numpy as np +import pytest +from ase import Atoms +from ase.calculators.emt import EMT + +from mlfsm.coords import Redundant +from mlfsm.opt import InternalsOptimizer +from mlfsm.utils import load_xyz +from tests.conftest import EXAMPLE_REACTION + + +@pytest.fixture +def endpoints() -> tuple[Atoms, Atoms]: + return load_xyz(EXAMPLE_REACTION) + + +@pytest.mark.xfail(reason="InternalsOptimizer is under active development (see opt.py)", strict=False) +def test_internals_optimizer_relaxes_node(endpoints: tuple[Atoms, Atoms]) -> None: + """A single internal-coordinate relaxation step returns a finite energy and geometry. + + Marked xfail because the internal-coordinate optimizer is not yet considered + working; this records its status and will start passing (XPASS) once it is. + """ + reactant, product = endpoints + calc = EMT() + coords = Redundant(reactant, product) + optimizer = InternalsOptimizer(calc, "L-BFGS-B", maxiter=1, maxls=3, dmax=0.05) + optimizer.coordsobj = coords + + tangent = (product.get_positions() - reactant.get_positions()).flatten() + tangent /= np.linalg.norm(tangent) + + atomsf, energy, _nfev, _nit = optimizer.optimize(reactant, tangent) + assert np.isfinite(energy) + assert atomsf.get_positions().shape == reactant.get_positions().shape diff --git a/tests/test_script.py b/tests/test_script.py index e838da3..1e6ce22 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -1,39 +1,15 @@ -"""Test FSM script on a sample reaction using EMT calculator.""" +"""Smoke test: run the FSM end-to-end in-process with default RIC interpolation.""" -import os -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path - - -def test_fsm_script_diels_alder() -> None: - """Run fsm_example.py on the Diels-Alder example default parameters with the EMT calculator.""" - example_dir = Path("examples/data/06_diels_alder") - script_path = Path("examples/fsm_example.py") +from __future__ import annotations - with tempfile.TemporaryDirectory() as tmpdir: - # Copy the example into a temporary directory - rxn_dir = Path(tmpdir) / "06_diels_alder" - shutil.copytree(example_dir, rxn_dir) - - env = os.environ.copy() - env["PYTHONPATH"] = os.getcwd() - # Run the FSM script +from pathlib import Path - result = subprocess.run( - [sys.executable, str(script_path), str(rxn_dir), "--calculator", "emt", "--suffix", "test_fsm_script"], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) +from tests.conftest import run_fsm - print("STDOUT:", result.stdout) - print("STDERR:", result.stderr) - # Check that the script completed without error - assert result.returncode == 0 - assert "Gradient calls:" in result.stdout +def test_fsm_default_ric(reaction_dir: Path) -> None: + """run_fsm completes on Diels-Alder with default parameters and EMT, writing fsm.out.""" + run_fsm(reaction_dir, calculator="emt", interp="ric", nnodes_min=5, ninterp=20, suffix="script") + outdir = next(reaction_dir.glob("fsm_interp_*_script")) + assert (outdir / "fsm.out").is_file() + assert "Total gradient calls" in (outdir / "fsm.out").read_text() diff --git a/tests/test_stepsize.py b/tests/test_stepsize.py index ca15539..542a2e5 100644 --- a/tests/test_stepsize.py +++ b/tests/test_stepsize.py @@ -1,49 +1,15 @@ -"""Test FSM script on a sample reaction using EMT calculator.""" +"""Run the FSM in-process with an explicit Cartesian step size.""" -import os -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path - - -def test_fsm_script_diels_alder() -> None: - """Run fsm_example.py on the Diels-Alder example default parameters with the EMT calculator.""" - example_dir = Path("examples/data/06_diels_alder") - script_path = Path("examples/fsm_example.py") +from __future__ import annotations - with tempfile.TemporaryDirectory() as tmpdir: - # Copy the example into a temporary directory - rxn_dir = Path(tmpdir) / "06_diels_alder" - shutil.copytree(example_dir, rxn_dir) - - env = os.environ.copy() - env["PYTHONPATH"] = os.getcwd() - # Run the FSM script +from pathlib import Path - result = subprocess.run( - [ - sys.executable, - str(script_path), - str(rxn_dir), - "--calculator", - "emt", - "--suffix", - "test_fsm_script", - "--stepsize", - "0.2", - ], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) +from tests.conftest import run_fsm - print("STDOUT:", result.stdout) - print("STDERR:", result.stderr) - # Check that the script completed without error - assert result.returncode == 0 - assert "Gradient calls:" in result.stdout +def test_fsm_explicit_stepsize(reaction_dir: Path) -> None: + """run_fsm completes when the step size is set explicitly (overriding nnodes_min).""" + run_fsm(reaction_dir, calculator="emt", stepsize=0.2, ninterp=20, suffix="stepsize") + outdir = next(reaction_dir.glob("fsm_interp_*_stepsize")) + assert (outdir / "fsm.out").is_file() + assert "Total gradient calls" in (outdir / "fsm.out").read_text() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..3fa6855 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,37 @@ +"""Unit tests for mlfsm.utils input loading and numeric helpers.""" + +from __future__ import annotations + +import numpy as np +from ase.constraints import FixAtoms + +from mlfsm.utils import float_check, load_xyz, load_xyz_fixed +from tests.conftest import EXAMPLE_REACTION as DIELS_ALDER + + +def test_float_check_passthrough_float() -> None: + assert float_check(3.5) == 3.5 + + +def test_load_xyz_returns_two_aligned_structures() -> None: + reactant, product = load_xyz(DIELS_ALDER) + assert len(reactant) == len(product) == 16 + r_centroid = reactant.get_positions().mean(axis=0) + p_centroid = product.get_positions().mean(axis=0) + assert np.allclose(r_centroid, p_centroid, atol=1e-6) + + +def test_load_xyz_fixed_empty_matches_load_xyz() -> None: + r_plain, _ = load_xyz(DIELS_ALDER) + r_fixed, _ = load_xyz_fixed(DIELS_ALDER, fixed=np.array([], dtype=int)) + assert np.allclose(r_plain.get_positions(), r_fixed.get_positions()) + assert len(r_fixed.constraints) == 0 + + +def test_load_xyz_fixed_attaches_constraint() -> None: + fixed = np.array([0, 1, 2], dtype=int) + reactant, product = load_xyz_fixed(DIELS_ALDER, fixed=fixed) + for atoms in (reactant, product): + assert len(atoms.constraints) == 1 + assert isinstance(atoms.constraints[0], FixAtoms) + assert set(atoms.constraints[0].get_indices()) == set(fixed)