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
+28 -16
View File
@@ -98,16 +98,22 @@ def test_combined_power_respects_the_two_megawatt_cap(spec, config):
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.
"""Across hours the model switches markets rather than favouring one.
Two cheap hours fill the battery to capacity (3.8 MWh stored). Delivering
that much exceeds what a single hour can carry at the 2 MW cap, so the
discharge must split across both peak hours regardless of price -- what
is under test is *which market* gets each hour's discharge, which each
hour's own prices pin unambiguously (300 vs 10 in both cases, not a tie).
"""
markets = markets_from(
[0.0, 0.0, 300.0, 300.0, 10.0, 10.0],
[0.0, 10.0, 300.0],
[0.0, 0.0, 0.0, 0.0, 300.0, 300.0, 10.0, 10.0],
[0.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 solution.discharge_mw[MARKET_1][4:6].sum() > 0, "hour 2 should sell into M1"
assert solution.discharge_mw[MARKET_2][6:8].sum() > 0, "hour 3 should sell into M2"
assert total_power(solution, "discharge").max() <= spec.max_discharge_mw + 1e-6
@@ -137,21 +143,27 @@ def test_degradation_cost_suppresses_marginal_cycling(spec):
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)
"""State of charge at a commit boundary is the next window's starting point.
config = RunConfig(window_hours=48, commit_hours=24)
Small on purpose: this is testing ``run_rolling_horizon``'s bookkeeping
(does the committed end-of-window SoC become the next window's start?),
not the optimiser's economics, so it uses a 2-hour window / 1-hour commit
rather than the production 48h/24h -- a real rolling-horizon MILP solve
at production scale belongs in a slower, separately-run integration
check, not in the unit suite.
"""
# Cheap for two hours (charge), expensive for two hours (discharge), the
# split falling on the commit boundary.
prices = prices_frame([0.0] * 4 + [100.0] * 4, [10.0] * 4)
config = RunConfig(window_hours=4, commit_hours=2)
result, state, stats = run_rolling_horizon(prices, spec, config)
assert stats.windows == 2
assert len(result) == 96
assert len(result) == 8
# 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
# The lookahead must carry energy over the boundary to reach the peak.
soc_at_boundary = result["soc_mwh"].iloc[3]
assert soc_at_boundary > 1.0
assert state.equivalent_full_cycles > 0