# Optimize a structure

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

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 json
from pathlib import Path

import numpy as np
from ase import Atoms
from ase.calculators.emt import EMT
from ase.io import write
from 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](/start/reusable-programs/) 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](/science/transition-states/) for saddle-point searches and IRC checks, or [Create an algorithm benchmark](/test/benchmarks/) to compare calculators across a dataset.
