Repository navigation
Run ML training off the event loop and let a shutdown abandon it - #5112
Open
springfall2008 wants to merge 7 commits into
Open
springfall2008 wants to merge 7 commits into
springfall2008 wants to merge 7 commits into
Conversation
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
springfall2008
commented
Sep 16, 2026
This was referenced Sep 21, 2026
…s-event-loop-5075
Addresses the review on #5112. Now that training runs off the loop, the run_timeout watchdog can actually fire, and its task.cancel() reaches only the coroutine - not the thread already inside train_curriculum. - _do_training raises the component's own cancel signal on CancelledError and the worker thread owns an in-flight flag, so a cancelled run is abandoned rather than left racing a second curriculum on the same predictor's weights, Adam state and model file. - training_stop_requested also answers to fatal_error, which exits ComponentBase.start() without ever calling stop(). - The abandon-vs-failure split is decided by whether the stop hook actually answered True during the run, not by re-reading api_stop afterwards, so a genuine all-passes-failed run is still warned about and counted. - run() publishes the model status on the stop path (training may have completed before the stop landed) and no longer waits out a prediction cycle it is about to abandon. - train() restores the normalisation statistics alongside the weights on an abort, and skips the minutes-long AR rollout diagnostic when a stop lands after the last epoch check. - train_curriculum rolls back the published training stamps when a run is abandoned mid-curriculum, with a new model_trained flag so a never-trained model cannot read as active while a legacy timestamp-less model still can. - A stop hook that raises is reported once at Error level instead of being warned about once per epoch. Five new tests in test_load_ml.py, all verified red without these changes, plus a stronger handshake in training_off_event_loop that cannot pass on a single incidental loop yield. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oroutine run()'s finally clears load_ml_calculating while a watchdog-cancelled run's worker thread is still inside train_curriculum, so the flag alone said no NumPy work was in flight during exactly that work. is_calculating() now also answers to training_running, which the worker thread owns for the span of the synchronous call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
springfall2008
marked this pull request as ready for review
September 24, 2026 12:35
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
One or more issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (3)
When the watchdog cancels the await, the worker remains active and this guard merely returns.… · Newabandon_curriculum()rolls back only the metadata tuple, but every completed intermediate… · New The abort rollback restores normalisation but notmodel_initializedor the Adam optimiser state.… · New
What changed in this PR
This PR makes ML curriculum training non-blocking and stoppable during component shutdown.
Changes:
- Runs training in a worker thread.
- Adds cooperative stop handling and model-state tracking.
- Adds extensive cancellation and event-loop tests.
| File | Description |
|---|---|
| apps/predbat/tests/test_load_ml.py | Updated as part of this pull request. |
| apps/predbat/load_predictor.py | Updated as part of this pull request. |
| apps/predbat/load_ml_component.py | Updated as part of this pull request. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
CossieRob
pushed a commit
to CossieRob/batpred
that referenced
this pull request
Sep 26, 2026
…pringfall2008#5103/springfall2008#5186/springfall2008#5053-adjacent entries Folded (13 entries from the 15-candidate slice, all re-verified against origin/main 130596b): - Load ML row (GH#5075, PR springfall2008#5112 open draft): is_valid() can report active for a never-trained model; training_timestamp is None is not a sound never-trained test (legacy saved models); train() stamps at the end of every curriculum pass, so an abort leaves fresh stamps that suppress the ml_max_model_age_hours retrain. Normalisation-statistics skew stays suspected. - Octopus row: GH#5144 overlapping DIRECT/NON_DIRECT_DEBIT rows, minute_data last-row-wins and the period-to-period flip - fixed in PR springfall2008#5145 (filter_payment_method, keeps nulls). - Car charging row: GH#5146/GH#5151 the two different Hold-for-car gates (execute.py live block vs prediction.py plan gate), deliberate export-window exemption test-codified (discharge_car_full_bat2), read-only users never see the status sensor's annotation. - Sunsynk/DEYE row: PR springfall2008#5150 control_active persistence + upgrade-cache inference; the in-code press every cycle comment is wrong (both press sites change-gated, post-restart re-commit is is_hm_format-only); GH#5156 TOU padding truncation fixed in tou_schedule.py _padding_segments. - Solis row: GH#5152 the hold lever is the window-scoped timed current (standing battery_discharge_current never driven); PR springfall2008#5189 adds the rated-current cap (GH#5187). - Predheat row: GH#5153 no Fahrenheit conversion in get_weather_data + the optional heating_energy age bug pinning minute_data_age to 0. - Teslemetry row: GH#5157 forced re-assert implemented (FORCED_ASSERT_SECONDS = 2h, dedupe bypassed, tariff excluded); tbc_control default on since PR springfall2008#5188. - Keep section: GH#5162 manual_soc floor penalty is import-rate-weighted, so inert inside 0p windows; the manual_soc_max ceiling is export-rate-weighted; manual_charge lookalike trap. - New Kraken row: GH#5166/PR springfall2008#5167 EDF 400-on-day/night vs Octopus 200-empty; KRAKEN_REST_RATES_UNAVAILABLE_STATUSES; the (None, None) tuple contract and the failures_total double-count, both fixed in the merged PR and kept as mechanisms. - New Manual rates row: GH#5168 day_of_week stamped across the whole rate horizon, fixed in PR springfall2008#5173; end ':59:59' final-minute trap and the metric_future_rate_offset test pin kept. Dropped: 5169-5053-cache-now-on-main (already covered by the redaction entry and the GH#5063 premise bullet; its lock-lazy-init nugget is recorded as a fact of the merged PR springfall2008#5171 mixin). Corrected by the merge scan: GH#5103 fixed in PR springfall2008#5109 (hourly settings refresh + the new check_ems_inverter_slots warning); teslemetry_tbc_control default on (PR springfall2008#5188); the log-redaction cache moved into the LogRedaction mixin (log_secrets.py, PR springfall2008#5171). Co-Authored-By: Claude Code <noreply@anthropic.com>
…olling back Addresses the three open Copilot review threads on #5112, which all came from training mutating the live LoadPredictor in place: an abandoned curriculum kept the completed passes' weights, Adam state and normalisation behind its rolled-back stamps, an aborted initial run left random weights marked model_initialized, and an aborted train() kept the best epoch of the abandoned run rather than the starting weights. An exception part way through had the same effect. _do_training now trains LoadPredictor.clone() - an independent deep copy of everything in TRAINING_STATE_FIELDS - and assigns it to self.predictor only when the run succeeds. An abandoned, failed or raising run simply drops the clone, so the live model is never part-trained and needs nothing rolled back. That removes the piecemeal rollbacks: train()'s normalisation restore and train_curriculum()'s stamp rollback. Called directly, an aborted train() or curriculum now documents that it leaves the model part-trained. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…run_timeout Moving training off the event loop woke up ComponentBase.start()'s run_timeout watchdog, which could never fire while training pinned the loop. load_ml's is 2 hours, so a first curriculum longer than that on slow hardware would be cancelled - and its clone thrown away - every time, and that model would never exist. run() now launches training with _start_training as a task on the component's own loop and returns, so the watchdog never covers it. Because the run trains a clone, later ticks keep fetching and predicting from the live model; they only hold off a second run. The tick after a run finishes re-fetches and publishes, replacing the post-training stale re-fetch, and the clone is taken on the worker thread inside the error handling. _watch_training judges the run on progress (time.monotonic(), so a host suspend is not read as a stall): no epoch for 2 hours logs an Error and asks the run to abandon itself, and a run still progressing after 2 hours is logged once. A failed, abandoned or raising run is retried after 30 minutes rather than relaunched every minute. sensor.predbat_load_ml_stats gains training_status (idle/training/stalled), training_started, training_last_progress and training_retry_after, also published during a first run, and docs/components.md describes them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-event-loop-5075 # Conflicts: # apps/predbat/load_ml_component.py
This branch has not been deployed
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.


Description updated by Claude on behalf of @chalfontchubby to match the PR after commits 0d1ab6b and 51f121e. Originally an automated draft generated from issue #5075; a maintainer should review it before merging.
Fixes #5075
Summary
LoadMLComponent._do_training()calledpredictor.train_curriculum()synchronously inside anasync def. A curriculum run is many passes of many epochs and takes minutes, sometimes hours. While it ran, the component's thread could not exit, so a shutdown logged "Predbat terminated" and then sat instop_all()'s 5-minute join until training finished. This PR makes training:asyncio.to_thread.train()andtrain_curriculum()take astop_callback(defaultedNone), checked before every epoch and curriculum pass.training_stop_requested()answers it fromapi_stop,fatal_errorand the component's owntraining_cancelled. An abandoned run is not published, saved or stamped._do_trainingtrainsLoadPredictor.clone()and assigns it toself.predictoronly when the run succeeds. If the run is abandoned, fails or raises, the clone is dropped and the live model is exactly as it was. Nothing needs rolling back, and nothing reading the live model can see a half-trained one.run()launches training as a task on the component's own loop and returns. Later ticks keep fetching and predicting from the live model and only hold off a second run. The tick after a run finishes re-fetches and publishes the new model.Why each part is needed
to_threadalone does not fix the symptom. Each component runs in its own non-daemon thread with its ownasyncio.run()(hass.pycreate_task), and that thread only exits oncerun()returns. An uninterruptible curriculum holds the join whether it blocks the loop or a worker the loop is awaiting. The stop check is what lets it exit.ComponentBase.start()'srun_timeoutwatchdog (wait_forthentask.cancel()) can fire for the first time, and it is 2 hours for load_ml. A first curriculum longer than that on slow hardware would be cancelled and thrown away every time, so that model would never exist. Launching training outsiderun()takes it out of the watchdog's scope._watch_trainingjudges the run ontime.monotonic(), so a host suspend is not read as a stall.model_initialized, and an orphaned thread racing later ticks. All three come from training the live model in place.Visible state
sensor.predbat_load_ml_statsgains four attributes, also published during a first run when there is no model yet.docs/components.mddescribes them.training_status:idle,trainingorstalledtraining_startedtraining_last_progresstraining_retry_afterA stall deliberately does not mark the component unhealthy. That path makes
/api/pingreturn 500, which can restart Docker installs into the same long first run.Testing
load_ml: 46/46. Quick suite andcoverage/run_pre_commitgreen.tests/test_load_ml.py:training_stop_callbacktraining_off_event_looptraining_abandoned_on_stopclone_is_independenttraining_swaps_clone_on_successtraining_task_canceltraining_in_backgroundinitial_training_publishedtraining_stall_and_slowtraining_retry_backofftraining_progress_reportedcomponent_run_data_mergeandcomponent_stale_midnight_baselinenow await the background task, and check that the following tick re-fetches across midnight.Notes
_do_training's two near-identicaltrain_curriculum(...)call sites became one; only the step size and pass cap differ, and those are chosen in the snapshot block. The behaviour is unchanged.prediction_startedwait inrun()now also exits onapi_stop.🤖 Generated with Claude Code