diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 3447922..62dbd85 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -321,9 +321,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): info = self._ventilation.window_state.get(window.key, {}) is_open, since = info.get("open"), info.get("since") direction = weather.get("wind_direction") + last_known, unknown_since = info.get("last_known_open"), info.get("unknown_since") windows[window.name] = { "zone": self.zones[window.zone].name, "sensor": window.sensor, "enabled": window.enabled, "state": "unknown" if is_open is None else "open" if is_open else "closed", + "last_known_state": None if last_known is None else "open" if last_known else "closed", + "unknown_minutes": round((now - unknown_since) / 60, 1) if unknown_since else None, "open_minutes": round((now - since) / 60, 1) if is_open and since else None, "azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted, "relative_wind_direction": info.get("relative_wind_direction"), diff --git a/custom_components/heating_mpc/evaluation.py b/custom_components/heating_mpc/evaluation.py index ee1677d..f946491 100644 --- a/custom_components/heating_mpc/evaluation.py +++ b/custom_components/heating_mpc/evaluation.py @@ -24,7 +24,7 @@ from math import isfinite from typing import Any from .physics import Learner -from .windows import ventilation_vector +from .windows import ventilation_vector_for_prediction from .zone_model import feature_vector HORIZONS = (15, 30, 60, 120) # minutes @@ -67,7 +67,7 @@ def snapshot_delta(snapshot: dict[str, Any], temp: float, inputs: dict[str, Any] ventilation = inputs.get("ventilation") if snapshot["ventilation"] and ventilation: features, theta = snapshot["ventilation"] - x = ventilation_vector(features, temp, inputs["outside"], ventilation) + x = ventilation_vector_for_prediction(features, temp, inputs["outside"], ventilation) if x is not None: main += _dot(theta, x) return main diff --git a/custom_components/heating_mpc/windows.py b/custom_components/heating_mpc/windows.py index 40248b8..dcfef76 100644 --- a/custom_components/heating_mpc/windows.py +++ b/custom_components/heating_mpc/windows.py @@ -190,6 +190,23 @@ def ventilation_vector(features: Iterable[str], temp: float, outside: float, return values +def ventilation_vector_for_prediction(features: Iterable[str], temp: float, outside: float, + inputs: Mapping[str, Any]) -> list[float] | None: + """Like ventilation_vector, but an unknown wind or gust counts as calm air. + + For predictions only: an open window keeps its natural ventilation when the wind + sensor is unavailable, instead of silently being treated as closed. Learning + always uses ventilation_vector, which refuses unknown inputs. + """ + features = list(features) + x = ventilation_vector(features, temp, outside, inputs) + if x is None and inputs.get("opening") is not None: + x = ventilation_vector(features, temp, outside, { + **inputs, "wind": inputs.get("wind") or 0.0, "gust": inputs.get("gust") or inputs.get("wind") or 0.0, + "facing": inputs.get("facing") or 0.0}) + return x + + def _iso(timestamp: float) -> str: return datetime.fromtimestamp(timestamp, timezone.utc).isoformat(timespec="seconds") @@ -206,6 +223,9 @@ class VentilationTracker: self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened self._last_temps: dict[str, float | None] = {} self._open_event: dict[str, dict[str, Any]] = {} + # window -> last known open/closed state, since when, and since when it is unknown. + self._memory: dict[str, dict[str, Any]] = {} + self._recovery_confirmed: dict[str, bool] = {} saved = saved if isinstance(saved, Mapping) else {} events = saved.get("events") if isinstance(saved.get("events"), list) else [] self.events: deque[dict[str, Any]] = deque( @@ -215,7 +235,13 @@ class VentilationTracker: def update(self, now: float, states: Mapping[str, tuple[str | None, float | None]], temps: Mapping[str, float | None], weather: Mapping[str, float | None]) -> None: - """One poll. `states`: window key -> (binary sensor state, last_changed timestamp).""" + """One poll. `states`: window key -> (binary sensor state, last_changed timestamp). + + Each window is OPEN, CLOSED or UNKNOWN (unknown/unavailable/missing). Unknown + never means closed: the last known state is kept, an open window's event + stays open, and a close that happened while the sensor was unknown is + recorded as unconfirmed. + """ self.window_state = {} zones: dict[str, dict[str, Any]] = {} for window in self.windows: @@ -228,9 +254,11 @@ class VentilationTracker: if previous and changed is not None and 0 < now - previous[0] <= 900: fraction = open_fraction(previous[1], is_open, changed, previous[0], now) self._previous[window.key] = (now, is_open) - zone = zones.setdefault(window.zone, {"opening": 0.0, "facing_unit": 0.0, "known": True, - "open_now": False, "open_windows": [], "last_close": None, - "first_open": None}) + memory = self._memory.setdefault(window.key, {"last_known": None, "since": None, "unknown_since": None}) + zone = zones.setdefault(window.zone, { + "opening": 0.0, "facing_unit": 0.0, "known": True, "open_now": False, "possibly_open": False, + "open_windows": [], "last_close": None, "close_confirmed": True, "first_open": None, + "open_confirmed": True, "unknown_windows": []}) if fraction is None: zone["known"] = False else: @@ -238,15 +266,31 @@ class VentilationTracker: zone["facing_unit"] += window.opening * fraction * ( facing_cos(weather["wind_direction"], window.azimuth) if weather.get("wind_direction") is not None else 0.0) - if is_open: - zone["open_now"] = True - zone["open_windows"].append(window.name) - if changed is not None: - zone["first_open"] = min(zone["first_open"] or changed, changed) - elif is_open is False and previous and changed is not None and changed > previous[0]: - zone["last_close"] = max(zone["last_close"] or 0.0, changed) + if is_open is None: + if memory["unknown_since"] is None: + memory["unknown_since"] = previous[0] if previous and previous[1] is not None else now + zone["unknown_windows"].append(window.name) + if memory["last_known"]: + zone["possibly_open"] = True # last seen open: still treated as open + else: + after_outage = memory["unknown_since"] is not None + if memory["last_known"] is not is_open: + memory["since"] = changed + if is_open and after_outage: + zone["open_confirmed"] = False # opened at some point during the outage + if is_open is False and memory["last_known"] and after_outage: + zone["close_confirmed"] = False # closed at some point during the outage + memory["last_known"], memory["unknown_since"] = is_open, None + if is_open: + zone["open_now"] = True + zone["open_windows"].append(window.name) + if changed is not None: + zone["first_open"] = min(zone["first_open"] or changed, changed) + elif previous and changed is not None and changed > previous[0]: + zone["last_close"] = max(zone["last_close"] or 0.0, changed) self.window_state[window.key] = { - "open": is_open, "since": changed, + "open": is_open, "since": changed, "last_known_open": memory["last_known"], + "last_known_since": memory["since"], "unknown_since": memory["unknown_since"], "relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1) if weather.get("wind_direction") is not None else None)} for key, zone in zones.items(): @@ -257,30 +301,46 @@ class VentilationTracker: def _update_zone(self, now: float, key: str, zone: dict[str, Any], temp: float | None, weather: Mapping[str, float | None], zones: Mapping[str, dict[str, Any]]) -> None: was_open = key in self._open_event + ventilating = zone["open_now"] or zone["possibly_open"] if zone["open_now"] and not was_open: # Temperature at the previous poll approximates the room before ventilation. self._t_ref[key] = self._last_temps.get(key) self._closed_at.pop(key, None) self._open_event[key] = { "zone": key, "opened": _iso(min(zone["first_open"] or now, now)), - "temperature_before": self._t_ref[key], + "opened_confirmed": zone["open_confirmed"], + "temperature_before": self._t_ref[key] if zone["open_confirmed"] else None, "outside": weather.get("temperature"), "wind": weather.get("wind"), "wind_direction": weather.get("wind_direction"), "wind_gust": weather.get("wind_gust"), "windows": list(zone["open_windows"]), "other_open_windows": [n for k, z in zones.items() if k != key for n in z["open_windows"]], - "lowest_temperature": temp, + "lowest_temperature": temp, "sensor_unknown_minutes": 0.0, } - elif zone["open_now"] and temp is not None: + if not zone["open_confirmed"]: + self._t_ref[key] = None # the room may already have cooled before we saw it + elif ventilating and was_open: event = self._open_event[key] - event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None) - elif was_open and not zone["open_now"]: + if temp is not None: + event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None) + if zone["unknown_windows"]: + event["sensor_unknown_minutes"] = round(event["sensor_unknown_minutes"] + 5.0, 1) + elif was_open and not ventilating: event = self._open_event.pop(key) - closed = zone["last_close"] or now + if zone["close_confirmed"]: + closed = zone["last_close"] or now + event.update(closed=_iso(closed), closed_confirmed=True, + duration_minutes=round((closed - _parse(event["opened"])) / 60, 1)) + else: + # Closed while the sensor was unknown: no confirmed time, only a range. + closed = now + event.update(closed=None, closed_confirmed=False, closed_reported=_iso(now), + duration_minutes=None, + duration_max_minutes=round((now - _parse(event["opened"])) / 60, 1)) self._closed_at[key] = closed - event.update(closed=_iso(closed), duration_minutes=round((closed - _parse(event["opened"])) / 60, 1), - temperature_at_close=temp) + self._recovery_confirmed[key] = zone["close_confirmed"] and event.get("opened_confirmed", True) + event["temperature_at_close"] = temp self.events.append(event) - elif not zone["open_now"] and key in self._closed_at and temp is not None: + elif not ventilating and key in self._closed_at and temp is not None: # Fill in the temperature half an hour after closing, for recovery review. for event in reversed(self.events): if event.get("zone") == key and "temperature_after" not in event: @@ -289,36 +349,46 @@ class VentilationTracker: event["temperature_after_minutes"] = EVENT_AFTER_MINUTES break closed = self._closed_at.get(key) - recovery = exp(-(now - closed) / self.tau) if closed is not None and not zone["open_now"] else 0.0 + recovery = exp(-(now - closed) / self.tau) if closed is not None and not ventilating else 0.0 if recovery < RECOVERY_THRESHOLD: recovery = 0.0 zone.update(recovery=recovery, t_ref=self._t_ref.get(key), + recovery_confirmed=self._recovery_confirmed.get(key, True), other_opening=sum(z["opening"] for k, z in zones.items() if k != key and z["known"])) # Learning of the closed-window model is unsafe while anything ventilation-related goes on. - zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or zone["open_now"] or recovery > 0 + zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or ventilating or recovery > 0 def zone_inputs(self, key: str, weather: Mapping[str, float | None]) -> dict[str, Any] | None: - """Ventilation inputs of the interval that just ended; None for zones without windows.""" + """Ventilation inputs of the interval that just ended; None for zones without windows. + + `known` is False while a window state is unknown, and during a recovery whose + start is not confirmed: those intervals are not used for any learning. + """ zone = self.zone_state.get(key) if zone is None: return None - return {"opening": zone["opening"] if zone["known"] else None, "facing": zone["facing_unit"], + known = zone["known"] and (zone["recovery"] == 0 or zone["recovery_confirmed"]) + return {"opening": zone["opening"] if known else None, "facing": zone["facing_unit"], "other_opening": zone["other_opening"], "recovery": zone["recovery"], "t_ref": zone["t_ref"], "wind": weather.get("wind"), "gust": weather.get("wind_gust"), - "affected": zone["affected"], "known": zone["known"]} + "affected": zone["affected"], "known": known, + "unknown_windows": list(zone["unknown_windows"])} def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None], now: float) -> list[dict[str, Any]]: """Assumed ventilation inputs per forecast step. - Windows open now are assumed to close after `open_minutes` (at least one - step); after that the zone recovers. Weather is held at current values. + Windows open now (or unknown but last seen open) are assumed to close after + `open_minutes` (at least one step); after that the zone recovers. Weather + is held at current values. """ zone = self.zone_state.get(key) if zone is None: return [{} for _ in range(steps)] windows = [w for w in self.windows if w.zone == key and w.enabled - and self.window_state.get(w.key, {}).get("open")] + and (self.window_state.get(w.key, {}).get("open") + or (self.window_state.get(w.key, {}).get("open") is None + and self.window_state.get(w.key, {}).get("last_known_open")))] opening = sum(w.opening for w in windows) facing = sum(w.opening * facing_cos(weather["wind_direction"], w.azimuth) for w in windows) \ if weather.get("wind_direction") is not None else 0.0 diff --git a/custom_components/heating_mpc/zone_model.py b/custom_components/heating_mpc/zone_model.py index 029bdc9..408a473 100644 --- a/custom_components/heating_mpc/zone_model.py +++ b/custom_components/heating_mpc/zone_model.py @@ -18,7 +18,7 @@ from typing import Any, Callable, Mapping from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES from .learning import FeatureLearner from .physics import Learner -from .windows import ventilation_vector +from .windows import ventilation_vector_for_prediction BASE_FEATURES = ["loss", "heat", "bias"] @@ -86,7 +86,7 @@ class ZoneModel: ventilation = inputs.get("ventilation") if self.ventilation is None or not ventilation: return 0.0 - x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation) + x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation) return self.ventilation.predict(x) if x is not None else 0.0 def delta(self, temp: float, inputs: Mapping[str, Any]) -> float: @@ -98,7 +98,7 @@ class ZoneModel: terms = self._main_contributions(temp, inputs) ventilation = inputs.get("ventilation") if self.ventilation is not None and ventilation: - x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation) + x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation) if x is not None: for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x): group = "recovery" if name == "recovery" else "ventilation" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 0f194e9..31d70f9 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -1066,3 +1066,24 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): await grown.async_restore_model() self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross') self.assertEqual(grown.ventilation['bed'].theta[:-1], vent.theta) + + async def test_unavailable_wind_during_ventilation(self): + self.c = self.bedroom_with_window() + for _ in range(2): + self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir') + await self.sample() + self.open_window('on', 240) + self.states['sensor.wind'].state = 'unavailable' + self.fresh('sensor.room', 'sensor.outside', 'sensor.wind_dir') + data = await self.sample() + # Learning needs the wind: nothing is fitted, the envelope model stays out too. + self.assertEqual(data['sample_result'], 'ventilation') + self.assertEqual((self.c.ventilation['bed'].count, self.c.learners['bed'].count), (0, 1)) + # Predictions keep the open window, as calm air, rather than dropping it. + vent = self.c.ventilation['bed'] + vent.theta[vent.features.index('vent_natural')] = 0.01 + model = coordinator.ZoneModel(self.c.learners['bed'], None, vent) + inputs = self.c._ventilation.zone_inputs('bed', {'wind': None, 'wind_gust': None}) + self.assertLess(model.ventilation_delta(20.0, {'outside': 5.0, 'ventilation': {**inputs, 'opening': 1.0}}), -0.1) + window = data['windows']['Bedroom window'] + self.assertEqual((window['state'], window['last_known_state'], window['unknown_minutes']), ('open', 'open', None)) diff --git a/tests/test_windows.py b/tests/test_windows.py index dfc1e01..6766dc2 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -145,3 +145,111 @@ class TrackerTests(unittest.TestCase): if __name__ == '__main__': unittest.main() + + +class UnknownStateTests(unittest.TestCase): + """OPEN, CLOSED and UNKNOWN are three states: unknown never means closed.""" + + def setUp(self): + self.tracker = wm.VentilationTracker(wm.build_windows([(window('w'), None)], ['bed']), recovery_minutes=20) + self.t = 1_000_000.0 + + def poll(self, state, changed_ago=None, temp=20.0): + changed = self.t - changed_ago if changed_ago is not None else self.t - 100_000 + self.tracker.update(self.t, {'w': (state, changed)}, {'bed': temp}, WEATHER) + inputs = self.tracker.zone_inputs('bed', WEATHER) + forecast = self.tracker.forecast('bed', 6, 15, WEATHER, self.t) + self.t += 300 + return inputs, forecast, dict(self.tracker.window_state['w']) + + def open_for_a_while(self): + self.poll('off') + self.poll('off', temp=21.0) + self.poll('on', changed_ago=100, temp=20.4) + self.poll('on', changed_ago=400, temp=20.0) + + def test_open_to_unknown_keeps_the_event_and_assumes_still_open(self): + self.open_for_a_while() + inputs, forecast, state = self.poll('unavailable', changed_ago=60, temp=19.8) + self.assertEqual(len(self.tracker.events), 0) # no confirmed closing without evidence + self.assertIn('bed', self.tracker._open_event) + self.assertEqual((inputs['known'], inputs['affected'], inputs['recovery']), (False, True, 0.0)) + self.assertEqual(forecast[0]['opening'], 1.0) # last seen open: still treated as open + self.assertEqual((state['open'], state['last_known_open']), (None, True)) + self.assertIsNotNone(state['unknown_since']) + + def test_unknown_to_open_continues_the_same_event(self): + self.open_for_a_while() + self.poll('unavailable', changed_ago=60) + self.poll('unavailable', changed_ago=360) + inputs, _, state = self.poll('on', changed_ago=30) # sensor back, window still open + self.assertEqual((len(self.tracker.events), state['unknown_since']), (0, None)) + self.assertFalse(inputs['known']) # the interval itself was unknown + inputs, _, _ = self.poll('on', changed_ago=330) + self.assertEqual((inputs['known'], inputs['opening']), (True, 1.0)) + self.poll('off', changed_ago=120, temp=20.2) # confirmed close + event = self.tracker.events[-1] + self.assertEqual((event['closed_confirmed'], event['sensor_unknown_minutes']), (True, 10.0)) # two polls + self.assertIsNotNone(event['duration_minutes']) + + def test_unknown_to_closed_records_an_unconfirmed_close(self): + self.open_for_a_while() + self.poll('unavailable', changed_ago=60) + inputs, forecast, state = self.poll('off', changed_ago=20, temp=20.1) + event = self.tracker.events[-1] + self.assertEqual((event['closed'], event['closed_confirmed'], event['duration_minutes']), (None, False, None)) + self.assertGreater(event['duration_max_minutes'], 0) + self.assertEqual((state['open'], state['last_known_open'], state['unknown_since']), (False, False, None)) + # Recovery is assumed from when the sensor reported closed, but its timing is + # unknown, so neither the ventilation nor the envelope model learns from it. + self.assertGreater(inputs['recovery'], 0.9) + self.assertEqual((inputs['known'], inputs['affected'], inputs['opening']), (False, True, None)) + self.assertEqual(forecast[0]['opening'], 0.0) + self.assertGreater(forecast[0]['recovery'], 0.0) + + def test_closed_to_unknown_assumes_closed_but_blocks_learning(self): + self.poll('off') + self.poll('off') + inputs, forecast, state = self.poll('unavailable', changed_ago=120) + self.assertEqual((inputs['known'], inputs['affected']), (False, True)) + self.assertEqual(forecast[0]['opening'], 0.0) + self.assertEqual((state['open'], state['last_known_open']), (None, False)) + self.assertEqual(len(self.tracker.events), 0) + + def test_unknown_to_open_after_a_closed_window_starts_an_unconfirmed_event(self): + self.poll('off') + self.poll('off', temp=21.0) + self.poll('unavailable', changed_ago=60, temp=20.6) + self.poll('on', changed_ago=30, temp=20.0) # opened at some point during the outage + event = self.tracker._open_event['bed'] + self.assertEqual((event['opened_confirmed'], event['temperature_before']), (False, None)) + self.poll('on', changed_ago=330, temp=19.8) + inputs, _, _ = self.poll('off', changed_ago=100, temp=19.9) + self.assertTrue(self.tracker.events[-1]['closed_confirmed']) + # Without the temperature before ventilation, recovery cannot be learned. + self.assertFalse(inputs['known']) + + def test_recovery_after_prolonged_unavailability(self): + self.open_for_a_while() + for _ in range(72): # six hours without the sensor + self.poll('unavailable', changed_ago=60) + self.assertEqual(len(self.tracker.events), 0) + inputs, _, _ = self.poll('off', changed_ago=60) + event = self.tracker.events[-1] + self.assertFalse(event['closed_confirmed']) + self.assertGreaterEqual(event['duration_max_minutes'], 360) + self.assertEqual(event['sensor_unknown_minutes'], 360.0) + for _ in range(12): # recovery decays, learning resumes + inputs, _, _ = self.poll('off') + self.assertEqual((inputs['known'], inputs['affected'], inputs['recovery']), (True, False, 0.0)) + + def test_multiple_windows_with_one_unknown(self): + tracker = wm.VentilationTracker(wm.build_windows([(window('a'), None), (window('b'), None)], ['bed'])) + for t, a, b in ((0, 'off', 'off'), (300, 'on', 'off'), (600, 'on', 'unavailable'), + (900, 'off', 'unavailable')): + tracker.update(float(t), {'a': (a, t - 60.0), 'b': (b, t - 60.0)}, {'bed': 20.0}, WEATHER) + # Window a closed (confirmed); b was last seen closed: the event ends, confirmed, + # but the zone stays out of learning while b's state is unknown. + self.assertTrue(tracker.events[-1]['closed_confirmed']) + inputs = tracker.zone_inputs('bed', WEATHER) + self.assertEqual((inputs['known'], inputs['unknown_windows']), (False, ['B']))