Compare commits

...
17 Commits
Author SHA1 Message Date
buzeraandClaude Opus 5.5 2d027f367a Release 0.4.2
Tests / tests (requirements-test-latest.txt) (push) Successful in 1m22s
Tests / tests (requirements-test.txt) (push) Successful in 1m26s
Final stability fixes after 0.4.1: Model Error leaves out spans with
unknown inputs; window sensors unknown at startup stay unknown, with last
known states restored as unknown across restarts; unload, reload and
removal can no longer publish stale results or recreate a deleted model;
Forecast Error scores only forecasts whose plan the relay matched within
2 minutes. Adds Reset learned model to Configure. Still observation-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:56:28 +02:00
buzeraandClaude Opus 5.5 c4035951df Document the final stability fixes and the model reset
README: window states and forecast assumptions, Model Error incomplete and
skipped spans, Forecast Error classes and why 2 and 5 minutes, and the
lifecycle tests. docs/MODEL.md: strict inputs, unknown-window assumptions,
the plan-matching table. CHANGELOG gains an Unreleased section (version
unchanged); ROADMAP and AUDIT record the findings, root causes, evidence and
remaining limitations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:54:19 +02:00
buzeraandClaude Opus 5.5 0b82a8e26c 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>
2026-10-27 04:50:58 +02:00
buzeraandClaude Opus 5.5 2cfba024ff Keep spans with unknown inputs out of Model Error
Reproduced: a window unknown for a whole 15-minute span was replayed as
closed and scored as a Model Error sample (MAE 0.76 °C on one sample).

- A zone's replay is complete only if every step's model inputs were
  observed: window openings (and a confirmed recovery), the wind, direction
  and other zones' windows that an open window's terms use, and, for the
  extended layer, electrical heat and neighbours, whose own replay must be
  complete too.
- Incomplete spans are counted, not scored; the base model's error on them
  is kept separately (it needs only the relay and outdoor temperature).
  Spans that could not be replayed (uncertain radiator lag, no reading at
  the end) are counted as skipped. Model Error sensors show incomplete,
  skipped and incomplete_base_model_mae; stored data gains an "excluded"
  section and older data loads unchanged.
- Window inputs no longer stand in for unknown values: the facing term is
  unknown while a window is open and the wind direction is not, and the
  cross-ventilation input is unknown while another zone's window is. With
  every window closed, opening terms are 0 and need no weather input.
  Learning refuses such samples; forecasts still assume calm air.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:46:21 +02:00
buzeraandClaude Opus 5.5 13cb972566 Treat window states unknown at startup as unknown, and continue across restarts
Reproduced: with a window sensor already unavailable at startup, the
forecast assumed the window closed, exactly as for a window last seen
closed, and the first reading of an already open window was logged as a
confirmed opening.

- An opening or closing is confirmed only when witnessed: the previous
  reading was the other state with no unknown period between. A first
  reading, or a change across an outage or restart, is unconfirmed.
- Last known states, open events and recovery are saved (also whenever a
  window's state changes) and restored as unknown until the sensor
  reports; saved state for another sensor or polarity is ignored. Downtime
  counts as unknown time of an interrupted event.
- Forecast assumption per window: observed; last seen open, assumed open
  only until the configured open time after it was last seen (never
  renewed); last seen closed or never observed, no ventilation but marked
  uncertain. Status shows each window's assumption; zone forecasts list
  their uncertain windows.
- Events report a duration only when both ends are confirmed, otherwise a
  minimum (and a maximum when the opening was seen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:41:28 +02:00
buzeraandClaude Opus 5.5 49565d610a 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>
2026-10-27 04:29:03 +02:00
buzeraandClaude Opus 5.5 28ff953ddd Stop publishing and saving from the start of unload; keep deletions final
Home Assistant marks a coordinator shut down only after the platforms are
unloaded, and the model save checked for shutdown before waiting for the
storage lock. A deletion could therefore be followed by a save that had
been waiting for it, recreating the file. Also, unloading cancels a running
scheduled update, which released the lock while the file write continued
in a worker thread.

- The coordinator is marked closing before platforms unload (restored if
  unloading fails); closing updates, relay callbacks and saves do nothing.
- The save checks closing and a removed-model mark after acquiring the
  lock, and runs shielded so the lock is held until the write finishes.
- Removal marks the model removed under the lock before deleting it.
- A failed setup also shuts the coordinator down.

Tests: unload and reload during a running optimization, removal during a
save, a save waiting for a deletion, simultaneous saves, repeated
setup/unload, shutdown ordering and failed setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:27:07 +02:00
buzeraandClaude Opus 5.5 87437cbd48 Release 0.4.1
Tests / tests (requirements-test-latest.txt) (push) Successful in 1m15s
Tests / tests (requirements-test.txt) (push) Successful in 1m25s
Stability and correctness after 0.4.0: unknown window states are no longer
read as closed; learned coefficients are reset selectively when the
configuration they were learned under changes; plans are discarded when an
input changed during optimization, and an overtaken run never publishes;
model accuracy (Model Error) is separated from the accuracy of published
forecasts (new Forecast Error sensor). Base coefficients report confidence;
sensor weights are labelled in the zone form. Still observation-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:57:41 +02:00
buzeraandClaude Opus 5.5 35370958fc Document the stabilization pass
README: window unknown states, what configuration changes do to the learned
model, inputs_changed tolerances, coefficient confidence, model_changes, and
Model Error vs Forecast Error. docs/MODEL.md: configuration-change table,
confidence semantics, both validation modes. CHANGELOG gains an Unreleased
section (version unchanged); AUDIT and ROADMAP record findings, evidence and
remaining risks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:55:06 +02:00
buzeraandClaude Opus 5.5 8eef04c385 Reset neighbours' cross-ventilation when a window's polarity flips
A flipped contact sensor reverses what every term saw as open, including the
other zones' cross-ventilation term, whose fingerprint covered only the
window's sensor and opening factor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:53:39 +02:00
buzeraandClaude Opus 5.5 4911b32c5e Test that reloads leave one relay listener and one polling coordinator
Three reloads in a row: a relay change reaches only the current coordinator,
replaced coordinators neither learn nor publish, and after unload nothing is
recorded or published. Checked to fail when the listener is not unsubscribed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:50:29 +02:00
buzeraandClaude Opus 5.5 a44163a57d Report how well the base coefficients are identified
The base layer (loss, heat, bias) showed values only, so a zone with weeks of
samples looked equally trustworthy whether or not the data ever varied its
inputs. Each base coefficient now reports a confidence from its own posterior
variance, as the extended and ventilation layers already did: it stays at 0
for `heat` while the relay never cycles and below 0.8 for `loss` and `bias`
while the indoor-outdoor difference is steady. Readiness is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:49:13 +02:00
buzeraandClaude Opus 5.5 26c415fa6c Separate model accuracy from forecast accuracy
The Forecast Error sensors replayed the model with the relay, electrical heat
and window history that actually followed, so they measured the model, not a
forecast anyone could have made. They are renamed Model Error (unique IDs
unchanged) and gain RMSE, effective sample count and a quality label; their
state stays unknown until there are at least 5 roughly independent spans.

A new Forecast Error sensor scores forecasts as published, with the plan and
assumptions of the moment they were made. A forecast is scored only when the
relay followed its plan; otherwise it is counted as counterfactual, not as
error. Statistics are saved with the model and dropped with a zone's
recalibration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:47:16 +02:00
buzeraandClaude Opus 5.5 d73e32a4b9 Never publish a plan computed from inputs that have since changed
Confirmed: the check run after the optimizer compared only zone sensors,
outdoor temperature, thermostat and relay. A window opening, a UPS load
change or a wind change during the calculation went unnoticed; nothing
prevented an older update that finished late from overwriting a newer
result; and an update in flight during unload still saved.

- Every input a prediction uses is captured as a normalized snapshot
  (zone and outdoor temperatures, wind speed/direction/gust, heat-source
  power, window states, relay state and switch time, thermostat mode and
  target) and compared after the calculation with tolerances (0.25 °C,
  50 W, 1 m/s, 2 m/s gusts, 20° direction around the circle; exact for
  relay, thermostat and windows). A meaningful change discards the plan
  and lists the inputs in changed_inputs; noise does not. No immediate
  recalculation: an off-schedule poll would break the 5-minute learning
  interval; the next regular poll computes a fresh plan.
- Updates are numbered; one overtaken by a newer update, or running when
  the entry unloads, keeps the newer data and skips saving and
  optimizing. Configuration changes reload the entry, so they are covered.
- A per-entry storage lock makes a reloaded coordinator restore only
  after the previous one's save finished, and integration removal waits
  for it too, so a late save cannot recreate a deleted model file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:37:51 +02:00
buzeraandClaude Opus 5.5 099c63cd02 Recalibrate only the model parts whose configuration changed
Confirmed: learned models were matched only by zone key, so replacing a
zone's sensors, changing sensor weights, moving or replacing a heat
source or turning a window kept coefficients learned for a different
configuration, silently.

compatibility.py fingerprints exactly what each learned component
depends on: the zone measurement (sensors and relative weights; equal
scaling changes nothing), each zone's heat sources, the neighbour's
measurement, the zone's windows, window orientation (direction term
only), the recovery time (recovery only) and other zones' openings
(cross term only). Comfort weight, target offset and names are not
included. Heat-source baselines have their own fingerprint (sensor and
included sources).

On restore, a changed measurement recalibrates the zone: base and
extended loss/heat/bias keep their values as a warm start, but their
uncertainty, counts and readiness start over, other terms return to the
prior, and the zone's forecast statistics are dropped. Otherwise only
the changed terms are reset; everything else, including other zones,
is kept. Each recalibration is logged (Status model_changes, info log)
and saved at once. Data from before fingerprints (0.4.0) is assumed to
match its current configuration and is fingerprinted at the first
start, so the next edit is detected even before a learned sample.

Tests cover comfort changes, sensor replacement and weights, window
orientation and size, moved and replaced heat sources, re-added zones,
0.4.0 data and a second restart, plus an HA Core test through real
subentry updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:35:11 +02:00
buzeraandClaude Opus 5.5 9bb64e79ca Make per-sensor weights visible and labelled in the zone form
Sensor weights already existed, but only as a second step after saving
a zone with two or more sensors, with fields labelled by raw keys such
as weight__sensor.living_a, so they were easy to miss.

The zone form now says that choosing two or more sensors leads to a
sensor-weight step. That step uses numbered fields ("Sensor 1 weight",
English and Romanian) and its description lists which sensor is which
by friendly name. Editing a multi-sensor zone shows the saved weights.
Stored data is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:29:29 +02:00
buzeraandClaude Opus 5.5 2bddc74d37 Keep unknown window states from passing as closed
Confirmed: when an open window's contact sensor became unknown or
unavailable, the tracker ended the ventilation event as a close at the
poll time, started recovery at full weight and forecasts assumed the
window closed. (Learning was already excluded for that zone.)

Each window is now OPEN, CLOSED or UNKNOWN with its last known state,
since when, and since when it is unknown:

- an unknown sensor keeps an open window's event (and the zone's
  "open" status) going; forecasts use the last known state;
- a close observed only after an outage is recorded as unconfirmed:
  closed=None with the reported time and a maximum duration; recovery
  is assumed from that report but not used for any learning;
- opening seen only after an outage starts an event without a
  temperature-before, so its recovery is not learned either;
- events count minutes with an unknown sensor; Status shows each
  window's last known state and unknown minutes;
- one unknown sensor only blocks learning for its own zone.

Predictions with an unavailable wind sensor now keep an open window's
natural ventilation (calm air) instead of dropping the term; learning
still refuses unknown inputs.

Regression tests for every OPEN/CLOSED/UNKNOWN transition, a six-hour
outage, multiple windows with one unknown, and missing wind during
ventilation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:28:06 +02:00
30 changed files with 3354 additions and 241 deletions

No files matched your search

+147
View File
@@ -320,3 +320,150 @@ weather forecasts. Learners of removed zones keep only the base model.
**Verification.** `sh scripts/check.sh` passes in both environments (lint, 125 **Verification.** `sh scripts/check.sh` passes in both environments (lint, 125
offline tests, 26 HA Core tests, compilation, package/translation/changelog checks). offline tests, 26 HA Core tests, compilation, package/translation/changelog checks).
The offline tests also pass on Python 3.9.6. The offline tests also pass on Python 3.9.6.
## 0.4.1 — stabilization after 0.4.0 — 2026-10-27
Scope: correctness of 0.4.0, no new features. Repository code
and tests only (HA Core 2025.12.0 and 2026.2.3 harness); no HAOS, device or
deployment testing. Still no actuator code; the static and runtime service-call
guards pass.
Confirmed issues and fixes:
1. **Unknown window states read as closed** (2bddc74). Reproduced: an open window
whose sensor became unavailable ended the ventilation event, started recovery
at full weight and let the forecast assume a closed window. Windows are now
tri-state with a remembered last known state: last seen open → possibly open
(base and extended layers do not learn, the forecast assumes open); a close
while unknown is logged as unconfirmed with its longest possible duration;
recovery is learned only after a confirmed close. Seven transition tests
(open/closed/unknown in every order, restart while unknown, polarity).
2. **Learned coefficients reused after incompatible changes** (099c63c, 8eef04c).
By construction: changing a zone's sensors or weight ratio, a heat source, a
window or the recovery time kept every coefficient. New
`compatibility.py` fingerprints each component's configuration; on restore
only changed components are reset (a measurement change recalibrates the zone:
coefficients kept as a starting point, uncertainty, counts and accuracy
statistics reset). Comfort weight, target offset and names never invalidate.
Heat-source baseline samples are dropped when the effective power's definition
changes. 0.4.0 data has no fingerprints, is assumed compatible, and is
fingerprinted at the first start. Resets are logged in `model_changes`.
3. **Plans published from changed inputs** (d73e32a). The pre/post-optimization
check covered only temperatures, relay and thermostat, and an overtaken update
could still publish. The input snapshot now covers zone and outdoor
temperatures, electrical power, wind, windows, relay and thermostat, with
tolerances so sensor noise does not discard every plan; a run counter keeps an
older run from overwriting a newer result; shutdown is checked after saving
and after the worker; a per-entry storage lock serializes save, restore and
delete (also fixing a delete-during-save race that could recreate the file).
4. **Model accuracy reported as forecast accuracy** (26c415f). The Forecast Error
sensors replayed the model with the actual future relay, electrical heat and
windows. They are now **Model Error** (unique IDs unchanged) with RMSE,
effective samples and a quality label, and their state stays unknown below 5
effective samples. A new **Forecast Error** sensor scores published forecasts
with the plan and assumptions of the moment, only when the relay followed the
plan (≥ 12 of every 15 minutes in the planned state); otherwise the forecast is
counted as counterfactual, unverifiable or missing, never as error.
Also reviewed:
- **Confidence** (a44163a). Extended and ventilation coefficients already used
posterior variance. Base coefficients now report it too; a test shows two weeks
of non-exciting data (relay off, steady difference) keep `heat` at 0 and `loss`
and `bias` below 0.8, while four varied days bring all three above 0.8.
Readiness still needs sample counts; it is a gate, not a confidence.
- **Lifecycle** (4911b32). Three reloads leave one relay listener and one polling
coordinator; after unload nothing is recorded or published. The test was
checked to fail with the unsubscribe removed.
- **Sensor weights UI** (9bb64e7, user report). Per-sensor weights existed but
were an unlabelled second step; they are now numbered fields with the sensors
listed, in English and Romanian.
Remaining risks:
- In observation mode the relay follows the MPC plan mainly when Generic
Thermostat happens to agree, so Forecast Error may stay `insufficient` for a
long time, and the scored subset is biased towards those periods.
- `effective_samples` corrects for overlapping horizons and shared zones only; it
is not an autocorrelation estimate. Quality thresholds (5, 30) are judgement.
- Configuration changes made before this update cannot be detected: 0.4.0 models
are fingerprinted as they are. A sensor replaced under the same entity ID, or a
weather provider swap with a systematic offset, does not change any fingerprint.
- Input-change tolerances are fixed constants, not options.
- Published forecasts awaiting their horizon are lost on restart.
- None of this has run on HAOS or with real devices.
**Verification.** In both environments (HA 2025.12.0 and 2026.2.3, Python 3.13):
ruff, 159 offline tests, 28 HA Core tests; the offline tests also pass on Python
3.9.6. `sh scripts/check.sh` additionally validates packaging, translations and
the changelog.
## 0.4.2 — final stability fixes after 0.4.1 — 2026-10-27
Scope: four issues from an independent review of 0.4.1, plus a user-requested
*Reset learned model* action. Repository code and tests only (HA Core 2025.12.0
and 2026.2.3 harness); no HAOS, device or deployment testing. Still no actuator
code; the static and runtime service-call guards pass. Released as 0.4.2.
Confirmed issues, root causes and fixes:
1. **Model Error scored spans with unknown ventilation inputs** (2cfba02).
Reproduced: a window unknown for a whole 15-minute span gave a Model Error
sample (MAE 0.76 °C). Root cause: the replay used the prediction path, which
drops the ventilation term when the opening is unknown, i.e. assumes closed.
Fix: per zone and step, `inputs_complete` checks every input the zone's model
used (window opening and confirmed recovery; wind, direction and other zones'
windows while a window is open; electrical heat and neighbours for the extended
layer, including the neighbour's own completeness). Incomplete spans are
counted, not scored, with the base model's error kept apart; unreplayable
spans are counted as skipped. Learning also stopped substituting a zero facing
term or zero cross-ventilation input for unknown values.
2. **Window unknown at startup treated as closed** (13cb972). Reproduced
(0 % assumed opening). Root cause: last known states lived only in memory, and
"never observed" fell through to the closed case; a first reading of an open
window was also logged as a confirmed opening. Fix: confirmation only for
witnessed transitions; last known states, open events and recovery saved (also
on every window state change) and restored as unknown; per-window forecast
assumptions (observed / last known open, bounded by the open-window time after
it was last seen / last known closed / unobserved), exposed in Status and on
zone forecasts; durations as ranges when an end was not seen. Chosen over
open/closed scenario pairs to avoid a second plan simulation per poll.
3. **Lifecycle and storage races** (28ff953). Confirmed by reading HA's unload
order: the coordinator's own shutdown runs only after platforms unload, and the
save checked for shutdown before waiting for the storage lock (check-then-act).
Found during testing: unloading cancels a running scheduled update, which
released the lock while the file write continued in a worker thread, so a
deletion could run first and be undone. Fix: closing mark at the start of
unload (restored if unload fails); checks after acquiring the lock, including a
removed-model mark set under the lock by removal; the write runs as a shielded,
HA-tracked task that holds the lock until it finishes; failed setup shuts the
coordinator down; queued relay callbacks are ignored once closing. Mutation
checks: removing the locked check or the shield makes the deletion tests fail;
removing the early closing mark fails the ordering test (the HA-level unload
test cannot distinguish it, as HA's own shutdown also lands before release).
4. **Forecast Error accepted materially different relay behaviour** (0b82a8e).
Reproduced: 12 of 15 planned minutes was scored. Root cause: a per-block
on-share tolerance of 20 %, blind to timing within a block. Fix: deviation =
time in another state than planned, block by block from the relay's switch
times, with unobserved time as possible deviation; matched ≤ 2 min (scored),
approximate ≤ 5 min (separate), counterfactual > 5 min observed, otherwise
incomplete with a reason; forecasts made with assumed inputs are incomplete.
Ledger statistics are versioned; 0.4.1 figures are dropped.
Added: *Reset learned model* (49565d6): unload, strip learned keys from the
stored model under the lock, set up again; configuration, heat-source baselines
and window history kept.
Tests added (by area): Model Error incomplete/skipped (7), plan matching (10),
window startup/restart/mixed/strict inputs (9), coordinator window reload and
uncertain forecast (2), lifecycle offline (5) and in HA (4), reset flow (1).
Remaining limitations: unknown-but-last-closed and never-seen windows still
produce a closed-window forecast (flagged, never scored); the 2 and 5 minute
limits rest on typical learned heat coefficients, not a per-forecast calculation;
spans lost when the replay window restarts are not counted; saved window state can
be up to one save old, which only makes the restored unknown period longer.
**Verification.** `sh scripts/check.sh` passes in both environments (pip check,
ruff, 192 offline tests, 33 HA Core tests, compilation, package/translation/
changelog checks, diff whitespace); the offline tests also pass on Python 3.9.6.
+147
View File
@@ -2,6 +2,153 @@
Engineering findings and test evidence behind each change are in [AUDIT.md](AUDIT.md). Engineering findings and test evidence behind each change are in [AUDIT.md](AUDIT.md).
## 0.4.2 — 2026-10-27
Four correctness fixes from an independent review of 0.4.1, and a **Reset learned
model** action. Still **observation-only**: no service calls; the boiler and
thermostat are untouched.
Tested in the Home Assistant Core harness with HA 2025.12.0 and 2026.2.3; not on
HAOS.
### Upgrade notes
- No reconfiguration needed. Zones, sensor and comfort weights, weather sources,
heat sources, windows, learned coefficients and fingerprints carry over; entity
IDs are unchanged.
- **Configure now opens a menu**: *Settings* (the previous form) or *Reset learned
model*.
- **Forecast Error statistics restart.** 0.4.1 accepted plans the relay followed
within 20 % per block; those figures are not comparable with the new rule and
are dropped on the first start (they covered at most a day).
- Model Error statistics carry over. Those from 0.4.0/0.4.1 may include a few
spans with unknown window states; they age out within 7 days.
### Added
- **Reset learned model** (Configure, with a confirmation): forgets every zone's
learned coefficients, models kept for removed zones, and the Model Error and
Forecast Error statistics; keeps the configuration, heat-source baselines and the
window event log, and logs the reset in `model_changes`.
### Fixed
- **Model Error no longer scores spans with unknown inputs.** A window unknown
during a span was replayed as closed and scored. Spans where a window state, an
open window's weather input, or the extended layer's electrical heat or
neighbour was unknown are now counted as `incomplete` (with the base model's
error kept apart as `incomplete_base_model_mae`); spans that cannot be replayed
are counted as `skipped`.
- **Window sensors unavailable at startup are unknown, not closed.** Last known
states, open events and recovery are saved and restored as unknown until the
sensor reports; openings and closings count as confirmed only when seen; an
unknown window last seen open is assumed open only until the open-window time
after it was last seen; other unknown windows are flagged as uncertain in
forecasts (`uncertain_windows`) and in Status (`forecast_assumption`). Event
durations are given as a range when an end was not seen.
- **Unload, reload and removal cannot publish or save stale work.** The
coordinator is marked closing before platforms unload; the model save checks
this, and a removed-model mark, after taking the storage lock and holds the lock
until the file write finishes, so a deletion can no longer be undone by a save
that was waiting or cancelled mid-write. A failed setup shuts the coordinator
down; relay callbacks queued before unload are ignored.
- **Forecast Error compares the relay's actual timing with the plan.** The
deviation is the time the relay spent in another state than planned (unobserved
time counts as possible deviation). Up to 2 minutes: matched and scored; up to 5:
approximate, scored separately; more: counterfactual; otherwise incomplete, with
a reason (relay history, assumed inputs, no poll at the due time, missing
reading). Forecasts made with an unknown window or electrical heat are never
scored.
### Changed
- Learning no longer substitutes values for an open window: samples where the
wind direction (for the directional term) or another zone's windows (for
cross-ventilation) were unknown are not learned. With every window closed, the
opening terms need no weather input, so recovery can be learned without wind.
- The gust option's hint refers to Model Error.
### Persisted data
- `schema_version` stays 2. `ventilation` gains `state` (last known window
states, open events, recovery); `evaluation` gains `excluded` (incomplete and
skipped spans); `operational_evaluation` gains `version` 2 and `approximate`.
Missing optional keys load as empty.
## 0.4.1 — 2026-10-27
Stability and correctness: fixes four problems found in 0.4.0 and separates model
accuracy from forecast accuracy. No new product features. Still
**observation-only**. Tested in the Home Assistant Core harness
with HA 2025.12.0 and 2026.2.3; not yet on HAOS.
### Upgrade notes
- **Forecast Error 15/30/60/120 min are renamed Model Error.** Unique IDs and,
on existing installs, entity IDs are unchanged, so history continues. Their
state now stays *unknown* until there are at least 5 roughly independent spans
(`quality` is `insufficient`); the numbers stay in the attributes meanwhile.
- **A new Heating MPC Forecast Error sensor** scores forecasts as they were
published. It stays unknown until the relay has followed enough MPC plans; while
Generic Thermostat disagrees with the plan, most forecasts are counted as
`counterfactual` instead of being scored.
- On the first start, the saved model gets configuration fingerprints. Models
learned under 0.4.0 are assumed to match the configuration they are loaded with;
later changes are checked against these fingerprints.
- Zone forecast attribute `coefficients.base` now gives `value` and `confidence`
per coefficient instead of a bare value.
- Downgrading to 0.4.0 keeps the learned models but drops the Model Error
statistics (0.4.0 rejects their new format).
### Fixed
- **Unknown window states no longer pass as closed.** An unavailable window
sensor used to end an open-window event, start recovery and let the forecast
assume a closed window. Now the last known state is kept: a window last seen
open is treated as possibly open (no base learning, forecast assumes open), an
event closed while the sensor was unknown is marked unconfirmed with a bounded
duration, and recovery is learned only after a confirmed close.
- **Learned parameters are no longer reused after incompatible changes.** Each
learned component has a fingerprint of the configuration it was learned under
(see *Persisted data*). Changing a zone's sensors or the ratio of their weights
recalibrates that zone; changing a heat source, neighbour, window, orientation
or recovery time resets only the affected coefficients; comfort weight, target
offset and names never invalidate anything. Every recalibration is listed in the
Status attribute `model_changes`.
- **Plans are no longer published from inputs that changed meanwhile.** Before
and after the plan is computed, the integration compares every input it used
(zone and outdoor temperatures beyond 0.25 °C, power beyond 50 W, wind, window
states, relay and thermostat) and discards the plan if one changed meaningfully
(`inputs_changed`, with `changed_inputs`). An older run finishing late never
overwrites a newer result, nothing is published after unload, and saving and
deleting the model can no longer overlap.
### Changed
- **Model accuracy and forecast accuracy are reported separately.** Model Error
replays the model with the inputs that actually happened (relay, electrical
heat, windows); Forecast Error uses only what was known when the forecast was
published, and scores it only when the relay followed the forecast's plan
(in the planned state for at least 12 minutes of every 15-minute block). Both report MAE, RMSE, bias, the
no-change baseline, the improvement over it, sample counts, effective
(roughly independent) sample counts and a quality label.
- Base-model coefficients (`loss`, `heat`, `bias`) report a confidence from their
posterior variance, like the other layers: it reflects whether the data varied
their inputs, not how many samples there are.
- The zone form's sensor weights are numbered fields with the sensors listed
above them (English and Romanian), and the zone form explains the two kinds of
weight.
### Persisted data
- `schema_version` stays 2. New optional keys: `fingerprint_version`,
`fingerprints`, `model_changes` (last 20), `operational_evaluation` (Forecast
Error statistics, 7 days) and per-source baseline fingerprints in
`heat_sources`. Retired zone models keep their fingerprint.
- Model Error statistics gain RMSE columns; 0.3 and 0.4.0 statistics load (their
RMSE stays empty until new forecasts are scored).
- Pending published forecasts are kept in memory only; a restart drops those not
yet due.
## 0.4.0 — 2026-10-27 ## 0.4.0 — 2026-10-27
Thermal zones, electrical heat sources, windows with ventilation and wind, and Thermal zones, electrical heat sources, windows with ventilation and wind, and
+156 -47
View File
@@ -1,4 +1,4 @@
# Heating MPC 0.4.0 — custom integration for Home Assistant OS # Heating MPC 0.4.2 — custom integration for Home Assistant OS
**Experimental, observation-only (shadow mode).** It **never** changes the Generic Thermostat, the physical relay, the boiler's setpoint, `heat/off`, or Comfort/Away/Sleep. No Docker add-on, MQTT or InfluxDB connection is needed. **Experimental, observation-only (shadow mode).** It **never** changes the Generic Thermostat, the physical relay, the boiler's setpoint, `heat/off`, or Comfort/Away/Sleep. No Docker add-on, MQTT or InfluxDB connection is needed.
@@ -23,17 +23,36 @@ 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 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 heat source** and **Add window** create items that can each be edited or deleted
later. **Configure** holds everything else: thermostat, relay, weather sources, later. **Configure** offers **Settings** (thermostat, relay, weather sources,
report-age limits, MPC settings and model options. 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 with **Zones** are rooms or areas. Each has a name, one or more temperature sensors, a
sensor weights (how much each counts in the zone temperature), a comfort weight comfort weight (how much the zone matters to the MPC), an optional target offset
(how much the zone matters to the MPC), an optional target offset relative to the relative to the thermostat's target, and optional adjacent zones it exchanges heat
thermostat's target, and optional adjacent zones it exchanges heat with. Sensor and with. A zone with several sensors gets a second step, **Sensor weights**: one
comfort weights never change the learned thermal coefficients. A sensor belongs to numbered field per sensor (listed above the fields), setting how much each counts
one zone only. Upgrading from 0.3 turns each room sensor into a one-sensor zone in the zone temperature. A sensor belongs to one zone only. Upgrading from 0.3
with the same comfort weight; to group two sensors of one room, edit one zone to turns each room sensor into a one-sensor zone with the same comfort weight; to
include both and delete the other. group two sensors of one room, edit one zone to include both and delete the other.
**What a change does to the learned model.** The zone temperature is defined by
its sensors and the ratio of their weights; changing either recalibrates that
zone (its coefficients are kept as a starting point, but their confidence, sample
counts and accuracy statistics start over, so *Ready* has to be earned again).
Scaling all of a zone's sensor weights by the same factor changes nothing. Adding,
editing or removing a heat source, a neighbour, a window, a window's orientation
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 **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 a UPS feeding computers. Settings: an active-power sensor (W or kW; VA and % are
@@ -47,8 +66,24 @@ modeled; the persistent load is already part of the zone's learned behavior.
**Windows** need a contact sensor (binary sensor), the zone, whether the sensor **Windows** need a contact sensor (binary sensor), the zone, whether the sensor
reports *on* when closed (inverted), the direction the outside of the window faces reports *on* when closed (inverted), the direction the outside of the window faces
(degrees clockwise from north; default 345°, editable) and an opening factor (degrees clockwise from north; default 345°, editable) and an opening factor
(1.0 normal, lower for tilted, higher for large). Unknown or unavailable states are (1.0 normal, lower for tilted, higher for large).
treated as unknown, never as closed.
A window is **open**, **closed** or **unknown** (unknown, unavailable or no state);
unknown never means closed. While any window of a zone is unknown, the zone does
not learn. What forecasts assume for an unknown window:
- last seen open: open, but only until the configured open-window time after it was
last seen (never renewed), then closed and recovering;
- last seen closed, or never seen since setup: no ventilation, but the forecast is
flagged (`uncertain_windows` on the zone's forecast entity, `forecast_assumption`
in Status) and is never used to measure forecast accuracy.
An opening or closing is *confirmed* only when it was seen happen: the previous
reading was the other state, with no unknown period or restart in between. Events
report a duration only when both ends are confirmed, otherwise a minimum and, where
possible, a maximum; recovery is learned only after a confirmed close. The last
known states, an open event and a recovery in progress are saved, so a restart or
reload continues them, but they come back as unknown until the sensor reports.
Model options in **Configure**: how fast extra electrical heat is assumed to fade in Model options in **Configure**: how fast extra electrical heat is assumed to fade in
forecasts (half-life, default 30 minutes), how long an open window is assumed to forecasts (half-life, default 30 minutes), how long an open window is assumed to
@@ -107,13 +142,16 @@ Entities (entity IDs may vary):
- **Heating MPC Last Sample** — what the last poll did for learning (for example - **Heating MPC Last Sample** — what the last poll did for learning (for example
*Learned*, *Relay switched*, *Waiting for radiators to settle*); its history shows *Learned*, *Relay switched*, *Waiting for radiators to settle*); its history shows
when learning happens. when learning happens.
- **Heating MPC Forecast Error 15/30/60/120 min** — how far the thermal model's - **Heating MPC Model Error 15/30/60/120 min** — how well the thermal model
forecasts were from what actually happened (see *Forecast accuracy*). explains what happened, replayed with the inputs that actually occurred.
- **Heating MPC Forecast Error** — how far published forecasts were from what
happened, using only what was known when they were made (see *Forecast
accuracy*).
## Known limitations / safety ## Known limitations / safety
- **Do not** connect the proposed binary sensor directly to the heating switch or build an automation from it. A production controller would need watchdog, manual override, safety handling, and an exclusive relay owner with Generic Thermostat fallback. - **Do not** connect the proposed binary sensor directly to the heating switch or build an automation from it. A production controller would need watchdog, manual override, safety handling, and an exclusive relay owner with Generic Thermostat fallback.
- Weather variables other than temperature, wind and gusts are **captured but not fitted**. Wind and gusts are used only for open windows. Keep optional model terms only when **Forecast Error** shows they help (`improvement_vs_base`). - Weather variables other than temperature, wind and gusts are **captured but not fitted**. Wind and gusts are used only for open windows. Keep optional model terms only when **Model Error** shows they help (`improvement_vs_base`).
- One temperature per zone (no separate wall mass), configured response times, and single-scenario forecasts of electrical heat and windows. See [docs/MODEL.md](docs/MODEL.md#limitations). - One temperature per zone (no separate wall mass), configured response times, and single-scenario forecasts of electrical heat and windows. See [docs/MODEL.md](docs/MODEL.md#limitations).
- All rooms share one heating actuator. The model does not command individual radiators. - All rooms share one heating actuator. The model does not command individual radiators.
- No weather forecast: assumes outdoor temperature remains constant during simulated horizon. - No weather forecast: assumes outdoor temperature remains constant during simulated horizon.
@@ -142,7 +180,10 @@ with it OFF, which can take a few days depending on how often the boiler runs.
The **Heating MPC Status** entity's state is one of `waiting_for_sensors`, The **Heating MPC Status** entity's state is one of `waiting_for_sensors`,
`no_zones` (add a zone), `learning`, `paused` (thermostat not in `heat`), `waiting_for_target`, `ready`, `no_zones` (add a zone), `learning`, `paused` (thermostat not in `heat`), `waiting_for_target`, `ready`,
`inputs_changed` or `prediction_error`. Proposed heat is available only while it `inputs_changed` or `prediction_error`. Proposed heat is available only while it
is `ready`. Useful attributes: is `ready`. `inputs_changed` means an input moved meaningfully while the plan was
being computed (beyond 0.25 °C, 50 W, 1 m/s wind, 2 m/s gust or 20° direction, or
any change of a window, the relay or the thermostat); `changed_inputs` lists them,
the plan is discarded and the next poll computes a fresh one. Useful attributes:
- `input_quality` — per zone (with each of its sensors) and per weather source - `input_quality` — per zone (with each of its sensors) and per weather source
(grouped by metric): `status` (`valid`, `missing`, `unknown`, `unavailable`, (grouped by metric): `status` (`valid`, `missing`, `unknown`, `unavailable`,
@@ -156,8 +197,11 @@ is `ready`. Useful attributes:
- `heat_sources` — per source: measured and effective power (after removing - `heat_sources` — per source: measured and effective power (after removing
included sources), baseline, variable power above it, thermal response, whether it included sources), baseline, variable power above it, thermal response, whether it
has settled. has settled.
- `windows` — per window: state, minutes open, wind direction relative to the - `windows` — per window: state (`open`, `closed` or `unknown`), the last known
window's facing and the resulting exposure (1 = wind straight at it). state and for how long the sensor has been unknown, what forecasts assume
(`forecast_assumption`: `observed`, `last_known_open`, `last_known_closed` or
`unobserved`; `forecast_open_minutes`), minutes open, wind direction relative to
the window's facing and the resulting exposure (1 = wind straight at it).
`ventilation` — per zone: current opening, recovery, temperature before opening, `ventilation` — per zone: current opening, recovery, temperature before opening,
its contribution in °C/h and the ventilation coefficients. `ventilation_events` — its contribution in °C/h and the ventilation coefficients. `ventilation_events` —
the last five openings with temperatures before, lowest, at closing and 30 the last five openings with temperatures before, lowest, at closing and 30
@@ -170,14 +214,20 @@ is `ready`. Useful attributes:
`ventilation` (only window-affected zones; given to the ventilation model), `ventilation` (only window-affected zones; given to the ventilation model),
`partly_rejected` (an outlier in some zone), `interval_out_of_range`, `partly_rejected` (an outlier in some zone), `interval_out_of_range`,
`relay_changed`, `outside_source_changed`, `lag_uncertain` or `waiting_for_inputs`. `relay_changed`, `outside_source_changed`, `lag_uncertain` or `waiting_for_inputs`.
- `models` — per-zone base coefficients, accepted sample counts and the in-sample - `models` — per-zone base coefficients with their `confidence`, accepted sample
5-minute error (`mae_5m`, **not** a forecast accuracy), `model_in_use` and the counts and the in-sample 5-minute error (`mae_5m`, **not** a forecast accuracy),
extended layer's coefficients with confidence. `model_in_use` and the extended layer's coefficients with confidence. Confidence
(0 to 1) is the share of a coefficient's information that comes from data rather
than the starting assumption. It grows only while the data varies that
coefficient's input: weeks with the relay always off leave `heat` at 0, however
many samples there are.
- `model_changes` — the last recalibrations: when, which zone, which coefficients
were reset and why.
- `retired_zone_models` — removed zones whose learned models are kept. - `retired_zone_models` — removed zones whose learned models are kept.
`input_quality`, `weather_comparison`, `heat_sources`, `windows`, `ventilation` `input_quality`, `weather_comparison`, `heat_sources`, `windows`, `ventilation`,
and `ventilation_events` change every poll, so they are not written to the `ventilation_events` and `model_changes` are not written to the Recorder; the other
Recorder; the other attributes are. attributes are.
### Choosing freshness limits ### Choosing freshness limits
@@ -216,37 +266,94 @@ age limit.
## Forecast accuracy ## Forecast accuracy
The four **Forecast Error** sensors measure the thermal model against reality, Two different questions, two kinds of sensor:
continuously and out of sample. At every poll the integration records each zone's
reading, the outdoor temperature, the radiator-lag estimate and the model's
parameters. 15, 30, 60 and 120 minutes later it replays the zones from the earlier
reading using the parameters it had **then**, the outdoor temperature held at its
starting value, and the radiator heat that actually followed from the relay's real
ON/OFF history. The result is compared with the current reading.
This tests the thermal model, not the MPC plan, because the plan is never - **Model Error** (four sensors, 15/30/60/120 min): *does the thermal model explain
executed. All zones are replayed together, with the electrical heat and window what happened?* Replayed with the relay, electrical heat and windows that
openings that actually happened, and the base model is scored on the same spans. Each sensor's state is the mean absolute error (°C) over the last 7 days, actually followed. Useful for judging the model and its options. It is **not**
across all zones. Attributes, all in °C except counts: a forecast accuracy: a real forecast does not know when the relay will switch or
a window will open.
- **Forecast Error** (one sensor): *how good were the forecasts as published?*
Uses only what was known when each forecast was made.
- `samples` — number of scored forecasts. Consecutive forecasts overlap, so they ### Model Error
are not independent; treat small counts as indicative only.
- `bias` — mean error: negative means the model predicts too cold. At every poll the integration records each zone's reading, the outdoor
temperature, the radiator-lag estimate and the model's parameters. 15, 30, 60 and
120 minutes later it replays all zones together from that reading using the
parameters it had **then**, the outdoor temperature held at its starting value,
and the relay, electrical heat and window history that actually followed. The base
model is scored on the same spans. Each sensor's state is the mean absolute error
(°C) over the last 7 days, across all zones. Attributes, all in °C except counts:
- `samples` — number of scored forecasts; `effective_samples` — roughly
independent ones. Forecasts start every 5 minutes, so 60-minute forecasts
overlap twelvefold, and zones share one boiler, so `effective_samples` is
`samples × 5 / horizon / zones`.
- `quality` — `insufficient` below 5 effective samples (the state then stays
unknown; the numbers are still in the attributes), `indicative` below 30, `ok`
from 30.
- `mae`, `rmse`, `bias` — mean absolute, root-mean-square and mean error
(negative bias: the model predicts too cold).
- `persistence_mae` — error of simply assuming no change; the baseline to beat. - `persistence_mae` — error of simply assuming no change; the baseline to beat.
- `skill` — `1 - mae / persistence_mae`: 1 is perfect, 0 is no better than - `skill` — `1 - mae / persistence_mae`: 1 is perfect, 0 is no better than
assuming no change, negative is worse. Empty while the temperature did not change. assuming no change, negative is worse. Empty while the temperature did not change.
- `base_model_mae`, `improvement_vs_base` — the base model's error on the same - `base_model_mae`, `improvement_vs_base` — the base model's error on the same
spans, and the share of it the extra layers remove (0 = no gain, negative = they spans, and the share of it the extra layers remove (0 = no gain, negative = they
make it worse: switch that option off). make it worse: switch that option off).
- `incomplete` — spans left out because an input the zone's model needed was not
observed at some step: a window state (or the start of a recovery), the wind,
wind direction or another zone's windows while a window was open, or, for the
extended layer, electrical heat or a neighbour (whose own span must be complete
too). The base model needs none of these, so its error on those spans is still
reported as `incomplete_base_model_mae`.
- `skipped` — spans that could not be replayed: the radiator-lag estimate was
uncertain along the way, or the zone had no reading at the end.
- `zones` — the same numbers per zone (not written to the Recorder). - `zones` — the same numbers per zone (not written to the Recorder).
Forecasts are scored only where the inputs were known: polls 4.5–5.5 minutes Spans need polls 4.5–5.5 minutes apart; a missing relay, outdoor temperature or
apart, a usable reading at the start and the end, and a well-known radiator-lag all zones restarts the replay window, so scores resume 15–120 minutes later (these
estimate throughout. A missing relay, outdoor temperature or all zones restarts the gaps are not counted). Statistics are kept with the model and survive restarts;
replay window, so scores resume 15–120 minutes later. Statistics are kept with the recalibrating a zone or resetting the learned model clears them.
model and survive restarts; deleting the model resets them.
What it does not cover: outdoor temperature changes during the horizon (held ### Forecast Error
Each time a plan is published, its forecast is recorded as it was: start
temperatures, the trajectory, the plan, the model revision and the assumptions
(outdoor temperature and wind held, electrical heat fading, open windows closing).
When the forecast's horizon comes, the relay's actual behaviour is compared with
the plan, block by block, from the relay's own switch times: the **deviation** is
how long the relay was in another state than planned, so the same on-time at other
times than planned counts as deviation. Relay time that was not observed counts as
possible deviation. Each forecast is then:
- `matched` — at most 2 minutes of deviation. Only these make the Forecast Error
figures.
- `approximate` — at most 5 minutes (of which at most 2 unobserved), for example
the thermostat switching a few minutes earlier than planned. Scored separately
in `approximate_stats`.
- `counterfactual` — more than 5 minutes: Generic Thermostat, which controls the
relay, did something else. Not an error of the forecast. Expected in observation
mode whenever the two disagree.
- `incomplete` — not comparable, with `incomplete_reasons`: `relay_history` (too
much relay time unknown), `uncertain_inputs` (the forecast had to assume an
unknown window or electrical heat), `no_poll` (no poll near the due time) or
`reading` (a zone had no reading; counted per zone).
Why these limits: d seconds of boiler heat that the plan did not assume move a room
by roughly *heat* × d / 300, where *heat* is the learned boiler coefficient
(typically 0.05–0.12 °C per 5 minutes with warm radiators). 2 minutes stay below
half a typical 0.1 °C sensor step and absorb timestamp jitter; 5 minutes are about
one step.
The state is the 60-minute MAE of matched forecasts once its quality is at least
`indicative`; the attribute `horizons` (not written to the Recorder) gives MAE,
RMSE, bias, the no-change error, `improvement_vs_persistence`, sample counts,
quality, the counts above, `approximate_stats` and the per-zone figures for each
horizon. Statistics cover 7 days and survive restarts; forecasts not yet due when
HA restarts are dropped.
What neither covers: outdoor temperature changes during the horizon (held
constant, as in the MPC), and periods when the model was still learning (included, constant, as in the MPC), and periods when the model was still learning (included,
so early numbers are pessimistic). The roadmap's working target is about so early numbers are pessimistic). The roadmap's working target is about
0.3–0.4 °C at 60 minutes with clearly positive skill, before any control is 0.3–0.4 °C at 60 minutes with clearly positive skill, before any control is
@@ -287,8 +394,10 @@ The brand icons in `custom_components/heating_mpc/brand/` are drawn by
`python scripts/make_brand_icons.py` (uses Pillow from the test requirements); `python scripts/make_brand_icons.py` (uses Pillow from the test requirements);
the package check verifies their sizes and transparency. the package check verifies their sizes and transparency.
HA tests cover setup, config/options forms, migration, reload and entity cleanup, HA tests cover setup, config/options forms (including the reset), migration,
scheduled sampling and poll cancellation, the relay listener, stored model reload (a single relay listener and polling coordinator after repeated reloads),
unload, reload and removal while an optimization or a save is still running,
entity cleanup, scheduled sampling and poll cancellation, the relay listener, stored model
restoration, retention and deletion, missing inputs, manual off/presets, optimizer restoration, retention and deletion, missing inputs, manual off/presets, optimizer
failure and Recorder exclusions. A fixture rejects all HA service calls throughout failure and Recorder exclusions. A fixture rejects all HA service calls throughout
each test, and a static test fails if actuator service calls are added to the each test, and a static test fails if actuator service calls are added to the
+33 -5
View File
@@ -85,6 +85,28 @@ Release 0.2.2 adds that event-based relay history (read-only listener), keeps
models of rooms removed in Options, shows each sensor's longest report gap, stops models of rooms removed in Options, shows each sensor's longest report gap, stops
recording volatile attributes, and tests against HA 2025.12.0 and 2026.2.3. recording volatile attributes, and tests against HA 2025.12.0 and 2026.2.3.
### Stabilization after 0.4.0 — 2026-10-27
See AUDIT.md. Four confirmed problems fixed, each with regression tests: unknown
window states read as closed; learned coefficients reused after incompatible
configuration changes (now per-component fingerprints with selective
recalibration, logged in `model_changes`); plans published from inputs that
changed during optimization or by an overtaken run; and model accuracy reported
as if it were forecast accuracy. Reload/unload tests now cover repeated reloads
(one relay listener, one polling coordinator). Released as 0.4.1; not yet tested on HAOS.
### Final stability fixes after 0.4.1 — 2026-10-27
An independent review of 0.4.1 reported four problems; three were reproduced and
the fourth (lifecycle races) confirmed by reading Home Assistant's unload order.
Fixed with regression tests (see AUDIT.md): Model Error spans with unknown inputs
are now `incomplete`, not scored; window sensors unavailable at startup are
unknown, with last known states saved and restored as unknown; unload, reload
and removal can no longer publish stale results or recreate a deleted model;
Forecast Error scores only forecasts whose plan the relay matched within 2
minutes. Also added: **Reset learned model** in Configure. Released as 0.4.2; not
yet tested on HAOS.
## Milestone 1 — Explainable forecasts and dashboards (target v0.3.0) ## Milestone 1 — Explainable forecasts and dashboards (target v0.3.0)
**Objective:** Users can verify what the model predicts and whether it is actually accurate. **Objective:** Users can verify what the model predicts and whether it is actually accurate.
@@ -92,9 +114,9 @@ recording volatile attributes, and tests against HA 2025.12.0 and 2026.2.3.
- [ ] Expose trajectories for each room at 5–15-minute resolution for 2–3 hours, with forecast generation timestamp, horizon and input provenance. *(0.3.0: 15-minute trajectory over the horizon; generation timestamp and provenance still to add.)* - [ ] Expose trajectories for each room at 5–15-minute resolution for 2–3 hours, with forecast generation timestamp, horizon and input provenance. *(0.3.0: 15-minute trajectory over the horizon; generation timestamp and provenance still to add.)*
- [x] Expose predicted +15/+30/+60/+120 minute temperatures and full proposed ON/OFF schedule. *(0.3.0: `forecast_15min` and `plan_15min` on each room forecast entity.)* - [x] Expose predicted +15/+30/+60/+120 minute temperatures and full proposed ON/OFF schedule. *(0.3.0: `forecast_15min` and `plan_15min` on each room forecast entity.)*
- [ ] Provide an HA dashboard example using native cards or a clearly documented optional chart card (do not bundle a heavyweight frontend unnecessarily). - [ ] Provide an HA dashboard example using native cards or a clearly documented optional chart card (do not bundle a heavyweight frontend unnecessarily).
- [ ] Make clear that the **MPC plan forecast is counterfactual** while Generic Thermostat is controlling the relay; add a separate actual-policy forecast/evaluation pathway. *(0.3.0: plan forecasts labelled counterfactual; evaluation uses the actual relay history. An actual-policy forecast, simulating Generic Thermostat, is still open.)* - [ ] Make clear that the **MPC plan forecast is counterfactual** while Generic Thermostat is controlling the relay; add a separate actual-policy forecast/evaluation pathway. *(0.3.0: plan forecasts labelled counterfactual; evaluation uses the actual relay history. Stabilization: published forecasts are scored only when the relay followed their plan and otherwise counted as `counterfactual`. An actual-policy forecast, simulating Generic Thermostat, is still open.)*
- [ ] Store a bounded history of predictions with snapshots of inputs, proposed plan and eventually observed outcome. - [ ] Store a bounded history of predictions with snapshots of inputs, proposed plan and eventually observed outcome. *(Stabilization: each published forecast is kept with its start readings, plan, model revision and assumptions, including any it had to assume, until scored, in memory only; the scored statistics persist, split into matched, approximate, counterfactual and incomplete. No browsable history yet.)*
- [x] Implement out-of-sample/walk-forward MAE and bias at 15, 30, 60 and 120 minutes; include sample counts and confidence/quality labels. *(0.3.0: Forecast Error sensors; replay with parameters frozen at the forecast start and the actual relay history; `skill` against persistence. No confidence intervals yet.)* - [x] Implement out-of-sample/walk-forward MAE and bias at 15, 30, 60 and 120 minutes; include sample counts and confidence/quality labels. *(0.3.0: replay with parameters frozen at the forecast start and the actual relay history; `skill` against persistence. Stabilization: these are now the Model Error sensors, with RMSE, effective sample counts and quality labels; a separate Forecast Error sensor scores published forecasts. No confidence intervals yet.)*
- [ ] Compare against simple benchmarks such as current-temperature persistence and a fixed thermal baseline. *(0.3.0: persistence done; fixed baseline open.)* - [ ] Compare against simple benchmarks such as current-temperature persistence and a fixed thermal baseline. *(0.3.0: persistence done; fixed baseline open.)*
- [ ] Add inspectable explanations: expected heat loss, expected heating response, target, room weights and reason for the next proposed action. - [ ] Add inspectable explanations: expected heat loss, expected heating response, target, room weights and reason for the next proposed action.
@@ -175,8 +197,14 @@ Items 1–3 of the original backlog are done (see AUDIT.md and CHANGELOG.md 0.2.
control, decide a minimum `weight_coverage` and the outdoor-temperature outage control, decide a minimum `weight_coverage` and the outdoor-temperature outage
policy, which still pauses. policy, which still pauses.
3. Configure zones, the UPS heat source and the windows on HAOS; after a week of 3. Configure zones, the UPS heat source and the windows on HAOS; after a week of
heating review `improvement_vs_base`, per-zone bias and which coefficients are heating review Model Error `improvement_vs_base` and `quality`, per-zone bias,
identified. Switch off optional terms that do not help. coefficient `confidence` (base layer included), `model_changes` after any
reconfiguration, and the Forecast Error counts (matched, approximate,
counterfactual, incomplete and why). Switch off optional terms that do not
help. During HAOS acceptance also try: a window sensor unavailable across an HA
restart, a zone edit, a reload while the integration is running, *Reset learned
model* and removing a disposable entry (its `.storage/heating_mpc.<entry_id>`
file must be gone afterwards).
4. Milestone 1 (rest): a dashboard example; forecast timestamps/provenance; 4. Milestone 1 (rest): a dashboard example; forecast timestamps/provenance;
explanations for the proposed action; an actual-policy forecast that simulates explanations for the proposed action; an actual-policy forecast that simulates
Generic Thermostat. Generic Thermostat.
+15 -2
View File
@@ -12,7 +12,7 @@ from homeassistant.helpers.event import async_track_state_change_event
from .const import CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES from .const import CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
from .weather_sources import sources_for from .weather_sources import sources_for
from .coordinator import HeatingMPCCoordinator from .coordinator import HeatingMPCCoordinator, mark_model_removed, storage_lock
from .zones import SUBENTRY_ZONE, ZONE_KEY, ZONE_NAME, legacy_zone_data from .zones import SUBENTRY_ZONE, ZONE_KEY, ZONE_NAME, legacy_zone_data
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
@@ -29,9 +29,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except BaseException: except BaseException:
# HA skips unload callbacks for unexpected setup errors, so clean up here. # HA skips unload callbacks for unexpected setup errors, so clean up here,
# including a refresh scheduled by entities that were already added.
unsub_relay() unsub_relay()
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
await coordinator.async_shutdown()
raise raise
entry.async_on_unload(unsub_relay) entry.async_on_unload(unsub_relay)
# Remove only obsolete zone forecasts belonging to this entry/platform. Zones # Remove only obsolete zone forecasts belonging to this entry/platform. Zones
@@ -53,16 +55,27 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = hass.data.get(DOMAIN, {}).get(entry.entry_id)
if coordinator is not None:
# Before anything is unloaded: an update still running must not publish or save.
coordinator.async_begin_shutdown()
ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if ok: if ok:
hass.data[DOMAIN].pop(entry.entry_id, None) hass.data[DOMAIN].pop(entry.entry_id, None)
elif coordinator is not None:
coordinator.async_cancel_shutdown()
return ok return ok
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove the learned model when this integration instance is deleted.""" """Remove the learned model when this integration instance is deleted."""
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}") store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
# Wait for a save still in progress, which would otherwise recreate the file;
# a save still waiting for the lock sees the mark and does not write.
async with storage_lock(hass, entry.entry_id):
mark_model_removed(hass, entry.entry_id)
await store.async_remove() await store.async_remove()
hass.data.get(f"{DOMAIN}_storage_locks", {}).pop(entry.entry_id, None)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -0,0 +1,113 @@
"""Which learned coefficients stay valid when the configuration changes.
Pure Python; no Home Assistant imports.
Every learned component gets a fingerprint of exactly the configuration it was
learned against. When a fingerprint changes, only that component is recalibrated;
everything else is kept. What each fingerprint covers:
* zone measurement ("base", "loss", "heat", "bias"): the zone's sensors and their
relative weights. Scaling all weights equally changes nothing; replacing, adding
or removing a sensor, or changing the ratio between weights, does.
* "internal": the measurement plus each enabled heat source in the zone: sensor,
heat share, baseline setting, response time and what it includes or is included in.
* "couple:<zone>": the measurement plus the neighbour's measurement.
* ventilation terms: the measurement plus the zone's windows (sensor, polarity,
opening factor); "vent_wind_dir" also the windows' facing directions;
"recovery" also the recovery time constant; "vent_cross" also the other zones'
windows (sensor, polarity, opening factor).
Comfort weight, target offset and names are not physical and are never included.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any, Iterable, Mapping
FINGERPRINT_VERSION = 1
def _hash(value: Any) -> str:
text = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha1(text.encode()).hexdigest()[:12]
def measurement(zone: Any) -> list[tuple[str, float]]:
"""The zone temperature's definition: sensors with normalized weights."""
weights = {sensor: zone.sensor_weight(sensor) for sensor in zone.sensors}
total = sum(weights.values())
return sorted((sensor, round(weight / total, 4)) for sensor, weight in weights.items())
def source_signature(source: Any, sources: Iterable[Any]) -> list[Any]:
sources = list(sources)
parent = next((s.sensor for s in sources if s.key == source.included_in), None)
children = sorted(s.sensor for s in sources if s.included_in == source.key and s.enabled)
baseline = source.baseline_w if source.baseline_mode == "manual" else None
return [source.sensor, source.fraction, source.baseline_mode, baseline, source.response_minutes,
parent, children]
def baseline_fingerprints(sources: Iterable[Any]) -> dict[str, str]:
"""A source's baseline samples are its effective power: its sensor minus included ones."""
sources = list(sources)
return {s.key: _hash([s.sensor, sorted(c.sensor for c in sources if c.included_in == s.key and c.enabled)])
for s in sources}
def zone_fingerprints(zones: Mapping[str, Any], sources: Iterable[Any], windows: Iterable[Any],
recovery_minutes: float) -> dict[str, dict[str, str]]:
"""Per zone: a fingerprint for "base" and for every extended and ventilation feature."""
sources, windows = list(sources), list(windows)
measured = {key: _hash(measurement(zone)) for key, zone in zones.items()}
def zone_windows(key: str, with_azimuth: bool = False) -> list[Any]:
return sorted([w.sensor, w.inverted, w.opening] + ([w.azimuth] if with_azimuth else [])
for w in windows if w.zone == key and w.enabled)
result = {}
for key, zone in zones.items():
m = measured[key]
plain = zone_windows(key)
# Cross-ventilation scales with the other zones' openings.
others = sorted([w.sensor, w.inverted, w.opening] for w in windows if w.zone != key and w.enabled)
prints = {"base": m, "loss": m, "heat": m, "bias": m,
"internal": _hash([m, sorted(source_signature(s, sources) for s in sources
if s.zone == key and s.enabled)]),
"vent_natural": _hash([m, plain]), "vent_wind": _hash([m, plain]),
"vent_gust": _hash([m, plain]),
"vent_wind_dir": _hash([m, zone_windows(key, with_azimuth=True)]),
"recovery": _hash([m, plain, float(recovery_minutes)]),
"vent_cross": _hash([m, plain, others])}
for neighbor in zone.neighbors:
prints[f"couple:{neighbor}"] = _hash([m, measured.get(neighbor)])
result[key] = prints
return result
REASONS = {
"base": "zone sensors or sensor weights changed",
"internal": "heat sources changed",
"couple": "a neighbouring zone's sensors changed",
"vent_wind_dir": "window orientation changed",
"recovery": "windows or recovery time changed",
}
def reason(component: str) -> str:
if component in REASONS:
return REASONS[component]
if component.startswith("couple:"):
return REASONS["couple"]
return "windows changed"
def changed(old: Mapping[str, str] | None, new: Mapping[str, str], components: Iterable[str]) -> list[str]:
"""Components whose fingerprint differs. Without stored fingerprints (0.4.0 data)
nothing is reported: those models were learned before fingerprints existed and
are assumed to match the configuration they are loaded with."""
if old is None:
return []
# A component missing from the old fingerprints is new, not changed.
return [c for c in components if c in old and old[c] != new.get(c)]
+31 -5
View File
@@ -23,6 +23,7 @@ from .heat_sources import (
SOURCE_KEY, SOURCE_NAME, SOURCE_RESPONSE, SOURCE_SENSOR, SOURCE_ZONE, SUBENTRY_HEAT_SOURCE, SOURCE_KEY, SOURCE_NAME, SOURCE_RESPONSE, SOURCE_SENSOR, SOURCE_ZONE, SUBENTRY_HEAT_SOURCE,
HeatSource, build_sources, validate_source, HeatSource, build_sources, validate_source,
) )
from .coordinator import async_reset_learned_model
from .weather_sources import sources_for from .weather_sources import sources_for
from .windows import ( from .windows import (
DEFAULT_AZIMUTH, OPENING_RANGE, SUBENTRY_WINDOW, WINDOW_AZIMUTH, WINDOW_ENABLED, WINDOW_INVERTED, 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} SUBENTRY_WINDOW: WindowSubentryFlow}
CONFIRM_RESET = "confirm_reset"
class HeatingMPCOptionsFlow(config_entries.OptionsFlow): class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
"""Edit sensors, ranked alternative weather providers and MPC parameters.""" """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] 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): 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} config = {**self.config_entry.data, **self.config_entry.options}
if user_input is not None: if user_input is not None:
self._draft = user_input self._draft = user_input
return await self.async_step_weather() 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)) 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): async def async_step_weather(self, user_input: dict[str, Any] | None = None):
config = {**self.config_entry.data, **self.config_entry.options, **self._draft} config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
if user_input is not None: if user_input is not None:
@@ -302,14 +319,23 @@ class ZoneSubentryFlow(config_entries.ConfigSubentryFlow):
return await self._zone_form("reconfigure", user_input, existing, existing[ZONE_KEY]) return await self._zone_form("reconfigure", user_input, existing, existing[ZONE_KEY])
async def async_step_sensor_weights(self, user_input: dict[str, Any] | None = None): async def async_step_sensor_weights(self, user_input: dict[str, Any] | None = None):
"""One weight per sensor; fields are numbered so they have translated labels."""
sensors = self._draft[ZONE_SENSORS] sensors = self._draft[ZONE_SENSORS]
if user_input is not None: if user_input is not None:
self._draft[ZONE_SENSOR_WEIGHTS] = {s: float(user_input[f"weight__{s}"]) for s in sensors} self._draft[ZONE_SENSOR_WEIGHTS] = {
s: float(user_input[f"sensor_weight_{i}"]) for i, s in enumerate(sensors, 1)}
return self._finish() return self._finish()
weights = self._draft.get(ZONE_SENSOR_WEIGHTS) or {} weights = self._draft.get(ZONE_SENSOR_WEIGHTS) or {}
return self.async_show_form(step_id="sensor_weights", data_schema=vol.Schema({ names = []
vol.Required(f"weight__{s}", default=weights.get(s, 1.0)): _number(*SENSOR_WEIGHT_RANGE, 0.1) for i, sensor in enumerate(sensors, 1):
for s in sensors})) state = self.hass.states.get(sensor)
names.append(f"{i}. {state.name if state else sensor} ({sensor})")
return self.async_show_form(
step_id="sensor_weights",
data_schema=vol.Schema({
vol.Required(f"sensor_weight_{i}", default=weights.get(s, 1.0)): _number(*SENSOR_WEIGHT_RANGE, 0.1)
for i, s in enumerate(sensors, 1)}),
description_placeholders={"sensors": "\n".join(names)})
def _finish(self): def _finish(self):
data = {key: self._draft[key] for key in ( data = {key: self._draft[key] for key in (
+304 -48
View File
@@ -1,6 +1,9 @@
"""Read existing HA entities, learn thermal response, produce shadow MPC decisions.""" """Read existing HA entities, learn thermal response, produce shadow MPC decisions."""
from __future__ import annotations from __future__ import annotations
import asyncio
import hashlib
import json
import logging import logging
from collections import deque from collections import deque
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -20,8 +23,9 @@ from .const import (
CONF_BAND, CONF_INTERNAL_HALF_LIFE, CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST, CONF_BAND, CONF_INTERNAL_HALF_LIFE, CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST,
CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES, CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
) )
from .data_quality import ReportAgeHistory, freshness from .compatibility import FINGERPRINT_VERSION, baseline_fingerprints, changed, reason, zone_fingerprints
from .evaluation import ForecastEvaluator, model_snapshot from .data_quality import ReportAgeHistory, freshness, inputs_differ
from .evaluation import ForecastEvaluator, OperationalForecastLedger, model_snapshot
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
from .learning import FeatureLearner from .learning import FeatureLearner
from .physics import ( from .physics import (
@@ -29,7 +33,9 @@ from .physics import (
lag_from_time_in_state, optimize, relay_segments, lag_from_time_in_state, optimize, relay_segments,
) )
from .weather_sources import choose_weather_sources, sources_for, normalize from .weather_sources import choose_weather_sources, sources_for, normalize
from .windows import SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, facing_cos, ventilation_vector from .windows import (
SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, contact_open, facing_cos, ventilation_vector,
)
from .zone_model import ZoneModel, compile_step_model, extended_features, feature_vector from .zone_model import ZoneModel, compile_step_model, extended_features, feature_vector
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
@@ -38,6 +44,53 @@ _LOGGER = logging.getLogger(__name__)
MAX_RELAY_EVENTS = 256 MAX_RELAY_EVENTS = 256
# Models of zones removed in the UI, kept in case a zone is added back. # Models of zones removed in the UI, kept in case a zone is added back.
MAX_RETIRED_ROOMS = 24 MAX_RETIRED_ROOMS = 24
MAX_MODEL_CHANGES = 20
BASE_TERMS = ("loss", "heat", "bias")
def storage_lock(hass: HomeAssistant, entry_id: str) -> asyncio.Lock:
"""One lock per entry, shared by successive coordinators across reloads.
A reload's new coordinator restores only after the old one's save finished.
"""
return hass.data.setdefault(f"{DOMAIN}_storage_locks", {}).setdefault(entry_id, asyncio.Lock())
def mark_model_removed(hass: HomeAssistant, entry_id: str) -> None:
"""Record, under the storage lock, that the entry's model file was deleted for good."""
hass.data.setdefault(f"{DOMAIN}_removed", set()).add(entry_id)
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]: def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
@@ -94,7 +147,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors] self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors]
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms} self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
self.sources = entry_source_list(entry, self.zones) self.sources = entry_source_list(entry, self.zones)
self._heat_sources = HeatSourceTracker(self.sources) self._baseline_prints = baseline_fingerprints(self.sources)
self._heat_sources = HeatSourceTracker(self.sources, fingerprints=self._baseline_prints)
# Extended model per zone: base terms plus electrical heat and adjacent zones. # Extended model per zone: base terms plus electrical heat and adjacent zones.
self._extended_features = { self._extended_features = {
key: extended_features(any(s.zone == key and s.enabled for s in self.sources), zone.neighbors) key: extended_features(any(s.zone == key and s.enabled for s in self.sources), zone.neighbors)
@@ -107,9 +161,16 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for key in self.zones} for key in self.zones}
self.ventilation: dict[str, FeatureLearner] = { self.ventilation: dict[str, FeatureLearner] = {
key: FeatureLearner(features) for key, features in self._ventilation_features.items() if features} key: FeatureLearner(features) for key, features in self._ventilation_features.items() if features}
# What each learned component depends on; a change recalibrates only that component.
self._fingerprints = zone_fingerprints(self.zones, self.sources, self.windows,
float(self.config[CONF_RECOVERY_MINUTES]))
self._model_changes: deque[dict[str, Any]] = deque(maxlen=MAX_MODEL_CHANGES)
self._retired: dict[str, dict[str, Any]] = {} self._retired: dict[str, dict[str, Any]] = {}
self._report_ages = ReportAgeHistory() self._report_ages = ReportAgeHistory()
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY) self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
self._forecasts = OperationalForecastLedger()
# Seconds the relay was on during the last interval (None if unknown), and its length.
self._relay_interval: tuple[float | None, float | None] = (None, None)
# How each poll was handled since start, to explain slow learning. # How each poll was handled since start, to explain slow learning.
self._sample_results: dict[str, int] = {} self._sample_results: dict[str, int] = {}
self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}") self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
@@ -122,8 +183,61 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._relay_events_dropped = False self._relay_events_dropped = False
self._on_samples = 0 self._on_samples = 0
self._off_samples = 0 self._off_samples = 0
# Increases with every update; an older update finishing late never publishes.
self._run = 0
self._closing = False
async def async_restore_model(self) -> None: async def async_restore_model(self) -> None:
async with storage_lock(self.hass, self.entry.entry_id):
await self._async_restore_model()
async def _async_save(self, outside_source: str | None) -> bool:
"""Write the model unless unloading began or the model was deleted.
Shielded: unloading cancels a running scheduled update, but the file write
itself continues in a worker thread. The lock must stay held until that
write has finished, or a deletion could run first and be undone by it.
"""
write = self.hass.async_create_task(self._async_write(outside_source), f"{DOMAIN} model save")
return await asyncio.shield(write)
async def _async_write(self, outside_source: str | None) -> bool:
async with storage_lock(self.hass, self.entry.entry_id):
# Checked after acquiring the lock, not before: a save that waited for
# a deletion to finish must not write the file again.
if self.closing or model_removed(self.hass, self.entry.entry_id):
return False
await self._store.async_save(self._payload(outside_source))
return True
@property
def closing(self) -> bool:
"""True from the start of unloading: nothing is published, saved or recorded."""
return self._closing or getattr(self, "_shutdown_requested", False)
@callback
def async_begin_shutdown(self) -> None:
"""Mark unloading before anything is unloaded.
Home Assistant's own shutdown of the coordinator runs only after platforms
are unloaded; an update still running in that window must not publish or save.
"""
self._closing = True
@callback
def async_cancel_shutdown(self) -> None:
"""Unloading failed and the entry stays loaded: carry on."""
self._closing = False
async def async_shutdown(self) -> None:
self._closing = True
await super().async_shutdown()
def _stale(self, run: int) -> bool:
"""True when a newer update started or the entry is unloading."""
return run != self._run or self.closing
async def _async_restore_model(self) -> None:
stored = await self._store.async_load() or {} stored = await self._store.async_load() or {}
if not isinstance(stored, dict) or stored.get("schema_version", 1) not in (1, 2): if not isinstance(stored, dict) or stored.get("schema_version", 1) not in (1, 2):
_LOGGER.warning("Ignoring unsupported Heating MPC model data") _LOGGER.warning("Ignoring unsupported Heating MPC model data")
@@ -134,7 +248,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model") _LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
return return
self._evaluator.load(stored.get("evaluation")) self._evaluator.load(stored.get("evaluation"))
self._heat_sources = HeatSourceTracker(self.sources, stored.get("heat_sources")) self._forecasts.load(stored.get("operational_evaluation"))
changes = stored.get("model_changes")
self._model_changes.extend(c for c in (changes if isinstance(changes, list) else []) if isinstance(c, dict))
self._heat_sources = HeatSourceTracker(self.sources, stored.get("heat_sources"), self._baseline_prints)
# None for 0.4.0 data: learned before fingerprints existed, assumed to match.
stored_prints = stored.get("fingerprints") if isinstance(stored.get("fingerprints"), dict) else None
saved_models = stored.get("zone_models") if isinstance(stored.get("zone_models"), dict) else {} saved_models = stored.get("zone_models") if isinstance(stored.get("zone_models"), dict) else {}
for key, features in self._extended_features.items(): for key, features in self._extended_features.items():
if features and key in saved_models: if features and key in saved_models:
@@ -146,8 +265,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for key, features in self._ventilation_features.items(): for key, features in self._ventilation_features.items():
if features and key in saved_vent: if features and key in saved_vent:
self.ventilation[key] = FeatureLearner.from_dict(saved_vent[key], features) self.ventilation[key] = FeatureLearner.from_dict(saved_vent[key], features)
# Models carry room identity, so changing the room list is safe. Models of # Models carry zone identity, so changing the zone list is safe. Models of
# rooms no longer configured are retained, most recently removed last. # zones no longer configured are retained, most recently removed last, with
# the measurement fingerprint they were learned against.
base_prints: dict[str, str | None] = {}
for key in ("retired_learners", "learners"): # current learners take precedence for key in ("retired_learners", "learners"): # current learners take precedence
saved = stored.get(key) saved = stored.get(key)
if not isinstance(saved, dict): if not isinstance(saved, dict):
@@ -156,14 +277,85 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not isinstance(room, str): if not isinstance(room, str):
continue continue
learner = Learner.from_dict(data) learner = Learner.from_dict(data)
if key == "learners":
fingerprint = (stored_prints or {}).get(room, {}).get("base") if stored_prints is not None else None
else:
fingerprint = data.get("fingerprint") if isinstance(data, dict) else None
if room in self.learners: if room in self.learners:
self.learners[room] = learner self.learners[room] = learner
base_prints[room] = fingerprint
elif learner.count: elif learner.count:
self._retired.pop(room, None) self._retired.pop(room, None)
self._retired[room] = learner.as_dict() self._retired[room] = {**learner.as_dict(), **({"fingerprint": fingerprint} if fingerprint else {})}
while len(self._retired) > MAX_RETIRED_ROOMS: while len(self._retired) > MAX_RETIRED_ROOMS:
self._retired.pop(next(iter(self._retired))) self._retired.pop(next(iter(self._retired)))
recalibrated = self._check_compatibility(stored_prints, base_prints)
self._update_counts() self._update_counts()
first_prints = stored_prints is None and bool(stored.get("learners"))
if recalibrated or self._heat_sources.reset_baselines or first_prints:
# Save at once: a recalibration is not redone after a restart, and data
# from before fingerprints gets them now, so the next edit is detected
# even if it happens before the next learned sample.
await self._store.async_save(self._payload(stored.get("outside_source"))) # lock held
def _check_compatibility(self, stored: dict[str, Any] | None, base_prints: dict[str, str | None]) -> bool:
"""Recalibrate exactly the components whose configuration changed."""
any_change = False
for room in self.rooms:
current, old = self._fingerprints[room], (stored or {}).get(room) if stored is not None else None
resets: list[str] = []
measured = base_prints.get(room) is not None and base_prints[room] != current["base"]
if measured:
# The zone temperature is defined differently: re-learn it from a warm start.
self.learners[room].recalibrate()
self._evaluator.forget(room)
self._forecasts.forget(room)
resets.append("base")
for layer in (self.extended.get(room), self.ventilation.get(room)):
if layer is None:
continue
features = list(layer.features) if measured else changed(old, current, layer.features)
if features:
layer.reset(features, keep_values=BASE_TERMS)
# loss/heat/bias change only with the measurement, reported as "base".
resets.extend(f for f in features if f not in BASE_TERMS)
if measured:
layer.reset_counts()
if resets:
any_change = True
# A changed measurement explains every reset in the zone.
reasons = sorted({reason("base")} if measured else {reason(c) for c in resets})
self._model_changes.append({
"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"zone": self.zones[room].name, "recalibrated": sorted(set(resets)), "reasons": reasons})
_LOGGER.info("Heating MPC: recalibrating %s for zone %s (%s)", ", ".join(sorted(set(resets))),
self.zones[room].name, "; ".join(reasons))
for key in self._heat_sources.reset_baselines:
name = next(s.name for s in self.sources if s.key == key)
self._model_changes.append({"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"heat_source": name, "recalibrated": ["baseline"],
"reasons": ["power sensor or included sources changed"]})
return any_change
def _payload(self, outside_source: str | None) -> dict[str, Any]:
return {
"schema_version": 2,
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
# Optional keys; older 0.2.x readers ignore them and keep working.
"retired_learners": self._retired,
"zone_models": {key: model.as_dict() for key, model in self.extended.items()},
"heat_sources": self._heat_sources.as_dict(),
"ventilation_models": {key: model.as_dict() for key, model in self.ventilation.items()},
"ventilation": self._ventilation.as_dict(),
"evaluation": self._evaluator.as_dict(),
"operational_evaluation": self._forecasts.as_dict(),
"fingerprint_version": FINGERPRINT_VERSION,
"fingerprints": self._fingerprints,
"model_changes": list(self._model_changes),
"on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER],
"outside_source": outside_source,
}
def _update_counts(self) -> None: def _update_counts(self) -> None:
# Without zones (all deleted in the UI) there is nothing to count. # Without zones (all deleted in the UI) there is nothing to count.
@@ -187,6 +379,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@callback @callback
def async_relay_changed(self, event: Event) -> None: def async_relay_changed(self, event: Event) -> None:
"""Record relay state changes as they happen; read-only state listener.""" """Record relay state changes as they happen; read-only state listener."""
if self.closing:
return # a callback queued before the listener was removed
old, new = event.data.get("old_state"), event.data.get("new_state") old, new = event.data.get("old_state"), event.data.get("new_state")
if new is None: if new is None:
self.record_relay_state(event.time_fired, "removed") self.record_relay_state(event.time_fired, "removed")
@@ -208,6 +402,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
tracker = self._lag tracker = self._lag
steady = False steady = False
lag = uncertainty = None lag = uncertainty = None
self._relay_interval = (None, None) # unknown unless the relay history is known below
if tracker is not None and now > tracker["time"]: if tracker is not None and now > tracker["time"]:
start = tracker["time"] start = tracker["time"]
changes = [(when, state) for when, state in self._relay_events if start < when <= now] changes = [(when, state) for when, state in self._relay_events if start < when <= now]
@@ -218,10 +413,13 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
segments = None if self._relay_events_dropped else relay_segments( segments = None if self._relay_events_dropped else relay_segments(
tracker["heater_on"], start.timestamp(), tracker["heater_on"], start.timestamp(),
[(when.timestamp(), state) for when, state in changes], now.timestamp()) [(when.timestamp(), state) for when, state in changes], now.timestamp())
self._relay_interval = (None, (now - start).total_seconds())
if segments is not None and segments[-1][0] == heater_on: if segments is not None and segments[-1][0] == heater_on:
lag = tracker["lag"] lag = tracker["lag"]
for command, seconds in segments: for command, seconds in segments:
lag = advance_lag(lag, command, seconds) lag = advance_lag(lag, command, seconds)
self._relay_interval = (sum(sec for command, sec in segments if command),
(now - start).total_seconds())
# The lag update is a contraction, so an initial error decays the same way. # The lag update is a contraction, so an initial error decays the same way.
uncertainty = tracker["uncertainty"] * exp( uncertainty = tracker["uncertainty"] * exp(
-(now - start).total_seconds() / LAG_TAU_SECONDS) -(now - start).total_seconds() / LAG_TAU_SECONDS)
@@ -235,6 +433,15 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._relay_events_dropped = False self._relay_events_dropped = False
return steady return steady
def _revision(self) -> str:
"""Short identity of the model configuration a forecast was made with."""
return hashlib.sha1(json.dumps(self._fingerprints, sort_keys=True).encode()).hexdigest()[:10]
def _superseded(self, status: dict[str, Any]) -> dict[str, Any]:
"""Result of an update that a newer one (or unloading) overtook: keep what is shown."""
current = getattr(self, "data", None)
return current if current is not None else {**status, "status": "inputs_changed", "proposal": None}
def _count_result(self, status: dict[str, Any], result: str) -> None: def _count_result(self, status: dict[str, Any], result: str) -> None:
"""Record how this poll was handled for learning, and the learning progress.""" """Record how this poll was handled for learning, and the learning progress."""
status["sample_result"] = result status["sample_result"] = result
@@ -302,8 +509,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
model = ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key)) model = ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
inputs = {"outside": outside, "lag": lag, "internal": internal[key], "temps": temps, inputs = {"outside": outside, "lag": lag, "internal": internal[key], "temps": temps,
"ventilation": vent_inputs.get(key) or {}} "ventilation": vent_inputs.get(key) or {}}
a, b, c = self.learners[key].theta base = self.learners[key]
coefficients = {"base": {"loss": round(a, 6), "heat": round(b, 6), "bias": round(c, 6)}} confidence = base.confidence()
coefficients = {"base": {name: {"value": round(value, 6), "confidence": confidence[name]}
for name, value in zip(("loss", "heat", "bias"), base.theta)}}
for layer, learner in (("extended", model.extended), ("ventilation", model.ventilation)): for layer, learner in (("extended", model.extended), ("ventilation", model.ventilation)):
if learner is not None: if learner is not None:
coefficients[layer] = {name: {k: info[k] for k in ("value", "std", "confidence", "identified")} coefficients[layer] = {name: {k: info[k] for k in ("value", "std", "confidence", "identified")}
@@ -311,7 +520,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
details[key] = {"temperature": round(temp, 3), details[key] = {"temperature": round(temp, 3),
"model_in_use": "extended" if model.uses_extended else "base", "model_in_use": "extended" if model.uses_extended else "base",
"contributions_c_per_h": model.contributions(temp, inputs), "contributions_c_per_h": model.contributions(temp, inputs),
"coefficients": coefficients} "coefficients": coefficients,
# Windows whose state the forecast had to assume (sensor unknown).
"uncertain_windows": list((vent_inputs.get(key) or {}).get("unknown_windows", []))}
return details return details
def _window_diagnostics(self, now: float, weather: dict[str, Any], temps: dict[str, float | None], def _window_diagnostics(self, now: float, weather: dict[str, Any], temps: dict[str, float | None],
@@ -321,9 +532,15 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
info = self._ventilation.window_state.get(window.key, {}) info = self._ventilation.window_state.get(window.key, {})
is_open, since = info.get("open"), info.get("since") is_open, since = info.get("open"), info.get("since")
direction = weather.get("wind_direction") direction = weather.get("wind_direction")
last_known, unknown_since = info.get("last_known_open"), info.get("unknown_since")
assumed = self._ventilation.assumption(window, float(self.config[CONF_WINDOW_OPEN_MINUTES]), now)
windows[window.name] = { windows[window.name] = {
"zone": self.zones[window.zone].name, "sensor": window.sensor, "enabled": window.enabled, "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", "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,
# What forecasts assume: observed, or a bounded guess from the last reading.
"forecast_assumption": assumed["basis"], "forecast_open_minutes": assumed["open_minutes"],
"open_minutes": round((now - since) / 60, 1) if is_open and 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, "azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted,
"relative_wind_direction": info.get("relative_wind_direction"), "relative_wind_direction": info.get("relative_wind_direction"),
@@ -349,22 +566,36 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return {"windows": windows, "ventilation": zones, return {"windows": windows, "ventilation": zones,
"ventilation_events": list(self._ventilation.events)[-5:]} "ventilation_events": list(self._ventilation.events)[-5:]}
def _prediction_inputs(self) -> tuple: def _input_snapshot(self) -> dict[tuple[str, str], Any]:
"""Values the prediction depends on, used to detect changes during calculation.""" """Normalized value of every input a prediction uses, keyed by (kind, entity).
heater = self.config[CONF_HEATER]
entities = sorted(set(self.sensors) | set(sources_for(self.config, "temperature")) | Compared (with tolerances, see data_quality.inputs_differ) before publishing,
{self.config[CONF_CLIMATE], heater}) so a result computed from inputs that have since changed is not shown.
values = [] """
for entity_id in entities: def value(entity_id: str, metric: str):
state = self.hass.states.get(entity_id) state = self.hass.states.get(entity_id)
if state is None: return normalize(metric, state.state, state.attributes.get("unit_of_measurement")) if state else None
values.append((entity_id, None))
continue snapshot: dict[tuple[str, str], Any] = {}
attrs = state.attributes for entity_id in set(self.sensors) | set(sources_for(self.config, "temperature")):
values.append((entity_id, state.state, attrs.get("unit_of_measurement"), snapshot[("temperature", entity_id)] = value(entity_id, "temperature")
attrs.get("temperature"), attrs.get("temperature_unit"), for metric in ("wind", "wind_direction", "wind_gust"):
state.last_changed if entity_id == heater else None)) for entity_id in sources_for(self.config, metric):
return tuple(values) snapshot[(metric, entity_id)] = value(entity_id, metric)
for source in self.sources:
state = self.hass.states.get(source.sensor)
snapshot[("power", source.sensor)] = normalize_power(
state.state, state.attributes.get("unit_of_measurement")) if state else None
for window in self.windows:
state = self.hass.states.get(window.sensor)
snapshot[("window", window.sensor)] = contact_open(state.state if state else None, window.inverted)
heater = self.hass.states.get(self.config[CONF_HEATER])
snapshot[("relay", self.config[CONF_HEATER])] = (heater.state, heater.last_changed) if heater else None
climate = self.hass.states.get(self.config[CONF_CLIMATE])
snapshot[("thermostat", self.config[CONF_CLIMATE])] = (
climate.state, climate.attributes.get("temperature"), climate.attributes.get("temperature_unit")
) if climate else None
return snapshot
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -378,7 +609,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
} }
snapshot = {entity: self.hass.states.get(entity) snapshot = {entity: self.hass.states.get(entity)
for entity in set(self.sensors) | weather_entity_ids} for entity in set(self.sensors) | weather_entity_ids}
inputs_before = self._prediction_inputs() self._run += 1
if self.closing:
return self._superseded({"status": "inputs_changed"})
run = self._run
inputs_before = self._input_snapshot()
sensor_quality = {} sensor_quality = {}
readings: dict[str, float | None] = {} readings: dict[str, float | None] = {}
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
@@ -440,6 +675,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"weather_comparison": {k: v for k, v in weather_comparisons.items() if v}, "weather_comparison": {k: v for k, v in weather_comparisons.items() if v},
"weather_model_inputs": ["temperature"], "weather_model_inputs": ["temperature"],
"retired_zone_models": sorted(self._retired), "retired_zone_models": sorted(self._retired),
"model_changes": list(self._model_changes)[-5:],
"excluded_zones": [room for room in self.rooms if temps[room] is None], "excluded_zones": [room for room in self.rooms if temps[room] is None],
} }
# Electrical heat sources: processed every poll so their responses stay continuous. # Electrical heat sources: processed every poll so their responses stay continuous.
@@ -479,6 +715,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._lag = None self._lag = None
self._relay_events.clear() self._relay_events.clear()
self._relay_events_dropped = False self._relay_events_dropped = False
self._relay_interval = (None, None)
# Published forecasts that are due are scored against these readings; nothing
# here is fed back into what those forecasts assumed.
self._forecasts.observe(now.timestamp(), {k: v for k, v in temps.items() if v is not None},
*self._relay_interval)
status["operational_forecast"] = self._forecasts.summary(self.rooms)
# Rooms without a usable reading are left out and the others carry on; # Rooms without a usable reading are left out and the others carry on;
# only the shared inputs, or losing every room, pause the integration. # only the shared inputs, or losing every room, pause the integration.
vals: dict[str, float] = {room: float(t) for room, t in temps.items() if t is not None} vals: dict[str, float] = {room: float(t) for room, t in temps.items() if t is not None}
@@ -562,22 +804,13 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.ventilation.get(room))) for room in vals}, self.ventilation.get(room))) for room in vals},
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals}) inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals})
status["forecast_accuracy"] = self._evaluator.summary(self.rooms) status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
if learned: # Persist at each sample so restart does not erase hours of training, and when
# Persist at each sample so restart does not erase hours of training. # a window's state changes so a restart continues from it.
await self._store.async_save({ window_changed, self._ventilation.changed = self._ventilation.changed, False
"schema_version": 2, if (learned or window_changed) and not self._stale(run):
"learners": {room: learner.as_dict() for room, learner in self.learners.items()}, await self._async_save(active_sources["temperature"])
# Optional keys; older 0.2.x readers ignore them and keep working. if self._stale(run):
"retired_learners": self._retired, return self._superseded(status)
"zone_models": {key: model.as_dict() for key, model in self.extended.items()},
"heat_sources": self._heat_sources.as_dict(),
"ventilation_models": {key: model.as_dict() for key, model in self.ventilation.items()},
"ventilation": self._ventilation.as_dict(),
"evaluation": self._evaluator.as_dict(),
"on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER],
"outside_source": active_sources["temperature"],
})
# Comfort weights are validated when zones are parsed (invalid -> 1.0). # Comfort weights are validated when zones are parsed (invalid -> 1.0).
weights = {r: self.zones[r].comfort_weight for r in self.rooms} weights = {r: self.zones[r].comfort_weight for r in self.rooms}
# Left-out zones take their weight with them; the rest are re-normalized. # Left-out zones take their weight with them; the rest are re-normalized.
@@ -596,6 +829,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"loss": round(model.theta[0], 6), "loss": round(model.theta[0], 6),
"heat": round(model.theta[1], 6), "heat": round(model.theta[1], 6),
"bias": round(model.theta[2], 6), "bias": round(model.theta[2], 6),
# How well the data pins each coefficient down, not how many samples.
"confidence": model.confidence(),
"samples": model.count, "samples": model.count,
"on_samples": model.on_count, "off_samples": model.off_count, "on_samples": model.on_count, "off_samples": model.off_count,
"rejected_samples": model.rejected_count, "rejected_samples": model.rejected_count,
@@ -644,23 +879,44 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON]), step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON]),
weather, now.timestamp()), weather, now.timestamp()),
) )
# Discard a result if a value used by the prediction changed while
# saving or while the worker calculated the hypothetical plan. Optional
# diagnostic weather sensors are not prediction inputs.
output = await self.hass.async_add_executor_job(job) output = await self.hass.async_add_executor_job(job)
if self._prediction_inputs() != inputs_before: if self._stale(run):
return self._superseded(status)
# Discard a result if an input it used changed meaningfully while saving
# or while the worker calculated the hypothetical plan. Not recomputed at
# once: an off-schedule poll would break the 5-minute learning interval;
# the next regular poll (at most 5 minutes) computes a fresh plan.
changed = inputs_differ(inputs_before, self._input_snapshot())
if changed:
status["status"] = "inputs_changed" status["status"] = "inputs_changed"
status["changed_inputs"] = [f"{kind}: {entity}" for kind, entity in changed[:10]]
return status return status
except (ValueError, RuntimeError, OverflowError) as err: except (ValueError, RuntimeError, OverflowError) as err:
_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._revision(), {
"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)),
"window_open_minutes": float(self.config[CONF_WINDOW_OPEN_MINUTES]),
"internal_kw": {room: internal[room] for room in vals},
"windows_open": [w.name for w in self.windows
if self._ventilation.window_state.get(w.key, {}).get("open")]})
status.update({ status.update({
"status": "ready", "proposal": output["proposal"], "status": "ready", "proposal": output["proposal"],
"prediction_60m": output["predicted_60m"], "prediction_60m": output["predicted_60m"],
"prediction_end": output["predicted_end"], "prediction_end": output["predicted_end"],
"plan_15min": output["plan_15min"], "objective": output["cost"], "plan_15min": output["plan_15min"], "objective": output["cost"],
"prediction_trajectory": output["trajectory_15min"], "prediction_trajectory": output["trajectory_15min"],
"model_quality_note": "5-minute in-sample MAE; not a validated 60-minute forecast error", "model_quality_note": ("mae_5m is the in-sample 5-minute error; Model Error sensors replay the "
"model with actual inputs; Forecast Error scores published forecasts"),
}) })
return status return status
@@ -37,3 +37,37 @@ class ReportAgeHistory:
else: else:
buckets.append([hour, age_seconds]) buckets.append([hour, age_seconds])
return max(age for start, age in buckets if hour - 24 < start <= hour) return max(age for start, age in buckets if hour - 24 < start <= hour)
# How much an input may move while a prediction is computed before the result no
# longer describes the current situation. Below these, changes are sensor noise.
INPUT_TOLERANCES = {
"temperature": 0.25, # °C, zone sensors and outdoor sources
"power": 50.0, # W, heat-source sensors
"wind": 1.0, # m/s
"wind_gust": 2.0, # m/s
"wind_direction": 20.0, # degrees, compared around the circle
}
def inputs_differ(before: dict, after: dict) -> list:
"""Inputs whose change is meaningful: keys are (kind, entity), values normalized.
Kinds without a tolerance (relay, thermostat mode and target, window states,
configuration revision) must match exactly; a value appearing or disappearing
(for example a sensor becoming unavailable) always counts.
"""
changed = []
for key in sorted(set(before) | set(after), key=str):
old, new = before.get(key), after.get(key)
tolerance = INPUT_TOLERANCES.get(key[0])
if old is None or new is None or tolerance is None:
if old != new:
changed.append(key)
continue
difference = abs(new - old)
if key[0] == "wind_direction":
difference = min(difference, 360.0 - difference)
if difference > tolerance:
changed.append(key)
return changed
+412 -39
View File
@@ -9,27 +9,51 @@ then (so later data is never used), the outdoor temperature held at its starting
value, and the radiator lag that actually followed from the relay's real ON/OFF value, and the radiator lag that actually followed from the relay's real ON/OFF
history. The replayed temperature is compared with the reading now. history. The replayed temperature is compared with the reading now.
This scores the thermal model, not the MPC's proposed plan: Generic Thermostat Two kinds of accuracy are kept apart:
controls the relay, so the plan is never executed. Two baselines are scored on
the same spans: persistence (no change) and the base 3-parameter model. The full * ForecastEvaluator, **model accuracy** (retrospective): the model is replayed
model replays all zones together with the electrical heat, neighbour exchange and with the relay, electrical heat and window history that actually happened. It
window openings that actually happened, so its improvement over the base model answers "how well does the model reproduce the rooms under known inputs?" and
shows whether those features earn their keep. compares the full model with the base model and with persistence. It is not a
forecast accuracy: a real forecast does not know the future inputs.
* OperationalForecastLedger, **forecast accuracy**: forecasts as published, with
the boiler plan, electrical heat and windows they assumed, compared with what
happened once their horizon has passed. A forecast assuming the MPC's plan can
only be checked when the relay actually followed that plan; otherwise it is
counted as counterfactual and not scored.
Forecasts start every 5 minutes, so consecutive ones overlap: n forecasts at a
horizon of h minutes carry roughly n x 5 / h independent samples. Metrics report
that number and a quality label, and sensors show no value while it is too small.
""" """
from __future__ import annotations from __future__ import annotations
from collections import deque from collections import deque
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from math import isfinite from math import isfinite, sqrt
from typing import Any from typing import Any
from .physics import Learner from .physics import Learner
from .windows import ventilation_vector from .windows import ventilation_known, ventilation_vector_for_prediction
from .zone_model import feature_vector from .zone_model import feature_vector
HORIZONS = (15, 30, 60, 120) # minutes HORIZONS = (15, 30, 60, 120) # minutes
WINDOW_DAYS = 7 WINDOW_DAYS = 7
STEP_SECONDS = 300 STEP_SECONDS = 300
# Independent samples (overlap-adjusted) needed before a metric is shown / trusted.
MIN_EFFECTIVE_SAMPLES = 5
GOOD_EFFECTIVE_SAMPLES = 30
def effective_samples(count: int, horizon: int, zones: int = 1) -> float:
"""Roughly independent samples among `count` forecasts started 5 minutes apart."""
return round(count / max(1, zones) * min(1.0, STEP_SECONDS / 60 / horizon), 1)
def quality(effective: float) -> str:
if effective < MIN_EFFECTIVE_SAMPLES:
return "insufficient"
return "indicative" if effective < GOOD_EFFECTIVE_SAMPLES else "ok"
def replay(temp: float, outside: float, theta: tuple[float, float, float], def replay(temp: float, outside: float, theta: tuple[float, float, float],
@@ -67,25 +91,56 @@ def snapshot_delta(snapshot: dict[str, Any], temp: float, inputs: dict[str, Any]
ventilation = inputs.get("ventilation") ventilation = inputs.get("ventilation")
if snapshot["ventilation"] and ventilation: if snapshot["ventilation"] and ventilation:
features, theta = snapshot["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: if x is not None:
main += _dot(theta, x) main += _dot(theta, x)
return main return main
def replay_zones(origin: dict[str, Any], path: list[dict[str, Any]]) -> dict[str, float]: def inputs_complete(snapshot: dict[str, Any], temp: float, inputs: dict[str, Any],
"""All zones advanced together with the origin's models and the actual inputs.""" incomplete: set[str]) -> bool:
"""Whether a step of a zone's model used only observed inputs.
Not when a window state, or a weather input an open window's terms need, was
unknown; nor when the extended layer is in use and its electrical heat or a
neighbour is unknown, or that neighbour's own replay is already incomplete.
The replay itself still falls back as forecasts do; the result is just not a
measurement of the model.
"""
vent_features = snapshot["ventilation"][0] if snapshot.get("ventilation") else ()
if not ventilation_known(vent_features, inputs.get("ventilation")):
return False
if snapshot["uses_extended"] and snapshot["extended"]:
features = snapshot["extended"][0]
if feature_vector(features, temp, inputs) is None:
return False
if any(f.startswith("couple:") and f.split(":", 1)[1] in incomplete for f in features):
return False
return True
def replay_zones(origin: dict[str, Any], path: list[dict[str, Any]]) -> tuple[dict[str, float], set[str]]:
"""All zones advanced together with the origin's models and the actual inputs.
Returns the replayed temperatures and the zones whose replay needed an input
that was not observed at some step (see inputs_complete).
"""
state = dict(origin["temps"]) state = dict(origin["temps"])
incomplete: set[str] = set()
for before, poll in zip(path, path[1:]): for before, poll in zip(path, path[1:]):
seconds = (poll["time"] - before["time"]).total_seconds() seconds = (poll["time"] - before["time"]).total_seconds()
new = {} new = {}
unknown = set(incomplete)
for zone, temp in state.items(): for zone, temp in state.items():
actual = poll["inputs"].get(zone, {}) actual = poll["inputs"].get(zone, {})
inputs = {"outside": origin["outside"], "lag": poll["lag"], "temps": state, inputs = {"outside": origin["outside"], "lag": poll["lag"], "temps": state,
"internal": actual.get("internal"), "ventilation": actual.get("ventilation")} "internal": actual.get("internal"), "ventilation": actual.get("ventilation")}
new[zone] = temp + snapshot_delta(origin["models"][zone], temp, inputs) * seconds / STEP_SECONDS snapshot = origin["models"][zone]
state = new if not inputs_complete(snapshot, temp, inputs, incomplete):
return state unknown.add(zone)
new[zone] = temp + snapshot_delta(snapshot, temp, inputs) * seconds / STEP_SECONDS
state, incomplete = new, unknown
return state, incomplete
class ForecastEvaluator: class ForecastEvaluator:
@@ -94,9 +149,12 @@ class ForecastEvaluator:
def __init__(self, max_lag_uncertainty: float) -> None: def __init__(self, max_lag_uncertainty: float) -> None:
self._max_lag_uncertainty = max_lag_uncertainty self._max_lag_uncertainty = max_lag_uncertainty
self._history: deque[dict[str, Any]] = deque(maxlen=HORIZONS[-1] * 60 // STEP_SECONDS + 1) self._history: deque[dict[str, Any]] = deque(maxlen=HORIZONS[-1] * 60 // STEP_SECONDS + 1)
# UTC day -> horizon -> room -> # UTC day -> horizon -> room -> [count, sum error, sum |error|, sum |persistence error|,
# [count, sum error, sum |error|, sum |persistence error|, sum |base model error|] # sum |base model error|, sum error^2, count with squares (0 for 0.3/0.4 data)]
self._days: dict[str, dict[str, dict[str, list[float]]]] = {} self._days: dict[str, dict[str, dict[str, list[float]]]] = {}
# UTC day -> horizon -> room -> [incomplete spans, skipped spans, sum |base model error|
# over the incomplete ones]. Neither kind enters the statistics above.
self._excluded: dict[str, dict[str, dict[str, list[float]]]] = {}
def reset_window(self) -> None: def reset_window(self) -> None:
"""Inputs were missing: no forecast may be scored across this poll.""" """Inputs were missing: no forecast may be scored across this poll."""
@@ -123,6 +181,11 @@ class ForecastEvaluator:
self._score() self._score()
self._prune(time) self._prune(time)
def _exclude(self, day: str, horizon: int, room: str, kind: int, base_error: float = 0.0) -> None:
bucket = self._excluded.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(room, [0, 0, 0.0])
bucket[kind] += 1
bucket[2] += abs(base_error)
def _score(self) -> None: def _score(self) -> None:
history = list(self._history) history = list(self._history)
latest = history[-1] latest = history[-1]
@@ -132,31 +195,42 @@ class ForecastEvaluator:
if len(history) < steps + 1: if len(history) < steps + 1:
continue continue
path = history[-(steps + 1):] path = history[-(steps + 1):]
origin = path[0]
# The actual heat input must be known along the way. # The actual heat input must be known along the way.
if any(poll["lag_uncertainty"] > self._max_lag_uncertainty for poll in path[1:]): if any(poll["lag_uncertainty"] > self._max_lag_uncertainty for poll in path[1:]):
for room in origin["temps"]:
self._exclude(day, horizon, room, SKIPPED)
continue continue
origin = path[0]
inputs = [(poll["lag"], (poll["time"] - before["time"]).total_seconds()) inputs = [(poll["lag"], (poll["time"] - before["time"]).total_seconds())
for before, poll in zip(path, path[1:])] for before, poll in zip(path, path[1:])]
full = replay_zones(origin, path) full, incomplete = replay_zones(origin, path)
for room, actual in latest["temps"].items(): for room, start in origin["temps"].items():
start, theta = origin["temps"].get(room), origin["theta"].get(room) actual, theta = latest["temps"].get(room), origin["theta"].get(room)
if start is None or theta is None: if actual is None or theta is None:
self._exclude(day, horizon, room, SKIPPED) # no reading at the end
continue continue
error = full[room] - actual error = full[room] - actual
base_error = replay(start, origin["outside"], theta, inputs) - actual base_error = replay(start, origin["outside"], theta, inputs) - actual
if room in incomplete:
# The base model needs only the relay and outdoor temperature: its
# error is still a measurement, kept apart from the main figures.
self._exclude(day, horizon, room, INCOMPLETE, base_error)
continue
bucket = self._days.setdefault(day, {}).setdefault(str(horizon), {}).setdefault( bucket = self._days.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(
room, [0, 0.0, 0.0, 0.0, 0.0]) room, [0, 0.0, 0.0, 0.0, 0.0, 0.0, 0])
bucket[0] += 1 bucket[0] += 1
bucket[1] += error bucket[1] += error
bucket[2] += abs(error) bucket[2] += abs(error)
bucket[3] += abs(start - actual) bucket[3] += abs(start - actual)
bucket[4] += abs(base_error) bucket[4] += abs(base_error)
bucket[5] += error * error
bucket[6] += 1
def _prune(self, time: datetime) -> None: def _prune(self, time: datetime) -> None:
oldest = (time.date() - timedelta(days=WINDOW_DAYS - 1)).isoformat() oldest = (time.date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
for day in [day for day in self._days if day < oldest]: for store in (self._days, self._excluded):
del self._days[day] for day in [day for day in store if day < oldest]:
del store[day]
def summary(self, rooms: list[str]) -> dict[int, dict[str, Any]]: def summary(self, rooms: list[str]) -> dict[int, dict[str, Any]]:
"""Error statistics per horizon, overall and per configured room.""" """Error statistics per horizon, overall and per configured room."""
@@ -166,24 +240,53 @@ class ForecastEvaluator:
for day in self._days.values(): for day in self._days.values():
for room, values in day.get(str(horizon), {}).items(): for room, values in day.get(str(horizon), {}).items():
if room in rooms: if room in rooms:
totals = per_room.setdefault(room, [0, 0.0, 0.0, 0.0, 0.0]) totals = per_room.setdefault(room, [0, 0.0, 0.0, 0.0, 0.0, 0.0, 0])
for i, value in enumerate(values): for i, value in enumerate(values):
totals[i] += value totals[i] += value
overall = [sum(values[i] for values in per_room.values()) for i in range(5)] overall = [sum(values[i] for values in per_room.values()) for i in range(7)]
result[horizon] = {**_stats(overall), excluded: dict[str, list[float]] = {}
"rooms": {room: _stats(values) for room, values in per_room.items()}} for day in self._excluded.values():
for room, values in day.get(str(horizon), {}).items():
if room in rooms:
totals = excluded.setdefault(room, [0, 0, 0.0])
for i, value in enumerate(values):
totals[i] += value
all_excluded = [sum(values[i] for values in excluded.values()) for i in range(3)]
result[horizon] = {**_stats(overall, horizon, len(per_room)), **_excluded_stats(all_excluded),
"rooms": {room: {**_stats(per_room.get(room, [0, 0.0, 0.0, 0.0, 0.0, 0.0, 0]),
horizon),
**_excluded_stats(excluded.get(room, [0, 0, 0.0]))}
for room in rooms if room in per_room or room in excluded}}
return result return result
def forget(self, room: str) -> None:
"""Drop a zone's statistics, for example when its temperature is measured differently."""
for store in (self._days, self._excluded):
for by_horizon in store.values():
for by_room in by_horizon.values():
by_room.pop(room, None)
for poll in self._history:
poll["temps"].pop(room, None)
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
# Rounded copy: shorter JSON on every save; in-memory sums stay exact. # Rounded copy: shorter JSON on every save; in-memory sums stay exact.
return {"days": {day: {horizon: {room: [v[0], *(round(x, 6) for x in v[1:])] return {"days": {day: {horizon: {room: [v[0], *(round(x, 6) for x in v[1:6]), v[6]]
for room, v in by_room.items()} for room, v in by_room.items()}
for horizon, by_room in by_horizon.items()} for horizon, by_room in by_horizon.items()}
for day, by_horizon in self._days.items()}} for day, by_horizon in self._days.items()},
"excluded": {day: {horizon: {room: [v[0], v[1], round(v[2], 6)] for room, v in by_room.items()}
for horizon, by_room in by_horizon.items()}
for day, by_horizon in self._excluded.items()}}
def load(self, data: Any) -> None: def load(self, data: Any) -> None:
"""Restore stored statistics, keeping only well-formed entries.""" """Restore stored statistics, keeping only well-formed entries."""
self._days = {} self._days, self._excluded = {}, {}
excluded = data.get("excluded") if isinstance(data, dict) else None
for day, horizon, room, values in _entries(excluded):
if (isinstance(values, list) and len(values) == 3 and all(type(v) is int and v >= 0 for v in values[:2])
and type(values[2]) in (int, float) and isfinite(values[2]) and values[2] >= 0):
self._excluded.setdefault(day, {}).setdefault(horizon, {})[room] = [
values[0], values[1], float(values[2])]
days = data.get("days") if isinstance(data, dict) else None days = data.get("days") if isinstance(data, dict) else None
if not isinstance(days, dict): if not isinstance(days, dict):
return return
@@ -201,28 +304,62 @@ class ForecastEvaluator:
for room, values in by_room.items(): for room, values in by_room.items():
if _valid_bucket(room, values): if _valid_bucket(room, values):
# 0.3 buckets had no base column: the model then was the base model. # 0.3 buckets had no base column: the model then was the base model.
base = values[4] if len(values) == 5 else values[2] # Older buckets have no squared errors: they do not count for RMSE.
base = values[4] if len(values) >= 5 else values[2]
squares = (float(values[5]), values[6]) if len(values) == 7 else (0.0, 0)
self._days.setdefault(day, {}).setdefault(horizon, {})[room] = [ self._days.setdefault(day, {}).setdefault(horizon, {})[room] = [
values[0], float(values[1]), float(values[2]), float(values[3]), float(base)] values[0], float(values[1]), float(values[2]), float(values[3]), float(base),
*squares]
INCOMPLETE, SKIPPED = 0, 1 # columns of the excluded-span counts
def _entries(days: Any):
"""(day, horizon, zone, values) of stored per-day statistics with a valid shape."""
if not isinstance(days, dict):
return
horizons = {str(horizon) for horizon in HORIZONS}
for day, by_horizon in days.items():
if not _valid_day(day) or not isinstance(by_horizon, dict):
continue
for horizon, by_zone in by_horizon.items():
if horizon in horizons and isinstance(by_zone, dict):
for zone, values in by_zone.items():
if isinstance(zone, str):
yield day, horizon, zone, values
def _excluded_stats(values: list[float]) -> dict[str, Any]:
incomplete, skipped, base = values
return {"incomplete": int(incomplete), "skipped": int(skipped),
"incomplete_base_model_mae": round(base / incomplete, 3) if incomplete else None}
def _valid_bucket(room: Any, values: Any) -> bool: def _valid_bucket(room: Any, values: Any) -> bool:
if not isinstance(room, str) or not isinstance(values, list) or len(values) not in (4, 5): if not isinstance(room, str) or not isinstance(values, list) or len(values) not in (4, 5, 7):
return False
count, *sums = values[:6]
if len(values) == 7 and (type(values[6]) is not int or not 0 <= values[6] <= count):
return False return False
count, *sums = values
return (type(count) is int and count >= 0 return (type(count) is int and count >= 0
and all(type(v) in (int, float) and isfinite(v) for v in sums) and all(type(v) in (int, float) and isfinite(v) for v in sums)
and all(v >= 0 for v in sums[1:])) and all(v >= 0 for v in sums[1:]))
def _stats(values: list[float]) -> dict[str, Any]: def _stats(values: list[float], horizon: int, zones: int = 1) -> dict[str, Any]:
count, error, absolute, persistence, base = values count, error, absolute, persistence, base, squares, square_count = values
effective = effective_samples(int(count), horizon, zones)
if not count: if not count:
return {"samples": 0, "mae": None, "bias": None, "persistence_mae": None, "skill": None, return {"samples": 0, "effective_samples": 0.0, "quality": "insufficient", "mae": None,
"rmse": None, "bias": None, "persistence_mae": None, "skill": None,
"base_model_mae": None, "improvement_vs_base": None} "base_model_mae": None, "improvement_vs_base": None}
return { return {
"samples": int(count), "samples": int(count),
"effective_samples": effective,
"quality": quality(effective),
"mae": round(absolute / count, 3), "mae": round(absolute / count, 3),
"rmse": round(sqrt(squares / square_count), 3) if square_count else None,
"bias": round(error / count, 3), "bias": round(error / count, 3),
"persistence_mae": round(persistence / count, 3), "persistence_mae": round(persistence / count, 3),
# Share of the persistence error removed by the model (1 = perfect, < 0 = worse). # Share of the persistence error removed by the model (1 = perfect, < 0 = worse).
@@ -232,3 +369,239 @@ def _stats(values: list[float]) -> dict[str, Any]:
# ventilation (0 = no gain, < 0 = the extra features make it worse). # ventilation (0 = no gain, < 0 = the extra features make it worse).
"improvement_vs_base": round(1 - absolute / base, 3) if base > 0 else None, "improvement_vs_base": round(1 - absolute / base, 3) if base > 0 else None,
} }
# How far the relay may depart from a forecast's plan (seconds in another state than
# planned, up to the horizon) for the forecast to be compared with what happened.
# 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
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:
"""Forecasts as published, scored against what happened once their horizon passed.
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 is the MPC's proposal while Generic Thermostat controls the relay, a
forecast is compared only when the relay actually followed that plan (see
MATCH_SECONDS), and not at all when it was made with an input it had to
assume. Pending forecasts live in memory; the statistics are saved.
"""
def __init__(self, saved: Any = None) -> None:
self._pending: deque[dict[str, Any]] = deque(maxlen=60)
# (interval end, seconds the relay was on or None if unknown, interval seconds)
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|]
self._days: dict[str, dict[str, dict[str, list[float]]]] = {} # matched forecasts
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.load(saved)
def record(self, time: float, start: dict[str, float], trajectory: dict[str, list[float]],
plan: list[int], revision: str, assumptions: dict[str, Any]) -> None:
self._pending.append({"time": time, "start": dict(start), "trajectory": dict(trajectory),
"plan": list(plan), "revision": revision, "assumptions": assumptions,
"done": set()})
def observe(self, now: float, actual: dict[str, float | None], on_seconds: float | None,
seconds: float | None) -> None:
"""One poll: the relay's last interval, then score forecasts that are due."""
if seconds:
self._relay.append((now, on_seconds, seconds))
day = datetime.fromtimestamp(now, timezone.utc).date().isoformat()
for forecast in list(self._pending):
for horizon in HORIZONS:
index = horizon // 15 - 1
due = forecast["time"] + horizon * 60
if horizon in forecast["done"] or now < due - DUE_TOLERANCE_SECONDS:
continue
forecast["done"].add(horizon)
if index >= len(forecast["plan"]):
continue # beyond this forecast's horizon
verdict = self._classify(forecast, horizon, now)
self._count(day, horizon, verdict)
if verdict not in ("matched", "approximate"):
continue
store = self._days if verdict == "matched" else self._approximate
for zone, path in forecast["trajectory"].items():
measured, start = actual.get(zone), forecast["start"].get(zone)
if measured is None or start is None or len(path) <= index:
self._count(day, horizon, "incomplete:reading")
continue
error = path[index] - measured
bucket = store.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(
zone, [0, 0.0, 0.0, 0.0, 0.0])
bucket[0] += 1
bucket[1] += error
bucket[2] += abs(error)
bucket[3] += error * error
bucket[4] += abs(start - measured)
if len(forecast["done"]) == len(HORIZONS) or now > forecast["time"] + (HORIZONS[-1] + 5) * 60:
self._pending.remove(forecast)
oldest = (datetime.fromtimestamp(now, timezone.utc).date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
for store in (self._days, self._approximate, self._verdicts):
for old in [d for d in store if d < oldest]:
del store[old]
def _classify(self, forecast: dict[str, Any], horizon: int, now: float) -> str:
if now > forecast["time"] + horizon * 60 + DUE_TOLERANCE_SECONDS:
return "incomplete:no_poll"
if forecast["assumptions"].get("uncertain_inputs"):
return "incomplete:uncertain_inputs"
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:
lo, hi = max(start, interval_end - seconds), min(end, interval_end)
if hi <= lo:
continue
covered += hi - lo
if on_seconds is None:
unobserved += hi - lo
continue
share = on_seconds / seconds
t = lo
while t < hi: # split at plan block boundaries
block = min(int((t - start) // 900), len(forecast["plan"]) - 1)
part_end = min(hi, start + (block + 1) * 900)
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:
counts = self._verdicts.setdefault(day, {}).setdefault(str(horizon), {})
counts[verdict] = counts.get(verdict, 0) + 1
def forget(self, zone: str) -> None:
for store in (self._days, self._approximate):
for by_horizon in store.values():
for by_zone in by_horizon.values():
by_zone.pop(zone, None)
for forecast in self._pending:
forecast["trajectory"].pop(zone, None)
@property
def pending(self) -> int:
return len(self._pending)
def summary(self, zones: list[str]) -> dict[int, dict[str, Any]]:
result = {}
for horizon in HORIZONS:
per_zone = _sum_by_zone(self._days, horizon, zones)
approximate = _sum_by_zone(self._approximate, horizon, zones)
counts: dict[str, int] = {}
for by_horizon in self._verdicts.values():
for verdict, count in by_horizon.get(str(horizon), {}).items():
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)]
approx_overall = [sum(values[i] for values in approximate.values()) for i in range(5)]
result[horizon] = {
**_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
def as_dict(self) -> dict[str, Any]:
def rounded(store):
return {day: {h: {zone: [v[0], *(round(x, 6) for x in v[1:])] for zone, v in by_zone.items()}
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:
self._days, self._approximate, self._verdicts = {}, {}, {}
# 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
for name, store in (("days", self._days), ("approximate", self._approximate)):
for day, horizon, zone, values in _entries(data.get(name)):
if (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 min(values[2:]) >= 0):
store.setdefault(day, {}).setdefault(horizon, {})[zone] = [
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 {}
horizons = {str(h) for h in HORIZONS}
for day, by_horizon in verdicts.items():
if not _valid_day(day) or not isinstance(by_horizon, dict):
continue
for horizon, counts in by_horizon.items():
if horizon in horizons and isinstance(counts, dict):
clean = {k: v for k, v in counts.items() if k in known and type(v) is int and v >= 0}
if 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:
try:
datetime.strptime(day, "%Y-%m-%d")
return True
except (TypeError, ValueError):
return False
def _operational_stats(values: list[float], horizon: int, zones: int = 1) -> dict[str, Any]:
count, error, absolute, squares, persistence = values
effective = effective_samples(int(count), horizon, zones)
if not count:
return {"samples": 0, "effective_samples": 0.0, "quality": "insufficient", "mae": None, "rmse": None,
"bias": None, "persistence_mae": None, "improvement_vs_persistence": None}
return {
"samples": int(count), "effective_samples": effective, "quality": quality(effective),
"mae": round(absolute / count, 3), "rmse": round(sqrt(squares / count), 3),
"bias": round(error / count, 3), "persistence_mae": round(persistence / count, 3),
"improvement_vs_persistence": round(1 - absolute / persistence, 3) if persistence > 0 else None,
}
+11 -3
View File
@@ -233,11 +233,18 @@ def forecast_response(response_kw: float, variable_kw: float, steps: int, tau_mi
class HeatSourceTracker: class HeatSourceTracker:
"""Per-poll processing of all heat sources: baselines, responses, zone inputs.""" """Per-poll processing of all heat sources: baselines, responses, zone inputs."""
def __init__(self, sources: list[HeatSource], saved: Any = None) -> None: def __init__(self, sources: list[HeatSource], saved: Any = None,
fingerprints: Mapping[str, str] | None = None) -> None:
self.sources = sources self.sources = sources
self.fingerprints = dict(fingerprints or {})
saved = saved if isinstance(saved, Mapping) else {} saved = saved if isinstance(saved, Mapping) else {}
stored = saved.get("baselines") if isinstance(saved.get("baselines"), Mapping) else {} stored = saved.get("baselines") if isinstance(saved.get("baselines"), Mapping) else {}
self._baselines = {s.key: BaselineEstimator(stored.get(s.key) if isinstance(stored.get(s.key), list) else ()) old_prints = saved.get("fingerprints") if isinstance(saved.get("fingerprints"), Mapping) else None
# Samples of a replaced sensor (or a changed inclusion) describe a different load.
self.reset_baselines = [s.key for s in sources if old_prints is not None and s.key in old_prints
and old_prints[s.key] != self.fingerprints.get(s.key)]
self._baselines = {s.key: BaselineEstimator(
stored.get(s.key) if isinstance(stored.get(s.key), list) and s.key not in self.reset_baselines else ())
for s in sources} for s in sources}
self._filters = {s.key: ResponseFilter(s.response_minutes) for s in sources} self._filters = {s.key: ResponseFilter(s.response_minutes) for s in sources}
self._held: dict[str, tuple[float, float | None]] = {} # key -> (timestamp, variable kW) self._held: dict[str, tuple[float, float | None]] = {} # key -> (timestamp, variable kW)
@@ -301,4 +308,5 @@ class HeatSourceTracker:
return total return total
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return {"baselines": {key: [round(v, 1) for v in est.samples] for key, est in self._baselines.items()}} return {"baselines": {key: [round(v, 1) for v in est.samples] for key, est in self._baselines.items()},
"fingerprints": self.fingerprints}
+27 -1
View File
@@ -15,7 +15,7 @@ displayed standard deviation uses a running estimate of the measurement noise.
from __future__ import annotations from __future__ import annotations
from math import isfinite, sqrt from math import isfinite, sqrt
from typing import Any, NamedTuple from typing import Any, Iterable, NamedTuple
FORGETTING = 0.999 # about 3.5 days of 5-minute samples FORGETTING = 0.999 # about 3.5 days of 5-minute samples
MAX_STEP_CHANGE = 0.8 # °C per 5 minutes: larger is a glitch, not physics MAX_STEP_CHANGE = 0.8 # °C per 5 minutes: larger is a glitch, not physics
@@ -195,6 +195,32 @@ class FeatureLearner:
bounded[i] = min(limit, max(-limit, bounded[i])) bounded[i] = min(limit, max(-limit, bounded[i]))
return bounded return bounded
def reset(self, features: Iterable[str], keep_values: Iterable[str] = ()) -> None:
"""Forget what was learned for `features` (after a configuration change).
Their variance returns to the prior and their correlations with the other
coefficients are cleared; values go back to the prior unless listed in
`keep_values` (used as a warm start). Other coefficients are untouched.
"""
keep = set(keep_values)
for name in features:
if name not in self.features:
continue
i = self.features.index(name)
spec = self._specs[i]
for j in range(len(self.features)):
self.p[i][j] = self.p[j][i] = 0.0
self.p[i][i] = spec.variance
if name not in keep:
self.theta[i] = spec.prior
self.active[i] = 0
self.theta = self._bounded(self.theta)
def reset_counts(self) -> None:
"""Readiness and error statistics start over (the measured quantity changed)."""
self.count = self.rejected_count = self.on_count = self.off_count = 0
self.error_sum = self._residual_sq = self._residual_weight = 0.0
@property @property
def noise_var(self) -> float: def noise_var(self) -> float:
"""Estimated variance of the unexplained 5-minute change (°C²).""" """Estimated variance of the unexplained 5-minute change (°C²)."""
+1 -1
View File
@@ -9,5 +9,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "0.4.0" "version": "0.4.2"
} }
+19
View File
@@ -122,10 +122,29 @@ class Learner:
self.rejected_count += 1 self.rejected_count += 1
return False return False
def recalibrate(self) -> None:
"""Re-learn after the zone's measurement changed: the coefficients stay as a
starting point, but their uncertainty and every count go back to the start,
so readiness has to be earned again."""
self.p = [list(row) for row in PRIOR_P]
self.count = self.on_count = self.off_count = self.rejected_count = 0
self.error_sum = 0.0
@property @property
def mae(self) -> float | None: def mae(self) -> float | None:
return self.error_sum / self.count if self.count else None return self.error_sum / self.count if self.count else None
def confidence(self) -> dict[str, float]:
"""Per coefficient, the share of its information that comes from data (0 = prior only).
Taken from the coefficient's own uncertainty, not from the sample count: it
only grows while the data varies that coefficient's input independently of
the others. Weeks with the relay always off leave `heat` at 0; a steady
indoor-outdoor difference cannot separate `loss` from `bias`.
"""
return {name: round(1 - min(prior, max(0.0, self.p[i][i])) / prior, 3)
for i, (name, prior) in enumerate(zip(("loss", "heat", "bias"), PRIOR_VARIANCE))}
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum, return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum,
"on_count": self.on_count, "off_count": self.off_count, "on_count": self.on_count, "off_count": self.off_count,
+66 -5
View File
@@ -16,6 +16,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id), entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id),
MPCLearningProgress(coordinator, entry.entry_id), MPCLastSample(coordinator, entry.entry_id)] MPCLearningProgress(coordinator, entry.entry_id), MPCLastSample(coordinator, entry.entry_id)]
entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS] entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS]
entities.append(MPCOperationalForecastError(coordinator, entry.entry_id))
# Zone forecasts belong to their zone subentry, so deleting a zone removes them. # Zone forecasts belong to their zone subentry, so deleting a zone removes them.
linked = {} linked = {}
for zone in coordinator.zones.values(): for zone in coordinator.zones.values():
@@ -36,7 +37,7 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
# Per-source report ages change every poll; recording them adds a new # Per-source report ages change every poll; recording them adds a new
# attribute row to the database every five minutes. Current values stay visible. # attribute row to the database every five minutes. Current values stay visible.
_unrecorded_attributes = frozenset({"input_quality", "weather_comparison", "heat_sources", "windows", _unrecorded_attributes = frozenset({"input_quality", "weather_comparison", "heat_sources", "windows",
"ventilation", "ventilation_events"}) "ventilation", "ventilation_events", "model_changes"})
def __init__(self, coordinator, entry_id): def __init__(self, coordinator, entry_id):
super().__init__(coordinator) super().__init__(coordinator)
@@ -52,7 +53,7 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
# Forecast details live on their own entities. # Forecast details live on their own entities.
return {key: value for key, value in data.items() if key not in ( return {key: value for key, value in data.items() if key not in (
"status", "prediction_60m", "prediction_end", "prediction_trajectory", "forecast_accuracy", "status", "prediction_60m", "prediction_end", "prediction_trajectory", "forecast_accuracy",
"zone_details")} "zone_details", "operational_forecast")}
class MPCWeightedTemp(CoordinatorEntity, SensorEntity): class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
@@ -102,6 +103,8 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
"forecast_15min": trajectory or None, "forecast_15min": trajectory or None,
"plan_15min": data.get("plan_15min"), "plan_15min": data.get("plan_15min"),
"basis": "proposed MPC plan; counterfactual while Generic Thermostat controls the relay", "basis": "proposed MPC plan; counterfactual while Generic Thermostat controls the relay",
# Windows with an unknown state: the forecast assumes their state (see Status).
"uncertain_windows": details.get("uncertain_windows") or [],
"model_in_use": details.get("model_in_use"), "model_in_use": details.get("model_in_use"),
"contributions_c_per_h": details.get("contributions_c_per_h"), "contributions_c_per_h": details.get("contributions_c_per_h"),
"coefficients": details.get("coefficients"), "coefficients": details.get("coefficients"),
@@ -133,7 +136,11 @@ class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
class MPCForecastError(CoordinatorEntity, SensorEntity): class MPCForecastError(CoordinatorEntity, SensorEntity):
"""Mean absolute error of the thermal model at one horizon, replayed against reality.""" """Model accuracy at one horizon: the model replayed with the inputs that actually happened.
Not a forecast accuracy (a real forecast does not know the future relay, power or
windows); see MPCOperationalForecastError. The unique ID keeps its original name.
"""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_device_class = SensorDeviceClass.TEMPERATURE_DELTA _attr_device_class = SensorDeviceClass.TEMPERATURE_DELTA
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
@@ -146,7 +153,7 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
super().__init__(coordinator) super().__init__(coordinator)
self.horizon = horizon self.horizon = horizon
self._attr_unique_id = f"{entry_id}_forecast_error_{horizon}min" self._attr_unique_id = f"{entry_id}_forecast_error_{horizon}min"
self._attr_name = f"Heating MPC Forecast Error {horizon} min" self._attr_name = f"Heating MPC Model Error {horizon} min"
@property @property
def _accuracy(self): def _accuracy(self):
@@ -154,18 +161,30 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
@property @property
def native_value(self): def native_value(self):
return self._accuracy.get("mae") accuracy = self._accuracy
# No number from a handful of overlapping forecasts.
return accuracy.get("mae") if accuracy.get("quality", "insufficient") != "insufficient" else None
@property @property
def extra_state_attributes(self): def extra_state_attributes(self):
accuracy = self._accuracy accuracy = self._accuracy
return { return {
"kind": "model accuracy: replayed with the actual relay, electrical heat and window history",
"samples": accuracy.get("samples", 0), "samples": accuracy.get("samples", 0),
"effective_samples": accuracy.get("effective_samples", 0),
"quality": accuracy.get("quality", "insufficient"),
"mae": accuracy.get("mae"),
"rmse": accuracy.get("rmse"),
"bias": accuracy.get("bias"), "bias": accuracy.get("bias"),
"persistence_mae": accuracy.get("persistence_mae"), "persistence_mae": accuracy.get("persistence_mae"),
"skill": accuracy.get("skill"), "skill": accuracy.get("skill"),
"base_model_mae": accuracy.get("base_model_mae"), "base_model_mae": accuracy.get("base_model_mae"),
"improvement_vs_base": accuracy.get("improvement_vs_base"), "improvement_vs_base": accuracy.get("improvement_vs_base"),
# Spans left out of the figures above: an input the model needed was unknown
# (the base model's error on them is still measured), or no replay was possible.
"incomplete": accuracy.get("incomplete", 0),
"incomplete_base_model_mae": accuracy.get("incomplete_base_model_mae"),
"skipped": accuracy.get("skipped", 0),
# Per zone, by name. # Per zone, by name.
"zones": {self.coordinator.zones[key].name if key in self.coordinator.zones else key: stats "zones": {self.coordinator.zones[key].name if key in self.coordinator.zones else key: stats
for key, stats in accuracy.get("rooms", {}).items()}, for key, stats in accuracy.get("rooms", {}).items()},
@@ -176,6 +195,48 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
} }
class MPCOperationalForecastError(CoordinatorEntity, SensorEntity):
"""Accuracy of forecasts as published, using only what was known when they were made.
A forecast assumes the MPC's plan; it is scored only when the relay actually
followed that plan, otherwise counted as counterfactual. State: the 60-minute
error; all horizons in the attributes.
"""
_attr_has_entity_name = True
_attr_name = "Heating MPC Forecast Error"
_attr_device_class = SensorDeviceClass.TEMPERATURE_DELTA
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_suggested_display_precision = 2
_attr_icon = "mdi:crystal-ball"
_unrecorded_attributes = frozenset({"horizons"})
def __init__(self, coordinator, entry_id):
super().__init__(coordinator)
self._attr_unique_id = f"{entry_id}_operational_forecast_error"
@property
def native_value(self):
hour = self.coordinator.data.get("operational_forecast", {}).get(60, {})
return hour.get("mae") if hour.get("quality", "insufficient") != "insufficient" else None
@property
def extra_state_attributes(self):
summary = self.coordinator.data.get("operational_forecast", {})
names = {key: zone.name for key, zone in self.coordinator.zones.items()}
horizons = {}
for horizon, stats in summary.items():
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()}}
return {
"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,
"window_days": WINDOW_DAYS,
"unit_of_attributes": "°C",
}
class MPCLearningProgress(CoordinatorEntity, SensorEntity): class MPCLearningProgress(CoordinatorEntity, SensorEntity):
"""How close the least trained room is to being used for forecasts.""" """How close the least trained room is to being used for forecasts."""
_attr_has_entity_name = True _attr_has_entity_name = True
+30 -4
View File
@@ -56,6 +56,13 @@
"options": { "options": {
"step": { "step": {
"init": { "init": {
"title": "Heating MPC",
"menu_options": {
"settings": "Settings",
"reset_model": "Reset learned model"
}
},
"settings": {
"title": "Heating MPC options", "title": "Heating MPC options",
"description": "Change room sensors and controller settings. This integration remains shadow-only.", "description": "Change room sensors and controller settings. This integration remains shadow-only.",
"data": { "data": {
@@ -83,10 +90,17 @@
"window_open_minutes": "Forecasts assume an open window closes after this many more minutes, then the room recovers.", "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.", "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_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." "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": { "weather": {
"title": "Weather source priority", "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.", "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": { "error": {
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." "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": { "entity": {
@@ -143,7 +161,7 @@
"step": { "step": {
"user": { "user": {
"title": "Thermal zone", "title": "Thermal zone",
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.", "description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
"data": { "data": {
"name": "Name", "name": "Name",
"sensors": "Temperature sensors", "sensors": "Temperature sensors",
@@ -159,11 +177,19 @@
}, },
"sensor_weights": { "sensor_weights": {
"title": "Sensor weights", "title": "Sensor weights",
"description": "How much each sensor counts in the zone temperature (0.1–10)." "description": "How much each sensor counts in the zone temperature (0.1–10). Equal weights give the plain average.\n\n{sensors}",
"data": {
"sensor_weight_1": "Sensor 1 weight",
"sensor_weight_2": "Sensor 2 weight",
"sensor_weight_3": "Sensor 3 weight",
"sensor_weight_4": "Sensor 4 weight",
"sensor_weight_5": "Sensor 5 weight",
"sensor_weight_6": "Sensor 6 weight"
}
}, },
"reconfigure": { "reconfigure": {
"title": "Thermal zone", "title": "Thermal zone",
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.", "description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
"data": { "data": {
"name": "Name", "name": "Name",
"sensors": "Temperature sensors", "sensors": "Temperature sensors",
@@ -56,6 +56,13 @@
"options": { "options": {
"step": { "step": {
"init": { "init": {
"title": "Heating MPC",
"menu_options": {
"settings": "Settings",
"reset_model": "Reset learned model"
}
},
"settings": {
"title": "Heating MPC options", "title": "Heating MPC options",
"description": "Change room sensors and controller settings. This integration remains shadow-only.", "description": "Change room sensors and controller settings. This integration remains shadow-only.",
"data": { "data": {
@@ -83,10 +90,17 @@
"window_open_minutes": "Forecasts assume an open window closes after this many more minutes, then the room recovers.", "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.", "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_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." "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": { "weather": {
"title": "Weather source priority", "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.", "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": { "error": {
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." "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": { "entity": {
@@ -143,7 +161,7 @@
"step": { "step": {
"user": { "user": {
"title": "Thermal zone", "title": "Thermal zone",
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.", "description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
"data": { "data": {
"name": "Name", "name": "Name",
"sensors": "Temperature sensors", "sensors": "Temperature sensors",
@@ -159,11 +177,19 @@
}, },
"sensor_weights": { "sensor_weights": {
"title": "Sensor weights", "title": "Sensor weights",
"description": "How much each sensor counts in the zone temperature (0.1–10)." "description": "How much each sensor counts in the zone temperature (0.1–10). Equal weights give the plain average.\n\n{sensors}",
"data": {
"sensor_weight_1": "Sensor 1 weight",
"sensor_weight_2": "Sensor 2 weight",
"sensor_weight_3": "Sensor 3 weight",
"sensor_weight_4": "Sensor 4 weight",
"sensor_weight_5": "Sensor 5 weight",
"sensor_weight_6": "Sensor 6 weight"
}
}, },
"reconfigure": { "reconfigure": {
"title": "Thermal zone", "title": "Thermal zone",
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.", "description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
"data": { "data": {
"name": "Name", "name": "Name",
"sensors": "Temperature sensors", "sensors": "Temperature sensors",
@@ -56,6 +56,13 @@
"options": { "options": {
"step": { "step": {
"init": { "init": {
"title": "Heating MPC",
"menu_options": {
"settings": "Setări",
"reset_model": "Resetează modelul învățat"
}
},
"settings": {
"title": "Configurare Heating MPC", "title": "Configurare Heating MPC",
"description": "Modifică senzorii, ponderile și optimizatorul. Rămâne activ doar modul de observație.", "description": "Modifică senzorii, ponderile și optimizatorul. Rămâne activ doar modul de observație.",
"data": { "data": {
@@ -83,10 +90,17 @@
"window_open_minutes": "Prognozele presupun că o fereastră deschisă se închide după atâtea minute, apoi camera revine.", "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.", "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_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." "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": { "weather": {
"title": "Prioritatea surselor meteo", "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.", "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": { "error": {
"invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor." "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": { "entity": {
@@ -143,7 +161,7 @@
"step": { "step": {
"user": { "user": {
"title": "Zonă termică", "title": "Zonă termică",
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei.", "description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei. Dacă alegi doi sau mai mulți senzori, pasul următor cere ponderea fiecărui senzor.",
"data": { "data": {
"name": "Nume", "name": "Nume",
"sensors": "Senzori de temperatură", "sensors": "Senzori de temperatură",
@@ -159,11 +177,19 @@
}, },
"sensor_weights": { "sensor_weights": {
"title": "Ponderile senzorilor", "title": "Ponderile senzorilor",
"description": "Cât contează fiecare senzor în temperatura zonei (0,1–10)." "description": "Cât contează fiecare senzor în temperatura zonei (0,1–10). Ponderi egale dau media simplă.\n\n{sensors}",
"data": {
"sensor_weight_1": "Ponderea senzorului 1",
"sensor_weight_2": "Ponderea senzorului 2",
"sensor_weight_3": "Ponderea senzorului 3",
"sensor_weight_4": "Ponderea senzorului 4",
"sensor_weight_5": "Ponderea senzorului 5",
"sensor_weight_6": "Ponderea senzorului 6"
}
}, },
"reconfigure": { "reconfigure": {
"title": "Zonă termică", "title": "Zonă termică",
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei.", "description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei. Dacă alegi doi sau mai mulți senzori, pasul următor cere ponderea fiecărui senzor.",
"data": { "data": {
"name": "Nume", "name": "Nume",
"sensors": "Senzori de temperatură", "sensors": "Senzori de temperatură",
+252 -41
View File
@@ -150,17 +150,22 @@ def ventilation_vector(features: Iterable[str], temp: float, outside: float,
inputs: Mapping[str, Any]) -> list[float] | None: inputs: Mapping[str, Any]) -> list[float] | None:
"""Ventilation feature values for one zone and step; None if an input is unknown. """Ventilation feature values for one zone and step; None if an input is unknown.
`inputs`: opening, facing (sum of opening x cos), other_opening, recovery, `inputs`: opening, facing (sum of opening x cos; None when the wind direction is
t_ref (pre-ventilation temperature), wind, gust. unknown), other_opening (None when another zone's windows are unknown),
recovery, t_ref (pre-ventilation temperature), wind, gust. With every window
closed the opening terms are 0 whatever the weather, so only the opening and,
while recovering, the reference temperature must be known.
""" """
difference = outside - temp difference = outside - temp
values = []
for name in features:
opening = inputs.get("opening") opening = inputs.get("opening")
if opening is None: if opening is None:
return None return None
values = []
for name in features:
if name == "vent_natural": if name == "vent_natural":
values.append(opening * difference) values.append(opening * difference)
elif name in ("vent_wind", "vent_wind_dir", "vent_gust", "vent_cross") and opening == 0:
values.append(0.0)
elif name in ("vent_wind", "vent_wind_dir", "vent_gust"): elif name in ("vent_wind", "vent_wind_dir", "vent_gust"):
wind = inputs.get("wind") wind = inputs.get("wind")
if wind is None: if wind is None:
@@ -178,7 +183,10 @@ def ventilation_vector(features: Iterable[str], temp: float, outside: float,
return None return None
values.append(opening * max(0.0, gust - wind) * difference) values.append(opening * max(0.0, gust - wind) * difference)
elif name == "vent_cross": elif name == "vent_cross":
values.append(opening * (inputs.get("other_opening") or 0.0) * difference) other = inputs.get("other_opening")
if other is None:
return None
values.append(opening * other * difference)
elif name == "recovery": elif name == "recovery":
recovery = inputs.get("recovery") or 0.0 recovery = inputs.get("recovery") or 0.0
t_ref = inputs.get("t_ref") t_ref = inputs.get("t_ref")
@@ -190,12 +198,49 @@ def ventilation_vector(features: Iterable[str], temp: float, outside: float,
return values 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, "other_opening": inputs.get("other_opening") or 0.0})
return x
def ventilation_known(features: Iterable[str], inputs: Mapping[str, Any] | None) -> bool:
"""True when a zone's ventilation term follows from observed inputs alone.
False while a window state is unknown or a recovery's start is unconfirmed,
or while a window is open and a term it activates lacks its weather input.
Zones without windows (`inputs` None or empty) need nothing.
"""
if not inputs:
return True
return ventilation_vector(list(features), 0.0, 0.0, inputs) is not None
def _iso(timestamp: float) -> str: def _iso(timestamp: float) -> str:
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat(timespec="seconds") return datetime.fromtimestamp(timestamp, timezone.utc).isoformat(timespec="seconds")
class VentilationTracker: class VentilationTracker:
"""Window states, ventilation inputs per zone, recovery after closing, event log.""" """Window states, ventilation inputs per zone, recovery after closing, event log.
Each window is OPEN, CLOSED or UNKNOWN (unknown, unavailable or missing).
Unknown never means closed. The last known state is kept apart from the
current one, and an opening or closing counts as confirmed only when it was
seen: the previous reading was the other state, with no unknown period in
between. Last known states, open events and recovery are saved so a restart
or reload continues them; they come back as unknown until the sensor reports.
"""
def __init__(self, windows: list[Window], recovery_minutes: float = DEFAULT_RECOVERY_MINUTES, def __init__(self, windows: list[Window], recovery_minutes: float = DEFAULT_RECOVERY_MINUTES,
saved: Any = None) -> None: saved: Any = None) -> None:
@@ -206,12 +251,20 @@ class VentilationTracker:
self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened
self._last_temps: dict[str, float | None] = {} self._last_temps: dict[str, float | None] = {}
self._open_event: dict[str, dict[str, Any]] = {} self._open_event: dict[str, dict[str, Any]] = {}
# window -> last known open/closed state, since when, when it was last seen
# and since when it is unknown (None while known).
self._memory: dict[str, dict[str, Any]] = {}
self._recovery_confirmed: dict[str, bool] = {}
self._last_update: float | None = None
self._restored_at: float | None = None
self.changed = False # something worth saving happened since the last save
saved = saved if isinstance(saved, Mapping) else {} saved = saved if isinstance(saved, Mapping) else {}
events = saved.get("events") if isinstance(saved.get("events"), list) else [] events = saved.get("events") if isinstance(saved.get("events"), list) else []
self.events: deque[dict[str, Any]] = deque( self.events: deque[dict[str, Any]] = deque(
(e for e in events if isinstance(e, dict)), maxlen=MAX_EVENTS) (e for e in events if isinstance(e, dict)), maxlen=MAX_EVENTS)
self.window_state: dict[str, dict[str, Any]] = {} self.window_state: dict[str, dict[str, Any]] = {}
self.zone_state: dict[str, dict[str, Any]] = {} self.zone_state: dict[str, dict[str, Any]] = {}
self._restore(saved.get("state"))
def update(self, now: float, states: Mapping[str, tuple[str | None, float | None]], def update(self, now: float, states: Mapping[str, tuple[str | None, float | None]],
temps: Mapping[str, float | None], weather: Mapping[str, float | None]) -> None: temps: Mapping[str, float | None], weather: Mapping[str, float | None]) -> None:
@@ -228,9 +281,12 @@ class VentilationTracker:
if previous and changed is not None and 0 < now - previous[0] <= 900: if previous and changed is not None and 0 < now - previous[0] <= 900:
fraction = open_fraction(previous[1], is_open, changed, previous[0], now) fraction = open_fraction(previous[1], is_open, changed, previous[0], now)
self._previous[window.key] = (now, is_open) self._previous[window.key] = (now, is_open)
zone = zones.setdefault(window.zone, {"opening": 0.0, "facing_unit": 0.0, "known": True, memory = self._memory.setdefault(window.key, {"last_known": None, "since": None, "seen": None,
"open_now": False, "open_windows": [], "last_close": None, "unknown_since": None})
"first_open": 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": [], "facing_known": True})
if fraction is None: if fraction is None:
zone["known"] = False zone["known"] = False
else: else:
@@ -238,49 +294,103 @@ class VentilationTracker:
zone["facing_unit"] += window.opening * fraction * ( zone["facing_unit"] += window.opening * fraction * (
facing_cos(weather["wind_direction"], window.azimuth) facing_cos(weather["wind_direction"], window.azimuth)
if weather.get("wind_direction") is not None else 0.0) if weather.get("wind_direction") is not None else 0.0)
if fraction > 0 and weather.get("wind_direction") is None:
zone["facing_known"] = False
if is_open is None:
if memory["unknown_since"] is None:
memory["unknown_since"] = memory["seen"] if memory["seen"] is not None else now
self.changed = True
zone["unknown_windows"].append(window.name)
if memory["last_known"]:
zone["possibly_open"] = True # last seen open: still treated as open
else:
if memory["last_known"] is not is_open: # a change, or the first reading
memory["since"] = changed
# Seen only if the previous reading was the other state, with no gap.
witnessed = memory["last_known"] is not None and memory["unknown_since"] is None
if not witnessed:
zone["open_confirmed" if is_open else "close_confirmed"] = False
self.changed = True
elif memory["unknown_since"] is not None:
self.changed = True # the same state again after a gap
memory.update(last_known=is_open, seen=now, unknown_since=None)
if is_open: if is_open:
zone["open_now"] = True zone["open_now"] = True
zone["open_windows"].append(window.name) zone["open_windows"].append(window.name)
if changed is not None: if changed is not None:
zone["first_open"] = min(zone["first_open"] or changed, changed) 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]: elif previous and changed is not None and changed > previous[0]:
zone["last_close"] = max(zone["last_close"] or 0.0, changed) zone["last_close"] = max(zone["last_close"] or 0.0, changed)
self.window_state[window.key] = { 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"], "seen": memory["seen"],
"unknown_since": memory["unknown_since"],
"relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1) "relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1)
if weather.get("wind_direction") is not None else None)} if weather.get("wind_direction") is not None else None)}
# Time the integration was not running counts as unknown for events it interrupted.
gap = now - self._restored_at if self._restored_at is not None else 0.0
self._restored_at = None
for key, zone in zones.items(): for key, zone in zones.items():
if gap > 330 and key in self._open_event:
event = self._open_event[key]
event["sensor_unknown_minutes"] = round(event.get("sensor_unknown_minutes", 0.0) + gap / 60, 1)
self._update_zone(now, key, zone, temps.get(key), weather, zones) self._update_zone(now, key, zone, temps.get(key), weather, zones)
self.zone_state = zones self.zone_state = zones
self._last_temps = dict(temps) self._last_temps = dict(temps)
self._last_update = now
def _update_zone(self, now: float, key: str, zone: dict[str, Any], temp: float | None, 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: weather: Mapping[str, float | None], zones: Mapping[str, dict[str, Any]]) -> None:
was_open = key in self._open_event was_open = key in self._open_event
ventilating = zone["open_now"] or zone["possibly_open"]
if zone["open_now"] and not was_open: if zone["open_now"] and not was_open:
# Temperature at the previous poll approximates the room before ventilation. # Temperature at the previous poll approximates the room before ventilation.
self._t_ref[key] = self._last_temps.get(key) self._t_ref[key] = self._last_temps.get(key)
self._closed_at.pop(key, None) self._closed_at.pop(key, None)
self._open_event[key] = { self._open_event[key] = {
"zone": key, "opened": _iso(min(zone["first_open"] or now, now)), "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"), "outside": weather.get("temperature"), "wind": weather.get("wind"),
"wind_direction": weather.get("wind_direction"), "wind_gust": weather.get("wind_gust"), "wind_direction": weather.get("wind_direction"), "wind_gust": weather.get("wind_gust"),
"windows": list(zone["open_windows"]), "windows": list(zone["open_windows"]),
"other_open_windows": [n for k, z in zones.items() if k != key for n in z["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, "last_seen_open": _iso(now),
} }
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
self.changed = True
elif ventilating and was_open:
event = self._open_event[key] event = self._open_event[key]
if temp is not None:
event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None) 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 zone["unknown_windows"]:
event["sensor_unknown_minutes"] = round(event["sensor_unknown_minutes"] + 5.0, 1)
if zone["open_now"]:
event["last_seen_open"] = _iso(now)
elif was_open and not ventilating:
event = self._open_event.pop(key) event = self._open_event.pop(key)
closed = zone["last_close"] or now opened = _parse(event["opened"]) # the confirmed opening, or when it was first seen open
seen_open = _parse(event.get("last_seen_open", event["opened"]))
closed = zone["last_close"] or now if zone["close_confirmed"] else now
confirmed = zone["close_confirmed"] and event.get("opened_confirmed", True)
# Uncertain periods give a range instead of a duration.
event.update(
closed=_iso(closed) if zone["close_confirmed"] else None,
closed_confirmed=zone["close_confirmed"],
duration_minutes=round((closed - opened) / 60, 1) if confirmed else None,
duration_min_minutes=round(((closed if zone["close_confirmed"] else seen_open) - opened) / 60, 1),
duration_max_minutes=(round((now - opened) / 60, 1)
if not zone["close_confirmed"] and event.get("opened_confirmed", True)
else None))
if not zone["close_confirmed"]:
event["closed_reported"] = _iso(now) # closed at some point before this
self._closed_at[key] = closed self._closed_at[key] = closed
event.update(closed=_iso(closed), duration_minutes=round((closed - _parse(event["opened"])) / 60, 1), self._recovery_confirmed[key] = confirmed
temperature_at_close=temp) event["temperature_at_close"] = temp
self.events.append(event) self.events.append(event)
elif not zone["open_now"] and key in self._closed_at and temp is not None: self.changed = True
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. # Fill in the temperature half an hour after closing, for recovery review.
for event in reversed(self.events): for event in reversed(self.events):
if event.get("zone") == key and "temperature_after" not in event: if event.get("zone") == key and "temperature_after" not in event:
@@ -289,58 +399,159 @@ class VentilationTracker:
event["temperature_after_minutes"] = EVENT_AFTER_MINUTES event["temperature_after_minutes"] = EVENT_AFTER_MINUTES
break break
closed = self._closed_at.get(key) 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: if recovery < RECOVERY_THRESHOLD:
recovery = 0.0 recovery = 0.0
zone.update(recovery=recovery, t_ref=self._t_ref.get(key), zone.update(recovery=recovery, t_ref=self._t_ref.get(key),
other_opening=sum(z["opening"] for k, z in zones.items() if k != key and z["known"])) 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"]),
others_known=all(z["known"] for k, z in zones.items() if k != key))
# Learning of the closed-window model is unsafe while anything ventilation-related goes on. # 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: 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) zone = self.zone_state.get(key)
if zone is None: if zone is None:
return 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"])
"other_opening": zone["other_opening"], "recovery": zone["recovery"], return {"opening": zone["opening"] if known else None,
"facing": zone["facing_unit"] if zone["facing_known"] else None,
"other_opening": zone["other_opening"] if zone["others_known"] else None,
"recovery": zone["recovery"],
"t_ref": zone["t_ref"], "wind": weather.get("wind"), "gust": weather.get("wind_gust"), "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 assumption(self, window: Window, open_minutes: float, now: float) -> dict[str, Any]:
"""What forecasts assume for one window, and on what basis.
Observed open: open for `open_minutes` more. Unknown but last seen open: open
until `open_minutes` after it was last seen, never renewed, so an unavailable
sensor does not keep a window open forever. Unknown otherwise: no ventilation,
marked uncertain (`last_known_closed`, or `unobserved` with no reading at all).
"""
state = self.window_state.get(window.key, {})
if state.get("open"):
return {"basis": "observed", "open_minutes": float(open_minutes), "uncertain": False}
if state.get("open") is False:
return {"basis": "observed", "open_minutes": 0.0, "uncertain": False}
if state.get("last_known_open"):
seen = state.get("seen")
left = open_minutes - (now - seen) / 60 if seen is not None else 0.0
return {"basis": "last_known_open", "open_minutes": round(max(0.0, left), 1), "uncertain": True,
"assumed_closed_at": seen + open_minutes * 60 if seen is not None else now}
basis = "last_known_closed" if state.get("last_known_open") is False else "unobserved"
return {"basis": basis, "open_minutes": 0.0, "uncertain": True}
def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None], def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None],
now: float) -> list[dict[str, Any]]: now: float) -> list[dict[str, Any]]:
"""Assumed ventilation inputs per forecast step. """Assumed ventilation inputs per forecast step (see `assumption`).
Windows open now are assumed to close after `open_minutes` (at least one Each window open now, or assumed open, closes after its assumed time; once
step); after that the zone recovers. Weather is held at current values. the last one has closed the zone recovers. Weather is held at current values.
""" """
zone = self.zone_state.get(key) zone = self.zone_state.get(key)
if zone is None: if zone is None:
return [{} for _ in range(steps)] return [{} for _ in range(steps)]
windows = [w for w in self.windows if w.zone == key and w.enabled windows = [w for w in self.windows if w.zone == key and w.enabled]
and self.window_state.get(w.key, {}).get("open")] assumed = {w.key: self.assumption(w, open_minutes, now) for w in windows}
opening = sum(w.opening for w in windows) # Steps each window stays open: at least one for an observed open window.
facing = sum(w.opening * facing_cos(weather["wind_direction"], w.azimuth) for w in windows) \ open_steps = {w.key: (max(1, round(open_minutes / 5)) if self.window_state.get(w.key, {}).get("open")
if weather.get("wind_direction") is not None else 0.0 else round(assumed[w.key]["open_minutes"] / 5)) for w in windows}
open_steps = max(1, round(open_minutes / 5)) if windows else 0 direction = weather.get("wind_direction")
last_open = max(open_steps.values(), default=0)
if last_open:
start = now + last_open * 300.0
else:
# Recovery from the last close: observed, or assumed for a window last seen open.
closes = [a["assumed_closed_at"] for a in assumed.values() if "assumed_closed_at" in a]
closed_at = self._closed_at.get(key) closed_at = self._closed_at.get(key)
start = max([t for t in (closed_at, *closes) if t is not None], default=None)
result = [] result = []
for k in range(steps): for k in range(steps):
t = now + (k + 1) * 300.0 t = now + (k + 1) * 300.0
if k < open_steps: open_now = [w for w in windows if k < open_steps[w.key]]
result.append({"opening": opening, "facing": facing, "recovery": 0.0, "t_ref": zone["t_ref"], if open_now:
"other_opening": zone["other_opening"], "wind": weather.get("wind") or 0.0, result.append({
"opening": sum(w.opening for w in open_now),
"facing": (sum(w.opening * facing_cos(direction, w.azimuth) for w in open_now)
if direction is not None else 0.0),
"recovery": 0.0, "t_ref": zone["t_ref"], "other_opening": zone["other_opening"],
"wind": weather.get("wind") or 0.0,
"gust": weather.get("wind_gust") or weather.get("wind") or 0.0}) "gust": weather.get("wind_gust") or weather.get("wind") or 0.0})
continue continue
start = now + open_steps * 300.0 if windows else closed_at recovery = exp(-(t - start) / self.tau) if start is not None and t > start else 0.0
recovery = exp(-(t - start) / self.tau) if start is not None else 0.0
result.append({"opening": 0.0, "facing": 0.0, "other_opening": 0.0, "wind": 0.0, "gust": 0.0, result.append({"opening": 0.0, "facing": 0.0, "other_opening": 0.0, "wind": 0.0, "gust": 0.0,
"recovery": recovery if recovery >= RECOVERY_THRESHOLD else 0.0, "recovery": recovery if recovery >= RECOVERY_THRESHOLD else 0.0,
"t_ref": zone["t_ref"]}) "t_ref": zone["t_ref"]})
return result return result
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return {"events": list(self.events)} """Events, plus the state a restart or reload needs to continue them."""
windows = {w.key: w for w in self.windows if w.enabled}
state = {
"time": self._last_update,
"windows": {key: {"sensor": windows[key].sensor, "inverted": windows[key].inverted,
"last_known": m["last_known"], "since": m["since"], "seen": m["seen"]}
for key, m in self._memory.items()
if key in windows and m["last_known"] is not None and m["seen"] is not None},
"zones": {zone: {"open_event": self._open_event.get(zone), "closed_at": self._closed_at.get(zone),
"recovery_confirmed": self._recovery_confirmed.get(zone, True),
"t_ref": self._t_ref.get(zone)}
for zone in {w.zone for w in windows.values()}
if zone in self._open_event or zone in self._closed_at},
}
return {"events": list(self.events), "state": state}
def _restore(self, state: Any) -> None:
"""Continue from saved state. Nothing restored is live: every window is unknown
from when it was last seen until its sensor reports again."""
if not isinstance(state, Mapping):
return
windows = {w.key: w for w in self.windows if w.enabled}
saved_windows = state.get("windows") if isinstance(state.get("windows"), Mapping) else {}
for key, item in saved_windows.items():
window = windows.get(key)
if (window is None or not isinstance(item, Mapping) or item.get("sensor") != window.sensor
or item.get("inverted") is not window.inverted or not isinstance(item.get("last_known"), bool)
or not _finite(item.get("seen"))):
continue # another sensor now, or polarity flipped: nothing carries over
seen = float(item["seen"])
self._memory[key] = {"last_known": item["last_known"], "seen": seen, "unknown_since": seen,
"since": float(item["since"]) if _finite(item.get("since")) else None}
remembered_open = {windows[key].zone for key, m in self._memory.items() if m["last_known"]}
saved_zones = state.get("zones") if isinstance(state.get("zones"), Mapping) else {}
for zone, item in saved_zones.items():
if zone not in {w.zone for w in windows.values()} or not isinstance(item, Mapping):
continue
event = item.get("open_event")
# An open event continues only while one of its windows is remembered open.
if isinstance(event, dict) and zone in remembered_open and _valid_iso(event.get("opened")):
self._open_event[zone] = dict(event)
if _finite(item.get("closed_at")):
self._closed_at[zone] = float(item["closed_at"])
self._recovery_confirmed[zone] = item.get("recovery_confirmed") is True
if _finite(item.get("t_ref")):
self._t_ref[zone] = float(item["t_ref"])
if self._open_event and _finite(state.get("time")):
self._restored_at = float(state["time"])
def _finite(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value)
def _valid_iso(value: Any) -> bool:
try:
datetime.fromisoformat(value)
return True
except (TypeError, ValueError):
return False
def _parse(iso: str) -> float: def _parse(iso: str) -> float:
+3 -3
View File
@@ -18,7 +18,7 @@ from typing import Any, Callable, Mapping
from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES
from .learning import FeatureLearner from .learning import FeatureLearner
from .physics import Learner from .physics import Learner
from .windows import ventilation_vector from .windows import ventilation_vector_for_prediction
BASE_FEATURES = ["loss", "heat", "bias"] BASE_FEATURES = ["loss", "heat", "bias"]
@@ -86,7 +86,7 @@ class ZoneModel:
ventilation = inputs.get("ventilation") ventilation = inputs.get("ventilation")
if self.ventilation is None or not ventilation: if self.ventilation is None or not ventilation:
return 0.0 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 return self.ventilation.predict(x) if x is not None else 0.0
def delta(self, temp: float, inputs: Mapping[str, Any]) -> float: def delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
@@ -98,7 +98,7 @@ class ZoneModel:
terms = self._main_contributions(temp, inputs) terms = self._main_contributions(temp, inputs)
ventilation = inputs.get("ventilation") ventilation = inputs.get("ventilation")
if self.ventilation is not None and 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: if x is not None:
for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x): for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x):
group = "recovery" if name == "recovery" else "ventilation" group = "recovery" if name == "recovery" else "ventilation"
+102 -14
View File
@@ -93,9 +93,24 @@ measure the input side or lower the heat fraction accordingly.
Present when a zone has an enabled window. Learned separately, from the part of Present when a zone has an enabled window. Learned separately, from the part of
the measured change the closed-window layers cannot explain, only while a window is the measured change the closed-window layers cannot explain, only while a window is
open, its state is unknown, or the zone is recovering. During those periods the open or the zone is recovering, and only while every window state in the zone is
base and extended layers do **not** learn, so window events cannot inflate `loss` known. While a window is open, possibly open (its sensor is unknown and it was last
or shift `bias`. seen open) or the zone is recovering, the base and extended layers do **not**
learn, so window events cannot inflate `loss` or shift `bias`.
An unknown window state is never read as closed, and an opening or closing counts
as confirmed only when it was seen: the previous reading was the other state with
no unknown period (or restart) in between. If a window closes while its sensor was
unknown, the event's duration is reported as a range and the recovery term is not
learned from it, because the closing time is not known. Last known states, open
events and recovery are saved and restored across restarts, as unknown until the
sensor reports again.
An input that a term needs is never filled in for learning or for Model Error:
while a window is open, the wind, its direction (for `vent_wind_dir`) and the other
zones' windows (for `vent_cross`) must be known. With every window closed, the
opening terms are 0 and need no weather. Forecasts, which must produce a number,
assume calm air for a missing wind input.
`vent_wind_dir` is bounded by `vent_wind` (|vent_wind_dir| ≤ vent_wind), so the `vent_wind_dir` is bounded by `vent_wind` (|vent_wind_dir| ≤ vent_wind), so the
combined wind term never goes negative: a leeward window still ventilates. combined wind term never goes negative: a leeward window still ventilates.
@@ -128,11 +143,20 @@ outlier rejection above 0.8 °C per 5 minutes.
Each coefficient reports: Each coefficient reports:
* `confidence`: the share of its information that comes from data rather than the * `confidence`: the share of its information that comes from data rather than the
prior (0 = prior only); prior (0 = prior only), from its posterior variance. It is not a sample count:
it grows only while the data varies the coefficient's input independently of
the others, and with forgetting it falls again during long periods without such
variation. The base layer reports it too (`loss`, `heat`, `bias`);
* `std`: its standard error, using a running estimate of the unexplained noise; * `std`: its standard error, using a running estimate of the unexplained noise;
* `samples`: how many accepted samples actually exercised it; * `samples`: how many accepted samples actually exercised it;
* `identified`: at least 30 exercising samples and confidence ≥ 0.8. * `identified`: at least 30 exercising samples and confidence ≥ 0.8.
The base layer has no per-coefficient sample counts or standard errors; its
`confidence` alone shows identification. In the tests, two weeks of samples with
the relay always off and a steady indoor-outdoor difference leave `heat` at 0 and
`loss` and `bias` below 0.8, while four days with the relay cycling and the
weather changing bring all three above 0.8.
A coefficient can move without being identified. Example from the tests: two rooms A coefficient can move without being identified. Example from the tests: two rooms
whose temperature difference stays fairly steady cannot separate exchange between whose temperature difference stays fairly steady cannot separate exchange between
them from each room's bias; the learner finds a positive coupling but correctly them from each room's bias; the learner finds a positive coupling but correctly
@@ -154,6 +178,27 @@ A zone learns from the interval between two polls only when:
No experiments are run and the boiler is never switched to gather data: everything No experiments are run and the boiler is never switched to gather data: everything
is learned passively. is learned passively.
## Configuration changes
Every learned component stores a fingerprint of the configuration it was learned
under (`custom_components/heating_mpc/compatibility.py`). At startup, components
whose fingerprint changed are reset; the rest are kept.
| Change | Effect |
|---|---|
| A zone's sensors, or the ratio of their weights | Zone recalibrated: coefficients kept as a starting point; uncertainty, counts and accuracy statistics reset. Its extended and ventilation layers start over too (their loss, heat and bias values kept as a starting point) |
| Scaling all of a zone's weights equally | Nothing (the normalized measurement is unchanged) |
| A heat source's sensor, heat share, baseline, response time or "included in" | `internal` of its zone; the source's baseline samples if its effective power changed |
| A neighbour's sensors or weights | `couple:<neighbour>` |
| A window's sensor, polarity or opening factor | that zone's ventilation terms; `vent_cross` of the other zones |
| A window's facing direction | `vent_wind_dir` |
| Recovery time constant | `recovery` |
| Comfort weight, target offset, names | Nothing (not physical) |
Models saved before fingerprints existed (0.4.0) are assumed to match the
configuration they are loaded with, and are fingerprinted at once. Every reset is
logged in `model_changes` with its reason.
## Forecasts ## Forecasts
Forecasts simulate all zones together, step by step, over the MPC horizon (60–180 Forecasts simulate all zones together, step by step, over the MPC horizon (60–180
@@ -165,7 +210,10 @@ minutes):
its response time. A gaming PC drawing 600 W now is not assumed to run on for two its response time. A gaming PC drawing 600 W now is not assumed to run on for two
hours. hours.
* **Windows:** an open window is assumed to stay open for a configurable time * **Windows:** an open window is assumed to stay open for a configurable time
(default 15 minutes), then close; the zone then recovers. (default 15 minutes), then close; the zone then recovers. A window whose sensor
is unknown but was last seen open is assumed open only until that time after it
was last seen, never renewed. One last seen closed, or never seen since setup, is
assumed closed, and the forecast is flagged as uncertain (`uncertain_windows`).
* **Relay:** the candidate plan being evaluated. * **Relay:** the candidate plan being evaluated.
For the plan search the step is compiled into exact per-zone coefficients (every For the plan search the step is compiled into exact per-zone coefficients (every
@@ -177,18 +225,55 @@ they describe what would happen under the MPC's proposed plan.
## Validation ## Validation
The Forecast Error sensors score forecasts that are valid for comparison with Two separate measurements, because they answer different questions.
measured temperatures. At every poll the model parameters are frozen; 15, 30, 60
and 120 minutes later all zones are replayed from that poll using the relay,
electrical heat and window history that actually followed (outdoor temperature held
at its start value), and compared with the readings. The base layer and a
no-change forecast are scored on exactly the same spans:
* `mae`, `bias`: the full model's error; **Model accuracy (Model Error sensors)** scores the model, not a forecast. At
every poll the model parameters are frozen; 15, 30, 60 and 120 minutes later all
zones are replayed from that poll using the relay, electrical heat and window
history that actually followed (outdoor temperature held at its start value),
and compared with the readings. The base layer and a no-change forecast are
scored on exactly the same spans:
* `mae`, `rmse`, `bias`: the full model's error;
* `persistence_mae`, `skill`: against assuming no change; * `persistence_mae`, `skill`: against assuming no change;
* `base_model_mae`, `improvement_vs_base`: against the base layer alone. A feature * `base_model_mae`, `improvement_vs_base`: against the base layer alone. A feature
that does not improve this should be switched off (for example the gust term). that does not improve this should be switched off (for example the gust term).
A span counts only if every input the zone's model used was observed at every
step (see *Ventilation layer*; for the extended layer also electrical heat and
neighbours, whose own replay must be complete). Otherwise it is counted as
`incomplete`, with the base model's error on it kept apart
(`incomplete_base_model_mae`), since the base model needs only the relay and the
outdoor temperature. Spans that cannot be replayed (uncertain radiator lag, no
reading at the end) are counted as `skipped`.
**Forecast accuracy (Forecast Error sensor)** scores forecasts as published: the
start readings, trajectory, plan, model revision and assumptions of the moment
they were made, nothing replaced with hindsight. Because the plan is the MPC's
proposal while Generic Thermostat controls the relay, a forecast is compared only
when the relay followed it. The deviation is the time, up to the horizon, the
relay spent in another state than planned, compared block by block from the
relay's own switch times; unobserved relay time counts as possible deviation.
| Deviation (worst case) | Class | Used for |
|---|---|---|
| ≤ 2 min | matched | Forecast Error figures |
| ≤ 5 min, ≤ 2 min unobserved | approximate | `approximate_stats` only |
| > 5 min observed | counterfactual | counted, never an error |
| otherwise | incomplete | counted, with a reason |
Forecasts made with an assumed input (an unknown window or electrical heat) are
incomplete as well. The limits follow from the model: d seconds of unplanned
boiler heat change a room by about heat × d / 300 with the learned `heat`
coefficient (typically 0.05–0.12 °C per 5 minutes), so 2 minutes stay below half a
0.1 °C sensor step and 5 minutes are about one step.
Both report how much evidence there is. Forecasts start every 5 minutes, so at
horizon h consecutive forecasts share most of their span, and all zones share one
boiler: `effective_samples = samples × 5 / h / zones`. Below 5 the quality is
`insufficient` and the sensor's state stays unknown; below 30 it is
`indicative`.
Results on synthetic apartments (the tests; these check the method, not real-world Results on synthetic apartments (the tests; these check the method, not real-world
accuracy): accuracy):
@@ -214,5 +299,8 @@ the contamination the separate ventilation layer prevents.
variation their split is uncertain even when their combined effect is accurate. variation their split is uncertain even when their combined effect is accurate.
* Forecasts hold weather constant and use one scenario for electrical heat and * Forecasts hold weather constant and use one scenario for electrical heat and
windows; no forecast uncertainty band is computed yet. windows; no forecast uncertainty band is computed yet.
* Window sensors with a permanently unknown state keep their zone out of learning; * Window sensors with a permanently unknown state keep their zone out of learning
disable such a window. and its Model Error spans incomplete; disable such a window.
* A window that is unknown and was last seen closed (or never seen) is assumed
closed in forecasts, flagged as uncertain; no open/closed scenario pair is
computed.
+134
View File
@@ -0,0 +1,134 @@
"""Unload, reload and removal while work is still in flight, in the real HA lifecycle."""
import asyncio
import threading
from datetime import timedelta
from unittest.mock import patch
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import async_fire_time_changed
from custom_components.heating_mpc import coordinator as coordinator_module
from custom_components.heating_mpc.const import DOMAIN
REAL_OPTIMIZE = coordinator_module.optimize
async def setup(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]
def trained(coordinator):
model = coordinator.learners["sensor.room"]
model.count, model.on_count, model.off_count = 250, 125, 125
coordinator._update_counts()
class BlockedOptimizer:
"""Holds the optimizer in its worker thread until released."""
def __init__(self):
self.started, self.release = threading.Event(), threading.Event()
def __call__(self, *args, **kwargs):
self.started.set()
self.release.wait(5)
return REAL_OPTIMIZE(*args, **kwargs)
async def wait_started(self):
# Polled on the event loop: the test executor may run one job at a time.
for _ in range(500):
if self.started.is_set():
return True
await asyncio.sleep(0.01)
return False
def status_id(hass, entry):
return er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status")
async def test_unload_during_optimization_publishes_nothing(hass, entry, inputs):
coordinator = await setup(hass, entry)
trained(coordinator)
shown = coordinator.data
blocked = BlockedOptimizer()
with patch.object(coordinator_module, "optimize", blocked):
refresh = hass.async_create_task(coordinator.async_refresh())
assert await blocked.wait_started()
assert await hass.config_entries.async_unload(entry.entry_id)
state_after_unload = hass.states.get(status_id(hass, entry))
blocked.release.set()
await refresh
await hass.async_block_till_done()
# The plan finished after unloading began: kept out of the data and the states.
assert coordinator.data is shown
assert coordinator.data["status"] == "learning"
assert hass.states.get(status_id(hass, entry)) == state_after_unload
async def test_reload_during_optimization_shows_only_the_new_coordinator(hass, entry, inputs):
old = await setup(hass, entry)
trained(old) # only in memory: the reloaded coordinator is not ready
blocked = BlockedOptimizer()
with patch.object(coordinator_module, "optimize", blocked):
refresh = hass.async_create_task(old.async_refresh())
assert await blocked.wait_started()
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
new = hass.data[DOMAIN][entry.entry_id]
blocked.release.set()
await refresh
await hass.async_block_till_done()
assert new is not old and old.closing
assert old.data["status"] != "ready"
assert hass.states.get(status_id(hass, entry)).state == new.data["status"] == "learning"
async def test_removal_during_a_save_leaves_no_model_behind(hass, entry, inputs, freezer):
coordinator = await setup(hass, entry)
started, release = asyncio.Event(), asyncio.Event()
write = coordinator._store.async_save
async def slow_save(data):
started.set()
await release.wait()
await write(data)
coordinator._store.async_save = slow_save
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
freezer.move_to(future)
async_fire_time_changed(hass, future) # a learning poll, which saves
await asyncio.wait_for(started.wait(), 5)
removal = hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
for _ in range(5):
await asyncio.sleep(0)
assert not removal.done() # deletion waits for the save in progress
release.set()
assert await removal
await hass.async_block_till_done()
assert await Store(hass, 1, f"{DOMAIN}.{entry.entry_id}").async_load() is None
async def test_repeated_setup_and_unload_leave_nothing_running(hass, entry, inputs, freezer):
entry.add_to_hass(hass)
coordinators = []
for _ in range(3):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinators.append(hass.data[DOMAIN][entry.entry_id])
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.entry_id not in hass.data[DOMAIN]
assert all(c.closing and c._unsub_refresh is None and not c._listeners for c in coordinators)
counts = [c.learners["sensor.room"].count for c in coordinators]
hass.states.async_set("switch.boiler", "on")
future = dt_util.utcnow() + timedelta(minutes=10)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert not any(c._relay_events for c in coordinators)
assert [c.learners["sensor.room"].count for c in coordinators] == counts
+66 -7
View File
@@ -21,6 +21,55 @@ async def setup_entry(hass, entry):
return hass.data[DOMAIN][entry.entry_id] 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): 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"}) 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}) 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): async def test_options_flow_keeps_settings_without_rooms(hass, entry, inputs):
await setup_entry(hass, entry) 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} 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}) 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"], 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_max_age_minutes"] == 45
assert coordinator.config["room_stale_use_last_value"] is True assert coordinator.config["room_stale_use_last_value"] is True
assert coordinator.rooms == ["sensor.room"] # zones are untouched by Configure 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 assert result["data_schema"](MAIN)["room_stale_use_last_value"] is True
hass.config_entries.options.async_abort(result["flow_id"]) hass.config_entries.options.async_abort(result["flow_id"])
async def test_weather_source_may_not_be_a_zone_sensor(hass, entry, inputs): async def test_weather_source_may_not_be_a_zone_sensor(hass, entry, inputs):
await setup_entry(hass, entry) 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"](MAIN))
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.room"]})) result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.room"]}))
@@ -132,8 +181,11 @@ async def test_zone_subentry_add_edit_and_validation(hass, entry, inputs):
"name": " Living ", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 2, "name": " Living ", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 2,
"target_offset": 0.5, "neighbors": ["sensor.room"]}) "target_offset": 0.5, "neighbors": ["sensor.room"]})
assert result["step_id"] == "sensor_weights" assert result["step_id"] == "sensor_weights"
result = await flows.async_configure(result["flow_id"], { # Numbered, translated fields; the description says which sensor is which.
"weight__sensor.living_a": 3, "weight__sensor.living_b": 1}) assert [str(key) for key in result["data_schema"].schema] == ["sensor_weight_1", "sensor_weight_2"]
assert result["description_placeholders"]["sensors"].splitlines() == [
"1. living a (sensor.living_a)", "2. living b (sensor.living_b)"] # HA name fallback
result = await flows.async_configure(result["flow_id"], {"sensor_weight_1": 3, "sensor_weight_2": 1})
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done() await hass.async_block_till_done()
living = next(sub for sub in zones_of(entry).values() if sub.title == "Living") living = next(sub for sub in zones_of(entry).values() if sub.title == "Living")
@@ -149,6 +201,13 @@ async def test_zone_subentry_add_edit_and_validation(hass, entry, inputs):
result = await flows.async_configure(result["flow_id"], { result = await flows.async_configure(result["flow_id"], {
"name": "Living", "sensors": ["sensor.living_a"], "comfort_weight": 1, "target_offset": 0}) "name": "Living", "sensors": ["sensor.living_a"], "comfort_weight": 1, "target_offset": 0})
assert result["type"] is FlowResultType.ABORT and result["reason"] == "reconfigure_successful" assert result["type"] is FlowResultType.ABORT and result["reason"] == "reconfigure_successful"
# Editing a multi-sensor zone shows the saved sensor weights.
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", "sensor.living_b"], "comfort_weight": 1, "target_offset": 0})
assert result["data_schema"]({})["sensor_weight_1"] == 1.0
flows.async_abort(result["flow_id"])
await hass.async_block_till_done() await hass.async_block_till_done()
assert entry.subentries[living.subentry_id].data["key"] == key assert entry.subentries[living.subentry_id].data["key"] == key
assert hass.data[DOMAIN][entry.entry_id].zones[key].sensors == ("sensor.living_a",) assert hass.data[DOMAIN][entry.entry_id].zones[key].sensors == ("sensor.living_a",)
@@ -162,7 +221,7 @@ async def test_options_flow_clears_optional_weather_sources(hass, inputs):
"weights": {"sensor.room": 1.0}}) "weights": {"sensor.room": 1.0}})
await setup_entry(hass, entry) await setup_entry(hass, entry)
flow = hass.config_entries.options 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)) result = await flow.async_configure(result["flow_id"], result["data_schema"](MAIN))
markers = {str(key): key for key in result["data_schema"].schema} markers = {str(key): key for key in result["data_schema"].schema}
assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]} assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]}
@@ -235,7 +294,7 @@ async def test_window_subentry_flow_and_ventilation_options(hass, entry, inputs)
("sensor.gust", "9", "m/s")): ("sensor.gust", "9", "m/s")):
hass.states.async_set(sensor, value, {"unit_of_measurement": unit}) hass.states.async_set(sensor, value, {"unit_of_measurement": unit})
options = hass.config_entries.options 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"]( result = await options.async_configure(result["flow_id"], result["data_schema"](
{**MAIN, "ventilation_gust": True, "ventilation_cross": True})) {**MAIN, "ventilation_gust": True, "ventilation_cross": True}))
result = await options.async_configure(result["flow_id"], result["data_schema"]({ result = await options.async_configure(result["flow_id"], result["data_schema"]({
+89 -4
View File
@@ -39,7 +39,7 @@ async def test_setup_reload_unload(hass, entry, inputs):
assert coordinator.config_entry is entry assert coordinator.config_entry is entry
registry = er.async_get(hass) registry = er.async_get(hass)
entities = er.async_entries_for_config_entry(registry, entry.entry_id) entities = er.async_entries_for_config_entry(registry, entry.entry_id)
assert len(entities) == 11 # 7 core + 4 forecast-error sensors assert len(entities) == 12 # 7 core + 4 model-error sensors + 1 forecast-error sensor
ids = {e.unique_id: e.entity_id for e in entities} ids = {e.unique_id: e.entity_id for e in entities}
assert coordinator.data["status"] == "learning" assert coordinator.data["status"] == "learning"
status = hass.states.get(ids[f"{entry.entry_id}_status"]) status = hass.states.get(ids[f"{entry.entry_id}_status"])
@@ -74,7 +74,7 @@ async def test_replacing_a_zone_removes_only_its_forecast(hass, entry, inputs):
assert registry.async_get(old) is None assert registry.async_get(old) is None
new = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_zone_new_predicted_60min") new = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_zone_new_predicted_60min")
assert hass.states.get(new).attributes["friendly_name"] == "MPC New 60min Prediction" assert hass.states.get(new).attributes["friendly_name"] == "MPC New 60min Prediction"
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 11 assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 12
async def test_storage_restore_and_remove(hass, entry, inputs): async def test_storage_restore_and_remove(hass, entry, inputs):
@@ -149,6 +149,45 @@ async def test_scheduled_sample_saves_and_unload_stops_polling(hass, entry, inpu
assert coordinator.learners["sensor.room"].count == 1 assert coordinator.learners["sensor.room"].count == 1
async def test_reloads_leave_one_relay_listener_and_one_polling_coordinator(hass, entry, inputs, freezer):
coordinators = [await setup(hass, entry)]
for _ in range(3):
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
coordinators.append(hass.data[DOMAIN][entry.entry_id])
*old, current = coordinators
assert len({id(c) for c in coordinators}) == 4
# A relay change reaches only the current coordinator.
hass.states.async_set("switch.boiler", "on")
await hass.async_block_till_done()
assert [state for _, state in current._relay_events] == ["on"]
assert not any(c._relay_events for c in old)
# Only the current coordinator polls; the replaced ones neither learn nor publish.
status_id = er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status")
updates = []
hass.bus.async_listen("state_changed", lambda event: updates.append(event.data["entity_id"]))
before = [(c.learners["sensor.room"].count, c.data) for c in old]
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert [(c.learners["sensor.room"].count, c.data) for c in old] == before
assert updates.count(status_id) <= 1
assert hass.states.get(status_id).state == current.data["status"]
# After unload, a relay change is recorded nowhere and nothing is published.
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
events = list(current._relay_events)
updates.clear()
hass.states.async_set("switch.boiler", "off")
future += timedelta(minutes=5)
freezer.move_to(future)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert list(current._relay_events) == events
assert status_id not in updates
@pytest.mark.parametrize("payload", ["broken", {"learners": []}, {"learners": {"sensor.room": {"theta": []}}}]) @pytest.mark.parametrize("payload", ["broken", {"learners": []}, {"learners": {"sensor.room": {"theta": []}}}])
async def test_corrupt_saved_payload_starts_learning(hass, entry, inputs, hass_storage, payload): async def test_corrupt_saved_payload_starts_learning(hass, entry, inputs, hass_storage, payload):
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": payload} hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": payload}
@@ -255,13 +294,37 @@ async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs,
async_fire_time_changed(hass, future) async_fire_time_changed(hass, future)
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get(ids[15]) state = hass.states.get(ids[15])
assert coordinator.data["forecast_accuracy"][15]["samples"] == 1 accuracy = coordinator.data["forecast_accuracy"][15]
assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0 assert accuracy["samples"] == 1
# One replayed forecast is no accuracy figure: the state stays unknown, the numbers
# are in the attributes with their quality.
assert state.state == "unknown"
assert state.attributes["mae"] == accuracy["mae"] > 0
assert state.attributes["quality"] == "insufficient"
assert state.attributes["samples"] == 1 assert state.attributes["samples"] == 1
assert (state.attributes["incomplete"], state.attributes["skipped"]) == (0, 0)
assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name
assert state.attributes["unit_of_measurement"] == "°C" assert state.attributes["unit_of_measurement"] == "°C"
assert state.attributes["friendly_name"] == "Heating MPC Model Error 15 min"
assert "zones" in state.state_info["unrecorded_attributes"] assert "zones" in state.state_info["unrecorded_attributes"]
assert hass.states.get(ids[30]).state == "unknown" assert hass.states.get(ids[30]).state == "unknown"
for step in range(15): # enough 15-minute spans for an indicative figure
future += timedelta(minutes=5, seconds=1)
freezer.move_to(future)
# Fresh readings, or the sensors turn stale after 30 minutes.
hass.states.async_set("sensor.room", str(20 - 0.01 * step), {"unit_of_measurement": "°C"})
hass.states.async_set("sensor.outside", "5", {"unit_of_measurement": "°C"}, force_update=True)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
accuracy = coordinator.data["forecast_accuracy"][15]
assert accuracy["quality"] == "indicative", accuracy
assert float(hass.states.get(ids[15]).state) == accuracy["mae"]
# Published forecasts are scored separately; nothing was published while learning.
operational = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_operational_forecast_error")
state = hass.states.get(operational)
assert state.state == "unknown"
assert state.attributes["friendly_name"] == "Heating MPC Forecast Error"
assert "horizons" in state.state_info["unrecorded_attributes"]
async def test_learning_progress_and_last_sample_sensors(hass, entry, inputs, freezer): async def test_learning_progress_and_last_sample_sensors(hass, entry, inputs, freezer):
@@ -305,3 +368,25 @@ async def test_deleting_the_last_zone_keeps_the_entry_loaded(hass, entry, inputs
add_zone(hass, entry, "sensor.room", ["sensor.room"]) add_zone(hass, entry, "sensor.room", ["sensor.room"])
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "learning" assert hass.data[DOMAIN][entry.entry_id].data["status"] == "learning"
async def test_zone_edits_recalibrate_only_when_the_measurement_changes(hass, entry, inputs, hass_storage):
from custom_components.heating_mpc.physics import Learner
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()}}}
await setup(hass, entry)
zone = zone_subentry(entry, "sensor.room")
hass.config_entries.async_update_subentry(entry, zone, data={**zone.data, "comfort_weight": 3.0})
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.learners["sensor.room"].count == 250 # comfort is not physics
hass.states.async_set("sensor.second", "21", {"unit_of_measurement": "°C"})
hass.config_entries.async_update_subentry(entry, zone, data={
**zone.data, "sensors": ["sensor.room", "sensor.second"],
"sensor_weights": {"sensor.room": 1.0, "sensor.second": 1.0}})
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.learners["sensor.room"].count == 0
status = hass.states.get(er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
assert status.attributes["model_changes"][-1]["reasons"] == ["zone sensors or sensor weights changed"]
+94
View File
@@ -0,0 +1,94 @@
"""Configuration fingerprints: what invalidates which learned coefficient."""
import sys
import types
import unittest
from pathlib import Path
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
from heating_mpc import compatibility as cp, heat_sources as hs, windows as wm, zones as zn # noqa: E402
def setup(zones=None, sources=None, windows=None, recovery=20):
zones = zones or [{'key': 'living', 'name': 'Living', 'sensors': ['sensor.l1', 'sensor.l2'],
'sensor_weights': {'sensor.l1': 1, 'sensor.l2': 1}, 'neighbors': ['bed']},
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.b']}]
built = {z.key: z for z in zn.build_zones((z, None) for z in zones)}
sources = hs.build_sources(((s, None) for s in (sources if sources is not None else [
{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living'},
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}])), built)
windows = wm.build_windows(((w, None) for w in (windows if windows is not None else [
{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 345},
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}])), built)
return cp.zone_fingerprints(built, sources, windows, recovery), sources
def differing(a, b):
return {zone: sorted(k for k in set(a[zone]) | set(b[zone]) if a[zone].get(k) != b[zone].get(k))
for zone in a if zone in b and a[zone] != b[zone]}
class FingerprintTests(unittest.TestCase):
def setUp(self):
self.base, _ = setup()
def test_deterministic_and_comfort_settings_are_not_physical(self):
self.assertEqual(setup()[0], self.base)
zones = [{'key': 'living', 'name': 'Renamed', 'sensors': ['sensor.l1', 'sensor.l2'], 'comfort_weight': 5,
'target_offset': 1.5, 'sensor_weights': {'sensor.l1': 2, 'sensor.l2': 2}, 'neighbors': ['bed']},
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.b'], 'comfort_weight': 3}]
self.assertEqual(differing(self.base, setup(zones)[0]), {}) # equal weights scaled: same average
def test_measurement_changes_reset_the_whole_zone_and_its_neighbour_term(self):
for sensors, weights in ((['sensor.l1', 'sensor.l2'], {'sensor.l1': 3, 'sensor.l2': 1}), # weight ratio
(['sensor.l1', 'sensor.l3'], {}), # replaced
(['sensor.l1'], {})): # removed
with self.subTest(sensors=sensors, weights=weights):
zones = [{'key': 'living', 'sensors': sensors, 'sensor_weights': weights, 'neighbors': ['bed']},
{'key': 'bed', 'sensors': ['sensor.b']}]
diff = differing(self.base, setup(zones)[0])
self.assertEqual(set(diff['living']), set(self.base['living']))
self.assertEqual(diff['bed'], ['couple:living']) # the bedroom's own model is untouched
def test_heat_source_changes_reset_only_the_electrical_term(self):
moved = [{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'bed'},
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}]
self.assertEqual(differing(self.base, setup(sources=moved)[0]), {'living': ['internal'], 'bed': ['internal']})
for change in ({'sensor': 'sensor.ups2'}, {'heat_fraction': 0.5}, {'response_minutes': 30},
{'baseline_mode': 'manual', 'baseline_w': 150}):
with self.subTest(change=change):
sources = [{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living', **change},
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}]
self.assertEqual(differing(self.base, setup(sources=sources)[0]), {'living': ['internal']})
def test_window_changes_are_selective(self):
turned = [{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 300},
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}]
self.assertEqual(differing(self.base, setup(windows=turned)[0]), {'bed': ['vent_wind_dir']})
bigger = [{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 345, 'opening_factor': 2},
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}]
diff = differing(self.base, setup(windows=bigger)[0])
self.assertEqual(diff['bed'], ['recovery', 'vent_cross', 'vent_gust', 'vent_natural', 'vent_wind',
'vent_wind_dir'])
self.assertEqual(diff['living'], ['vent_cross']) # sees the bedroom's windows
# A flipped polarity reverses what every term saw as open, the neighbour's too.
flipped = [{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 345, 'inverted': True},
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}]
diff = differing(self.base, setup(windows=flipped)[0])
self.assertIn('vent_natural', diff['bed'])
self.assertEqual(diff['living'], ['vent_cross'])
self.assertEqual(differing(self.base, setup(recovery=40)[0]), {'living': ['recovery'], 'bed': ['recovery']})
def test_baseline_prints_and_change_detection(self):
_, sources = setup()
prints = cp.baseline_fingerprints(sources)
_, without_pc = setup(sources=[{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living'}])
self.assertNotEqual(prints['ups'], cp.baseline_fingerprints(without_pc)['ups'])
self.assertEqual(cp.changed(None, self.base['living'], ['base']), []) # 0.4.0 data: assumed to match
self.assertEqual(cp.changed({'base': 'x'}, self.base['living'], ['base', 'internal']), ['base'])
self.assertEqual(cp.reason('couple:bed'), "a neighbouring zone's sensors changed")
if __name__ == '__main__':
unittest.main()
+394
View File
@@ -2,6 +2,7 @@
import importlib.util import importlib.util
import sys import sys
import types import types
import asyncio
import unittest import unittest
import unittest.mock import unittest.mock
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -23,6 +24,9 @@ class CoordinatorBase:
async def async_config_entry_first_refresh(self): async def async_config_entry_first_refresh(self):
self.data = await self._async_update_data() self.data = await self._async_update_data()
async def async_shutdown(self):
self._shutdown_requested = True
class Store: class Store:
def __init__(self, *args): def __init__(self, *args):
@@ -71,6 +75,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
config=types.SimpleNamespace(units=types.SimpleNamespace(temperature_unit='°C')), config=types.SimpleNamespace(units=types.SimpleNamespace(temperature_unit='°C')),
services=types.SimpleNamespace(async_call=Mock(side_effect=AssertionError('No service calls permitted'))), services=types.SimpleNamespace(async_call=Mock(side_effect=AssertionError('No service calls permitted'))),
track_state=Mock(return_value=Mock(name='unsub_relay')), track_state=Mock(return_value=Mock(name='unsub_relay')),
async_create_task=lambda coro, name=None, eager_start=True: asyncio.ensure_future(coro),
registry=types.SimpleNamespace(entries=[], async_remove=Mock()), registry=types.SimpleNamespace(entries=[], async_remove=Mock()),
config_entries=types.SimpleNamespace( config_entries=types.SimpleNamespace(
async_forward_entry_setups=AsyncMock(), async_unload_platforms=AsyncMock(return_value=True), async_forward_entry_setups=AsyncMock(), async_unload_platforms=AsyncMock(return_value=True),
@@ -784,6 +789,11 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(details['model_in_use'], 'extended') self.assertEqual(details['model_in_use'], 'extended')
self.assertGreater(details['contributions_c_per_h']['internal'], 0.2) self.assertGreater(details['contributions_c_per_h']['internal'], 0.2)
self.assertTrue(details['coefficients']['extended']['internal']['identified']) self.assertTrue(details['coefficients']['extended']['internal']['identified'])
# The base layer reports how well each coefficient is pinned down too.
base = details['coefficients']['base']
self.assertEqual(set(base), {'loss', 'heat', 'bias'})
self.assertEqual(base['heat']['confidence'], data['models']['office']['confidence']['heat'])
self.assertGreater(base['heat']['confidence'], 0.8)
# Replayed against what happened, the electrical term removes most of the base error. # Replayed against what happened, the electrical term removes most of the base error.
accuracy = data['forecast_accuracy'][60] accuracy = data['forecast_accuracy'][60]
self.assertLess(accuracy['mae'], 0.06) self.assertLess(accuracy['mae'], 0.06)
@@ -1066,3 +1076,387 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
await grown.async_restore_model() await grown.async_restore_model()
self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross') self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross')
self.assertEqual(grown.ventilation['bed'].theta[:-1], vent.theta) 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))
ZONES = [{'key': 'living', 'name': 'Living', 'sensors': ['sensor.room'], 'neighbors': ['bed']},
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.bedroom']}]
SOURCES = [{'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'living'}]
WINDOWS = [{'key': 'w', 'name': 'Bed window', 'sensor': 'binary_sensor.bed_window', 'zone': 'bed',
'azimuth': 345}]
def configured(self, zones=None, sources=None, windows=None):
self.entry.data.update(outside_wind_sources=['sensor.wind'], outside_wind_direction_sources=['sensor.wind_dir'])
self.entry.subentries = {}
for kind, items in (('zone', zones or self.ZONES), ('heat_source', self.SOURCES if sources is None else sources),
('window', self.WINDOWS if windows is None else windows)):
for i, data in enumerate(items):
self.entry.subentries[f'{kind}{i}'] = types.SimpleNamespace(
subentry_id=f'{kind}{i}', subentry_type=kind, data=data)
return coordinator.HeatingMPCCoordinator(self.hass, self.entry)
def trained_payload(self):
c = self.configured()
for key in ('living', 'bed'):
base = c.learners[key]
base.count, base.on_count, base.off_count, base.theta = 300, 100, 100, [0.004, 0.12, 0.001]
living = c.extended['living']
living.count, living.on_count, living.off_count = 300, 100, 100
living.theta = [0.004, 0.12, 0.001, 0.15, 0.008]
vent = c.ventilation['bed']
vent.count = 80
vent.theta[vent.features.index('vent_natural')] = 0.01
vent.theta[vent.features.index('vent_wind_dir')] = 0.0005
c._evaluator._days = {'2026-10-27': {'60': {'living': [10, 0.1, 0.2, 0.3, 0.4, 0.05, 10],
'bed': [10, 0.1, 0.2, 0.3, 0.4, 0.05, 10]}}}
c._heat_sources._baselines['ups'].samples.extend([200.0] * 24)
return c._payload('sensor.outside')
async def restored(self, payload, **config):
c = self.configured(**config)
c._store.async_load.return_value = payload
await c.async_restore_model()
return c
async def test_comfort_settings_keep_every_learned_coefficient(self):
payload = self.trained_payload()
zones = [{**self.ZONES[0], 'name': 'Lounge', 'comfort_weight': 4, 'target_offset': 1},
{**self.ZONES[1], 'comfort_weight': 0.5}]
c = await self.restored(payload, zones=zones)
self.assertEqual((c.learners['living'].count, c.extended['living'].theta, c.ventilation['bed'].count),
(300, [0.004, 0.12, 0.001, 0.15, 0.008], 80))
self.assertEqual(list(c._model_changes), [])
c._store.async_save.assert_not_called()
async def test_changed_zone_sensors_recalibrate_only_that_zone(self):
payload = self.trained_payload()
zones = [{**self.ZONES[0], 'sensors': ['sensor.room', 'sensor.second']}, self.ZONES[1]]
c = await self.restored(payload, zones=zones)
base = c.learners['living']
self.assertEqual((base.theta, base.count, base.p), ([0.004, 0.12, 0.001], 0, coordinator.Learner().p)) # prior uncertainty
living = c.extended['living']
self.assertEqual((living.count, living.theta[:3], living.coefficient('internal')), (0, [0.004, 0.12, 0.001], 0.0))
# The bedroom keeps its model; only its exchange term with the living room restarts.
self.assertEqual((c.learners['bed'].count, c.ventilation['bed'].count), (300, 80))
self.assertEqual(c.extended['bed'].coefficient('couple:living'), 0.0)
self.assertEqual(set(c._evaluator._days['2026-10-27']['60']), {'bed'}) # living measured differently now
change = next(ch for ch in c._model_changes if ch.get('zone') == 'Living')
self.assertEqual(change['reasons'], ['zone sensors or sensor weights changed'])
# Saved at once with the new fingerprints: another restart changes nothing.
saved = c._store.async_save.call_args.args[0]
again = await self.restored(saved, zones=zones)
self.assertEqual(len(again._model_changes), len(c._model_changes))
again._store.async_save.assert_not_called()
async def test_turned_window_resets_only_the_direction_term(self):
payload = self.trained_payload()
c = await self.restored(payload, windows=[{**self.WINDOWS[0], 'azimuth': 300}])
vent = c.ventilation['bed']
self.assertEqual((vent.coefficient('vent_natural'), vent.coefficient('vent_wind_dir'), vent.count),
(0.01, 0.0, 80))
self.assertEqual((c.learners['bed'].count, c.extended['living'].coefficient('internal')), (300, 0.15))
self.assertEqual(c._model_changes[-1]['reasons'], ['window orientation changed'])
async def test_moved_or_replaced_heat_source(self):
payload = self.trained_payload()
c = await self.restored(payload, sources=[{**self.SOURCES[0], 'zone': 'bed'}])
self.assertEqual(c.extended['living'].coefficient('internal'), 0.0)
self.assertEqual(c.extended['living'].coefficient('couple:bed'), 0.008) # unrelated term kept
self.assertEqual(c.extended['bed'].coefficient('internal'), 0.0) # new there, from the prior
self.assertEqual(len(c._heat_sources._baselines['ups'].samples), 24) # same sensor: same load
replaced = await self.restored(payload, sources=[{**self.SOURCES[0], 'sensor': 'sensor.ups2'}])
self.assertEqual(len(replaced._heat_sources._baselines['ups'].samples), 0)
self.assertEqual(replaced.extended['living'].coefficient('internal'), 0.0)
self.assertIn('baseline', replaced._model_changes[-1]['recalibrated'])
async def test_v040_data_without_fingerprints_is_kept_and_then_fingerprinted(self):
payload = self.trained_payload()
del payload['fingerprints']
c = await self.restored(payload)
self.assertEqual((c.learners['living'].count, c.extended['living'].coefficient('internal')), (300, 0.15))
# Fingerprinted at once, so an edit right after the upgrade is detected.
self.assertEqual(c._store.async_save.call_args.args[0]['fingerprints'], c._fingerprints)
async def test_re_added_zone_with_other_sensors_is_recalibrated(self):
payload = self.trained_payload()
without = await self.restored(payload, zones=[self.ZONES[1]], sources=[])
self.assertIn('living', without._retired)
saved = without._payload('sensor.outside')
back = await self.restored(saved, zones=[{**self.ZONES[0], 'sensors': ['sensor.other']}, self.ZONES[1]])
self.assertEqual((back.learners['living'].theta, back.learners['living'].count), ([0.004, 0.12, 0.001], 0))
def ready_office(self):
"""Office with a UPS and a window, trained enough to optimize."""
self.entry.subentries = {
'z': types.SimpleNamespace(subentry_id='z', subentry_type='zone', data={
'key': 'office', 'name': 'Office', 'sensors': ['sensor.room']}),
'h': types.SimpleNamespace(subentry_id='h', subentry_type='heat_source', data={
'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'office'}),
'w': types.SimpleNamespace(subentry_id='w', subentry_type='window', data={
'key': 'w', 'name': 'Window', 'sensor': 'binary_sensor.office_window', 'zone': 'office'})}
self.set_state('sensor.ups_power', '200', unit_of_measurement='W')
self.set_state('binary_sensor.office_window', 'off')
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
base = self.c.learners['office']
base.count, base.on_count, base.off_count = 200, 100, 100
async def test_reload_with_an_unavailable_window_keeps_its_last_known_state_uncertain(self):
self.ready_office()
await self.sample()
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
window = self.states['binary_sensor.office_window']
window.state, window.last_changed = 'on', self.now - timedelta(seconds=100)
self.c._store.async_save.reset_mock()
await self.sample()
self.c._store.async_save.assert_awaited() # a window change is saved even without learning
payload = self.c._store.async_save.await_args.args[0]
# Reload: a new coordinator restores the saved state; the sensor is now unavailable.
window.state = 'unavailable'
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
successor = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
successor._store.async_load.return_value = payload
await successor.async_restore_model()
base = successor.learners['office']
base.count, base.on_count, base.off_count = 200, 100, 100
successor._update_counts()
self.c = successor
data = await self.sample()
info = data['windows']['Window']
self.assertEqual((info['state'], info['last_known_state'], info['forecast_assumption']),
('unknown', 'open', 'last_known_open'))
self.assertGreater(info['forecast_open_minutes'], 0)
self.assertEqual(data['zone_details']['office']['uncertain_windows'], ['Window'])
self.assertTrue(data['ventilation']['Office']['affected'])
self.assertEqual(data['sample_result'], 'first_sample') # nothing learned across the reload
async def change_during_optimization(self, change):
async def worker(job):
output = job()
change()
return output
self.hass.async_add_executor_job = worker
return await self.sample()
async def test_meaningful_input_changes_during_optimization_discard_the_plan(self):
cases = {
'window: binary_sensor.office_window': lambda: setattr(self.states['binary_sensor.office_window'], 'state', 'on'),
'power: sensor.ups_power': lambda: setattr(self.states['sensor.ups_power'], 'state', '600'),
'thermostat: climate.reference': lambda: self.states['climate.reference'].attributes.update(temperature=21),
'temperature: sensor.room': lambda: setattr(self.states['sensor.room'], 'state', '20.6'),
}
for expected, change in cases.items():
with self.subTest(change=expected):
self.setUp()
self.ready_office()
data = await self.change_during_optimization(change)
self.assertEqual((data['status'], data['proposal'], data['changed_inputs']),
('inputs_changed', None, [expected]))
async def test_sensor_noise_during_optimization_does_not_discard_the_plan(self):
self.ready_office()
def noise():
self.states['sensor.room'].state = '20.1'
self.states['sensor.ups_power'].state = '230'
self.assertEqual((await self.change_during_optimization(noise))['status'], 'ready')
async def test_an_older_run_finishing_late_never_overwrites_a_newer_result(self):
self.ready_office()
release = asyncio.Event()
async def slow_then_fast(job):
if not release.is_set() and not getattr(self, '_first_started', False):
self._first_started = True
await release.wait()
return job()
self.hass.async_add_executor_job = slow_then_fast
with patch.object(coordinator, 'datetime') as clock:
clock.now.return_value = self.now
older = asyncio.ensure_future(self.c._async_update_data())
for _ in range(50): # until the older run is inside the optimizer (it may save first)
if getattr(self, '_first_started', False):
break
await asyncio.sleep(0)
self.assertTrue(self._first_started)
clock.now.return_value = self.now + timedelta(seconds=60)
newer = await self.c._async_update_data()
self.c.data = newer # what DataUpdateCoordinator publishes
release.set()
result = await older
self.assertEqual(newer['status'], 'ready')
self.assertIs(result, newer)
async def test_published_forecasts_are_scored_only_when_the_relay_followed_the_plan(self):
# The relay stays off. A warm room's plan says off, so its forecast is scored;
# a cold room's plan says heat, so its forecast is counterfactual, never an error.
for room, follows in (('23.5', True), ('18', False)):
with self.subTest(room=room):
self.setUp()
self.ready_office()
self.set_state('sensor.room', room, unit_of_measurement='°C')
first = await self.sample()
self.assertEqual(first['status'], 'ready')
self.assertEqual(first['plan_15min'][0], 0 if follows else 1)
for _ in range(3):
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
data = await self.sample()
fifteen = data['operational_forecast'][15]
if follows:
self.assertEqual((fifteen['samples'], fifteen['counterfactual']), (1, 0))
predicted = first['prediction_trajectory']['office'][0]
self.assertAlmostEqual(fifteen['mae'], abs(predicted - 23.5), places=3)
self.assertEqual(fifteen['persistence_mae'], 0.0)
self.assertEqual(fifteen['quality'], 'insufficient')
else:
self.assertEqual((fifteen['samples'], fifteen['counterfactual'], fifteen['mae']), (0, 1, None))
# Longer horizons are not due yet.
self.assertEqual(data['operational_forecast'][30]['samples'], 0)
stored = self.c._payload('sensor.outside')['operational_evaluation']
counts = {}
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):
self.ready_office()
await self.sample() # a first sample to learn from
previous = self.c.data = {'status': 'learning', 'proposal': None}
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
async def unload_while_saving(payload):
self.c._shutdown_requested = True
self.c._store.async_save.side_effect = unload_while_saving
worker = AsyncMock(side_effect=AssertionError('no optimization after unload'))
self.hass.async_add_executor_job = worker
self.assertIs(await self.sample(), previous)
worker.assert_not_called()
async def test_unload_marks_shutdown_before_platforms_unload(self):
await integration.async_setup_entry(self.hass, self.entry)
c = self.hass.data['heating_mpc']['entry']
seen = []
async def unload_platforms(entry, platforms):
seen.append(c.closing)
return True
self.hass.config_entries.async_unload_platforms.side_effect = unload_platforms
self.assertTrue(await integration.async_unload_entry(self.hass, self.entry))
self.assertEqual(seen, [True])
# A failed unload leaves the entry loaded, so the coordinator carries on.
await integration.async_setup_entry(self.hass, self.entry)
c = self.hass.data['heating_mpc']['entry']
self.hass.config_entries.async_unload_platforms.side_effect = None
self.hass.config_entries.async_unload_platforms.return_value = False
self.assertFalse(await integration.async_unload_entry(self.hass, self.entry))
self.assertFalse(c.closing)
async def test_nothing_is_learned_saved_or_recorded_once_unloading_began(self):
self.ready_office()
await self.sample()
self.c._store.async_save.reset_mock() # the window's first reading is saved
shown = self.c.data = {'status': 'learning', 'proposal': None}
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
self.c.async_begin_shutdown()
count = self.c.learners['office'].count
self.assertIs(await self.sample(), shown)
self.assertEqual(self.c.learners['office'].count, count)
self.c._store.async_save.assert_not_awaited()
self.assertFalse(await self.c._async_save('sensor.outside'))
# A relay callback queued before the listener was removed is ignored.
new = types.SimpleNamespace(state='on', last_changed=self.now)
self.c.async_relay_changed(types.SimpleNamespace(
data={'old_state': types.SimpleNamespace(state='off'), 'new_state': new}, time_fired=self.now))
self.assertFalse(self.c._relay_events)
async def test_failed_setup_shuts_the_coordinator_down(self):
self.hass.config_entries.async_forward_entry_setups.side_effect = RuntimeError('test')
with patch.object(coordinator.HeatingMPCCoordinator, 'async_shutdown', autospec=True) as shutdown:
with self.assertRaises(RuntimeError):
await integration.async_setup_entry(self.hass, self.entry)
shutdown.assert_awaited_once()
async def test_simultaneous_saves_never_overlap(self):
self.ready_office()
order = []
async def slow_save(payload):
order.append('start')
await asyncio.sleep(0)
await asyncio.sleep(0)
order.append('end')
self.c._store.async_save.side_effect = slow_save
results = await asyncio.gather(*(self.c._async_save('sensor.outside') for _ in range(3)))
self.assertEqual(results, [True, True, True])
self.assertEqual(order, ['start', 'end'] * 3)
async def test_a_save_waiting_for_a_deletion_does_not_recreate_the_model(self):
self.ready_office()
order, release = [], asyncio.Event()
store = Store()
async def slow_remove():
order.append('delete started')
await release.wait()
order.append('deleted')
store.async_remove.side_effect = slow_remove
with patch.object(integration, 'Store', return_value=store):
removal = asyncio.ensure_future(integration.async_remove_entry(self.hass, self.entry))
await asyncio.sleep(0)
saving = asyncio.ensure_future(self.c._async_save('sensor.outside'))
await asyncio.sleep(0)
self.assertEqual(order, ['delete started']) # the save is waiting for the lock
release.set()
await removal
self.assertFalse(await saving)
self.assertEqual(order, ['delete started', 'deleted'])
self.c._store.async_save.assert_not_awaited()
async def test_restore_waits_for_a_save_still_in_progress(self):
self.ready_office()
order = []
release = asyncio.Event()
async def slow_save(payload):
order.append('save started')
await release.wait()
order.append('save finished')
self.c._store.async_save.side_effect = slow_save
saving = asyncio.ensure_future(self.c._async_save('sensor.outside'))
await asyncio.sleep(0)
successor = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
async def load():
order.append('load')
return None
successor._store.async_load.side_effect = load
restoring = asyncio.ensure_future(successor.async_restore_model())
await asyncio.sleep(0)
self.assertEqual(order, ['save started']) # the new coordinator waits
release.set()
await asyncio.gather(saving, restoring)
self.assertEqual(order, ['save started', 'save finished', 'load'])
+22 -1
View File
@@ -46,10 +46,31 @@ class FreshnessTests(unittest.TestCase):
self.assertEqual(history.observe('sensor.a', later, 300), 300) self.assertEqual(history.observe('sensor.a', later, 300), 300)
self.assertEqual(history.observe('sensor.b', later, 30), 30) self.assertEqual(history.observe('sensor.b', later, 30), 30)
def test_inputs_differ_ignores_noise_but_not_meaningful_changes(self):
before = {('temperature', 'sensor.room'): 20.0, ('power', 'sensor.ups'): 210.0,
('wind_direction', 'sensor.dir'): 355.0, ('wind', 'sensor.wind'): 4.0,
('window', 'binary_sensor.w'): False, ('thermostat', 'climate.t'): ('heat', 22, None)}
noise = {**before, ('temperature', 'sensor.room'): 20.1, ('power', 'sensor.ups'): 240.0,
('wind_direction', 'sensor.dir'): 5.0, ('wind', 'sensor.wind'): 4.6}
self.assertEqual(quality.inputs_differ(before, noise), []) # 10° across north is small
for key, value in ((('temperature', 'sensor.room'), 20.5), (('power', 'sensor.ups'), 610.0),
(('wind_direction', 'sensor.dir'), 40.0), (('window', 'binary_sensor.w'), True),
(('window', 'binary_sensor.w'), None), (('thermostat', 'climate.t'), ('heat', 21, None)),
(('temperature', 'sensor.room'), None)):
with self.subTest(key=key, value=value):
self.assertEqual(quality.inputs_differ(before, {**before, key: value}), [key])
self.assertEqual(quality.inputs_differ(before, {**before, ('power', 'sensor.new'): 5.0}),
[('power', 'sensor.new')])
def test_freshness_settings_translated(self): def test_freshness_settings_translated(self):
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'): for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
data = json.loads((ROOT / name).read_text()) 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'] labels = data[section]['step'][step]['data']
for key in ('room_max_age_minutes', 'weather_max_age_minutes', 'room_stale_use_last_value'): for key in ('room_max_age_minutes', 'weather_max_age_minutes', 'room_stale_use_last_value'):
self.assertTrue(labels[key]) self.assertTrue(labels[key])
+297 -3
View File
@@ -69,6 +69,16 @@ class EvaluationTests(unittest.TestCase):
self.assertEqual(uncertain.summary(['sensor.room'])[15]['samples'], 2) # k = 3 and 7 only self.assertEqual(uncertain.summary(['sensor.room'])[15]['samples'], 2) # k = 3 and 7 only
self.assertEqual(plant(8).summary(['sensor.other'])[15]['samples'], 0) self.assertEqual(plant(8).summary(['sensor.other'])[15]['samples'], 0)
def test_rmse_effective_samples_and_quality(self):
stats = plant(10, model_theta=(0.004, 0.0, 0.0005), lag_at=lambda k: 1.0).summary(['sensor.room'])
s15 = stats[15]
self.assertGreaterEqual(s15['rmse'], s15['mae'])
# 7 overlapping 15-minute forecasts started 5 minutes apart: ~2.3 independent ones.
self.assertEqual((s15['samples'], s15['effective_samples'], s15['quality']), (7, 2.3, 'insufficient'))
self.assertEqual(ev.quality(5), 'indicative')
self.assertEqual(ev.quality(30), 'ok')
self.assertEqual(ev.effective_samples(120, 60, zones=2), 5.0)
def test_window_keeps_seven_days(self): def test_window_keeps_seven_days(self):
evaluator = plant(10) evaluator = plant(10)
before = evaluator.summary(['sensor.room'])[15]['samples'] before = evaluator.summary(['sensor.room'])[15]['samples']
@@ -106,14 +116,298 @@ class EvaluationTests(unittest.TestCase):
day: {'45': {'sensor.room': [1, 0.0, 0.1, 0.1]}, day: {'45': {'sensor.room': [1, 0.0, 0.1, 0.1]},
'15': {'sensor.a': [1, 0.0, -0.1, 0.1], 'sensor.b': [True, 0, 0, 0], '15': {'sensor.a': [1, 0.0, -0.1, 0.1], 'sensor.b': [True, 0, 0, 0],
'sensor.c': [1, float('nan'), 0, 0], 'sensor.d': [2, 0.1, 0.2, 0.4], 'sensor.c': [1, float('nan'), 0, 0], 'sensor.d': [2, 0.1, 0.2, 0.4],
'sensor.e': [2, 0.1, 0.2, 0.4, 0.3], 'sensor.f': [2, 0.1, 0.2, 0.4, -1]}}}}) 'sensor.e': [2, 0.1, 0.2, 0.4, 0.3], 'sensor.f': [2, 0.1, 0.2, 0.4, -1]}}},
'excluded': {day: {'15': {'sensor.d': [1, 2, 0.3], 'sensor.x': [1, -2, 0.3], 'sensor.y': [1, 2]},
'45': {'sensor.d': [1, 2, 0.3]}}}})
# A 0.3 bucket has no base-model column; its model was the base model then. # A 0.3 bucket has no base-model column; its model was the base model then.
# Neither 0.3 nor 0.4 buckets have squared errors: they do not count for RMSE.
# 0.4 data has no excluded-span counts.
self.assertEqual(restored.as_dict(), {'days': {day: {'15': { self.assertEqual(restored.as_dict(), {'days': {day: {'15': {
'sensor.d': [2, 0.1, 0.2, 0.4, 0.2], 'sensor.e': [2, 0.1, 0.2, 0.4, 0.3]}}}}) 'sensor.d': [2, 0.1, 0.2, 0.4, 0.2, 0.0, 0], 'sensor.e': [2, 0.1, 0.2, 0.4, 0.3, 0.0, 0]}}},
'excluded': {day: {'15': {'sensor.d': [1, 2, 0.3]}}}})
self.assertIsNone(restored.summary(['sensor.d'])[15]['rmse'])
self.assertEqual({k: restored.summary(['sensor.d'])[15][k] for k in ('incomplete', 'skipped',
'incomplete_base_model_mae')},
{'incomplete': 1, 'skipped': 2, 'incomplete_base_model_mae': 0.3})
for garbage in (None, [], {'days': []}): for garbage in (None, [], {'days': []}):
restored.load(garbage) restored.load(garbage)
self.assertEqual(restored.as_dict(), {'days': {}}) self.assertEqual(restored.as_dict(), {'days': {}, 'excluded': {}})
T0 = START.timestamp()
def run_ledger(plan, relay_on, temps, trajectory, start=20.0, polls=25, unknown_relay_at=None, skip=()):
"""One forecast at T0, then a poll every 5 minutes with the given relay state per poll."""
ledger = ev.OperationalForecastLedger()
ledger.observe(T0, {'r': start}, None, None)
ledger.record(T0, {'r': start}, {'r': trajectory}, plan, 'rev1', {'strategy': 'mpc_plan'})
for k in range(1, polls):
if k in skip:
continue
on = None if k == unknown_relay_at else (300.0 if relay_on(k) else 0.0)
ledger.observe(T0 + 300 * k, {'r': temps(k)}, on, 300.0)
return ledger.summary(['r'])
class OperationalForecastTests(unittest.TestCase):
PLAN_OFF = [0] * 8
TRAJECTORY = [19.9, 19.8, 19.7, 19.6, 19.5, 19.4, 19.3, 19.2] # every 15 minutes
def test_followed_plan_is_scored_at_every_horizon_against_persistence(self):
stats = run_ledger(self.PLAN_OFF, lambda k: False, lambda k: 20.0 - k / 30, self.TRAJECTORY)
for horizon, actual in ((15, 19.9), (30, 19.8), (60, 19.6), (120, 19.2)):
with self.subTest(horizon=horizon):
s = stats[horizon]
self.assertEqual((s['samples'], s['matched'], s['counterfactual'], s['incomplete']), (1, 1, 0, 0))
self.assertAlmostEqual(s['mae'], 0, places=3)
self.assertAlmostEqual(s['persistence_mae'], 20.0 - actual, places=3)
self.assertEqual(s['improvement_vs_persistence'], 1.0)
self.assertEqual(s['quality'], 'insufficient') # one forecast proves little
def test_plan_not_followed_is_counterfactual_not_scored(self):
stats = run_ledger([1] * 8, lambda k: False, lambda k: 20.0, self.TRAJECTORY)
for horizon in (15, 30, 60, 120):
self.assertEqual((stats[horizon]['samples'], stats[horizon]['counterfactual']), (0, 1))
self.assertIsNone(stats[horizon]['mae'])
def test_plan_followed_for_a_while(self):
# The thermostat switches the boiler on in the 4th block (after 45 minutes).
stats = run_ledger(self.PLAN_OFF, lambda k: k > 9, lambda k: 20.0, self.TRAJECTORY)
self.assertEqual([stats[h]['samples'] for h in (15, 30, 60, 120)], [1, 1, 0, 0])
self.assertEqual([stats[h]['counterfactual'] for h in (15, 30, 60, 120)], [0, 0, 1, 1])
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)
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)
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.
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]['incomplete_reasons']['no_poll']), (0, 1))
def test_due_time_alignment(self):
ledger = ev.OperationalForecastLedger()
ledger.record(T0, {'r': 20.0}, {'r': self.TRAJECTORY}, self.PLAN_OFF, 'rev1', {})
for offset in (300, 600):
ledger.observe(T0 + offset, {'r': 20.0}, 0.0, 300.0)
ledger.observe(T0 + 900 - 200, {'r': 20.0}, 0.0, 100.0) # too early for +15 min
self.assertEqual(ledger.summary(['r'])[15]['samples'], 0)
ledger.observe(T0 + 900 + 100, {'r': 19.9}, 0.0, 300.0) # within half a poll of the due time
self.assertEqual(ledger.summary(['r'])[15]['samples'], 1)
def test_round_trip_forget_and_garbage(self):
stats_ledger = ev.OperationalForecastLedger()
stats_ledger.record(T0, {'r': 20.0}, {'r': self.TRAJECTORY}, [1] * 8, 'rev1', {})
stats_ledger.record(T0, {'r': 20.0}, {'r': self.TRAJECTORY}, self.PLAN_OFF, 'rev1', {})
for k in range(1, 4):
stats_ledger.observe(T0 + 300 * k, {'r': 19.9}, 0.0, 300.0)
restored = ev.OperationalForecastLedger(stats_ledger.as_dict())
self.assertEqual(restored.summary(['r']), stats_ledger.summary(['r']))
self.assertEqual(restored.summary(['r'])[15]['counterfactual'], 1)
restored.forget('r')
self.assertEqual(restored.summary(['r'])[15]['samples'], 0)
empty = {'version': 2, 'days': {}, 'approximate': {}, '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__':
unittest.main() unittest.main()
BASE = (0.004, 0.12, 0.0005)
def ventilated(observed, actual=None, wind=4.0, features=('vent_natural',), zones=('r',), internal=None,
extended=None, lag_uncertain_at=(), missing_end_at=()):
"""Zones cooled by real ventilation; `observed[k]` is what the window sensor said
for the interval ending at poll k (None: unknown). Returns the summary at 15 min."""
actual = actual if actual is not None else [1.0 if o is None else o for o in observed]
snapshot = {'base': BASE, 'uses_extended': extended is not None, 'extended': extended,
'ventilation': (list(features), (0.02,) * len(features))}
evaluator, temps = ev.ForecastEvaluator(0.1), {zone: 20.0 for zone in zones}
for k, seen in enumerate(observed):
if k:
temps = {zone: t + 0.004 * (5 - t) + 0.12 * 0.5 + 0.0005
+ 0.02 * actual[k] * (5 - t) * (1 + (wind or 0) * ('vent_wind' in features))
for zone, t in temps.items()}
vent = {'opening': seen, 'facing': 0.0, 'other_opening': 0.0, 'recovery': 0.0, 't_ref': None,
'wind': wind}
readings = {z: t for z, t in temps.items() if not (k in missing_end_at and z == zones[0])}
evaluator.observe(START + timedelta(seconds=300 * k), readings, 5.0, 0.5,
0.5 if k in lag_uncertain_at else 0.0, {z: BASE for z in zones},
models={z: snapshot for z in zones},
inputs={z: {'internal': internal, 'ventilation': vent if z == zones[0] else
{**vent, 'opening': actual[k]}} for z in zones})
return evaluator.summary(list(zones))[15]
class IncompleteInputTests(unittest.TestCase):
"""Unknown ventilation or extended inputs never become Model Error samples."""
def test_window_unknown_for_the_whole_span_is_incomplete(self):
stats = ventilated([0.0, None, None, None])
self.assertEqual((stats['samples'], stats['mae'], stats['incomplete']), (0, None, 1))
# The base model needs no window input: its error is still kept, apart.
self.assertGreater(stats['incomplete_base_model_mae'], 0.1)
def test_window_unknown_halfway_or_recovering_during_the_span(self):
self.assertEqual(ventilated([0.0, 1.0, None, 1.0])['incomplete'], 1) # unavailable halfway
stats = ventilated([0.0, None, 1.0, 1.0, 1.0, 1.0, 1.0]) # back after one poll
# Spans ending at polls 3-6: only the first contains the unknown interval.
self.assertEqual((stats['incomplete'], stats['samples']), (1, 3))
self.assertAlmostEqual(stats['mae'], 0, places=6) # the known spans are exact
def test_one_zone_unknown_leaves_the_other_zone_scored(self):
stats = ventilated([0.0, None, None, None], zones=('a', 'b'))
self.assertEqual((stats['rooms']['a']['incomplete'], stats['rooms']['a']['samples']), (1, 0))
self.assertEqual((stats['rooms']['b']['incomplete'], stats['rooms']['b']['samples']), (0, 1))
def test_missing_wind_matters_only_while_a_window_is_open(self):
features = ('vent_natural', 'vent_wind')
open_span = ventilated([0.0, 1.0, 1.0, 1.0], wind=None, features=features)
closed_span = ventilated([0.0, 0.0, 0.0, 0.0], wind=None, features=features)
self.assertEqual((open_span['incomplete'], open_span['samples']), (1, 0))
self.assertEqual((closed_span['incomplete'], closed_span['samples']), (0, 1))
self.assertEqual(ventilated([0.0, 1.0, 1.0, 1.0], features=features)['samples'], 1)
def test_unknown_electrical_heat_makes_the_extended_replay_incomplete(self):
extended = (['loss', 'heat', 'bias', 'internal'], BASE + (0.15,))
stats = ventilated([0.0] * 4, extended=extended, internal=None)
self.assertEqual((stats['incomplete'], stats['samples']), (1, 0))
self.assertIsNotNone(stats['incomplete_base_model_mae'])
self.assertEqual(ventilated([0.0] * 4, extended=extended, internal=0.0)['samples'], 1)
def test_a_neighbour_with_unknown_inputs_spoils_a_coupled_zone(self):
coupled = {'base': BASE, 'uses_extended': True, 'ventilation': None,
'extended': (['loss', 'heat', 'bias', 'couple:a'], BASE + (0.01,))}
plain = {'base': BASE, 'uses_extended': False, 'extended': None,
'ventilation': (['vent_natural'], (0.02,))}
origin = {'temps': {'a': 20.0, 'b': 20.0}, 'outside': 5.0, 'models': {'a': plain, 'b': coupled}}
path = [origin] + [{'time': START + timedelta(seconds=300 * k), 'lag': 0.5,
'inputs': {'a': {'ventilation': {'opening': None}}, 'b': {}}} for k in range(4)]
origin['time'] = START - timedelta(seconds=300)
_, incomplete = ev.replay_zones(origin, path)
self.assertEqual(incomplete, {'a', 'b'})
path = [origin] + [{**poll, 'inputs': {'a': {'ventilation': {'opening': 0.0}}, 'b': {}}} for poll in path[1:]]
self.assertEqual(ev.replay_zones(origin, path)[1], set())
def test_skipped_spans_are_counted_and_samples_stay_correct(self):
stats = ventilated([0.0] * 9, lag_uncertain_at=(4,))
# Spans ending at 3..8: those crossing poll 4 (ending 4, 5, 6) are skipped.
self.assertEqual((stats['samples'], stats['skipped'], stats['incomplete']), (3, 3, 0))
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']['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']))
+25
View File
@@ -140,6 +140,31 @@ class ModelTests(unittest.TestCase):
self.assertEqual(restored.count, 30000) self.assertEqual(restored.count, 30000)
self.assertEqual(restored.p, learn.p) self.assertEqual(restored.p, learn.p)
def test_confidence_reflects_excitation_not_sample_count(self):
# Two weeks of samples, relay always off, a steady 15 degC difference to outside.
steady = m.Learner()
for _ in range(4000):
steady.learn(20.0, 20.0 + 0.003 * -15 + 0.001, 5.0, 0.0, command=False)
confidence = steady.confidence()
self.assertEqual(steady.count, 4000)
self.assertEqual(confidence['heat'], 0) # never exercised
self.assertLess(confidence['loss'], 0.8) # not separable from bias
self.assertLess(confidence['bias'], 0.8)
# Fewer samples, with the relay cycling and a cold and a mild spell each day.
varied = m.Learner()
t, lag = 20.0, 0.0
for step in range(1200):
heater = int(step % 96 < 35)
lag = lag + m.HEAT_LAG * (heater - lag)
outside = -2 + 14 * ((step // 144) % 2)
value = t + 0.003 * (outside - t) + 0.055 * lag + 0.001
varied.learn(t, value, outside, lag, command=bool(heater))
t = value
self.assertTrue(all(value > 0.8 for value in varied.confidence().values()), varied.confidence())
# Recalibration starts confidence over.
varied.recalibrate()
self.assertEqual(set(varied.confidence().values()), {0})
def test_restore_tolerates_round_off_and_bounds_wound_up_covariance(self): def test_restore_tolerates_round_off_and_bounds_wound_up_covariance(self):
# Shape of a v0.2.1 model after a summer: huge heat variance, tiny asymmetry. # Shape of a v0.2.1 model after a summer: huge heat variance, tiny asymmetry.
data = m.Learner(count=40000, off_count=40000).as_dict() data = m.Learner(count=40000, off_count=40000).as_dict()
+270
View File
@@ -145,3 +145,273 @@ class TrackerTests(unittest.TestCase):
if __name__ == '__main__': if __name__ == '__main__':
unittest.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']))
class StartupAndRestartTests(unittest.TestCase):
"""Unknown at startup is not closed; saved state comes back as unknown, never live."""
WINDOWS = wm.build_windows([(window('w'), None)], ['bed'])
def setUp(self):
self.t = 2_000_000.0
def poll(self, tracker, state, changed_ago=100_000, temp=20.0):
tracker.update(self.t, {'w': (state, self.t - changed_ago)}, {'bed': temp}, WEATHER)
result = (tracker.zone_inputs('bed', WEATHER), tracker.forecast('bed', 6, 15, WEATHER, self.t),
tracker.assumption(self.WINDOWS[0], 15, self.t))
self.t += 300
return result
def saved_with(self, *states):
tracker = wm.VentilationTracker(self.WINDOWS)
for state in states:
self.poll(tracker, state, changed_ago=200 if state == 'on' else 100_000, temp=21.0)
return tracker, tracker.as_dict()
def test_unavailable_from_startup_is_unobserved_not_closed(self):
tracker = wm.VentilationTracker(self.WINDOWS)
for _ in range(3):
inputs, forecast, assumed = self.poll(tracker, 'unavailable')
self.assertEqual(assumed, {'basis': 'unobserved', 'open_minutes': 0.0, 'uncertain': True})
self.assertEqual((inputs['known'], inputs['affected'], inputs['unknown_windows']), (False, True, ['W']))
self.assertEqual(forecast[0]['opening'], 0.0) # no ventilation assumed, but flagged uncertain
self.assertEqual((len(tracker.events), tracker._open_event), (0, {}))
def test_first_reading_open_is_an_unconfirmed_opening(self):
tracker = wm.VentilationTracker(self.WINDOWS)
self.poll(tracker, 'on', changed_ago=30) # we never saw it closed
event = tracker._open_event['bed']
self.assertEqual((event['opened_confirmed'], event['temperature_before']), (False, None))
self.poll(tracker, 'on')
self.poll(tracker, 'off', changed_ago=60)
event = tracker.events[-1]
self.assertTrue(event['closed_confirmed'])
# Opened at some unknown time before it was first seen: a minimum duration only.
self.assertEqual((event['duration_minutes'], event['duration_max_minutes']), (None, None))
self.assertEqual(event['duration_min_minutes'], 9.5)
def test_restart_with_saved_open_window_and_sensor_unavailable(self):
before, saved = self.saved_with('off', 'off', 'on', 'on')
self.assertEqual(saved['state']['windows']['w']['last_known'], True)
self.assertIn('bed', saved['state']['zones'])
self.t += 120 # restart takes two minutes
tracker = wm.VentilationTracker(self.WINDOWS, saved=saved)
self.assertEqual(tracker.window_state, {}) # nothing is live before the first reading
inputs, forecast, assumed = self.poll(tracker, 'unavailable')
state = tracker.window_state['w']
self.assertEqual((state['open'], state['last_known_open']), (None, True))
self.assertEqual(state['unknown_since'], saved['state']['windows']['w']['seen'])
# Assumed open only until 15 minutes after it was last seen (7 minutes ago).
self.assertEqual((assumed['basis'], assumed['open_minutes'], assumed['uncertain']),
('last_known_open', 8.0, True))
self.assertEqual([s['opening'] for s in forecast[:3]], [1.0, 1.0, 0.0])
self.assertEqual((inputs['known'], inputs['affected']), (False, True))
# The event from before the restart continues; the downtime counts as unknown.
self.assertEqual(len(tracker.events), 0)
event = tracker._open_event['bed']
self.assertEqual((event['opened'], event['opened_confirmed']), (before._open_event['bed']['opened'], True))
self.assertEqual(event['sensor_unknown_minutes'], 12.0) # 7 minutes down + one unknown poll
# Not assumed open forever: past the bound the forecast assumes it closed.
for _ in range(3):
_, forecast, assumed = self.poll(tracker, 'unavailable')
self.assertEqual((assumed['open_minutes'], forecast[0]['opening']), (0.0, 0.0))
self.assertGreater(forecast[0]['recovery'], 0.0)
# Back as closed: closed at an unknown time, so an unconfirmed close and recovery.
inputs, _, _ = self.poll(tracker, 'off', changed_ago=30)
event = tracker.events[-1]
self.assertEqual((event['closed_confirmed'], event['duration_minutes']), (False, None))
self.assertEqual(event['duration_min_minutes'], 8.3) # opened 3.3 min before a poll, seen open 5 min later
self.assertEqual((inputs['known'], inputs['affected']), (False, True))
def test_restart_with_saved_closed_window(self):
_, saved = self.saved_with('off', 'off')
tracker = wm.VentilationTracker(self.WINDOWS, saved=saved)
inputs, forecast, assumed = self.poll(tracker, 'unavailable')
self.assertEqual((assumed['basis'], assumed['uncertain'], forecast[0]['opening']),
('last_known_closed', True, 0.0))
self.assertEqual((inputs['known'], len(tracker.events)), (False, 0))
# It reports open: opened during the unknown period, so not a confirmed opening.
self.poll(tracker, 'on', changed_ago=60)
self.assertFalse(tracker._open_event['bed']['opened_confirmed'])
def test_restart_with_the_same_state_again_is_a_known_state(self):
_, saved = self.saved_with('off', 'off')
tracker = wm.VentilationTracker(self.WINDOWS, saved=saved)
self.poll(tracker, 'off') # first interval after restart: unknown
inputs, _, assumed = self.poll(tracker, 'off')
self.assertEqual((inputs['known'], inputs['affected'], assumed['basis']), (True, False, 'observed'))
def test_saved_state_of_another_sensor_or_polarity_is_ignored(self):
_, saved = self.saved_with('off', 'off', 'on', 'on')
for changed in ({'sensor': 'binary_sensor.other'}, {'inverted': True}):
with self.subTest(changed=changed):
windows = wm.build_windows([(window('w', **changed), None)], ['bed'])
tracker = wm.VentilationTracker(windows, saved=saved)
self.assertEqual((tracker._memory, tracker._open_event), ({}, {}))
corrupt = {'events': [], 'state': {'windows': {'w': {'sensor': 'binary_sensor.w', 'last_known': 'yes',
'seen': 'x'}}, 'zones': {'bed': 5}}}
self.assertEqual(wm.VentilationTracker(self.WINDOWS, saved=corrupt)._memory, {})
def test_mixed_windows_each_get_their_own_assumption(self):
windows = wm.build_windows([(window('a'), None), (window('b', opening_factor=0.5), None),
(window('c', zone='living'), None)], ['bed', 'living'])
tracker = wm.VentilationTracker(windows)
t = 0.0
for a, b, c in (('off', 'off', 'off'), ('off', 'on', 'off'), ('on', 'on', 'unavailable'),
('on', 'unavailable', 'unavailable'), ('on', 'unavailable', 'unavailable'),
('on', 'unavailable', 'unavailable'), ('on', 'unavailable', 'unavailable')):
t += 300
tracker.update(t, {'a': (a, t - 200), 'b': (b, t - 200), 'c': (c, t - 200)},
{'bed': 20.0, 'living': 21.0}, WEATHER)
# b was last seen open 20 minutes ago: no longer assumed open; a is observed open.
self.assertEqual(tracker.assumption(windows[1], 15, t)['open_minutes'], 0.0)
self.assertEqual(tracker.forecast('bed', 4, 15, WEATHER, t)[0]['opening'], 1.0)
self.assertEqual(tracker.assumption(windows[2], 15, t)['basis'], 'last_known_closed')
# An unknown window in one zone does not stop another zone with known windows.
tracker2 = wm.VentilationTracker(windows)
for t in (300.0, 600.0):
tracker2.update(t, {'a': ('off', 0), 'b': ('off', 0), 'c': ('unavailable', 0)},
{'bed': 20.0, 'living': 21.0}, WEATHER)
self.assertEqual((tracker2.zone_inputs('bed', WEATHER)['known'],
tracker2.zone_inputs('living', WEATHER)['known']), (True, False))
def test_changes_worth_saving_are_flagged(self):
tracker = wm.VentilationTracker(self.WINDOWS)
self.poll(tracker, 'off')
self.assertTrue(tracker.changed) # first reading
tracker.changed = False
self.poll(tracker, 'off')
self.assertFalse(tracker.changed) # nothing new
self.poll(tracker, 'unavailable')
self.assertTrue(tracker.changed)
class StrictInputTests(unittest.TestCase):
"""Weather inputs of an open window are required, never assumed, outside forecasts."""
def test_unknown_direction_or_other_zone_while_open(self):
windows = wm.build_windows([(window('a'), None), (window('c', zone='living'), None)], ['bed', 'living'])
tracker = wm.VentilationTracker(windows)
calm = {**WEATHER, 'wind_direction': None}
for t, a, c in ((300.0, 'off', 'off'), (600.0, 'on', 'unavailable'), (900.0, 'on', 'unavailable')):
tracker.update(t, {'a': (a, 450.0), 'c': (c, 450.0)}, {'bed': 20.0, 'living': 21.0}, calm)
bed = tracker.zone_inputs('bed', calm)
self.assertEqual((bed['opening'], bed['facing'], bed['other_opening']), (1.0, None, None))
features = ['vent_natural', 'vent_wind', 'vent_wind_dir', 'vent_cross']
self.assertFalse(wm.ventilation_known(features, bed))
self.assertTrue(wm.ventilation_known(['vent_natural', 'vent_wind'], bed))
self.assertIsNone(wm.ventilation_vector(features, 20.0, 2.0, bed)) # learning refuses
self.assertIsNotNone(wm.ventilation_vector_for_prediction(features, 20.0, 2.0, bed))
# Closed, every opening term is 0 whatever the weather: nothing is missing.
closed = {**bed, 'opening': 0.0, 'facing': 0.0, 'wind': None, 'other_opening': None}
self.assertEqual(wm.ventilation_vector(features, 20.0, 2.0, closed), [0.0, 0.0, 0.0, 0.0])
self.assertTrue(wm.ventilation_known(features, closed))
self.assertTrue(wm.ventilation_known(features, None)) # no windows