Spaces:
Running
Running
| """Tests for the power sub-package. | |
| Solar: physics-first-principles assertions plus a Yutu-2 noon-power | |
| cross-check. | |
| Battery: round-trip efficiency, SOC clamping, temperature derating, and | |
| the SMAD-style usable-capacity validation gate ("100 Wh nominal pack | |
| delivers ~85 Wh usable" at 20 C with the default 15 % DoD floor). | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import numpy as np | |
| import pytest | |
| from roverdevkit.power.battery import ( | |
| BatteryState, | |
| step, | |
| stored_energy_wh, | |
| temperature_derating_factor, | |
| usable_capacity_wh, | |
| ) | |
| from roverdevkit.power.solar import ( | |
| LUNAR_HOUR_ANGLE_RATE_DEG_PER_HR, | |
| LUNAR_SYNODIC_DAY_HOURS, | |
| SOLAR_CONSTANT_AU_1_W_PER_M2, | |
| lunar_hour_angle_deg, | |
| panel_power_w, | |
| solar_power_timeseries, | |
| sun_azimuth_deg, | |
| sun_elevation_deg, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Solar geometry | |
| # --------------------------------------------------------------------------- | |
| class TestSunElevation: | |
| """Closed-form sanity checks against textbook spherical-astronomy cases.""" | |
| def test_noon_sun_at_equator_is_zenith(self) -> None: | |
| assert sun_elevation_deg(latitude_deg=0.0, lunar_hour_angle_deg=0.0) == pytest.approx( | |
| 90.0, abs=1e-9 | |
| ) | |
| def test_noon_sun_elevation_equals_complement_of_latitude(self) -> None: | |
| # delta = 0 => sin(el) = cos(phi) => el = 90 - |phi| | |
| for lat in (-60.0, -30.0, 10.0, 45.5, 80.0): | |
| expected = 90.0 - abs(lat) | |
| assert sun_elevation_deg(lat, lunar_hour_angle_deg=0.0) == pytest.approx( | |
| expected, abs=1e-9 | |
| ) | |
| def test_sun_at_horizon_when_hour_angle_is_90_deg_at_equator(self) -> None: | |
| assert sun_elevation_deg(latitude_deg=0.0, lunar_hour_angle_deg=90.0) == pytest.approx( | |
| 0.0, abs=1e-9 | |
| ) | |
| def test_sun_below_horizon_at_midnight_at_equator(self) -> None: | |
| assert sun_elevation_deg(latitude_deg=0.0, lunar_hour_angle_deg=180.0) == pytest.approx( | |
| -90.0, abs=1e-9 | |
| ) | |
| def test_sun_elevation_is_symmetric_in_hour_angle(self) -> None: | |
| for lat in (-30.0, 0.0, 45.5): | |
| for h in (15.0, 60.0, 89.0): | |
| assert sun_elevation_deg(lat, +h) == pytest.approx(sun_elevation_deg(lat, -h)) | |
| def test_pole_with_zero_declination_keeps_sun_at_horizon(self) -> None: | |
| # phi = +/-90, delta = 0 => sin(el) = 0 for all H. We use 89.999 to | |
| # avoid the cos(phi) singularity in the azimuth formula; that | |
| # 0.001 deg offset bounds the elevation error at ~0.001 deg. | |
| for h in (-180.0, -90.0, 0.0, 45.0, 180.0): | |
| assert sun_elevation_deg(latitude_deg=89.999, lunar_hour_angle_deg=h) == pytest.approx( | |
| 0.0, abs=2e-3 | |
| ) | |
| class TestSunAzimuth: | |
| def test_azimuth_due_south_at_noon_for_northern_latitude(self) -> None: | |
| # Northern hemisphere with delta=0: sun is due south at local noon. | |
| assert sun_azimuth_deg(latitude_deg=45.0, lunar_hour_angle_deg=0.0) == pytest.approx( | |
| 180.0, abs=1e-6 | |
| ) | |
| def test_azimuth_in_valid_range(self) -> None: | |
| for lat in (-60.0, 0.0, 45.5): | |
| for h in (-179.9, -45.0, 0.0, 45.0, 179.9): | |
| az = sun_azimuth_deg(lat, h) | |
| assert 0.0 <= az < 360.0 | |
| class TestLunarHourAngle: | |
| def test_noon_returns_zero(self) -> None: | |
| assert lunar_hour_angle_deg(elapsed_hours=0.0, noon_hour=0.0) == pytest.approx(0.0) | |
| def test_full_synodic_day_wraps(self) -> None: | |
| wrapped = lunar_hour_angle_deg(elapsed_hours=LUNAR_SYNODIC_DAY_HOURS, noon_hour=0.0) | |
| assert abs(wrapped) < 1e-6 or abs(abs(wrapped) - 360.0) < 1e-6 | |
| def test_quarter_day_advances_90_deg(self) -> None: | |
| h = lunar_hour_angle_deg(elapsed_hours=LUNAR_SYNODIC_DAY_HOURS / 4.0, noon_hour=0.0) | |
| assert h == pytest.approx(90.0, abs=1e-6) | |
| # --------------------------------------------------------------------------- | |
| # Panel power | |
| # --------------------------------------------------------------------------- | |
| class TestPanelPower: | |
| def test_panel_power_is_zero_below_horizon(self) -> None: | |
| assert ( | |
| panel_power_w(panel_area_m2=1.0, panel_efficiency=0.30, sun_elevation_deg=-10.0) == 0.0 | |
| ) | |
| assert panel_power_w(panel_area_m2=1.0, panel_efficiency=0.30, sun_elevation_deg=0.0) == 0.0 | |
| def test_horizontal_panel_at_zenith_yields_full_irradiance(self) -> None: | |
| p = panel_power_w(panel_area_m2=1.0, panel_efficiency=0.30, sun_elevation_deg=90.0) | |
| assert p == pytest.approx(SOLAR_CONSTANT_AU_1_W_PER_M2 * 1.0 * 0.30, rel=1e-9) | |
| def test_horizontal_panel_follows_sin_elevation(self) -> None: | |
| # For beta = 0, P should scale as sin(elevation). | |
| for el in (10.0, 30.0, 45.5, 75.0): | |
| p = panel_power_w(panel_area_m2=1.0, panel_efficiency=0.30, sun_elevation_deg=el) | |
| expected = SOLAR_CONSTANT_AU_1_W_PER_M2 * 0.30 * math.sin(math.radians(el)) | |
| assert p == pytest.approx(expected, rel=1e-9) | |
| def test_dust_factor_scales_linearly(self) -> None: | |
| clean = panel_power_w( | |
| panel_area_m2=1.0, | |
| panel_efficiency=0.30, | |
| sun_elevation_deg=45.0, | |
| dust_degradation_factor=1.0, | |
| ) | |
| dusty = panel_power_w( | |
| panel_area_m2=1.0, | |
| panel_efficiency=0.30, | |
| sun_elevation_deg=45.0, | |
| dust_degradation_factor=0.7, | |
| ) | |
| assert dusty == pytest.approx(0.7 * clean) | |
| def test_tilted_panel_aimed_at_sun_outperforms_horizontal(self) -> None: | |
| # At Yutu-2-like latitude, tilting the panel by (90 - el) toward the | |
| # sun's azimuth should recover (very nearly) the full irradiance. | |
| el = 44.5 # noon elevation at Yutu-2 latitude (45.5 N), delta=0 | |
| sun_az = 180.0 | |
| horiz = panel_power_w( | |
| panel_area_m2=1.0, | |
| panel_efficiency=0.30, | |
| sun_elevation_deg=el, | |
| ) | |
| tilted = panel_power_w( | |
| panel_area_m2=1.0, | |
| panel_efficiency=0.30, | |
| sun_elevation_deg=el, | |
| panel_tilt_deg=90.0 - el, | |
| panel_azimuth_deg=sun_az, | |
| sun_azimuth_deg=sun_az, | |
| ) | |
| # Tilted panel should be brighter, and very close to full irradiance. | |
| assert tilted > horiz | |
| assert tilted == pytest.approx(SOLAR_CONSTANT_AU_1_W_PER_M2 * 0.30, rel=1e-6) | |
| def test_back_illuminated_tilted_panel_does_not_go_negative(self) -> None: | |
| # Sun in front, panel tilted way back so the cosine flips sign. | |
| p = panel_power_w( | |
| panel_area_m2=1.0, | |
| panel_efficiency=0.30, | |
| sun_elevation_deg=20.0, | |
| panel_tilt_deg=80.0, | |
| panel_azimuth_deg=0.0, | |
| sun_azimuth_deg=180.0, | |
| ) | |
| assert p == 0.0 | |
| def test_invalid_efficiency_rejected(self) -> None: | |
| with pytest.raises(ValueError): | |
| panel_power_w(panel_area_m2=1.0, panel_efficiency=1.5, sun_elevation_deg=45.0) | |
| with pytest.raises(ValueError): | |
| panel_power_w(panel_area_m2=1.0, panel_efficiency=-0.1, sun_elevation_deg=45.0) | |
| def test_invalid_dust_factor_rejected(self) -> None: | |
| with pytest.raises(ValueError): | |
| panel_power_w( | |
| panel_area_m2=1.0, | |
| panel_efficiency=0.30, | |
| sun_elevation_deg=45.0, | |
| dust_degradation_factor=1.5, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Yutu-2 validation gate | |
| # --------------------------------------------------------------------------- | |
| class TestYutu2Validation: | |
| """Cross-check against the published Yutu-2 power-profile numbers. | |
| Yutu-2 specs (Di et al. 2020 *Icarus*; CNSA mission documents): | |
| - Selenographic latitude: ~45.5 N | |
| - Solar array: nominally 1.0 m^2, ~30 % cell efficiency | |
| (Chinese GaAs triple junction). | |
| - Reported in-flight noon-equivalent panel output: ~120-140 W, | |
| with the gap between cell-level theoretical and as-flown power | |
| attributable to dust deposition, harness/MPPT losses, thermal | |
| derating of the cells, and a several-degree array-tilt offset. | |
| The first sub-test confirms the *clean-sky theoretical* power matches | |
| the closed-form S * A * eta * sin(el) for the Yutu-2 geometry. The | |
| second sub-test shows that applying realistic loss factors (dust ~0.5, | |
| cell thermal derating ~0.85) brings the model into the published | |
| in-flight band - i.e. the unmodelled gap between physics and flight | |
| data is well-characterised by parameters the user can tune. | |
| """ | |
| def test_yutu2_clean_sky_matches_closed_form(self) -> None: | |
| elev = sun_elevation_deg(latitude_deg=45.5, lunar_hour_angle_deg=0.0) | |
| p = panel_power_w( | |
| panel_area_m2=1.0, | |
| panel_efficiency=0.30, | |
| sun_elevation_deg=elev, | |
| ) | |
| expected = SOLAR_CONSTANT_AU_1_W_PER_M2 * 1.0 * 0.30 * math.sin(math.radians(44.5)) | |
| assert p == pytest.approx(expected, rel=1e-6) | |
| # Theoretical clean-sky upper bound for this geometry: ~286 W. | |
| assert 250.0 < p < 320.0 | |
| def test_yutu2_with_realistic_losses_in_published_band(self) -> None: | |
| elev = sun_elevation_deg(latitude_deg=45.5, lunar_hour_angle_deg=0.0) | |
| # Dust + cell thermal derating bring the in-flight number down. | |
| p = panel_power_w( | |
| panel_area_m2=1.0, | |
| panel_efficiency=0.30 * 0.85, # ~85 % thermal derate at lunar-noon array temp | |
| sun_elevation_deg=elev, | |
| dust_degradation_factor=0.55, # accumulated regolith deposition | |
| ) | |
| # Published in-flight: ~120-140 W noon-equivalent. | |
| assert 100.0 < p < 160.0 | |
| # --------------------------------------------------------------------------- | |
| # Solar power timeseries | |
| # --------------------------------------------------------------------------- | |
| class TestSolarPowerTimeseries: | |
| def test_timeseries_has_expected_shape(self) -> None: | |
| t, p = solar_power_timeseries( | |
| duration_hours=LUNAR_SYNODIC_DAY_HOURS, | |
| dt_hours=10.0, | |
| latitude_deg=0.0, | |
| panel_area_m2=1.0, | |
| panel_efficiency=0.30, | |
| ) | |
| assert t.shape == p.shape | |
| assert t[0] == 0.0 | |
| assert t[-1] == pytest.approx(LUNAR_SYNODIC_DAY_HOURS, abs=10.0) | |
| # Default noon at quarter-day puts sunrise at t=0; midnight at half-day. | |
| midnight_idx = int(np.argmin(p)) | |
| # Power should be zero for substantial portions (~half) of the cycle. | |
| assert (p == 0.0).sum() >= len(p) // 3 | |
| # Non-zero peak should exceed S * A * eta * sin(some elevation). | |
| assert p.max() > 0.5 * SOLAR_CONSTANT_AU_1_W_PER_M2 * 0.30 | |
| # Midnight should be deep in the dark portion. | |
| assert p[midnight_idx] == 0.0 | |
| def test_lunar_day_period_constants_consistent(self) -> None: | |
| # Hour-angle rate * synodic day length = 360 deg. | |
| product = LUNAR_HOUR_ANGLE_RATE_DEG_PER_HR * LUNAR_SYNODIC_DAY_HOURS | |
| assert product == pytest.approx(360.0) | |
| # --------------------------------------------------------------------------- | |
| # Battery state-of-charge | |
| # --------------------------------------------------------------------------- | |
| def _fresh_state(soc: float = 1.0, **kwargs: float) -> BatteryState: | |
| defaults: dict[str, float] = { | |
| "capacity_wh": 100.0, | |
| "state_of_charge": soc, | |
| "temperature_c": 20.0, | |
| } | |
| defaults.update(kwargs) | |
| return BatteryState(**defaults) | |
| class TestBatteryConstruction: | |
| def test_default_construction(self) -> None: | |
| s = BatteryState(capacity_wh=100.0, state_of_charge=0.8) | |
| assert s.capacity_wh == 100.0 | |
| assert s.state_of_charge == 0.8 | |
| assert s.min_state_of_charge == 0.15 | |
| def test_invalid_construction_rejected(self, kwargs: dict[str, float]) -> None: | |
| with pytest.raises(ValueError): | |
| BatteryState(**kwargs) | |
| class TestBatteryStep: | |
| def test_step_zero_dt_is_noop(self) -> None: | |
| s0 = _fresh_state(soc=0.5) | |
| s1 = step(s0, power_net_w=100.0, dt_s=0.0) | |
| assert s1.state_of_charge == s0.state_of_charge | |
| def test_charging_increases_soc(self) -> None: | |
| s0 = _fresh_state(soc=0.5) | |
| s1 = step(s0, power_net_w=10.0, dt_s=3600.0) # 10 W * 1 h = 10 Wh in | |
| # eta_charge = 0.95 => stored 9.5 Wh in 100 Wh pack => +0.095 | |
| assert s1.state_of_charge == pytest.approx(0.5 + 0.095, abs=1e-9) | |
| def test_discharging_decreases_soc(self) -> None: | |
| s0 = _fresh_state(soc=0.5) | |
| s1 = step(s0, power_net_w=-10.0, dt_s=3600.0) # 10 W * 1 h = 10 Wh out | |
| # eta_discharge = 0.95 => cells must give up 10 / 0.95 ≈ 10.526 Wh | |
| assert s1.state_of_charge == pytest.approx(0.5 - (10.0 / 0.95) / 100.0, abs=1e-9) | |
| def test_soc_clamped_at_full(self) -> None: | |
| s0 = _fresh_state(soc=0.99) | |
| s1 = step(s0, power_net_w=100.0, dt_s=3600.0) | |
| assert s1.state_of_charge == pytest.approx(1.0) | |
| def test_soc_clamped_at_dod_floor(self) -> None: | |
| s0 = _fresh_state(soc=0.20, min_state_of_charge=0.15) | |
| s1 = step(s0, power_net_w=-100.0, dt_s=3600.0) | |
| assert s1.state_of_charge == pytest.approx(0.15) | |
| def test_round_trip_loses_energy(self) -> None: | |
| s0 = _fresh_state(soc=0.5) | |
| s1 = step(s0, power_net_w=10.0, dt_s=3600.0) | |
| s2 = step(s1, power_net_w=-10.0, dt_s=3600.0) | |
| # Net energy change should be negative (round-trip losses). | |
| assert s2.state_of_charge < s0.state_of_charge | |
| # Loss ≈ (1 - eta_c * eta_d) * 10 Wh consumed at the load | |
| # Charged 10 Wh in -> stored 9.5; discharged 10 Wh out -> drew 10/0.95 ≈ 10.53 | |
| # Net stored change: 9.5 - 10.53 = -1.03 Wh -> -0.0103 SOC change. | |
| assert s2.state_of_charge == pytest.approx(0.5 + 0.095 - 10.0 / 0.95 / 100.0, abs=1e-9) | |
| def test_returned_state_is_independent_object(self) -> None: | |
| s0 = _fresh_state(soc=0.5) | |
| s1 = step(s0, power_net_w=10.0, dt_s=60.0) | |
| assert s0.state_of_charge == 0.5 # original untouched | |
| assert s1 is not s0 | |
| def test_negative_dt_rejected(self) -> None: | |
| s0 = _fresh_state(soc=0.5) | |
| with pytest.raises(ValueError): | |
| step(s0, power_net_w=10.0, dt_s=-1.0) | |
| class TestTemperatureDerating: | |
| def test_room_temperature_is_calibration_point(self) -> None: | |
| assert temperature_derating_factor(20.0) == pytest.approx(1.0) | |
| def test_cold_reduces_capacity(self) -> None: | |
| assert temperature_derating_factor(-20.0) < 1.0 | |
| assert temperature_derating_factor(-40.0) < temperature_derating_factor(-20.0) | |
| def test_hot_reduces_capacity_modestly(self) -> None: | |
| f = temperature_derating_factor(60.0) | |
| assert 0.9 < f < 1.0 | |
| def test_clamped_outside_table(self) -> None: | |
| assert temperature_derating_factor(-100.0) == pytest.approx( | |
| temperature_derating_factor(-40.0) | |
| ) | |
| assert temperature_derating_factor(200.0) == pytest.approx( | |
| temperature_derating_factor(60.0) | |
| ) | |
| def test_factor_in_unit_interval(self) -> None: | |
| for t in np.linspace(-50.0, 80.0, 50): | |
| f = temperature_derating_factor(float(t)) | |
| assert 0.0 <= f <= 1.0 | |
| class TestUsableCapacity: | |
| def test_validation_gate_100wh_pack_at_room_temp(self) -> None: | |
| """SMAD-style sizing rule of thumb: 100 Wh nominal -> ~85 Wh usable | |
| at 20 C with the default 15 % DoD floor.""" | |
| s = BatteryState(capacity_wh=100.0, state_of_charge=1.0) | |
| assert usable_capacity_wh(s) == pytest.approx(85.0, abs=1.0) | |
| def test_cold_reduces_usable_capacity(self) -> None: | |
| warm = usable_capacity_wh(BatteryState(capacity_wh=100.0, state_of_charge=1.0)) | |
| cold = usable_capacity_wh( | |
| BatteryState(capacity_wh=100.0, state_of_charge=1.0, temperature_c=-20.0) | |
| ) | |
| assert cold < warm | |
| def test_higher_dod_floor_reduces_usable_capacity(self) -> None: | |
| loose = usable_capacity_wh( | |
| BatteryState(capacity_wh=100.0, state_of_charge=1.0, min_state_of_charge=0.1) | |
| ) | |
| strict = usable_capacity_wh( | |
| BatteryState(capacity_wh=100.0, state_of_charge=1.0, min_state_of_charge=0.4) | |
| ) | |
| assert strict < loose | |
| class TestStoredEnergy: | |
| def test_full_pack(self) -> None: | |
| assert stored_energy_wh(BatteryState(capacity_wh=100.0, state_of_charge=1.0)) == 100.0 | |
| def test_half_pack(self) -> None: | |
| assert stored_energy_wh(BatteryState(capacity_wh=200.0, state_of_charge=0.5)) == 100.0 | |