"""Tests for the time-stepped traverse simulator.""" from __future__ import annotations import numpy as np import pytest from roverdevkit.mission.scenarios import load_scenario from roverdevkit.mission.traverse_sim import ( TraverseLog, _solve_step_wheel_forces, run_traverse, ) from roverdevkit.schema import DesignVector, MissionScenario from roverdevkit.terramechanics.bekker_wong import SoilParameters, WheelGeometry from roverdevkit.terramechanics.soils import get_soil_parameters @pytest.fixture def soil_nominal(): return get_soil_parameters("Apollo_regolith_nominal") @pytest.fixture def equatorial(rashid_like_design: DesignVector): # use an easier slope so the micro-rover isn't stalled every test return load_scenario("equatorial_mare_traverse").model_copy(update={"max_slope_deg": 5.0}) # --------------------------------------------------------------------------- # Wheel-force slip solve # --------------------------------------------------------------------------- def test_solve_step_wheel_forces_balances_drawbar_pull() -> None: """The solved slip should develop ~the required drawbar pull.""" wheel = WheelGeometry(radius_m=0.12, width_m=0.10, grouser_height_m=0.01, grouser_count=12) soil = SoilParameters(n=1.0, k_c=1.0, k_phi=800.0, cohesion_kpa=0.5, friction_angle_deg=40.0) forces, stalled = _solve_step_wheel_forces(wheel, soil, 40.0, 5.0) assert not stalled assert forces.drawbar_pull_n == pytest.approx(5.0, abs=0.5) # --------------------------------------------------------------------------- # Structural / smoke tests # --------------------------------------------------------------------------- def test_traverse_returns_full_log_for_full_duration( rashid_like_design: DesignVector, equatorial: MissionScenario, soil_nominal, ) -> None: log = run_traverse(rashid_like_design, equatorial, soil_nominal, total_mass_kg=15.0) assert isinstance(log, TraverseLog) # Default dt_s = 3600, duration = 14 days => 337 steps inclusive. n = len(log.t_s) assert n > 200 for arr in ( log.position_m, log.state_of_charge, log.power_in_w, log.power_out_w, log.mobility_power_w, log.slip, log.sinkage_m, log.wheel_torque_nm, log.sun_elevation_deg, ): assert len(arr) == n # Always runs to the end of the mission duration: assert log.t_s[-1] == pytest.approx( equatorial.mission_duration_earth_days * 24 * 3600.0, rel=1e-6 ) def test_time_array_is_monotonic( rashid_like_design: DesignVector, equatorial: MissionScenario, soil_nominal, ) -> None: log = run_traverse(rashid_like_design, equatorial, soil_nominal, 15.0) assert np.all(np.diff(log.t_s) > 0.0) def test_position_is_non_decreasing( rashid_like_design: DesignVector, equatorial: MissionScenario, soil_nominal, ) -> None: log = run_traverse(rashid_like_design, equatorial, soil_nominal, 15.0) assert np.all(np.diff(log.position_m) >= -1e-9) def test_soc_stays_within_physical_bounds( rashid_like_design: DesignVector, equatorial: MissionScenario, soil_nominal, ) -> None: log = run_traverse(rashid_like_design, equatorial, soil_nominal, 15.0) assert np.all(log.state_of_charge >= 0.0) assert np.all(log.state_of_charge <= 1.0) # Floor is the default 0.15: assert np.all(log.state_of_charge >= 0.15 - 1e-6) def test_solar_power_is_zero_during_night( rashid_like_design: DesignVector, equatorial: MissionScenario, soil_nominal, ) -> None: log = run_traverse(rashid_like_design, equatorial, soil_nominal, 15.0) night_mask = log.sun_elevation_deg <= 0.0 assert np.all(log.power_in_w[night_mask] <= 1e-9) # --------------------------------------------------------------------------- # Physics checks # --------------------------------------------------------------------------- def test_position_caps_at_traverse_distance(rashid_like_design: DesignVector, soil_nominal) -> None: # Short traverse + plenty of time so the rover will finish. scenario = MissionScenario( name="crater_rim_survey", latitude_deg=0.0, traverse_distance_m=200.0, terrain_class="mare_nominal", soil_simulant="Apollo_regolith_nominal", mission_duration_earth_days=7.0, max_slope_deg=0.0, ) log = run_traverse(rashid_like_design, scenario, soil_nominal, 12.0) assert log.reached_distance assert log.position_m[-1] == pytest.approx(scenario.traverse_distance_m, rel=1e-3) def test_steeper_slope_draws_more_mobility_power( rashid_like_design: DesignVector, soil_nominal, ) -> None: def make(slope: float) -> MissionScenario: return MissionScenario( name="equatorial_mare_traverse", latitude_deg=10.0, traverse_distance_m=500.0, terrain_class="mare_nominal", soil_simulant="Apollo_regolith_nominal", mission_duration_earth_days=5.0, max_slope_deg=slope, ) flat = run_traverse(rashid_like_design, make(0.0), soil_nominal, 15.0) steep = run_traverse(rashid_like_design, make(10.0), soil_nominal, 15.0) # Average mobility power is higher when climbing than on flat. flat_avg = float(np.mean(flat.mobility_power_w)) steep_avg = float(np.mean(steep.mobility_power_w)) assert steep_avg > flat_avg def test_bigger_solar_area_charges_battery_more( rashid_like_design: DesignVector, equatorial: MissionScenario, soil_nominal, ) -> None: bigger = rashid_like_design.model_copy(update={"solar_area_m2": 1.0}) a = run_traverse(rashid_like_design, equatorial, soil_nominal, 15.0) b = run_traverse(bigger, equatorial, soil_nominal, 15.0) # Integrated energy in is higher for the bigger panel: assert float(np.sum(b.power_in_w)) > float(np.sum(a.power_in_w)) def test_underpowered_rover_eventually_floors_battery(soil_nominal) -> None: # Tiny solar, hefty avionics, long mission -> battery drains and floors. design = DesignVector( wheel_radius_m=0.10, wheel_width_m=0.06, grouser_height_m=0.005, grouser_count=12, n_wheels=4, chassis_mass_kg=6.0, wheelbase_m=0.35, solar_area_m2=0.1, battery_capacity_wh=20.0, avionics_power_w=40.0, peak_wheel_torque_nm=1.5, ) scenario = MissionScenario( name="equatorial_mare_traverse", latitude_deg=89.0, # low sun elevation -> less solar traverse_distance_m=500.0, terrain_class="mare_nominal", soil_simulant="Apollo_regolith_nominal", mission_duration_earth_days=14.0, max_slope_deg=0.0, operational_duty_cycle=0.6, ) log = run_traverse(design, scenario, soil_nominal, 10.0) assert log.battery_floored def test_range_matches_capability_envelope_when_energy_is_non_binding( soil_nominal, ) -> None: """schema-v7 Step A regression (energy non-binding case). Constructed to keep the battery comfortably above floor at every step: a short (1 Earth-day) noon-anchored mission on a flat, nominal-mare site with ample solar + battery + low avionics. Under these conditions the per-step energy-feasibility throttle should never engage, and the delivered range should match the v6 derived-cruise envelope (``log.cruise_speed_mps * log.effective_duty_cycle * mission_duration_s``, capped by ``traverse_distance_m``). Pairs with ``test_range_below_envelope_on_designed_to_floor_case`` below (the energy-binding regression). """ design = DesignVector( wheel_radius_m=0.10, wheel_width_m=0.06, grouser_height_m=0.005, grouser_count=12, n_wheels=4, chassis_mass_kg=6.0, wheelbase_m=0.35, solar_area_m2=1.0, # ample battery_capacity_wh=200.0, # ample avionics_power_w=8.0, # low parasitic load peak_wheel_torque_nm=2.0, ) scenario = MissionScenario( name="equatorial_mare_traverse", latitude_deg=0.0, traverse_distance_m=10_000.0, terrain_class="mare_nominal", soil_simulant="Apollo_regolith_nominal", mission_duration_earth_days=1.0, # in-daylight window max_slope_deg=0.0, operational_duty_cycle=0.3, ) log = run_traverse(design, scenario, soil_nominal, 12.0) duration_s = scenario.mission_duration_earth_days * 86400.0 capability_m = min( log.cruise_speed_mps * log.effective_duty_cycle * duration_s, scenario.traverse_distance_m, ) assert not log.battery_floored assert log.position_m[-1] == pytest.approx(capability_m, rel=1e-2) def test_range_below_envelope_on_designed_to_floor_case(soil_nominal) -> None: """schema-v7 Step A regression (energy-binding case). Same low-solar / high-avionics / high-duty design as test_underpowered_rover_eventually_floors_battery; here we additionally assert that the energy-feasibility throttle reduces delivered range strictly below the capability envelope. Before schema-v7 Step A this test would have failed (range was duty * speed * time regardless of energy budget). """ design = DesignVector( wheel_radius_m=0.10, wheel_width_m=0.06, grouser_height_m=0.005, grouser_count=12, n_wheels=4, chassis_mass_kg=6.0, wheelbase_m=0.35, solar_area_m2=0.1, battery_capacity_wh=20.0, avionics_power_w=40.0, peak_wheel_torque_nm=1.5, ) scenario = MissionScenario( name="equatorial_mare_traverse", latitude_deg=89.0, traverse_distance_m=5000.0, # well above kinematic capability terrain_class="mare_nominal", soil_simulant="Apollo_regolith_nominal", mission_duration_earth_days=14.0, max_slope_deg=0.0, operational_duty_cycle=0.6, ) log = run_traverse(design, scenario, soil_nominal, 10.0) # v6: with avionics > P_solar_avg the energy-balance solve returns # v_cruise = 0 (rover can't even sustain its bus load); the # battery-floor pathway then asserts no forward progress at all. assert log.battery_floored assert log.position_m[-1] < scenario.traverse_distance_m - 1.0 def test_unclimbable_slope_records_stall(rashid_like_design: DesignVector, soil_nominal) -> None: # Soft soil + steep slope -> rover stalls. scenario = MissionScenario( name="highland_slope_capability", latitude_deg=10.0, traverse_distance_m=200.0, terrain_class="highland_dense", soil_simulant="Apollo_regolith_nominal", mission_duration_earth_days=3.0, max_slope_deg=30.0, ) loose = get_soil_parameters("Apollo_regolith_loose") log = run_traverse(rashid_like_design, scenario, loose, 20.0) assert log.rover_stalled def test_log_flags_are_all_bools( rashid_like_design: DesignVector, equatorial: MissionScenario, soil_nominal, ) -> None: log = run_traverse(rashid_like_design, equatorial, soil_nominal, 15.0) assert isinstance(log.battery_floored, bool) assert isinstance(log.rover_stalled, bool) assert isinstance(log.reached_distance, bool) assert isinstance(log.terminated_reason, str) assert log.terminated_reason # non-empty