Skip to content

Commit a912b73

Browse files
johanzanderclaude
andauthored
test(perf): cache per-scenario optimization in test_min_schedule_e2e (#153)
The 144 slow tests in this file (16 assertions × 9 real-world scenarios) each re-ran a full DP optimization via _run_and_build_schedule — but every test only asserts a different property of the *same* schedule. The optimization is the sole expensive step (quarterly DP); the scheduler build is cheap. Split out @cache _optimize_scenario(scenario_name) so the optimizer runs ONCE per scenario (9x total instead of 144x), and rebuild a fresh GrowattScheduleManager per test to preserve isolation. No test removed, no coverage lost — purely eliminates redundant recomputation. Verified: all 16 tests for one scenario pass in ~34s (a single optimization), vs ~16 optimizations before. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b8ae0a6 commit a912b73

1 file changed

Lines changed: 44 additions & 36 deletions

File tree

core/bess/tests/unit/test_min_schedule_e2e.py

Lines changed: 44 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import json
1717
import logging
18+
from functools import cache
1819
from pathlib import Path
1920

2021
import pytest
@@ -44,13 +45,20 @@ def _load_scenario(name: str) -> dict:
4445
return json.load(f)
4546

4647

47-
def _run_and_build_schedule(
48-
scenario: dict, current_period: int = 0
49-
) -> tuple[GrowattScheduleManager, list[str]]:
50-
"""Run optimizer on scenario and build MIN Growatt TOU schedule.
48+
@cache
49+
def _optimize_scenario(scenario_name: str):
50+
"""Run the DP optimizer for a scenario ONCE and cache the result.
5151
52-
Returns (scheduler, strategic_intents) tuple.
52+
The optimization (full quarterly DP) is the only expensive step in this
53+
module — every test asserts a different property of the *same* schedule. By
54+
caching the result per scenario and rebuilding the cheap
55+
GrowattScheduleManager fresh per test (see _run_and_build_schedule), we avoid
56+
recomputing the identical optimization ~16x per scenario while preserving
57+
test isolation.
58+
59+
Returns (OptimizationResult, BatterySettings).
5360
"""
61+
scenario = _load_scenario(scenario_name)
5462
battery = scenario["battery"]
5563
price_data = scenario["price_data"]
5664
base_prices = scenario["base_prices"]
@@ -87,6 +95,21 @@ def _run_and_build_schedule(
8795
battery_settings=battery_settings,
8896
period_duration_hours=period_duration_hours,
8997
)
98+
return result, battery_settings
99+
100+
101+
def _run_and_build_schedule(
102+
scenario_name: str, current_period: int = 0
103+
) -> tuple[GrowattScheduleManager, list[str]]:
104+
"""Build a MIN Growatt TOU schedule from the (cached) optimizer result.
105+
106+
The expensive optimization is cached by _optimize_scenario; the scheduler is
107+
rebuilt fresh here so each test gets an isolated object.
108+
109+
Returns (scheduler, strategic_intents) tuple.
110+
"""
111+
result, battery_settings = _optimize_scenario(scenario_name)
112+
scenario = _load_scenario(scenario_name)
90113

91114
# Convert OptimizationResult → DPSchedule (same path as battery_system_manager.py)
92115
strategic_intents = [pd.decision.strategic_intent for pd in result.period_data]
@@ -119,33 +142,29 @@ class TestEndToEndHardwareConstraints:
119142

120143
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
121144
def test_no_overlapping_intervals(self, scenario_name):
122-
scenario = _load_scenario(scenario_name)
123-
scheduler, _ = _run_and_build_schedule(scenario)
145+
scheduler, _ = _run_and_build_schedule(scenario_name)
124146
assert (
125147
scheduler.has_no_overlapping_intervals()
126148
), f"{scenario_name}: TOU intervals overlap"
127149

128150
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
129151
def test_chronological_order(self, scenario_name):
130-
scenario = _load_scenario(scenario_name)
131-
scheduler, _ = _run_and_build_schedule(scenario)
152+
scheduler, _ = _run_and_build_schedule(scenario_name)
132153
assert (
133154
scheduler.intervals_are_chronologically_ordered()
134155
), f"{scenario_name}: TOU intervals not in chronological order"
135156

136157
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
137158
def test_hardware_slot_limit(self, scenario_name):
138-
scenario = _load_scenario(scenario_name)
139-
scheduler, _ = _run_and_build_schedule(scenario)
159+
scheduler, _ = _run_and_build_schedule(scenario_name)
140160
assert len(scheduler.active_tou_intervals) <= 9, (
141161
f"{scenario_name}: {len(scheduler.active_tou_intervals)} active intervals "
142162
f"exceeds 9-slot hardware limit"
143163
)
144164

145165
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
146166
def test_segment_ids_in_valid_range(self, scenario_name):
147-
scenario = _load_scenario(scenario_name)
148-
scheduler, _ = _run_and_build_schedule(scenario)
167+
scheduler, _ = _run_and_build_schedule(scenario_name)
149168
for seg in scheduler.active_tou_intervals:
150169
assert (
151170
1 <= seg["segment_id"] <= 9
@@ -158,8 +177,7 @@ class TestEndToEndIntentExecution:
158177
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
159178
def test_charging_intents_produce_charging_config(self, scenario_name):
160179
"""Hours with GRID_CHARGING intent must be configured for charging."""
161-
scenario = _load_scenario(scenario_name)
162-
scheduler, intents = _run_and_build_schedule(scenario)
180+
scheduler, intents = _run_and_build_schedule(scenario_name)
163181

164182
# Find hours where all 4 quarterly periods are GRID_CHARGING
165183
for hour in range(24):
@@ -173,8 +191,7 @@ def test_charging_intents_produce_charging_config(self, scenario_name):
173191
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
174192
def test_export_intents_produce_export_config(self, scenario_name):
175193
"""Hours with EXPORT_ARBITRAGE intent must be configured for export."""
176-
scenario = _load_scenario(scenario_name)
177-
scheduler, intents = _run_and_build_schedule(scenario)
194+
scheduler, intents = _run_and_build_schedule(scenario_name)
178195

179196
for hour in range(24):
180197
quarter_intents = set(intents[hour * 4 : (hour + 1) * 4])
@@ -189,8 +206,7 @@ def test_export_intents_produce_export_config(self, scenario_name):
189206
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
190207
def test_idle_hours_use_default_mode(self, scenario_name):
191208
"""Hours where all quarters are default-mode intents should be load_first."""
192-
scenario = _load_scenario(scenario_name)
193-
scheduler, intents = _run_and_build_schedule(scenario)
209+
scheduler, intents = _run_and_build_schedule(scenario_name)
194210

195211
for hour in range(24):
196212
quarter_intents = set(intents[hour * 4 : (hour + 1) * 4])
@@ -210,8 +226,7 @@ class TestEndToEndChargeDischargeRates:
210226
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
211227
def test_grid_charging_periods_have_correct_rates(self, scenario_name):
212228
"""GRID_CHARGING: grid_charge=True, charge_rate=100%, discharge_rate=0%."""
213-
scenario = _load_scenario(scenario_name)
214-
scheduler, _ = _run_and_build_schedule(scenario)
229+
scheduler, _ = _run_and_build_schedule(scenario_name)
215230

216231
for period in range(len(scheduler.strategic_intents)):
217232
settings = scheduler.get_period_settings(period)
@@ -229,8 +244,7 @@ def test_grid_charging_periods_have_correct_rates(self, scenario_name):
229244
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
230245
def test_export_arbitrage_periods_have_correct_rates(self, scenario_name):
231246
"""EXPORT_ARBITRAGE: grid_charge=False, charge_rate=0%, discharge_rate=100%."""
232-
scenario = _load_scenario(scenario_name)
233-
scheduler, _ = _run_and_build_schedule(scenario)
247+
scheduler, _ = _run_and_build_schedule(scenario_name)
234248

235249
for period in range(len(scheduler.strategic_intents)):
236250
settings = scheduler.get_period_settings(period)
@@ -248,8 +262,7 @@ def test_export_arbitrage_periods_have_correct_rates(self, scenario_name):
248262
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
249263
def test_idle_periods_have_correct_rates(self, scenario_name):
250264
"""IDLE: grid_charge=False, discharge_rate=0%."""
251-
scenario = _load_scenario(scenario_name)
252-
scheduler, _ = _run_and_build_schedule(scenario)
265+
scheduler, _ = _run_and_build_schedule(scenario_name)
253266

254267
for period in range(len(scheduler.strategic_intents)):
255268
settings = scheduler.get_period_settings(period)
@@ -264,8 +277,7 @@ def test_idle_periods_have_correct_rates(self, scenario_name):
264277
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
265278
def test_solar_storage_periods_have_correct_rates(self, scenario_name):
266279
"""SOLAR_STORAGE: grid_charge=False, charge_rate=100%, discharge_rate=0%."""
267-
scenario = _load_scenario(scenario_name)
268-
scheduler, _ = _run_and_build_schedule(scenario)
280+
scheduler, _ = _run_and_build_schedule(scenario_name)
269281

270282
for period in range(len(scheduler.strategic_intents)):
271283
settings = scheduler.get_period_settings(period)
@@ -283,8 +295,7 @@ def test_solar_storage_periods_have_correct_rates(self, scenario_name):
283295
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
284296
def test_load_support_periods_have_correct_rates(self, scenario_name):
285297
"""LOAD_SUPPORT: grid_charge=False, charge_rate=0%, discharge_rate=100%."""
286-
scenario = _load_scenario(scenario_name)
287-
scheduler, _ = _run_and_build_schedule(scenario)
298+
scheduler, _ = _run_and_build_schedule(scenario_name)
288299

289300
for period in range(len(scheduler.strategic_intents)):
290301
settings = scheduler.get_period_settings(period)
@@ -302,8 +313,7 @@ def test_load_support_periods_have_correct_rates(self, scenario_name):
302313
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
303314
def test_all_periods_have_settings(self, scenario_name):
304315
"""Every period must return valid settings via get_period_settings."""
305-
scenario = _load_scenario(scenario_name)
306-
scheduler, intents = _run_and_build_schedule(scenario)
316+
scheduler, intents = _run_and_build_schedule(scenario_name)
307317

308318
for period in range(len(intents)):
309319
settings = scheduler.get_period_settings(period)
@@ -327,7 +337,7 @@ def test_mid_day_update_maintains_constraints(self, scenario_name):
327337
midpoint = min(horizon // 2, 48) # cap at period 48 (noon)
328338

329339
for current_period in [0, midpoint]:
330-
scheduler, _ = _run_and_build_schedule(scenario, current_period)
340+
scheduler, _ = _run_and_build_schedule(scenario_name, current_period)
331341

332342
assert (
333343
scheduler.has_no_overlapping_intervals()
@@ -347,8 +357,7 @@ class TestEndToEndHardwareWrite:
347357
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
348358
def test_hardware_writes_use_valid_slot_ids(self, scenario_name):
349359
"""All hardware writes use segment_id 1-9."""
350-
scenario = _load_scenario(scenario_name)
351-
scheduler, _ = _run_and_build_schedule(scenario)
360+
scheduler, _ = _run_and_build_schedule(scenario_name)
352361

353362
calls = []
354363

@@ -383,8 +392,7 @@ def set_inverter_time_segment(
383392
@pytest.mark.parametrize("scenario_name", _get_realworld_scenarios())
384393
def test_hardware_write_count_within_limit(self, scenario_name):
385394
"""Never write more than 9 segments to hardware."""
386-
scenario = _load_scenario(scenario_name)
387-
scheduler, _ = _run_and_build_schedule(scenario)
395+
scheduler, _ = _run_and_build_schedule(scenario_name)
388396

389397
write_count = 0
390398

0 commit comments

Comments
 (0)