Add Reset learned model to Configure

Configure now opens a menu: Settings (the previous form) or Reset learned
model. After an explicit confirmation the entry is unloaded, the learned
parts of the stored model (zone, extended and ventilation coefficients,
models kept for removed zones, Model Error and Forecast Error statistics,
fingerprints) are removed under the storage lock, and the entry is set up
again. Configuration, heat-source baselines and the window event log are
kept, and model_changes records the reset. Unloading first means no update
still running can write the old model back. English and Romanian strings;
the gust hint now refers to Model Error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 04:29:03 +02:00
1 parent 28ff953ddd
commit 49565d610a
8 files changed
+174 -12

No files matched your search

+11 -2
View File
@@ -23,8 +23,8 @@ On Home Assistant 2026.3 or newer the integration shows its own icon (a radiator
On **Settings → Devices & services → Heating MPC**, the buttons **Add zone**, **Add
heat source** and **Add window** create items that can each be edited or deleted
later. **Configure** holds everything else: thermostat, relay, weather sources,
report-age limits, MPC settings and model options.
later. **Configure** offers **Settings** (thermostat, relay, weather sources,
report-age limits, MPC settings and model options) and **Reset learned model**.
**Zones** are rooms or areas. Each has a name, one or more temperature sensors, a
comfort weight (how much the zone matters to the MPC), an optional target offset
@@ -45,6 +45,15 @@ or the recovery time resets only the coefficients that depend on it. Comfort
weight, target offset and names never touch the model. Each recalibration is
listed in the Status attribute `model_changes`.
**Reset learned model** (in **Configure**, after ticking the confirmation) forgets
everything learned: every zone's coefficients, including those kept for removed
zones, with their confidence and sample counts, and the Model Error and Forecast
Error statistics. Zones, sensors, weights, heat sources, windows, weather sources
and settings stay as they are, and so do the heat sources' baselines and the window
event log. The integration restarts and learns from the beginning (first sample
after about 30 minutes, *Ready* after a day or more); `model_changes` records the
reset. It never touches the boiler or the thermostat.
**Heat sources** are equipment whose electricity ends up as heat in a zone, such as
a UPS feeding computers. Settings: an active-power sensor (W or kW; VA and % are
rejected), the zone, the share of the power that becomes heat there (1.0 by
+18 -1
View File
@@ -23,6 +23,7 @@ from .heat_sources import (
SOURCE_KEY, SOURCE_NAME, SOURCE_RESPONSE, SOURCE_SENSOR, SOURCE_ZONE, SUBENTRY_HEAT_SOURCE,
HeatSource, build_sources, validate_source,
)
from .coordinator import async_reset_learned_model
from .weather_sources import sources_for
from .windows import (
DEFAULT_AZIMUTH, OPENING_RANGE, SUBENTRY_WINDOW, WINDOW_AZIMUTH, WINDOW_ENABLED, WINDOW_INVERTED,
@@ -205,6 +206,9 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
SUBENTRY_WINDOW: WindowSubentryFlow}
CONFIRM_RESET = "confirm_reset"
class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
"""Edit sensors, ranked alternative weather providers and MPC parameters."""
@@ -215,13 +219,26 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
return [sensor for zone in entry_zones(self.config_entry) for sensor in zone.sensors]
async def async_step_init(self, user_input: dict[str, Any] | None = None):
return self.async_show_menu(step_id="init", menu_options=["settings", "reset_model"])
async def async_step_settings(self, user_input: dict[str, Any] | None = None):
config = {**self.config_entry.data, **self.config_entry.options}
if user_input is not None:
self._draft = user_input
return await self.async_step_weather()
return self.async_show_form(step_id="init", data_schema=_main_schema(
return self.async_show_form(step_id="settings", data_schema=_main_schema(
config, detailed=True))
async def async_step_reset_model(self, user_input: dict[str, Any] | None = None):
"""Forget the learned model after an explicit confirmation; settings stay."""
if user_input is not None:
if user_input.get(CONFIRM_RESET) is not True:
return self.async_abort(reason="model_not_reset")
await async_reset_learned_model(self.hass, self.config_entry)
return self.async_abort(reason="model_reset")
return self.async_show_form(step_id="reset_model", data_schema=vol.Schema(
{vol.Required(CONFIRM_RESET, default=False): selector.BooleanSelector()}))
async def async_step_weather(self, user_input: dict[str, Any] | None = None):
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
if user_input is not None:
@@ -65,6 +65,34 @@ def model_removed(hass: HomeAssistant, entry_id: str) -> bool:
return entry_id in hass.data.get(f"{DOMAIN}_removed", ())
# Stored keys holding what was learned. The rest is observation, not model: the
# heat sources' baseline samples, the window history and the heater's identity.
LEARNED_KEYS = ("learners", "retired_learners", "zone_models", "ventilation_models", "evaluation",
"operational_evaluation", "fingerprint_version", "fingerprints", "on_samples", "off_samples")
async def async_reset_learned_model(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Forget everything learned for this entry; the configuration is not touched.
The entry is unloaded first, so no running update can save the old model
afterwards, then set up again from the stripped data. Accuracy statistics go
too: they describe the model being discarded.
"""
await hass.config_entries.async_unload(entry.entry_id)
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
async with storage_lock(hass, entry.entry_id):
stored = await store.async_load()
if isinstance(stored, dict):
kept = {key: value for key, value in stored.items() if key not in LEARNED_KEYS}
changes = kept.get("model_changes") if isinstance(kept.get("model_changes"), list) else []
kept["model_changes"] = [*changes, {
"time": datetime.now(timezone.utc).isoformat(timespec="seconds"), "zone": "all",
"recalibrated": ["all"], "reasons": ["learned model reset by the user"]}][-MAX_MODEL_CHANGES:]
await store.async_save(kept)
_LOGGER.info("Heating MPC: learned model reset by the user")
await hass.config_entries.async_setup(entry.entry_id)
def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
"""Zones from the entry's zone subentries; one zone per legacy room sensor otherwise."""
subentries = getattr(entry, "subentries", None) or {}
+19 -1
View File
@@ -56,6 +56,13 @@
"options": {
"step": {
"init": {
"title": "Heating MPC",
"menu_options": {
"settings": "Settings",
"reset_model": "Reset learned model"
}
},
"settings": {
"title": "Heating MPC options",
"description": "Change room sensors and controller settings. This integration remains shadow-only.",
"data": {
@@ -83,10 +90,17 @@
"window_open_minutes": "Forecasts assume an open window closes after this many more minutes, then the room recovers.",
"recovery_minutes": "Time constant of the air warming back towards the walls' temperature.",
"ventilation_wind": "Needs wind sources; direction also needs wind direction sources.",
"ventilation_gust": "Adds a term for gusts above the mean wind. Keep it only if Forecast Error improves.",
"ventilation_gust": "Adds a term for gusts above the mean wind. Keep it only if Model Error improves.",
"ventilation_cross": "Adds a term for windows open in several zones at once. Needs windows in two or more zones."
}
},
"reset_model": {
"title": "Reset learned model",
"description": "Forgets everything the model has learned: every zone's thermal coefficients (also those kept for removed zones), their confidence and sample counts, and the Model Error and Forecast Error statistics.\n\nKept: zones, sensors and weights, heat sources, windows, weather sources and all settings, plus the heat sources' baselines and the window event log.\n\nThe integration restarts and learns from the beginning: the first sample after about 30 minutes, forecasts after a day or more. This cannot be undone. The boiler and the thermostat are not affected.",
"data": {
"confirm_reset": "Yes, forget the learned model"
}
},
"weather": {
"title": "Weather source priority",
"description": "Choose as many sensors per metric as needed and arrange them in priority order. If the preferred source is unavailable, the next one is used automatically. Outdoor temperature is mandatory; wind and cloud coverage are diagnostic-only in this version.",
@@ -113,6 +127,10 @@
},
"error": {
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
},
"abort": {
"model_reset": "The learned model was reset. Learning starts again from the beginning.",
"model_not_reset": "Nothing was changed: the reset was not confirmed."
}
},
"entity": {
@@ -56,6 +56,13 @@
"options": {
"step": {
"init": {
"title": "Heating MPC",
"menu_options": {
"settings": "Settings",
"reset_model": "Reset learned model"
}
},
"settings": {
"title": "Heating MPC options",
"description": "Change room sensors and controller settings. This integration remains shadow-only.",
"data": {
@@ -83,10 +90,17 @@
"window_open_minutes": "Forecasts assume an open window closes after this many more minutes, then the room recovers.",
"recovery_minutes": "Time constant of the air warming back towards the walls' temperature.",
"ventilation_wind": "Needs wind sources; direction also needs wind direction sources.",
"ventilation_gust": "Adds a term for gusts above the mean wind. Keep it only if Forecast Error improves.",
"ventilation_gust": "Adds a term for gusts above the mean wind. Keep it only if Model Error improves.",
"ventilation_cross": "Adds a term for windows open in several zones at once. Needs windows in two or more zones."
}
},
"reset_model": {
"title": "Reset learned model",
"description": "Forgets everything the model has learned: every zone's thermal coefficients (also those kept for removed zones), their confidence and sample counts, and the Model Error and Forecast Error statistics.\n\nKept: zones, sensors and weights, heat sources, windows, weather sources and all settings, plus the heat sources' baselines and the window event log.\n\nThe integration restarts and learns from the beginning: the first sample after about 30 minutes, forecasts after a day or more. This cannot be undone. The boiler and the thermostat are not affected.",
"data": {
"confirm_reset": "Yes, forget the learned model"
}
},
"weather": {
"title": "Weather source priority",
"description": "Choose as many sensors per metric as needed and arrange them in priority order. If the preferred source is unavailable, the next one is used automatically. Outdoor temperature is mandatory; wind and cloud coverage are diagnostic-only in this version.",
@@ -113,6 +127,10 @@
},
"error": {
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
},
"abort": {
"model_reset": "The learned model was reset. Learning starts again from the beginning.",
"model_not_reset": "Nothing was changed: the reset was not confirmed."
}
},
"entity": {
@@ -56,6 +56,13 @@
"options": {
"step": {
"init": {
"title": "Heating MPC",
"menu_options": {
"settings": "Setări",
"reset_model": "Resetează modelul învățat"
}
},
"settings": {
"title": "Configurare Heating MPC",
"description": "Modifică senzorii, ponderile și optimizatorul. Rămâne activ doar modul de observație.",
"data": {
@@ -83,10 +90,17 @@
"window_open_minutes": "Prognozele presupun că o fereastră deschisă se închide după atâtea minute, apoi camera revine.",
"recovery_minutes": "Constanta de timp a aerului care se încălzește din nou spre temperatura pereților.",
"ventilation_wind": "Necesită surse de vânt; direcția necesită și surse pentru direcția vântului.",
"ventilation_gust": "Adaugă un termen pentru rafalele peste vântul mediu. Păstrează-l doar dacă eroarea de prognoză scade.",
"ventilation_gust": "Adaugă un termen pentru rafalele peste vântul mediu. Păstrează-l doar dacă Model Error scade.",
"ventilation_cross": "Adaugă un termen pentru ferestre deschise în mai multe zone deodată. Necesită ferestre în cel puțin două zone."
}
},
"reset_model": {
"title": "Resetează modelul învățat",
"description": "Șterge tot ce a învățat modelul: coeficienții termici ai fiecărei zone (inclusiv cei păstrați pentru zonele eliminate), încrederea și numărul de eșantioane, precum și statisticile Model Error și Forecast Error.\n\nSe păstrează: zonele, senzorii și ponderile, sursele de căldură, ferestrele, sursele meteo și toate setările, plus nivelurile de bază ale surselor de căldură și jurnalul ferestrelor.\n\nIntegrarea repornește și învață de la început: primul eșantion după aproximativ 30 de minute, prognozele după o zi sau mai mult. Operația nu poate fi anulată. Centrala și termostatul nu sunt afectate.",
"data": {
"confirm_reset": "Da, șterge modelul învățat"
}
},
"weather": {
"title": "Prioritatea surselor meteo",
"description": "Alege câți senzori dorești pentru fiecare variabilă și ordonează-i după prioritate. Când prima sursă este indisponibilă, integrarea încearcă automat următoarea. Temperatura exterioară este obligatorie; vântul și nebulozitatea sunt acum doar diagnostice.",
@@ -113,6 +127,10 @@
},
"error": {
"invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor."
},
"abort": {
"model_reset": "Modelul învățat a fost resetat. Învățarea reîncepe de la zero.",
"model_not_reset": "Nu s-a modificat nimic: resetarea nu a fost confirmată."
}
},
"entity": {
+54 -5
View File
@@ -21,6 +21,55 @@ async def setup_entry(hass, entry):
return hass.data[DOMAIN][entry.entry_id]
async def open_settings(hass, entry):
"""Configure opens a menu; Settings is the first choice."""
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["settings", "reset_model"]
return await hass.config_entries.options.async_configure(result["flow_id"], {"next_step_id": "settings"})
async def open_reset(hass, entry):
result = await hass.config_entries.options.async_init(entry.entry_id)
return await hass.config_entries.options.async_configure(result["flow_id"], {"next_step_id": "reset_model"})
async def test_reset_learned_model_keeps_configuration_and_observations(hass, entry, inputs, hass_storage):
coordinator = await setup_entry(hass, entry)
model = coordinator.learners["sensor.room"]
model.count, model.on_count, model.off_count = 250, 125, 125
payload = coordinator._payload("sensor.outside")
payload["retired_learners"] = {"sensor.gone": {**model.as_dict()}}
payload["ventilation"] = {"events": [{"zone": "sensor.room", "opened": "2026-10-27T10:00:00+00:00"}]}
payload["heat_sources"] = {"samples": {"ups": [[1.0, 200.0]]}}
await coordinator._store.async_save(payload)
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "ready"
before = (dict(entry.data), dict(entry.options), dict(entry.subentries))
# Not confirmed: nothing happens.
result = await open_reset(hass, entry)
assert (result["type"], result["step_id"]) == (FlowResultType.FORM, "reset_model")
result = await hass.config_entries.options.async_configure(result["flow_id"], {"confirm_reset": False})
assert (result["type"], result["reason"]) == (FlowResultType.ABORT, "model_not_reset")
assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250
# Confirmed: a fresh model, the same configuration.
result = await open_reset(hass, entry)
result = await hass.config_entries.options.async_configure(result["flow_id"], {"confirm_reset": True})
assert (result["type"], result["reason"]) == (FlowResultType.ABORT, "model_reset")
await hass.async_block_till_done()
fresh = hass.data[DOMAIN][entry.entry_id]
assert fresh is not coordinator and fresh.learners["sensor.room"].count == 0
assert fresh.data["status"] == "learning" and not fresh._retired
assert (dict(entry.data), dict(entry.options), dict(entry.subentries)) == before
stored = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"]
assert not {"learners", "retired_learners", "evaluation", "operational_evaluation", "fingerprints"} & set(stored)
assert stored["ventilation"]["events"][0]["opened"] == "2026-10-27T10:00:00+00:00"
assert stored["heat_sources"] == {"samples": {"ups": [[1.0, 200.0]]}}
assert stored["model_changes"][-1]["reasons"] == ["learned model reset by the user"]
assert fresh.data["model_changes"][-1]["recalibrated"] == ["all"]
async def test_config_flow_creates_one_zone_per_sensor(hass, inputs):
hass.states.async_set("sensor.bedroom", "19", {"unit_of_measurement": "°C", "friendly_name": "Bedroom"})
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
@@ -51,7 +100,7 @@ async def test_config_flow_creates_one_zone_per_sensor(hass, inputs):
async def test_options_flow_keeps_settings_without_rooms(hass, entry, inputs):
await setup_entry(hass, entry)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await open_settings(hass, entry)
assert "room_entities" not in {str(key) for key in result["data_schema"].schema}
main = result["data_schema"]({**MAIN, "room_max_age_minutes": 45, "room_stale_use_last_value": True})
result = await hass.config_entries.options.async_configure(result["flow_id"], main)
@@ -65,14 +114,14 @@ async def test_options_flow_keeps_settings_without_rooms(hass, entry, inputs):
assert coordinator.config["room_max_age_minutes"] == 45
assert coordinator.config["room_stale_use_last_value"] is True
assert coordinator.rooms == ["sensor.room"] # zones are untouched by Configure
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await open_settings(hass, entry)
assert result["data_schema"](MAIN)["room_stale_use_last_value"] is True
hass.config_entries.options.async_abort(result["flow_id"])
async def test_weather_source_may_not_be_a_zone_sensor(hass, entry, inputs):
await setup_entry(hass, entry)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await open_settings(hass, entry)
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"](MAIN))
result = await hass.config_entries.options.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.room"]}))
@@ -172,7 +221,7 @@ async def test_options_flow_clears_optional_weather_sources(hass, inputs):
"weights": {"sensor.room": 1.0}})
await setup_entry(hass, entry)
flow = hass.config_entries.options
result = await flow.async_init(entry.entry_id)
result = await open_settings(hass, entry)
result = await flow.async_configure(result["flow_id"], result["data_schema"](MAIN))
markers = {str(key): key for key in result["data_schema"].schema}
assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]}
@@ -245,7 +294,7 @@ async def test_window_subentry_flow_and_ventilation_options(hass, entry, inputs)
("sensor.gust", "9", "m/s")):
hass.states.async_set(sensor, value, {"unit_of_measurement": unit})
options = hass.config_entries.options
result = await options.async_init(entry.entry_id)
result = await open_settings(hass, entry)
result = await options.async_configure(result["flow_id"], result["data_schema"](
{**MAIN, "ventilation_gust": True, "ventilation_cross": True}))
result = await options.async_configure(result["flow_id"], result["data_schema"]({
+6 -1
View File
@@ -65,7 +65,12 @@ class FreshnessTests(unittest.TestCase):
def test_freshness_settings_translated(self):
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
data = json.loads((ROOT / name).read_text())
for section, step in (('config', 'user'), ('options', 'init')):
# Configure opens a menu: settings, or resetting the learned model.
options = data['options']
self.assertEqual(set(options['step']['init']['menu_options']), {'settings', 'reset_model'})
self.assertTrue(options['step']['reset_model']['data']['confirm_reset'])
self.assertEqual(set(options['abort']), {'model_reset', 'model_not_reset'})
for section, step in (('config', 'user'), ('options', 'settings')):
labels = data[section]['step'][step]['data']
for key in ('room_max_age_minutes', 'weather_max_age_minutes', 'room_stale_use_last_value'):
self.assertTrue(labels[key])