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

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