roverdevkit / tests /test_terramechanics_experiment.py
jjreif's picture
Deploy roverdevkit @ 2676a67
b3d14e3
Raw
History Blame Contribute Delete
5.06 kB
"""Tests for the Layer-3 experiment-vs-model comparison harness.
Two layers of guarantees:
1. **Structural** — the harness loads the digitisation worksheet, resolves
soil parameters, and produces finite, physically-ordered BW predictions
at every operating point (always exercised).
2. **Accuracy** — once measured values are digitised into the worksheet,
BW lands inside the published Bekker-Wong model-form band against the
real measurements. These activate automatically per source as soon as
that source has ``meas_*`` data; they ``skip`` while it is blank, so the
suite stays green before digitisation without ever silently passing on
absent data.
"""
from __future__ import annotations
import math
import pytest
from roverdevkit.validation.terramechanics_experiment import (
compare_to_experiment,
load_experiment_points,
summarise,
)
# Published BW single-wheel model-form error is ~15-30 %; allow a generous
# band so the accuracy check guards against gross disagreement / unit bugs
# rather than over-fitting to a particular digitisation.
BW_MODEL_FORM_BAND_PCT = 40.0
# Per-source acceptance bands. Ding and Hurrell are held to the tight model-form
# band. Wang & Han 2016 (KLS-1) is a documented stress case at the edge of the
# rigid-wheel kernel's regime: the smallest/most-lightly-loaded wheel (R=85 mm,
# 59 N) on a firm, dense, fines-rich simulant that barely sinks, so the
# force-balance sinkage solve over-predicts DP/sinkage and cannot capture the
# s~0.5 soil-disturbance DP collapse. This is a MODEL-FORM limit, not a soil
# mis-specification: KLS-1's pressure-sinkage moduli are its OWN bevameter-fit
# values (Lim et al. 2021), yet the error persists. Its loose band guards
# against unit/sign bugs only -- it is NOT a validation claim.
SOURCE_BAND_PCT = {
"ding2011": BW_MODEL_FORM_BAND_PCT,
"hurrell2025_rashid1": BW_MODEL_FORM_BAND_PCT,
"wang_han_2016_kls1": 200.0,
}
@pytest.fixture(scope="module")
def comparison():
return compare_to_experiment()
def test_worksheet_loads_with_known_sources() -> None:
points = load_experiment_points()
assert len(points) > 0
sources = {p.source for p in points}
assert "ding2011" in sources
assert "wang_han_2016_kls1" in sources
def test_bw_predictions_finite_everywhere(comparison) -> None:
"""Every operating point yields a finite BW prediction (no full burial)."""
assert comparison["bw_drawbar_pull_n"].notna().all()
assert comparison["bw_sinkage_m"].notna().all()
assert comparison["bw_torque_nm"].notna().all()
assert (comparison["bw_sinkage_m"] > 0.0).all()
def test_bw_drawbar_pull_rises_with_slip(comparison) -> None:
"""Within each (source, grouser) family, DP increases monotonically in slip."""
grouped = comparison.sort_values("slip").groupby(["source", "grouser_height_m"])
for _, group in grouped:
dp = group["bw_drawbar_pull_n"].to_numpy()
diffs = dp[1:] - dp[:-1]
assert (diffs >= -1e-6).all(), f"DP not monotonic in slip: {dp}"
def test_grousers_increase_drawbar_pull() -> None:
"""At matched slip/load, a grousered wheel out-pulls a smooth one."""
df = compare_to_experiment()
for source in ("ding2011", "wang_han_2016_kls1"):
sub = df[df["source"] == source]
smooth = sub[sub["grouser_height_m"] == 0.0].set_index("slip")[
"bw_drawbar_pull_n"
]
lugged = sub[sub["grouser_height_m"] > 0.0].set_index("slip")[
"bw_drawbar_pull_n"
]
shared = smooth.index.intersection(lugged.index)
assert len(shared) > 0
for slip in shared:
assert lugged[slip] >= smooth[slip] - 1e-6
def test_summary_has_expected_shape(comparison) -> None:
summary = summarise(comparison)
assert summary["n_operating_points"] == len(comparison)
assert summary["n_digitised"] + summary["n_pending_digitisation"] <= summary[
"n_operating_points"
]
@pytest.mark.parametrize(
"source", ["ding2011", "wang_han_2016_kls1", "hurrell2025_rashid1"]
)
def test_bw_within_band_when_digitised(comparison, source: str) -> None:
"""BW drawbar pull lands in the published band against real measurements.
Skips until the source's ``meas_drawbar_pull_n`` column is populated, so
the check never passes vacuously on absent data.
"""
sub = comparison[
(comparison["source"] == source)
& comparison["meas_drawbar_pull_n"].notna()
# near zero-slip DP crosses zero, so % error is ill-defined there
& (comparison["slip"] >= 0.1)
]
if sub.empty:
pytest.skip(f"{source}: drawbar-pull measurements not yet digitised")
errors = sub["bw_dp_abs_pct_err"].dropna()
assert len(errors) > 0
median_err = float(errors.median())
band = SOURCE_BAND_PCT.get(source, BW_MODEL_FORM_BAND_PCT)
assert median_err < band, (
f"{source}: BW median DP error {median_err:.1f}% exceeds "
f"{band:.0f}% model-form band"
)
assert not math.isnan(median_err)