Skip to content

Commit 9be8f4d

Browse files
test: vary iboost and low power export across the random scenarios (#4550)
* test: vary iboost and low power export across the random scenarios The 20-scenario byte-identical benchmark is the gate every planner change is held to, but three feature flags were pinned by the template for all 20 runs: low power charge always on, iboost and low power export always off. Anything guarded by the two disabled ones could be changed - or broken - without the gate noticing. That was not theoretical. The iboost arm of export_window_allowed, added in #4549, could not be reached by any scenario: it needs iboost_enable, a non-empty iboost_plan and iboost_on_export off. It is now taken 126 times across the suite. kernel_parity did cover iboost for prediction, but it never runs the optimiser, so nothing reached that branch. iboost_enable is drawn at 40% (matching kernel_parity) and set_export_low_power 50/50, from an rng salted off the scenario seed rather than the main stream - the same device the car block uses, and for the same reason: every pre-existing parameter and stored profile is bit-identical, so a plan that moves has moved because of the new flags and nothing else. Verified: 0 drift across all 20 scenarios, and with the flags present but unapplied the plans still match the old baseline exactly. The whole iboost block is written on every scenario rather than only the enabled ones. Setting it only when enabled left the previous scenario's values behind, so a plan depended on what ran before it - the same trap run_debug_cases documents, and it moved all 20 scenarios instead of the 13 the flags actually touch. With the off-case values taken from the template, the 7 scenarios neither flag touches are byte-identical to before, which is what makes the regenerated baseline reviewable. iboost_plan is built here on the same condition fetch_sensor_data uses, because the scenario runner never calls fetch and an empty plan cannot reach the optimiser path this exists to cover. Also fixes the static context cache fixture, which ran with iboost off and no battery temperature curves - so iboost_plan_load was 288 zeroes and every temperature produced the identical cap. Both are what the cache reuses, so the equivalence tests would have agreed whatever it did with them. Now 36 of 288 iboost steps are non-zero and the caps take 3 distinct values. Baseline regenerated: 13 scenarios move, 7 are unchanged, 217 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: run the random benchmark in the suite, and randomise the load scalings Three changes to the random scenario benchmark. The 20-scenario plan comparison now runs as part of run_all rather than only by hand. It fails if any recorded field differs from the committed baseline, and reports runtime without asserting on it - the suite runs on machines of wildly different speeds, so a runtime threshold would either be so loose it caught nothing or so tight it failed for reasons unrelated to the change under test. It takes ~28s, so it runs under --quick too. Verified falsifiable: a 0.0001 metric change and a changed final SoC both fail it, while multiplying every baseline runtime by ten does not. compare_results gains a per-scenario time_diff column and a suite total, both as percentages as well as seconds. Absolute seconds only mean something against the machine that produced them; the ratio survives a comparison between machines. The load scalings for the three simulated futures are now randomised over 0.2-2.0, sorted so load_scaling90 <= load_scaling <= load_scaling10 - the order the planner requires, PV90 being the sunny light-load future and PV10 the cloudy heavy one. The template pinned load_scaling 0.5 and load_scaling10 0.6 and left load_scaling90 at its 0.7 default, which the planner detects as inverted and clamps back to 0.5. Every scenario therefore ran PV90 with exactly the central case's load, and the warning fired on all twenty. Both clamp warnings are now gone, and the mean gap between cost_pv90 and cost widens from 105.84 to 349.32 - the pv90 column was previously separated from nominal only by the PV forecast, never by load. Drawn from their own rng stream, as the car and feature blocks are, so adding them leaves every pre-existing scenario parameter bit-identical. Baseline regenerated. 217 tests pass, pre-commit clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): reset the iboost carry-over between scenarios, correct a step count Both review comments on this PR. calculate_plan writes iboost_next back onto the instance and the scenario runner never calls fetch_config_options, which is what resets it every cycle in the product. A scenario therefore inherited the previous one's iboost carry-over, which is the same order dependence this PR already fixed for the rest of the iboost block - just incompletely. The reset now mirrors fetch_config_options: iboost_next, the three running flags and iboost_energy_today. No scenario's plan moves. Verified by running the full suite with and without the reset and comparing all twenty metrics: zero differ, so this closes a real hazard rather than a live defect, and the baseline is unchanged. The static cache fixture docstring said iboost_plan_load would be 576 zeroes. The fixture sets forecast_minutes to 24 hours and the kernel steps at 5 minutes, so the arrays are 288 long - the number the comment exists to make concrete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 308f348 commit 9be8f4d

5 files changed

Lines changed: 638 additions & 218 deletions

File tree

apps/predbat/tests/test_kernel_static_cache.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,13 @@ def run_kernel_static_cache_tests(my_predbat):
3737

3838

3939
def build_environment(my_predbat):
40-
"""Set up a predbat with a varying battery temperature so the temperature memo is exercised"""
40+
"""Set up a predbat exercising the parts of the static context that vary per step.
41+
42+
The battery temperature changes over the horizon so the temperature memo has to key on the right
43+
thing, and iboost runs with a real plan so iboost_plan_load is a non-trivial array rather than
44+
288 zeroes - otherwise the cache could be reusing it wrongly and every comparison would still
45+
agree.
46+
"""
4147
reset_inverter(my_predbat)
4248
my_predbat.forecast_minutes = 24 * 60
4349
my_predbat.prediction_kernel_enable = True
@@ -47,8 +53,29 @@ def build_environment(my_predbat):
4753
# Real rates, or every prediction costs 0.0 and the two loads compare equal - which would leave
4854
# the leak test above unable to fail
4955
reset_rates(my_predbat, 10.0, 5.0)
50-
# A temperature that changes over the horizon, so a memo keyed on it has to key on the right thing
56+
# A temperature that changes over the horizon, so a memo keyed on it has to key on the right thing.
57+
# The curves matter as much as the profile: with no curve configured every temperature yields the
58+
# same cap, and a memo keyed on the wrong thing entirely would still agree at every step.
5159
my_predbat.battery_temperature_prediction = {minute: max(20 - minute / (2 * 60.0), -5) for minute in range(0, my_predbat.forecast_minutes, 5)}
60+
my_predbat.battery_temperature_charge_curve = {20: 1.0, 15: 0.9, 10: 0.7, 5: 0.5, 0: 0.3, -5: 0.2, -10: 0.1}
61+
my_predbat.battery_temperature_discharge_curve = {20: 1.0, 15: 0.95, 10: 0.85, 5: 0.6, 0: 0.4, -5: 0.25, -10: 0.15}
62+
63+
# iboost on, with a plan covering part of the horizon, so the cached iboost_plan_load array
64+
# carries real slot data
65+
my_predbat.iboost_enable = True
66+
my_predbat.iboost_solar = False
67+
my_predbat.iboost_charging = False
68+
my_predbat.iboost_on_export = False
69+
my_predbat.iboost_prevent_discharge = False
70+
my_predbat.iboost_max_energy = 5.0
71+
my_predbat.iboost_max_power = 2.5 / 60.0
72+
my_predbat.iboost_min_power = 0.0
73+
my_predbat.iboost_today = 0.0
74+
my_predbat.iboost_next = 0.0
75+
my_predbat.iboost_plan = [
76+
{"start": my_predbat.minutes_now + 120, "end": my_predbat.minutes_now + 240, "kwh": 3.0},
77+
{"start": my_predbat.minutes_now + 480, "end": my_predbat.minutes_now + 540, "kwh": 1.5},
78+
]
5279

5380
pv_step = {minute: 0.05 for minute in range(0, my_predbat.forecast_minutes, 5)}
5481
return pv_step

apps/predbat/tests/test_random_scenarios.py

Lines changed: 185 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import math
1212
import json
13+
import os
1314
import random
1415
import datetime
1516
import time
@@ -36,6 +37,19 @@
3637
CAR_SLOTS_MIN = 4 # a plugged-in EV typically has a handful of planned slots
3738
CAR_SLOTS_MAX = 20
3839
CAR_BATTERY_KWH_OPTIONS = [40.0, 60.0, 77.0, 100.0]
40+
# Feature flags. Drawn from their own RNG stream for the same reason as cars, so adding them leaves
41+
# every pre-existing scenario parameter bit-identical. Until these existed the template pinned all
42+
# three for every scenario - low power charge on, iboost and low power export off - so the
43+
# byte-identical benchmark could not see any change to the code paths they guard.
44+
FEATURE_SEED_SALT = 0xFEA7 # keeps the feature draws off the main rng sequence
45+
IBOOST_PROBABILITY = 0.4 # fraction of scenarios running an immersion boost
46+
# Load scaling for the three simulated futures, again on its own rng stream. The template pinned
47+
# load_scaling 0.5 / load_scaling10 0.6 and left load_scaling90 at its 0.7 default, which the planner
48+
# detects as inverted and clamps back to 0.5 - so PV90 carried exactly the central case's load in
49+
# every scenario and the pv90 column could not measure anything the nominal column did not.
50+
LOAD_SCALING_SEED_SALT = 0x10AD5
51+
LOAD_SCALING_MIN = 0.2
52+
LOAD_SCALING_MAX = 2.0
3953
CLOCK_STEP_MINUTES = 5 # predbat runs on a 5 minute cadence, so start times are multiples of 5
4054
RATE_HISTORY_DAYS = 3 # days of past + future rates to generate
4155
RATE_FUTURE_DAYS = 2
@@ -331,13 +345,50 @@ def generate_random_scenario(scenario_id, seed):
331345
slots.append({"start": start, "end": start + 30, "kwh": round(car_rng.uniform(0.0, 3.5), 3)})
332346
car_slots.append(slots)
333347

348+
# --- Load scaling ---
349+
# Own rng stream, see the car block above. Sorted so that load_scaling90 <= load_scaling <=
350+
# load_scaling10, which is the order the planner requires: PV90 is the sunny, light load future
351+
# and PV10 the cloudy, heavy one. Out of order it clamps them and warns, which is what the
352+
# template was doing.
353+
scaling_rng = random.Random(seed ^ LOAD_SCALING_SEED_SALT)
354+
scaling_pv90, scaling_nominal, scaling_pv10 = sorted(round(scaling_rng.uniform(LOAD_SCALING_MIN, LOAD_SCALING_MAX), 3) for _ in range(3))
355+
356+
# --- Feature flags ---
357+
# Separate rng, see the car block above. iboost_smart is what makes fetch build an iboost_plan,
358+
# and iboost_on_export decides whether an export window colliding with that plan is rejected -
359+
# the two together are what exercise the optimiser's iboost path rather than just the prediction's.
360+
feature_rng = random.Random(seed ^ FEATURE_SEED_SALT)
361+
features = {
362+
"set_export_low_power": feature_rng.choice([True, False]),
363+
"iboost_enable": feature_rng.random() < IBOOST_PROBABILITY,
364+
}
365+
if features["iboost_enable"]:
366+
features.update(
367+
{
368+
"iboost_solar": feature_rng.choice([True, False]),
369+
"iboost_smart": feature_rng.choice([True, False]),
370+
"iboost_charging": feature_rng.choice([True, False]),
371+
"iboost_on_export": feature_rng.choice([True, False]),
372+
"iboost_prevent_discharge": feature_rng.choice([True, False]),
373+
"iboost_max_energy": round(feature_rng.uniform(1.0, 10.0), 2),
374+
"iboost_max_power_kw": round(feature_rng.uniform(1.0, 3.0), 2),
375+
"iboost_today": round(feature_rng.uniform(0.0, 3.0), 2),
376+
}
377+
)
378+
334379
return {
335380
"id": scenario_id,
336381
"seed": seed,
337382
"params": {
338383
"clock": {
339384
"minutes_now": clock_minutes_now,
340385
},
386+
"features": features,
387+
"load_scaling": {
388+
"nominal": scaling_nominal,
389+
"pv10": scaling_pv10,
390+
"pv90": scaling_pv90,
391+
},
341392
"cars": {
342393
"num_cars": num_cars,
343394
"battery_kwh": car_battery_kwh,
@@ -668,6 +719,49 @@ def apply_scenario_to_predbat(my_predbat, scenario):
668719
if my_predbat.rate_low_threshold == 0 and highest >= my_predbat.rate_min:
669720
my_predbat.rate_import_cost_threshold = highest
670721

722+
# --- Load scaling for the three simulated futures ---
723+
# Absent in scenario files written before this existed, which keep the template's own values.
724+
scaling = params.get("load_scaling")
725+
if scaling:
726+
my_predbat.load_scaling = scaling["nominal"]
727+
my_predbat.load_scaling10 = scaling["pv10"]
728+
my_predbat.load_scaling90 = scaling["pv90"]
729+
730+
# --- Feature flags ---
731+
# Scenario files written before this existed carry no "features" entry; those keep the template's
732+
# own settings so previously recorded benchmark results stay comparable, exactly as "clock" does.
733+
features = params.get("features")
734+
if features:
735+
my_predbat.set_export_low_power = features["set_export_low_power"]
736+
my_predbat.iboost_enable = features["iboost_enable"]
737+
# The whole block is written every scenario, not just the enabled ones. Setting these only
738+
# when iboost is on leaves the previous scenario's values behind, which makes a plan depend
739+
# on what ran before it - the same trap run_debug_cases documents. The off-case values are the
740+
# template's, so a scenario with iboost disabled plans exactly as it did before this existed.
741+
my_predbat.iboost_solar = features.get("iboost_solar", True)
742+
my_predbat.iboost_smart = features.get("iboost_smart", False)
743+
my_predbat.iboost_charging = features.get("iboost_charging", False)
744+
my_predbat.iboost_on_export = features.get("iboost_on_export", False)
745+
my_predbat.iboost_prevent_discharge = features.get("iboost_prevent_discharge", False)
746+
my_predbat.iboost_max_energy = features.get("iboost_max_energy", 3.0)
747+
my_predbat.iboost_max_power = features["iboost_max_power_kw"] / 60.0 if "iboost_max_power_kw" in features else 0.04
748+
my_predbat.iboost_today = features.get("iboost_today", 0.0)
749+
# Mirror the per-cycle reset in fetch_config_options. calculate_plan writes iboost_next back
750+
# onto the instance, and the scenario runner never calls fetch, so without this a scenario
751+
# inherits the previous one's iboost carry-over. No scenario's plan moves today - verified by
752+
# running the suite with and without it - but the hazard is real and the reset is free.
753+
my_predbat.iboost_next = my_predbat.iboost_today
754+
my_predbat.iboost_running = False
755+
my_predbat.iboost_running_solar = False
756+
my_predbat.iboost_running_full = False
757+
my_predbat.iboost_energy_today = {}
758+
my_predbat.iboost_plan = []
759+
# fetch_sensor_data builds the plan in the product, and the scenario runner never calls fetch,
760+
# so it is built here on the same condition. Without it iboost_plan stays empty and the
761+
# optimiser's iboost path - which only fires on a non-empty plan - is never reached.
762+
if my_predbat.iboost_enable and (((not my_predbat.iboost_solar) and (not my_predbat.iboost_charging)) or my_predbat.iboost_smart):
763+
my_predbat.iboost_plan = my_predbat.plan_iboost_smart()
764+
671765

672766
# ---------------------------------------------------------------------------
673767
# Single scenario runner
@@ -977,7 +1071,11 @@ def run_scenarios_from_file(my_predbat, scenarios_file, template_yaml, results_f
9771071
print(" ERROR: {}".format(result["error"]))
9781072
results.append(result)
9791073

980-
_save_results(results, results_file, scenarios_file, template_yaml)
1074+
# results_file is optional so an in-process caller - the plan regression test - can compare the
1075+
# results directly instead of round-tripping them through a file it would then have to clean up
1076+
if results_file:
1077+
_save_results(results, results_file, scenarios_file, template_yaml)
1078+
return results
9811079

9821080

9831081
def _save_results(results, results_file, scenarios_file, template_yaml):
@@ -1044,8 +1142,8 @@ def compare_results(file_a, file_b):
10441142
print("")
10451143

10461144
# Column widths
1047-
header = "{:>4} {:>12} {:>12} {:>10} {:>12} {:>12} {:>10} {:>8} {:>8} {:>8}".format(
1048-
"ID", "metric_A", "metric_B", "met_diff", "cost_A", "cost_B", "cost_diff", "time_A", "time_B", "status"
1145+
header = "{:>4} {:>12} {:>12} {:>10} {:>12} {:>12} {:>10} {:>8} {:>8} {:>10} {:>8}".format(
1146+
"ID", "metric_A", "metric_B", "met_diff", "cost_A", "cost_B", "cost_diff", "time_A", "time_B", "time_diff", "status"
10491147
)
10501148
print(header)
10511149
print("-" * len(header))
@@ -1103,21 +1201,25 @@ def compare_results(file_a, file_b):
11031201

11041202
ta_str = "n/a"
11051203
tb_str = "n/a"
1204+
time_diff_str = "n/a"
11061205
if ta is not None:
11071206
ta_str = "{:.3f}s".format(ta)
11081207
if tb is not None:
11091208
tb_str = "{:.3f}s".format(tb)
11101209
if ta is not None and tb is not None:
11111210
runtime_diffs.append(tb - ta)
1211+
# Shown as a percentage as well as seconds: the absolute numbers only mean anything
1212+
# against the machine that produced them, but the ratio survives the comparison
1213+
time_diff_str = "{:+.1f}%".format(((tb - ta) / ta * 100) if ta else 0.0)
11121214

11131215
for key, diffs in future_diffs.items():
11141216
fa = ra.get(key)
11151217
fb = rb.get(key)
11161218
if fa is not None and fb is not None:
11171219
diffs.append(fb - fa)
11181220

1119-
print("{:>4} {:>12} {:>12} {:>10} {:>12} {:>12} {:>10} {:>8} {:>8} {:>8}".format(
1120-
sid, ma_str, mb_str, met_diff_str, ca_str, cb_str, cost_diff_str, ta_str, tb_str, status
1221+
print("{:>4} {:>12} {:>12} {:>10} {:>12} {:>12} {:>10} {:>8} {:>8} {:>10} {:>8}".format(
1222+
sid, ma_str, mb_str, met_diff_str, ca_str, cb_str, cost_diff_str, ta_str, tb_str, time_diff_str, status
11211223
))
11221224

11231225
print("-" * len(header))
@@ -1176,6 +1278,84 @@ def compare_results(file_a, file_b):
11761278
print(" Average diff : {:+.3f}s (+ = B slower, - = B faster)".format(avg_rt_diff))
11771279
print(" Min diff : {:+.3f}s".format(min(runtime_diffs)))
11781280
print(" Max diff : {:+.3f}s".format(max(runtime_diffs)))
1281+
total_a = sum(r.get("runtime_s") or 0 for r in results_a.values())
1282+
total_b = sum(r.get("runtime_s") or 0 for r in results_b.values())
1283+
if total_a:
1284+
print(" Total : {:.2f}s -> {:.2f}s ({:+.1f}%)".format(total_a, total_b, (total_b - total_a) / total_a * 100))
1285+
1286+
1287+
RANDOM_TEMPLATE = "cases/predbat_debug_agile1.yaml"
1288+
RANDOM_SCENARIOS = "cases/random_scenarios.yaml"
1289+
RANDOM_BASELINE = "cases/random_results.json"
1290+
# Every recorded field except these has to match the baseline exactly. runtime_s is wall-clock and so
1291+
# says more about the machine than the code; timestamp is when the baseline was taken.
1292+
RANDOM_IGNORED_FIELDS = ("runtime_s",)
1293+
1294+
1295+
def run_random_scenario_tests(my_predbat):
1296+
"""Replay the 20 scenario benchmark and fail if any plan differs from the committed baseline.
1297+
1298+
This is the gate the planning work is held to: every optimiser change is expected to leave all
1299+
twenty plans bit-identical, so a change that moves one has either changed behaviour or introduced
1300+
a bug, and either way wants a deliberate baseline regeneration rather than a quiet drift.
1301+
1302+
Timing is reported but never asserted on. The suite runs on developer machines and CI runners of
1303+
wildly different speeds, so a runtime threshold here would either be so loose it caught nothing or
1304+
so tight it failed for reasons that have nothing to do with the change under test.
1305+
1306+
my_predbat is deliberately unused - each run gets a fresh instance, for the reason run_debug_cases
1307+
gives: read_debug_yaml only restores what its dump carries, so replaying a plan on the shared
1308+
instance both inherits and leaves behind state that other tests depend on.
1309+
"""
1310+
print("**** Running random scenario plan regression ****")
1311+
1312+
if not os.path.exists(RANDOM_BASELINE):
1313+
print("ERROR: no baseline at {} - regenerate it with ./run_random".format(RANDOM_BASELINE))
1314+
return True
1315+
1316+
with open(RANDOM_BASELINE, "r") as file_handle:
1317+
baseline = {r["id"]: r for r in json.load(file_handle).get("results", [])}
1318+
1319+
# Imported here rather than at module scope: unit_test imports this module, so a top level
1320+
# import would be circular
1321+
from unit_test import create_predbat
1322+
1323+
scenario_predbat = create_predbat()
1324+
results = run_scenarios_from_file(scenario_predbat, RANDOM_SCENARIOS, RANDOM_TEMPLATE, None)
1325+
current = {r["id"]: r for r in results}
1326+
1327+
missing = sorted(set(baseline) - set(current))
1328+
extra = sorted(set(current) - set(baseline))
1329+
if missing or extra:
1330+
print("ERROR: scenario ids do not match the baseline - missing {} extra {}".format(missing, extra))
1331+
return True
1332+
1333+
failed = False
1334+
compared_fields = 0
1335+
total_base = 0.0
1336+
total_now = 0.0
1337+
for sid in sorted(baseline):
1338+
want = baseline[sid]
1339+
got = current[sid]
1340+
total_base += want.get("runtime_s") or 0.0
1341+
total_now += got.get("runtime_s") or 0.0
1342+
for field in want:
1343+
if field in RANDOM_IGNORED_FIELDS:
1344+
continue
1345+
compared_fields += 1
1346+
if want[field] != got.get(field):
1347+
print("ERROR: scenario {} {} changed: {} -> {}".format(sid, field, want[field], got.get(field)))
1348+
failed = True
1349+
1350+
# Informational only - see the docstring for why this is not an assertion
1351+
if total_base:
1352+
print("Runtime: baseline {:.2f}s, this run {:.2f}s ({:+.1f}%) - not asserted on, machine dependent".format(total_base, total_now, (total_now - total_base) / total_base * 100))
1353+
1354+
if failed:
1355+
print("ERROR: plans differ from {}. If the change is intended, regenerate with ./run_random".format(RANDOM_BASELINE))
1356+
else:
1357+
print("All {} scenarios match the baseline across {} compared fields".format(len(baseline), compared_fields))
1358+
return failed
11791359

11801360

11811361
# ---------------------------------------------------------------------------

apps/predbat/unit_test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@
182182
from tests.test_plan_why_reason import run_test_plan_why_reason
183183
from tests.test_rate_replicate_missing_slots import test_rate_replicate
184184
from tests.test_find_charge_window import test_find_charge_window
185-
from tests.test_random_scenarios import generate_scenarios, save_scenarios, run_scenarios_from_file, compare_results, profile_scenario
185+
from tests.test_random_scenarios import generate_scenarios, save_scenarios, run_scenarios_from_file, compare_results, profile_scenario, run_random_scenario_tests
186186
from tests.test_carbon import test_carbon
187187
from tests.test_storage import test_storage
188188
from tests.test_plan_persistence import test_plan_persistence
@@ -547,6 +547,7 @@ def main():
547547
("tariff_catalogue", test_tariff_catalogue, "Tariff catalogue tests", False),
548548
("annual_integration", run_annual_integration_isolated, "Annual prediction integration tests", True),
549549
("load_ml", test_load_ml, "ML Load Forecaster tests (MLP, training, persistence, validation)", True),
550+
("random", run_random_scenario_tests, "Random scenario plan regression against the committed baseline", False),
550551
]
551552

552553
# Parse command line arguments

0 commit comments

Comments
 (0)