Skip to content

Commit 4e169b1

Browse files
WhatIsThisAllAboutSillyAnonymous
andauthored
feat: configure ML retraining interval in apps.yaml (#5433)
Keep the two-hour default and support daily retraining with a 1-48 hour range. Preserve startup training and prediction cadence. Refs #5072. Co-authored-by: Anonymous <anonymous@example.invalid>
1 parent b88cbd8 commit 4e169b1

7 files changed

Lines changed: 89 additions & 11 deletions

File tree

‎apps/predbat/components.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,7 @@ def load_component_class(component_info):
711711
"load_ml_source": {"required": False, "config": "load_ml_source", "default": False},
712712
"load_ml_max_days_history": {"required": False, "config": "load_ml_max_days_history", "default": 28},
713713
"load_ml_database_days": {"required": False, "config": "load_ml_database_days", "default": 90},
714+
"load_ml_retrain_interval_hours": {"required": False, "config": "load_ml_retrain_interval_hours", "default": 2},
714715
},
715716
"phase": 2, # Load ML in phase 2 so that any Predbat cloud components (such as GEcloud) have been started and initialised pv_today, etc
716717
"can_restart": True,

‎apps/predbat/config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2897,6 +2897,7 @@
28972897
"enable_coarse_fine_levels": {"type": "boolean"},
28982898
"load_power_fill_enable": {"type": "boolean"},
28992899
"load_ml_enable": {"type": "boolean"},
2900+
"load_ml_retrain_interval_hours": {"type": "integer", "min": 1, "max": 48},
29002901
"gateway_device_id": {"type": "string", "empty": False},
29012902
"gateway_mqtt_host": {"type": "string", "empty": False},
29022903
"gateway_mqtt_port": {"type": "integer", "zero": False},

‎apps/predbat/load_ml_component.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
1616
ComponentBase wrapper that manages the LoadPredictor lifecycle including
1717
data fetching, periodic training/fine-tuning, prediction generation, and
18-
status sensor publishing. Retrains every 2 hours and updates predictions
18+
status sensor publishing. Retrains every 2 hours by default and updates predictions
1919
every 30 minutes.
2020
"""
2121

@@ -31,7 +31,6 @@
3131
import numpy as np
3232

3333
# Training intervals
34-
RETRAIN_INTERVAL_SECONDS = 2 * 60 * 60 # 2 hours between training cycles
3534
PREDICTION_INTERVAL_SECONDS = 30 * 60 # 30 minutes between predictions
3635

3736
# Database schema version - increment when the saved format changes to force a clean rebuild
@@ -51,7 +50,7 @@ class LoadMLComponent(ComponentBase):
5150
- Falls back to empty predictions when validation fails or model is stale
5251
"""
5352

54-
def initialize(self, load_ml_enable, load_ml_source=True, load_ml_max_days_history=28, load_ml_database_days=90):
53+
def initialize(self, load_ml_enable, load_ml_source=True, load_ml_max_days_history=28, load_ml_database_days=90, load_ml_retrain_interval_hours=2):
5554
"""
5655
Initialise the ML load forecaster component.
5756
@@ -60,6 +59,7 @@ def initialize(self, load_ml_enable, load_ml_source=True, load_ml_max_days_histo
6059
load_ml_source: Whether using ML as the data source is enabled or not
6160
load_ml_max_days_history: Maximum number of days of load history to fetch
6261
load_ml_database_days: Number of days of historical data to store in the database, maximum length for training
62+
load_ml_retrain_interval_hours: Hours between training cycles, clamped to 1-48
6363
"""
6464

6565
self.ml_enable = load_ml_enable
@@ -86,6 +86,7 @@ def initialize(self, load_ml_enable, load_ml_source=True, load_ml_max_days_histo
8686
self.ml_time_decay_days = 30
8787
self.ml_max_load_kw = 50.0
8888
self.ml_max_model_age_hours = 48
89+
self.ml_retrain_interval_seconds = max(1, min(self.ml_max_model_age_hours, load_ml_retrain_interval_hours)) * 60 * 60
8990
self.ml_weight_decay = 0.01
9091
self.ml_dropout_rate = 0.1
9192
self.ml_max_days_history = load_ml_max_days_history
@@ -721,8 +722,8 @@ async def run(self, seconds, first):
721722
is_initial = not self.initial_training_done
722723

723724
# Retrain if the model is older than the retrain interval (rather than on a fixed tick)
724-
retrain_age_seconds = (self.now_utc - self.last_train_time).total_seconds() if self.last_train_time else RETRAIN_INTERVAL_SECONDS
725-
should_train = not first and (retrain_age_seconds >= RETRAIN_INTERVAL_SECONDS)
725+
retrain_age_seconds = (self.now_utc - self.last_train_time).total_seconds() if self.last_train_time else self.ml_retrain_interval_seconds
726+
should_train = not first and (retrain_age_seconds >= self.ml_retrain_interval_seconds)
726727

727728
# Fetch fresh load data periodically (every N minutes)
728729
should_fetch = first or should_train or ((seconds % PREDICTION_INTERVAL_SECONDS) == 0)
@@ -756,7 +757,7 @@ async def run(self, seconds, first):
756757
self.log("ML Component: Initial training is required, delaying until component has started")
757758
return True
758759
elif should_train:
759-
self.log("ML Component: Starting fine-tune training (2h interval), model age is {} hours".format(retrain_age_seconds / 3600.0))
760+
self.log("ML Component: Starting fine-tune training ({:g}h interval), model age is {} hours".format(self.ml_retrain_interval_seconds / 3600.0, retrain_age_seconds / 3600.0))
760761
elif should_fetch:
761762
# If not training either then no need to print anything
762763
self.log("ML Component: No training needed, model age is {} hours".format(dp2(retrain_age_seconds / 3600.0)))

‎apps/predbat/tests/test_load_ml.py‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def test_load_ml(my_predbat=None):
6464
("component_stale_midnight_baseline", _test_component_stale_midnight_baseline, "LoadMLComponent baseline handling when publishing crosses midnight"),
6565
("car_subtraction_direct", _test_car_subtraction_direct, "Direct car_subtraction method with interpolation and smoothing"),
6666
("component_run_data_merge", _test_component_run_data_merge, "LoadMLComponent run() data fetch, save and merge across two runs"),
67+
("component_retrain_interval", _test_component_retrain_interval, "YAML retraining interval, boundaries, startup and prediction cadence"),
6768
("component_init_predictor_last_train_time", _test_component_init_predictor_sets_last_train_time, "LoadMLComponent _init_predictor sets last_train_time from embedded training_timestamp"),
6869
("nan_inf_robustness", _test_nan_inf_robustness, "LoadPredictor handles NaN, Inf, and None across input channels without NaN loss"),
6970
("database_zero_preservation", _test_database_zero_preservation, "Database save/load preserves 0.0 values across roundtrip"),
@@ -981,6 +982,61 @@ def _test_prediction_with_temp():
981982
assert max_minute >= 2800, f"Predictions should span ~48h (2880 min), got {max_minute} min"
982983

983984

985+
def _test_component_retrain_interval():
986+
"""Exercise YAML-to-component wiring and age-based scheduling without expensive training."""
987+
import asyncio
988+
from unittest.mock import AsyncMock, Mock
989+
from components import Components
990+
from mock_base import MockBase
991+
992+
async def run_test():
993+
"""Check defaults, configured bounds, clamping and the independent prediction timer."""
994+
key = "load_ml_retrain_interval_hours"
995+
for args, expected_hours in (({}, 2), ({key: 24}, 24), ({key: 1}, 1), ({key: 48}, 48), ({key: 0}, 1), ({key: -1}, 1), ({key: 49}, 48)):
996+
base = MockBase(config_root=None, load_ml_enable=True, load_ml_database_days=0, load_today=["sensor.load_today"], **args)
997+
base.log = Mock()
998+
base.prediction_started = False
999+
registry = Components(base)
1000+
registry.initialize(only="load_ml", phase=2)
1001+
component = registry.get_component("load_ml")
1002+
assert component is not None, registry.component_errors
1003+
assert component.ml_retrain_interval_seconds == expected_hours * 3600
1004+
component.data_ready = True
1005+
component.load_data_age_days = 7
1006+
component.database_history_loaded = True
1007+
component._do_fetch = AsyncMock()
1008+
component._do_training = AsyncMock()
1009+
component._get_predictions = Mock(return_value={})
1010+
component._update_model_status = Mock()
1011+
component._publish_entity = Mock()
1012+
1013+
# Use a non-prediction tick to prove retraining follows model age, not uptime.
1014+
for age_seconds, seconds, first, expected_train, expected_predict in (
1015+
(expected_hours * 3600 - 1, 30, False, False, False),
1016+
(expected_hours * 3600, 30, False, True, True),
1017+
(expected_hours * 3600 + 1, 30, False, True, True),
1018+
(1800, 1800, False, False, True),
1019+
(None, 0, True, False, False),
1020+
(None, 30, False, True, True),
1021+
):
1022+
component.initial_training_done = age_seconds is not None
1023+
component.last_train_time = base.now_utc - timedelta(seconds=age_seconds) if age_seconds is not None else None
1024+
component._do_fetch.reset_mock()
1025+
component._do_training.reset_mock()
1026+
component._get_predictions.reset_mock()
1027+
base.log.reset_mock()
1028+
assert await component.run(seconds=seconds, first=first)
1029+
assert component._do_training.await_count == int(expected_train), (args, age_seconds, first)
1030+
assert component._get_predictions.call_count == int(expected_predict), (args, age_seconds, first)
1031+
assert component._do_fetch.await_count == int(first or expected_predict), (args, age_seconds, first)
1032+
if expected_train:
1033+
component._do_training.assert_awaited_once_with(age_seconds is None)
1034+
if age_seconds is not None:
1035+
assert any("({}h interval)".format(expected_hours) in call.args[0] for call in base.log.call_args_list)
1036+
1037+
asyncio.run(run_test())
1038+
1039+
9841040
def _test_component_run_data_merge():
9851041
"""Test LoadMLComponent.run() - mocks _fetch_load_data and verifies:
9861042
1. Run 1 (first=True): data is fetched and stored, but training is deferred (no save).
@@ -1104,7 +1160,7 @@ async def mock_save_database_history():
11041160
mock_base.now_utc = mock_base.now_utc + timedelta(minutes=ELAPSED_MINUTES)
11051161

11061162
# ── Run 2 (first=False, seconds=30) ─────────────────────────────────────
1107-
# last_train_time is None -> retrain_age_seconds = RETRAIN_INTERVAL_SECONDS -> should_train=True
1163+
# last_train_time is None -> retrain_age_seconds = configured interval -> should_train=True
11081164
# Expect: shift old keys, merge fresh data, run initial training, save once.
11091165
component._fetch_load_data = AsyncMock(return_value=(fetch_data_2, 7, 3.0, None, None, None, None))
11101166

@@ -1133,7 +1189,7 @@ async def mock_save_database_history():
11331189

11341190
# ── Run 3 (first=False, seconds=PREDICTION_INTERVAL_SECONDS) ─────────────
11351191
# last_train_time = 30 min ago (set in mock_do_training to component.now_utc of Run 2).
1136-
# retrain_age_seconds = 30*60 = 1800 < RETRAIN_INTERVAL_SECONDS (7200) -> should_train=False.
1192+
# retrain_age_seconds = 30*60 = 1800 < default interval (7200) -> should_train=False.
11371193
# seconds % PREDICTION_INTERVAL_SECONDS == 0 -> should_fetch=True.
11381194
# Expect: fetch+predict+save only, no training.
11391195
component._fetch_load_data = AsyncMock(return_value=(fetch_data_3, 7, 3.0, None, None, None, None))

‎apps/predbat/tests/test_validate_config.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ def test_validate_config(my_predbat):
150150
_run(my_predbat, {"log_count": 2}, expect_clean=["log_count"])
151151
_run(my_predbat, {"log_count": 100}, expect_clean=["log_count"])
152152

153+
print(" [ML retrain interval] whole hours within 1-48 pass; invalid values fail")
154+
for hours in (1, 2, 24, 48):
155+
_run(my_predbat, {"load_ml_retrain_interval_hours": hours}, expect_clean=["load_ml_retrain_interval_hours"])
156+
for hours in (-1, 0, 49, 2.5, "daily"):
157+
_run(my_predbat, {"load_ml_retrain_interval_hours": hours}, expect_errors=["load_ml_retrain_interval_hours"])
158+
153159
# ==========================================================================
154160
# INTEGER_LIST type (days_previous: {"type": "integer_list"})
155161
# ==========================================================================

‎docs/components.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1797,7 +1797,7 @@ This provides more accurate load predictions than simple averaging, especially f
17971797
- Optionally incorporates PV generation and temperature forecast data
17981798
- Trains a multi-layer neural network on your historical patterns
17991799
- Makes autoregressive predictions for 48 hours ahead in 5-minute intervals
1800-
- Fine-tunes periodically (every 2 hours) to adapt to changing patterns
1800+
- Fine-tunes periodically (every 2 hours by default) to adapt to changing patterns
18011801
- Validates predictions and falls back gracefully if accuracy is poor
18021802
- Publishes predictions to `sensor.predbat_load_ml_forecast`
18031803

@@ -1813,6 +1813,7 @@ For a detailed explanation of how the neural network works and comprehensive con
18131813
| `load_ml_source` | Boolean | No | false | `load_ml_source` | Set to `true` to use ML predictions in Predbat battery planning |
18141814
| `load_ml_max_days_history` | Integer | No | 28 | `load_ml_max_days_history` | Maximum days of load history to fetch from HA on each poll (bounded by HA recorder retention) |
18151815
| `load_ml_database_days` | Integer | No | 90 | `load_ml_database_days` | Days of history to accumulate in the on-disk database (`predbat_ml_history.npz`); set to 0 to disable the database |
1816+
| `load_ml_retrain_interval_hours` | Integer | No | 2 | `load_ml_retrain_interval_hours` | Hours between training cycles (1–48); use 24 for daily retraining. Predictions still update every 30 minutes |
18161817

18171818
Note: **load_today**, **pv_today** and **car_charging_energy** `apps.yaml` configuration items are also used, but these should already be set in Predbat.
18181819

@@ -1831,6 +1832,9 @@ predbat:
18311832
# Optional: days of history to accumulate on disk for training (default 90)
18321833
# load_ml_database_days: 90
18331834
1835+
# Optional: hours between training cycles (default 2, range 1-48)
1836+
# load_ml_retrain_interval_hours: 24
1837+
18341838
# Optional but recommended: enable temperature forecasts
18351839
temperature_enable: true
18361840
```

‎docs/load-ml.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ The ML Load Prediction component uses a lightweight multi-layer perceptron (MLP)
3030
- Deep neural network with 3 hidden layers [512, 256, 64 neurons]
3131
- Optimised with He initialisation and AdamW weight decay for robust training
3232
- Automatically trains on historical data (requires at least 1 day, recommended 7+ days; fetches up to `load_ml_max_days_history` days from HA and accumulates up to `load_ml_database_days` days in the on-disk database)
33-
- Fine-tunes periodically (every 2 hours) using full dataset to adapt to changing patterns
33+
- Fine-tunes periodically (every 2 hours by default) using full dataset to adapt to changing patterns
3434
- Time-weighted training prioritizes recent data while learning from historical patterns
3535
- Model persists across restarts
3636
- Falls back gracefully if predictions are unreliable
@@ -207,12 +207,21 @@ predbat:
207207

208208
# Optional: Number of days of history to accumulate in the on-disk database (default: 90)
209209
# load_ml_database_days: 90
210+
211+
# Optional: Retrain once a day instead of every 2 hours
212+
# load_ml_retrain_interval_hours: 24
210213
```
211214

212215
**Configuration Parameter Details:**
213216

214217
- `load_ml_enable`: Enables the ML component (required), defaults to false.
215218
- `load_ml_source`: When `true`, Predbat uses ML predictions for battery planning. Set to `false` (the default) to test predictions without affecting battery control
219+
- `load_ml_retrain_interval_hours`: Hours between training cycles, measured from the model's last training time
220+
- **Default**: 2 hours
221+
- **Range**: Whole hours from 1 to 48; out-of-range values are clamped to these limits because models become stale after 48 hours
222+
- **Example**: Set to `24` for daily retraining to reduce how often training uses CPU
223+
- **Unchanged behaviour**: Initial training still runs after startup once enough data is available; predictions still update every 30 minutes
224+
- **Apply changes**: Restart Predbat after editing `apps.yaml`; no additional Home Assistant entity is created
216225
- `load_ml_max_days_history`: Optional maximum days of historical data to fetch from Home Assistant on each poll (every 30 minutes)
217226
- **Default**: 28 days
218227
- **Minimum**: 7 days
@@ -383,7 +392,7 @@ Good predictions require:
383392
4. **Energy Rate Data**: Automatically included - helps model learn consumption patterns based on time-of-use tariffs
384393
5. **PV Generation Data**: If you have solar panels, include `pv_today` sensor for better correlation
385394
6. **Clean Data**: Avoid gaps or incorrect readings in historical data
386-
7. **Recent Training**: Model retrains every 2 hours using full dataset with time-weighted sampling to adapt to changing patterns
395+
7. **Recent Training**: Model retrains every `load_ml_retrain_interval_hours` (default 2 hours) using full dataset with time-weighted sampling to adapt to changing patterns
387396

388397
### Understanding MAE (Mean Absolute Error)
389398

0 commit comments

Comments
 (0)