"""Render the terramechanics model-form sensitivity figure. Overlays the four-scenario Pareto fronts (range vs. mass) produced under a sweep of the Bekker-Wong shear-stress perturbation (:func:`roverdevkit.terramechanics.bekker_wong.traction_perturbation`), showing how far the headline fronts move when the kernel is perturbed by its own measured drawbar-pull model-form error. Consumes the artifacts written by ``scripts/run_terramechanics_sensitivity.py``. Usage ----- :: python scripts/run_terramechanics_sensitivity.py # writes the fronts python scripts/make_terramechanics_sensitivity_figure.py """ from __future__ import annotations import argparse import sys from pathlib import Path _REPO_ROOT = Path(__file__).resolve().parents[1] if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) from roverdevkit.tradespace.visualize import ( # noqa: E402 CANONICAL_SCENARIO_LABELS, PAPER_FIGURE_DPI, set_paper_rcparams, ) # Nominal shear scale (the unperturbed kernel = the canonical paper fronts). _NOMINAL_SCALE = 1.00 def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: p = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) p.add_argument( "--sensitivity-dir", type=Path, default=Path("reports/terramechanics_sensitivity"), help="Directory holding front___scale_.csv files.", ) p.add_argument( "--out", type=Path, default=Path("paper/figures/fig_terramechanics_sensitivity.png"), ) return p.parse_args(argv) def _scale_label(scale: float, calib: dict[float, float]) -> str: """Legend label: shear scale annotated with its median net-DP shift.""" if scale == _NOMINAL_SCALE: return f"{scale:.2f} (nominal)" dp = calib.get(round(scale, 2)) sign = "+" if scale > _NOMINAL_SCALE else "\u2212" if dp is not None: return f"{scale:.2f} ({sign}{dp:.0f}% DP)" return f"{scale:.2f}" def main(argv: list[str] | None = None) -> int: import matplotlib.colors as mcolors import matplotlib.pyplot as plt import pandas as pd from matplotlib import colormaps args = _parse_args(argv) sens_dir = args.sensitivity_dir calib: dict[float, float] = {} calib_path = sens_dir / "traction_scale_calibration.csv" if calib_path.exists(): cdf = pd.read_csv(calib_path) calib = { round(float(r.shear_scale), 2): float(r.median_abs_dp_shift_pct) for r in cdf.itertuples() } set_paper_rcparams() # Discover the swept scales from the filenames. scales: set[float] = set() for path in sens_dir.glob("front_*__scale_*.csv"): tag = path.stem.split("__scale_")[-1] scales.add(round(float(tag.replace("p", ".")), 2)) if not scales: raise FileNotFoundError( f"no front_*__scale_*.csv files in {sens_dir}; " "run scripts/run_terramechanics_sensitivity.py first." ) sorted_scales = sorted(scales) # Diverging color map centered on the nominal scale: pessimistic # traction (scale < 1) blue, optimistic (scale > 1) red. span = max(_NOMINAL_SCALE - sorted_scales[0], sorted_scales[-1] - _NOMINAL_SCALE) norm = mcolors.Normalize(vmin=_NOMINAL_SCALE - span, vmax=_NOMINAL_SCALE + span) cmap = colormaps["coolwarm"] fig, axes = plt.subplots(2, 2, figsize=(9.5, 7.5)) for ax, (slug, title) in zip(axes.ravel(), CANONICAL_SCENARIO_LABELS.items()): for scale in sorted_scales: tag = f"{scale:.2f}".replace(".", "p") path = sens_dir / f"front_{slug}__scale_{tag}.csv" if not path.exists(): continue df = pd.read_csv(path).sort_values("total_mass_kg") # Range-vs-mass is a 2-D projection of a 3-objective # (range, mass, slope) front, so the raw points are not # monotonic. We draw the *attainment frontier* -- the best # range achievable at or below each mass (a cumulative max) -- # so each scale is one clean curve and the vertical gap # between curves is the conclusion band under the perturbation. mass = df["total_mass_kg"].to_numpy() rng = df["range_km"].cummax().to_numpy() is_nominal = scale == _NOMINAL_SCALE ax.step( mass, rng, where="post", marker="o", markersize=3.0 if is_nominal else 2.0, linewidth=2.2 if is_nominal else 1.3, color="black" if is_nominal else cmap(norm(scale)), alpha=1.0 if is_nominal else 0.9, zorder=5 if is_nominal else 3, label=_scale_label(scale, calib), ) ax.set_title(title) ax.set_xlabel("total mass (kg)") ax.set_ylabel("max range at \u2264 mass (km)") handles, labels = axes.ravel()[0].get_legend_handles_labels() fig.legend( handles, labels, title="shear scale (= traction model-form perturbation)", loc="lower center", ncol=len(sorted_scales), bbox_to_anchor=(0.5, -0.02), ) fig.suptitle( "Pareto-front sensitivity to the terramechanics model-form error\n" "(range vs. mass; black = unperturbed kernel)" ) fig.tight_layout(rect=(0, 0.04, 1, 1)) args.out.parent.mkdir(parents=True, exist_ok=True) fig.savefig(args.out, dpi=PAPER_FIGURE_DPI, bbox_inches="tight") plt.close(fig) print(f"Wrote {args.out}") return 0 if __name__ == "__main__": sys.exit(main())