fix(load-ml): stop a training run that crosses midnight inflating the… - #4537
Merged
springfall2008 merged 1 commit intoAug 16, 2026
Merged
Conversation
… forecast The load baseline (load_minutes_now, cumulative load since local midnight) is captured during the data fetch, while minutes_now is read live from the base at publish time. Training runs for many minutes, so when a fine-tune starts before midnight and finishes after it the two disagree: minutes_now has reset to the new day but the baseline still holds yesterday's full daily total, which then gets added on top of every published forecast point. Seen in the wild as a single-sample spike in the ML load chart - a fetch at 23:50 followed by training that completed at 00:15 published load_today_h1 of ~25 kWh and load_today_h8 of ~30 kWh against an actual daily total of 25 kWh. The next cycle re-fetched and the values returned to normal. Re-fetch after training whenever the data has gone stale, which re-anchors both the baseline and the lookback window feeding the prediction to the current time. As a second line of defence, detect a baseline snapshot belonging to a previous day at publish time and re-derive it from the per-step load history instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes intermittent ML load forecast spikes when a training run spans local midnight by ensuring the load baseline and lookback window are re-anchored after long training, and by adding a publish-time backstop for stale baselines.
Changes:
- Factor data fetching into
_do_fetch()and re-fetch after training when fetched data is stale (>=PREDICT_STEPminutes). - Track
load_minutes_now_timeand add_load_baseline_now()to re-derive the baseline from per-step history when the snapshot is stale. - Add a new unit sub-test covering midnight-crossing stale baseline behavior and post-training re-fetch.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| apps/predbat/load_ml_component.py | Adds post-training re-fetch + baseline timestamping and a stale-baseline backstop during publish. |
| apps/predbat/tests/test_load_ml.py | Adds a new sub-test validating midnight-crossing baseline handling and re-fetch after training. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
… forecast
The load baseline (load_minutes_now, cumulative load since local midnight) is captured during the data fetch, while minutes_now is read live from the base at publish time. Training runs for many minutes, so when a fine-tune starts before midnight and finishes after it the two disagree: minutes_now has reset to the new day but the baseline still holds yesterday's full daily total, which then gets added on top of every published forecast point.
Seen in the wild as a single-sample spike in the ML load chart - a fetch at 23:50 followed by training that completed at 00:15 published load_today_h1 of ~25 kWh and load_today_h8 of ~30 kWh against an actual daily total of 25 kWh. The next cycle re-fetched and the values returned to normal.
Re-fetch after training whenever the data has gone stale, which re-anchors both the baseline and the lookback window feeding the prediction to the current time. As a second line of defence, detect a baseline snapshot belonging to a previous day at publish time and re-derive it from the per-step load history instead.
PR note: ML load forecast spikes when a training run crosses midnight
Working note for a later PR. Branch:
fix/load-ml-midnight-stale-baseline, commite58fd256(rebased onto main at v8.48.3). Not intended to be committed — delete before opening the PR.
Symptom
Occasional single-sample spikes in the LoadML chart, where the
Forecast (+1h)andForecast (+8h)series jump to a value above the household's entire daily consumptionand then return to normal on the next cycle.
Reported by a user on a ~25 kWh/day install:
Forecast (+1h)spiked to ~26 kWh justafter midnight and
Forecast (+8h)to ~30 kWh, against an actual daily total of 25 kWh.The
Load (Actual)series was unaffected apart from the same single sample.Because the chart series carry a
+1h/+8hplotting offset(
web.py,prune_today(..., offset_minutes=...)), the spikes appear on the chart one andeight hours after the cycle that produced them, which makes them look unrelated to
midnight at first glance.
Root cause
load_ml_component.pymixes two different notions of "now" when publishing:self.load_minutes_now— cumulative load since local midnight, a snapshot taken duringthe data fetch (
_fetch_load_data→_merge_fetch_data).self.minutes_now— read live from the base object via theComponentBaseproperty._publish_entity()uses both together:Normally the fetch and the publish are seconds apart and the two agree. But training runs
between them, and a fine-tune on a large history takes tens of minutes. When a training run
starts before local midnight and finishes after it:
self.minutes_nowhas reset to the new day, so the midnight-reset branch does not fireuntil minute
1440 - minutes_now, which is now late in the forecast.self.load_minutes_nowstill holds the previous day's full total.Every forecast point before that reset therefore gets a whole day of load added to it.
load_today_h1(minute 60) andload_today_h8(minute 480) are both in that range, so bothpublished stats are inflated by roughly one day of consumption.
Secondary effect, present regardless of midnight: the prediction itself is generated from the
pre-training lookback window, so after a 25-minute training run the model is fed data that is
25 minutes out of date.
Evidence
From the reporter's log (local time, Europe/Copenhagen):
The fetch at 23:50 captured
load_minutes_now≈ 25 kWh (minutes_nowwas 1430). The publishat 00:15 used
minutes_now= 15. Predicted values for that cycle were normal — the totals inGenerated 576 predictionsare in the usual 43–48 kWh range throughout the log — so the erroris entirely in the published baseline, not in the model.
Scope
sensor.<prefix>_load_ml_statsattributes and theresultsattribute ofsensor.<prefix>_load_ml_forecast, i.e. the LoadML/LoadMLPower charts.load_ml_sourceenabled, the bad cycle also feeds the planner viafetch_ml_load_forecast(), which reads theresultsattribute back. The reporter hadload_ml_sourceoff, so for them it was display-only.load_predictor.pyis byte-identical across that range;
load_ml_component.pychanged once, at v8.48.0, wherefeat(plan): add a pv90 upside forecast scenario to balance the one-sided pv10 hedge #4462 (PV90) made
fetch_pv_forecast()return three values — a single line at the call site,nowhere near the baseline handling.
Fix
Two layers:
Root cause. The fetch block in
run()is factored out into_do_fetch(), and after_do_training()the data is re-fetched when it has gone stale (>=PREDICT_STEPminutes).This re-anchors both the baseline and the lookback window feeding the prediction to the
current time, which also fixes the "predicting from 25-minute-old data" problem.
Defence in depth.
load_minutes_now_timeis stored alongside the baseline, and_load_baseline_now()detects a snapshot belonging to a previous local day and re-derivesthe value from the per-step
load_datahistory instead, logging a warning.New log lines to look for:
Tests
New sub-test
component_stale_midnight_baselineintests/test_load_ml.py, covering:run()cycle where training moves the clock from 23:50 to 00:15 triggers a secondfetch and publishes the corrected values.
Without the fix the test fails with
Expected load_today 0.15 re-derived since midnight, got 24.9.Verification performed:
--test load_ml: 29/29 pass (28/29 without the fix).pre-commit(ruff, black, cspell) on both changed files: clean.Field verification
The fix has run on a live install since 2026-08-08. The bug scenario occurred once in that
window, on the night of 12-13 August, and was handled correctly:
Training started 19 minutes before local midnight and finished 6 minutes after it. The
published forecast is continuous across the boundary — 41.52 kWh at 23:28, 43.25 kWh at the
crossing, 43.26 kWh at 00:24 — where before the fix the baseline would have carried the
previous day's ~25 kWh total into every point.
Over 11-15 August: 48 training runs, 1 midnight crossing, 244 prediction cycles all within
37.6-58.86 kWh. No inflation anywhere.
The layer-2 backstop (
_load_baseline_nowre-deriving the baseline) has never fired on thelive install — the re-fetch in layer 1 catches the condition first, which is the intended
order. It is covered by unit tests only.
Unrelated issues noticed while verifying
Worth separate PRs, not addressed here:
test_fox_api.py::test_run_midnight_resetis flaky. The final assertionfox.start_time_today > initial_start_timeraces against clock resolution and fails onroughly 4 out of 5 runs on Windows. It aborts
run_all --quickbefore the rest of the suite.ge_cloudandannual_load_octopusfail on Windows withRuntimeError: aiodns needs a SelectorEventLoop on Windows. Environment limitation,reproduces on a clean tree.
Open question, not part of this fix
The reporter's
Forecast (+8h)series sits systematically below actual load through the day(~20 kWh predicted vs ~25 kWh actual at 18:00), which is why they run with
load_ml_sourcedisabled. This is not the midnight bug — the model's own diagnostics are strong
(
ar_mae≈ 0.003–0.006 kWh per 5-min chunk, bias near zero, teacher-forced drift ~0.0002 kWh).A plausible candidate is the historical-pattern blending in
load_predictor.py:1553-1560(
blend_floor = 0.5), which pulls the forecast toward the day-of-week mean as the horizongrows. Needs its own investigation before anything is claimed.