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,75 @@
|
||||
"""Shared fixtures: a spec matching Attachment 1 and helpers for tiny markets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from battery_dispatch.config import BatterySpec, RunConfig
|
||||
from battery_dispatch.markets import MARKET_1, MARKET_2, Market
|
||||
|
||||
# Equal to Attachment 1, but stated literally so the tests do not depend on the
|
||||
# workbook being present or unchanged.
|
||||
SPEC = BatterySpec(
|
||||
max_charge_mw=2.0,
|
||||
max_discharge_mw=2.0,
|
||||
capacity_mwh=4.0,
|
||||
charge_efficiency=0.95,
|
||||
discharge_efficiency=0.95,
|
||||
lifetime_years=10.0,
|
||||
lifetime_cycles=5000.0,
|
||||
degradation_pct_per_cycle=0.001,
|
||||
capex_gbp=500_000.0,
|
||||
fixed_opex_gbp_per_year=5_000.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec() -> BatterySpec:
|
||||
return SPEC
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config() -> RunConfig:
|
||||
return RunConfig()
|
||||
|
||||
|
||||
def markets_from(
|
||||
market_1_prices: list[float],
|
||||
market_2_prices: list[float] | None = None,
|
||||
) -> list[Market]:
|
||||
"""Build both markets from a half-hourly Market 1 price list.
|
||||
|
||||
``market_2_prices`` is given per *hour*; when omitted Market 2 is priced so
|
||||
low that it never trades, isolating Market 1 behaviour.
|
||||
"""
|
||||
n = len(market_1_prices)
|
||||
if market_2_prices is None:
|
||||
broadcast = np.zeros(n)
|
||||
else:
|
||||
assert n == 2 * len(market_2_prices), "one Market 2 price per hour"
|
||||
broadcast = np.repeat(np.asarray(market_2_prices, dtype=float), 2)
|
||||
|
||||
return [
|
||||
Market(MARKET_1, 1, np.asarray(market_1_prices, dtype=float)),
|
||||
Market(MARKET_2, 2, broadcast),
|
||||
]
|
||||
|
||||
|
||||
def prices_frame(
|
||||
market_1_prices: list[float],
|
||||
market_2_prices: list[float],
|
||||
start: str = "2018-01-01",
|
||||
) -> pd.DataFrame:
|
||||
"""A price frame shaped like the one ``load_prices`` returns."""
|
||||
n = len(market_1_prices)
|
||||
index = pd.date_range(start, periods=n, freq="30min")
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"market_1_price": np.asarray(market_1_prices, dtype=float),
|
||||
"market_2_price": np.repeat(np.asarray(market_2_prices, dtype=float), 2),
|
||||
"hour_index": np.arange(n) // 2,
|
||||
},
|
||||
index=index,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Tests for loading, cleaning and aligning the two price series."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import openpyxl
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from battery_dispatch.config import BatterySpec
|
||||
from battery_dispatch.data import (
|
||||
HALF_HOURLY_SHEET,
|
||||
HOURLY_SHEET,
|
||||
build_markets,
|
||||
load_prices,
|
||||
slice_whole_days,
|
||||
)
|
||||
from battery_dispatch.markets import MARKET_1, MARKET_2
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PRICE_FILE = REPO_ROOT / "data" / "Attachment 2.xlsx"
|
||||
SPEC_FILE = REPO_ROOT / "data" / "Attachment 1.xlsx"
|
||||
|
||||
requires_data = pytest.mark.skipif(
|
||||
not PRICE_FILE.exists(), reason="Attachment 2.xlsx not present"
|
||||
)
|
||||
|
||||
|
||||
def write_workbook(
|
||||
path: Path,
|
||||
half_hourly: list[tuple],
|
||||
hourly: list[tuple],
|
||||
) -> Path:
|
||||
"""Build a miniature Attachment 2 with the same sheet names and layout."""
|
||||
workbook = openpyxl.Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = HALF_HOURLY_SHEET
|
||||
sheet.append([None, "Market 1 Price [£/MWh]"])
|
||||
for row in half_hourly:
|
||||
sheet.append(list(row))
|
||||
|
||||
second = workbook.create_sheet(HOURLY_SHEET)
|
||||
second.append([None, "Market 2 Price [£/MWh]"])
|
||||
for row in hourly:
|
||||
second.append(list(row))
|
||||
|
||||
workbook.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def synthetic_day(tmp_path: Path, trailing_blanks: int = 0) -> Path:
|
||||
"""One clean day: 48 half-hours and 24 hours, plus optional blank rows."""
|
||||
half_hourly = [
|
||||
(pd.Timestamp("2018-01-01") + pd.Timedelta(minutes=30 * i), float(i))
|
||||
for i in range(48)
|
||||
]
|
||||
hourly = [
|
||||
(pd.Timestamp("2018-01-01") + pd.Timedelta(hours=h), 100.0 + h)
|
||||
for h in range(24)
|
||||
]
|
||||
hourly += [(None, None)] * trailing_blanks
|
||||
return write_workbook(tmp_path / "prices.xlsx", half_hourly, hourly)
|
||||
|
||||
|
||||
def test_trailing_blank_rows_are_dropped(tmp_path):
|
||||
path = synthetic_day(tmp_path, trailing_blanks=24)
|
||||
prices = load_prices(path, expected_days=1)
|
||||
assert len(prices) == 48
|
||||
|
||||
|
||||
def test_hourly_prices_are_broadcast_onto_half_hours(tmp_path):
|
||||
prices = load_prices(synthetic_day(tmp_path), expected_days=1)
|
||||
|
||||
# Each hourly price covers exactly two consecutive half-hours.
|
||||
assert prices["market_2_price"].iloc[0] == 100.0
|
||||
assert prices["market_2_price"].iloc[1] == 100.0
|
||||
assert prices["market_2_price"].iloc[2] == 101.0
|
||||
assert prices["market_2_price"].iloc[47] == 123.0
|
||||
|
||||
pairs = prices["market_2_price"].to_numpy().reshape(-1, 2)
|
||||
assert np.array_equal(pairs[:, 0], pairs[:, 1])
|
||||
|
||||
# Market 1 is untouched, and hour_index groups half-hours in twos.
|
||||
assert prices["market_1_price"].tolist() == [float(i) for i in range(48)]
|
||||
assert prices["hour_index"].tolist() == [i // 2 for i in range(48)]
|
||||
|
||||
|
||||
def test_row_count_mismatch_is_an_error_not_a_guess(tmp_path):
|
||||
half_hourly = [
|
||||
(pd.Timestamp("2018-01-01") + pd.Timedelta(minutes=30 * i), 1.0)
|
||||
for i in range(47) # one short
|
||||
]
|
||||
hourly = [
|
||||
(pd.Timestamp("2018-01-01") + pd.Timedelta(hours=h), 1.0) for h in range(24)
|
||||
]
|
||||
path = write_workbook(tmp_path / "short.xlsx", half_hourly, hourly)
|
||||
|
||||
with pytest.raises(ValueError, match="expected 48 rows"):
|
||||
load_prices(path, expected_days=1)
|
||||
|
||||
|
||||
def test_index_is_rebuilt_regularly_despite_clock_change_labels(tmp_path, caplog):
|
||||
"""Duplicated 02:00 labels are logged and replaced by a regular index."""
|
||||
stamps = [
|
||||
pd.Timestamp("2018-03-25") + pd.Timedelta(minutes=30 * i) for i in range(48)
|
||||
]
|
||||
# Reproduce the spring-forward labelling: 01:00/01:30 skipped, 02:00/02:30 twice.
|
||||
stamps[2] = pd.Timestamp("2018-03-25 02:00")
|
||||
stamps[3] = pd.Timestamp("2018-03-25 02:30")
|
||||
|
||||
half_hourly = [(ts, 10.0) for ts in stamps]
|
||||
hourly = [
|
||||
(pd.Timestamp("2018-03-25") + pd.Timedelta(hours=h), 20.0) for h in range(24)
|
||||
]
|
||||
path = write_workbook(tmp_path / "clocks.xlsx", half_hourly, hourly)
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
prices = load_prices(path, expected_days=1)
|
||||
|
||||
assert "do not match a regular half-hourly index" in caplog.text
|
||||
expected = pd.date_range("2018-01-01", periods=48, freq="30min")
|
||||
assert prices.index.equals(expected)
|
||||
assert not prices.index.duplicated().any()
|
||||
|
||||
|
||||
def test_slice_whole_days_includes_the_whole_end_day(tmp_path):
|
||||
half_hourly = [
|
||||
(pd.Timestamp("2018-01-01") + pd.Timedelta(minutes=30 * i), float(i))
|
||||
for i in range(96)
|
||||
]
|
||||
hourly = [
|
||||
(pd.Timestamp("2018-01-01") + pd.Timedelta(hours=h), 1.0) for h in range(48)
|
||||
]
|
||||
prices = load_prices(write_workbook(tmp_path / "two.xlsx", half_hourly, hourly),
|
||||
expected_days=2)
|
||||
|
||||
first_day = slice_whole_days(prices, "2018-01-01", "2018-01-01")
|
||||
assert len(first_day) == 48
|
||||
assert first_day.index[-1] == pd.Timestamp("2018-01-01 23:30")
|
||||
|
||||
# hour_index is re-based so a slice can be optimised standalone.
|
||||
assert first_day["hour_index"].iloc[0] == 0
|
||||
second_day = slice_whole_days(prices, "2018-01-02", "2018-01-02")
|
||||
assert second_day["hour_index"].iloc[0] == 0
|
||||
|
||||
|
||||
def test_build_markets_sets_the_commitment_block_lengths(tmp_path):
|
||||
prices = load_prices(synthetic_day(tmp_path), expected_days=1)
|
||||
market_1, market_2 = build_markets(prices)
|
||||
|
||||
assert market_1.name == MARKET_1 and market_1.block_half_hours == 1
|
||||
assert market_2.name == MARKET_2 and market_2.block_half_hours == 2
|
||||
assert market_2.block_of(0) == market_2.block_of(1) == 0
|
||||
assert market_2.block_of(2) == 1
|
||||
|
||||
|
||||
@requires_data
|
||||
def test_real_workbook_loads_with_the_expected_shape():
|
||||
prices = load_prices(PRICE_FILE)
|
||||
|
||||
assert len(prices) == 1096 * 48
|
||||
assert prices.index[0] == pd.Timestamp("2018-01-01 00:00")
|
||||
assert prices.index[-1] == pd.Timestamp("2020-12-31 23:30")
|
||||
assert prices.index.is_monotonic_increasing
|
||||
assert not prices.index.duplicated().any()
|
||||
assert prices.notna().all().all()
|
||||
|
||||
pairs = prices["market_2_price"].to_numpy().reshape(-1, 2)
|
||||
assert np.array_equal(pairs[:, 0], pairs[:, 1])
|
||||
|
||||
|
||||
@requires_data
|
||||
def test_spec_reads_losses_as_efficiencies():
|
||||
"""Attachment 1 quotes losses; the model must store 1 - loss."""
|
||||
spec = BatterySpec.from_excel(SPEC_FILE)
|
||||
|
||||
assert spec.max_charge_mw == 2.0
|
||||
assert spec.max_discharge_mw == 2.0
|
||||
assert spec.capacity_mwh == 4.0
|
||||
assert spec.charge_efficiency == pytest.approx(0.95)
|
||||
assert spec.discharge_efficiency == pytest.approx(0.95)
|
||||
assert spec.round_trip_efficiency == pytest.approx(0.9025)
|
||||
assert spec.lifetime_years == 10.0
|
||||
assert spec.lifetime_cycles == 5000.0
|
||||
assert spec.degradation_fraction_per_cycle == pytest.approx(1e-5)
|
||||
assert spec.capex_gbp == 500_000.0
|
||||
assert spec.fixed_opex_gbp_per_year == 5_000.0
|
||||
@@ -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)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""The validator must catch each violation type when it is injected."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from conftest import prices_frame
|
||||
|
||||
from battery_dispatch.config import RunConfig
|
||||
from battery_dispatch.optimiser import run_rolling_horizon
|
||||
from battery_dispatch.validation import validate_schedule
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schedule(spec) -> pd.DataFrame:
|
||||
"""A genuine, valid two-day schedule to mutate in each test."""
|
||||
prices = prices_frame(
|
||||
[0.0] * 20 + [90.0] * 8 + [10.0] * 20 + [5.0] * 16 + [120.0] * 8 + [40.0] * 24,
|
||||
[30.0] * 48,
|
||||
)
|
||||
result, _, _ = run_rolling_horizon(prices, spec, RunConfig())
|
||||
return result
|
||||
|
||||
|
||||
def test_a_valid_schedule_passes_every_check(schedule, spec):
|
||||
report = validate_schedule(schedule, spec)
|
||||
assert report.ok, report.summary()
|
||||
assert len(report.checks) == 7
|
||||
assert "all 7 validation checks passed" in report.summary()
|
||||
|
||||
|
||||
def test_catches_charging_above_the_power_limit(schedule, spec):
|
||||
schedule.loc[schedule.index[0], "charge_market_1_mw"] = 5.0
|
||||
report = validate_schedule(schedule, spec)
|
||||
|
||||
assert not report.ok
|
||||
assert report.checks["charge_within_limit"] is False
|
||||
assert any("combined charge" in failure for failure in report.failures)
|
||||
|
||||
|
||||
def test_catches_discharging_above_the_power_limit(schedule, spec):
|
||||
schedule.loc[schedule.index[30], "discharge_market_2_mw"] = 3.5
|
||||
report = validate_schedule(schedule, spec)
|
||||
|
||||
assert not report.ok
|
||||
assert report.checks["discharge_within_limit"] is False
|
||||
|
||||
|
||||
def test_catches_simultaneous_charge_and_discharge(schedule, spec):
|
||||
row = schedule.index[0]
|
||||
schedule.loc[row, "charge_market_1_mw"] = 1.0
|
||||
schedule.loc[row, "discharge_market_1_mw"] = 1.0
|
||||
report = validate_schedule(schedule, spec)
|
||||
|
||||
assert not report.ok
|
||||
assert report.checks["no_simultaneous_charge_discharge"] is False
|
||||
assert report.details["simultaneous_half_hours"] == 1.0
|
||||
|
||||
|
||||
def test_catches_market_2_power_varying_within_an_hour(schedule, spec):
|
||||
# Break only the second half-hour of hour zero.
|
||||
schedule.loc[schedule.index[1], "discharge_market_2_mw"] = 1.0
|
||||
schedule.loc[schedule.index[0], "discharge_market_2_mw"] = 0.0
|
||||
report = validate_schedule(schedule, spec)
|
||||
|
||||
assert not report.ok
|
||||
assert report.checks["hourly_commitment_constant"] is False
|
||||
|
||||
|
||||
def test_catches_a_state_of_charge_that_does_not_follow_the_power_flows(
|
||||
schedule, spec
|
||||
):
|
||||
schedule.loc[schedule.index[10], "soc_mwh"] += 1.5
|
||||
report = validate_schedule(schedule, spec)
|
||||
|
||||
assert not report.ok
|
||||
assert report.checks["soc_matches_power_flows"] is False
|
||||
|
||||
|
||||
def test_catches_a_state_of_charge_above_capacity(schedule, spec):
|
||||
"""Energy appearing from nowhere shows up as an out-of-bounds recompute."""
|
||||
# Charge hard enough to overfill, and keep the reported SoC consistent so
|
||||
# only the bounds check can fail.
|
||||
schedule["charge_market_1_mw"] = 2.0
|
||||
schedule["discharge_market_1_mw"] = 0.0
|
||||
schedule["charge_market_2_mw"] = 0.0
|
||||
schedule["discharge_market_2_mw"] = 0.0
|
||||
delta = 0.5 * spec.charge_efficiency * schedule["charge_market_1_mw"]
|
||||
schedule["soc_mwh"] = delta.cumsum()
|
||||
for name in ("market_1", "market_2"):
|
||||
schedule[f"revenue_{name}_gbp"] = (
|
||||
0.5
|
||||
* schedule[f"{name}_price"]
|
||||
* (schedule[f"discharge_{name}_mw"] - schedule[f"charge_{name}_mw"])
|
||||
)
|
||||
|
||||
report = validate_schedule(schedule, spec)
|
||||
assert not report.ok
|
||||
assert report.checks["soc_within_bounds"] is False
|
||||
assert report.checks["soc_matches_power_flows"] is True
|
||||
|
||||
|
||||
def test_catches_revenue_that_does_not_match_prices_and_powers(schedule, spec):
|
||||
schedule.loc[schedule.index[5], "revenue_market_1_gbp"] += 25.0
|
||||
report = validate_schedule(schedule, spec)
|
||||
|
||||
assert not report.ok
|
||||
assert report.checks["revenue_matches_prices_and_powers"] is False
|
||||
assert report.details["max_revenue_error_gbp"] == pytest.approx(25.0)
|
||||
|
||||
|
||||
def test_catches_a_sign_error_in_the_revenue_convention(schedule, spec):
|
||||
"""Paying for exports instead of being paid is the classic sign slip."""
|
||||
schedule["revenue_market_1_gbp"] *= -1
|
||||
report = validate_schedule(schedule, spec)
|
||||
|
||||
assert not report.ok
|
||||
assert report.checks["revenue_matches_prices_and_powers"] is False
|
||||
|
||||
|
||||
def test_reports_are_falsy_only_when_something_failed(schedule, spec):
|
||||
good = validate_schedule(schedule, spec)
|
||||
assert good.ok and not good.failures
|
||||
|
||||
schedule.loc[schedule.index[0], "charge_market_1_mw"] = np.float64(9.0)
|
||||
bad = validate_schedule(schedule, spec)
|
||||
assert not bad.ok and bad.failures
|
||||
assert "FAILED" in bad.summary()
|
||||
Reference in New Issue
Block a user