"""End-to-end mission-evaluator integration tests. Smoke tests in mission-evaluator: the pipeline runs end-to-end on every canonical scenario for a Rashid-like design and returns finite, in-range metrics. The real-rover validation acceptance test -- "loaded with Yutu-2 / Pragyan / Rashid parameters, does the evaluator predict daily traverse distance and power profile in the right order of magnitude?" -- lives in a separate notebook. """ from __future__ import annotations import math import pytest from roverdevkit.mission.evaluator import evaluate from roverdevkit.mission.scenarios import list_scenarios, load_scenario from roverdevkit.schema import DesignVector, MissionMetrics, MissionScenario @pytest.mark.integration def test_evaluator_returns_mission_metrics( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: metrics = evaluate(rashid_like_design, equatorial_scenario) assert isinstance(metrics, MissionMetrics) @pytest.mark.integration def test_mass_in_micro_rover_class( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: metrics = evaluate(rashid_like_design, equatorial_scenario) # Rashid was ~10 kg; the design vector yields a bottom-up estimate # in the 5-50 kg lunar micro-rover class. assert 5.0 <= metrics.total_mass_kg <= 50.0 @pytest.mark.integration def test_all_metrics_are_finite( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: m = evaluate(rashid_like_design, equatorial_scenario) for value in ( m.range_km, m.energy_margin_pct, m.slope_capability_deg, m.total_mass_kg, m.peak_motor_torque_nm, m.sinkage_max_m, ): assert math.isfinite(value) assert value >= 0.0 @pytest.mark.integration def test_payload_mass_increases_total_mass( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: """Schema v9: a scenario payload mass adds one-for-one to total mass.""" base = evaluate(rashid_like_design, equatorial_scenario) loaded = evaluate( rashid_like_design, equatorial_scenario.model_copy(update={"payload_mass_kg": 8.0}), ) assert loaded.total_mass_kg - base.total_mass_kg == pytest.approx(8.0, abs=1e-6) @pytest.mark.integration def test_payload_override_beats_scenario_default( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: """The per-call ``payload_mass_kg`` override supersedes the YAML value.""" scenario = equatorial_scenario.model_copy(update={"payload_mass_kg": 5.0}) base = evaluate(rashid_like_design, equatorial_scenario) overridden = evaluate(rashid_like_design, scenario, payload_mass_kg=0.0) assert overridden.total_mass_kg == pytest.approx(base.total_mass_kg, abs=1e-6) @pytest.mark.integration def test_payload_power_reduces_range( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: """Schema v9: payload draws continuous power, so a heavy payload load cannot *increase* achievable range (it competes with mobility).""" base = evaluate(rashid_like_design, equatorial_scenario) loaded = evaluate( rashid_like_design, equatorial_scenario.model_copy(update={"payload_power_w": 25.0}), ) assert loaded.range_km <= base.range_km + 1e-9 @pytest.mark.integration def test_range_bounded_by_traverse_distance( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: m = evaluate(rashid_like_design, equatorial_scenario) # range_km cannot exceed traverse_distance_m/1000 -- the sim caps it. assert m.range_km <= equatorial_scenario.traverse_distance_m / 1000.0 + 1e-9 @pytest.mark.integration def test_slope_capability_within_schema_bounds( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: m = evaluate(rashid_like_design, equatorial_scenario) # schema allows 0-35 deg assert 0.0 <= m.slope_capability_deg <= 35.0 @pytest.mark.integration @pytest.mark.parametrize("name", sorted(list_scenarios())) def test_evaluator_runs_on_every_scenario(rashid_like_design: DesignVector, name: str) -> None: scenario = load_scenario(name) metrics = evaluate(rashid_like_design, scenario) assert metrics.total_mass_kg > 0.0 assert metrics.range_km >= 0.0 @pytest.mark.integration def test_bigger_battery_gives_higher_or_equal_energy_margin( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: baseline = evaluate(rashid_like_design, equatorial_scenario) bigger_battery = rashid_like_design.model_copy(update={"battery_capacity_wh": 400.0}) upgraded = evaluate(bigger_battery, equatorial_scenario) # A bigger battery cannot worsen energy margin on the same scenario. assert upgraded.energy_margin_pct >= baseline.energy_margin_pct - 1e-6 @pytest.mark.integration def test_denser_soil_boosts_slope_capability( rashid_like_design: DesignVector, ) -> None: def make(soil: str, slope: float) -> MissionScenario: return MissionScenario( name="highland_slope_capability", latitude_deg=10.0, traverse_distance_m=500.0, terrain_class="highland_dense", soil_simulant=soil, mission_duration_earth_days=5.0, max_slope_deg=slope, ) loose = evaluate(rashid_like_design, make("Apollo_regolith_loose", 10.0)) dense = evaluate(rashid_like_design, make("Apollo_regolith_dense", 10.0)) assert dense.slope_capability_deg > loose.slope_capability_deg # --------------------------------------------------------------------------- # Schema v6/v7 (v6 schema update): operational_duty_cycle override # --------------------------------------------------------------------------- # # The pre-v6 ``range_at_utilisation`` post-hoc rescaler is gone. Schema # v6 plumbed an ``operational_duty_cycle`` override directly into the # evaluator. Schema v7 collapsed the v6 ``min(δ_des, δ_ops)`` rule # into ``δ_eff = clamp(δ_ops, [0, 1])`` after ``designed_duty_cycle`` # was removed from the design vector. The tests below pin the v7 # contract. @pytest.mark.integration def test_evaluate_default_uses_scenario_operational_duty_cycle( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: """``operational_duty_cycle=None`` reproduces the scenario default.""" metrics_default = evaluate(rashid_like_design, equatorial_scenario) metrics_explicit = evaluate( rashid_like_design, equatorial_scenario, operational_duty_cycle=equatorial_scenario.operational_duty_cycle, ) assert math.isclose(metrics_default.range_km, metrics_explicit.range_km, rel_tol=1e-9) @pytest.mark.integration def test_lower_operational_duty_cycle_does_not_increase_range( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: """Halving δ_ops cannot grow forward progress (it scales linearly with δ_eff in the kinematic regime; the energy-binding regime cancels δ_eff out, in which case range is invariant).""" base = evaluate( rashid_like_design, equatorial_scenario, operational_duty_cycle=equatorial_scenario.operational_duty_cycle, ) half = evaluate( rashid_like_design, equatorial_scenario, operational_duty_cycle=0.5 * equatorial_scenario.operational_duty_cycle, ) assert half.range_km <= base.range_km + 1e-9 @pytest.mark.integration def test_operational_duty_cycle_override_changes_effective_duty( rashid_like_design: DesignVector, equatorial_scenario: MissionScenario ) -> None: """Schema v7: δ_eff equals the supplied δ_ops (clamped to [0, 1]).""" from roverdevkit.mission.evaluator import evaluate_verbose detailed_low = evaluate_verbose( rashid_like_design, equatorial_scenario, operational_duty_cycle=0.10, ) detailed_high = evaluate_verbose( rashid_like_design, equatorial_scenario, operational_duty_cycle=0.40, ) assert detailed_low.log.effective_duty_cycle == pytest.approx(0.10) assert detailed_high.log.effective_duty_cycle == pytest.approx(0.40)