"""Parameter optimization for LAMPSUI driven via the REST API. The script doesn't know anything about LAMMPS internals — it submits runs, polls until they finish, then reduces the run's thermo trace to a single scalar that Optuna optimizes over. Because the bridge is the REST API, you can swap Optuna for any other optimizer (BoTorch, scikit-optimize, your own Bayesian loop) without touching LAMPSUI itself. Quick start: pip install -r scripts/requirements.txt # In one terminal: start LAMPSUI docker run --rm -p 7860:7860 -v "$(pwd)/work:/work" lampsui # In another terminal: run a 15-trial sweep python scripts/optimize.py \\ --example test_100 \\ --space scripts/space_example.json \\ --metric Press --reduce abs_last \\ --direction minimize --n-trials 15 The search space JSON has one entry per parameter to vary: { "dh": {"type": "float", "low": 0.15, "high": 0.30}, "sigmao": {"type": "float", "low": 0.5, "high": 2.0, "log": true}, "F": {"type": "int", "low": 1, "high": 3}, "randPos":{"type": "categorical", "choices": [0, 1, 2]} } """ from __future__ import annotations import argparse import json import statistics import sys import time from pathlib import Path import optuna import requests def submit_and_wait( api: str, example: str, overrides: dict, poll_s: float = 2.0 ) -> tuple[str, str]: """Submit a run, poll until it terminates. Returns (run_id, final_status).""" payload = { "example": example, "overrides": overrides, "image": {"enabled": False}, # images off — we don't need them for objective } r = requests.post(f"{api}/runs", json=payload, timeout=30) r.raise_for_status() rid = r.json()["id"] while True: s = requests.get(f"{api}/runs/{rid}", timeout=30).json() if s["status"] in ("done", "failed", "cancelled"): return rid, s["status"] time.sleep(poll_s) def reduce_thermo( api: str, rid: str, column: str, reduce: str, target: float | None = None ) -> float: """Fetch thermo data and reduce one column to a scalar. For mae / mape, `target` must be supplied (the reference value to compare the column against). MAPE is returned as a percentage (e.g. 12.34 means 12.34%). """ t = requests.get(f"{api}/runs/{rid}/thermo", timeout=30).json() if not t["rows"]: raise RuntimeError(f"run {rid}: no thermo data") if column not in t["columns"]: raise RuntimeError( f"run {rid}: column {column!r} not in {t['columns']}" ) idx = t["columns"].index(column) vals = [row[idx] for row in t["rows"]] if reduce == "last": return vals[-1] if reduce == "abs_last": return abs(vals[-1]) if reduce == "mean": return sum(vals) / len(vals) if reduce == "abs_mean": return abs(sum(vals) / len(vals)) if reduce == "stability": tail = vals[len(vals) // 2 :] return statistics.pstdev(tail) if len(tail) >= 2 else 0.0 if reduce == "mae": if target is None: raise ValueError("reduce=mae requires --target") return sum(abs(v - target) for v in vals) / len(vals) if reduce == "mape": if target is None: raise ValueError("reduce=mape requires --target") if target == 0.0: raise ValueError("MAPE is undefined when target=0") return ( 100.0 * sum(abs(v - target) for v in vals) / len(vals) / abs(target) ) raise ValueError(f"unknown reduce: {reduce}") def suggest(trial: optuna.Trial, name: str, spec: dict): t = spec["type"] if t == "float": return trial.suggest_float( name, spec["low"], spec["high"], log=spec.get("log", False) ) if t == "int": return trial.suggest_int(name, spec["low"], spec["high"]) if t == "categorical": return trial.suggest_categorical(name, spec["choices"]) raise ValueError(f"unsupported parameter type: {t!r}") def main() -> int: p = argparse.ArgumentParser( description="Optuna parameter sweep driving LAMPSUI through its REST API.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) p.add_argument("--api", default="http://localhost:7860/api") p.add_argument("--example", required=True, help="Example name (e.g. test_100)") p.add_argument( "--space", required=True, help="Path to JSON file describing the search space", ) p.add_argument( "--fixed", default="{}", help="JSON dict of fixed overrides applied to every trial", ) p.add_argument("--n-trials", type=int, default=15) p.add_argument("--metric", default="Press", help="Thermo column to reduce") p.add_argument( "--reduce", default="abs_last", choices=["last", "abs_last", "mean", "abs_mean", "stability", "mae", "mape"], help="How to reduce the column to a single scalar", ) p.add_argument( "--target", type=float, default=None, help="Reference value for mae / mape (required for those reducers)", ) p.add_argument( "--direction", default="minimize", choices=["minimize", "maximize"] ) p.add_argument( "--study", help="Study name. If set, results persist to .db (SQLite) so the " "study can be resumed.", ) p.add_argument( "--sampler", default="tpe", choices=["tpe", "random", "cmaes"], help="Optuna sampler to use", ) p.add_argument( "--seed", type=int, default=None, help="Sampler seed for reproducibility" ) args = p.parse_args() space_path = Path(args.space) if not space_path.is_file(): print(f"error: space file not found: {space_path}", file=sys.stderr) return 2 space = json.loads(space_path.read_text()) fixed = json.loads(args.fixed) # Sanity: poke the API once try: requests.get(f"{args.api}/health", timeout=5).raise_for_status() except Exception as e: print( f"error: cannot reach LAMPSUI API at {args.api} ({e})\n" "Make sure the container is running on the same host.", file=sys.stderr, ) return 2 def objective(trial: optuna.Trial) -> float: overrides = dict(fixed) for name, spec in space.items(): overrides[name] = suggest(trial, name, spec) rid, status = submit_and_wait(args.api, args.example, overrides) if status != "done": print(f" trial {trial.number}: run {rid} → {status} (pruned)") raise optuna.TrialPruned() val = reduce_thermo(args.api, rid, args.metric, args.reduce, args.target) suffix = "%" if args.reduce == "mape" else "" print( f" trial {trial.number:3d}: run {rid} " f"{args.metric}/{args.reduce}={val:.6g}{suffix} overrides={overrides}" ) # Tag the LAMPSUI run with the trial number so it's easy to find later try: requests.patch( f"{args.api}/runs/{rid}", json={ "name": ( f"{args.study or 'opt'} #{trial.number} " f"({args.metric}/{args.reduce}={val:.4g})" )[:80] }, timeout=10, ) except Exception: pass return val samplers = { "tpe": optuna.samplers.TPESampler(seed=args.seed), "random": optuna.samplers.RandomSampler(seed=args.seed), "cmaes": optuna.samplers.CmaEsSampler(seed=args.seed), } storage = f"sqlite:///{args.study}.db" if args.study else None study = optuna.create_study( study_name=args.study, direction=args.direction, sampler=samplers[args.sampler], storage=storage, load_if_exists=True, ) print( f"Optimising {args.example} via {args.api}\n" f" metric: {args.metric} reduced by {args.reduce} ({args.direction})\n" f" search: {list(space.keys())}\n" f" fixed: {fixed or '{}'}\n" f" trials: {args.n_trials}\n" f" sampler: {args.sampler}\n" + (f" storage: {storage}\n" if storage else "") ) study.optimize(objective, n_trials=args.n_trials) print() print(f"Best {args.direction}d value: {study.best_value:.6g}") print(f"Best params: {study.best_params}") if storage: print(f"\nResume / inspect later with:\n" f" optuna-dashboard {storage}") return 0 if __name__ == "__main__": sys.exit(main())