CI / test (push) Failing after 45m4s
Implements the package from PLAN.md: config/data loading, the LP-first MILP-fallback rolling-horizon optimiser, battery degradation tracking, independent schedule validation, metrics, plots and the CLI, plus the matching test suite. Adds a Gitea Actions workflow (lint + tests) that posts a pass/fail notification to ntfy on every run, and a .gitignore for build/cache artefacts that had been tracked by mistake. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
86 lines
3.0 KiB
Python
86 lines
3.0 KiB
Python
"""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)
|