diff --git a/data/sensitivity/README.md b/data/sensitivity/README.md new file mode 100644 index 0000000..1714b05 --- /dev/null +++ b/data/sensitivity/README.md @@ -0,0 +1,67 @@ +# Skidpad score sensitivity study + +One-at-a-time (OAT) local sensitivity of the **average timed-lap time** (the FS +*score*, `profiling.skidpad_score_s` = mean of the two timed laps) to the +four-wheel vehicle/solver parameters. + +Reproduce with: + +```bash +OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 \ +PYTHONPATH=src python src/experiments/skidpad_sensitivity.py \ + --config configs/skidpad.yaml --delta 0.15 --jobs 4 +``` + +## Method + +- Skidpad track is built once from `configs/skidpad.yaml`; the four-wheel OCP is + then re-solved for each perturbation (IPOPT, euler integrator, identical + `time_weights`/`terminal_speed`). +- Each parameter is moved **±15 %** around the baseline, one at a time. Knobs + that group several params (`D_rear` = `D_rr` & `D_rl`, `D_front` = `D_fl` & + `D_fr`) scale all of them together. +- **Elasticity** `E = (Δscore/score) / (Δp/p)` is a dimensionless slope: `E=-0.45` + means a +1 % parameter increase lowers the average lap by 0.45 %. The ranking + uses the absolute lap-time **swing** (seconds) over the ±15 % window. +- Baseline average timed-lap score: **4.740 s**. All 19 solves returned + `Solve_Succeeded`. + +## Results (ranked by influence) + +| Rank | Parameter | Base | −15 % | +15 % | swing [s] | elasticity | +|-----:|-----------|-----:|------:|------:|----------:|-----------:| +| 1 | rear grip `D_rear` | 1.20 | 5.095 | 4.457 | **0.638** | −0.449 | +| 2 | front grip `D_front` | 1.20 | 4.902 | 4.629 | 0.273 | −0.192 | +| 3 | mass `m` | 170 | 4.615 | 4.835 | 0.219 | +0.154 | +| 4 | downforce `C_l` | 5.54 | 4.844 | 4.634 | 0.210 | −0.148 | +| 5 | margin `boundary_margin` | 0.20 | 4.732 | 4.748 | 0.016 | +0.011 | +| 6 | drag `C_d` | 1.58 | 4.733 | 4.748 | 0.015 | +0.011 | +| 7 | `reg_u_l2` | 0.015 | 4.734 | 4.748 | 0.014 | +0.010 | +| 8 | CG height `h` | 0.246 | 4.737 | 4.744 | 0.007 | +0.005 | +| 9 | yaw inertia `Iz` | 250 | 4.738 | 4.742 | 0.004 | +0.003 | + +See `skidpad_sensitivity.png` (tornado plot), `skidpad_sensitivity.csv` (every +raw solve), and `skidpad_sensitivity_summary.json` (machine-readable summary). + +## Takeaways + +- **Tyre peak grip dominates.** Rear grip `D_rear` is the single biggest lever + (~3× the next parameter); front grip is second. Skidpad is a steady-state, + grip-limited corner, so peak `D` maps almost directly into corner speed, and + the rear-biased static load (`lf > lr`) makes the rear tyres the limiting + pair. This is also the least free parameter physically — it reflects the tyre + model, not something you tune. +- Of the *tunable / design* knobs, **mass** and **aero downforce `C_l`** are the + meaningful ones (~0.21–0.22 s over ±15 %, elasticity ≈ 0.15). They trade off + in opposite directions, as expected. Note `C_l` only enters through downforce, + so its grip benefit scales with v² and is modest at skidpad speeds (~12 m/s). +- **Solver / corridor knobs are negligible**: `reg_u_l2`, `boundary_margin`, + `C_d`, `h`, and `Iz` each move the score by <0.02 s (elasticity ≤ 0.01). The + L2 regularisation is well below the level where it distorts the lap, and yaw + inertia barely matters in this near-steady manoeuvre — both reassuring for the + current setup. + +> Caveat: these are *local* ±15 % OAT sensitivities about one operating point. +> Interactions between parameters and larger excursions (e.g. big margin or grip +> changes) can be non-linear; widen `--delta` or run a coupled sweep to probe +> those. diff --git a/data/sensitivity/skidpad_sensitivity.csv b/data/sensitivity/skidpad_sensitivity.csv new file mode 100644 index 0000000..814ace1 --- /dev/null +++ b/data/sensitivity/skidpad_sensitivity.csv @@ -0,0 +1,20 @@ +knob,tag,multiplier,param_value,score_s,timed_time_s,full_time_s,status,solve_time_s,iters +baseline,baseline,1.0,nan,4.740072009101534,9.480144018203069,24.408978411789178,Solve_Succeeded,103.158144795,36 +m,m_x0p85,0.85,144.5,4.615329978661124,9.230659957322247,23.733437263235718,Solve_Succeeded,103.07811617699997,37 +m,m_x1p15,1.15,195.49999999999997,4.834693092402854,9.669386184805708,24.93276289268389,Solve_Succeeded,86.61417656799995,35 +Iz,Iz_x0p85,0.85,212.5,4.737568582217398,9.475137164434797,24.37953623746616,Solve_Succeeded,91.54032464400007,38 +Iz,Iz_x1p15,1.15,287.5,4.742002966935889,9.484005933871778,24.433947555840923,Solve_Succeeded,90.80592223899998,34 +reg_u_l2,reg_u_l2_x0p85,0.85,0.01275,4.733660775527216,9.467321551054432,24.316874679803124,Solve_Succeeded,90.239864238,38 +reg_u_l2,reg_u_l2_x1p15,1.15,0.017249999999999998,4.747747794848977,9.495495589697954,24.495904367233393,Solve_Succeeded,77.98857451999993,35 +D_rear,D_rear_x0p85,0.85,1.02,5.094813913837953,10.189627827675906,26.049556440109733,Solve_Succeeded,83.41913291800006,40 +D_rear,D_rear_x1p15,1.15,1.38,4.456787934473439,8.913575868946879,23.101462714559105,Solve_Succeeded,91.38867170399999,35 +boundary_margin,boundary_margin_x0p85,0.85,0.17,4.732085539416261,9.464171078832521,24.387458259898395,Solve_Succeeded,77.02125245299999,36 +boundary_margin,boundary_margin_x1p15,1.15,0.22999999999999998,4.7480569541913695,9.496113908382739,24.434465704566502,Solve_Succeeded,93.01054706900004,37 +C_l,C_l_x0p85,0.85,4.709,4.8439512656554555,9.687902531310911,24.881045259787204,Solve_Succeeded,77.36289373099999,36 +C_l,C_l_x1p15,1.15,6.3709999999999996,4.633963737301138,9.267927474602276,23.921053788576025,Solve_Succeeded,75.75866207400009,35 +D_front,D_front_x0p85,0.85,1.02,4.90179078112475,9.8035815622495,25.11281055420629,Solve_Succeeded,76.651006639,36 +D_front,D_front_x1p15,1.15,1.38,4.629125252379634,9.258250504759268,23.929064563227428,Solve_Succeeded,114.6799860220001,51 +C_d,C_d_x0p85,0.85,1.343,4.732673846625645,9.46534769325129,24.373709527780054,Solve_Succeeded,91.18542284300008,36 +C_d,C_d_x1p15,1.15,1.817,4.747695161405053,9.495390322810106,24.442892572842457,Solve_Succeeded,75.72656160200006,35 +h,h_x0p85,0.85,0.20909999999999998,4.736617700377183,9.473235400754366,24.388892039984515,Solve_Succeeded,76.00479720299995,36 +h,h_x1p15,1.15,0.2829,4.743668823804108,9.487337647608216,24.426229710426636,Solve_Succeeded,90.405708581,36 diff --git a/data/sensitivity/skidpad_sensitivity.png b/data/sensitivity/skidpad_sensitivity.png new file mode 100644 index 0000000..0faa09f Binary files /dev/null and b/data/sensitivity/skidpad_sensitivity.png differ diff --git a/data/sensitivity/skidpad_sensitivity_summary.json b/data/sensitivity/skidpad_sensitivity_summary.json new file mode 100644 index 0000000..246de59 --- /dev/null +++ b/data/sensitivity/skidpad_sensitivity_summary.json @@ -0,0 +1,88 @@ +{ + "config": "configs/skidpad.yaml", + "delta": 0.15, + "score_base_s": 4.740072009101534, + "knobs": [ + { + "knob": "m", + "label": "mass m", + "base_value": 170.0, + "score_lo": 4.615329978661124, + "score_hi": 4.834693092402854, + "swing_s": 0.21936311374173023, + "elasticity": 0.1542614495592229 + }, + { + "knob": "Iz", + "label": "yaw inertia Iz", + "base_value": 250.0, + "score_lo": 4.737568582217398, + "score_hi": 4.742002966935889, + "swing_s": 0.004434384718490669, + "elasticity": 0.0031183666337952203 + }, + { + "knob": "reg_u_l2", + "label": "reg_u_l2", + "base_value": 0.015, + "score_lo": 4.733660775527216, + "score_hi": 4.747747794848977, + "swing_s": 0.01408701932176104, + "elasticity": 0.009906332853672681 + }, + { + "knob": "D_rear", + "label": "rear grip D_rear", + "base_value": 1.2, + "score_lo": 5.094813913837953, + "score_hi": 4.456787934473439, + "swing_s": -0.6380259793645138, + "elasticity": -0.4486753071397957 + }, + { + "knob": "boundary_margin", + "label": "margin", + "base_value": 0.2, + "score_lo": 4.732085539416261, + "score_hi": 4.7480569541913695, + "swing_s": 0.015971414775108883, + "elasticity": 0.011231485333585482 + }, + { + "knob": "C_l", + "label": "downforce C_l", + "base_value": 5.54, + "score_lo": 4.8439512656554555, + "score_hi": 4.633963737301138, + "swing_s": -0.20998752835431755, + "elasticity": -0.14766831105175554 + }, + { + "knob": "D_front", + "label": "front grip D_front", + "base_value": 1.2, + "score_lo": 4.90179078112475, + "score_hi": 4.629125252379634, + "swing_s": -0.27266552874511607, + "elasticity": -0.19174499755950541 + }, + { + "knob": "C_d", + "label": "drag C_d", + "base_value": 1.58, + "score_lo": 4.732673846625645, + "score_hi": 4.747695161405053, + "swing_s": 0.015021314779407824, + "elasticity": 0.010563352026836309 + }, + { + "knob": "h", + "label": "CG height h", + "base_value": 0.246, + "score_lo": 4.736617700377183, + "score_hi": 4.743668823804108, + "swing_s": 0.007051123426925265, + "elasticity": 0.004958520611350927 + } + ] +} \ No newline at end of file diff --git a/src/experiments/skidpad_sensitivity.py b/src/experiments/skidpad_sensitivity.py new file mode 100644 index 0000000..4fb3220 --- /dev/null +++ b/src/experiments/skidpad_sensitivity.py @@ -0,0 +1,506 @@ +from __future__ import annotations + +""" +One-at-a-time (OAT) sensitivity study for the skidpad LTO. + +The objective of interest is the FS *score*: the average of the two timed-lap +times (``profiling.skidpad_score_s``). We perturb each parameter individually +by a fixed relative amount around the baseline config, re-solve the four-wheel +OCP, and measure how the score moves. Cross-parameter ranking uses the +dimensionless *elasticity* + + E = (dscore / score_base) / (dp / p_base) + +estimated from a centred low/high pair, so a value of e.g. ``-0.8`` means a ++1% increase in the parameter lowers the average lap time by 0.8%. + +Parameters studied (four-wheel model only): + m mass + Iz yaw inertia + reg_u_l2 L2 input-magnitude regularisation weight + D_rear rear tyre peak-grip (D_rr and D_rl scaled together) + boundary_margin corridor shrink margin on each side + C_l aero downforce coefficient (baseline 5.54) + D_front front tyre peak-grip (D_fl and D_fr) -- for front/rear contrast + C_d aero drag coefficient + h CG height (drives longitudinal/lateral load transfer) + +Usage (from repo root): + PYTHONPATH=src python src/experiments/skidpad_sensitivity.py \ + --config configs/skidpad.yaml --delta 0.15 --jobs 4 + +Outputs (under data/sensitivity/ by default): + skidpad_sensitivity.csv one row per solve (baseline + perturbations) + skidpad_sensitivity.png tornado plot ranked by |elasticity| +""" + +import argparse +import csv +import json +from concurrent.futures import ProcessPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +if __name__ == "__main__": + import sys + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from config import RunConfig +from optimization.global_ocp import solve_ocp_and_save +from optimization.integrators import EulerIntegrator, RK4Integrator +from pipeline import _resolve_path, PipelineConfig +from vehicle_models.four_wheel import FourWheelModel + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +# --------------------------------------------------------------------------- +# Parameter definitions +# --------------------------------------------------------------------------- +# +# A "knob" describes how a single perturbation is applied. ``kind`` selects +# where the value lives: +# "model" -> override one or more keys in the four-wheel params dict +# "solve" -> override a keyword argument to solve_ocp_and_save +# ``keys`` lists every param the knob scales together (e.g. both rear D's). + + +@dataclass(frozen=True) +class Knob: + name: str + kind: str # "model" | "solve" + keys: Tuple[str, ...] + label: str + + +KNOBS: List[Knob] = [ + Knob("m", "model", ("m",), "mass m"), + Knob("Iz", "model", ("Iz",), "yaw inertia Iz"), + Knob("reg_u_l2", "solve", ("reg_u_l2",), "reg_u_l2"), + Knob("D_rear", "model", ("D_rr", "D_rl"), "rear grip D_rear"), + Knob("boundary_margin", "solve", ("boundary_margin",), "margin"), + Knob("C_l", "model", ("C_l",), "downforce C_l"), + Knob("D_front", "model", ("D_fl", "D_fr"), "front grip D_front"), + Knob("C_d", "model", ("C_d",), "drag C_d"), + Knob("h", "model", ("h",), "CG height h"), +] + + +# --------------------------------------------------------------------------- +# Baseline / shared inputs +# --------------------------------------------------------------------------- + + +@dataclass +class Baseline: + track: Dict[str, Any] + time_weights: np.ndarray + model_params: Dict[str, Any] + integrator_name: str + initial_speed: float + reg_du: Any + reg_u_l2: Optional[float] + boundary_margin: float + terminal_speed: Optional[float] + normalize: bool + + +def _build_baseline(config_path: Path) -> Baseline: + """Build the skidpad track once and gather all baseline solve inputs.""" + from tracks.skidpad import build_skidpad_track + + rc = RunConfig.from_yaml(config_path) + rc.validate_for_model() + pc: PipelineConfig = rc.to_pipeline_config() + pc.repo_root = _repo_root() + pc.__post_init__() + + map_csv = _resolve_path(pc.repo_root, pc.skidpad_map_csv) + ref_csv = _resolve_path(pc.repo_root, pc.skidpad_reference_csv) + track = build_skidpad_track( + map_csv=map_csv, + ref_csv=ref_csv, + ds_m=pc.ds_m, + entry_exit_halfwidth=pc.entry_exit_halfwidth, + kappa_blend_m=pc.kappa_blend_m, + ) + + mask = np.asarray(track["timed_mask"], dtype=float) + decel = np.asarray(track.get("decel_mask", np.zeros_like(mask)), dtype=float) + time_weights = np.where(mask > 0.5, 1.0, float(pc.eps_time)) + time_weights = np.where(decel > 0.5, 0.0, time_weights) + + model_params = rc.vehicle.build_model_params("four_wheel") + + return Baseline( + track=track, + time_weights=time_weights, + model_params=model_params, + integrator_name=pc.integrator_name, + initial_speed=float(pc.initial_speed), + reg_du=pc.reg_u, + reg_u_l2=pc.reg_u_l2, + boundary_margin=float(pc.boundary_margin), + terminal_speed=pc.terminal_speed, + normalize=bool(pc.normalize_states_and_inputs), + ) + + +# --------------------------------------------------------------------------- +# Single solve +# --------------------------------------------------------------------------- + + +def _make_integrator(name: str): + return RK4Integrator() if name == "rk4" else EulerIntegrator() + + +def _solve_one(job: Dict[str, Any]) -> Dict[str, Any]: + """Solve one perturbed configuration and return the score + metadata. + + ``job`` carries the baseline payload plus the model/solve overrides for this + run. Runs as a worker process, so everything in it must be picklable. + """ + base: Baseline = job["baseline"] + model_overrides: Dict[str, Any] = job["model_overrides"] + reg_u_l2 = job["reg_u_l2"] + boundary_margin = job["boundary_margin"] + tag = job["tag"] + + params = dict(base.model_params) + params.update(model_overrides) + model = FourWheelModel(params=params) + integrator = _make_integrator(base.integrator_name) + + sol_path = Path(job["scratch_dir"]) / f"sol_{tag}.json" + + try: + sol = solve_ocp_and_save( + track=base.track, + model=model, + solution_path=sol_path, + integrator=integrator, + initial_speed=base.initial_speed, + reg_du=base.reg_du, + reg_u_l2=reg_u_l2, + run_config={"tag": tag}, + use_normalization=base.normalize, + solver_verbose=False, + boundary_margin=boundary_margin, + mode="skidpad", + time_weights=base.time_weights, + terminal_speed=base.terminal_speed, + ) + prof = sol.get("profiling", {}) + result = { + "score_s": prof.get("skidpad_score_s"), + "timed_time_s": prof.get("pure_timed_time_s"), + "full_time_s": prof.get("lap_time_s"), + "status": prof.get("return_status"), + "solve_time_s": prof.get("solve_time_s"), + "iters": prof.get("iter_count"), + } + except Exception as exc: # keep the sweep alive on a single failure + result = { + "score_s": None, + "timed_time_s": None, + "full_time_s": None, + "status": f"ERROR: {type(exc).__name__}: {exc}", + "solve_time_s": None, + "iters": None, + } + finally: + try: + sol_path.unlink() + except OSError: + pass + + result.update( + { + "tag": tag, + "knob": job["knob"], + "param_value": job["param_value"], + "multiplier": job["multiplier"], + } + ) + return result + + +# --------------------------------------------------------------------------- +# Job construction +# --------------------------------------------------------------------------- + + +def _baseline_value(base: Baseline, knob: Knob) -> float: + if knob.kind == "model": + return float(base.model_params[knob.keys[0]]) + if knob.name == "reg_u_l2": + return float(base.reg_u_l2 if base.reg_u_l2 is not None else 0.0) + if knob.name == "boundary_margin": + return float(base.boundary_margin) + raise ValueError(f"Unhandled knob {knob.name}") + + +def _make_job( + base: Baseline, + knob: Knob, + multiplier: float, + scratch_dir: Path, +) -> Dict[str, Any]: + base_val = _baseline_value(base, knob) + new_val = base_val * multiplier + + model_overrides: Dict[str, Any] = {} + reg_u_l2 = base.reg_u_l2 + boundary_margin = base.boundary_margin + + if knob.kind == "model": + for k in knob.keys: + model_overrides[k] = float(base.model_params[k]) * multiplier + elif knob.name == "reg_u_l2": + reg_u_l2 = new_val + elif knob.name == "boundary_margin": + boundary_margin = new_val + + tag = f"{knob.name}_x{multiplier:.2f}".replace(".", "p") + return { + "baseline": base, + "knob": knob.name, + "param_value": new_val, + "multiplier": multiplier, + "model_overrides": model_overrides, + "reg_u_l2": reg_u_l2, + "boundary_margin": boundary_margin, + "scratch_dir": str(scratch_dir), + "tag": tag, + } + + +def _baseline_job(base: Baseline, scratch_dir: Path) -> Dict[str, Any]: + return { + "baseline": base, + "knob": "baseline", + "param_value": float("nan"), + "multiplier": 1.0, + "model_overrides": {}, + "reg_u_l2": base.reg_u_l2, + "boundary_margin": base.boundary_margin, + "scratch_dir": str(scratch_dir), + "tag": "baseline", + } + + +# --------------------------------------------------------------------------- +# Analysis / output +# --------------------------------------------------------------------------- + + +def _elasticity(score_lo: float, score_hi: float, score_base: float, delta: float) -> float: + """Centred elasticity over a +-delta multiplicative perturbation.""" + if score_base in (None, 0) or score_lo is None or score_hi is None: + return float("nan") + return ((score_hi - score_lo) / score_base) / (2.0 * delta) + + +def _write_csv(path: Path, rows: List[Dict[str, Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + cols = [ + "knob", "tag", "multiplier", "param_value", + "score_s", "timed_time_s", "full_time_s", + "status", "solve_time_s", "iters", + ] + with path.open("w", newline="") as f: + w = csv.DictWriter(f, fieldnames=cols) + w.writeheader() + for r in rows: + w.writerow({c: r.get(c) for c in cols}) + + +def _tornado_plot( + path: Path, + summary: List[Dict[str, Any]], + score_base: float, + delta: float, +) -> None: + # Rank by absolute lap-time swing (seconds), most influential at the top. + summary = sorted(summary, key=lambda d: abs(d["swing_s"]), reverse=True) + labels = [d["label"] for d in summary] + lo = [d["score_lo"] - score_base for d in summary] + hi = [d["score_hi"] - score_base for d in summary] + y = np.arange(len(labels)) + + fig, ax = plt.subplots(figsize=(10, 0.7 * len(labels) + 2)) + for yi, l, h in zip(y, lo, hi): + ax.plot([l, h], [yi, yi], color="0.7", lw=2, zorder=1) + ax.scatter(lo, y, color="tab:blue", zorder=3, label=f"-{delta*100:.0f}%") + ax.scatter(hi, y, color="tab:red", zorder=3, label=f"+{delta*100:.0f}%") + for yi, d in zip(y, summary): + ax.annotate( + f"E={d['elasticity']:+.2f}", + xy=(0, yi), + xytext=(0, yi + 0.18), + ha="center", + fontsize=8, + color="0.25", + ) + ax.axvline(0.0, color="k", lw=1) + ax.set_yticks(y) + ax.set_yticklabels(labels) + ax.invert_yaxis() + ax.set_xlabel(f"change in average timed-lap score [s] (baseline = {score_base:.3f} s)") + ax.set_title( + f"Skidpad score sensitivity (OAT, +-{delta*100:.0f}% per parameter)\n" + "annotation E = elasticity (dscore%/dparam%); |swing| sets the ranking" + ) + ax.legend(loc="lower right") + ax.grid(True, axis="x", ls="--", alpha=0.3) + fig.tight_layout() + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(path, dpi=160) + plt.close(fig) + + +def run(config_path: Path, delta: float, jobs: int, out_dir: Path) -> None: + scratch_dir = out_dir / "_scratch" + scratch_dir.mkdir(parents=True, exist_ok=True) + + print(f"Building skidpad track and baseline from {config_path} ...") + base = _build_baseline(config_path) + + multipliers = (1.0 - delta, 1.0 + delta) + all_jobs: List[Dict[str, Any]] = [_baseline_job(base, scratch_dir)] + for knob in KNOBS: + for m in multipliers: + all_jobs.append(_make_job(base, knob, m, scratch_dir)) + + print( + f"Submitting {len(all_jobs)} solves " + f"({len(KNOBS)} knobs x2 + baseline) on {jobs} worker(s) ..." + ) + + results: List[Dict[str, Any]] = [] + if jobs <= 1: + for j in all_jobs: + r = _solve_one(j) + print(f" [{r['tag']:>22}] score={r['score_s']} status={r['status']}") + results.append(r) + else: + with ProcessPoolExecutor(max_workers=jobs) as ex: + for r in ex.map(_solve_one, all_jobs): + print(f" [{r['tag']:>22}] score={r['score_s']} status={r['status']}") + results.append(r) + + by_tag = {r["tag"]: r for r in results} + score_base = by_tag["baseline"]["score_s"] + if score_base is None: + raise RuntimeError("Baseline solve failed; cannot compute sensitivities.") + + # Build per-knob summary. + summary: List[Dict[str, Any]] = [] + for knob in KNOBS: + lo_tag = f"{knob.name}_x{multipliers[0]:.2f}".replace(".", "p") + hi_tag = f"{knob.name}_x{multipliers[1]:.2f}".replace(".", "p") + s_lo = by_tag[lo_tag]["score_s"] + s_hi = by_tag[hi_tag]["score_s"] + base_val = _baseline_value(base, knob) + elast = _elasticity(s_lo, s_hi, score_base, delta) + swing = (s_hi - s_lo) if (s_lo is not None and s_hi is not None) else float("nan") + summary.append( + { + "knob": knob.name, + "label": knob.label, + "base_value": base_val, + "score_lo": s_lo, + "score_hi": s_hi, + "swing_s": swing, + "elasticity": elast, + } + ) + + # CSV with every raw solve. + csv_path = out_dir / "skidpad_sensitivity.csv" + _write_csv(csv_path, results) + + # Tornado plot. + png_path = out_dir / "skidpad_sensitivity.png" + _tornado_plot(png_path, summary, score_base, delta) + + # JSON summary for programmatic use. + json_path = out_dir / "skidpad_sensitivity_summary.json" + with json_path.open("w") as f: + json.dump( + { + "config": str(config_path), + "delta": delta, + "score_base_s": score_base, + "knobs": summary, + }, + f, + indent=2, + ) + + # Console ranking. + ranked = sorted(summary, key=lambda d: abs(d["swing_s"]), reverse=True) + print("\n" + "=" * 78) + print(f"Baseline average timed-lap score: {score_base:.4f} s (+-{delta*100:.0f}% OAT)") + print("=" * 78) + hdr = f"{'parameter':<18}{'base':>10}{'score-':>10}{'score+':>10}{'swing[s]':>11}{'elasticity':>12}" + print(hdr) + print("-" * 78) + for d in ranked: + slo = f"{d['score_lo']:.3f}" if d["score_lo"] is not None else " fail" + shi = f"{d['score_hi']:.3f}" if d["score_hi"] is not None else " fail" + print( + f"{d['label']:<18}{d['base_value']:>10.3f}{slo:>10}{shi:>10}" + f"{d['swing_s']:>11.4f}{d['elasticity']:>12.3f}" + ) + print("-" * 78) + top = ranked[0] + print( + f"\nMost influential parameter: {top['label']} " + f"(|swing| = {abs(top['swing_s']):.4f} s over +-{delta*100:.0f}%, " + f"elasticity = {top['elasticity']:+.3f})." + ) + print(f"\nWrote:\n {csv_path}\n {png_path}\n {json_path}") + + # Tidy scratch. + try: + for p in scratch_dir.glob("*"): + p.unlink() + scratch_dir.rmdir() + except OSError: + pass + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", type=Path, default=_repo_root() / "configs" / "skidpad.yaml") + ap.add_argument( + "--delta", + type=float, + default=0.15, + help="Relative one-at-a-time perturbation (fraction). Default 0.15 (+-15%%).", + ) + ap.add_argument("--jobs", type=int, default=4, help="Parallel solve workers.") + ap.add_argument( + "--out-dir", + type=Path, + default=_repo_root() / "data" / "sensitivity", + help="Output directory for CSV/PNG/JSON.", + ) + args = ap.parse_args() + run(args.config, args.delta, args.jobs, args.out_dir) + + +if __name__ == "__main__": + main()