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])