1515
1616import json
1717import logging
18+ from functools import cache
1819from pathlib import Path
1920
2021import 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