Files
heating-mpc-ha/tests/ha/test_config_flow.py
T
buzeraandClaude Opus 5.5 00e70c612b Model electrical heat sources (UPS, gaming PC) per zone
Electrical equipment heats its room, and a gaming session can add
hundreds of watts, which the 3-parameter model attributed to the boiler
or the bias term.

heat_sources.py adds heat sources as config subentries ("Add heat
source"): an active-power sensor (W/kW; VA and % rejected), the zone,
the share that becomes heat, an automatic baseline (20th percentile of
24 h, so the persistent UPS load is not double counted with the bias
term) or a manual one, an optional "included in" parent (the UPS total
minus a separately measured PC, one level deep) and a first-order
thermal response time. The heat during each interval is the power held
from its start, like the radiator lag. After startup or an outage a
source's response must run two time constants before it counts as
settled.

zone_model.py layers each zone: the base model is kept unchanged as the
fallback and comparison, and an extended FeatureLearner adds "internal"
(kW above baseline after the response) and neighbour coupling for
adjacent zones. It learns only intervals where all its inputs are
known, and is used for forecasts once as trained as the base. The
optimizer accepts a multi-zone step model; forecasts let extra power
fade to baseline with a configurable half-life (default 30 min).

On a synthetic office with thermostat cycling and gaming sessions the
extended model recovers the electrical, boiler and loss coefficients
within 10 %. Status lists each source's power, effective power,
baseline, variable and responding heat; models show the extended
coefficients with confidence. Saved models gain optional zone_models
and heat_sources keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:39:18 +02:00

212 lines
13 KiB
Python

"""Validate actual HA form schemas, options, zone subentries and entry migration."""
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.heating_mpc.const import DOMAIN
from custom_components.heating_mpc.weather_sources import sources_for
MAIN = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler"}
def zones_of(entry):
return {sub.unique_id: sub for sub in entry.subentries.values() if sub.subentry_type == "zone"}
async def setup_entry(hass, entry):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return hass.data[DOMAIN][entry.entry_id]
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})
assert result["type"] is FlowResultType.FORM
validated = result["data_schema"]({**MAIN, "room_entities": ["sensor.room", "sensor.bedroom"]})
assert validated["room_max_age_minutes"] == 30
assert validated["weather_max_age_minutes"] == 120
assert validated["room_stale_use_last_value"] is False
result = await hass.config_entries.flow.async_configure(result["flow_id"], validated)
assert result["step_id"] == "weather"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]}))
assert result["step_id"] == "weather_extra"
result = await hass.config_entries.flow.async_configure(result["flow_id"], result["data_schema"]({}))
assert result["step_id"] == "weights"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"weight__sensor.room": 2, "weight__sensor.bedroom": 1})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
entry = result["result"]
assert entry.version == 3
assert "room_entities" not in entry.data and "weights" not in entry.data
zones = zones_of(entry)
assert {key: (sub.title, sub.data["comfort_weight"]) for key, sub in zones.items()} == {
"sensor.room": ("room", 2.0), "sensor.bedroom": ("Bedroom", 1.0)} # HA's name fallback
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.room", "sensor.bedroom"]
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)
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)
result = await hass.config_entries.options.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]}))
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"]({}))
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
assert entry.options["room_max_age_minutes"] == 45
coordinator = hass.data[DOMAIN][entry.entry_id]
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)
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 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"]}))
assert result["errors"] == {"base": "invalid_weather"}
async def test_v1_entry_migrates_to_zones_on_setup(hass, inputs):
entry = MockConfigEntry(domain=DOMAIN, version=1, data={
**MAIN, "room_entities": ["sensor.room"], "outside_entity": "sensor.outside"})
await setup_entry(hass, entry)
assert entry.version == 3
assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
assert "outside_entity" not in entry.data and "room_entities" not in entry.data
assert set(zones_of(entry)) == {"sensor.room"}
async def test_v2_migration_keeps_weights_models_and_entities(hass, entry, inputs, hass_storage):
from custom_components.heating_mpc.physics import Learner
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C", "friendly_name": "Living room"})
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": {
"schema_version": 2, "heater_entity": "switch.boiler",
"learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}}
entry.add_to_hass(hass)
# A forecast entity created by 0.3 for this room.
registry = er.async_get(hass)
old = registry.async_get_or_create("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min",
config_entry=entry, suggested_object_id="mpc_living_forecast")
hass.config_entries.async_update_entry(entry, options={"weights": {"sensor.room": 4.0}})
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
zone = zones_of(entry)["sensor.room"]
assert (zone.title, zone.data["comfort_weight"], zone.data["sensors"]) == ("Living room", 4.0, ["sensor.room"])
assert "weights" not in entry.options
assert coordinator.learners["sensor.room"].count == 250
# Same entity, now linked to its zone, so deleting the zone removes it.
migrated = registry.async_get(old.entity_id)
assert migrated.config_subentry_id == zone.subentry_id
assert hass.config_entries.async_remove_subentry(entry, zone.subentry_id)
await hass.async_block_till_done()
assert registry.async_get(old.entity_id) is None
async def test_zone_subentry_add_edit_and_validation(hass, entry, inputs):
await setup_entry(hass, entry)
for entity, value in (("sensor.living_a", "21"), ("sensor.living_b", "22")):
hass.states.async_set(entity, value, {"unit_of_measurement": "°C"})
flows = hass.config_entries.subentries
result = await flows.async_init((entry.entry_id, "zone"), context={"source": SOURCE_USER})
assert result["type"] is FlowResultType.FORM
# A sensor of another zone, or an outdoor source, is refused.
for sensors, error in ((["sensor.room"], "sensor_in_other_zone"), (["sensor.outside"], "sensor_is_weather_source")):
result = await flows.async_configure(result["flow_id"], {
"name": "Living", "sensors": sensors, "comfort_weight": 2, "target_offset": 0})
assert result["errors"] == {"base": error}
result = await flows.async_configure(result["flow_id"], {
"name": " Living ", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 2,
"target_offset": 0.5, "neighbors": ["sensor.room"]})
assert result["step_id"] == "sensor_weights"
result = await flows.async_configure(result["flow_id"], {
"weight__sensor.living_a": 3, "weight__sensor.living_b": 1})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
living = next(sub for sub in zones_of(entry).values() if sub.title == "Living")
assert living.data["sensor_weights"] == {"sensor.living_a": 3.0, "sensor.living_b": 1.0}
coordinator = hass.data[DOMAIN][entry.entry_id]
key = living.data["key"]
assert coordinator.zones[key].neighbors == ("sensor.room",)
assert coordinator.zones["sensor.room"].neighbors == (key,) # symmetric
assert coordinator.data["temperatures"][key] == 21.25
# Edit: drop a sensor; the key (and so the learned model) is kept.
result = await flows.async_init((entry.entry_id, "zone"), context={
"source": SOURCE_RECONFIGURE, "subentry_id": living.subentry_id})
result = await flows.async_configure(result["flow_id"], {
"name": "Living", "sensors": ["sensor.living_a"], "comfort_weight": 1, "target_offset": 0})
assert result["type"] is FlowResultType.ABORT and result["reason"] == "reconfigure_successful"
await hass.async_block_till_done()
assert entry.subentries[living.subentry_id].data["key"] == key
assert hass.data[DOMAIN][entry.entry_id].zones[key].sensors == ("sensor.living_a",)
async def test_options_flow_clears_optional_weather_sources(hass, inputs):
"""Swapping providers in the UI must allow removing an optional source list."""
entry = MockConfigEntry(domain=DOMAIN, version=2, unique_id="home_heating", data={
**MAIN, "room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"],
"outside_wind_sources": ["sensor.wind"], "outside_pressure_sources": ["sensor.pressure"],
"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 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"]}
# The user removes the wind provider; a cleared field may be omitted on submit.
result = await flow.async_configure(result["flow_id"], result["data_schema"](
{"outside_temperature_sources": ["sensor.outside"]}))
result = await flow.async_configure(result["flow_id"], result["data_schema"](
{"outside_pressure_sources": ["sensor.pressure"]}))
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
assert entry.options["outside_wind_sources"] == []
assert entry.options["outside_pressure_sources"] == ["sensor.pressure"]
config = hass.data[DOMAIN][entry.entry_id].config
assert sources_for(config, "wind") == []
assert sources_for(config, "pressure") == ["sensor.pressure"]
async def test_heat_source_subentry_flow(hass, entry, inputs):
await setup_entry(hass, entry)
for sensor, value in (("sensor.ups_power", "600"), ("sensor.pc_power", "400")):
hass.states.async_set(sensor, value, {"unit_of_measurement": "W", "device_class": "power"})
flows = hass.config_entries.subentries
base = {"zone": "sensor.room", "heat_fraction": 1, "response_minutes": 15, "enabled": True}
result = await flows.async_init((entry.entry_id, "heat_source"), context={"source": SOURCE_USER})
result = await flows.async_configure(result["flow_id"], {
**base, "name": "UPS", "sensor": "sensor.ups_power", "baseline_mode": "auto"})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
ups = next(sub for sub in entry.subentries.values() if sub.subentry_type == "heat_source")
# The PC is behind the UPS: its power is already in the UPS reading.
result = await flows.async_init((entry.entry_id, "heat_source"), context={"source": SOURCE_USER})
pc = {**base, "name": "Gaming PC", "sensor": "sensor.pc_power", "baseline_mode": "manual",
"included_in": ups.unique_id, "response_minutes": 10}
result = await flows.async_configure(result["flow_id"], pc)
assert result["errors"] == {"base": "baseline_required"}
result = await flows.async_configure(result["flow_id"], {**pc, "baseline_w": 0})
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert [(s.name, s.included_in) for s in coordinator.sources] == [("UPS", None), ("Gaming PC", ups.unique_id)]
assert coordinator.extended["sensor.room"].features == ["loss", "heat", "bias", "internal"]
registry = er.async_get(hass)
status = hass.states.get(registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
sources = status.attributes["heat_sources"]
assert (sources["UPS"]["power_w"], sources["UPS"]["effective_w"]) == (600, 200) # PC not counted twice
assert (sources["Gaming PC"]["included_in"], sources["Gaming PC"]["variable_w"]) == ("UPS", 400)