Skip to content

Commit e4323c7

Browse files
feat(log): log an empty load forecast, iBoost used today, load_forecast_only and the dynamic load baseline (#5427 review)
Four inputs the plan reads that the replay lines did not carry: - an empty load forecast, logged when the forecast becomes empty (not every cycle, as many setups have none), so a replay drops the forecast it still holds and plans on history as live did; - iboost_today, which the iBoost plan and the prediction's remaining iBoost allowance read, and load_forecast_only, which decides whether the load history is used at all, both now in the state line; - the dynamic load baseline that dynamic_load() sets for a high-load cycle and every load scenario adds, logged on change from the live plan (usually {}). The replay does not read these yet; they are logged now so that logs carry them when it does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 11a21c8 commit e4323c7

3 files changed

Lines changed: 69 additions & 5 deletions

File tree

‎apps/predbat/fetch.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1033,7 +1033,11 @@ def log_replay_load_forecast(self):
10331033
"""
10341034
try:
10351035
if not self.load_forecast:
1036+
# The plan then uses the load history alone, so a replay must drop any forecast it still holds.
1037+
# Logged when the forecast becomes empty, not every cycle, as many setups have no forecast at all.
1038+
self.log_replay_changed("replay_load_empty", lambda: True, lambda: "Replay input: load forecast empty", "load forecast")
10361039
return
1040+
self.replay_load_empty = None
10371041
start = self.minutes_now
10381042
steps = list(range(start, start + self.forecast_minutes + self.plan_interval_minutes, PREDICT_STEP))
10391043
# The value at each step, the minute after it, and the start of the step after the last
@@ -1126,6 +1130,16 @@ def build():
11261130

11271131
self.log_replay_changed(attribute, lambda: repr(state), build, label + " state")
11281132

1133+
def log_replay_baseline(self):
1134+
"""Log the dynamic load baseline the plan will use, when it changes.
1135+
1136+
dynamic_load() raises the load in the current slot or two to the load just measured when it is high, and
1137+
step_data_history() adds that floor to all three load scenarios, so a replay needs it as it was. Usually
1138+
empty. Logged as a dict of minute to kWh whose repr reads back exactly.
1139+
"""
1140+
baseline = dict(self.dynamic_load_baseline or {})
1141+
self.log_replay_changed("replay_baseline_text", lambda: repr(baseline), lambda: "Replay input: dynamic load baseline {!r}".format(baseline), "dynamic load baseline")
1142+
11291143
def log_replay_state(self):
11301144
"""Log the values the plan starts from this cycle at full precision.
11311145
@@ -1140,7 +1154,7 @@ def exact(value):
11401154
return None if value is None else repr(float(value))
11411155

11421156
self.log(
1143-
"Replay input: state soc_kw {} soc_max {} inday {} cost_today {} load_today {} import_today {} export_today {} pv_today {} charge_rate_now {} discharge_rate_now {} battery_temperature {}".format(
1157+
"Replay input: state soc_kw {} soc_max {} inday {} cost_today {} load_today {} import_today {} export_today {} pv_today {} charge_rate_now {} discharge_rate_now {} battery_temperature {} iboost_today {} load_forecast_only {}".format(
11441158
exact(self.soc_kw),
11451159
exact(self.soc_max),
11461160
exact(self.load_inday_adjustment),
@@ -1152,6 +1166,8 @@ def exact(value):
11521166
exact(self.charge_rate_now),
11531167
exact(self.discharge_rate_now),
11541168
exact(self.battery_temperature),
1169+
exact(self.iboost_today),
1170+
exact(self.load_forecast_only),
11551171
)
11561172
)
11571173
except Exception as e:

‎apps/predbat/plan.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1967,6 +1967,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
19671967
# Exactly as the plan uses it (None when divergence is off): the human-readable line rounds the percentage,
19681968
# which can round the other way. Not for a tariff comparison run (publish=False).
19691969
self.log("Replay input: load divergence {!r}".format(self.metric_load_divergence))
1970+
self.log_replay_baseline()
19701971

19711972
# Clamp the three load scalings so load_scaling90 <= load_scaling <= load_scaling10 always
19721973
# holds: the PV90 case can never end up with more load than the central case, and the PV10

‎apps/predbat/tests/test_log_replay_inputs.py‎

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
SAVED = (
99
"pv_light_dark",
10+
"iboost_today",
11+
"load_forecast_only",
12+
"dynamic_load_baseline",
1013
"pv_forecast_minute",
1114
"pv_forecast_minute10",
1215
"pv_forecast_minute90",
@@ -120,9 +123,11 @@ def test_load_forecast_logged(my_predbat):
120123
if lines[0].count("[") - 1 != steps:
121124
print("ERROR: the line should cover every step the plan builds ({}), got {}".format(steps, lines[0].count("[") - 1))
122125
return 1
126+
# No forecast is a state the replay must follow too, so it is logged (once - see test_empty_load_forecast_logged_on_change)
123127
my_predbat.load_forecast = {}
124-
if capture(my_predbat, my_predbat.log_replay_load_forecast):
125-
print("ERROR: no load forecast should log nothing")
128+
my_predbat.replay_load_empty = None
129+
if capture(my_predbat, my_predbat.log_replay_load_forecast) != ["Replay input: load forecast empty"]:
130+
print("ERROR: no load forecast should be logged as empty")
126131
return 1
127132
return 0
128133

@@ -238,8 +243,10 @@ def test_state_logged(my_predbat):
238243
my_predbat.charge_rate_now = 25 / 60000.0
239244
my_predbat.discharge_rate_now = 0.09625
240245
my_predbat.battery_temperature = 24
246+
my_predbat.iboost_today = 1.25
247+
my_predbat.load_forecast_only = True
241248
lines = capture(my_predbat, my_predbat.log_replay_state)
242-
expected = "Replay input: state soc_kw 4.104 soc_max 18.08 inday 0.8347826086956521 cost_today 54.2213 load_today 3.09 import_today 17.05 export_today 7.64 pv_today None charge_rate_now {!r} discharge_rate_now 0.09625 battery_temperature 24.0".format(25 / 60000.0)
249+
expected = "Replay input: state soc_kw 4.104 soc_max 18.08 inday 0.8347826086956521 cost_today 54.2213 load_today 3.09 import_today 17.05 export_today 7.64 pv_today None charge_rate_now {!r} discharge_rate_now 0.09625 battery_temperature 24.0 iboost_today 1.25 load_forecast_only 1.0".format(25 / 60000.0)
243250
if lines != [expected]:
244251
print("ERROR: state line wrong:\n got {}\n expected {}".format(lines, expected))
245252
return 1
@@ -342,6 +349,44 @@ def test_logging_never_raises(my_predbat):
342349
return 0
343350

344351

352+
def test_empty_load_forecast_logged_on_change(my_predbat):
353+
"""An empty load forecast is logged when the forecast becomes empty, not every cycle, and again after it returns."""
354+
my_predbat.replay_load_empty = None
355+
my_predbat.load_forecast = {}
356+
first = capture(my_predbat, my_predbat.log_replay_load_forecast)
357+
second = capture(my_predbat, my_predbat.log_replay_load_forecast)
358+
if first != ["Replay input: load forecast empty"] or second:
359+
print("ERROR: an empty forecast should be logged once, got {} then {}".format(first, second))
360+
return 1
361+
my_predbat.minutes_now = 9 * 60
362+
my_predbat.forecast_minutes = 10
363+
my_predbat.load_forecast = {minute: minute / 10000.0 for minute in range(0, 24 * 60)}
364+
capture(my_predbat, my_predbat.log_replay_load_forecast)
365+
my_predbat.load_forecast = {}
366+
if capture(my_predbat, my_predbat.log_replay_load_forecast) != ["Replay input: load forecast empty"]:
367+
print("ERROR: a forecast that empties again should be logged again")
368+
return 1
369+
return 0
370+
371+
372+
def test_baseline_logged_on_change(my_predbat):
373+
"""The dynamic load baseline is logged as a dict that reads back exactly, and only when it changes."""
374+
my_predbat.replay_baseline_text = None
375+
my_predbat.dynamic_load_baseline = {600: 0.0625, 605: 0.0625}
376+
lines = capture(my_predbat, my_predbat.log_replay_baseline)
377+
if len(lines) != 1 or ast.literal_eval(lines[0].split("baseline ", 1)[1]) != {600: 0.0625, 605: 0.0625}:
378+
print("ERROR: expected one baseline line that reads back, got {}".format(lines))
379+
return 1
380+
if capture(my_predbat, my_predbat.log_replay_baseline):
381+
print("ERROR: an unchanged baseline should not be logged again")
382+
return 1
383+
my_predbat.dynamic_load_baseline = {}
384+
if capture(my_predbat, my_predbat.log_replay_baseline) != ["Replay input: dynamic load baseline {}"]:
385+
print("ERROR: a baseline that clears should be logged")
386+
return 1
387+
return 0
388+
389+
345390
def test_unreadable_state_warns_once(my_predbat):
346391
"""A value whose repr does not read back warns once per change, not on every cycle."""
347392
my_predbat.num_cars = 1
@@ -371,7 +416,7 @@ def test_comparison_runs_not_logged(my_predbat):
371416
def run_log_replay_inputs_tests(my_predbat):
372417
"""Run the replay-input logging tests, restoring the shared fixture afterwards."""
373418
missing = object()
374-
saved = {key: getattr(my_predbat, key, missing) for key in SAVED + ("replay_pv_signature", "replay_ml_signature", "replay_rates_signature", "replay_cars_text", "replay_inverter_text")}
419+
saved = {key: getattr(my_predbat, key, missing) for key in SAVED + ("replay_pv_signature", "replay_ml_signature", "replay_rates_signature", "replay_cars_text", "replay_inverter_text", "replay_load_empty", "replay_baseline_text")}
375420
failed = 0
376421
try:
377422
failed += test_pv_forecast_logged_on_change(my_predbat)
@@ -385,6 +430,8 @@ def run_log_replay_inputs_tests(my_predbat):
385430
failed += test_inverter_logged_on_change(my_predbat)
386431
failed += test_logging_never_raises(my_predbat)
387432
failed += test_unreadable_state_warns_once(my_predbat)
433+
failed += test_empty_load_forecast_logged_on_change(my_predbat)
434+
failed += test_baseline_logged_on_change(my_predbat)
388435
failed += test_comparison_runs_not_logged(my_predbat)
389436
finally:
390437
for key, value in saved.items():

0 commit comments

Comments
 (0)