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>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""Optimiser tests built on small, hand-checkable cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from conftest import markets_from, prices_frame
|
||||
|
||||
from battery_dispatch.config import RunConfig
|
||||
from battery_dispatch.markets import MARKET_1, MARKET_2
|
||||
from battery_dispatch.optimiser import (
|
||||
POWER_TOLERANCE,
|
||||
run_rolling_horizon,
|
||||
solve_window,
|
||||
)
|
||||
|
||||
|
||||
def total_power(solution, kind: str) -> np.ndarray:
|
||||
values = solution.charge_mw if kind == "charge" else solution.discharge_mw
|
||||
return sum(values.values())
|
||||
|
||||
|
||||
def test_two_period_arbitrage_matches_analytic_revenue(spec, config):
|
||||
"""£0 then £100 in Market 1: revenue is pinned by the round-trip efficiency.
|
||||
|
||||
Charging at 2 MW for half an hour imports 1 MWh, of which 0.95 MWh is
|
||||
stored; discharging all of it delivers 0.95 x 0.95 = 0.9025 MWh to the grid
|
||||
at £100/MWh. Market 2 is priced at £50 for the hour, which is strictly
|
||||
worse in both directions, so it must stay out of the optimum.
|
||||
"""
|
||||
markets = markets_from([0.0, 100.0], [50.0])
|
||||
solution, _ = solve_window(markets, spec, 0.0, spec.capacity_mwh, config)
|
||||
|
||||
assert solution.objective_gbp == pytest.approx(90.25, abs=0.01)
|
||||
|
||||
# The whole trade sits in Market 1.
|
||||
assert solution.charge_mw[MARKET_1] == pytest.approx([2.0, 0.0], abs=1e-6)
|
||||
assert solution.discharge_mw[MARKET_1] == pytest.approx([0.0, 1.805], abs=1e-6)
|
||||
assert solution.charge_mw[MARKET_2] == pytest.approx([0.0, 0.0], abs=1e-6)
|
||||
assert solution.discharge_mw[MARKET_2] == pytest.approx([0.0, 0.0], abs=1e-6)
|
||||
|
||||
# Energy delivered to the grid, cross-checked independently.
|
||||
delivered = 0.5 * solution.discharge_mw[MARKET_1][1]
|
||||
assert delivered == pytest.approx(1.0 * 0.95 * 0.95, abs=1e-6)
|
||||
|
||||
|
||||
def test_market_2_power_is_constant_within_an_hour(spec, config):
|
||||
"""Opposite Market 1 signals inside one hour cannot bend Market 2's power."""
|
||||
# Market 1 pays to charge in the first half-hour and pays well to discharge
|
||||
# in the second; Market 2 sits at a flat £50 across the whole hour.
|
||||
markets = markets_from([-100.0, 200.0, 0.0, 0.0], [50.0, 10.0])
|
||||
solution, _ = solve_window(markets, spec, 2.0, spec.capacity_mwh, config)
|
||||
|
||||
for series in (solution.charge_mw[MARKET_2], solution.discharge_mw[MARKET_2]):
|
||||
assert series[0] == pytest.approx(series[1], abs=1e-9)
|
||||
assert series[2] == pytest.approx(series[3], abs=1e-9)
|
||||
|
||||
|
||||
def test_milp_fallback_removes_cross_market_simultaneous_flow(spec):
|
||||
"""Paid to charge in one market and to discharge in the other at once.
|
||||
|
||||
The LP relaxation takes both sides of that trade, which no single battery
|
||||
can do. The MILP fallback must detect and eliminate it.
|
||||
"""
|
||||
markets = markets_from([-50.0, -50.0], [50.0])
|
||||
|
||||
lp_solution, _ = solve_window(
|
||||
markets, spec, 0.0, spec.capacity_mwh, RunConfig(solver_mode="lp-only")
|
||||
)
|
||||
cheating = (total_power(lp_solution, "charge") > POWER_TOLERANCE) & (
|
||||
total_power(lp_solution, "discharge") > POWER_TOLERANCE
|
||||
)
|
||||
assert cheating.any(), "expected the relaxation to charge and discharge at once"
|
||||
|
||||
solution, used_milp = solve_window(
|
||||
markets, spec, 0.0, spec.capacity_mwh, RunConfig(solver_mode="auto")
|
||||
)
|
||||
assert used_milp, "the fallback should have been triggered"
|
||||
|
||||
honest = (total_power(solution, "charge") > POWER_TOLERANCE) & (
|
||||
total_power(solution, "discharge") > POWER_TOLERANCE
|
||||
)
|
||||
assert not honest.any()
|
||||
assert solution.objective_gbp <= lp_solution.objective_gbp + 1e-6
|
||||
|
||||
|
||||
def test_combined_power_respects_the_two_megawatt_cap(spec, config):
|
||||
"""Both markets attractive at once: the cap applies to their sum, not each."""
|
||||
# A full battery and a high price in both markets. How the power splits
|
||||
# between them is a tie, but the total is capped at 2 MW either way.
|
||||
markets = markets_from([1000.0, 1000.0], [1000.0])
|
||||
solution, _ = solve_window(markets, spec, spec.capacity_mwh, spec.capacity_mwh, config)
|
||||
|
||||
combined = total_power(solution, "discharge")
|
||||
assert combined.max() <= spec.max_discharge_mw + POWER_TOLERANCE
|
||||
assert combined[0] == pytest.approx(spec.max_discharge_mw, abs=1e-6)
|
||||
assert total_power(solution, "charge").max() <= POWER_TOLERANCE
|
||||
|
||||
|
||||
def test_optimum_uses_whichever_market_pays_more_in_each_hour(spec, config):
|
||||
"""Across hours the model switches markets rather than favouring one."""
|
||||
# Hour 0 cheap in both (charge); hour 1 Market 1 pays best; hour 2 Market 2 does.
|
||||
markets = markets_from(
|
||||
[0.0, 0.0, 300.0, 300.0, 10.0, 10.0],
|
||||
[0.0, 10.0, 300.0],
|
||||
)
|
||||
solution, _ = solve_window(markets, spec, 0.0, spec.capacity_mwh, config)
|
||||
|
||||
assert solution.discharge_mw[MARKET_1][2:4].sum() > 0, "hour 1 should sell into M1"
|
||||
assert solution.discharge_mw[MARKET_2][4:6].sum() > 0, "hour 2 should sell into M2"
|
||||
assert total_power(solution, "discharge").max() <= spec.max_discharge_mw + 1e-6
|
||||
|
||||
|
||||
def test_negative_prices_make_charging_profitable(spec, config):
|
||||
"""A negative price is an inducement to import, not merely a cheap one."""
|
||||
markets = markets_from([-80.0, -80.0], [-80.0])
|
||||
solution, _ = solve_window(markets, spec, 0.0, spec.capacity_mwh, config)
|
||||
|
||||
assert total_power(solution, "charge")[0] == pytest.approx(2.0, abs=1e-6)
|
||||
assert solution.objective_gbp > 0
|
||||
|
||||
|
||||
def test_degradation_cost_suppresses_marginal_cycling(spec):
|
||||
"""Pricing cycle life makes a thin spread not worth taking."""
|
||||
# A £12/MWh gross spread: profitable at zero degradation cost, not at £40.
|
||||
prices = [0.0, 0.0, 12.0, 12.0]
|
||||
markets = markets_from(prices, [0.0, 12.0])
|
||||
|
||||
free, _ = solve_window(markets, spec, 0.0, spec.capacity_mwh, RunConfig())
|
||||
priced, _ = solve_window(
|
||||
markets, spec, 0.0, spec.capacity_mwh,
|
||||
RunConfig(degradation_cost_gbp_per_mwh=40.0),
|
||||
)
|
||||
|
||||
assert total_power(free, "discharge").sum() > 0
|
||||
assert total_power(priced, "discharge").sum() == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_rolling_horizon_carries_state_across_windows(spec):
|
||||
"""State of charge at a commit boundary is the next window's starting point."""
|
||||
# Two days: charge cheaply late on day 1, sell into the day-2 morning peak.
|
||||
day = [0.0] * 40 + [5.0] * 8
|
||||
day_2 = [200.0] * 8 + [50.0] * 40
|
||||
prices = prices_frame(day + day_2, [40.0] * 48)
|
||||
|
||||
config = RunConfig(window_hours=48, commit_hours=24)
|
||||
result, state, stats = run_rolling_horizon(prices, spec, config)
|
||||
|
||||
assert stats.windows == 2
|
||||
assert len(result) == 96
|
||||
|
||||
# The lookahead must carry energy over midnight to reach the day-2 peak.
|
||||
soc_at_midnight = result["soc_mwh"].iloc[47]
|
||||
assert soc_at_midnight > 1.0
|
||||
assert state.equivalent_full_cycles > 0
|
||||
|
||||
|
||||
def test_window_start_must_align_to_hour_blocks(spec):
|
||||
"""Slicing a window mid-hour would silently break Market 2's commitment."""
|
||||
markets = markets_from([1.0, 2.0, 3.0, 4.0], [1.0, 2.0])
|
||||
with pytest.raises(ValueError, match="block boundary"):
|
||||
markets[1].slice(1, 3)
|
||||
Reference in New Issue
Block a user