Spaces:
Sleeping
Sleeping
| """Tests for the Layer-5 rediscovery harness. | |
| Three groups: | |
| 1. **Leakage controls.** Every flown rover maps to one of the | |
| dedicated class-generic ``*_micro`` scenarios returned by | |
| :func:`list_class_generic_micro_scenarios`, not to either a | |
| per-rover validation YAML or one of the canonical tradespace | |
| scenarios (whose ``operational_duty_cycle`` values were | |
| inspection-calibrated against real-rover ops history). The | |
| ``*_micro`` library further enforces a class-neutral δ_ops anchor. | |
| 2. **Result-shape contract.** A run on Pragyan (low-budget NSGA-II) | |
| returns a populated :class:`RediscoveryResult` with the fields the | |
| downstream report generator depends on. | |
| 3. **Determinism.** Two back-to-back runs at the same seed produce | |
| identical results so the paper figure is reproducible. | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| from roverdevkit.mission.scenarios import ( | |
| list_class_generic_micro_scenarios, | |
| list_scenarios, | |
| load_scenario, | |
| ) | |
| from roverdevkit.tradespace.optimizer import DEFAULT_OBJECTIVES | |
| from roverdevkit.validation.rover_registry import flown_registry, registry_by_name | |
| from roverdevkit.validation.rover_rediscovery import ( | |
| RediscoveryResult, | |
| _CLASS_GENERIC_SCENARIO, | |
| _MAX_PANEL_TILT_DEG, | |
| _scenario_panel_orientation, | |
| class_generic_scenario_for, | |
| rediscover, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Leakage controls | |
| # --------------------------------------------------------------------------- | |
| def test_every_flown_rover_has_a_class_generic_scenario() -> None: | |
| """No flown rover falls back to a canonical tradespace or per-rover YAML.""" | |
| micro_names = set(list_class_generic_micro_scenarios()) | |
| for entry in flown_registry(): | |
| scenario_name = class_generic_scenario_for(entry.rover_name) | |
| assert scenario_name in micro_names, ( | |
| f"{entry.rover_name} maps to {scenario_name!r}, which is not " | |
| "one of the class-generic micro-rover scenarios. The " | |
| "rediscovery test must use the *_micro library only." | |
| ) | |
| def test_class_generic_scenarios_are_never_canonical_or_per_rover() -> None: | |
| """No mapped scenario name overlaps with the canonical or per-rover scenario sets. | |
| Both overlap forbidden because: | |
| - per-rover YAMLs (e.g. ``chandrayaan3_pragyan``) carry rover- | |
| specific ops calibration. Reusing them leaks the label into the | |
| search target. | |
| - canonical tradespace YAMLs (e.g. ``polar_prospecting``) pin | |
| ``operational_duty_cycle`` to values that were inspection- | |
| calibrated against real-rover ops history (Pragyan / Yutu-2 / | |
| Apollo-17 LRV / MER) — a weaker but still real leakage path. | |
| """ | |
| per_rover_yaml_names = { | |
| "chandrayaan3_pragyan", | |
| "change4_yutu2_per_lunar_day", | |
| "moonranger_polar_demo", | |
| "rashid_atlas_crater", | |
| "ispace_m2_tenacious", | |
| "cadre_polar_unit", | |
| } | |
| canonical_names = set(list_scenarios()) | |
| for rover_name, scenario_name in _CLASS_GENERIC_SCENARIO.items(): | |
| assert scenario_name not in per_rover_yaml_names, ( | |
| f"{rover_name} maps to {scenario_name!r}, which is a " | |
| "per-rover validation YAML. The rediscovery test would " | |
| "leak per-rover ops calibration into the optimiser." | |
| ) | |
| assert scenario_name not in canonical_names, ( | |
| f"{rover_name} maps to {scenario_name!r}, which is a " | |
| "canonical tradespace YAML with an inspection-calibrated " | |
| "operational_duty_cycle. Use the *_micro library instead." | |
| ) | |
| def test_class_generic_micro_yamls_pin_duty_cycle_to_class_neutral() -> None: | |
| """Every *_micro scenario uses the class-neutral 0.10 δ_ops anchor. | |
| This is the rediscovery library's main correctness guarantee: by | |
| construction no *_micro scenario carries a real-rover ops | |
| calibration, so δ_ops can never leak into the search target. | |
| """ | |
| for name in list_class_generic_micro_scenarios(): | |
| scenario = load_scenario(name) | |
| assert scenario.operational_duty_cycle == pytest.approx(0.10), ( | |
| f"{name} has operational_duty_cycle=" | |
| f"{scenario.operational_duty_cycle}; class-generic micro " | |
| "scenarios must pin δ_ops to 0.10 (class-neutral)." | |
| ) | |
| def test_class_generic_micro_yamls_are_payload_neutral() -> None: | |
| """Schema v9: every *_micro scenario ships a zero payload placeholder. | |
| Scientific payload is a per-rover requirement, so the class-generic | |
| library must not bake in a payload of its own — the rediscovery | |
| harness injects each rover's *published* payload onto the scenario | |
| at run time. A non-zero default here would leak a class-typical | |
| mass requirement into rovers that carry a different instrument suite. | |
| """ | |
| for name in list_class_generic_micro_scenarios(): | |
| scenario = load_scenario(name) | |
| assert scenario.payload_mass_kg == pytest.approx(0.0), ( | |
| f"{name} has payload_mass_kg={scenario.payload_mass_kg}; " | |
| "class-generic micro scenarios must be payload-neutral." | |
| ) | |
| assert scenario.payload_power_w == pytest.approx(0.0) | |
| def test_rediscovery_injects_published_payload_mass() -> None: | |
| """Schema v9: rediscover forwards the rover's published payload mass | |
| onto the class-generic scenario. | |
| Verifies the evaluator-level contract the harness relies on: under | |
| the same class-generic scenario, evaluating the rover's design with | |
| the published payload yields a modelled total mass higher than the | |
| payload-free case by exactly the payload (it sits outside the | |
| dry-mass growth margin). | |
| """ | |
| from roverdevkit.mission.evaluator import evaluate | |
| entry = registry_by_name("Pragyan") | |
| payload = entry.scenario.payload_mass_kg | |
| assert payload > 0.0, "Pragyan should carry a non-zero instrument payload." | |
| scenario = load_scenario(class_generic_scenario_for("Pragyan")) | |
| no_payload = evaluate(entry.design, scenario, payload_mass_kg=0.0) | |
| with_payload = evaluate(entry.design, scenario, payload_mass_kg=payload) | |
| assert with_payload.total_mass_kg == pytest.approx( | |
| no_payload.total_mass_kg + payload, abs=1e-6 | |
| ) | |
| def test_class_generic_micro_library_is_complete() -> None: | |
| """The four *_micro scenarios exist on disk and validate.""" | |
| assert set(list_class_generic_micro_scenarios()) == { | |
| "polar_micro", | |
| "mare_micro", | |
| "highland_micro", | |
| "crater_rim_micro", | |
| } | |
| def test_unknown_rover_raises_keyerror() -> None: | |
| with pytest.raises(KeyError, match="no class-generic scenario"): | |
| class_generic_scenario_for("Definitely-Not-A-Real-Rover") | |
| # --------------------------------------------------------------------------- | |
| # Result-shape contract (end-to-end smoke against one rover) | |
| # --------------------------------------------------------------------------- | |
| def pragyan_rediscovery() -> RediscoveryResult: | |
| """One short-budget rediscovery run cached for the whole module. | |
| The population size is the binding parameter for small-rover | |
| rediscovery: random LHS initialisation needs enough candidates to | |
| contain at least a few mass-feasible designs before NSGA-II's | |
| feasibility-driven selection can take over. 24 is the smallest | |
| population that reliably clears Pragyan's ~22 kg modelled budget; | |
| we also widen ``mass_ceiling_slop`` to 0.20 here purely for test | |
| headroom (the production default of 0.10 still applies in normal | |
| usage). | |
| """ | |
| return rediscover( | |
| "Pragyan", | |
| population_size=24, | |
| n_generations=4, | |
| mass_ceiling_slop=0.20, | |
| seed=0, | |
| ) | |
| def test_rediscovery_returns_pareto_front(pragyan_rediscovery: RediscoveryResult) -> None: | |
| """NSGA-II returns at least one feasible Pareto point.""" | |
| result = pragyan_rediscovery | |
| assert result.optimization_result.design_vectors | |
| assert result.optimization_result.metrics | |
| assert len(result.optimization_result.design_vectors) == len( | |
| result.optimization_result.metrics | |
| ) | |
| def test_rediscovery_uses_class_generic_scenario(pragyan_rediscovery: RediscoveryResult) -> None: | |
| result = pragyan_rediscovery | |
| assert result.class_generic_scenario in list_class_generic_micro_scenarios() | |
| assert result.class_generic_scenario == "polar_micro" | |
| def test_nearest_pareto_design_is_in_front(pragyan_rediscovery: RediscoveryResult) -> None: | |
| result = pragyan_rediscovery | |
| idx = result.nearest_pareto_index | |
| assert 0 <= idx < len(result.optimization_result.design_vectors) | |
| assert result.nearest_pareto_design == result.optimization_result.design_vectors[idx] | |
| def test_per_variable_errors_cover_all_continuous_vars( | |
| pragyan_rediscovery: RediscoveryResult, | |
| ) -> None: | |
| """Every continuous design variable has a signed percent error.""" | |
| expected = { | |
| "wheel_radius_m", | |
| "wheel_width_m", | |
| "grouser_height_m", | |
| "chassis_mass_kg", | |
| "wheelbase_m", | |
| "solar_area_m2", | |
| "battery_capacity_wh", | |
| "avionics_power_w", | |
| "peak_wheel_torque_nm", | |
| } | |
| assert set(result_keys(pragyan_rediscovery.per_variable_percent_errors)) == expected | |
| def test_integer_matches_cover_n_wheels_and_grouser_count( | |
| pragyan_rediscovery: RediscoveryResult, | |
| ) -> None: | |
| assert set(pragyan_rediscovery.integer_matches) == {"n_wheels", "grouser_count"} | |
| def test_mass_ceiling_constraint_respected(pragyan_rediscovery: RediscoveryResult) -> None: | |
| """Every Pareto point sits at or below the published mass + 5% ceiling.""" | |
| result = pragyan_rediscovery | |
| for metric in result.optimization_result.metrics: | |
| assert metric["total_mass_kg"] <= result.mass_budget_kg + 1e-6 | |
| def test_rover_metrics_under_generic_scenario_present( | |
| pragyan_rediscovery: RediscoveryResult, | |
| ) -> None: | |
| """Reference metrics include every objective target.""" | |
| rover_metrics = pragyan_rediscovery.rover_metrics_under_generic_scenario | |
| for obj in DEFAULT_OBJECTIVES: | |
| assert obj.target in rover_metrics | |
| def test_design_space_distance_is_nonnegative( | |
| pragyan_rediscovery: RediscoveryResult, | |
| ) -> None: | |
| assert pragyan_rediscovery.design_space_distance >= 0.0 | |
| # --------------------------------------------------------------------------- | |
| # Determinism (so the paper figure is reproducible) | |
| # --------------------------------------------------------------------------- | |
| def test_rediscovery_is_deterministic_at_fixed_seed() -> None: | |
| kwargs = {"population_size": 24, "n_generations": 4, "mass_ceiling_slop": 0.20, "seed": 42} | |
| a = rediscover("Pragyan", **kwargs) | |
| b = rediscover("Pragyan", **kwargs) | |
| assert a.nearest_pareto_index == b.nearest_pareto_index | |
| assert a.design_space_distance == pytest.approx(b.design_space_distance) | |
| assert a.mass_budget_kg == pytest.approx(b.mass_budget_kg) | |
| assert a.pareto_dominated == b.pareto_dominated | |
| for var, va in a.per_variable_percent_errors.items(): | |
| assert va == pytest.approx(b.per_variable_percent_errors[var]) | |
| # Tiny helper so the keys-check assertion reads cleanly. | |
| def result_keys(mapping: dict[str, float]) -> set[str]: | |
| return set(mapping) | |
| # --------------------------------------------------------------------------- | |
| # Panel-orientation fix (2026-05-28): scenario-driven tilt | |
| # --------------------------------------------------------------------------- | |
| def test_scenario_panel_orientation_collapses_to_horizontal_at_equator() -> None: | |
| """At lat=0 the fixed-tilt approximation is just a horizontal panel.""" | |
| scenario = load_scenario("polar_micro").model_copy(update={"latitude_deg": 0.0}) | |
| tilt, _ = _scenario_panel_orientation(scenario) | |
| assert tilt == pytest.approx(0.0) | |
| def test_scenario_panel_orientation_caps_polar_tilt() -> None: | |
| """At lat=±85 the tilt clamps to ``_MAX_PANEL_TILT_DEG`` (80 deg).""" | |
| south = load_scenario("polar_micro") # already at lat=-85.0 | |
| north = south.model_copy(update={"latitude_deg": +85.0}) | |
| south_tilt, south_az = _scenario_panel_orientation(south) | |
| north_tilt, north_az = _scenario_panel_orientation(north) | |
| assert south_tilt == pytest.approx(_MAX_PANEL_TILT_DEG) | |
| assert north_tilt == pytest.approx(_MAX_PANEL_TILT_DEG) | |
| # Southern-hemisphere rovers face local north (azimuth=0); northern | |
| # rovers face local south (azimuth=180). | |
| assert south_az == pytest.approx(0.0) | |
| assert north_az == pytest.approx(180.0) | |
| def test_scenario_panel_orientation_tracks_latitude_below_cap() -> None: | |
| """Below the cap, tilt = |latitude| so the panel normal points at noon sun.""" | |
| scenario = load_scenario("mare_micro") # lat=+30 | |
| tilt, az = _scenario_panel_orientation(scenario) | |
| assert tilt == pytest.approx(30.0) | |
| assert az == pytest.approx(180.0) | |
| def test_polar_micro_resolves_polar_energy_stall() -> None: | |
| """Pre-fix the horizontal-panel default sent every polar registry rover | |
| to ``range_km = 0`` and ``energy_margin_raw_pct < -70 %`` under the | |
| polar_micro scenario (an ~18x insolation deficit at lat=-85). With | |
| the scenario-driven panel-tilt fix the rover's own re-evaluation | |
| now produces *positive* energy margin for the entire polar trio, | |
| so the rediscovery dominance check is no longer being driven by | |
| the horizontal-panel modelling artefact. | |
| Note: range_km > 0 is asserted only for Pragyan and MoonRanger, | |
| which have enough wheel torque to clear the polar_micro 20-deg | |
| typical-ops slope. CADRE-unit's 0.06 Nm peak wheel torque | |
| bottoms out below the scenario's slope-capability threshold | |
| (12.3 deg < 20 deg), so it still reports range_km=0 and | |
| stalled=True — but for a *mobility* reason now, not an energy | |
| one. That's an honest finding rather than a model artefact and | |
| is documented separately in the comparison report. | |
| """ | |
| from roverdevkit.validation.rover_rediscovery import _evaluate_rover_under | |
| polar_scenario = load_scenario("polar_micro") | |
| # Energy fix: every polar rover now has positive net generation. | |
| for name in ("Pragyan", "MoonRanger", "CADRE-unit"): | |
| entry = registry_by_name(name) | |
| metrics = _evaluate_rover_under(entry, polar_scenario) | |
| assert metrics["energy_margin_raw_pct"] > 0.0, ( | |
| f"{name}: energy_margin_raw_pct={metrics['energy_margin_raw_pct']:.2f}%, " | |
| "expected > 0 with the polar panel-tilt fix in place. The " | |
| "scenario-driven tilt should give the rover ~18x more " | |
| "insolation than the horizontal-panel default at lat=-85." | |
| ) | |
| # Mobility check: rovers with enough torque to clear the | |
| # scenario's 20-deg slope make headway. | |
| for name in ("Pragyan", "MoonRanger"): | |
| entry = registry_by_name(name) | |
| metrics = _evaluate_rover_under(entry, polar_scenario) | |
| assert metrics["range_km"] > 0.0, ( | |
| f"{name}: range_km={metrics['range_km']:.2f} km, expected > 0 " | |
| "with positive energy margin and slope-capable hardware " | |
| "under polar_micro." | |
| ) | |