Parallel black-box optimization and a live dashboard for SOFA simulations — bring your own scene, your own SOFA build.
sofaopt drives the hard, generic part of optimizing a SOFA simulation:
- samples parameters with CMA-ES (default), GP Bayesian optimization, TPE, Random, or NSGA-II for multi-objective Pareto search (all via Optuna),
- runs many candidates in parallel as headless
runSofasubprocesses (or an in-process Python runner with per-trial video recording), - collects, normalizes, weights and aggregates scores across multiple tests,
- shows live progress, leaderboards, parameter importance and Pareto fronts in a web dashboard, and archives runs for later comparison.
pip install -e . # core optimizer
pip install -e .[dashboard] # + web UI
pip install -e .[preview] # + STL preview rendering (shape projects)
pip install -e .[video] # + trial recording / video generation (pygame, PyOpenGL, ...)
pip install -e .[analysis] # + fANOVA importance & interaction analysis (scikit-learn)ffmpeg (for the [video] features) is an external tool: install it separately
and make sure it is on PATH.
sofaopt does not depend on SOFA as a Python package. It launches whatever
runSofa you point it at, so any build with the SofaPython3 plugin works.
You write one project.py and one or more scene.py files.
-
SofaOptProject— declares parameters (ParamSpec), tests (TestSpec→ ascene.py), and how to reach SOFA (runsofa_exe,sofa_env). Seesrc/sofaopt/project.py. -
Each scene reads the trial and writes a score:
from sofaopt.scene import open_trial trial = open_trial() k = trial.params["stiffness"] # ... build & run your SOFA scene ... trial.write_score(score, reason="...")
-
(optional) a prepare hook turns sampled params into an asset (e.g. generate a mesh) before the scene launches — for shape optimization.
from sofaopt import run_optimization, launch_dashboard
from project import PROJECT
run_optimization(PROJECT) # headless
# or
launch_dashboard(PROJECT, port=8050) # web UIThe search is controlled by fields on SofaOptProject. The full "which sampler,
which knobs, and why" discussion lives in the
optimization guide; the surface:
| Field | Default | What it does |
|---|---|---|
sampler |
"cmaes" |
Search algorithm: "cmaes", "gp" (Gaussian-process BO — sample-efficient for expensive sims, <20-D), "tpe", or "random". |
n_parallel |
5 | Population size — candidates per generation, run as parallel runSofa processes (≥ 4 for CMA-ES). |
n_generations |
100 | How many generations to run. |
cmaes_startup_trials |
auto | Space-filling startup phase (see below). None auto-sizes from the number of searched params (resolve_startup_trials()). |
cmaes_sigma0 |
1.0 | Initial spread (std-dev) of the search once CMA-ES begins. |
cmaes_with_margin |
False |
CMA-ES with Margin — keeps low-cardinality integer params from stagnating. |
seed_sampler |
"random" |
Startup design for cmaes/gp: "sobol" gives an evenly space-filling (QMC) design; seed_sampler_seed re-scrambles it for an independent exploration. |
multi_objective |
False |
Each test becomes a Pareto objective (NSGA-II); gating/weights disabled. |
dedup_trials |
False |
Reuse the recorded score of an identical completed candidate instead of re-simulating (deterministic objectives only). |
stall_generations |
0 | Stop early after N generations without best-score improvement (0 = off). |
max_active_sofa_procs |
12 | Hard cap on concurrent SOFA processes across all tests/repeats. |
Startup phase. CMA-ES and GP need evaluated points before their model is
meaningful, so the first cmaes_startup_trials trials are sampled
independently (uniform random, or a Sobol' space-filling design with
seed_sampler="sobol") within each parameter's bounds; only after that does the
model-based sampler take over. Raise it for more upfront exploration on rugged
landscapes, lower it to converge sooner.
Two related details:
- Starting point — CMA-ES is centered on each
ParamSpec'sdefault, not on a random point. Frozen params (min == max) are excluded from the search. - Once CMA-ES is active it explores with spread
cmaes_sigma0around that evolving center.
How a trial is scored. Per run the scene writes one raw score. The pipeline
then runs in exactly this order: repeats of a test are combined by its
score_aggregation ("mean" | "median" | "sum" | "exponential_coverage")
→ the per-test aggregate is normalized by max_score (clamped at 1.0) → tests
are combined by weight (renormalized over the tests actually counted, e.g.
when a gated test is skipped) → the 0–100 study objective. Every display
(dashboard, videos) reads this recorded score — nothing recomputes its own.
Failure semantics. A trial whose prepare hook raises, or whose runs all
crash, is reported to the optimizer as a real observation of
hard_fail_score (default −3.0) so the sampler learns to avoid that region.
A trial killed by the sofa_realtime_timeout backstop is reported as
pruned instead — a wall-clock timeout says the run wedged, not that the
parameters were bad. This split is intentional.
Requires the [video] extra and ffmpeg on PATH. When using
runner="python", set record_frames=True to capture a video of every trial:
PROJECT = SofaOptProject(
...
runner="python",
record_frames=True,
record_frame_skip=16, # capture every 16th step (default)
record_frame_size=(640, 480),
record_keep_top_n=15, # keep recordings for 15 best trials
record_keep_bottom_n=5, # keep recordings for 5 worst trials
record_prune_every_n=20, # prune every 20 completed trials (mid-run)
)Each trial writes a trial.mp4 to its directory. After each generation the framework
burns gen/trial/score/params text into the video via ffmpeg. Excess recordings are pruned
mid-run (every record_prune_every_n completed trials) to keep disk usage bounded.
At the end of the run a summary video is generated from the top+bottom trials.
launch_dashboard(PROJECT, port=8050)The web UI provides:
- Performance graph — score over trials, click any point to select it
- "Test it" button — click a trial in the graph then press "Test it" to launch
runSofa -g imguiwith that trial's params and the scene its first run used (loadsSofaImGuiautomatically). Useful for visually inspecting a candidate. Viewer windows are attached to a kill-on-close job, so they never outlive the dashboard; the headless optimization run itself is not — it survives closing the dashboard. - "View recording" link — if the trial has a recorded
trial.mp4, a direct link appears next to the "Test it" button. - "Generate Summary" button — concatenates the top+bottom trial recordings into a single summary MP4.
- Run controls — Run, Pause/Resume (pausing tree-kills in-flight SOFA processes; resuming re-evaluates the interrupted trials, which are excluded from rankings), and Stop & archive current run.
- Live leaderboard, progress, parameter bounds, importance/interactions (fANOVA), and Pareto front tabs.
- Archives tab — archive the current run (with a name and notes), restore or delete archives, and compare runs: overlaid best-so-far convergence curves plus a summary and best-params diff table.
Archiving moves work_dir/runtime/ into work_dir/archives/<timestamp>_<name>/
(instant, no copy) together with an archive.json manifest (settings snapshot,
best score/params, notes) — and thereby resets the workspace. Starting a fresh
run auto-archives any existing run first, so a new run can never destroy a
previous one. Restoring moves an archive back to runtime/ (auto-archiving the
current run first); a restored run can be resumed since its study.db is intact.
from sofaopt import archive_run, list_archives, restore_archive, delete_archive
archive_run(PROJECT, name="baseline", notes="before widening bounds")
for info in list_archives(PROJECT):
print(info.name, info.best_score, info.n_trials)
restore_archive(PROJECT, list_archives(PROJECT)[0].path.name)Comparison (also available in the dashboard's Archives tab) reads each run's recorded scores — the same numbers the studies optimized.
A runnable example needing only a SOFA install with SofaPython3:
examples/cube_drop/— Demo: a cube falls and the optimizer learns to make it bigger and heavier. Uses a prepare hook that generates a scaled cube mesh per trial; the optimizer is rewarded for the cube touching the ground sooner, so it is incentivized to scale the cube up and make it heavier. Variants demonstrate the TPE and GP samplers, the Sobol' startup design, the Python runner with recording, multi-objective NSGA-II, and OAT sensitivity analysis.
pytest # unit suite — fast, no SOFA needed (the SOFA process is faked)
ruff check src tests examples # lint gate — clean is part of "done"Two end-to-end tests (tests/test_e2e_cube_drop.py)
additionally run one deterministic real trial of examples/cube_drop — once
through a real runSofa (score written, child stops itself, run archives
intact) and once through the in-process Python runner (same score, plus a
recorded trial.mp4). They skip automatically unless a SOFA build is reachable
(SOFA_ROOT / RUNSOFA_EXE) and the dev-only toolkit providing the sofa
pytest marker is installed — a plain public checkout stays green.
When to run what:
| Trigger | Tier |
|---|---|
| any commit touching Python | ruff check clean + pytest (e2e auto-skips without SOFA) |
| changes to the trial contract (runner, scoring, trial_state, archiving) | pytest on a machine with SOFA, so the e2e actually executes |
| before a merge / PR / release | full suite with the e2e executing |
docs/porting-guide.md— step-by-step: plug your SOFA project in (scene contract, project file, prepare hooks, running).docs/optimization-guide.md— choosing and tuning the search: which sampler for which problem, budget/parallelism, noise & repeats, scoring/gating, failure semantics, analyzing results.