Fix test suite hangs caused by degenerate MILP fixtures
CI / test (push) Successful in 12s

Two tests were spinning up real CBC solves over 96-half-hour windows
with long runs of exactly-repeated prices (e.g. [0.0] * 40). That gives
the LP relaxation a huge set of economically indistinguishable ways to
spread a trade, which the MILP fallback's branch-and-bound then wastes
enormous effort disambiguating (47k+ nodes without closing the gap,
confirmed by running CBC verbosely). Real Attachment 2 data has no such
flat runs and solves in ~0.1s/window; a synthetic sine wiggle wasn't
enough either, since neighbouring half-hours stayed too similar.

Fixes, matched to what each test actually needs:
- The 9 validator tests only need *some* structurally valid schedule to
  mutate; they were deriving it by running the real optimiser once per
  test. Replaced with make_valid_schedule(), built directly from the
  model's own energy-balance formulas -- no solver involved, and it's
  now a true unit test of validate_schedule() in isolation.
- The rolling-horizon carry-forward test was exercising the mechanism
  at full production scale (48h window / 24h commit) when a 2h/1h
  window proves the same boundary-carrying behaviour with a trivial
  MILP, regardless of price structure.
- Fixed a genuine tie in test_optimum_uses_whichever_market_pays_more:
  two equal-price hours with just enough stored energy for one meant
  either market was a valid optimum. Sized the charge phase so delivery
  must split across both hours, pinning a unique answer.

Full suite: 38 passed in ~1.3s (previously hung indefinitely on CI and
locally within seconds of the same wall-clock variance CBC shows on
degenerate MIPs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 16:10:55 +01:00
co-authored by Claude Sonnet 5
parent 298724a9d3
commit afa02864a7
3 changed files with 91 additions and 27 deletions
+58 -1
View File
@@ -2,11 +2,13 @@
from __future__ import annotations
import math
import numpy as np
import pandas as pd
import pytest
from battery_dispatch.config import BatterySpec, RunConfig
from battery_dispatch.config import HALF_HOUR, 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
@@ -73,3 +75,58 @@ def prices_frame(
},
index=index,
)
def make_valid_schedule(spec: BatterySpec, n: int = 48) -> pd.DataFrame:
"""A schedule (shaped like the optimiser's output) that satisfies every
invariant ``validate_schedule`` checks -- built directly from the model's
own energy-balance and revenue formulas, not by running the optimiser.
``validate_schedule`` is tested in isolation: it only needs *some*
structurally valid schedule to mutate one violation into. Deriving that
from a real rolling-horizon solve would mean spinning up CBC once per
test for no reason connected to what is under test, so this constructs
one by hand instead -- charge for two hours, sit idle, discharge for two
hours, comfortably within every power and capacity bound.
"""
charge1 = np.zeros(n)
charge2 = np.zeros(n)
discharge1 = np.zeros(n)
discharge2 = np.zeros(n)
charge1[0:4] = 1.0
charge2[0:4] = 0.5
discharge1[36:40] = 0.5
discharge2[36:40] = 0.3
total_charge = charge1 + charge2
total_discharge = discharge1 + discharge2
delta = HALF_HOUR * (
spec.charge_efficiency * total_charge - total_discharge / spec.discharge_efficiency
)
soc = np.cumsum(delta)
assert soc.min() >= -1e-9 and soc.max() <= spec.capacity_mwh - 1e-9, (
"fixture parameters must stay clear of the battery's bounds"
)
price1 = np.array([40.0 + 10.0 * math.sin(i / 5) for i in range(n)])
price2_hourly = np.array([35.0 + 8.0 * math.sin(h / 4) for h in range(n // 2)])
price2 = np.repeat(price2_hourly, 2)
frame = pd.DataFrame(
{
"market_1_price": price1,
"market_2_price": price2,
"charge_market_1_mw": charge1,
"discharge_market_1_mw": discharge1,
"charge_market_2_mw": charge2,
"discharge_market_2_mw": discharge2,
"soc_mwh": soc,
"capacity_mwh": np.full(n, spec.capacity_mwh),
},
index=pd.date_range("2018-01-01", periods=n, freq="30min"),
)
frame.index.name = "timestamp"
for name in (MARKET_1, MARKET_2):
net_export = frame[f"discharge_{name}_mw"] - frame[f"charge_{name}_mw"]
frame[f"revenue_{name}_gbp"] = HALF_HOUR * frame[f"{name}_price"] * net_export
return frame