roverdevkit / scripts /make_terramechanics_experiment_figure.py
jjreif's picture
Deploy roverdevkit @ 2676a67
b3d14e3
Raw
History Blame Contribute Delete
4.85 kB
"""Render the terramechanics validation figure (Fig. 8).
Reproduces ``paper/figures/fig_terramechanics_experiment.png``: the analytical
Bekker-Wong physics layer evaluated against measured single-wheel drawbar pull
and sinkage from three independent sources (Ding 2011, Wang & Han 2016 KLS-1,
Hurrell 2025 Rashid-1), smooth and grousered. Data and BW predictions come from
``roverdevkit.validation.terramechanics_experiment`` (digitised measurements in
``data/validation/single_wheel_experiments.csv``).
Usage
-----
::
python scripts/make_terramechanics_experiment_figure.py
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from roverdevkit.tradespace.visualize import set_paper_rcparams
from roverdevkit.validation.terramechanics_experiment import (
compare_to_experiment,
summarise,
)
SOURCE_LABELS: dict[str, str] = {
"ding2011": "Ding et al. 2011 (Wh3, R=157 mm, 80 N)",
"wang_han_2016_kls1": "Wang & Han 2016, KLS-1 (R=85 mm, 59 N)",
"hurrell2025_rashid1": "Hurrell et al. 2025, Rashid-1 (R=100 mm, 24.5 N)",
}
# (axis label, measured column, BW column, unit scale).
QUANTITIES = [
("drawbar pull (N)", "meas_drawbar_pull_n", "bw_drawbar_pull_n", 1.0),
("sinkage (mm)", "meas_sinkage_m", "bw_sinkage_m", 1000.0),
]
# (grouser height selector, marker, name, colour); None -> the grousered family.
FAMILIES = [(0.0, "o", "smooth", "#2166ac"), (None, "s", "grousered", "#b2182b")]
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
p.add_argument(
"--out",
type=Path,
default=Path("paper/figures/fig_terramechanics_experiment.png"),
)
return p.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv)
terra = compare_to_experiment()
summary = summarise(terra)
print(
f"operating points: {summary['n_operating_points']} | "
f"digitised: {summary['n_digitised']} | "
f"pending: {summary['n_pending_digitisation']}"
)
if summary["n_digitised"]:
print(
f"BW median |%err| DP: {summary['bw_dp_median_abs_pct_err']:.1f}% "
f"sinkage: {summary['bw_sinkage_median_abs_pct_err']:.1f}%"
)
# Plot sources that have measured data; fall back to all (model-only preview).
with_data = [
s for s in SOURCE_LABELS
if terra[(terra["source"] == s) & terra["meas_drawbar_pull_n"].notna()].shape[0]
]
plot_sources = with_data or [s for s in SOURCE_LABELS if (terra["source"] == s).any()]
set_paper_rcparams()
import matplotlib.pyplot as plt
fig, axes = plt.subplots(
len(QUANTITIES), len(plot_sources),
figsize=(4.7 * len(plot_sources), 7.4), squeeze=False,
)
for col, source in enumerate(plot_sources):
sub = terra[terra["source"] == source]
hg_lug = sub.loc[sub["grouser_height_m"] > 0, "grouser_height_m"].max()
for row, (ylab, meascol, bwcol, scale) in enumerate(QUANTITIES):
ax = axes[row][col]
for hg, marker, _name, color in FAMILIES:
target_hg = hg_lug if hg is None else hg
if target_hg != target_hg: # NaN -> no grousered family
continue
fam = sub[sub["grouser_height_m"] == target_hg].sort_values("slip")
if fam.empty:
continue
fam_label = (
"smooth (h=0)" if target_hg == 0
else f"grousered (h={target_hg * 1000:.0f} mm)"
)
ax.plot(
fam["slip"], fam[bwcol] * scale, "-", color=color, lw=1.6,
label=f"BW \u2014 {fam_label}",
)
meas = fam[fam[meascol].notna()]
if not meas.empty:
ax.plot(
meas["slip"], meas[meascol] * scale, marker, color=color,
ms=8, mfc="none", mew=1.6, label=f"measured \u2014 {fam_label}",
)
if row == 0:
ax.set_title(SOURCE_LABELS[source], fontsize=9)
ax.axhline(0.0, color="0.75", lw=0.6, zorder=0)
ax.set_xlabel("slip ratio")
ax.set_ylabel(ylab)
ax.legend(fontsize=7, frameon=False)
if summary["n_digitised"] == 0:
fig.suptitle(
"Measurements pending digitisation \u2014 BW curves shown",
fontsize=9, y=1.01,
)
fig.tight_layout()
args.out.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(args.out)
plt.close(fig)
print(f"Wrote {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())