Spaces:
Running
Running
| """Real-rover validation gate. | |
| This is **the** real-rover validation CI gate. The plan calls out real-rover validation as the critical | |
| pre-ML check: if the evaluator can't reproduce real rover behaviour, we | |
| fix the evaluator before touching the surrogate layer. The tests here | |
| encode the acceptance criteria that let that gate fail loudly in CI. | |
| See :mod:`roverdevkit.validation.rover_comparison` for the scoring | |
| definitions. The gate hits five criteria per rover (range feasibility, | |
| range sanity ceiling, thermal survival match, motor/traversal ok, peak | |
| solar in band); this file adds finer-grained per-rover tests so that | |
| when the gate fires, the failure message points at a specific criterion | |
| rather than a generic aggregate. | |
| Performance | |
| ----------- | |
| Per-rover criteria tests share a session-scoped ``rover_compare_results`` | |
| fixture (see ``conftest.py``) so the entire registry is evaluated only | |
| once per pytest run. Sensitivity tests still call ``evaluate`` directly | |
| because they vary inputs from the cached baseline. | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| from roverdevkit.validation.rover_comparison import ( | |
| ComparisonSummary, | |
| RoverComparisonResult, | |
| acceptance_gate, | |
| compare_all, | |
| ) | |
| from roverdevkit.validation.rover_registry import ( | |
| flown_registry, | |
| registry_by_name, | |
| truth_by_rover, | |
| ) | |
| # Local copy used by the @parametrize decorator. Resolving this at | |
| # import time (rather than via the session-scoped fixture) is necessary | |
| # because parametrize evaluates before fixtures run. | |
| # | |
| # Layer-0 truth comparison uses the *flown* subset of the registry — | |
| # design-target rovers (MoonRanger, Rashid-1) have no published flight | |
| # data and only participate in the Layer-1 surrogate sanity check | |
| # (baseline-surrogate). | |
| REGISTERED_ROVERS = [e.rover_name for e in flown_registry()] | |
| # --------------------------------------------------------------------------- | |
| # Aggregate gate | |
| # --------------------------------------------------------------------------- | |
| def test_acceptance_gate_passes_for_full_registry( | |
| rover_compare_summary: ComparisonSummary, | |
| ) -> None: | |
| """The real-rover validation gate: every registered rover passes every criterion.""" | |
| acceptance_gate(rover_compare_summary) | |
| assert rover_compare_summary.all_pass | |
| assert rover_compare_summary.n_pass == len(REGISTERED_ROVERS) | |
| def test_comparison_summary_is_deterministic_across_runs( | |
| rover_compare_summary: ComparisonSummary, | |
| ) -> None: | |
| """Two back-to-back runs must produce identical scoring. | |
| If this ever flakes, either (a) `evaluate` has an unseeded random | |
| source, or (b) the traverse sim has a floating-point sensitivity | |
| we haven't documented. Both are bugs. | |
| The cached summary is the "first" run; we issue a second | |
| ``compare_all()`` for the comparison. Determinism still costs one | |
| extra full-registry pass but only this single test pays for it. | |
| """ | |
| second = compare_all() | |
| for a, b in zip(rover_compare_summary.results, second.results, strict=True): | |
| assert a.range_m_predicted == pytest.approx(b.range_m_predicted) | |
| assert a.peak_solar_power_w_predicted == pytest.approx(b.peak_solar_power_w_predicted) | |
| assert a.metrics.thermal_survival == b.metrics.thermal_survival | |
| assert a.passes == b.passes | |
| # --------------------------------------------------------------------------- | |
| # Per-rover per-criterion tests (fail messages are self-diagnosing) | |
| # --------------------------------------------------------------------------- | |
| def test_range_is_feasible_vs_published( | |
| rover_name: str, | |
| rover_compare_results: dict[str, RoverComparisonResult], | |
| ) -> None: | |
| """Predicted range >= published low-band: sim must claim the rover | |
| *could* at least reach what it actually flew.""" | |
| result = rover_compare_results[rover_name] | |
| assert result.range_feasible, ( | |
| f"{rover_name}: predicted range {result.range_m_predicted:.1f} m is " | |
| f"below the published low bound {result.truth.traverse_m_low:.1f} m." | |
| ) | |
| def test_range_below_sanity_ceiling( | |
| rover_name: str, | |
| rover_compare_results: dict[str, RoverComparisonResult], | |
| ) -> None: | |
| """Predicted range <= 10 x published high band: catch pathological | |
| over-prediction (e.g. a broken stall detector).""" | |
| result = rover_compare_results[rover_name] | |
| assert result.range_below_sanity_ceiling, ( | |
| f"{rover_name}: predicted range {result.range_m_predicted:.1f} m " | |
| f"exceeds 10x the published high bound " | |
| f"{result.truth.traverse_m_high:.1f} m." | |
| ) | |
| def test_thermal_survival_matches_published( | |
| rover_name: str, | |
| rover_compare_results: dict[str, RoverComparisonResult], | |
| ) -> None: | |
| """Sim's hot+cold steady-state survival prediction matches reality. | |
| Pragyan's published False (died in lunar night) is the strongest | |
| signal in the set - it validates the sink-temp + RHU-carrying | |
| logic. | |
| """ | |
| result = rover_compare_results[rover_name] | |
| assert result.thermal_matches, ( | |
| f"{rover_name}: thermal prediction {result.metrics.thermal_survival} " | |
| f"!= published {result.truth.thermal_survival_published}." | |
| ) | |
| def test_motor_and_traversal_not_stalled( | |
| rover_name: str, | |
| rover_compare_results: dict[str, RoverComparisonResult], | |
| ) -> None: | |
| """Motor torque within envelope and rover didn't stall on scenario slope.""" | |
| result = rover_compare_results[rover_name] | |
| assert result.motor_and_traversal_ok, ( | |
| f"{rover_name}: rover stalled on the " | |
| f"scenario's {registry_by_name(rover_name).scenario.max_slope_deg:.0f} " | |
| f"deg typical-ops slope (schema v6 stall gate)." | |
| ) | |
| def test_peak_solar_power_in_published_band( | |
| rover_name: str, | |
| rover_compare_results: dict[str, RoverComparisonResult], | |
| ) -> None: | |
| """Predicted peak solar power sits inside the published low/high band.""" | |
| result = rover_compare_results[rover_name] | |
| assert result.peak_solar_in_band, ( | |
| f"{rover_name}: predicted peak solar " | |
| f"{result.peak_solar_power_w_predicted:.1f} W is outside the " | |
| f"published band [{result.truth.peak_solar_power_w_low:.1f}, " | |
| f"{result.truth.peak_solar_power_w_high:.1f}] W." | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Sensitivity: "right direction when parameters change" (plan §6 real-rover validation) | |
| # --------------------------------------------------------------------------- | |
| def test_larger_battery_never_reduces_energy_margin() -> None: | |
| """Monotonic: doubling the battery should not decrease energy margin.""" | |
| from roverdevkit.mission.evaluator import evaluate | |
| base = registry_by_name("Pragyan") | |
| baseline = evaluate( | |
| base.design, | |
| base.scenario, | |
| gravity_m_per_s2=base.gravity_m_per_s2, | |
| thermal_architecture=base.thermal_architecture, | |
| ) | |
| larger = base.design.model_copy( | |
| update={"battery_capacity_wh": min(500.0, base.design.battery_capacity_wh * 2.0)} | |
| ) | |
| bigger_battery = evaluate( | |
| larger, | |
| base.scenario, | |
| gravity_m_per_s2=base.gravity_m_per_s2, | |
| thermal_architecture=base.thermal_architecture, | |
| ) | |
| assert bigger_battery.energy_margin_pct >= baseline.energy_margin_pct - 1e-6 | |
| def test_polar_latitude_reduces_peak_solar_power() -> None: | |
| """Yutu-2 at 45 deg latitude must see higher peak solar than at 85 deg.""" | |
| from roverdevkit.power.solar import panel_power_w, sun_elevation_deg | |
| yutu2 = registry_by_name("Yutu-2") | |
| peak_mid = panel_power_w( | |
| panel_area_m2=yutu2.design.solar_area_m2, | |
| panel_efficiency=yutu2.panel_efficiency, | |
| sun_elevation_deg=sun_elevation_deg(45.5, lunar_hour_angle_deg=0.0), | |
| dust_degradation_factor=yutu2.panel_dust_factor, | |
| ) | |
| peak_polar = panel_power_w( | |
| panel_area_m2=yutu2.design.solar_area_m2, | |
| panel_efficiency=yutu2.panel_efficiency, | |
| sun_elevation_deg=sun_elevation_deg(-85.0, lunar_hour_angle_deg=0.0), | |
| dust_degradation_factor=yutu2.panel_dust_factor, | |
| ) | |
| assert peak_mid > peak_polar | |
| # --------------------------------------------------------------------------- | |
| # Truth-table sanity: catches CSV drift | |
| # --------------------------------------------------------------------------- | |
| def test_every_flown_rover_has_a_truth_row() -> None: | |
| for entry in flown_registry(): | |
| truth = truth_by_rover(entry.rover_name) | |
| assert truth.rover_name == entry.rover_name | |
| assert truth.scenario_name == entry.scenario.name | |
| def test_published_traverse_bands_are_valid() -> None: | |
| """Low <= published <= high for every row.""" | |
| for entry in flown_registry(): | |
| t = truth_by_rover(entry.rover_name) | |
| assert t.traverse_m_low <= t.traverse_m_published <= t.traverse_m_high | |
| assert ( | |
| t.peak_solar_power_w_low <= t.peak_solar_power_w_published <= t.peak_solar_power_w_high | |
| ) | |