roverdevkit / scripts /make_terramechanics_sensitivity_figure.py
jjreif's picture
Deploy roverdevkit @ 2676a67
b3d14e3
Raw
History Blame Contribute Delete
5.73 kB
"""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_<scenario>__scale_<s>.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())