"""Cycle counting, capacity fade and end-of-life arithmetic.""" from __future__ import annotations import pytest from battery_dispatch.battery import BatteryState, implied_life_years def test_a_fresh_battery_is_at_nominal_capacity(spec): state = BatteryState(spec=spec) assert state.capacity_mwh == pytest.approx(4.0) assert state.capacity_retained == pytest.approx(1.0) assert state.equivalent_full_cycles == 0.0 def test_one_full_discharge_counts_as_one_cycle(spec): state = BatteryState(spec=spec) state.apply_cycles(spec.capacity_mwh) assert state.equivalent_full_cycles == pytest.approx(1.0) def test_partial_discharges_accumulate_into_whole_cycles(spec): """Attachment 1's definition: 75% then 25% is one cycle, not two.""" state = BatteryState(spec=spec) state.apply_cycles(0.75 * spec.capacity_mwh) state.apply_cycles(0.25 * spec.capacity_mwh) assert state.equivalent_full_cycles == pytest.approx(1.0) def test_capacity_fades_by_one_thousandth_of_a_percent_per_cycle(spec): state = BatteryState(spec=spec) state.apply_cycles(100 * spec.capacity_mwh) # 100 cycles # 0.001 %/cycle over 100 cycles is a 0.1 % loss. assert state.capacity_retained == pytest.approx(0.999) assert state.capacity_mwh == pytest.approx(4.0 * 0.999) def test_the_full_cycle_budget_fades_capacity_by_five_percent(spec): state = BatteryState(spec=spec) state.apply_cycles(spec.lifetime_cycles * spec.capacity_mwh) assert state.equivalent_full_cycles == pytest.approx(5000.0) assert state.capacity_retained == pytest.approx(0.95) def test_commit_accounts_for_discharge_losses(spec): """Grid-side export of 0.95 MWh draws 1.0 MWh from storage.""" state = BatteryState(spec=spec) state.commit(soc_mwh=3.0, charged_grid_mwh=0.0, discharged_grid_mwh=0.95) assert state.total_discharged_grid_mwh == pytest.approx(0.95) assert state.total_discharged_storage_mwh == pytest.approx(1.0) assert state.equivalent_full_cycles == pytest.approx(0.25) assert state.soc_mwh == pytest.approx(3.0) def test_commit_is_cumulative(spec): state = BatteryState(spec=spec) for _ in range(3): state.commit(soc_mwh=1.0, charged_grid_mwh=2.0, discharged_grid_mwh=0.95) assert state.total_charged_grid_mwh == pytest.approx(6.0) assert state.total_discharged_storage_mwh == pytest.approx(3.0) def test_negative_throughput_is_rejected(spec): state = BatteryState(spec=spec) with pytest.raises(ValueError): state.apply_cycles(-1.0) def test_calendar_life_binds_when_cycling_is_light(spec): # 300 cycles a year exhausts 5,000 cycles only after 16 years. assert implied_life_years(spec, 300.0) == pytest.approx(10.0) def test_cycle_life_binds_when_cycling_is_heavy(spec): # 1,000 cycles a year exhausts the budget in 5 years, inside the 10-year life. assert implied_life_years(spec, 1000.0) == pytest.approx(5.0) def test_an_idle_battery_lives_out_its_calendar_life(spec): assert implied_life_years(spec, 0.0) == pytest.approx(10.0)