Spaces:
Running
Running
| """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, | |
| } | |
| 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" | |
| ] | |
| 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) | |