"""Tests for the lumped-parameter thermal survival check.""" from __future__ import annotations import math import pytest from roverdevkit.power.thermal import ( STEFAN_BOLTZMANN_W_PER_M2_K4, ThermalArchitecture, default_architecture_for_design, evaluate_thermal, survives_mission, ) @pytest.fixture def nominal_arch() -> ThermalArchitecture: return ThermalArchitecture(surface_area_m2=0.1) # --------------------------------------------------------------------------- # Construction validation # --------------------------------------------------------------------------- def test_construction_validates_positive_area() -> None: with pytest.raises(ValueError, match="surface_area_m2"): ThermalArchitecture(surface_area_m2=0.0) def test_construction_validates_emissivity_range() -> None: with pytest.raises(ValueError, match="emissivity"): ThermalArchitecture(surface_area_m2=0.1, emissivity=1.5) def test_construction_validates_op_temp_order() -> None: with pytest.raises(ValueError, match="min_operating_temp_c"): ThermalArchitecture( surface_area_m2=0.1, min_operating_temp_c=50.0, max_operating_temp_c=-30.0, ) # --------------------------------------------------------------------------- # Physics: hot case # --------------------------------------------------------------------------- def test_cold_case_equals_sink_with_no_internal_power() -> None: # With no sun, no hibernation load, and no RHU, equilibrium must equal # the cold-case sink temperature. arch = ThermalArchitecture( surface_area_m2=0.1, absorptivity=0.0, hibernation_power_w=0.0, rhu_power_w=0.0, ) result = evaluate_thermal(arch, avionics_power_w=0.0, latitude_deg=0.0) assert result.lunar_night_temp_c == pytest.approx( arch.sink_temp_lunar_night_k - 273.15, abs=0.1 ) def test_hot_case_is_hotter_at_equator_than_at_pole( nominal_arch: ThermalArchitecture, ) -> None: equator = evaluate_thermal(nominal_arch, avionics_power_w=10.0, latitude_deg=0.0) pole = evaluate_thermal(nominal_arch, avionics_power_w=10.0, latitude_deg=85.0) assert equator.peak_sun_temp_c > pole.peak_sun_temp_c def test_hot_case_temp_increases_with_avionics_power( nominal_arch: ThermalArchitecture, ) -> None: low = evaluate_thermal(nominal_arch, avionics_power_w=5.0, latitude_deg=20.0) high = evaluate_thermal(nominal_arch, avionics_power_w=30.0, latitude_deg=20.0) assert high.peak_sun_temp_c > low.peak_sun_temp_c # --------------------------------------------------------------------------- # Physics: cold case # --------------------------------------------------------------------------- def test_cold_case_temp_increases_with_rhu_power() -> None: warm = ThermalArchitecture(surface_area_m2=0.1, rhu_power_w=10.0) cold = ThermalArchitecture(surface_area_m2=0.1, rhu_power_w=0.0) warm_r = evaluate_thermal(warm, avionics_power_w=15.0, latitude_deg=20.0) cold_r = evaluate_thermal(cold, avionics_power_w=15.0, latitude_deg=20.0) assert warm_r.lunar_night_temp_c > cold_r.lunar_night_temp_c def test_cold_case_does_not_depend_on_operating_avionics_power( nominal_arch: ThermalArchitecture, ) -> None: low = evaluate_thermal(nominal_arch, avionics_power_w=5.0, latitude_deg=20.0) high = evaluate_thermal(nominal_arch, avionics_power_w=30.0, latitude_deg=20.0) assert low.lunar_night_temp_c == pytest.approx(high.lunar_night_temp_c, abs=1e-6) # --------------------------------------------------------------------------- # Survival flag # --------------------------------------------------------------------------- def test_survive_is_true_for_well_balanced_rover() -> None: # OSR-like coating (low alpha, high eps), modest RHU, and enough # hibernation draw keep the cold case above -30 C without frying # the rover at noon. A realistic passing design. arch = ThermalArchitecture( surface_area_m2=0.1, absorptivity=0.15, emissivity=0.9, rhu_power_w=15.0, hibernation_power_w=5.0, ) result = evaluate_thermal(arch, avionics_power_w=15.0, latitude_deg=20.0) assert arch.min_operating_temp_c <= result.lunar_night_temp_c assert result.peak_sun_temp_c <= arch.max_operating_temp_c assert survives_mission(arch, avionics_power_w=15.0, latitude_deg=20.0) is True def test_survive_is_false_for_unheated_rover_in_lunar_night() -> None: arch = ThermalArchitecture( surface_area_m2=0.3, rhu_power_w=0.0, hibernation_power_w=0.1, ) # No internal power, no sun: T ≈ sink (100 K = -173 C), fails cold limit. result = evaluate_thermal(arch, avionics_power_w=15.0, latitude_deg=20.0) assert result.lunar_night_temp_c < arch.min_operating_temp_c assert not result.survives def test_survive_is_false_if_overheating_in_hot_case() -> None: # Very absorptive, tiny area, small emissivity -> overheats fast. arch = ThermalArchitecture( surface_area_m2=0.02, absorptivity=1.0, emissivity=0.3, rhu_power_w=20.0, hibernation_power_w=5.0, ) result = evaluate_thermal(arch, avionics_power_w=30.0, latitude_deg=0.0) assert result.peak_sun_temp_c > arch.max_operating_temp_c # --------------------------------------------------------------------------- # Sanity: radiative balance closes # --------------------------------------------------------------------------- def test_radiative_balance_closes(nominal_arch: ThermalArchitecture) -> None: # Plug the output back into Q_in = Q_out and check to 0.1 W. result = evaluate_thermal(nominal_arch, avionics_power_w=15.0, latitude_deg=20.0) t_hot_k = result.peak_sun_temp_c + 273.15 q_out = ( nominal_arch.emissivity * STEFAN_BOLTZMANN_W_PER_M2_K4 * nominal_arch.surface_area_m2 * (t_hot_k**4 - nominal_arch.sink_temp_peak_sun_k**4) ) # Reconstruct Q_in independently: elevation_factor = math.cos(math.radians(20.0)) sunlit_area = nominal_arch.surface_area_m2 * nominal_arch.solar_projected_area_fraction q_solar = nominal_arch.absorptivity * 1361.0 * elevation_factor * sunlit_area q_in = q_solar + 15.0 + nominal_arch.rhu_power_w assert q_out == pytest.approx(q_in, rel=1e-6) def test_default_architecture_for_design_returns_valid_arch() -> None: arch = default_architecture_for_design(surface_area_m2=0.05, rhu_power_w=5.0) assert arch.surface_area_m2 == pytest.approx(0.05) assert arch.rhu_power_w == pytest.approx(5.0) assert arch.hibernation_power_w > 0.0