Files
mattandClaude Sonnet 5 298724a9d3
CI / test (push) Failing after 45m4s
Add battery dispatch implementation and Gitea CI pipeline
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>
2026-09-24 13:32:06 +01:00

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)