roverdevkit / tests /test_mission_evaluator.py
jjreif's picture
Deploy roverdevkit @ 2676a67
b3d14e3
Raw
History Blame Contribute Delete
8.33 kB
"""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)