Add battery dispatch implementation and Gitea CI pipeline
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>
This commit is contained in:
2026-09-24 13:32:06 +01:00
co-authored by Claude Sonnet 5
parent 54e877db9b
commit 298724a9d3
22 changed files with 3330 additions and 0 deletions
+75
View File
@@ -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,
)
+85
View File
@@ -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)
+189
View File
@@ -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
+162
View File
@@ -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)
+129
View File
@@ -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()