Compare commits
17
Commits
17dbe76223
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d027f367a | ||
|
|
c4035951df | ||
|
|
0b82a8e26c | ||
|
|
2cfba024ff | ||
|
|
13cb972566 | ||
|
|
49565d610a | ||
|
|
28ff953ddd | ||
|
|
87437cbd48 | ||
|
|
35370958fc | ||
|
|
8eef04c385 | ||
|
|
4911b32c5e | ||
|
|
a44163a57d | ||
|
|
26c415fa6c | ||
|
|
d73e32a4b9 | ||
|
|
099c63cd02 | ||
|
|
9bb64e79ca | ||
|
|
2bddc74d37 |
No files matched your search
@@ -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
|
||||
offline tests, 26 HA Core tests, compilation, package/translation/changelog checks).
|
||||
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
@@ -2,6 +2,153 @@
|
||||
|
||||
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
|
||||
|
||||
Thermal zones, electrical heat sources, windows with ventilation and wind, and
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
heat source** and **Add window** create items that can each be edited or deleted
|
||||
later. **Configure** holds everything else: thermostat, relay, weather sources,
|
||||
report-age limits, MPC settings and model options.
|
||||
later. **Configure** offers **Settings** (thermostat, relay, weather sources,
|
||||
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
|
||||
sensor weights (how much each counts in the zone temperature), a comfort weight
|
||||
(how much the zone matters to the MPC), an optional target offset relative to the
|
||||
thermostat's target, and optional adjacent zones it exchanges heat with. Sensor and
|
||||
comfort weights never change the learned thermal coefficients. A sensor belongs to
|
||||
one zone only. Upgrading from 0.3 turns each room sensor into a one-sensor zone
|
||||
with the same comfort weight; to group two sensors of one room, edit one zone to
|
||||
include both and delete the other.
|
||||
**Zones** are rooms or areas. Each has a name, one or more temperature sensors, a
|
||||
comfort weight (how much the zone matters to the MPC), an optional target offset
|
||||
relative to the thermostat's target, and optional adjacent zones it exchanges heat
|
||||
with. A zone with several sensors gets a second step, **Sensor weights**: one
|
||||
numbered field per sensor (listed above the fields), setting how much each counts
|
||||
in the zone temperature. A sensor belongs to one zone only. Upgrading from 0.3
|
||||
turns each room sensor into a one-sensor zone with the same comfort weight; to
|
||||
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
|
||||
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
|
||||
reports *on* when closed (inverted), the direction the outside of the window faces
|
||||
(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
|
||||
treated as unknown, never as closed.
|
||||
(1.0 normal, lower for tilted, higher for large).
|
||||
|
||||
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
|
||||
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
|
||||
*Learned*, *Relay switched*, *Waiting for radiators to settle*); its history shows
|
||||
when learning happens.
|
||||
- **Heating MPC Forecast Error 15/30/60/120 min** — how far the thermal model's
|
||||
forecasts were from what actually happened (see *Forecast accuracy*).
|
||||
- **Heating MPC Model Error 15/30/60/120 min** — how well the thermal model
|
||||
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
|
||||
|
||||
- **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).
|
||||
- All rooms share one heating actuator. The model does not command individual radiators.
|
||||
- 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`,
|
||||
`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
|
||||
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
|
||||
(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
|
||||
included sources), baseline, variable power above it, thermal response, whether it
|
||||
has settled.
|
||||
- `windows` — per window: state, minutes open, wind direction relative to the
|
||||
window's facing and the resulting exposure (1 = wind straight at it).
|
||||
- `windows` — per window: state (`open`, `closed` or `unknown`), the last known
|
||||
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,
|
||||
its contribution in °C/h and the ventilation coefficients. `ventilation_events` —
|
||||
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),
|
||||
`partly_rejected` (an outlier in some zone), `interval_out_of_range`,
|
||||
`relay_changed`, `outside_source_changed`, `lag_uncertain` or `waiting_for_inputs`.
|
||||
- `models` — per-zone base coefficients, accepted sample counts and the in-sample
|
||||
5-minute error (`mae_5m`, **not** a forecast accuracy), `model_in_use` and the
|
||||
extended layer's coefficients with confidence.
|
||||
- `models` — per-zone base coefficients with their `confidence`, accepted sample
|
||||
counts and the in-sample 5-minute error (`mae_5m`, **not** a forecast accuracy),
|
||||
`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.
|
||||
|
||||
`input_quality`, `weather_comparison`, `heat_sources`, `windows`, `ventilation`
|
||||
and `ventilation_events` change every poll, so they are not written to the
|
||||
Recorder; the other attributes are.
|
||||
`input_quality`, `weather_comparison`, `heat_sources`, `windows`, `ventilation`,
|
||||
`ventilation_events` and `model_changes` are not written to the Recorder; the other
|
||||
attributes are.
|
||||
|
||||
### Choosing freshness limits
|
||||
|
||||
@@ -216,37 +266,94 @@ age limit.
|
||||
|
||||
## Forecast accuracy
|
||||
|
||||
The four **Forecast Error** sensors measure the thermal model against reality,
|
||||
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.
|
||||
Two different questions, two kinds of sensor:
|
||||
|
||||
This tests the thermal model, not the MPC plan, because the plan is never
|
||||
executed. All zones are replayed together, with the electrical heat and window
|
||||
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,
|
||||
across all zones. Attributes, all in °C except counts:
|
||||
- **Model Error** (four sensors, 15/30/60/120 min): *does the thermal model explain
|
||||
what happened?* Replayed with the relay, electrical heat and windows that
|
||||
actually followed. Useful for judging the model and its options. It is **not**
|
||||
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
|
||||
are not independent; treat small counts as indicative only.
|
||||
- `bias` — mean error: negative means the model predicts too cold.
|
||||
### Model Error
|
||||
|
||||
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.
|
||||
- `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.
|
||||
- `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
|
||||
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).
|
||||
|
||||
Forecasts are scored only where the inputs were known: polls 4.5–5.5 minutes
|
||||
apart, a usable reading at the start and the end, and a well-known radiator-lag
|
||||
estimate throughout. A missing relay, outdoor temperature or all zones restarts the
|
||||
replay window, so scores resume 15–120 minutes later. Statistics are kept with the
|
||||
model and survive restarts; deleting the model resets them.
|
||||
Spans need polls 4.5–5.5 minutes apart; a missing relay, outdoor temperature or
|
||||
all zones restarts the replay window, so scores resume 15–120 minutes later (these
|
||||
gaps are not counted). Statistics are kept with the model and survive restarts;
|
||||
recalibrating a zone or resetting the learned model clears 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,
|
||||
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
|
||||
@@ -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);
|
||||
the package check verifies their sizes and transparency.
|
||||
|
||||
HA tests cover setup, config/options forms, migration, reload and entity cleanup,
|
||||
scheduled sampling and poll cancellation, the relay listener, stored model
|
||||
HA tests cover setup, config/options forms (including the reset), migration,
|
||||
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
|
||||
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
|
||||
|
||||
+33
-5
@@ -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
|
||||
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)
|
||||
|
||||
**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.)*
|
||||
- [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).
|
||||
- [ ] 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.)*
|
||||
- [ ] Store a bounded history of predictions with snapshots of inputs, proposed plan and eventually observed outcome.
|
||||
- [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.)*
|
||||
- [ ] 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. *(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: 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.)*
|
||||
- [ ] 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
|
||||
policy, which still pauses.
|
||||
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
|
||||
identified. Switch off optional terms that do not help.
|
||||
heating review Model Error `improvement_vs_base` and `quality`, per-zone bias,
|
||||
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;
|
||||
explanations for the proposed action; an actual-policy forecast that simulates
|
||||
Generic Thermostat.
|
||||
|
||||
@@ -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 .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
|
||||
|
||||
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
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
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()
|
||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
await coordinator.async_shutdown()
|
||||
raise
|
||||
entry.async_on_unload(unsub_relay)
|
||||
# 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:
|
||||
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)
|
||||
if ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||
elif coordinator is not None:
|
||||
coordinator.async_cancel_shutdown()
|
||||
return ok
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Remove the learned model when this integration instance is deleted."""
|
||||
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
||||
await store.async_remove()
|
||||
# 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()
|
||||
hass.data.get(f"{DOMAIN}_storage_locks", {}).pop(entry.entry_id, None)
|
||||
|
||||
|
||||
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)]
|
||||
@@ -23,6 +23,7 @@ from .heat_sources import (
|
||||
SOURCE_KEY, SOURCE_NAME, SOURCE_RESPONSE, SOURCE_SENSOR, SOURCE_ZONE, SUBENTRY_HEAT_SOURCE,
|
||||
HeatSource, build_sources, validate_source,
|
||||
)
|
||||
from .coordinator import async_reset_learned_model
|
||||
from .weather_sources import sources_for
|
||||
from .windows import (
|
||||
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}
|
||||
|
||||
|
||||
CONFIRM_RESET = "confirm_reset"
|
||||
|
||||
|
||||
class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||
"""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]
|
||||
|
||||
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}
|
||||
if user_input is not None:
|
||||
self._draft = user_input
|
||||
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))
|
||||
|
||||
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):
|
||||
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
|
||||
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])
|
||||
|
||||
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]
|
||||
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()
|
||||
weights = self._draft.get(ZONE_SENSOR_WEIGHTS) or {}
|
||||
return self.async_show_form(step_id="sensor_weights", data_schema=vol.Schema({
|
||||
vol.Required(f"weight__{s}", default=weights.get(s, 1.0)): _number(*SENSOR_WEIGHT_RANGE, 0.1)
|
||||
for s in sensors}))
|
||||
names = []
|
||||
for i, sensor in enumerate(sensors, 1):
|
||||
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):
|
||||
data = {key: self._draft[key] for key in (
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Read existing HA entities, learn thermal response, produce shadow MPC decisions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections import deque
|
||||
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_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
|
||||
)
|
||||
from .data_quality import ReportAgeHistory, freshness
|
||||
from .evaluation import ForecastEvaluator, model_snapshot
|
||||
from .compatibility import FINGERPRINT_VERSION, baseline_fingerprints, changed, reason, zone_fingerprints
|
||||
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 .learning import FeatureLearner
|
||||
from .physics import (
|
||||
@@ -29,7 +33,9 @@ from .physics import (
|
||||
lag_from_time_in_state, optimize, relay_segments,
|
||||
)
|
||||
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 .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
|
||||
|
||||
@@ -38,6 +44,53 @@ _LOGGER = logging.getLogger(__name__)
|
||||
MAX_RELAY_EVENTS = 256
|
||||
# Models of zones removed in the UI, kept in case a zone is added back.
|
||||
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]:
|
||||
@@ -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.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
||||
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.
|
||||
self._extended_features = {
|
||||
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}
|
||||
self.ventilation: dict[str, FeatureLearner] = {
|
||||
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._report_ages = ReportAgeHistory()
|
||||
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.
|
||||
self._sample_results: dict[str, int] = {}
|
||||
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._on_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 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 {}
|
||||
if not isinstance(stored, dict) or stored.get("schema_version", 1) not in (1, 2):
|
||||
_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")
|
||||
return
|
||||
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 {}
|
||||
for key, features in self._extended_features.items():
|
||||
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():
|
||||
if features and key in saved_vent:
|
||||
self.ventilation[key] = FeatureLearner.from_dict(saved_vent[key], features)
|
||||
# Models carry room identity, so changing the room list is safe. Models of
|
||||
# rooms no longer configured are retained, most recently removed last.
|
||||
# Models carry zone identity, so changing the zone list is safe. Models of
|
||||
# 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
|
||||
saved = stored.get(key)
|
||||
if not isinstance(saved, dict):
|
||||
@@ -156,14 +277,85 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if not isinstance(room, str):
|
||||
continue
|
||||
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:
|
||||
self.learners[room] = learner
|
||||
base_prints[room] = fingerprint
|
||||
elif learner.count:
|
||||
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:
|
||||
self._retired.pop(next(iter(self._retired)))
|
||||
recalibrated = self._check_compatibility(stored_prints, base_prints)
|
||||
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:
|
||||
# Without zones (all deleted in the UI) there is nothing to count.
|
||||
@@ -187,6 +379,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
@callback
|
||||
def async_relay_changed(self, event: Event) -> None:
|
||||
"""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")
|
||||
if new is None:
|
||||
self.record_relay_state(event.time_fired, "removed")
|
||||
@@ -208,6 +402,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
tracker = self._lag
|
||||
steady = False
|
||||
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"]:
|
||||
start = tracker["time"]
|
||||
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(
|
||||
tracker["heater_on"], start.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:
|
||||
lag = tracker["lag"]
|
||||
for command, seconds in segments:
|
||||
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.
|
||||
uncertainty = tracker["uncertainty"] * exp(
|
||||
-(now - start).total_seconds() / LAG_TAU_SECONDS)
|
||||
@@ -235,6 +433,15 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._relay_events_dropped = False
|
||||
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:
|
||||
"""Record how this poll was handled for learning, and the learning progress."""
|
||||
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))
|
||||
inputs = {"outside": outside, "lag": lag, "internal": internal[key], "temps": temps,
|
||||
"ventilation": vent_inputs.get(key) or {}}
|
||||
a, b, c = self.learners[key].theta
|
||||
coefficients = {"base": {"loss": round(a, 6), "heat": round(b, 6), "bias": round(c, 6)}}
|
||||
base = self.learners[key]
|
||||
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)):
|
||||
if learner is not None:
|
||||
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),
|
||||
"model_in_use": "extended" if model.uses_extended else "base",
|
||||
"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
|
||||
|
||||
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, {})
|
||||
is_open, since = info.get("open"), info.get("since")
|
||||
direction = weather.get("wind_direction")
|
||||
last_known, unknown_since = info.get("last_known_open"), info.get("unknown_since")
|
||||
assumed = self._ventilation.assumption(window, float(self.config[CONF_WINDOW_OPEN_MINUTES]), now)
|
||||
windows[window.name] = {
|
||||
"zone": self.zones[window.zone].name, "sensor": window.sensor, "enabled": window.enabled,
|
||||
"state": "unknown" if is_open is None else "open" if is_open else "closed",
|
||||
"last_known_state": None if last_known is None else "open" if last_known else "closed",
|
||||
"unknown_minutes": round((now - unknown_since) / 60, 1) if unknown_since else None,
|
||||
# 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,
|
||||
"azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted,
|
||||
"relative_wind_direction": info.get("relative_wind_direction"),
|
||||
@@ -349,22 +566,36 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
return {"windows": windows, "ventilation": zones,
|
||||
"ventilation_events": list(self._ventilation.events)[-5:]}
|
||||
|
||||
def _prediction_inputs(self) -> tuple:
|
||||
"""Values the prediction depends on, used to detect changes during calculation."""
|
||||
heater = self.config[CONF_HEATER]
|
||||
entities = sorted(set(self.sensors) | set(sources_for(self.config, "temperature")) |
|
||||
{self.config[CONF_CLIMATE], heater})
|
||||
values = []
|
||||
for entity_id in entities:
|
||||
def _input_snapshot(self) -> dict[tuple[str, str], Any]:
|
||||
"""Normalized value of every input a prediction uses, keyed by (kind, entity).
|
||||
|
||||
Compared (with tolerances, see data_quality.inputs_differ) before publishing,
|
||||
so a result computed from inputs that have since changed is not shown.
|
||||
"""
|
||||
def value(entity_id: str, metric: str):
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is None:
|
||||
values.append((entity_id, None))
|
||||
continue
|
||||
attrs = state.attributes
|
||||
values.append((entity_id, state.state, attrs.get("unit_of_measurement"),
|
||||
attrs.get("temperature"), attrs.get("temperature_unit"),
|
||||
state.last_changed if entity_id == heater else None))
|
||||
return tuple(values)
|
||||
return normalize(metric, state.state, state.attributes.get("unit_of_measurement")) if state else None
|
||||
|
||||
snapshot: dict[tuple[str, str], Any] = {}
|
||||
for entity_id in set(self.sensors) | set(sources_for(self.config, "temperature")):
|
||||
snapshot[("temperature", entity_id)] = value(entity_id, "temperature")
|
||||
for metric in ("wind", "wind_direction", "wind_gust"):
|
||||
for entity_id in sources_for(self.config, metric):
|
||||
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]:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -378,7 +609,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
}
|
||||
snapshot = {entity: self.hass.states.get(entity)
|
||||
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 = {}
|
||||
readings: dict[str, float | None] = {}
|
||||
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_model_inputs": ["temperature"],
|
||||
"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],
|
||||
}
|
||||
# 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._relay_events.clear()
|
||||
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;
|
||||
# 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}
|
||||
@@ -562,22 +804,13 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.ventilation.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)
|
||||
if learned:
|
||||
# Persist at each sample so restart does not erase hours of training.
|
||||
await self._store.async_save({
|
||||
"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(),
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
"heater_entity": self.config[CONF_HEATER],
|
||||
"outside_source": active_sources["temperature"],
|
||||
})
|
||||
# Persist at each sample so restart does not erase hours of training, and when
|
||||
# a window's state changes so a restart continues from it.
|
||||
window_changed, self._ventilation.changed = self._ventilation.changed, False
|
||||
if (learned or window_changed) and not self._stale(run):
|
||||
await self._async_save(active_sources["temperature"])
|
||||
if self._stale(run):
|
||||
return self._superseded(status)
|
||||
# Comfort weights are validated when zones are parsed (invalid -> 1.0).
|
||||
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.
|
||||
@@ -596,6 +829,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"loss": round(model.theta[0], 6),
|
||||
"heat": round(model.theta[1], 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,
|
||||
"on_samples": model.on_count, "off_samples": model.off_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]),
|
||||
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)
|
||||
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["changed_inputs"] = [f"{kind}: {entity}" for kind, entity in changed[:10]]
|
||||
return status
|
||||
except (ValueError, RuntimeError, OverflowError) as err:
|
||||
_LOGGER.warning("MPC prediction failed: %s", err)
|
||||
status["status"] = "prediction_error"
|
||||
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": "ready", "proposal": output["proposal"],
|
||||
"prediction_60m": output["predicted_60m"],
|
||||
"prediction_end": output["predicted_end"],
|
||||
"plan_15min": output["plan_15min"], "objective": output["cost"],
|
||||
"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
|
||||
@@ -37,3 +37,37 @@ class ReportAgeHistory:
|
||||
else:
|
||||
buckets.append([hour, age_seconds])
|
||||
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
|
||||
@@ -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
|
||||
history. The replayed temperature is compared with the reading now.
|
||||
|
||||
This scores the thermal model, not the MPC's proposed plan: Generic Thermostat
|
||||
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
|
||||
model replays all zones together with the electrical heat, neighbour exchange and
|
||||
window openings that actually happened, so its improvement over the base model
|
||||
shows whether those features earn their keep.
|
||||
Two kinds of accuracy are kept apart:
|
||||
|
||||
* ForecastEvaluator, **model accuracy** (retrospective): the model is replayed
|
||||
with the relay, electrical heat and window history that actually happened. It
|
||||
answers "how well does the model reproduce the rooms under known inputs?" and
|
||||
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 collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from math import isfinite
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import isfinite, sqrt
|
||||
from typing import Any
|
||||
|
||||
from .physics import Learner
|
||||
from .windows import ventilation_vector
|
||||
from .windows import ventilation_known, ventilation_vector_for_prediction
|
||||
from .zone_model import feature_vector
|
||||
|
||||
HORIZONS = (15, 30, 60, 120) # minutes
|
||||
WINDOW_DAYS = 7
|
||||
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],
|
||||
@@ -67,25 +91,56 @@ def snapshot_delta(snapshot: dict[str, Any], temp: float, inputs: dict[str, Any]
|
||||
ventilation = inputs.get("ventilation")
|
||||
if snapshot["ventilation"] and ventilation:
|
||||
features, theta = snapshot["ventilation"]
|
||||
x = ventilation_vector(features, temp, inputs["outside"], ventilation)
|
||||
x = ventilation_vector_for_prediction(features, temp, inputs["outside"], ventilation)
|
||||
if x is not None:
|
||||
main += _dot(theta, x)
|
||||
return main
|
||||
|
||||
|
||||
def replay_zones(origin: dict[str, Any], path: list[dict[str, Any]]) -> dict[str, float]:
|
||||
"""All zones advanced together with the origin's models and the actual inputs."""
|
||||
def inputs_complete(snapshot: dict[str, Any], temp: float, inputs: dict[str, Any],
|
||||
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"])
|
||||
incomplete: set[str] = set()
|
||||
for before, poll in zip(path, path[1:]):
|
||||
seconds = (poll["time"] - before["time"]).total_seconds()
|
||||
new = {}
|
||||
unknown = set(incomplete)
|
||||
for zone, temp in state.items():
|
||||
actual = poll["inputs"].get(zone, {})
|
||||
inputs = {"outside": origin["outside"], "lag": poll["lag"], "temps": state,
|
||||
"internal": actual.get("internal"), "ventilation": actual.get("ventilation")}
|
||||
new[zone] = temp + snapshot_delta(origin["models"][zone], temp, inputs) * seconds / STEP_SECONDS
|
||||
state = new
|
||||
return state
|
||||
snapshot = origin["models"][zone]
|
||||
if not inputs_complete(snapshot, temp, inputs, incomplete):
|
||||
unknown.add(zone)
|
||||
new[zone] = temp + snapshot_delta(snapshot, temp, inputs) * seconds / STEP_SECONDS
|
||||
state, incomplete = new, unknown
|
||||
return state, incomplete
|
||||
|
||||
|
||||
class ForecastEvaluator:
|
||||
@@ -94,9 +149,12 @@ class ForecastEvaluator:
|
||||
def __init__(self, max_lag_uncertainty: float) -> None:
|
||||
self._max_lag_uncertainty = max_lag_uncertainty
|
||||
self._history: deque[dict[str, Any]] = deque(maxlen=HORIZONS[-1] * 60 // STEP_SECONDS + 1)
|
||||
# UTC day -> horizon -> room ->
|
||||
# [count, sum error, sum |error|, sum |persistence error|, sum |base model error|]
|
||||
# UTC day -> horizon -> room -> [count, sum error, sum |error|, sum |persistence 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]]]] = {}
|
||||
# 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:
|
||||
"""Inputs were missing: no forecast may be scored across this poll."""
|
||||
@@ -123,6 +181,11 @@ class ForecastEvaluator:
|
||||
self._score()
|
||||
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:
|
||||
history = list(self._history)
|
||||
latest = history[-1]
|
||||
@@ -132,31 +195,42 @@ class ForecastEvaluator:
|
||||
if len(history) < steps + 1:
|
||||
continue
|
||||
path = history[-(steps + 1):]
|
||||
origin = path[0]
|
||||
# The actual heat input must be known along the way.
|
||||
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
|
||||
origin = path[0]
|
||||
inputs = [(poll["lag"], (poll["time"] - before["time"]).total_seconds())
|
||||
for before, poll in zip(path, path[1:])]
|
||||
full = replay_zones(origin, path)
|
||||
for room, actual in latest["temps"].items():
|
||||
start, theta = origin["temps"].get(room), origin["theta"].get(room)
|
||||
if start is None or theta is None:
|
||||
full, incomplete = replay_zones(origin, path)
|
||||
for room, start in origin["temps"].items():
|
||||
actual, theta = latest["temps"].get(room), origin["theta"].get(room)
|
||||
if actual is None or theta is None:
|
||||
self._exclude(day, horizon, room, SKIPPED) # no reading at the end
|
||||
continue
|
||||
error = full[room] - 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(
|
||||
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[1] += error
|
||||
bucket[2] += abs(error)
|
||||
bucket[3] += abs(start - actual)
|
||||
bucket[4] += abs(base_error)
|
||||
bucket[5] += error * error
|
||||
bucket[6] += 1
|
||||
|
||||
def _prune(self, time: datetime) -> None:
|
||||
oldest = (time.date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
|
||||
for day in [day for day in self._days if day < oldest]:
|
||||
del self._days[day]
|
||||
for store in (self._days, self._excluded):
|
||||
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]]:
|
||||
"""Error statistics per horizon, overall and per configured room."""
|
||||
@@ -166,24 +240,53 @@ class ForecastEvaluator:
|
||||
for day in self._days.values():
|
||||
for room, values in day.get(str(horizon), {}).items():
|
||||
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):
|
||||
totals[i] += value
|
||||
overall = [sum(values[i] for values in per_room.values()) for i in range(5)]
|
||||
result[horizon] = {**_stats(overall),
|
||||
"rooms": {room: _stats(values) for room, values in per_room.items()}}
|
||||
overall = [sum(values[i] for values in per_room.values()) for i in range(7)]
|
||||
excluded: dict[str, list[float]] = {}
|
||||
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
|
||||
|
||||
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]:
|
||||
# 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 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:
|
||||
"""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
|
||||
if not isinstance(days, dict):
|
||||
return
|
||||
@@ -201,28 +304,62 @@ class ForecastEvaluator:
|
||||
for room, values in by_room.items():
|
||||
if _valid_bucket(room, values):
|
||||
# 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] = [
|
||||
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:
|
||||
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
|
||||
count, *sums = values
|
||||
return (type(count) is int and count >= 0
|
||||
and all(type(v) in (int, float) and isfinite(v) for v in sums)
|
||||
and all(v >= 0 for v in sums[1:]))
|
||||
|
||||
|
||||
def _stats(values: list[float]) -> dict[str, Any]:
|
||||
count, error, absolute, persistence, base = values
|
||||
def _stats(values: list[float], horizon: int, zones: int = 1) -> dict[str, Any]:
|
||||
count, error, absolute, persistence, base, squares, square_count = values
|
||||
effective = effective_samples(int(count), horizon, zones)
|
||||
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}
|
||||
return {
|
||||
"samples": int(count),
|
||||
"effective_samples": effective,
|
||||
"quality": quality(effective),
|
||||
"mae": round(absolute / count, 3),
|
||||
"rmse": round(sqrt(squares / square_count), 3) if square_count else None,
|
||||
"bias": round(error / count, 3),
|
||||
"persistence_mae": round(persistence / count, 3),
|
||||
# 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).
|
||||
"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,
|
||||
}
|
||||
@@ -233,12 +233,19 @@ def forecast_response(response_kw: float, variable_kw: float, steps: int, tau_mi
|
||||
class HeatSourceTracker:
|
||||
"""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.fingerprints = dict(fingerprints or {})
|
||||
saved = saved if isinstance(saved, 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 ())
|
||||
for s in sources}
|
||||
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}
|
||||
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._settled_since: dict[str, float] = {}
|
||||
@@ -301,4 +308,5 @@ class HeatSourceTracker:
|
||||
return total
|
||||
|
||||
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}
|
||||
@@ -15,7 +15,7 @@ displayed standard deviation uses a running estimate of the measurement noise.
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
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]))
|
||||
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
|
||||
def noise_var(self) -> float:
|
||||
"""Estimated variance of the unexplained 5-minute change (°C²)."""
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "0.4.0"
|
||||
"version": "0.4.2"
|
||||
}
|
||||
@@ -122,10 +122,29 @@ class Learner:
|
||||
self.rejected_count += 1
|
||||
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
|
||||
def mae(self) -> float | 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]:
|
||||
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,
|
||||
|
||||
@@ -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),
|
||||
MPCLearningProgress(coordinator, entry.entry_id), MPCLastSample(coordinator, entry.entry_id)]
|
||||
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.
|
||||
linked = {}
|
||||
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
|
||||
# attribute row to the database every five minutes. Current values stay visible.
|
||||
_unrecorded_attributes = frozenset({"input_quality", "weather_comparison", "heat_sources", "windows",
|
||||
"ventilation", "ventilation_events"})
|
||||
"ventilation", "ventilation_events", "model_changes"})
|
||||
|
||||
def __init__(self, coordinator, entry_id):
|
||||
super().__init__(coordinator)
|
||||
@@ -52,7 +53,7 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
|
||||
# Forecast details live on their own entities.
|
||||
return {key: value for key, value in data.items() if key not in (
|
||||
"status", "prediction_60m", "prediction_end", "prediction_trajectory", "forecast_accuracy",
|
||||
"zone_details")}
|
||||
"zone_details", "operational_forecast")}
|
||||
|
||||
|
||||
class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
|
||||
@@ -102,6 +103,8 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
|
||||
"forecast_15min": trajectory or None,
|
||||
"plan_15min": data.get("plan_15min"),
|
||||
"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"),
|
||||
"contributions_c_per_h": details.get("contributions_c_per_h"),
|
||||
"coefficients": details.get("coefficients"),
|
||||
@@ -133,7 +136,11 @@ class MPCOutdoorTemperature(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_device_class = SensorDeviceClass.TEMPERATURE_DELTA
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
@@ -146,7 +153,7 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
|
||||
super().__init__(coordinator)
|
||||
self.horizon = horizon
|
||||
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
|
||||
def _accuracy(self):
|
||||
@@ -154,18 +161,30 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
|
||||
|
||||
@property
|
||||
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
|
||||
def extra_state_attributes(self):
|
||||
accuracy = self._accuracy
|
||||
return {
|
||||
"kind": "model accuracy: replayed with the actual relay, electrical heat and window history",
|
||||
"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"),
|
||||
"persistence_mae": accuracy.get("persistence_mae"),
|
||||
"skill": accuracy.get("skill"),
|
||||
"base_model_mae": accuracy.get("base_model_mae"),
|
||||
"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.
|
||||
"zones": {self.coordinator.zones[key].name if key in self.coordinator.zones else key: stats
|
||||
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):
|
||||
"""How close the least trained room is to being used for forecasts."""
|
||||
_attr_has_entity_name = True
|
||||
|
||||
@@ -56,6 +56,13 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Heating MPC",
|
||||
"menu_options": {
|
||||
"settings": "Settings",
|
||||
"reset_model": "Reset learned model"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Heating MPC options",
|
||||
"description": "Change room sensors and controller settings. This integration remains shadow-only.",
|
||||
"data": {
|
||||
@@ -83,10 +90,17 @@
|
||||
"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.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -113,6 +127,10 @@
|
||||
},
|
||||
"error": {
|
||||
"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": {
|
||||
@@ -143,7 +161,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"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": {
|
||||
"name": "Name",
|
||||
"sensors": "Temperature sensors",
|
||||
@@ -159,11 +177,19 @@
|
||||
},
|
||||
"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": {
|
||||
"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": {
|
||||
"name": "Name",
|
||||
"sensors": "Temperature sensors",
|
||||
|
||||
@@ -56,6 +56,13 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Heating MPC",
|
||||
"menu_options": {
|
||||
"settings": "Settings",
|
||||
"reset_model": "Reset learned model"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Heating MPC options",
|
||||
"description": "Change room sensors and controller settings. This integration remains shadow-only.",
|
||||
"data": {
|
||||
@@ -83,10 +90,17 @@
|
||||
"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.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -113,6 +127,10 @@
|
||||
},
|
||||
"error": {
|
||||
"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": {
|
||||
@@ -143,7 +161,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"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": {
|
||||
"name": "Name",
|
||||
"sensors": "Temperature sensors",
|
||||
@@ -159,11 +177,19 @@
|
||||
},
|
||||
"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": {
|
||||
"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": {
|
||||
"name": "Name",
|
||||
"sensors": "Temperature sensors",
|
||||
|
||||
@@ -56,6 +56,13 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Heating MPC",
|
||||
"menu_options": {
|
||||
"settings": "Setări",
|
||||
"reset_model": "Resetează modelul învățat"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurare Heating MPC",
|
||||
"description": "Modifică senzorii, ponderile și optimizatorul. Rămâne activ doar modul de observație.",
|
||||
"data": {
|
||||
@@ -83,10 +90,17 @@
|
||||
"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.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -113,6 +127,10 @@
|
||||
},
|
||||
"error": {
|
||||
"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": {
|
||||
@@ -143,7 +161,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"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": {
|
||||
"name": "Nume",
|
||||
"sensors": "Senzori de temperatură",
|
||||
@@ -159,11 +177,19 @@
|
||||
},
|
||||
"sensor_weights": {
|
||||
"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": {
|
||||
"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": {
|
||||
"name": "Nume",
|
||||
"sensors": "Senzori de temperatură",
|
||||
|
||||
@@ -150,17 +150,22 @@ def ventilation_vector(features: Iterable[str], temp: float, outside: float,
|
||||
inputs: Mapping[str, Any]) -> list[float] | None:
|
||||
"""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,
|
||||
t_ref (pre-ventilation temperature), wind, gust.
|
||||
`inputs`: opening, facing (sum of opening x cos; None when the wind direction is
|
||||
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
|
||||
opening = inputs.get("opening")
|
||||
if opening is None:
|
||||
return None
|
||||
values = []
|
||||
for name in features:
|
||||
opening = inputs.get("opening")
|
||||
if opening is None:
|
||||
return None
|
||||
if name == "vent_natural":
|
||||
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"):
|
||||
wind = inputs.get("wind")
|
||||
if wind is None:
|
||||
@@ -178,7 +183,10 @@ def ventilation_vector(features: Iterable[str], temp: float, outside: float,
|
||||
return None
|
||||
values.append(opening * max(0.0, gust - wind) * difference)
|
||||
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":
|
||||
recovery = inputs.get("recovery") or 0.0
|
||||
t_ref = inputs.get("t_ref")
|
||||
@@ -190,12 +198,49 @@ def ventilation_vector(features: Iterable[str], temp: float, outside: float,
|
||||
return values
|
||||
|
||||
|
||||
def ventilation_vector_for_prediction(features: Iterable[str], temp: float, outside: float,
|
||||
inputs: Mapping[str, Any]) -> list[float] | None:
|
||||
"""Like ventilation_vector, but an unknown wind or gust counts as calm air.
|
||||
|
||||
For predictions only: an open window keeps its natural ventilation when the wind
|
||||
sensor is unavailable, instead of silently being treated as closed. Learning
|
||||
always uses ventilation_vector, which refuses unknown inputs.
|
||||
"""
|
||||
features = list(features)
|
||||
x = ventilation_vector(features, temp, outside, inputs)
|
||||
if x is None and inputs.get("opening") is not None:
|
||||
x = ventilation_vector(features, temp, outside, {
|
||||
**inputs, "wind": inputs.get("wind") or 0.0, "gust": inputs.get("gust") or inputs.get("wind") or 0.0,
|
||||
"facing": inputs.get("facing") or 0.0, "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:
|
||||
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
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,
|
||||
saved: Any = None) -> None:
|
||||
@@ -206,12 +251,20 @@ class VentilationTracker:
|
||||
self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened
|
||||
self._last_temps: dict[str, float | None] = {}
|
||||
self._open_event: dict[str, dict[str, Any]] = {}
|
||||
# window -> last known open/closed state, since when, 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 {}
|
||||
events = saved.get("events") if isinstance(saved.get("events"), list) else []
|
||||
self.events: deque[dict[str, Any]] = deque(
|
||||
(e for e in events if isinstance(e, dict)), maxlen=MAX_EVENTS)
|
||||
self.window_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]],
|
||||
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:
|
||||
fraction = open_fraction(previous[1], is_open, changed, previous[0], now)
|
||||
self._previous[window.key] = (now, is_open)
|
||||
zone = zones.setdefault(window.zone, {"opening": 0.0, "facing_unit": 0.0, "known": True,
|
||||
"open_now": False, "open_windows": [], "last_close": None,
|
||||
"first_open": None})
|
||||
memory = self._memory.setdefault(window.key, {"last_known": None, "since": None, "seen": None,
|
||||
"unknown_since": None})
|
||||
zone = zones.setdefault(window.zone, {
|
||||
"opening": 0.0, "facing_unit": 0.0, "known": True, "open_now": False, "possibly_open": False,
|
||||
"open_windows": [], "last_close": None, "close_confirmed": True, "first_open": None,
|
||||
"open_confirmed": True, "unknown_windows": [], "facing_known": True})
|
||||
if fraction is None:
|
||||
zone["known"] = False
|
||||
else:
|
||||
@@ -238,49 +294,103 @@ class VentilationTracker:
|
||||
zone["facing_unit"] += window.opening * fraction * (
|
||||
facing_cos(weather["wind_direction"], window.azimuth)
|
||||
if weather.get("wind_direction") is not None else 0.0)
|
||||
if is_open:
|
||||
zone["open_now"] = True
|
||||
zone["open_windows"].append(window.name)
|
||||
if changed is not None:
|
||||
zone["first_open"] = min(zone["first_open"] or changed, changed)
|
||||
elif is_open is False and previous and changed is not None and changed > previous[0]:
|
||||
zone["last_close"] = max(zone["last_close"] or 0.0, changed)
|
||||
if 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:
|
||||
zone["open_now"] = True
|
||||
zone["open_windows"].append(window.name)
|
||||
if changed is not None:
|
||||
zone["first_open"] = min(zone["first_open"] or changed, changed)
|
||||
elif previous and changed is not None and changed > previous[0]:
|
||||
zone["last_close"] = max(zone["last_close"] or 0.0, changed)
|
||||
self.window_state[window.key] = {
|
||||
"open": is_open, "since": changed,
|
||||
"open": is_open, "since": changed, "last_known_open": memory["last_known"],
|
||||
"last_known_since": memory["since"], "seen": memory["seen"],
|
||||
"unknown_since": memory["unknown_since"],
|
||||
"relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1)
|
||||
if weather.get("wind_direction") is not None else None)}
|
||||
# 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():
|
||||
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.zone_state = zones
|
||||
self._last_temps = dict(temps)
|
||||
self._last_update = now
|
||||
|
||||
def _update_zone(self, now: float, key: str, zone: dict[str, Any], temp: float | None,
|
||||
weather: Mapping[str, float | None], zones: Mapping[str, dict[str, Any]]) -> None:
|
||||
was_open = key in self._open_event
|
||||
ventilating = zone["open_now"] or zone["possibly_open"]
|
||||
if zone["open_now"] and not was_open:
|
||||
# Temperature at the previous poll approximates the room before ventilation.
|
||||
self._t_ref[key] = self._last_temps.get(key)
|
||||
self._closed_at.pop(key, None)
|
||||
self._open_event[key] = {
|
||||
"zone": key, "opened": _iso(min(zone["first_open"] or now, now)),
|
||||
"temperature_before": self._t_ref[key],
|
||||
"opened_confirmed": zone["open_confirmed"],
|
||||
"temperature_before": self._t_ref[key] if zone["open_confirmed"] else None,
|
||||
"outside": weather.get("temperature"), "wind": weather.get("wind"),
|
||||
"wind_direction": weather.get("wind_direction"), "wind_gust": weather.get("wind_gust"),
|
||||
"windows": list(zone["open_windows"]),
|
||||
"other_open_windows": [n for k, z in zones.items() if k != key for n in z["open_windows"]],
|
||||
"lowest_temperature": temp,
|
||||
"lowest_temperature": temp, "sensor_unknown_minutes": 0.0, "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["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None)
|
||||
elif was_open and not zone["open_now"]:
|
||||
if temp is not None:
|
||||
event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None)
|
||||
if zone["unknown_windows"]:
|
||||
event["sensor_unknown_minutes"] = round(event["sensor_unknown_minutes"] + 5.0, 1)
|
||||
if zone["open_now"]:
|
||||
event["last_seen_open"] = _iso(now)
|
||||
elif was_open and not ventilating:
|
||||
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
|
||||
event.update(closed=_iso(closed), duration_minutes=round((closed - _parse(event["opened"])) / 60, 1),
|
||||
temperature_at_close=temp)
|
||||
self._recovery_confirmed[key] = confirmed
|
||||
event["temperature_at_close"] = temp
|
||||
self.events.append(event)
|
||||
elif not zone["open_now"] and key in self._closed_at and temp is not None:
|
||||
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.
|
||||
for event in reversed(self.events):
|
||||
if event.get("zone") == key and "temperature_after" not in event:
|
||||
@@ -289,58 +399,159 @@ class VentilationTracker:
|
||||
event["temperature_after_minutes"] = EVENT_AFTER_MINUTES
|
||||
break
|
||||
closed = self._closed_at.get(key)
|
||||
recovery = exp(-(now - closed) / self.tau) if closed is not None and not zone["open_now"] else 0.0
|
||||
recovery = exp(-(now - closed) / self.tau) if closed is not None and not ventilating else 0.0
|
||||
if recovery < RECOVERY_THRESHOLD:
|
||||
recovery = 0.0
|
||||
zone.update(recovery=recovery, t_ref=self._t_ref.get(key),
|
||||
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.
|
||||
zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or zone["open_now"] or recovery > 0
|
||||
zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or ventilating or recovery > 0
|
||||
|
||||
def zone_inputs(self, key: str, weather: Mapping[str, float | None]) -> dict[str, Any] | None:
|
||||
"""Ventilation inputs of the interval that just ended; None for zones without windows."""
|
||||
"""Ventilation inputs of the interval that just ended; None for zones without windows.
|
||||
|
||||
`known` is False while a window state is unknown, and during a recovery whose
|
||||
start is not confirmed: those intervals are not used for any learning.
|
||||
"""
|
||||
zone = self.zone_state.get(key)
|
||||
if zone is None:
|
||||
return None
|
||||
return {"opening": zone["opening"] if zone["known"] else None, "facing": zone["facing_unit"],
|
||||
"other_opening": zone["other_opening"], "recovery": zone["recovery"],
|
||||
known = zone["known"] and (zone["recovery"] == 0 or zone["recovery_confirmed"])
|
||||
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"),
|
||||
"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],
|
||||
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
|
||||
step); after that the zone recovers. Weather is held at current values.
|
||||
Each window open now, or assumed open, closes after its assumed time; once
|
||||
the last one has closed the zone recovers. Weather is held at current values.
|
||||
"""
|
||||
zone = self.zone_state.get(key)
|
||||
if zone is None:
|
||||
return [{} for _ in range(steps)]
|
||||
windows = [w for w in self.windows if w.zone == key and w.enabled
|
||||
and self.window_state.get(w.key, {}).get("open")]
|
||||
opening = sum(w.opening for w in windows)
|
||||
facing = sum(w.opening * facing_cos(weather["wind_direction"], w.azimuth) for w in windows) \
|
||||
if weather.get("wind_direction") is not None else 0.0
|
||||
open_steps = max(1, round(open_minutes / 5)) if windows else 0
|
||||
closed_at = self._closed_at.get(key)
|
||||
windows = [w for w in self.windows if w.zone == key and w.enabled]
|
||||
assumed = {w.key: self.assumption(w, open_minutes, now) for w in windows}
|
||||
# Steps each window stays open: at least one for an observed open window.
|
||||
open_steps = {w.key: (max(1, round(open_minutes / 5)) if self.window_state.get(w.key, {}).get("open")
|
||||
else round(assumed[w.key]["open_minutes"] / 5)) for w in windows}
|
||||
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)
|
||||
start = max([t for t in (closed_at, *closes) if t is not None], default=None)
|
||||
result = []
|
||||
for k in range(steps):
|
||||
t = now + (k + 1) * 300.0
|
||||
if k < open_steps:
|
||||
result.append({"opening": opening, "facing": facing, "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})
|
||||
open_now = [w for w in windows if k < open_steps[w.key]]
|
||||
if open_now:
|
||||
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})
|
||||
continue
|
||||
start = now + open_steps * 300.0 if windows else closed_at
|
||||
recovery = exp(-(t - start) / self.tau) if start is not None else 0.0
|
||||
recovery = exp(-(t - start) / self.tau) if start is not None and t > start else 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,
|
||||
"t_ref": zone["t_ref"]})
|
||||
return result
|
||||
|
||||
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:
|
||||
|
||||
@@ -18,7 +18,7 @@ from typing import Any, Callable, Mapping
|
||||
from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES
|
||||
from .learning import FeatureLearner
|
||||
from .physics import Learner
|
||||
from .windows import ventilation_vector
|
||||
from .windows import ventilation_vector_for_prediction
|
||||
|
||||
BASE_FEATURES = ["loss", "heat", "bias"]
|
||||
|
||||
@@ -86,7 +86,7 @@ class ZoneModel:
|
||||
ventilation = inputs.get("ventilation")
|
||||
if self.ventilation is None or not ventilation:
|
||||
return 0.0
|
||||
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||
x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||
return self.ventilation.predict(x) if x is not None else 0.0
|
||||
|
||||
def delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
|
||||
@@ -98,7 +98,7 @@ class ZoneModel:
|
||||
terms = self._main_contributions(temp, inputs)
|
||||
ventilation = inputs.get("ventilation")
|
||||
if self.ventilation is not None and ventilation:
|
||||
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||
x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||
if x is not None:
|
||||
for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x):
|
||||
group = "recovery" if name == "recovery" else "ventilation"
|
||||
|
||||
+102
-14
@@ -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
|
||||
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
|
||||
base and extended layers do **not** learn, so window events cannot inflate `loss`
|
||||
or shift `bias`.
|
||||
open or the zone is recovering, and only while every window state in the zone is
|
||||
known. While a window is open, possibly open (its sensor is unknown and it was last
|
||||
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
|
||||
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:
|
||||
|
||||
* `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;
|
||||
* `samples`: how many accepted samples actually exercised it;
|
||||
* `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
|
||||
whose temperature difference stays fairly steady cannot separate exchange between
|
||||
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
|
||||
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 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
|
||||
hours.
|
||||
* **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.
|
||||
|
||||
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
|
||||
|
||||
The Forecast Error sensors score forecasts that are valid for comparison with
|
||||
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:
|
||||
Two separate measurements, because they answer different questions.
|
||||
|
||||
* `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;
|
||||
* `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).
|
||||
|
||||
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
|
||||
accuracy):
|
||||
|
||||
@@ -214,5 +299,8 @@ the contamination the separate ventilation layer prevents.
|
||||
variation their split is uncertain even when their combined effect is accurate.
|
||||
* Forecasts hold weather constant and use one scenario for electrical heat and
|
||||
windows; no forecast uncertainty band is computed yet.
|
||||
* Window sensors with a permanently unknown state keep their zone out of learning;
|
||||
disable such a window.
|
||||
* Window sensors with a permanently unknown state keep their zone out of learning
|
||||
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.
|
||||
@@ -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
|
||||
@@ -21,6 +21,55 @@ async def setup_entry(hass, entry):
|
||||
return hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
|
||||
async def open_settings(hass, entry):
|
||||
"""Configure opens a menu; Settings is the first choice."""
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
assert result["menu_options"] == ["settings", "reset_model"]
|
||||
return await hass.config_entries.options.async_configure(result["flow_id"], {"next_step_id": "settings"})
|
||||
|
||||
|
||||
async def open_reset(hass, entry):
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
return await hass.config_entries.options.async_configure(result["flow_id"], {"next_step_id": "reset_model"})
|
||||
|
||||
|
||||
async def test_reset_learned_model_keeps_configuration_and_observations(hass, entry, inputs, hass_storage):
|
||||
coordinator = await setup_entry(hass, entry)
|
||||
model = coordinator.learners["sensor.room"]
|
||||
model.count, model.on_count, model.off_count = 250, 125, 125
|
||||
payload = coordinator._payload("sensor.outside")
|
||||
payload["retired_learners"] = {"sensor.gone": {**model.as_dict()}}
|
||||
payload["ventilation"] = {"events": [{"zone": "sensor.room", "opened": "2026-10-27T10:00:00+00:00"}]}
|
||||
payload["heat_sources"] = {"samples": {"ups": [[1.0, 200.0]]}}
|
||||
await coordinator._store.async_save(payload)
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "ready"
|
||||
before = (dict(entry.data), dict(entry.options), dict(entry.subentries))
|
||||
# Not confirmed: nothing happens.
|
||||
result = await open_reset(hass, entry)
|
||||
assert (result["type"], result["step_id"]) == (FlowResultType.FORM, "reset_model")
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], {"confirm_reset": False})
|
||||
assert (result["type"], result["reason"]) == (FlowResultType.ABORT, "model_not_reset")
|
||||
assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250
|
||||
# Confirmed: a fresh model, the same configuration.
|
||||
result = await open_reset(hass, entry)
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], {"confirm_reset": True})
|
||||
assert (result["type"], result["reason"]) == (FlowResultType.ABORT, "model_reset")
|
||||
await hass.async_block_till_done()
|
||||
fresh = hass.data[DOMAIN][entry.entry_id]
|
||||
assert fresh is not coordinator and fresh.learners["sensor.room"].count == 0
|
||||
assert fresh.data["status"] == "learning" and not fresh._retired
|
||||
assert (dict(entry.data), dict(entry.options), dict(entry.subentries)) == before
|
||||
stored = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"]
|
||||
assert not {"learners", "retired_learners", "evaluation", "operational_evaluation", "fingerprints"} & set(stored)
|
||||
assert stored["ventilation"]["events"][0]["opened"] == "2026-10-27T10:00:00+00:00"
|
||||
assert stored["heat_sources"] == {"samples": {"ups": [[1.0, 200.0]]}}
|
||||
assert stored["model_changes"][-1]["reasons"] == ["learned model reset by the user"]
|
||||
assert fresh.data["model_changes"][-1]["recalibrated"] == ["all"]
|
||||
|
||||
|
||||
async def test_config_flow_creates_one_zone_per_sensor(hass, inputs):
|
||||
hass.states.async_set("sensor.bedroom", "19", {"unit_of_measurement": "°C", "friendly_name": "Bedroom"})
|
||||
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
|
||||
@@ -51,7 +100,7 @@ async def test_config_flow_creates_one_zone_per_sensor(hass, inputs):
|
||||
|
||||
async def test_options_flow_keeps_settings_without_rooms(hass, entry, inputs):
|
||||
await setup_entry(hass, entry)
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await open_settings(hass, entry)
|
||||
assert "room_entities" not in {str(key) for key in result["data_schema"].schema}
|
||||
main = result["data_schema"]({**MAIN, "room_max_age_minutes": 45, "room_stale_use_last_value": True})
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], main)
|
||||
@@ -65,14 +114,14 @@ async def test_options_flow_keeps_settings_without_rooms(hass, entry, inputs):
|
||||
assert coordinator.config["room_max_age_minutes"] == 45
|
||||
assert coordinator.config["room_stale_use_last_value"] is True
|
||||
assert coordinator.rooms == ["sensor.room"] # zones are untouched by Configure
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await open_settings(hass, entry)
|
||||
assert result["data_schema"](MAIN)["room_stale_use_last_value"] is True
|
||||
hass.config_entries.options.async_abort(result["flow_id"])
|
||||
|
||||
|
||||
async def test_weather_source_may_not_be_a_zone_sensor(hass, entry, inputs):
|
||||
await setup_entry(hass, entry)
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await open_settings(hass, entry)
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"](MAIN))
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.room"]}))
|
||||
@@ -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,
|
||||
"target_offset": 0.5, "neighbors": ["sensor.room"]})
|
||||
assert result["step_id"] == "sensor_weights"
|
||||
result = await flows.async_configure(result["flow_id"], {
|
||||
"weight__sensor.living_a": 3, "weight__sensor.living_b": 1})
|
||||
# Numbered, translated fields; the description says which sensor is which.
|
||||
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
|
||||
await hass.async_block_till_done()
|
||||
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"], {
|
||||
"name": "Living", "sensors": ["sensor.living_a"], "comfort_weight": 1, "target_offset": 0})
|
||||
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()
|
||||
assert entry.subentries[living.subentry_id].data["key"] == key
|
||||
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}})
|
||||
await setup_entry(hass, entry)
|
||||
flow = hass.config_entries.options
|
||||
result = await flow.async_init(entry.entry_id)
|
||||
result = await open_settings(hass, entry)
|
||||
result = await flow.async_configure(result["flow_id"], result["data_schema"](MAIN))
|
||||
markers = {str(key): key for key in result["data_schema"].schema}
|
||||
assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]}
|
||||
@@ -235,7 +294,7 @@ async def test_window_subentry_flow_and_ventilation_options(hass, entry, inputs)
|
||||
("sensor.gust", "9", "m/s")):
|
||||
hass.states.async_set(sensor, value, {"unit_of_measurement": unit})
|
||||
options = hass.config_entries.options
|
||||
result = await options.async_init(entry.entry_id)
|
||||
result = await open_settings(hass, entry)
|
||||
result = await options.async_configure(result["flow_id"], result["data_schema"](
|
||||
{**MAIN, "ventilation_gust": True, "ventilation_cross": True}))
|
||||
result = await options.async_configure(result["flow_id"], result["data_schema"]({
|
||||
|
||||
@@ -39,7 +39,7 @@ async def test_setup_reload_unload(hass, entry, inputs):
|
||||
assert coordinator.config_entry is entry
|
||||
registry = er.async_get(hass)
|
||||
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}
|
||||
assert coordinator.data["status"] == "learning"
|
||||
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
|
||||
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 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):
|
||||
@@ -149,6 +149,45 @@ async def test_scheduled_sample_saves_and_unload_stops_polling(hass, entry, inpu
|
||||
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": []}}}])
|
||||
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}
|
||||
@@ -255,13 +294,37 @@ async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs,
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(ids[15])
|
||||
assert coordinator.data["forecast_accuracy"][15]["samples"] == 1
|
||||
assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0
|
||||
accuracy = coordinator.data["forecast_accuracy"][15]
|
||||
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["incomplete"], state.attributes["skipped"]) == (0, 0)
|
||||
assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name
|
||||
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 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):
|
||||
@@ -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"])
|
||||
await hass.async_block_till_done()
|
||||
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"]
|
||||
@@ -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()
|
||||
@@ -2,6 +2,7 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
import asyncio
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -23,6 +24,9 @@ class CoordinatorBase:
|
||||
async def async_config_entry_first_refresh(self):
|
||||
self.data = await self._async_update_data()
|
||||
|
||||
async def async_shutdown(self):
|
||||
self._shutdown_requested = True
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, *args):
|
||||
@@ -71,6 +75,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
config=types.SimpleNamespace(units=types.SimpleNamespace(temperature_unit='°C')),
|
||||
services=types.SimpleNamespace(async_call=Mock(side_effect=AssertionError('No service calls permitted'))),
|
||||
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()),
|
||||
config_entries=types.SimpleNamespace(
|
||||
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.assertGreater(details['contributions_c_per_h']['internal'], 0.2)
|
||||
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.
|
||||
accuracy = data['forecast_accuracy'][60]
|
||||
self.assertLess(accuracy['mae'], 0.06)
|
||||
@@ -1066,3 +1076,387 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
await grown.async_restore_model()
|
||||
self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross')
|
||||
self.assertEqual(grown.ventilation['bed'].theta[:-1], vent.theta)
|
||||
|
||||
async def test_unavailable_wind_during_ventilation(self):
|
||||
self.c = self.bedroom_with_window()
|
||||
for _ in range(2):
|
||||
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||
await self.sample()
|
||||
self.open_window('on', 240)
|
||||
self.states['sensor.wind'].state = 'unavailable'
|
||||
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind_dir')
|
||||
data = await self.sample()
|
||||
# Learning needs the wind: nothing is fitted, the envelope model stays out too.
|
||||
self.assertEqual(data['sample_result'], 'ventilation')
|
||||
self.assertEqual((self.c.ventilation['bed'].count, self.c.learners['bed'].count), (0, 1))
|
||||
# Predictions keep the open window, as calm air, rather than dropping it.
|
||||
vent = self.c.ventilation['bed']
|
||||
vent.theta[vent.features.index('vent_natural')] = 0.01
|
||||
model = coordinator.ZoneModel(self.c.learners['bed'], None, vent)
|
||||
inputs = self.c._ventilation.zone_inputs('bed', {'wind': None, 'wind_gust': None})
|
||||
self.assertLess(model.ventilation_delta(20.0, {'outside': 5.0, 'ventilation': {**inputs, 'opening': 1.0}}), -0.1)
|
||||
window = data['windows']['Bedroom window']
|
||||
self.assertEqual((window['state'], window['last_known_state'], window['unknown_minutes']), ('open', 'open', None))
|
||||
|
||||
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'])
|
||||
@@ -46,10 +46,31 @@ class FreshnessTests(unittest.TestCase):
|
||||
self.assertEqual(history.observe('sensor.a', later, 300), 300)
|
||||
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):
|
||||
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
|
||||
data = json.loads((ROOT / name).read_text())
|
||||
for section, step in (('config', 'user'), ('options', 'init')):
|
||||
# Configure opens a menu: settings, or resetting the learned model.
|
||||
options = data['options']
|
||||
self.assertEqual(set(options['step']['init']['menu_options']), {'settings', 'reset_model'})
|
||||
self.assertTrue(options['step']['reset_model']['data']['confirm_reset'])
|
||||
self.assertEqual(set(options['abort']), {'model_reset', 'model_not_reset'})
|
||||
for section, step in (('config', 'user'), ('options', 'settings')):
|
||||
labels = data[section]['step'][step]['data']
|
||||
for key in ('room_max_age_minutes', 'weather_max_age_minutes', 'room_stale_use_last_value'):
|
||||
self.assertTrue(labels[key])
|
||||
|
||||
+297
-3
@@ -69,6 +69,16 @@ class EvaluationTests(unittest.TestCase):
|
||||
self.assertEqual(uncertain.summary(['sensor.room'])[15]['samples'], 2) # k = 3 and 7 only
|
||||
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):
|
||||
evaluator = plant(10)
|
||||
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]},
|
||||
'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.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.
|
||||
# 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': {
|
||||
'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': []}):
|
||||
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__':
|
||||
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']))
|
||||
@@ -140,6 +140,31 @@ class ModelTests(unittest.TestCase):
|
||||
self.assertEqual(restored.count, 30000)
|
||||
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):
|
||||
# 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()
|
||||
|
||||
@@ -145,3 +145,273 @@ class TrackerTests(unittest.TestCase):
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class UnknownStateTests(unittest.TestCase):
|
||||
"""OPEN, CLOSED and UNKNOWN are three states: unknown never means closed."""
|
||||
|
||||
def setUp(self):
|
||||
self.tracker = wm.VentilationTracker(wm.build_windows([(window('w'), None)], ['bed']), recovery_minutes=20)
|
||||
self.t = 1_000_000.0
|
||||
|
||||
def poll(self, state, changed_ago=None, temp=20.0):
|
||||
changed = self.t - changed_ago if changed_ago is not None else self.t - 100_000
|
||||
self.tracker.update(self.t, {'w': (state, changed)}, {'bed': temp}, WEATHER)
|
||||
inputs = self.tracker.zone_inputs('bed', WEATHER)
|
||||
forecast = self.tracker.forecast('bed', 6, 15, WEATHER, self.t)
|
||||
self.t += 300
|
||||
return inputs, forecast, dict(self.tracker.window_state['w'])
|
||||
|
||||
def open_for_a_while(self):
|
||||
self.poll('off')
|
||||
self.poll('off', temp=21.0)
|
||||
self.poll('on', changed_ago=100, temp=20.4)
|
||||
self.poll('on', changed_ago=400, temp=20.0)
|
||||
|
||||
def test_open_to_unknown_keeps_the_event_and_assumes_still_open(self):
|
||||
self.open_for_a_while()
|
||||
inputs, forecast, state = self.poll('unavailable', changed_ago=60, temp=19.8)
|
||||
self.assertEqual(len(self.tracker.events), 0) # no confirmed closing without evidence
|
||||
self.assertIn('bed', self.tracker._open_event)
|
||||
self.assertEqual((inputs['known'], inputs['affected'], inputs['recovery']), (False, True, 0.0))
|
||||
self.assertEqual(forecast[0]['opening'], 1.0) # last seen open: still treated as open
|
||||
self.assertEqual((state['open'], state['last_known_open']), (None, True))
|
||||
self.assertIsNotNone(state['unknown_since'])
|
||||
|
||||
def test_unknown_to_open_continues_the_same_event(self):
|
||||
self.open_for_a_while()
|
||||
self.poll('unavailable', changed_ago=60)
|
||||
self.poll('unavailable', changed_ago=360)
|
||||
inputs, _, state = self.poll('on', changed_ago=30) # sensor back, window still open
|
||||
self.assertEqual((len(self.tracker.events), state['unknown_since']), (0, None))
|
||||
self.assertFalse(inputs['known']) # the interval itself was unknown
|
||||
inputs, _, _ = self.poll('on', changed_ago=330)
|
||||
self.assertEqual((inputs['known'], inputs['opening']), (True, 1.0))
|
||||
self.poll('off', changed_ago=120, temp=20.2) # confirmed close
|
||||
event = self.tracker.events[-1]
|
||||
self.assertEqual((event['closed_confirmed'], event['sensor_unknown_minutes']), (True, 10.0)) # two polls
|
||||
self.assertIsNotNone(event['duration_minutes'])
|
||||
|
||||
def test_unknown_to_closed_records_an_unconfirmed_close(self):
|
||||
self.open_for_a_while()
|
||||
self.poll('unavailable', changed_ago=60)
|
||||
inputs, forecast, state = self.poll('off', changed_ago=20, temp=20.1)
|
||||
event = self.tracker.events[-1]
|
||||
self.assertEqual((event['closed'], event['closed_confirmed'], event['duration_minutes']), (None, False, None))
|
||||
self.assertGreater(event['duration_max_minutes'], 0)
|
||||
self.assertEqual((state['open'], state['last_known_open'], state['unknown_since']), (False, False, None))
|
||||
# Recovery is assumed from when the sensor reported closed, but its timing is
|
||||
# unknown, so neither the ventilation nor the envelope model learns from it.
|
||||
self.assertGreater(inputs['recovery'], 0.9)
|
||||
self.assertEqual((inputs['known'], inputs['affected'], inputs['opening']), (False, True, None))
|
||||
self.assertEqual(forecast[0]['opening'], 0.0)
|
||||
self.assertGreater(forecast[0]['recovery'], 0.0)
|
||||
|
||||
def test_closed_to_unknown_assumes_closed_but_blocks_learning(self):
|
||||
self.poll('off')
|
||||
self.poll('off')
|
||||
inputs, forecast, state = self.poll('unavailable', changed_ago=120)
|
||||
self.assertEqual((inputs['known'], inputs['affected']), (False, True))
|
||||
self.assertEqual(forecast[0]['opening'], 0.0)
|
||||
self.assertEqual((state['open'], state['last_known_open']), (None, False))
|
||||
self.assertEqual(len(self.tracker.events), 0)
|
||||
|
||||
def test_unknown_to_open_after_a_closed_window_starts_an_unconfirmed_event(self):
|
||||
self.poll('off')
|
||||
self.poll('off', temp=21.0)
|
||||
self.poll('unavailable', changed_ago=60, temp=20.6)
|
||||
self.poll('on', changed_ago=30, temp=20.0) # opened at some point during the outage
|
||||
event = self.tracker._open_event['bed']
|
||||
self.assertEqual((event['opened_confirmed'], event['temperature_before']), (False, None))
|
||||
self.poll('on', changed_ago=330, temp=19.8)
|
||||
inputs, _, _ = self.poll('off', changed_ago=100, temp=19.9)
|
||||
self.assertTrue(self.tracker.events[-1]['closed_confirmed'])
|
||||
# Without the temperature before ventilation, recovery cannot be learned.
|
||||
self.assertFalse(inputs['known'])
|
||||
|
||||
def test_recovery_after_prolonged_unavailability(self):
|
||||
self.open_for_a_while()
|
||||
for _ in range(72): # six hours without the sensor
|
||||
self.poll('unavailable', changed_ago=60)
|
||||
self.assertEqual(len(self.tracker.events), 0)
|
||||
inputs, _, _ = self.poll('off', changed_ago=60)
|
||||
event = self.tracker.events[-1]
|
||||
self.assertFalse(event['closed_confirmed'])
|
||||
self.assertGreaterEqual(event['duration_max_minutes'], 360)
|
||||
self.assertEqual(event['sensor_unknown_minutes'], 360.0)
|
||||
for _ in range(12): # recovery decays, learning resumes
|
||||
inputs, _, _ = self.poll('off')
|
||||
self.assertEqual((inputs['known'], inputs['affected'], inputs['recovery']), (True, False, 0.0))
|
||||
|
||||
def test_multiple_windows_with_one_unknown(self):
|
||||
tracker = wm.VentilationTracker(wm.build_windows([(window('a'), None), (window('b'), None)], ['bed']))
|
||||
for t, a, b in ((0, 'off', 'off'), (300, 'on', 'off'), (600, 'on', 'unavailable'),
|
||||
(900, 'off', 'unavailable')):
|
||||
tracker.update(float(t), {'a': (a, t - 60.0), 'b': (b, t - 60.0)}, {'bed': 20.0}, WEATHER)
|
||||
# Window a closed (confirmed); b was last seen closed: the event ends, confirmed,
|
||||
# but the zone stays out of learning while b's state is unknown.
|
||||
self.assertTrue(tracker.events[-1]['closed_confirmed'])
|
||||
inputs = tracker.zone_inputs('bed', WEATHER)
|
||||
self.assertEqual((inputs['known'], inputs['unknown_windows']), (False, ['B']))
|
||||
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user