From 49565d610a5819d1e6a198228a2af1853a600fea Mon Sep 17 00:00:00 2001 From: Andrei Buzera Date: Tue, 27 Oct 2026 04:29:03 +0200 Subject: [PATCH] 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 --- README.md | 13 +++- custom_components/heating_mpc/config_flow.py | 19 +++++- custom_components/heating_mpc/coordinator.py | 28 +++++++++ custom_components/heating_mpc/strings.json | 20 ++++++- .../heating_mpc/translations/en.json | 20 ++++++- .../heating_mpc/translations/ro.json | 20 ++++++- tests/ha/test_config_flow.py | 59 +++++++++++++++++-- tests/test_data_quality.py | 7 ++- 8 files changed, 174 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0ff9d64..f649c66 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/custom_components/heating_mpc/config_flow.py b/custom_components/heating_mpc/config_flow.py index a07589d..03ca994 100644 --- a/custom_components/heating_mpc/config_flow.py +++ b/custom_components/heating_mpc/config_flow.py @@ -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: diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 13ffa47..5989838 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -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 {} diff --git a/custom_components/heating_mpc/strings.json b/custom_components/heating_mpc/strings.json index 8e608ab..8e23e71 100644 --- a/custom_components/heating_mpc/strings.json +++ b/custom_components/heating_mpc/strings.json @@ -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": { diff --git a/custom_components/heating_mpc/translations/en.json b/custom_components/heating_mpc/translations/en.json index 8e608ab..8e23e71 100644 --- a/custom_components/heating_mpc/translations/en.json +++ b/custom_components/heating_mpc/translations/en.json @@ -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": { diff --git a/custom_components/heating_mpc/translations/ro.json b/custom_components/heating_mpc/translations/ro.json index cbe2118..c61e078 100644 --- a/custom_components/heating_mpc/translations/ro.json +++ b/custom_components/heating_mpc/translations/ro.json @@ -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": { diff --git a/tests/ha/test_config_flow.py b/tests/ha/test_config_flow.py index 6caf6ee..b3ccaee 100644 --- a/tests/ha/test_config_flow.py +++ b/tests/ha/test_config_flow.py @@ -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"]({ diff --git a/tests/test_data_quality.py b/tests/test_data_quality.py index 84416a2..3747972 100644 --- a/tests/test_data_quality.py +++ b/tests/test_data_quality.py @@ -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])