Score a forecast only when the relay matched its plan in time, not just in share

Reproduced: a forecast planning 15 minutes of heat was scored although the
relay was on for 12 (the 0.4.1 rule allowed 20 % per 15-minute block), and
the same on-time placed elsewhere in a block passed as well.

- The relay's on-time per poll interval, integrated at its own switch
  times, is compared with the plan block by block up to each horizon: the
  deviation is the time the relay spent in another state than planned.
  Unobserved relay time counts as possible deviation.
- Matched (at most 2 minutes, worst case): Forecast Error figures.
  Approximate (at most 5 minutes, at most 2 of them unobserved): separate
  approximate_stats. Counterfactual (more than 5 minutes seen): counted, not
  scored. Incomplete otherwise, by reason: relay history, assumed inputs at
  forecast time (an unknown window or electrical heat), no poll near the
  due time, or a missing reading.
- 2 minutes of unplanned boiler heat move a room by roughly half a 0.1 °C
  sensor step for the learned heat coefficients (0.05-0.12 °C per 5
  minutes); 5 minutes by about one step.
- Stored ledger statistics are versioned (2); 0.4.1 statistics used the
  looser rule and are dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 04:50:58 +02:00
1 parent 2cfba024ff
commit 0b82a8e26c
5 files changed
+258 -74

No files matched your search

@@ -895,9 +895,16 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
_LOGGER.warning("MPC prediction failed: %s", err) _LOGGER.warning("MPC prediction failed: %s", err)
status["status"] = "prediction_error" status["status"] = "prediction_error"
return status return status
# Inputs the forecast had to assume: such forecasts are not compared with reality.
uncertain = [f"window: {name}" for room in vals
for name in (vent_inputs.get(room) or {}).get("unknown_windows", [])]
uncertain += [f"electrical heat: {self.zones[room].name}" for room in vals
if internal[room] is None and room in self.extended
and ZoneModel(self.learners[room], self.extended[room]).uses_extended]
self._forecasts.record(now.timestamp(), vals, output["trajectory_15min"], output["plan_15min"], self._forecasts.record(now.timestamp(), vals, output["trajectory_15min"], output["plan_15min"],
self._revision(), { self._revision(), {
"strategy": "mpc_plan", "outside": outside, "wind": weather.get("wind"), "strategy": "mpc_plan", "outside": outside, "wind": weather.get("wind"),
"uncertain_inputs": uncertain,
"internal_half_life_minutes": float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30)), "internal_half_life_minutes": float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30)),
"window_open_minutes": float(self.config[CONF_WINDOW_OPEN_MINUTES]), "window_open_minutes": float(self.config[CONF_WINDOW_OPEN_MINUTES]),
"internal_kw": {room: internal[room] for room in vals}, "internal_kw": {room: internal[room] for room in vals},
+113 -65
View File
@@ -371,11 +371,24 @@ def _stats(values: list[float], horizon: int, zones: int = 1) -> dict[str, Any]:
} }
# A forecast's plan counts as executed when, in every 15-minute block, the share # How far the relay may depart from a forecast's plan (seconds in another state than
# of time the relay was on is within this of what the plan assumed (0 or 1). # planned, up to the horizon) for the forecast to be compared with what happened.
PLAN_TOLERANCE = 0.2 # d seconds of boiler heat that the plan did not assume (or the reverse) move the
# room by roughly heat x d / 300, where heat is the learned boiler coefficient
# (typically 0.05-0.12 °C per 5 minutes with warm radiators). Up to 2 minutes keeps
# that below half a typical 0.1 °C sensor step and absorbs timestamp jitter;
# matched forecasts make the Forecast Error figures. Up to 5 minutes (one poll) is
# about one sensor step: kept apart as approximate. Beyond, the relay did
# something else: the forecast is counterfactual and is not scored. Relay time
# that was not observed counts as possible deviation, and more than 2 minutes of it
# leaves the forecast incomplete rather than approximate.
MATCH_SECONDS = 120
APPROXIMATE_SECONDS = 300
DUE_TOLERANCE_SECONDS = 150 # half a poll: the poll closest to the forecast's due time DUE_TOLERANCE_SECONDS = 150 # half a poll: the poll closest to the forecast's due time
VERDICTS = ("counterfactual", "unverifiable", "missing") VERDICTS = ("matched", "approximate", "counterfactual", "incomplete")
# Why a forecast (or, for a missing reading, a zone's forecast) could not be compared.
INCOMPLETE_REASONS = ("relay_history", "uncertain_inputs", "no_poll", "reading")
LEDGER_VERSION = 2 # 1 (0.4.1) accepted plans within 20 % per 15-minute block
class OperationalForecastLedger: class OperationalForecastLedger:
@@ -383,10 +396,10 @@ class OperationalForecastLedger:
Nothing is replaced with hindsight: the start temperatures, trajectory, boiler Nothing is replaced with hindsight: the start temperatures, trajectory, boiler
plan and assumptions are those of the moment the forecast was made. Because the plan and assumptions are those of the moment the forecast was made. Because the
plan is the MPC's proposal while Generic Thermostat controls the relay, a forecast plan is the MPC's proposal while Generic Thermostat controls the relay, a
is only scored when the relay actually followed the plan up to that horizon; forecast is compared only when the relay actually followed that plan (see
otherwise it is counted as counterfactual. Pending forecasts live in memory; MATCH_SECONDS), and not at all when it was made with an input it had to
the statistics are saved. assume. Pending forecasts live in memory; the statistics are saved.
""" """
def __init__(self, saved: Any = None) -> None: def __init__(self, saved: Any = None) -> None:
@@ -394,8 +407,9 @@ class OperationalForecastLedger:
# (interval end, seconds the relay was on or None if unknown, interval seconds) # (interval end, seconds the relay was on or None if unknown, interval seconds)
self._relay: deque[tuple[float, float | None, float]] = deque(maxlen=48) self._relay: deque[tuple[float, float | None, float]] = deque(maxlen=48)
# UTC day -> horizon -> zone -> [count, sum error, sum |error|, sum error^2, sum |persistence error|] # UTC day -> horizon -> zone -> [count, sum error, sum |error|, sum error^2, sum |persistence error|]
self._days: dict[str, dict[str, dict[str, list[float]]]] = {} self._days: dict[str, dict[str, dict[str, list[float]]]] = {} # matched forecasts
# UTC day -> horizon -> verdict -> count of forecasts not scored, and why self._approximate: dict[str, dict[str, dict[str, list[float]]]] = {}
# UTC day -> horizon -> verdict (incomplete ones as "incomplete:<reason>") -> count
self._verdicts: dict[str, dict[str, dict[str, int]]] = {} self._verdicts: dict[str, dict[str, dict[str, int]]] = {}
self.load(saved) self.load(saved)
@@ -420,20 +434,18 @@ class OperationalForecastLedger:
forecast["done"].add(horizon) forecast["done"].add(horizon)
if index >= len(forecast["plan"]): if index >= len(forecast["plan"]):
continue # beyond this forecast's horizon continue # beyond this forecast's horizon
if now > due + DUE_TOLERANCE_SECONDS: verdict = self._classify(forecast, horizon, now)
self._count(day, horizon, "missing") # no poll near the due time
continue
verdict = self._verify(forecast, horizon)
if verdict:
self._count(day, horizon, verdict) self._count(day, horizon, verdict)
if verdict not in ("matched", "approximate"):
continue continue
store = self._days if verdict == "matched" else self._approximate
for zone, path in forecast["trajectory"].items(): for zone, path in forecast["trajectory"].items():
measured, start = actual.get(zone), forecast["start"].get(zone) measured, start = actual.get(zone), forecast["start"].get(zone)
if measured is None or start is None or len(path) <= index: if measured is None or start is None or len(path) <= index:
self._count(day, horizon, "missing") self._count(day, horizon, "incomplete:reading")
continue continue
error = path[index] - measured error = path[index] - measured
bucket = self._days.setdefault(day, {}).setdefault(str(horizon), {}).setdefault( bucket = store.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(
zone, [0, 0.0, 0.0, 0.0, 0.0]) zone, [0, 0.0, 0.0, 0.0, 0.0])
bucket[0] += 1 bucket[0] += 1
bucket[1] += error bucket[1] += error
@@ -443,36 +455,60 @@ class OperationalForecastLedger:
if len(forecast["done"]) == len(HORIZONS) or now > forecast["time"] + (HORIZONS[-1] + 5) * 60: if len(forecast["done"]) == len(HORIZONS) or now > forecast["time"] + (HORIZONS[-1] + 5) * 60:
self._pending.remove(forecast) self._pending.remove(forecast)
oldest = (datetime.fromtimestamp(now, timezone.utc).date() - timedelta(days=WINDOW_DAYS - 1)).isoformat() oldest = (datetime.fromtimestamp(now, timezone.utc).date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
for store in (self._days, self._verdicts): for store in (self._days, self._approximate, self._verdicts):
for old in [d for d in store if d < oldest]: for old in [d for d in store if d < oldest]:
del store[old] del store[old]
def _verify(self, forecast: dict[str, Any], horizon: int) -> str | None: def _classify(self, forecast: dict[str, Any], horizon: int, now: float) -> str:
"""None when the relay followed the plan up to `horizon`, else the reason.""" if now > forecast["time"] + horizon * 60 + DUE_TOLERANCE_SECONDS:
for block in range(horizon // 15): return "incomplete:no_poll"
start = forecast["time"] + block * 900 if forecast["assumptions"].get("uncertain_inputs"):
end = start + 900 return "incomplete:uncertain_inputs"
on = covered = 0.0 deviation, unobserved = self.deviation(forecast, horizon)
if deviation > APPROXIMATE_SECONDS:
return "counterfactual" # whatever happened in the unobserved part
# Otherwise judged by the worst case: unobserved time may all have deviated.
if deviation + unobserved <= MATCH_SECONDS:
return "matched"
if deviation + unobserved <= APPROXIMATE_SECONDS and unobserved <= MATCH_SECONDS:
return "approximate"
return "incomplete:relay_history"
def deviation(self, forecast: dict[str, Any], horizon: int) -> tuple[float, float]:
"""Seconds up to `horizon` the relay was seen in another state than planned,
and seconds its state is not known.
Compared per 15-minute plan block, from the relay's on-time per poll interval,
itself integrated at the relay's own switch times. So timing counts: the same
total on-time at other times than planned is a deviation, while a switch a few
seconds off a block boundary is not.
"""
start, end = forecast["time"], forecast["time"] + horizon * 60
deviation = covered = unobserved = 0.0
for interval_end, on_seconds, seconds in self._relay: for interval_end, on_seconds, seconds in self._relay:
overlap = min(end, interval_end) - max(start, interval_end - seconds) lo, hi = max(start, interval_end - seconds), min(end, interval_end)
if overlap <= 0: if hi <= lo:
continue continue
covered += hi - lo
if on_seconds is None: if on_seconds is None:
return "unverifiable" unobserved += hi - lo
on += on_seconds * overlap / seconds continue
covered += overlap share = on_seconds / seconds
if covered < 0.8 * 900: t = lo
return "unverifiable" while t < hi: # split at plan block boundaries
if abs(on / covered - forecast["plan"][block]) > PLAN_TOLERANCE: block = min(int((t - start) // 900), len(forecast["plan"]) - 1)
return "counterfactual" part_end = min(hi, start + (block + 1) * 900)
return None deviation += (part_end - t) * (share if not forecast["plan"][block] else 1.0 - share)
t = part_end
return deviation, unobserved + max(0.0, (end - start) - covered)
def _count(self, day: str, horizon: int, verdict: str) -> None: def _count(self, day: str, horizon: int, verdict: str) -> None:
counts = self._verdicts.setdefault(day, {}).setdefault(str(horizon), {}) counts = self._verdicts.setdefault(day, {}).setdefault(str(horizon), {})
counts[verdict] = counts.get(verdict, 0) + 1 counts[verdict] = counts.get(verdict, 0) + 1
def forget(self, zone: str) -> None: def forget(self, zone: str) -> None:
for by_horizon in self._days.values(): for store in (self._days, self._approximate):
for by_horizon in store.values():
for by_zone in by_horizon.values(): for by_zone in by_horizon.values():
by_zone.pop(zone, None) by_zone.pop(zone, None)
for forecast in self._pending: for forecast in self._pending:
@@ -485,58 +521,70 @@ class OperationalForecastLedger:
def summary(self, zones: list[str]) -> dict[int, dict[str, Any]]: def summary(self, zones: list[str]) -> dict[int, dict[str, Any]]:
result = {} result = {}
for horizon in HORIZONS: for horizon in HORIZONS:
per_zone: dict[str, list[float]] = {} per_zone = _sum_by_zone(self._days, horizon, zones)
verdicts = {verdict: 0 for verdict in VERDICTS} approximate = _sum_by_zone(self._approximate, horizon, zones)
for by_horizon in self._days.values(): counts: dict[str, int] = {}
for zone, values in by_horizon.get(str(horizon), {}).items():
if zone in zones:
totals = per_zone.setdefault(zone, [0, 0.0, 0.0, 0.0, 0.0])
for i, value in enumerate(values):
totals[i] += value
for by_horizon in self._verdicts.values(): for by_horizon in self._verdicts.values():
for verdict, count in by_horizon.get(str(horizon), {}).items(): for verdict, count in by_horizon.get(str(horizon), {}).items():
verdicts[verdict] = verdicts.get(verdict, 0) + count counts[verdict] = counts.get(verdict, 0) + count
reasons = {reason: counts.get(f"incomplete:{reason}", 0) for reason in INCOMPLETE_REASONS}
overall = [sum(values[i] for values in per_zone.values()) for i in range(5)] overall = [sum(values[i] for values in per_zone.values()) for i in range(5)]
result[horizon] = {**_operational_stats(overall, horizon, len(per_zone)), **verdicts, approx_overall = [sum(values[i] for values in approximate.values()) for i in range(5)]
"zones": {zone: _operational_stats(values, horizon) result[horizon] = {
for zone, values in per_zone.items()}} **_operational_stats(overall, horizon, len(per_zone)),
# Forecasts by how the relay compared with their plan.
"matched": counts.get("matched", 0), "approximate": counts.get("approximate", 0),
"counterfactual": counts.get("counterfactual", 0), "incomplete": sum(reasons.values()),
"incomplete_reasons": reasons,
"approximate_stats": _operational_stats(approx_overall, horizon, len(approximate)),
"zones": {zone: _operational_stats(values, horizon) for zone, values in per_zone.items()},
}
return result return result
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return {"days": {day: {h: {zone: [v[0], *(round(x, 6) for x in v[1:])] for zone, v in by_zone.items()} def rounded(store):
for h, by_zone in by_horizon.items()} for day, by_horizon in self._days.items()}, return {day: {h: {zone: [v[0], *(round(x, 6) for x in v[1:])] for zone, v in by_zone.items()}
"verdicts": self._verdicts} for h, by_zone in by_horizon.items()} for day, by_horizon in store.items()}
return {"version": LEDGER_VERSION, "days": rounded(self._days),
"approximate": rounded(self._approximate), "verdicts": self._verdicts}
def load(self, data: Any) -> None: def load(self, data: Any) -> None:
self._days, self._verdicts = {}, {} self._days, self._approximate, self._verdicts = {}, {}, {}
if not isinstance(data, dict): # 0.4.1 statistics used a looser plan criterion: they are not comparable.
if not isinstance(data, dict) or data.get("version") != LEDGER_VERSION:
return return
horizons = {str(h) for h in HORIZONS} for name, store in (("days", self._days), ("approximate", self._approximate)):
days = data.get("days") if isinstance(data.get("days"), dict) else {} for day, horizon, zone, values in _entries(data.get(name)):
for day, by_horizon in days.items(): if (isinstance(values, list) and len(values) == 5 and type(values[0]) is int and values[0] >= 0
if not _valid_day(day) or not isinstance(by_horizon, dict):
continue
for horizon, by_zone in by_horizon.items():
if horizon not in horizons or not isinstance(by_zone, dict):
continue
for zone, values in by_zone.items():
if (isinstance(zone, str) and isinstance(values, list) and len(values) == 5
and type(values[0]) is int and values[0] >= 0
and all(type(v) in (int, float) and isfinite(v) for v in values[1:]) and all(type(v) in (int, float) and isfinite(v) for v in values[1:])
and min(values[2:]) >= 0): and min(values[2:]) >= 0):
self._days.setdefault(day, {}).setdefault(horizon, {})[zone] = [ store.setdefault(day, {}).setdefault(horizon, {})[zone] = [
values[0], *(float(v) for v in values[1:])] values[0], *(float(v) for v in values[1:])]
known = {"matched", "approximate", "counterfactual", *(f"incomplete:{r}" for r in INCOMPLETE_REASONS)}
verdicts = data.get("verdicts") if isinstance(data.get("verdicts"), dict) else {} verdicts = data.get("verdicts") if isinstance(data.get("verdicts"), dict) else {}
horizons = {str(h) for h in HORIZONS}
for day, by_horizon in verdicts.items(): for day, by_horizon in verdicts.items():
if not _valid_day(day) or not isinstance(by_horizon, dict): if not _valid_day(day) or not isinstance(by_horizon, dict):
continue continue
for horizon, counts in by_horizon.items(): for horizon, counts in by_horizon.items():
if horizon in horizons and isinstance(counts, dict): if horizon in horizons and isinstance(counts, dict):
clean = {k: v for k, v in counts.items() if k in VERDICTS and type(v) is int and v >= 0} clean = {k: v for k, v in counts.items() if k in known and type(v) is int and v >= 0}
if clean: if clean:
self._verdicts.setdefault(day, {})[horizon] = clean self._verdicts.setdefault(day, {})[horizon] = clean
def _sum_by_zone(store: dict[str, dict[str, dict[str, list[float]]]], horizon: int,
zones: list[str]) -> dict[str, list[float]]:
totals: dict[str, list[float]] = {}
for by_horizon in store.values():
for zone, values in by_horizon.get(str(horizon), {}).items():
if zone in zones:
bucket = totals.setdefault(zone, [0, 0.0, 0.0, 0.0, 0.0])
for i, value in enumerate(values):
bucket[i] += value
return totals
def _valid_day(day: Any) -> bool: def _valid_day(day: Any) -> bool:
try: try:
datetime.strptime(day, "%Y-%m-%d") datetime.strptime(day, "%Y-%m-%d")
+2 -1
View File
@@ -229,7 +229,8 @@ class MPCOperationalForecastError(CoordinatorEntity, SensorEntity):
horizons[f"{horizon}_min"] = {**{k: v for k, v in stats.items() if k != "zones"}, horizons[f"{horizon}_min"] = {**{k: v for k, v in stats.items() if k != "zones"},
"zones": {names.get(z, z): v for z, v in stats.get("zones", {}).items()}} "zones": {names.get(z, z): v for z, v in stats.get("zones", {}).items()}}
return { return {
"kind": "forecast accuracy: published forecasts, scored only where the relay followed the plan", "kind": ("forecast accuracy: published forecasts, scored only where the relay matched the plan "
"(at most 2 minutes in another state); approximate matches (up to 5 minutes) apart"),
"horizons": horizons, "horizons": horizons,
"window_days": WINDOW_DAYS, "window_days": WINDOW_DAYS,
"unit_of_attributes": "°C", "unit_of_attributes": "°C",
+18 -2
View File
@@ -1329,8 +1329,24 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
# Longer horizons are not due yet. # Longer horizons are not due yet.
self.assertEqual(data['operational_forecast'][30]['samples'], 0) self.assertEqual(data['operational_forecast'][30]['samples'], 0)
stored = self.c._payload('sensor.outside')['operational_evaluation'] stored = self.c._payload('sensor.outside')['operational_evaluation']
self.assertEqual(sum(sum(v.get('15', {}).values()) for v in stored['verdicts'].values()), counts = {}
0 if follows else 1) for by_horizon in stored['verdicts'].values():
for name, count in by_horizon.get('15', {}).items():
counts[name] = counts.get(name, 0) + count
self.assertEqual(counts, {'matched': 1} if follows else {'counterfactual': 1})
async def test_a_forecast_made_with_an_unknown_window_is_never_scored(self):
self.ready_office()
self.set_state('sensor.room', '23.5', unit_of_measurement='°C') # plan: off, as the relay
self.states['binary_sensor.office_window'].state = 'unavailable'
first = await self.sample()
self.assertEqual(first['status'], 'ready')
self.assertEqual(self.c._forecasts._pending[-1]['assumptions']['uncertain_inputs'], ['window: Window'])
for _ in range(3):
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
data = await self.sample()
fifteen = data['operational_forecast'][15]
self.assertEqual((fifteen['samples'], fifteen['incomplete_reasons']['uncertain_inputs']), (0, 1))
async def test_unloading_during_save_or_optimization_publishes_nothing_new(self): async def test_unloading_during_save_or_optimization_publishes_nothing_new(self):
self.ready_office() self.ready_office()
+118 -6
View File
@@ -159,7 +159,7 @@ class OperationalForecastTests(unittest.TestCase):
for horizon, actual in ((15, 19.9), (30, 19.8), (60, 19.6), (120, 19.2)): for horizon, actual in ((15, 19.9), (30, 19.8), (60, 19.6), (120, 19.2)):
with self.subTest(horizon=horizon): with self.subTest(horizon=horizon):
s = stats[horizon] s = stats[horizon]
self.assertEqual((s['samples'], s['counterfactual'], s['unverifiable']), (1, 0, 0)) self.assertEqual((s['samples'], s['matched'], s['counterfactual'], s['incomplete']), (1, 1, 0, 0))
self.assertAlmostEqual(s['mae'], 0, places=3) self.assertAlmostEqual(s['mae'], 0, places=3)
self.assertAlmostEqual(s['persistence_mae'], 20.0 - actual, places=3) self.assertAlmostEqual(s['persistence_mae'], 20.0 - actual, places=3)
self.assertEqual(s['improvement_vs_persistence'], 1.0) self.assertEqual(s['improvement_vs_persistence'], 1.0)
@@ -179,12 +179,14 @@ class OperationalForecastTests(unittest.TestCase):
def test_unknown_relay_history_or_missing_observations(self): def test_unknown_relay_history_or_missing_observations(self):
stats = run_ledger(self.PLAN_OFF, lambda k: False, lambda k: 20.0, self.TRAJECTORY, unknown_relay_at=2) stats = run_ledger(self.PLAN_OFF, lambda k: False, lambda k: 20.0, self.TRAJECTORY, unknown_relay_at=2)
self.assertEqual((stats[15]['samples'], stats[15]['unverifiable'], stats[60]['unverifiable']), (0, 1, 1)) self.assertEqual((stats[15]['samples'], stats[15]['incomplete_reasons']['relay_history'],
stats[60]['incomplete']), (0, 1, 1))
missing = run_ledger(self.PLAN_OFF, lambda k: False, lambda k: None if k == 12 else 20.0, self.TRAJECTORY) missing = run_ledger(self.PLAN_OFF, lambda k: False, lambda k: None if k == 12 else 20.0, self.TRAJECTORY)
self.assertEqual((missing[60]['samples'], missing[60]['missing'], missing[30]['samples']), (0, 1, 1)) self.assertEqual((missing[60]['samples'], missing[60]['incomplete_reasons']['reading'],
missing[30]['samples']), (0, 1, 1))
# No poll near the due time (polls 11 to 13 missing): the 60-minute point cannot be scored. # No poll near the due time (polls 11 to 13 missing): the 60-minute point cannot be scored.
gap = run_ledger(self.PLAN_OFF, lambda k: False, lambda k: 20.0, self.TRAJECTORY, skip=(11, 12, 13)) gap = run_ledger(self.PLAN_OFF, lambda k: False, lambda k: 20.0, self.TRAJECTORY, skip=(11, 12, 13))
self.assertEqual((gap[60]['samples'], gap[60]['missing']), (0, 1)) self.assertEqual((gap[60]['samples'], gap[60]['incomplete_reasons']['no_poll']), (0, 1))
def test_due_time_alignment(self): def test_due_time_alignment(self):
ledger = ev.OperationalForecastLedger() ledger = ev.OperationalForecastLedger()
@@ -207,8 +209,13 @@ class OperationalForecastTests(unittest.TestCase):
self.assertEqual(restored.summary(['r'])[15]['counterfactual'], 1) self.assertEqual(restored.summary(['r'])[15]['counterfactual'], 1)
restored.forget('r') restored.forget('r')
self.assertEqual(restored.summary(['r'])[15]['samples'], 0) self.assertEqual(restored.summary(['r'])[15]['samples'], 0)
for garbage in (None, [], {'days': {'x': {}}, 'verdicts': {'2026-10-27': {'15': {'bogus': 3}}}}): empty = {'version': 2, 'days': {}, 'approximate': {}, 'verdicts': {}}
self.assertEqual(ev.OperationalForecastLedger(garbage).as_dict(), {'days': {}, 'verdicts': {}}) for garbage in (None, [], {'version': 2, 'days': {'x': {}}, 'verdicts': {'2026-10-27': {'15': {'bogus': 3}}}}):
self.assertEqual(ev.OperationalForecastLedger(garbage).as_dict(), empty)
# 0.4.1 statistics accepted plans within 20 % per block: not comparable, dropped.
old = {**stats_ledger.as_dict(), 'version': None}
old.pop('version')
self.assertEqual(ev.OperationalForecastLedger(old).as_dict(), empty)
if __name__ == '__main__': if __name__ == '__main__':
@@ -299,3 +306,108 @@ class IncompleteInputTests(unittest.TestCase):
stats = ventilated([0.0] * 5, missing_end_at=(4,), zones=('a', 'b')) stats = ventilated([0.0] * 5, missing_end_at=(4,), zones=('a', 'b'))
self.assertEqual((stats['rooms']['a']['samples'], stats['rooms']['a']['skipped']), (1, 1)) self.assertEqual((stats['rooms']['a']['samples'], stats['rooms']['a']['skipped']), (1, 1))
self.assertEqual(stats['rooms']['b']['samples'], 2) self.assertEqual(stats['rooms']['b']['samples'], 2)
def ledger_with(plan, on_seconds, temps=None, uncertain=None, polls=None):
"""One forecast at T0; `on_seconds[k-1]` is the relay's on-time in the interval ending
at poll k (None: unknown). Returns the ledger."""
ledger = ev.OperationalForecastLedger()
trajectory = [20.0 + 0.1 * (b + 1) for b in range(len(plan))]
ledger.observe(T0, {'r': 20.0}, None, None)
ledger.record(T0, {'r': 20.0}, {'r': trajectory}, plan, 'rev1',
{'strategy': 'mpc_plan', 'uncertain_inputs': uncertain or []})
for k in range(1, (polls or len(on_seconds)) + 1):
on = on_seconds[k - 1] if k - 1 < len(on_seconds) else 0.0
ledger.observe(T0 + 300 * k, {'r': (temps or (lambda k: 20.0 + k / 30))(k)}, on, 300.0)
return ledger
def verdict(ledger, horizon):
stats = ledger.summary(['r'])[horizon]
for name in ('matched', 'approximate', 'counterfactual'):
if stats[name]:
return name
return next(f'incomplete:{r}' for r, n in stats['incomplete_reasons'].items() if n)
class PlanMatchTests(unittest.TestCase):
"""Forecasts are compared with reality only when the relay did what their plan assumed."""
ON, OFF = 300.0, 0.0
def test_exact_match_is_scored(self):
ledger = ledger_with([1, 1, 0, 0], [self.ON] * 6 + [self.OFF] * 6)
self.assertEqual([verdict(ledger, h) for h in (15, 30, 60)], ['matched'] * 3)
self.assertEqual(ledger.deviation({'time': T0, 'plan': [1, 1, 0, 0]}, 60), (0.0, 0.0))
stats = ledger.summary(['r'])[60]
self.assertEqual((stats['samples'], stats['approximate_stats']['samples']), (1, 0))
def test_small_transition_jitter_still_matches(self):
# Planned off at 30 minutes; the relay switched off 40 s later.
ledger = ledger_with([1, 1, 0, 0], [self.ON] * 6 + [40.0] + [self.OFF] * 5)
self.assertEqual(ledger.deviation({'time': T0, 'plan': [1, 1, 0, 0]}, 60), (40.0, 0.0))
self.assertEqual(verdict(ledger, 60), 'matched')
def test_twelve_of_fifteen_planned_minutes_is_only_approximate(self):
ledger = ledger_with([1, 0], [self.ON, self.ON, 120.0, self.OFF, self.OFF, self.OFF])
self.assertEqual(verdict(ledger, 15), 'approximate')
stats = ledger.summary(['r'])[15]
# Not in the main figures; kept apart.
self.assertEqual((stats['samples'], stats['mae'], stats['approximate']), (0, None, 1))
self.assertEqual(stats['approximate_stats']['samples'], 1)
def test_same_on_time_at_another_time_is_counterfactual(self):
# 15 minutes on as planned, but in the second block instead of the first.
ledger = ledger_with([1, 0, 0, 0], [self.OFF] * 3 + [self.ON] * 3 + [self.OFF] * 6)
self.assertEqual(ledger.deviation({'time': T0, 'plan': [1, 0, 0, 0]}, 30), (1800.0, 0.0))
self.assertEqual([verdict(ledger, h) for h in (15, 30, 60)], ['counterfactual'] * 3)
def test_early_shutdown_and_unexpected_activation(self):
early = ledger_with([1, 1, 1, 1], [self.ON] * 4 + [self.OFF] * 8) # off after 20 of 60 minutes
self.assertEqual([verdict(early, h) for h in (15, 30, 60)], ['matched', 'counterfactual', 'counterfactual'])
brief = ledger_with([0, 0, 0, 0], [self.OFF] * 4 + [240.0] + [self.OFF] * 7) # 4 minutes on
self.assertEqual([verdict(brief, h) for h in (15, 30)], ['matched', 'approximate'])
long = ledger_with([0, 0, 0, 0], [self.OFF] * 4 + [self.ON, self.ON] + [self.OFF] * 6)
self.assertEqual(verdict(long, 30), 'counterfactual')
def test_several_cycles_followed_closely_match(self):
plan = [1, 0, 1, 0, 1, 0, 1, 0]
relay = []
for block in plan:
relay += [self.ON] * 3 if block else [30.0, self.OFF, self.OFF] # each off 30 s late
ledger = ledger_with(plan, relay)
self.assertEqual(ledger.deviation({'time': T0, 'plan': plan}, 120)[0], 120.0) # four transitions
self.assertEqual(verdict(ledger, 60), 'matched')
self.assertEqual(verdict(ledger, 120), 'matched')
def test_missing_relay_history_is_incomplete_not_matched(self):
ledger = ledger_with([0, 0, 0, 0], [self.OFF, None] + [self.OFF] * 10)
self.assertEqual([verdict(ledger, h) for h in (15, 60)], ['incomplete:relay_history'] * 2)
# A plan already contradicted is counterfactual whatever the gap hides.
ledger = ledger_with([1, 1, 1, 1], [None] + [self.OFF] * 11)
self.assertEqual(verdict(ledger, 30), 'counterfactual')
def test_thermostat_disagreeing_is_counterfactual_never_an_error(self):
ledger = ledger_with([1] * 4, [self.OFF] * 12, temps=lambda k: 20.0)
stats = ledger.summary(['r'])
self.assertEqual([stats[h]['counterfactual'] for h in (15, 30, 60)], [1, 1, 1])
self.assertEqual([stats[h]['samples'] for h in (15, 30, 60)], [0, 0, 0])
def test_forecasts_made_with_assumed_inputs_are_incomplete(self):
ledger = ledger_with([0] * 4, [self.OFF] * 12, uncertain=['window: Bedroom'])
self.assertEqual(verdict(ledger, 15), 'incomplete:uncertain_inputs')
self.assertEqual(ledger.summary(['r'])[15]['samples'], 0)
def test_counts_over_several_forecasts(self):
ledger = ev.OperationalForecastLedger()
ledger.observe(T0, {'r': 20.0}, None, None)
for k, plan in enumerate(([0, 0], [1, 1], [0, 0])): # one forecast per poll, relay always off
ledger.record(T0 + 300 * k, {'r': 20.0}, {'r': [20.0, 20.0]}, plan, 'rev1', {})
ledger.observe(T0 + 300 * (k + 1), {'r': 20.0}, 0.0, 300.0)
for k in range(4, 12):
ledger.observe(T0 + 300 * k, {'r': 20.0}, 0.0, 300.0)
stats = ledger.summary(['r'])[15]
self.assertEqual((stats['matched'], stats['counterfactual'], stats['samples'], stats['incomplete']),
(2, 1, 2, 0))
restored = ev.OperationalForecastLedger(ledger.as_dict())
self.assertEqual(restored.summary(['r']), ledger.summary(['r']))