"""Tests for the bottom-up mass model and its published-rover validation. Covers: - ``MassBreakdown`` construction, totaling, and immutability; - ``MassModelParams`` field defaults; - per-subsystem physics checks (positivity, monotonicity, linearity); - fixed-point iteration convergence (iterations count, and that result is independent of the starting guess within tolerance); - design-vector wrapper round-tripping through the pydantic schema; - the mass-validation gate: median absolute percent error on in-class rovers must be <= 30 % (plan section 8). """ from __future__ import annotations import math from typing import Any import pytest from roverdevkit.mass import ( MassBreakdown, MassModelParams, estimate_mass, estimate_mass_from_design, validate_against_published_rovers, ) from roverdevkit.mass.parametric_mers import _wheels_mass from roverdevkit.schema import DesignVector def _rashid_like_kwargs(**overrides: Any) -> dict[str, Any]: """Reasonable Rashid-class design vector for tests.""" # Schema v6 (v6 schema update): ``peak_wheel_torque_nm`` is a true design # input that sizes motor mass directly. 1.0 Nm is in the middle of # the Rashid / Pragyan-class hub-torque band. base: dict[str, Any] = dict( wheel_radius_m=0.10, wheel_width_m=0.05, n_wheels=4, chassis_mass_kg=3.5, solar_area_m2=0.4, battery_capacity_wh=50.0, avionics_power_w=10.0, peak_wheel_torque_nm=1.0, grouser_height_m=0.005, grouser_count=12, ) base.update(overrides) return base # --------------------------------------------------------------------------- # Breakdown / params dataclass behaviour # --------------------------------------------------------------------------- class TestMassBreakdown: def test_total_equals_sum_of_fields(self) -> None: b = MassBreakdown( chassis_kg=1.0, wheels_kg=1.0, motors_and_drives_kg=1.0, solar_panels_kg=1.0, battery_kg=1.0, avionics_kg=1.0, harness_kg=1.0, thermal_kg=1.0, margin_kg=1.0, ) assert b.total_kg == pytest.approx(9.0) assert b.dry_kg == pytest.approx(8.0) def test_is_frozen(self) -> None: from dataclasses import FrozenInstanceError b = MassBreakdown( chassis_kg=1.0, wheels_kg=0.0, motors_and_drives_kg=0.0, solar_panels_kg=0.0, battery_kg=0.0, avionics_kg=0.0, harness_kg=0.0, thermal_kg=0.0, margin_kg=0.0, ) with pytest.raises(FrozenInstanceError): b.chassis_kg = 99.0 # type: ignore[misc] class TestMassModelParams: def test_defaults_are_finite_and_positive(self) -> None: p = MassModelParams() for field_name in ( "wheel_structural_area_density_kg_per_m2", "grouser_plate_thickness_m", "grouser_material_density_kg_per_m3", "motor_base_mass_kg", "motor_specific_torque_kg_per_nm", "motor_peak_friction_coef", "motor_sizing_safety_factor", "solar_specific_area_mass_kg_per_m2", "battery_pack_specific_energy_wh_per_kg", "avionics_base_mass_kg", "avionics_specific_mass_kg_per_w", "harness_fraction", "thermal_fraction", "margin_fraction", "gravity_moon_m_per_s2", ): value = getattr(p, field_name) assert math.isfinite(value) and value > 0, field_name # --------------------------------------------------------------------------- # Per-subsystem physics # --------------------------------------------------------------------------- class TestWheelsMass: def test_positive(self) -> None: m = _wheels_mass( wheel_radius_m=0.1, wheel_width_m=0.05, grouser_height_m=0.005, grouser_count=12, n_wheels=4, params=MassModelParams(), ) assert m > 0 def test_larger_wheel_weighs_more(self) -> None: params = MassModelParams() small = _wheels_mass(0.08, 0.04, 0.0, 0, 4, params) big = _wheels_mass(0.16, 0.08, 0.0, 0, 4, params) assert big > small def test_more_wheels_weigh_more(self) -> None: params = MassModelParams() four = _wheels_mass(0.1, 0.05, 0.0, 0, 4, params) six = _wheels_mass(0.1, 0.05, 0.0, 0, 6, params) assert six == pytest.approx(1.5 * four) def test_grouser_mass_linear_in_count(self) -> None: params = MassModelParams() base = _wheels_mass(0.1, 0.05, 0.005, 0, 4, params) m12 = _wheels_mass(0.1, 0.05, 0.005, 12, 4, params) m24 = _wheels_mass(0.1, 0.05, 0.005, 24, 4, params) assert m12 > base assert (m24 - base) == pytest.approx(2 * (m12 - base)) @pytest.mark.parametrize( "bad_kwargs", [ dict(wheel_radius_m=0.0, wheel_width_m=0.05), dict(wheel_radius_m=-0.1, wheel_width_m=0.05), dict(wheel_radius_m=0.1, wheel_width_m=0.0), dict(wheel_radius_m=0.1, wheel_width_m=0.05, grouser_height_m=-0.01), dict(wheel_radius_m=0.1, wheel_width_m=0.05, grouser_count=-1), ], ) def test_rejects_bad_input(self, bad_kwargs: dict[str, Any]) -> None: defaults: dict[str, Any] = dict( wheel_radius_m=0.1, wheel_width_m=0.05, grouser_height_m=0.005, grouser_count=12, n_wheels=4, ) defaults.update(bad_kwargs) with pytest.raises(ValueError): _wheels_mass(params=MassModelParams(), **defaults) class TestEstimateMassSubsystemLinearities: """``_solar_panels_mass``, ``_battery_mass``, and ``_avionics_mass`` are deliberately linear in their respective design variable.""" def test_solar_mass_linear_in_area(self) -> None: b1 = estimate_mass(**_rashid_like_kwargs(solar_area_m2=0.2)) b2 = estimate_mass(**_rashid_like_kwargs(solar_area_m2=0.4)) b3 = estimate_mass(**_rashid_like_kwargs(solar_area_m2=0.8)) assert b2.solar_panels_kg == pytest.approx(2 * b1.solar_panels_kg) assert b3.solar_panels_kg == pytest.approx(4 * b1.solar_panels_kg) def test_battery_mass_linear_in_capacity(self) -> None: b1 = estimate_mass(**_rashid_like_kwargs(battery_capacity_wh=50.0)) b2 = estimate_mass(**_rashid_like_kwargs(battery_capacity_wh=200.0)) assert b2.battery_kg == pytest.approx(4 * b1.battery_kg) def test_avionics_mass_affine_in_power(self) -> None: b1 = estimate_mass(**_rashid_like_kwargs(avionics_power_w=10.0)) b2 = estimate_mass(**_rashid_like_kwargs(avionics_power_w=30.0)) # P increases by 20 W -> avionics mass increases by # 20 * 0.05 = 1.0 kg per MassModelParams defaults. assert b2.avionics_kg - b1.avionics_kg == pytest.approx(1.0, abs=1e-9) # --------------------------------------------------------------------------- # Top-level estimate_mass behaviour # --------------------------------------------------------------------------- class TestEstimateMass: def test_returns_positive_subsystems(self) -> None: b = estimate_mass(**_rashid_like_kwargs()) for attr in ( "chassis_kg", "wheels_kg", "motors_and_drives_kg", "solar_panels_kg", "battery_kg", "avionics_kg", "harness_kg", "thermal_kg", "margin_kg", ): assert getattr(b, attr) > 0, attr assert b.total_kg > b.chassis_kg def test_iteration_converges_in_a_single_pass(self) -> None: # Schema v6 (v6 schema update): peak_wheel_torque_nm is a direct design # input, so the pre-v6 fixed-point iteration on motor mass vs. # total mass is gone and ``n_iterations`` is pinned to 1. b = estimate_mass(**_rashid_like_kwargs()) assert b.n_iterations == 1 def test_monotonic_in_chassis_mass(self) -> None: # Schema v6: motor mass is sized by ``peak_wheel_torque_nm``, not # by total vehicle mass, so chassis no longer pulls motor mass # along with it. We only assert the direct-additive effect on # ``total_kg`` here; motor monotonicity is checked separately. b1 = estimate_mass(**_rashid_like_kwargs(chassis_mass_kg=3.0)) b2 = estimate_mass(**_rashid_like_kwargs(chassis_mass_kg=6.0)) assert b2.total_kg > b1.total_kg def test_motor_mass_monotonic_in_peak_wheel_torque(self) -> None: # Schema v6: ``_motors_mass`` is m_0 + k_tau * tau_peak, so # bumping the design's peak hub torque must increase motor mass. b1 = estimate_mass(**_rashid_like_kwargs(peak_wheel_torque_nm=1.0)) b2 = estimate_mass(**_rashid_like_kwargs(peak_wheel_torque_nm=4.0)) assert b2.motors_and_drives_kg > b1.motors_and_drives_kg def test_monotonic_in_wheel_size(self) -> None: b_small = estimate_mass(**_rashid_like_kwargs(wheel_radius_m=0.08, wheel_width_m=0.04)) b_big = estimate_mass(**_rashid_like_kwargs(wheel_radius_m=0.18, wheel_width_m=0.10)) assert b_big.wheels_kg > b_small.wheels_kg def test_monotonic_in_battery_capacity(self) -> None: b1 = estimate_mass(**_rashid_like_kwargs(battery_capacity_wh=30.0)) b2 = estimate_mass(**_rashid_like_kwargs(battery_capacity_wh=200.0)) assert b2.total_kg > b1.total_kg def test_rejects_zero_chassis(self) -> None: with pytest.raises(ValueError): estimate_mass(**_rashid_like_kwargs(chassis_mass_kg=0.0)) class TestPayloadMass: """Schema v9: scientific payload is a top-level mass line item added *outside* the dry-mass growth margin.""" def test_payload_defaults_to_zero(self) -> None: b = estimate_mass(**_rashid_like_kwargs()) assert b.payload_kg == pytest.approx(0.0) def test_payload_added_one_for_one_to_total(self) -> None: b0 = estimate_mass(**_rashid_like_kwargs()) b5 = estimate_mass(**_rashid_like_kwargs(), payload_mass_kg=5.0) # Payload is *not* grown by the margin: total rises by exactly # the payload mass, and no subsystem or margin term changes. assert b5.payload_kg == pytest.approx(5.0) assert b5.total_kg - b0.total_kg == pytest.approx(5.0) assert b5.margin_kg == pytest.approx(b0.margin_kg) assert b5.dry_kg == pytest.approx(b0.dry_kg) def test_dry_kg_excludes_payload_and_margin(self) -> None: b = estimate_mass(**_rashid_like_kwargs(), payload_mass_kg=4.0) assert b.dry_kg == pytest.approx(b.total_kg - b.margin_kg - b.payload_kg) def test_rejects_negative_payload(self) -> None: with pytest.raises(ValueError): estimate_mass(**_rashid_like_kwargs(), payload_mass_kg=-1.0) def test_from_design_forwards_payload(self, rashid_like_design: DesignVector) -> None: b0 = estimate_mass_from_design(rashid_like_design) b3 = estimate_mass_from_design(rashid_like_design, payload_mass_kg=3.0) assert b3.total_kg - b0.total_kg == pytest.approx(3.0) class TestEstimateMassFromDesign: def test_round_trips_through_design_vector(self, rashid_like_design: DesignVector) -> None: b_direct = estimate_mass( wheel_radius_m=rashid_like_design.wheel_radius_m, wheel_width_m=rashid_like_design.wheel_width_m, n_wheels=rashid_like_design.n_wheels, chassis_mass_kg=rashid_like_design.chassis_mass_kg, solar_area_m2=rashid_like_design.solar_area_m2, battery_capacity_wh=rashid_like_design.battery_capacity_wh, avionics_power_w=rashid_like_design.avionics_power_w, peak_wheel_torque_nm=rashid_like_design.peak_wheel_torque_nm, grouser_height_m=rashid_like_design.grouser_height_m, grouser_count=rashid_like_design.grouser_count, ) b_via_dv = estimate_mass_from_design(rashid_like_design) assert b_via_dv.total_kg == pytest.approx(b_direct.total_kg, rel=1e-9) # --------------------------------------------------------------------------- # Published-rover validation gate # --------------------------------------------------------------------------- class TestPublishedRoverValidation: """The plan's real-rover validation gate is <= 30 % error on real rovers (section 8). We enforce it as a test here at mass-validation on the mass-only cross-check to catch regressions early.""" def test_median_in_class_error_below_30_percent(self) -> None: summary = validate_against_published_rovers() assert summary.n_in_class >= 4, "need at least 4 in-class rovers to compute median" assert summary.median_abs_percent_error_in_class <= 30.0 def test_no_in_class_rover_worse_than_30_percent(self) -> None: summary = validate_against_published_rovers() assert abs(summary.worst_in_class.percent_error) <= 30.0 def test_all_in_class_predictions_positive(self) -> None: summary = validate_against_published_rovers() for r in summary.per_rover: assert r.mass_predicted_kg > 0, r.rover_name def test_report_formats(self) -> None: from roverdevkit.mass import format_report summary = validate_against_published_rovers() report = format_report(summary) assert "Rashid" in report assert "Aggregates" in report