diff --git a/.gitignore b/.gitignore index 82f9275..80307c0 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,9 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +*.bas4 +*.bas2 +*.bas3 +*.bas5 +*.bas0 +*.bas1 diff --git a/mace.sh b/mace.sh new file mode 100755 index 0000000..2267401 --- /dev/null +++ b/mace.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +SCRIPT_PATH="$(dirname -- "${BASH_SOURCE[0]}")" +src="${SCRIPT_PATH}/mace" +venv="$src/.venv" + +need_setup=0 +if [ ! -d "$venv" ]; then + need_setup=1 +elif [ ! -x "$venv/bin/maceexttool" ]; then + need_setup=1 +fi + +if [ $need_setup -eq 1 ]; then + "$src"/install.sh +fi + +source "$venv"/bin/activate +maceexttool "$@" diff --git a/mace/README.md b/mace/README.md new file mode 100644 index 0000000..1cbaabd --- /dev/null +++ b/mace/README.md @@ -0,0 +1,39 @@ +# MACE ExtTool for ORCA + +Wrapper around MACE calculators to use ORCA's `otool_external` interface. Mirrors the UMA tool interface with standalone and server–client modes. + +- Suites: `mace-mp` (Materials Project) and `mace-omol` (OMOL foundation model) +- Extras: `dispersion` (MP only), `default_dtype` (`float32` or `float64`), optional `device`, and `head` for advanced MP heads. + +## Quick start + +- Standalone (single call per ORCA task): + - `./mace.sh -s mp -m medium-mpa-0 your_job_EXT.extinp.tmp` + - `./mace.sh -s omol your_job_EXT.extinp.tmp` + +- Server–client (faster for many calls in one ORCA job): + - Start: `./maceserver.sh -s mp -m medium-mpa-0 -n 2 -b 127.0.0.1:8888` + - Use in ORCA input: `progext ".../maceclient.sh"` (ORCA passes the temp input filepath) + +## Arguments + +Common: +- `-s, --suite`: `mp` or `omol` (default: `omol`) +- `-m, --model`: model spec or local path (MP: e.g. `medium-mpa-0`, `medium`, `small`; OMOL: `extra_large` or path) +- `--default-dtype`: `float32` (MD speed) or `float64` (opt accuracy) +- `--device`: `cpu`, `cuda`, etc. + +MP only: +- `--dispersion`: enable D3 dispersion (off by default) +- `--damping`, `--dispersion-xc`, `--dispersion-cutoff` advanced dispersion controls +- `--head`: MACE head selection for multi-head variants + +Server: +- `-b, --bind`: `host:port` (default: `127.0.0.1:8888`) +- `-n, --nthreads`: number of threads per server + +## Install + +- `cd mace && ./install.sh` +- This installs a venv, the wrapper, and tries to `pip install -e ../../mace` if the local MACE repo exists. Otherwise it relies on `mace-torch`. + diff --git a/mace/examples/README.md b/mace/examples/README.md new file mode 100644 index 0000000..3dac847 --- /dev/null +++ b/mace/examples/README.md @@ -0,0 +1,49 @@ +# MACE GOAT Examples (Custom Model) + +This folder shows how to run ORCA's global optimization tool (GOAT) using the MACE wrapper with a custom model file. + +Two variants are provided: +- Server–client (recommended for many external calls): `goat_water_mace_omol_server.inp` +- Standalone (simpler, slower for many calls): `goat_water_mace_omol_standalone.inp` + +Replace `/abs/path/to/your/MACE-omol-custom.model` with your actual model file path before running. + +## Server–Client (recommended) + +1) Start the MACE server in another terminal with your custom model: + +``` +../../maceserver.sh \ + -s omol \ + -m /abs/path/to/your/MACE-omol-custom.model \ + --default-dtype float64 \ + --device cpu \ + -n 4 \ + -b 127.0.0.1:8888 +``` + +2) Run ORCA on the provided input: + +``` +orca goat_water_mace_omol_server.inp > goat_water_mace_omol_server.out +``` + +- The input references `../../maceclient.sh` and points it to the server via `Ext_Params "-b 127.0.0.1:8888"`. +- This example uses `pal1` to avoid requiring MPI. If you have MPI, you can change to `pal4`. + +## Standalone (simple, no server) + +Run ORCA on the standalone input: + +``` +orca goat_water_mace_omol_standalone.inp > goat_water_mace_omol_standalone.out +``` + +- The input references `../../mace.sh` and passes the suite and custom model via `Ext_Params`. +- Recommended flags for optimization: `--default-dtype float64` and `--device cpu`. + +Notes +- Ensure ORCA is available on your PATH (or call it via absolute path). +- Use absolute paths for the custom model so ORCA can find it regardless of working directory. +- For Materials Project models (mp), change `-s omol` to `-s mp` and add flags like `--dispersion` or `--head mh0` as needed. + diff --git a/mace/examples/goat_water_mace_omol_server.inp b/mace/examples/goat_water_mace_omol_server.inp new file mode 100644 index 0000000..546f032 --- /dev/null +++ b/mace/examples/goat_water_mace_omol_server.inp @@ -0,0 +1,11 @@ +! extopt goat pal1 +%method + progext "../../maceclient.sh" + ext_params "-b 127.0.0.1:8888" +end +*xyz 0 1 + O 0.000000 0.000000 0.000000 + H 0.000000 0.000000 0.960000 + H 0.000000 0.750000 -0.240000 +* + diff --git a/mace/examples/goat_water_mace_omol_standalone.inp b/mace/examples/goat_water_mace_omol_standalone.inp new file mode 100644 index 0000000..febc6fa --- /dev/null +++ b/mace/examples/goat_water_mace_omol_standalone.inp @@ -0,0 +1,11 @@ +! extopt goat pal1 +%method + progext "../../mace.sh" + ext_params "-s omol -m /abs/path/to/your/MACE-omol-custom.model --default-dtype float64 --device cpu" +end +*xyz 0 1 + O 0.000000 0.000000 0.000000 + H 0.000000 0.000000 0.960000 + H 0.000000 0.750000 -0.240000 +* + diff --git a/mace/install.sh b/mace/install.sh new file mode 100755 index 0000000..7e6153a --- /dev/null +++ b/mace/install.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +#!/usr/bin/env bash +# Get the location of this script +SCRIPT_PATH="$(dirname -- "${BASH_SOURCE[0]}")" +# Name of the virtual environment +VENV=.venv + +set -e + +cd "$SCRIPT_PATH" + +choose_python() { + for exe in python3.12 python3.11 python3.10 python3 python; do + if command -v "$exe" >/dev/null 2>&1; then + if "$exe" -c 'import sys; raise SystemExit(int(sys.version_info< (3,10)))'; then + echo "$exe"; return 0 + fi + fi + done + echo ""; return 1 +} + +PYEXE="$(choose_python)" || { + echo "No suitable Python >=3.10 found to create venv. Please install Python 3.10+." >&2 + exit 1 +} +echo "Using Python interpreter: $PYEXE" + +# Reset venv +rm -rf "$VENV" 2>/dev/null || true +"$PYEXE" -m venv "$VENV" +source "$VENV/bin/activate" + +# Ensure modern pip/setuptools for editable installs with pyproject +python -m pip install --upgrade pip setuptools wheel + +# Install wrapper +pip install -e . + +# Try to install local MACE if available; otherwise rely on dependency +LOCAL_MACE_DIR="$(cd "$SCRIPT_PATH/../.." && pwd)/mace" +if [ -d "$LOCAL_MACE_DIR" ]; then + echo "Installing local MACE from $LOCAL_MACE_DIR" + pip install -e "$LOCAL_MACE_DIR" || echo "Warning: failed to install local MACE; ensure mace-torch is available." +else + echo "Local MACE repo not found. Using mace-torch from PyPI if available." +fi + +echo "Setup complete. Activate venv with: source $SCRIPT_PATH/$VENV/bin/activate" diff --git a/mace/pyproject.toml b/mace/pyproject.toml new file mode 100644 index 0000000..1be550a --- /dev/null +++ b/mace/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "maceexttool" +version = "0.1.0" +description = "MACE wrapper for ORCA's ExtTool interface (standalone/server-client)" +requires-python = ">=3.10" +dependencies = [ + "ase>=3.22.1", + "Flask>=3,<4", + "numpy<3.0.0,>=1.24", + "requests>=2,<3", + "waitress>=3,<4", + # Prefer local install, but allow PyPI fallback if available + "mace-torch>=0.3.14", +] + +[project.scripts] +maceexttool = "maceexttool.standalone:main" +maceserver = "maceexttool.server:main" +maceclient = "maceexttool.client:main" + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + diff --git a/mace/src/maceexttool/__init__.py b/mace/src/maceexttool/__init__.py new file mode 100644 index 0000000..f1f235b --- /dev/null +++ b/mace/src/maceexttool/__init__.py @@ -0,0 +1,4 @@ +__all__ = [ + "common", +] + diff --git a/mace/src/maceexttool/calculator.py b/mace/src/maceexttool/calculator.py new file mode 100644 index 0000000..28eb97c --- /dev/null +++ b/mace/src/maceexttool/calculator.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Optional + +from ase import Atoms + + +def init( + suite: str, + model: Optional[str] = None, + *, + device: str = "", + default_dtype: Optional[str] = None, + dispersion: bool = False, + damping: str = "bj", + dispersion_xc: str = "pbe", + dispersion_cutoff: Optional[float] = None, + head: Optional[str] = None, +): + """Initialize a MACE calculator based on suite and options. + + Returns an ASE calculator compatible object which can be attached to Atoms. + """ + # Lazy import to avoid heavy deps at import-time + from mace.calculators.foundations_models import mace_mp, mace_omol + from ase import units + + if suite == "mp": + kwargs = dict( + model=model, + device=device or ("cuda" if _torch_cuda_available() else "cpu"), + default_dtype=default_dtype or "float32", + dispersion=dispersion, + damping=damping, + dispersion_xc=dispersion_xc, + dispersion_cutoff=(dispersion_cutoff if dispersion_cutoff is not None else 40.0 * units.Bohr), + ) + if head: + kwargs["head"] = head + calc = mace_mp(**kwargs) + return calc + elif suite == "omol": + calc = mace_omol( + model=model, + device=device or ("cuda" if _torch_cuda_available() else "cpu"), + default_dtype=default_dtype or "float64", + ) + return calc + else: + raise ValueError(f"Unknown suite: {suite}. Expected 'mp' or 'omol'.") + + +def _torch_cuda_available() -> bool: + try: + import torch # type: ignore + + return torch.cuda.is_available() + except Exception: + return False diff --git a/mace/src/maceexttool/client.py b/mace/src/maceexttool/client.py new file mode 100644 index 0000000..1b4edc1 --- /dev/null +++ b/mace/src/maceexttool/client.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +import sys +from argparse import Namespace +from typing import Tuple + +import requests + +from maceexttool import common + + +def submit_mace(server_url: str, + atom_types: list[str], + coordinates: list[tuple[float, float, float]], + charge: int, + mult: int, + nthreads: int) -> Tuple[float, list[float]]: + payload = { + "atom_types": atom_types, + "coordinates": coordinates, + "charge": charge, + "mult": mult, + "nthreads": nthreads, + } + + try: + response = requests.post('http://' + server_url + "/calculate", json=payload) + response.raise_for_status() + except requests.exceptions.ConnectionError: + print("The server is probably not running.") + print("Please start the server with the maceserver.sh script.") + raise + except requests.exceptions.Timeout as timeout_err: + print("Request to MACE server timed out:", timeout_err) + raise + + data = response.json() + return data['energy'], data['gradient'] + + +def run(arglist: list[str]): + args: Namespace = common.cli_parse(arglist, mode=common.RunMode.Client) + + # read ORCA-generated input + xyzname, charge, mult, ncores, dograd = common.read_input(args.inputfile) + basename = xyzname.removesuffix(".xyz") + orca_engrad = basename + ".engrad" + + atom_types, coordinates = common.read_xyzfile(xyzname) + natoms = len(atom_types) + + energy, gradient = submit_mace(server_url=args.bind, + atom_types=atom_types, + coordinates=coordinates, + charge=charge, + mult=mult, + nthreads=ncores) + + common.write_engrad(orca_engrad, natoms, energy, dograd, gradient) + + +def main(): + run(sys.argv[1:]) + + +if __name__ == '__main__': + main() + diff --git a/mace/src/maceexttool/common.py b/mace/src/maceexttool/common.py new file mode 100644 index 0000000..b01738c --- /dev/null +++ b/mace/src/maceexttool/common.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import subprocess +from enum import StrEnum, auto +from argparse import ArgumentParser, Namespace +from pathlib import Path +from typing import Iterable +import os +import socket +import numpy as np +from ase import Atoms + + +# Energy and length conversion to atomic units (same as UMA) +ENERGY_CONVERSION = {"eV": 27.21138625} +LENGTH_CONVERSION = {"Ang": 0.529177210903} + + +def strip_comments(s: str) -> str: + return s.split("#")[0].strip() + + +def enforce_path_object(fname: str | Path) -> Path: + if isinstance(fname, str): + return Path(fname) + elif isinstance(fname, Path): + return fname + else: + raise TypeError("Input must be a string or a Path object.") + + +def read_input(inpfile: str | Path) -> tuple[str, int, int, int, bool]: + inpfile = enforce_path_object(inpfile) + with inpfile.open() as f: + xyzname = strip_comments(f.readline()) + charge = int(strip_comments(f.readline())) + mult = int(strip_comments(f.readline())) + ncores = int(strip_comments(f.readline())) + dograd = bool(int(strip_comments(f.readline()))) + return xyzname, charge, mult, ncores, dograd + + +def write_engrad( + outfile: str | Path, + natoms: int, + energy: float, + dograd: bool, + gradient: Iterable[float] = None, +) -> None: + outfile = enforce_path_object(outfile) + with outfile.open("w") as f: + output = "#\n" + output += "# Number of atoms\n" + output += "#\n" + output += f"{natoms}\n" + output += "#\n" + output += "# Total energy [Eh]\n" + output += "#\n" + output += f"{energy:.12e}\n" + if dograd: + output += "#\n" + output += "# Gradient [Eh/Bohr] A1X, A1Y, A1Z, A2X, ...\n" + output += "#\n" + output += "\n".join(f"{g: .12e}" for g in gradient) + "\n" + f.write(output) + + +def run_command(command: str | Path, outname: str | Path, *args: tuple[str, ...]) -> None: + command = enforce_path_object(command) + outname = enforce_path_object(outname) + with outname.open("w") as of: + try: + subprocess.run( + [command] + list(args), stdout=of, stderr=subprocess.STDOUT, check=True + ) + except subprocess.CalledProcessError as err: + print(err) + exit(err.returncode) + + +def clean_output(outfile: str | Path, namespace: str) -> None: + outfile = enforce_path_object(outfile) + with outfile.open() as f: + for line in f: + print(line, end="") + for f in Path(".").glob(namespace + "*"): + f.unlink() + + +def read_xyzfile(xyzname: str | Path) -> tuple[list[str], list[tuple[float, float, float]]]: + atom_types: list[str] = [] + coordinates: list[tuple[float, float, float]] = [] + xyzname = enforce_path_object(xyzname) + with xyzname.open() as xyzf: + natoms = int(xyzf.readline().strip()) + xyzf.readline() + for _ in range(natoms): + line = xyzf.readline() + if not line: + break + parts = line.split() + atom_types.append(parts[0]) + coords = tuple(float(c) for c in parts[1:4]) + coordinates.append(coords) + return atom_types, coordinates + + +def process_output(atoms: Atoms) -> tuple[float, list[float]]: + """Convert ASE outputs (eV, Ang) to ORCA units (Eh, Eh/Bohr).""" + energy = atoms.get_potential_energy() / ENERGY_CONVERSION["eV"] + gradient: list[float] = [] + try: + forces = atoms.get_forces() + fac = -LENGTH_CONVERSION["Ang"] / ENERGY_CONVERSION["eV"] + gradient = (fac * np.asarray(forces)).flatten().tolist() + except Exception: + pass + return energy, gradient + + +class RunMode(StrEnum): + Server = auto() + Client = auto() + Standalone = auto() + + +ProgName = { + RunMode.Server: "maceserver", + RunMode.Client: "maceclient", + RunMode.Standalone: "maceexttool", +} + + +def is_port_available(host: str, port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + result = sock.connect_ex((host, port)) + return result != 0 + + +def get_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("", 0)) + return sock.getsockname()[1] + + +def cli_parse(args: list[str], mode: RunMode) -> Namespace: + """Parse CLI for MACE wrapper; options differ slightly by mode.""" + parser = ArgumentParser( + prog=ProgName[mode], + description=f'ORCA "external tool" interface for MACE calculations ({mode} mode)' + ) + + if mode in (RunMode.Standalone, RunMode.Client): + parser.add_argument("inputfile", help="ORCA-generated input file.") + + # Common MACE options (for Standalone and Server) + if mode in (RunMode.Standalone, RunMode.Server): + parser.add_argument( + "-s", "--suite", + choices=["mp", "omol", "mace-mp", "mace-omol"], + default="omol", + help="Select MACE suite: mp/mace-mp or omol/mace-omol. Default: omol" + ) + parser.add_argument( + "-m", "--model", + type=str, + default=None, + help="Model spec or local path. MP: small/medium/large/medium-mpa-0/... OMOL: extra_large or path." + ) + parser.add_argument( + "--default-dtype", + choices=["float32", "float64"], + default=None, + help="Default float precision (recommended: float64 for opt, float32 for MD)." + ) + parser.add_argument( + "--device", + type=str, + default="", + help="Device string for torch/ASE calculator (e.g., cuda, cpu)." + ) + # MP-specific extras + parser.add_argument( + "--dispersion", + action="store_true", + help="Enable D3 dispersion (MP suite only)." + ) + parser.add_argument("--damping", type=str, default="bj", + help="D3 damping (zero,bj,zerom,bjm). MP only.") + parser.add_argument("--dispersion-xc", type=str, default="pbe", + help="XC functional for D3. MP only.") + parser.add_argument("--dispersion-cutoff", type=float, default=None, + help="Cutoff radius for D3 in Bohr (default: 40 Bohr). MP only.") + parser.add_argument("--head", type=str, default=None, + help="Advanced: select MACE head (MP only).") + + if mode in RunMode.Server: + parser.add_argument( + "-b", "--bind", metavar="hostname:port", default="127.0.0.1:8888", + help="Server bind address and port. Default: 127.0.0.1:8888." + ) + if mode is RunMode.Client: + default_bind = os.getenv("MACE_BIND", "127.0.0.1:8888") + parser.add_argument( + "-b", "--bind", metavar="hostname:port", default=default_bind, + help="Server bind address and port." + ) + if mode is RunMode.Server: + parser.add_argument("-n", "--nthreads", metavar="N", type=int, default=1, + help="Number of threads to use. Default: 1") + + parsed = parser.parse_args(args) + + if mode is RunMode.Server: + try: + host, port = parsed.bind.split(":") + port = int(port) + except ValueError: + parser.error("Invalid --bind format. Use host:port") + if not is_port_available(host, port): + print(f"Port {port} on {host} is already in use. Selecting a free one...") + port = get_free_port() + parsed.bind = f"{host}:{port}" + os.system(f"export MACE_BIND={host}:{port}") + print(f"Using new port: {parsed.bind}") + + return parsed diff --git a/mace/src/maceexttool/server.py b/mace/src/maceexttool/server.py new file mode 100644 index 0000000..8422616 --- /dev/null +++ b/mace/src/maceexttool/server.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import logging +import threading +import sys +from typing import Callable + +import torch +from flask import Flask, request, jsonify +from ase import Atoms + +from maceexttool import common, calculator + +app = Flask('maceserver') + +# Global configuration chosen at server startup +_suite: str = 'omol' +_model: str | None = None +_default_dtype: str | None = None +_device: str = '' +_dispersion: bool = False +_damping: str = 'bj' +_dispersion_xc: str = 'pbe' +_dispersion_cutoff: float | None = None +_head: str | None = None + +# One calculator per server thread +calculators: dict[int | Callable] = {} + + +@app.route('/calculate', methods=['POST']) +def run_mace(): + input = request.get_json() + + atoms = Atoms(symbols=input["atom_types"], positions=input["coordinates"]) + atoms.info = {"charge": input["charge"], "spin": input["mult"]} + + nthreads = input.get('nthreads', 1) + torch.set_num_threads(nthreads) + + thread_id = threading.get_ident() + global calculators + if thread_id not in calculators: + calculators[thread_id] = calculator.init( + suite=_suite, + model=_model, + device=_device, + default_dtype=_default_dtype, + dispersion=_dispersion, + damping=_damping, + dispersion_xc=_dispersion_xc, + dispersion_cutoff=_dispersion_cutoff, + head=_head, + ) + calc = calculators[thread_id] + atoms.calc = calc + + energy, gradient = common.process_output(atoms) + return jsonify({'energy': energy, 'gradient': gradient}) + + +def run(arglist: list[str]): + args = common.cli_parse(arglist, mode=common.RunMode.Server) + + global _suite, _model, _default_dtype, _device, _dispersion, _damping, _dispersion_xc, _dispersion_cutoff, _head + _suite = args.suite + if _suite.startswith("mace-"): + _suite = _suite.split("-", 1)[1] + _model = args.model + _default_dtype = args.default_dtype or ("float64" if _suite == "omol" else "float32") + _device = args.device + _dispersion = bool(args.dispersion) + _damping = args.damping + _dispersion_xc = args.dispersion_xc + _dispersion_cutoff = args.dispersion_cutoff + _head = args.head + + # Try waitress; fallback to Flask dev server for testing if waitress missing + try: + import waitress # type: ignore + logger = logging.getLogger('waitress') + logger.setLevel(logging.INFO) + waitress.serve(app, listen=args.bind, threads=args.nthreads) + except Exception: + host, port = args.bind.split(":") + port = int(port) + app.run(host=host, port=port, threaded=True) + + +def main(): + run(sys.argv[1:]) + + +if __name__ == '__main__': + main() diff --git a/mace/src/maceexttool/standalone.py b/mace/src/maceexttool/standalone.py new file mode 100644 index 0000000..35de2b1 --- /dev/null +++ b/mace/src/maceexttool/standalone.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import sys +import time + +import torch +from ase import Atoms + +from maceexttool import common, calculator + + +def run_mace( + atom_types: list[str], + coordinates: list[tuple[float, float, float]], + charge: int, + mult: int, + suite: str, + model: str | None, + dograd: bool, + nthreads: int, + default_dtype: str | None, + device: str, + dispersion: bool, + damping: str, + dispersion_xc: str, + dispersion_cutoff: float | None, + head: str | None, +) -> tuple[float, list[float]]: + """Run a MACE calculation and return energy and gradient in ORCA units.""" + calc = calculator.init( + suite=suite, + model=model, + device=device, + default_dtype=default_dtype, + dispersion=dispersion, + damping=damping, + dispersion_xc=dispersion_xc, + dispersion_cutoff=dispersion_cutoff, + head=head, + ) + + torch.set_num_threads(nthreads) + + atoms = Atoms(symbols=atom_types, positions=coordinates) + atoms.info = {"charge": charge, "spin": mult} + atoms.calc = calc + + return common.process_output(atoms) + + +def run(arglist: list[str]): + args = common.cli_parse(arglist, mode=common.RunMode.Standalone) + + # Canonicalize suite and set defaults + suite = args.suite + if suite.startswith("mace-"): + suite = suite.split("-", 1)[1] + default_dtype = args.default_dtype + if default_dtype is None: + default_dtype = "float64" if suite == "omol" else "float32" + + # read ORCA input + xyzname, charge, mult, ncores, dograd = common.read_input(args.inputfile) + basename = xyzname.removesuffix(".xyz") + orca_engrad = basename + ".engrad" + + atom_types, coordinates = common.read_xyzfile(xyzname) + natoms = len(atom_types) + + start_time = time.perf_counter() + energy, gradient = run_mace( + atom_types=atom_types, + coordinates=coordinates, + charge=charge, + mult=mult, + suite=suite, + model=args.model, + dograd=dograd, + nthreads=ncores, + default_dtype=default_dtype, + device=args.device, + dispersion=args.dispersion, + damping=args.damping, + dispersion_xc=args.dispersion_xc, + dispersion_cutoff=args.dispersion_cutoff, + head=args.head, + ) + + common.write_engrad(orca_engrad, natoms, energy, dograd, gradient) + print("Total time: {:6.3f} seconds".format(time.perf_counter() - start_time)) + + +def main(): + run(sys.argv[1:]) + + +if __name__ == "__main__": + main() diff --git a/mace/test/.gitignore b/mace/test/.gitignore new file mode 100644 index 0000000..4bf3485 --- /dev/null +++ b/mace/test/.gitignore @@ -0,0 +1,16 @@ +# Cache folder for test artifacts +.cache/ + +# ORCA + wrapper outputs +*.out +*.serverout +*.lastext +*.engrad +*.extinp.tmp +*.tmp +pid.txt +s.out + +# Generated XYZ files from ORCA (tracked inputs remain tracked) +*.xyz + diff --git a/mace/test/H2O_goat_extclient.inp b/mace/test/H2O_goat_extclient.inp new file mode 100644 index 0000000..d20336d --- /dev/null +++ b/mace/test/H2O_goat_extclient.inp @@ -0,0 +1,10 @@ +! extopt goat pal4 +%method + progext "../../maceclient.sh" +end +*xyz 0 1 + O 0.000000 0.000000 0.000000 + H 0.000000 0.000000 0.960000 + H 0.000000 0.750000 -0.240000 +* + diff --git a/mace/test/H2O_goat_extclient.property.txt b/mace/test/H2O_goat_extclient.property.txt new file mode 100644 index 0000000..59459a9 --- /dev/null +++ b/mace/test/H2O_goat_extclient.property.txt @@ -0,0 +1,3 @@ +************************************************* +******************* ORCA 6.1.0 ****************** +************************************************* diff --git a/mace/test/HF.xyz b/mace/test/HF.xyz new file mode 100644 index 0000000..e6c48e8 --- /dev/null +++ b/mace/test/HF.xyz @@ -0,0 +1,5 @@ +2 + +H 0 0 0 +F 0 0 0.9 + diff --git a/mace/test/HF_exttool.inp b/mace/test/HF_exttool.inp new file mode 100644 index 0000000..b9dd279 --- /dev/null +++ b/mace/test/HF_exttool.inp @@ -0,0 +1,6 @@ +HF.xyz +0 +1 +1 +1 + diff --git a/mace/test/HF_orca_ext.bibtex b/mace/test/HF_orca_ext.bibtex new file mode 100644 index 0000000..1f5378c --- /dev/null +++ b/mace/test/HF_orca_ext.bibtex @@ -0,0 +1,84 @@ +@article{RN269, +author = {Neese,F.}, +title = {Software update: the ORCA program system, version 6.0}, +journal = {WIRES Comput. Molec. Sci.}, +volume = {15}, +number = {1}, +pages = {e70019}, +DOI = {10.1002/wcms.7019}, +year = {2025}, +type = {journal Article} +} + +@article{RN232, +author = {Neese,F.}, +title = {The SHARK Integral Generation and Digestion System}, +journal = {J. Comp. Chem.}, +volume = {44}, +number = {3}, +pages = {381}, +DOI = {10.1002/jcc.26942}, +year = {2022}, +type = {journal Article} +} + +@article{RN95, +author = {Neese,F.}, +title = {The ORCA program system}, +journal = {WIRES Comput. Molec. Sci.}, +volume = {2}, +number = {1}, +pages = {73-78}, +DOI = {10.1002/wcms.81}, +year = {2012}, +type = {journal Article} +} + +@article{RN157, +author = {Neese,F.}, +title = {Software update: the ORCA program system, version 4.0}, +journal = {WIRES Comput. Molec. Sci.}, +volume = {8}, +number = {1}, +pages = {1-6}, +DOI = {10.1002/wcms.1327}, +year = {2018}, +type = {journal Article} +} + +@article{RN204, +author = {Neese,F. and Wennmohs,F. and Becker,U. and Riplinger,C.}, +title = {The ORCA quantum chemistry program package}, +journal = {J. Chem. Phys.}, +volume = {152}, +number = {22}, +pages = {224108}, +DOI = {10.1063/5.0004608}, +year = {2020}, +type = {journal Article} +} + +@article{RN231, +author = {Neese,F.}, +title = {Software update: The ORCA program system—Version 5.0}, +journal = {WIRES Comput. Molec. Sci.}, +volume = {12}, +number = {1}, +pages = {e1606}, +DOI = {10.1002/wcms.1606}, +year = {2022}, +type = {journal Article} +} + +@article{RN24, +author = {Neese,F.}, +title = {Approximate second-order SCF convergence for spin unrestricted wavefunctions}, +journal = {Chem. Phys. Lett.}, +volume = {325}, +number = {1-3}, +pages = {93-98}, +DOI = {10.1016/s0009-2614(00)00662-x}, +year = {2000}, +type = {journal Article} +} + diff --git a/mace/test/HF_orca_ext.inp b/mace/test/HF_orca_ext.inp new file mode 100644 index 0000000..6c21cb7 --- /dev/null +++ b/mace/test/HF_orca_ext.inp @@ -0,0 +1,9 @@ +! extopt opt +%method + progext "../../mace.sh" +end +*xyz 0 1 + H 0 0 0 + F 0 0 0.9 +* + diff --git a/mace/test/HF_orca_ext.opt b/mace/test/HF_orca_ext.opt new file mode 100644 index 0000000..3ece88a Binary files /dev/null and b/mace/test/HF_orca_ext.opt differ diff --git a/mace/test/HF_orca_ext.property.txt b/mace/test/HF_orca_ext.property.txt new file mode 100644 index 0000000..70b1294 --- /dev/null +++ b/mace/test/HF_orca_ext.property.txt @@ -0,0 +1,83 @@ +************************************************* +******************* ORCA 6.1.0 ****************** +************************************************* +$Calculation_Status + &GeometryIndex 0 + &version [&Type "String"] "6.1.0" + &progName [&Type "String"] "orca" + &Status [&Type "String"] "NORMAL TERMINATION" +$End +$Geometry + &GeometryIndex 1 + &NAtoms [&Type "Integer"] 2 + &NCorelessECP [&Type "Integer"] 0 + &NGhostAtoms [&Type "Integer"] 0 + &CartesianCoordinates [&Type "Coordinates", &Dim(2,4), &Units "Bohr"] + H 0.000000000000 0.000000000000 0.000000000000 + F 0.000000000000 0.000000000000 1.700753520529 +$End +$Single_Point_Data + &GeometryIndex 1 + &FinalEnergy [&Type "Double"] -1.0046146779919999e+02 "Final single point energy" + &Converged [&Type "Boolean"] true +$End +$Geometry + &GeometryIndex 2 + &NAtoms [&Type "Integer"] 2 + &NCorelessECP [&Type "Integer"] 0 + &NGhostAtoms [&Type "Integer"] 0 + &CartesianCoordinates [&Type "Coordinates", &Dim(2,4), &Units "Bohr"] + H 0.000000000000 0.000000000000 -0.024335017042 + F 0.000000000000 0.000000000000 1.725088537571 +$End +$Single_Point_Data + &GeometryIndex 2 + &FinalEnergy [&Type "Double"] -1.0046209305660000e+02 "Final single point energy" + &Converged [&Type "Boolean"] true +$End +$Geometry + &GeometryIndex 3 + &NAtoms [&Type "Integer"] 2 + &NCorelessECP [&Type "Integer"] 0 + &NGhostAtoms [&Type "Integer"] 0 + &CartesianCoordinates [&Type "Coordinates", &Dim(2,4), &Units "Bohr"] + H 0.000000000000 0.000000000000 -0.022329656513 + F 0.000000000000 0.000000000000 1.723083177042 +$End +$Single_Point_Data + &GeometryIndex 3 + &FinalEnergy [&Type "Double"] -1.0046209882930000e+02 "Final single point energy" + &Converged [&Type "Boolean"] true +$End +$Geometry + &GeometryIndex 4 + &NAtoms [&Type "Integer"] 2 + &NCorelessECP [&Type "Integer"] 0 + &NGhostAtoms [&Type "Integer"] 0 + &CartesianCoordinates [&Type "Coordinates", &Dim(2,4), &Units "Bohr"] + H 0.000000000000 0.000000000000 -0.022135144768 + F 0.000000000000 0.000000000000 1.722888665297 +$End +$Single_Point_Data + &GeometryIndex 4 + &FinalEnergy [&Type "Double"] -1.0046209887430000e+02 "Final single point energy" + &Converged [&Type "Boolean"] true +$End +$Calculation_Info + &GeometryIndex 4 + &Mult [&Type "Integer"] 1 + &Charge [&Type "Integer"] 0 + &NumOfAtoms [&Type "Integer"] 2 + &NumOfElectrons [&Type "Integer"] 0 + &NumOfBasisFuncts [&Type "Integer"] 0 + &NumOfAuxCBasisFuncts [&Type "Integer"] 0 + &NumOfAuxJBasisFuncts [&Type "Integer"] 0 + &NumOfAuxJKBasisFuncts [&Type "Integer"] 0 + &NumOfCABSBasisFuncts [&Type "Integer"] 0 +$End +$Calculation_Timings + &GeometryIndex 4 + &GSTEP [&Type "Double"] 3.9419999999994321e-03 + &EXT [&Type "Double"] 9.4839280000000006e+00 + &SUM [&Type "Double"] 9.4878699999999991e+00 +$End diff --git a/mace/test/HF_orca_extclient.bibtex b/mace/test/HF_orca_extclient.bibtex new file mode 100644 index 0000000..1f5378c --- /dev/null +++ b/mace/test/HF_orca_extclient.bibtex @@ -0,0 +1,84 @@ +@article{RN269, +author = {Neese,F.}, +title = {Software update: the ORCA program system, version 6.0}, +journal = {WIRES Comput. Molec. Sci.}, +volume = {15}, +number = {1}, +pages = {e70019}, +DOI = {10.1002/wcms.7019}, +year = {2025}, +type = {journal Article} +} + +@article{RN232, +author = {Neese,F.}, +title = {The SHARK Integral Generation and Digestion System}, +journal = {J. Comp. Chem.}, +volume = {44}, +number = {3}, +pages = {381}, +DOI = {10.1002/jcc.26942}, +year = {2022}, +type = {journal Article} +} + +@article{RN95, +author = {Neese,F.}, +title = {The ORCA program system}, +journal = {WIRES Comput. Molec. Sci.}, +volume = {2}, +number = {1}, +pages = {73-78}, +DOI = {10.1002/wcms.81}, +year = {2012}, +type = {journal Article} +} + +@article{RN157, +author = {Neese,F.}, +title = {Software update: the ORCA program system, version 4.0}, +journal = {WIRES Comput. Molec. Sci.}, +volume = {8}, +number = {1}, +pages = {1-6}, +DOI = {10.1002/wcms.1327}, +year = {2018}, +type = {journal Article} +} + +@article{RN204, +author = {Neese,F. and Wennmohs,F. and Becker,U. and Riplinger,C.}, +title = {The ORCA quantum chemistry program package}, +journal = {J. Chem. Phys.}, +volume = {152}, +number = {22}, +pages = {224108}, +DOI = {10.1063/5.0004608}, +year = {2020}, +type = {journal Article} +} + +@article{RN231, +author = {Neese,F.}, +title = {Software update: The ORCA program system—Version 5.0}, +journal = {WIRES Comput. Molec. Sci.}, +volume = {12}, +number = {1}, +pages = {e1606}, +DOI = {10.1002/wcms.1606}, +year = {2022}, +type = {journal Article} +} + +@article{RN24, +author = {Neese,F.}, +title = {Approximate second-order SCF convergence for spin unrestricted wavefunctions}, +journal = {Chem. Phys. Lett.}, +volume = {325}, +number = {1-3}, +pages = {93-98}, +DOI = {10.1016/s0009-2614(00)00662-x}, +year = {2000}, +type = {journal Article} +} + diff --git a/mace/test/HF_orca_extclient.gbw b/mace/test/HF_orca_extclient.gbw new file mode 100644 index 0000000..6a96a2e Binary files /dev/null and b/mace/test/HF_orca_extclient.gbw differ diff --git a/mace/test/HF_orca_extclient.inp b/mace/test/HF_orca_extclient.inp new file mode 100644 index 0000000..ab127ef --- /dev/null +++ b/mace/test/HF_orca_extclient.inp @@ -0,0 +1,9 @@ +! extopt opt +%method + progext "../../maceclient.sh" +end +*xyz 0 1 + H 0 0 0 + F 0 0 0.9 +* + diff --git a/mace/test/HF_orca_extclient.opt b/mace/test/HF_orca_extclient.opt new file mode 100644 index 0000000..3ece88a Binary files /dev/null and b/mace/test/HF_orca_extclient.opt differ diff --git a/mace/test/HF_orca_extclient.property.txt b/mace/test/HF_orca_extclient.property.txt new file mode 100644 index 0000000..f6aaf56 --- /dev/null +++ b/mace/test/HF_orca_extclient.property.txt @@ -0,0 +1,83 @@ +************************************************* +******************* ORCA 6.1.0 ****************** +************************************************* +$Calculation_Status + &GeometryIndex 0 + &version [&Type "String"] "6.1.0" + &progName [&Type "String"] "orca" + &Status [&Type "String"] "NORMAL TERMINATION" +$End +$Geometry + &GeometryIndex 1 + &NAtoms [&Type "Integer"] 2 + &NCorelessECP [&Type "Integer"] 0 + &NGhostAtoms [&Type "Integer"] 0 + &CartesianCoordinates [&Type "Coordinates", &Dim(2,4), &Units "Bohr"] + H 0.000000000000 0.000000000000 0.000000000000 + F 0.000000000000 0.000000000000 1.700753520529 +$End +$Single_Point_Data + &GeometryIndex 1 + &FinalEnergy [&Type "Double"] -1.0046146779919999e+02 "Final single point energy" + &Converged [&Type "Boolean"] true +$End +$Geometry + &GeometryIndex 2 + &NAtoms [&Type "Integer"] 2 + &NCorelessECP [&Type "Integer"] 0 + &NGhostAtoms [&Type "Integer"] 0 + &CartesianCoordinates [&Type "Coordinates", &Dim(2,4), &Units "Bohr"] + H 0.000000000000 0.000000000000 -0.024335017042 + F 0.000000000000 0.000000000000 1.725088537571 +$End +$Single_Point_Data + &GeometryIndex 2 + &FinalEnergy [&Type "Double"] -1.0046209305660000e+02 "Final single point energy" + &Converged [&Type "Boolean"] true +$End +$Geometry + &GeometryIndex 3 + &NAtoms [&Type "Integer"] 2 + &NCorelessECP [&Type "Integer"] 0 + &NGhostAtoms [&Type "Integer"] 0 + &CartesianCoordinates [&Type "Coordinates", &Dim(2,4), &Units "Bohr"] + H 0.000000000000 0.000000000000 -0.022329656513 + F 0.000000000000 0.000000000000 1.723083177042 +$End +$Single_Point_Data + &GeometryIndex 3 + &FinalEnergy [&Type "Double"] -1.0046209882930000e+02 "Final single point energy" + &Converged [&Type "Boolean"] true +$End +$Geometry + &GeometryIndex 4 + &NAtoms [&Type "Integer"] 2 + &NCorelessECP [&Type "Integer"] 0 + &NGhostAtoms [&Type "Integer"] 0 + &CartesianCoordinates [&Type "Coordinates", &Dim(2,4), &Units "Bohr"] + H 0.000000000000 0.000000000000 -0.022135144768 + F 0.000000000000 0.000000000000 1.722888665297 +$End +$Single_Point_Data + &GeometryIndex 4 + &FinalEnergy [&Type "Double"] -1.0046209887430000e+02 "Final single point energy" + &Converged [&Type "Boolean"] true +$End +$Calculation_Info + &GeometryIndex 4 + &Mult [&Type "Integer"] 1 + &Charge [&Type "Integer"] 0 + &NumOfAtoms [&Type "Integer"] 2 + &NumOfElectrons [&Type "Integer"] 0 + &NumOfBasisFuncts [&Type "Integer"] 0 + &NumOfAuxCBasisFuncts [&Type "Integer"] 0 + &NumOfAuxJBasisFuncts [&Type "Integer"] 0 + &NumOfAuxJKBasisFuncts [&Type "Integer"] 0 + &NumOfCABSBasisFuncts [&Type "Integer"] 0 +$End +$Calculation_Timings + &GeometryIndex 4 + &GSTEP [&Type "Double"] 2.4739999999999901e-03 + &EXT [&Type "Double"] 1.1125980000000000e+00 + &SUM [&Type "Double"] 1.1150720000000001e+00 +$End diff --git a/mace/test/run_tests.sh b/mace/test/run_tests.sh new file mode 100755 index 0000000..6d10fca --- /dev/null +++ b/mace/test/run_tests.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# Output directory for artifacts (can override with OUTDIR=/path) +OUTDIR="${OUTDIR:-.cache}" +mkdir -p "$OUTDIR" + +# Direct execution of standalone wrapper script +echo "These tests might take a few minutes." +echo "To check the progress, please see the respective outputs." +cmd="../../mace.sh HF_exttool.inp > \"$OUTDIR\"/HF_exttool.out" +echo "Test command: ${cmd}" +eval $cmd +mv -f ./*.engrad "$OUTDIR" 2>/dev/null || true + +# Check if ORCA is available; if not, skip ORCA-based tests +ORCA_CMD="" +if [ -n "${ORCA:-}" ] && [ -x "${ORCA}" ]; then + ORCA_CMD="${ORCA}" +elif command -v orca >/dev/null 2>&1 ; then + ORCA_CMD="$(command -v orca)" +fi + +# Check if ORCA is available; if not, skip ORCA-based tests +if [ -z "$ORCA_CMD" ]; then + echo "ORCA not found. Set ORCA env var or add to PATH. Skipping ORCA-based tests (orca_ext, client, GOAT)." + exit 0 +fi + +# Execution of standalone wrapper script via ORCA optimization +cmd="\"$ORCA_CMD\" HF_orca_ext.inp > \"$OUTDIR\"/HF_orca_ext.out" +echo "Test command: ${cmd}" +eval $cmd + +# Server/client test via ORCA +# - function that kills the server on exit +killserver(){ + cmd="killall maceserver" + echo "Stopping server: ${cmd}" + eval $cmd +} +trap "killserver; exit" INT TERM EXIT +# - start the server +sf="$OUTDIR/HF_orca_extclient.serverout" +cmd="../../maceserver.sh > \"$sf\" 2>&1 &" +echo "Starting server: ${cmd}" +eval $cmd +# - initialize the output file +of="$OUTDIR/HF_orca_extclient.out" +> "$of" +# - wait for the server to start +WAITED=0 +while [ -z "$(grep -E "Serving|Running on" "$sf")" ]; do echo "Waiting for server" >> "$of"; sleep 1s; WAITED=$((WAITED+1)); if [ $WAITED -gt 30 ]; then echo "Timeout waiting for server" >> "$of"; break; fi; done +# - start the ORCA job +cmd="\"$ORCA_CMD\" HF_orca_extclient.inp >> \"$of\"" +echo "Test command: ${cmd}" +eval $cmd + +# stop the server between tests +killall maceserver || true + +# Parallel server/client GOAT test +# - start the server (4 threads) +sf="$OUTDIR/H2O_goat_extclient.serverout" +cmd="../../maceserver.sh -n 4 > \"$sf\" 2>&1 &" +echo "Starting server: ${cmd}" +eval $cmd +# - initialize the output file +of="$OUTDIR/H2O_goat_extclient.out" +> "$of" +# - wait for the server to start +WAITED=0 +while [ -z "$(grep -E "Serving|Running on" "$sf")" ]; do echo "Waiting for server" >> "$of"; sleep 1s; WAITED=$((WAITED+1)); if [ $WAITED -gt 30 ]; then echo "Timeout waiting for server" >> "$of"; break; fi; done +# - start the ORCA job +cmd="\"$ORCA_CMD\" H2O_goat_extclient.inp >> \"$of\"" +echo "Test command: ${cmd}" +eval $cmd diff --git a/mace/tests/conftest.py b/mace/tests/conftest.py new file mode 100644 index 0000000..38fcb0e --- /dev/null +++ b/mace/tests/conftest.py @@ -0,0 +1,11 @@ +import os +import sys +from pathlib import Path + + +# Ensure local src is importable as maceexttool +SRC = Path(__file__).resolve().parents[1] / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +# No dummy calculator: use real MACE in integration tests diff --git a/mace/tests/test_mace_exttool.py b/mace/tests/test_mace_exttool.py new file mode 100644 index 0000000..e4e203a --- /dev/null +++ b/mace/tests/test_mace_exttool.py @@ -0,0 +1,209 @@ +import io +import os +from pathlib import Path + +import numpy as np +import pytest +from ase import Atoms +from ase.calculators.calculator import Calculator, all_changes + + +EV_TO_EH = 1.0 / 27.21138625 +ANG_TO_BOHR = 0.529177210903 +FORCE_TO_GRAD = -ANG_TO_BOHR / 27.21138625 + + +class DummyCalc(Calculator): + implemented_properties = ["energy", "forces"] + + def __init__(self, energy_eV: float, forces_eVA: np.ndarray): + super().__init__() + self._energy = float(energy_eV) + self._forces = np.array(forces_eVA, dtype=float) + + def calculate(self, atoms=None, properties=("energy", "forces"), system_changes=all_changes): + super().calculate(atoms, properties, system_changes) + self.results["energy"] = self._energy + self.results["forces"] = self._forces + + +def write_extinp(tmpdir: Path, name: str, symbols: list[str], coords: list[tuple[float, float, float]], charge=0, mult=1, ncores=1, dograd=1) -> Path: + xyz = tmpdir / f"{name}_EXT.xyz" + with xyz.open("w") as fh: + fh.write(f"{len(symbols)}\n") + fh.write("generated by test\n") + for s, (x, y, z) in zip(symbols, coords): + fh.write(f"{s} {x} {y} {z}\n") + + extinp = tmpdir / f"{name}_EXT.extinp.tmp" + with extinp.open("w") as fh: + fh.write(str(xyz) + "\n") + fh.write(str(charge) + "\n") + fh.write(str(mult) + "\n") + fh.write(str(ncores) + "\n") + fh.write(str(dograd) + "\n") + return extinp + + +def read_engrad(path: Path): + with path.open() as fh: + lines = [l.strip() for l in fh.readlines()] + # lines structure: comments + natoms + comments + energy + [grad] + # find the first numeric after the "Total energy" header + energy_idx = None + for i, l in enumerate(lines): + if l.startswith("# Total energy"): + energy_idx = i + 2 # next non-comment is energy line + break + assert energy_idx is not None + energy = float(lines[energy_idx]) + # gradient lines are all numeric after the gradient header + grad = [] + grad_header_idx = None + for i, l in enumerate(lines): + if l.startswith("# Gradient"): + grad_header_idx = i + 2 + break + if grad_header_idx is not None: + for l in lines[grad_header_idx:]: + if not l or l.startswith("#"): + break + grad.append(float(l)) + return energy, grad + + +def test_standalone_omol_basic(tmp_path, monkeypatch): + from maceexttool import standalone + + # One H2 molecule; energy 1 eV, zero forces + extinp = write_extinp(tmp_path, "h2", ["H", "H"], [(0, 0, 0), (0, 0, 0.75)]) + + def fake_init(**kwargs): + # energy in eV; forces in eV/Ang + return DummyCalc(energy_eV=1.0, forces_eVA=np.zeros((2, 3))) + + monkeypatch.setattr("maceexttool.calculator.init", lambda **kwargs: fake_init(**kwargs)) + + standalone.run(["-s", "omol", str(extinp)]) + + engrad = Path(str(extinp).replace(".extinp.tmp", ".engrad")).with_suffix(".engrad") + energy, grad = read_engrad(engrad) + assert pytest.approx(1.0 * EV_TO_EH, rel=1e-8) == energy + assert all(abs(g) < 1e-15 for g in grad) + + +def test_standalone_mp_extras_and_defaults(tmp_path, monkeypatch): + from maceexttool import standalone + + extinp = write_extinp(tmp_path, "h1", ["H"], [(0, 0, 0)]) + + calls = {} + + def rec_init(**kwargs): + calls.update(kwargs) + # one-atom, unit forces + return DummyCalc(energy_eV=2.0, forces_eVA=np.ones((1, 3))) + + monkeypatch.setattr("maceexttool.calculator.init", lambda **kwargs: rec_init(**kwargs)) + + standalone.run(["-s", "mp", "--dispersion", "--head", "mh0", str(extinp)]) + + # defaults for mp: float32 if not provided + assert calls.get("default_dtype") == "float32" + assert calls.get("dispersion") is True + assert calls.get("head") == "mh0" + + engrad = Path(str(extinp).replace(".extinp.tmp", ".engrad")).with_suffix(".engrad") + energy, grad = read_engrad(engrad) + assert pytest.approx(2.0 * EV_TO_EH, rel=1e-8) == energy + # gradient per component should be FORCE_TO_GRAD + assert len(grad) == 3 + for g in grad: + assert pytest.approx(FORCE_TO_GRAD, rel=1e-8) == g + + +def test_suite_synonyms_are_normalized(tmp_path, monkeypatch): + from maceexttool import standalone + + extinp = write_extinp(tmp_path, "w", ["H"], [(0, 0, 0)]) + + seen = {} + + def check_suite(**kwargs): + seen["suite"] = kwargs.get("suite") + return DummyCalc(energy_eV=0.0, forces_eVA=np.zeros((1, 3))) + + monkeypatch.setattr("maceexttool.calculator.init", lambda **kwargs: check_suite(**kwargs)) + standalone.run(["-s", "mace-omol", str(extinp)]) + assert seen.get("suite") == "omol" + + +def test_server_route_with_dummy_calc(monkeypatch): + import json + from maceexttool import server + + server._suite = "omol" + server._model = None + server._default_dtype = "float64" + server._device = "" + server._dispersion = False + server._head = None + server.calculators.clear() + + monkeypatch.setattr( + "maceexttool.calculator.init", + lambda **kwargs: DummyCalc(energy_eV=3.0, forces_eVA=np.zeros((2, 3))), + ) + + client = server.app.test_client() + payload = { + "atom_types": ["H", "H"], + "coordinates": [[0, 0, 0], [0, 0, 0.74]], + "charge": 0, + "mult": 1, + "nthreads": 1, + } + resp = client.post("/calculate", json=payload) + assert resp.status_code == 200 + data = resp.get_json() + assert pytest.approx(3.0 * EV_TO_EH, rel=1e-8) == data["energy"] + assert all(abs(g) < 1e-15 for g in data["gradient"]) # zero forces + + +def test_client_writes_engrad_via_stubbed_requests(tmp_path, monkeypatch): + from maceexttool import client, server + + # Prepare ORCA input + extinp = write_extinp(tmp_path, "h2c", ["H", "H"], [(0, 0, 0), (0, 0, 0.74)]) + + # stub requests.post to route to Flask test client + flask_client = server.app.test_client() + def post_stub(url, json=None, **kwargs): # noqa: A002 + # ignore url, directly call app + resp = flask_client.post("/calculate", json=json) + # mimic requests.Response subset + class R: + def __init__(self, resp): + self._resp = resp + self.status_code = resp.status_code + def raise_for_status(self): + if not (200 <= self.status_code < 300): + raise RuntimeError("HTTP error") + def json(self): + return self._resp.get_json() + return R(resp) + + # route server to a dummy calc + server.calculators.clear() + monkeypatch.setattr( + "maceexttool.calculator.init", + lambda **kwargs: DummyCalc(energy_eV=4.0, forces_eVA=np.zeros((2, 3))), + ) + monkeypatch.setattr("maceexttool.client.requests.post", post_stub) + + client.run([str(extinp)]) + + engrad = Path(str(extinp).replace(".extinp.tmp", ".engrad")).with_suffix(".engrad") + energy, grad = read_engrad(engrad) + assert pytest.approx(4.0 * EV_TO_EH, rel=1e-8) == energy + assert all(abs(g) < 1e-15 for g in grad) diff --git a/mace/tests/test_mace_integration.py b/mace/tests/test_mace_integration.py new file mode 100644 index 0000000..0c7d808 --- /dev/null +++ b/mace/tests/test_mace_integration.py @@ -0,0 +1,108 @@ +import os +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest + + +def _free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def _write_extinp(tmp: Path, name: str, xyz_content: str, charge=0, mult=1, ncores=1, dograd=1): + xyz_path = tmp / f"{name}_EXT.xyz" + xyz_path.write_text(xyz_content) + ext = tmp / f"{name}_EXT.extinp.tmp" + ext.write_text("\n".join([ + str(xyz_path), + str(charge), + str(mult), + str(ncores), + str(dograd), + ]) + "\n") + return ext + + +def test_integration_standalone_cli(tmp_path, monkeypatch): + # Prepare simple H2 in XYZ + xyz = """2 +H2 +H 0 0 0 +H 0 0 0.75 +""" + ext = _write_extinp(tmp_path, "h2_cli", xyz) + + env = os.environ.copy() + src_path = Path(__file__).resolve().parents[1] / "src" + mace_repo = Path(__file__).resolve().parents[3] / "mace" + env["PYTHONPATH"] = os.pathsep.join([str(src_path), str(mace_repo), env.get("PYTHONPATH", "")]) + # call entry module instead of venv shell script + cmd = [sys.executable, "-m", "maceexttool.standalone", "-s", "omol", "-m", "extra_large", "--device", "cpu", "--default-dtype", "float64", str(ext)] + cp = subprocess.run(cmd, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=600) + assert cp.returncode == 0, cp.stderr + + engrad = Path(str(ext).replace(".extinp.tmp", ".engrad")).with_suffix(".engrad") + assert engrad.exists() + # sanity: energy is written, gradient lines exist + out = engrad.read_text() + assert "Total energy" in out + + +def test_integration_server_client_cli(tmp_path): + # simple water molecule + xyz = """3 +H2O +O 0.0 0.0 0.0 +H 0.0 0.0 0.96 +H 0.0 0.75 -0.24 +""" + ext = _write_extinp(tmp_path, "h2o_cli", xyz) + + env = os.environ.copy() + src_path = Path(__file__).resolve().parents[1] / "src" + mace_repo = Path(__file__).resolve().parents[3] / "mace" + env["PYTHONPATH"] = os.pathsep.join([str(src_path), str(mace_repo), env.get("PYTHONPATH", "")]) + + port = _free_port() + bind = f"127.0.0.1:{port}" + + # Start server + server_cmd = [sys.executable, "-m", "maceexttool.server", "-s", "omol", "-m", "extra_large", "--device", "cpu", "-b", bind] + server = subprocess.Popen(server_cmd, cwd=tmp_path, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + + # Wait for port to accept connections + ready = False + t0 = time.time() + while time.time() - t0 < 60: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.5) + try: + sock.connect(("127.0.0.1", port)) + ready = True + break + except OSError: + time.sleep(0.1) + continue + assert ready, "Server did not start in time" + + try: + # Run client + client_cmd = [sys.executable, "-m", "maceexttool.client", "-b", bind, str(ext)] + cp = subprocess.run(client_cmd, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=600) + assert cp.returncode == 0, cp.stderr + + engrad = Path(str(ext).replace(".extinp.tmp", ".engrad")).with_suffix(".engrad") + assert engrad.exists() + out = engrad.read_text() + assert "Total energy" in out + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() diff --git a/maceclient.sh b/maceclient.sh new file mode 100755 index 0000000..ad3789d --- /dev/null +++ b/maceclient.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +SCRIPT_PATH="$(dirname -- "${BASH_SOURCE[0]}")" +src="${SCRIPT_PATH}/mace" +venv="$src/.venv" + +need_setup=0 +if [ ! -d "$venv" ]; then + need_setup=1 +elif [ ! -x "$venv/bin/maceclient" ]; then + need_setup=1 +fi + +if [ $need_setup -eq 1 ]; then + "$src"/install.sh +fi + +source "$venv"/bin/activate +maceclient "$@" diff --git a/maceserver.sh b/maceserver.sh new file mode 100755 index 0000000..a124cc9 --- /dev/null +++ b/maceserver.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +SCRIPT_PATH="$(dirname -- "${BASH_SOURCE[0]}")" +src="${SCRIPT_PATH}/mace" +venv="$src/.venv" + +need_setup=0 +if [ ! -d "$venv" ]; then + need_setup=1 +elif [ ! -x "$venv/bin/maceserver" ]; then + need_setup=1 +fi + +if [ $need_setup -eq 1 ]; then + "$src"/install.sh +fi + +source "$venv"/bin/activate +maceserver "$@" & +PID=$! +echo "MACESERVER_PID: $PID"