Search documentationSearch

Optimize a structure

Run an ASE geometry optimization and save the relaxed coordinates, trajectory and convergence report.

Before you start

The shared Python runtime (`compute/runtime`). Uses an inexpensive local EMT model.

A relaxed copper dimer (`relaxed.extxyz`), trajectory (`optimization.traj`) and convergence summary.

Run an ASE geometry optimization inside a job and save the relaxed coordinates, trajectory, and convergence report together under results/.

Note: This tutorial uses an inexpensive copper dimer with ASE's built-in EMT calculator so it runs in seconds on any laptop. For real molecular chemistry, swap EMT() for a qualified ML potential or quantum chemistry calculator.

1. Install the scientific runtime

Sync the shared Python 3.13 environment (compute/runtime), which includes ASE, NumPy, and SciPy:

bash
uv sync --project compute/runtime --frozen

2. Inspect the optimization script

Look at compute/jobs/examples/optimize_structure.py:

python
"""Illustrate an ASE optimization job with an inexpensive copper-dimer model."""
from __future__ import annotations
import jsonfrom pathlib import Path
import numpy as npfrom ase import Atomsfrom ase.calculators.emt import EMTfrom ase.io import writefrom ase.optimize import BFGS

def main() -> None:    output = Path("results")    output.mkdir(exist_ok=True)    atoms = Atoms("Cu2", positions=[[0.0, 0.0, 0.0], [2.5, 0.0, 0.0]])    atoms.calc = EMT()    optimizer = BFGS(        atoms, trajectory=str(output / "optimization.traj"), logfile=str(output / "optimizer.log")    )    converged = bool(optimizer.run(fmax=0.05, steps=100))    report = {        "converged": converged,        "steps": optimizer.nsteps,        "energy_ev": float(atoms.get_potential_energy()),        "max_force_ev_per_angstrom": float(np.linalg.norm(atoms.get_forces(), axis=1).max()),        "calculator": "ASE EMT",        "purpose": "illustrative copper dimer, not a production molecular method",    }    write(output / "relaxed.extxyz", atoms)    (output / "summary.json").write_text(json.dumps(report, indent=2) + "\n")    if not converged:        raise RuntimeError("Optimization did not converge; inspect retained outputs")

if __name__ == "__main__":    main()

What this script records under results/:

  • relaxed.extxyz — Final atomic coordinates and calculator energy/forces.
  • optimization.traj & optimizer.log — Full BFGS step history and force progression.
  • summary.json — Structured convergence report (converged, steps, energy_ev, max_force_ev_per_angstrom).
  • Explicit convergence check — If optimizer.run(fmax=0.05, steps=100) does not converge within 100 steps, the script still writes the diagnostic files to results/ before raising RuntimeError.

3. Run the optimization locally

Pass the shared runtime's Python interpreter to ct-cli run:

bash
ct-cli run compute/jobs/examples/optimize_structure.py \  --profile-python "$PWD/compute/runtime/.venv/bin/python"

Open results/summary.json inside the printed attempt directory:

json
{	"converged": true,	"steps": 4,	"energy_ev": -0.0079,	"max_force_ev_per_angstrom": 0.0091,	"calculator": "ASE EMT",	"purpose": "illustrative copper dimer, not a production molecular method"}

4. Turn it into a reusable Program

When you are ready to run optimizations across many structures on remote workers:

  1. Accept typed inputs: Wrap the function in a reusable Program that takes the input .xyz filename, charge, multiplicity, and fmax.
  2. Keep large arrays in files: Return the small summary dict as your Pydantic output model and keep .traj and .extxyz files in results/.
  3. Separate minimum optimization from TS search: Relaxing to a local minimum does not verify a transition state—use Search and verify a transition state for saddle-point searches and IRC checks, or Create an algorithm benchmark to compare calculators across a dataset.