Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87437cbd48 | ||
|
|
35370958fc | ||
|
|
8eef04c385 | ||
|
|
4911b32c5e | ||
|
|
a44163a57d | ||
|
|
26c415fa6c | ||
|
|
d73e32a4b9 | ||
|
|
099c63cd02 | ||
|
|
9bb64e79ca | ||
|
|
2bddc74d37 |
No files matched your search
@@ -320,3 +320,80 @@ weather forecasts. Learners of removed zones keep only the base model.
|
|||||||
**Verification.** `sh scripts/check.sh` passes in both environments (lint, 125
|
**Verification.** `sh scripts/check.sh` passes in both environments (lint, 125
|
||||||
offline tests, 26 HA Core tests, compilation, package/translation/changelog checks).
|
offline tests, 26 HA Core tests, compilation, package/translation/changelog checks).
|
||||||
The offline tests also pass on Python 3.9.6.
|
The offline tests also pass on Python 3.9.6.
|
||||||
|
|
||||||
|
## 0.4.1 — stabilization after 0.4.0 — 2026-10-27
|
||||||
|
|
||||||
|
Scope: correctness of 0.4.0, no new features. Repository code
|
||||||
|
and tests only (HA Core 2025.12.0 and 2026.2.3 harness); no HAOS, device or
|
||||||
|
deployment testing. Still no actuator code; the static and runtime service-call
|
||||||
|
guards pass.
|
||||||
|
|
||||||
|
Confirmed issues and fixes:
|
||||||
|
|
||||||
|
1. **Unknown window states read as closed** (2bddc74). Reproduced: an open window
|
||||||
|
whose sensor became unavailable ended the ventilation event, started recovery
|
||||||
|
at full weight and let the forecast assume a closed window. Windows are now
|
||||||
|
tri-state with a remembered last known state: last seen open → possibly open
|
||||||
|
(base and extended layers do not learn, the forecast assumes open); a close
|
||||||
|
while unknown is logged as unconfirmed with its longest possible duration;
|
||||||
|
recovery is learned only after a confirmed close. Seven transition tests
|
||||||
|
(open/closed/unknown in every order, restart while unknown, polarity).
|
||||||
|
2. **Learned coefficients reused after incompatible changes** (099c63c, 8eef04c).
|
||||||
|
By construction: changing a zone's sensors or weight ratio, a heat source, a
|
||||||
|
window or the recovery time kept every coefficient. New
|
||||||
|
`compatibility.py` fingerprints each component's configuration; on restore
|
||||||
|
only changed components are reset (a measurement change recalibrates the zone:
|
||||||
|
coefficients kept as a starting point, uncertainty, counts and accuracy
|
||||||
|
statistics reset). Comfort weight, target offset and names never invalidate.
|
||||||
|
Heat-source baseline samples are dropped when the effective power's definition
|
||||||
|
changes. 0.4.0 data has no fingerprints, is assumed compatible, and is
|
||||||
|
fingerprinted at the first start. Resets are logged in `model_changes`.
|
||||||
|
3. **Plans published from changed inputs** (d73e32a). The pre/post-optimization
|
||||||
|
check covered only temperatures, relay and thermostat, and an overtaken update
|
||||||
|
could still publish. The input snapshot now covers zone and outdoor
|
||||||
|
temperatures, electrical power, wind, windows, relay and thermostat, with
|
||||||
|
tolerances so sensor noise does not discard every plan; a run counter keeps an
|
||||||
|
older run from overwriting a newer result; shutdown is checked after saving
|
||||||
|
and after the worker; a per-entry storage lock serializes save, restore and
|
||||||
|
delete (also fixing a delete-during-save race that could recreate the file).
|
||||||
|
4. **Model accuracy reported as forecast accuracy** (26c415f). The Forecast Error
|
||||||
|
sensors replayed the model with the actual future relay, electrical heat and
|
||||||
|
windows. They are now **Model Error** (unique IDs unchanged) with RMSE,
|
||||||
|
effective samples and a quality label, and their state stays unknown below 5
|
||||||
|
effective samples. A new **Forecast Error** sensor scores published forecasts
|
||||||
|
with the plan and assumptions of the moment, only when the relay followed the
|
||||||
|
plan (≥ 12 of every 15 minutes in the planned state); otherwise the forecast is
|
||||||
|
counted as counterfactual, unverifiable or missing, never as error.
|
||||||
|
|
||||||
|
Also reviewed:
|
||||||
|
|
||||||
|
- **Confidence** (a44163a). Extended and ventilation coefficients already used
|
||||||
|
posterior variance. Base coefficients now report it too; a test shows two weeks
|
||||||
|
of non-exciting data (relay off, steady difference) keep `heat` at 0 and `loss`
|
||||||
|
and `bias` below 0.8, while four varied days bring all three above 0.8.
|
||||||
|
Readiness still needs sample counts; it is a gate, not a confidence.
|
||||||
|
- **Lifecycle** (4911b32). Three reloads leave one relay listener and one polling
|
||||||
|
coordinator; after unload nothing is recorded or published. The test was
|
||||||
|
checked to fail with the unsubscribe removed.
|
||||||
|
- **Sensor weights UI** (9bb64e7, user report). Per-sensor weights existed but
|
||||||
|
were an unlabelled second step; they are now numbered fields with the sensors
|
||||||
|
listed, in English and Romanian.
|
||||||
|
|
||||||
|
Remaining risks:
|
||||||
|
|
||||||
|
- In observation mode the relay follows the MPC plan mainly when Generic
|
||||||
|
Thermostat happens to agree, so Forecast Error may stay `insufficient` for a
|
||||||
|
long time, and the scored subset is biased towards those periods.
|
||||||
|
- `effective_samples` corrects for overlapping horizons and shared zones only; it
|
||||||
|
is not an autocorrelation estimate. Quality thresholds (5, 30) are judgement.
|
||||||
|
- Configuration changes made before this update cannot be detected: 0.4.0 models
|
||||||
|
are fingerprinted as they are. A sensor replaced under the same entity ID, or a
|
||||||
|
weather provider swap with a systematic offset, does not change any fingerprint.
|
||||||
|
- Input-change tolerances are fixed constants, not options.
|
||||||
|
- Published forecasts awaiting their horizon are lost on restart.
|
||||||
|
- None of this has run on HAOS or with real devices.
|
||||||
|
|
||||||
|
**Verification.** In both environments (HA 2025.12.0 and 2026.2.3, Python 3.13):
|
||||||
|
ruff, 159 offline tests, 28 HA Core tests; the offline tests also pass on Python
|
||||||
|
3.9.6. `sh scripts/check.sh` additionally validates packaging, translations and
|
||||||
|
the changelog.
|
||||||
@@ -2,6 +2,81 @@
|
|||||||
|
|
||||||
Engineering findings and test evidence behind each change are in [AUDIT.md](AUDIT.md).
|
Engineering findings and test evidence behind each change are in [AUDIT.md](AUDIT.md).
|
||||||
|
|
||||||
|
## 0.4.1 — 2026-10-27
|
||||||
|
|
||||||
|
Stability and correctness: fixes four problems found in 0.4.0 and separates model
|
||||||
|
accuracy from forecast accuracy. No new product features. Still
|
||||||
|
**observation-only**. Tested in the Home Assistant Core harness
|
||||||
|
with HA 2025.12.0 and 2026.2.3; not yet on HAOS.
|
||||||
|
|
||||||
|
### Upgrade notes
|
||||||
|
|
||||||
|
- **Forecast Error 15/30/60/120 min are renamed Model Error.** Unique IDs and,
|
||||||
|
on existing installs, entity IDs are unchanged, so history continues. Their
|
||||||
|
state now stays *unknown* until there are at least 5 roughly independent spans
|
||||||
|
(`quality` is `insufficient`); the numbers stay in the attributes meanwhile.
|
||||||
|
- **A new Heating MPC Forecast Error sensor** scores forecasts as they were
|
||||||
|
published. It stays unknown until the relay has followed enough MPC plans; while
|
||||||
|
Generic Thermostat disagrees with the plan, most forecasts are counted as
|
||||||
|
`counterfactual` instead of being scored.
|
||||||
|
- On the first start, the saved model gets configuration fingerprints. Models
|
||||||
|
learned under 0.4.0 are assumed to match the configuration they are loaded with;
|
||||||
|
later changes are checked against these fingerprints.
|
||||||
|
- Zone forecast attribute `coefficients.base` now gives `value` and `confidence`
|
||||||
|
per coefficient instead of a bare value.
|
||||||
|
- Downgrading to 0.4.0 keeps the learned models but drops the Model Error
|
||||||
|
statistics (0.4.0 rejects their new format).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Unknown window states no longer pass as closed.** An unavailable window
|
||||||
|
sensor used to end an open-window event, start recovery and let the forecast
|
||||||
|
assume a closed window. Now the last known state is kept: a window last seen
|
||||||
|
open is treated as possibly open (no base learning, forecast assumes open), an
|
||||||
|
event closed while the sensor was unknown is marked unconfirmed with a bounded
|
||||||
|
duration, and recovery is learned only after a confirmed close.
|
||||||
|
- **Learned parameters are no longer reused after incompatible changes.** Each
|
||||||
|
learned component has a fingerprint of the configuration it was learned under
|
||||||
|
(see *Persisted data*). Changing a zone's sensors or the ratio of their weights
|
||||||
|
recalibrates that zone; changing a heat source, neighbour, window, orientation
|
||||||
|
or recovery time resets only the affected coefficients; comfort weight, target
|
||||||
|
offset and names never invalidate anything. Every recalibration is listed in the
|
||||||
|
Status attribute `model_changes`.
|
||||||
|
- **Plans are no longer published from inputs that changed meanwhile.** Before
|
||||||
|
and after the plan is computed, the integration compares every input it used
|
||||||
|
(zone and outdoor temperatures beyond 0.25 °C, power beyond 50 W, wind, window
|
||||||
|
states, relay and thermostat) and discards the plan if one changed meaningfully
|
||||||
|
(`inputs_changed`, with `changed_inputs`). An older run finishing late never
|
||||||
|
overwrites a newer result, nothing is published after unload, and saving and
|
||||||
|
deleting the model can no longer overlap.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Model accuracy and forecast accuracy are reported separately.** Model Error
|
||||||
|
replays the model with the inputs that actually happened (relay, electrical
|
||||||
|
heat, windows); Forecast Error uses only what was known when the forecast was
|
||||||
|
published, and scores it only when the relay followed the forecast's plan
|
||||||
|
(in the planned state for at least 12 minutes of every 15-minute block). Both report MAE, RMSE, bias, the
|
||||||
|
no-change baseline, the improvement over it, sample counts, effective
|
||||||
|
(roughly independent) sample counts and a quality label.
|
||||||
|
- Base-model coefficients (`loss`, `heat`, `bias`) report a confidence from their
|
||||||
|
posterior variance, like the other layers: it reflects whether the data varied
|
||||||
|
their inputs, not how many samples there are.
|
||||||
|
- The zone form's sensor weights are numbered fields with the sensors listed
|
||||||
|
above them (English and Romanian), and the zone form explains the two kinds of
|
||||||
|
weight.
|
||||||
|
|
||||||
|
### Persisted data
|
||||||
|
|
||||||
|
- `schema_version` stays 2. New optional keys: `fingerprint_version`,
|
||||||
|
`fingerprints`, `model_changes` (last 20), `operational_evaluation` (Forecast
|
||||||
|
Error statistics, 7 days) and per-source baseline fingerprints in
|
||||||
|
`heat_sources`. Retired zone models keep their fingerprint.
|
||||||
|
- Model Error statistics gain RMSE columns; 0.3 and 0.4.0 statistics load (their
|
||||||
|
RMSE stays empty until new forecasts are scored).
|
||||||
|
- Pending published forecasts are kept in memory only; a restart drops those not
|
||||||
|
yet due.
|
||||||
|
|
||||||
## 0.4.0 — 2026-10-27
|
## 0.4.0 — 2026-10-27
|
||||||
|
|
||||||
Thermal zones, electrical heat sources, windows with ventilation and wind, and
|
Thermal zones, electrical heat sources, windows with ventilation and wind, and
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Heating MPC 0.4.0 — custom integration for Home Assistant OS
|
# Heating MPC 0.4.1 — custom integration for Home Assistant OS
|
||||||
|
|
||||||
**Experimental, observation-only (shadow mode).** It **never** changes the Generic Thermostat, the physical relay, the boiler's setpoint, `heat/off`, or Comfort/Away/Sleep. No Docker add-on, MQTT or InfluxDB connection is needed.
|
**Experimental, observation-only (shadow mode).** It **never** changes the Generic Thermostat, the physical relay, the boiler's setpoint, `heat/off`, or Comfort/Away/Sleep. No Docker add-on, MQTT or InfluxDB connection is needed.
|
||||||
|
|
||||||
@@ -26,14 +26,24 @@ heat source** and **Add window** create items that can each be edited or deleted
|
|||||||
later. **Configure** holds everything else: thermostat, relay, weather sources,
|
later. **Configure** holds everything else: thermostat, relay, weather sources,
|
||||||
report-age limits, MPC settings and model options.
|
report-age limits, MPC settings and model options.
|
||||||
|
|
||||||
**Zones** are rooms or areas. Each has a name, one or more temperature sensors with
|
**Zones** are rooms or areas. Each has a name, one or more temperature sensors, a
|
||||||
sensor weights (how much each counts in the zone temperature), a comfort weight
|
comfort weight (how much the zone matters to the MPC), an optional target offset
|
||||||
(how much the zone matters to the MPC), an optional target offset relative to the
|
relative to the thermostat's target, and optional adjacent zones it exchanges heat
|
||||||
thermostat's target, and optional adjacent zones it exchanges heat with. Sensor and
|
with. A zone with several sensors gets a second step, **Sensor weights**: one
|
||||||
comfort weights never change the learned thermal coefficients. A sensor belongs to
|
numbered field per sensor (listed above the fields), setting how much each counts
|
||||||
one zone only. Upgrading from 0.3 turns each room sensor into a one-sensor zone
|
in the zone temperature. A sensor belongs to one zone only. Upgrading from 0.3
|
||||||
with the same comfort weight; to group two sensors of one room, edit one zone to
|
turns each room sensor into a one-sensor zone with the same comfort weight; to
|
||||||
include both and delete the other.
|
group two sensors of one room, edit one zone to include both and delete the other.
|
||||||
|
|
||||||
|
**What a change does to the learned model.** The zone temperature is defined by
|
||||||
|
its sensors and the ratio of their weights; changing either recalibrates that
|
||||||
|
zone (its coefficients are kept as a starting point, but their confidence, sample
|
||||||
|
counts and accuracy statistics start over, so *Ready* has to be earned again).
|
||||||
|
Scaling all of a zone's sensor weights by the same factor changes nothing. Adding,
|
||||||
|
editing or removing a heat source, a neighbour, a window, a window's orientation
|
||||||
|
or the recovery time resets only the coefficients that depend on it. Comfort
|
||||||
|
weight, target offset and names never touch the model. Each recalibration is
|
||||||
|
listed in the Status attribute `model_changes`.
|
||||||
|
|
||||||
**Heat sources** are equipment whose electricity ends up as heat in a zone, such as
|
**Heat sources** are equipment whose electricity ends up as heat in a zone, such as
|
||||||
a UPS feeding computers. Settings: an active-power sensor (W or kW; VA and % are
|
a UPS feeding computers. Settings: an active-power sensor (W or kW; VA and % are
|
||||||
@@ -48,7 +58,11 @@ modeled; the persistent load is already part of the zone's learned behavior.
|
|||||||
reports *on* when closed (inverted), the direction the outside of the window faces
|
reports *on* when closed (inverted), the direction the outside of the window faces
|
||||||
(degrees clockwise from north; default 345°, editable) and an opening factor
|
(degrees clockwise from north; default 345°, editable) and an opening factor
|
||||||
(1.0 normal, lower for tilted, higher for large). Unknown or unavailable states are
|
(1.0 normal, lower for tilted, higher for large). Unknown or unavailable states are
|
||||||
treated as unknown, never as closed.
|
treated as unknown, never as closed: a window last seen open counts as possibly
|
||||||
|
open (the forecast assumes it open, the zone's base model does not learn), and an
|
||||||
|
opening that ended while the sensor was unknown is logged as unconfirmed, with its
|
||||||
|
longest possible duration. Recovery after closing is learned only from a confirmed
|
||||||
|
close.
|
||||||
|
|
||||||
Model options in **Configure**: how fast extra electrical heat is assumed to fade in
|
Model options in **Configure**: how fast extra electrical heat is assumed to fade in
|
||||||
forecasts (half-life, default 30 minutes), how long an open window is assumed to
|
forecasts (half-life, default 30 minutes), how long an open window is assumed to
|
||||||
@@ -107,13 +121,16 @@ Entities (entity IDs may vary):
|
|||||||
- **Heating MPC Last Sample** — what the last poll did for learning (for example
|
- **Heating MPC Last Sample** — what the last poll did for learning (for example
|
||||||
*Learned*, *Relay switched*, *Waiting for radiators to settle*); its history shows
|
*Learned*, *Relay switched*, *Waiting for radiators to settle*); its history shows
|
||||||
when learning happens.
|
when learning happens.
|
||||||
- **Heating MPC Forecast Error 15/30/60/120 min** — how far the thermal model's
|
- **Heating MPC Model Error 15/30/60/120 min** — how well the thermal model
|
||||||
forecasts were from what actually happened (see *Forecast accuracy*).
|
explains what happened, replayed with the inputs that actually occurred.
|
||||||
|
- **Heating MPC Forecast Error** — how far published forecasts were from what
|
||||||
|
happened, using only what was known when they were made (see *Forecast
|
||||||
|
accuracy*).
|
||||||
|
|
||||||
## Known limitations / safety
|
## Known limitations / safety
|
||||||
|
|
||||||
- **Do not** connect the proposed binary sensor directly to the heating switch or build an automation from it. A production controller would need watchdog, manual override, safety handling, and an exclusive relay owner with Generic Thermostat fallback.
|
- **Do not** connect the proposed binary sensor directly to the heating switch or build an automation from it. A production controller would need watchdog, manual override, safety handling, and an exclusive relay owner with Generic Thermostat fallback.
|
||||||
- Weather variables other than temperature, wind and gusts are **captured but not fitted**. Wind and gusts are used only for open windows. Keep optional model terms only when **Forecast Error** shows they help (`improvement_vs_base`).
|
- Weather variables other than temperature, wind and gusts are **captured but not fitted**. Wind and gusts are used only for open windows. Keep optional model terms only when **Model Error** shows they help (`improvement_vs_base`).
|
||||||
- One temperature per zone (no separate wall mass), configured response times, and single-scenario forecasts of electrical heat and windows. See [docs/MODEL.md](docs/MODEL.md#limitations).
|
- One temperature per zone (no separate wall mass), configured response times, and single-scenario forecasts of electrical heat and windows. See [docs/MODEL.md](docs/MODEL.md#limitations).
|
||||||
- All rooms share one heating actuator. The model does not command individual radiators.
|
- All rooms share one heating actuator. The model does not command individual radiators.
|
||||||
- No weather forecast: assumes outdoor temperature remains constant during simulated horizon.
|
- No weather forecast: assumes outdoor temperature remains constant during simulated horizon.
|
||||||
@@ -142,7 +159,10 @@ with it OFF, which can take a few days depending on how often the boiler runs.
|
|||||||
The **Heating MPC Status** entity's state is one of `waiting_for_sensors`,
|
The **Heating MPC Status** entity's state is one of `waiting_for_sensors`,
|
||||||
`no_zones` (add a zone), `learning`, `paused` (thermostat not in `heat`), `waiting_for_target`, `ready`,
|
`no_zones` (add a zone), `learning`, `paused` (thermostat not in `heat`), `waiting_for_target`, `ready`,
|
||||||
`inputs_changed` or `prediction_error`. Proposed heat is available only while it
|
`inputs_changed` or `prediction_error`. Proposed heat is available only while it
|
||||||
is `ready`. Useful attributes:
|
is `ready`. `inputs_changed` means an input moved meaningfully while the plan was
|
||||||
|
being computed (beyond 0.25 °C, 50 W, 1 m/s wind, 2 m/s gust or 20° direction, or
|
||||||
|
any change of a window, the relay or the thermostat); `changed_inputs` lists them,
|
||||||
|
the plan is discarded and the next poll computes a fresh one. Useful attributes:
|
||||||
|
|
||||||
- `input_quality` — per zone (with each of its sensors) and per weather source
|
- `input_quality` — per zone (with each of its sensors) and per weather source
|
||||||
(grouped by metric): `status` (`valid`, `missing`, `unknown`, `unavailable`,
|
(grouped by metric): `status` (`valid`, `missing`, `unknown`, `unavailable`,
|
||||||
@@ -156,8 +176,10 @@ is `ready`. Useful attributes:
|
|||||||
- `heat_sources` — per source: measured and effective power (after removing
|
- `heat_sources` — per source: measured and effective power (after removing
|
||||||
included sources), baseline, variable power above it, thermal response, whether it
|
included sources), baseline, variable power above it, thermal response, whether it
|
||||||
has settled.
|
has settled.
|
||||||
- `windows` — per window: state, minutes open, wind direction relative to the
|
- `windows` — per window: state (`open`, `closed` or `unknown`), the last known
|
||||||
window's facing and the resulting exposure (1 = wind straight at it).
|
state and for how long the sensor has been unknown, minutes open, wind direction
|
||||||
|
relative to the window's facing and the resulting exposure (1 = wind straight at
|
||||||
|
it).
|
||||||
`ventilation` — per zone: current opening, recovery, temperature before opening,
|
`ventilation` — per zone: current opening, recovery, temperature before opening,
|
||||||
its contribution in °C/h and the ventilation coefficients. `ventilation_events` —
|
its contribution in °C/h and the ventilation coefficients. `ventilation_events` —
|
||||||
the last five openings with temperatures before, lowest, at closing and 30
|
the last five openings with temperatures before, lowest, at closing and 30
|
||||||
@@ -170,14 +192,20 @@ is `ready`. Useful attributes:
|
|||||||
`ventilation` (only window-affected zones; given to the ventilation model),
|
`ventilation` (only window-affected zones; given to the ventilation model),
|
||||||
`partly_rejected` (an outlier in some zone), `interval_out_of_range`,
|
`partly_rejected` (an outlier in some zone), `interval_out_of_range`,
|
||||||
`relay_changed`, `outside_source_changed`, `lag_uncertain` or `waiting_for_inputs`.
|
`relay_changed`, `outside_source_changed`, `lag_uncertain` or `waiting_for_inputs`.
|
||||||
- `models` — per-zone base coefficients, accepted sample counts and the in-sample
|
- `models` — per-zone base coefficients with their `confidence`, accepted sample
|
||||||
5-minute error (`mae_5m`, **not** a forecast accuracy), `model_in_use` and the
|
counts and the in-sample 5-minute error (`mae_5m`, **not** a forecast accuracy),
|
||||||
extended layer's coefficients with confidence.
|
`model_in_use` and the extended layer's coefficients with confidence. Confidence
|
||||||
|
(0 to 1) is the share of a coefficient's information that comes from data rather
|
||||||
|
than the starting assumption. It grows only while the data varies that
|
||||||
|
coefficient's input: weeks with the relay always off leave `heat` at 0, however
|
||||||
|
many samples there are.
|
||||||
|
- `model_changes` — the last recalibrations: when, which zone, which coefficients
|
||||||
|
were reset and why.
|
||||||
- `retired_zone_models` — removed zones whose learned models are kept.
|
- `retired_zone_models` — removed zones whose learned models are kept.
|
||||||
|
|
||||||
`input_quality`, `weather_comparison`, `heat_sources`, `windows`, `ventilation`
|
`input_quality`, `weather_comparison`, `heat_sources`, `windows`, `ventilation`,
|
||||||
and `ventilation_events` change every poll, so they are not written to the
|
`ventilation_events` and `model_changes` are not written to the Recorder; the other
|
||||||
Recorder; the other attributes are.
|
attributes are.
|
||||||
|
|
||||||
### Choosing freshness limits
|
### Choosing freshness limits
|
||||||
|
|
||||||
@@ -216,22 +244,35 @@ age limit.
|
|||||||
|
|
||||||
## Forecast accuracy
|
## Forecast accuracy
|
||||||
|
|
||||||
The four **Forecast Error** sensors measure the thermal model against reality,
|
Two different questions, two kinds of sensor:
|
||||||
continuously and out of sample. At every poll the integration records each zone's
|
|
||||||
reading, the outdoor temperature, the radiator-lag estimate and the model's
|
|
||||||
parameters. 15, 30, 60 and 120 minutes later it replays the zones from the earlier
|
|
||||||
reading using the parameters it had **then**, the outdoor temperature held at its
|
|
||||||
starting value, and the radiator heat that actually followed from the relay's real
|
|
||||||
ON/OFF history. The result is compared with the current reading.
|
|
||||||
|
|
||||||
This tests the thermal model, not the MPC plan, because the plan is never
|
- **Model Error** (four sensors, 15/30/60/120 min): *does the thermal model explain
|
||||||
executed. All zones are replayed together, with the electrical heat and window
|
what happened?* Replayed with the relay, electrical heat and windows that
|
||||||
openings that actually happened, and the base model is scored on the same spans. Each sensor's state is the mean absolute error (°C) over the last 7 days,
|
actually followed. Useful for judging the model and its options. It is **not**
|
||||||
across all zones. Attributes, all in °C except counts:
|
a forecast accuracy: a real forecast does not know when the relay will switch or
|
||||||
|
a window will open.
|
||||||
|
- **Forecast Error** (one sensor): *how good were the forecasts as published?*
|
||||||
|
Uses only what was known when each forecast was made.
|
||||||
|
|
||||||
- `samples` — number of scored forecasts. Consecutive forecasts overlap, so they
|
### Model Error
|
||||||
are not independent; treat small counts as indicative only.
|
|
||||||
- `bias` — mean error: negative means the model predicts too cold.
|
At every poll the integration records each zone's reading, the outdoor
|
||||||
|
temperature, the radiator-lag estimate and the model's parameters. 15, 30, 60 and
|
||||||
|
120 minutes later it replays all zones together from that reading using the
|
||||||
|
parameters it had **then**, the outdoor temperature held at its starting value,
|
||||||
|
and the relay, electrical heat and window history that actually followed. The base
|
||||||
|
model is scored on the same spans. Each sensor's state is the mean absolute error
|
||||||
|
(°C) over the last 7 days, across all zones. Attributes, all in °C except counts:
|
||||||
|
|
||||||
|
- `samples` — number of scored forecasts; `effective_samples` — roughly
|
||||||
|
independent ones. Forecasts start every 5 minutes, so 60-minute forecasts
|
||||||
|
overlap twelvefold, and zones share one boiler, so `effective_samples` is
|
||||||
|
`samples × 5 / horizon / zones`.
|
||||||
|
- `quality` — `insufficient` below 5 effective samples (the state then stays
|
||||||
|
unknown; the numbers are still in the attributes), `indicative` below 30, `ok`
|
||||||
|
from 30.
|
||||||
|
- `mae`, `rmse`, `bias` — mean absolute, root-mean-square and mean error
|
||||||
|
(negative bias: the model predicts too cold).
|
||||||
- `persistence_mae` — error of simply assuming no change; the baseline to beat.
|
- `persistence_mae` — error of simply assuming no change; the baseline to beat.
|
||||||
- `skill` — `1 - mae / persistence_mae`: 1 is perfect, 0 is no better than
|
- `skill` — `1 - mae / persistence_mae`: 1 is perfect, 0 is no better than
|
||||||
assuming no change, negative is worse. Empty while the temperature did not change.
|
assuming no change, negative is worse. Empty while the temperature did not change.
|
||||||
@@ -240,13 +281,35 @@ across all zones. Attributes, all in °C except counts:
|
|||||||
make it worse: switch that option off).
|
make it worse: switch that option off).
|
||||||
- `zones` — the same numbers per zone (not written to the Recorder).
|
- `zones` — the same numbers per zone (not written to the Recorder).
|
||||||
|
|
||||||
Forecasts are scored only where the inputs were known: polls 4.5–5.5 minutes
|
Spans are scored only where the inputs were known: polls 4.5–5.5 minutes apart, a
|
||||||
apart, a usable reading at the start and the end, and a well-known radiator-lag
|
usable reading at the start and the end, and a well-known radiator-lag estimate
|
||||||
estimate throughout. A missing relay, outdoor temperature or all zones restarts the
|
throughout. A missing relay, outdoor temperature or all zones restarts the replay
|
||||||
replay window, so scores resume 15–120 minutes later. Statistics are kept with the
|
window, so scores resume 15–120 minutes later. Statistics are kept with the model
|
||||||
model and survive restarts; deleting the model resets them.
|
and survive restarts; recalibrating a zone or deleting the model resets them.
|
||||||
|
|
||||||
What it does not cover: outdoor temperature changes during the horizon (held
|
### Forecast Error
|
||||||
|
|
||||||
|
Each time a plan is published, its forecast is recorded as it was: start
|
||||||
|
temperatures, the trajectory, the plan, the model revision and the assumptions
|
||||||
|
(outdoor temperature and wind held, electrical heat fading, open windows closing).
|
||||||
|
When the forecast's horizon comes, it is scored against the reading, but only if
|
||||||
|
the relay actually followed the plan: in every 15-minute block up to that horizon
|
||||||
|
the relay must have been in the planned state for at least 12 of the 15 minutes.
|
||||||
|
Otherwise the forecast is not an error measurement, and is counted instead:
|
||||||
|
|
||||||
|
- `counterfactual` — the relay did something else: Generic Thermostat, which
|
||||||
|
controls it, disagreed with the plan. Expected in observation mode whenever the
|
||||||
|
two disagree.
|
||||||
|
- `unverifiable` — the relay history is incomplete for that span.
|
||||||
|
- `missing` — no poll near the due time, or no reading for a zone.
|
||||||
|
|
||||||
|
The state is the 60-minute MAE 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 and the per-zone figures for each horizon. Statistics cover 7 days and
|
||||||
|
survive restarts; forecasts not yet due when HA restarts are dropped.
|
||||||
|
|
||||||
|
What neither covers: outdoor temperature changes during the horizon (held
|
||||||
constant, as in the MPC), and periods when the model was still learning (included,
|
constant, as in the MPC), and periods when the model was still learning (included,
|
||||||
so early numbers are pessimistic). The roadmap's working target is about
|
so early numbers are pessimistic). The roadmap's working target is about
|
||||||
0.3–0.4 °C at 60 minutes with clearly positive skill, before any control is
|
0.3–0.4 °C at 60 minutes with clearly positive skill, before any control is
|
||||||
@@ -287,7 +350,8 @@ The brand icons in `custom_components/heating_mpc/brand/` are drawn by
|
|||||||
`python scripts/make_brand_icons.py` (uses Pillow from the test requirements);
|
`python scripts/make_brand_icons.py` (uses Pillow from the test requirements);
|
||||||
the package check verifies their sizes and transparency.
|
the package check verifies their sizes and transparency.
|
||||||
|
|
||||||
HA tests cover setup, config/options forms, migration, reload and entity cleanup,
|
HA tests cover setup, config/options forms, migration, reload (a single relay
|
||||||
|
listener and polling coordinator after repeated reloads) and entity cleanup,
|
||||||
scheduled sampling and poll cancellation, the relay listener, stored model
|
scheduled sampling and poll cancellation, the relay listener, stored model
|
||||||
restoration, retention and deletion, missing inputs, manual off/presets, optimizer
|
restoration, retention and deletion, missing inputs, manual off/presets, optimizer
|
||||||
failure and Recorder exclusions. A fixture rejects all HA service calls throughout
|
failure and Recorder exclusions. A fixture rejects all HA service calls throughout
|
||||||
|
|||||||
+18
-5
@@ -85,6 +85,16 @@ Release 0.2.2 adds that event-based relay history (read-only listener), keeps
|
|||||||
models of rooms removed in Options, shows each sensor's longest report gap, stops
|
models of rooms removed in Options, shows each sensor's longest report gap, stops
|
||||||
recording volatile attributes, and tests against HA 2025.12.0 and 2026.2.3.
|
recording volatile attributes, and tests against HA 2025.12.0 and 2026.2.3.
|
||||||
|
|
||||||
|
### Stabilization after 0.4.0 — 2026-10-27
|
||||||
|
|
||||||
|
See AUDIT.md. Four confirmed problems fixed, each with regression tests: unknown
|
||||||
|
window states read as closed; learned coefficients reused after incompatible
|
||||||
|
configuration changes (now per-component fingerprints with selective
|
||||||
|
recalibration, logged in `model_changes`); plans published from inputs that
|
||||||
|
changed during optimization or by an overtaken run; and model accuracy reported
|
||||||
|
as if it were forecast accuracy. Reload/unload tests now cover repeated reloads
|
||||||
|
(one relay listener, one polling coordinator). Released as 0.4.1; not yet tested on HAOS.
|
||||||
|
|
||||||
## Milestone 1 — Explainable forecasts and dashboards (target v0.3.0)
|
## Milestone 1 — Explainable forecasts and dashboards (target v0.3.0)
|
||||||
|
|
||||||
**Objective:** Users can verify what the model predicts and whether it is actually accurate.
|
**Objective:** Users can verify what the model predicts and whether it is actually accurate.
|
||||||
@@ -92,9 +102,9 @@ recording volatile attributes, and tests against HA 2025.12.0 and 2026.2.3.
|
|||||||
- [ ] Expose trajectories for each room at 5–15-minute resolution for 2–3 hours, with forecast generation timestamp, horizon and input provenance. *(0.3.0: 15-minute trajectory over the horizon; generation timestamp and provenance still to add.)*
|
- [ ] Expose trajectories for each room at 5–15-minute resolution for 2–3 hours, with forecast generation timestamp, horizon and input provenance. *(0.3.0: 15-minute trajectory over the horizon; generation timestamp and provenance still to add.)*
|
||||||
- [x] Expose predicted +15/+30/+60/+120 minute temperatures and full proposed ON/OFF schedule. *(0.3.0: `forecast_15min` and `plan_15min` on each room forecast entity.)*
|
- [x] Expose predicted +15/+30/+60/+120 minute temperatures and full proposed ON/OFF schedule. *(0.3.0: `forecast_15min` and `plan_15min` on each room forecast entity.)*
|
||||||
- [ ] Provide an HA dashboard example using native cards or a clearly documented optional chart card (do not bundle a heavyweight frontend unnecessarily).
|
- [ ] Provide an HA dashboard example using native cards or a clearly documented optional chart card (do not bundle a heavyweight frontend unnecessarily).
|
||||||
- [ ] Make clear that the **MPC plan forecast is counterfactual** while Generic Thermostat is controlling the relay; add a separate actual-policy forecast/evaluation pathway. *(0.3.0: plan forecasts labelled counterfactual; evaluation uses the actual relay history. An actual-policy forecast, simulating Generic Thermostat, is still open.)*
|
- [ ] Make clear that the **MPC plan forecast is counterfactual** while Generic Thermostat is controlling the relay; add a separate actual-policy forecast/evaluation pathway. *(0.3.0: plan forecasts labelled counterfactual; evaluation uses the actual relay history. Stabilization: published forecasts are scored only when the relay followed their plan and otherwise counted as `counterfactual`. An actual-policy forecast, simulating Generic Thermostat, is still open.)*
|
||||||
- [ ] Store a bounded history of predictions with snapshots of inputs, proposed plan and eventually observed outcome.
|
- [ ] Store a bounded history of predictions with snapshots of inputs, proposed plan and eventually observed outcome. *(Stabilization: each published forecast is kept with its start readings, plan, model revision and assumptions until scored, in memory only; the scored statistics persist. No browsable history yet.)*
|
||||||
- [x] Implement out-of-sample/walk-forward MAE and bias at 15, 30, 60 and 120 minutes; include sample counts and confidence/quality labels. *(0.3.0: Forecast Error sensors; replay with parameters frozen at the forecast start and the actual relay history; `skill` against persistence. No confidence intervals yet.)*
|
- [x] Implement out-of-sample/walk-forward MAE and bias at 15, 30, 60 and 120 minutes; include sample counts and confidence/quality labels. *(0.3.0: replay with parameters frozen at the forecast start and the actual relay history; `skill` against persistence. Stabilization: these are now the Model Error sensors, with RMSE, effective sample counts and quality labels; a separate Forecast Error sensor scores published forecasts. No confidence intervals yet.)*
|
||||||
- [ ] Compare against simple benchmarks such as current-temperature persistence and a fixed thermal baseline. *(0.3.0: persistence done; fixed baseline open.)*
|
- [ ] Compare against simple benchmarks such as current-temperature persistence and a fixed thermal baseline. *(0.3.0: persistence done; fixed baseline open.)*
|
||||||
- [ ] Add inspectable explanations: expected heat loss, expected heating response, target, room weights and reason for the next proposed action.
|
- [ ] Add inspectable explanations: expected heat loss, expected heating response, target, room weights and reason for the next proposed action.
|
||||||
|
|
||||||
@@ -175,8 +185,11 @@ Items 1–3 of the original backlog are done (see AUDIT.md and CHANGELOG.md 0.2.
|
|||||||
control, decide a minimum `weight_coverage` and the outdoor-temperature outage
|
control, decide a minimum `weight_coverage` and the outdoor-temperature outage
|
||||||
policy, which still pauses.
|
policy, which still pauses.
|
||||||
3. Configure zones, the UPS heat source and the windows on HAOS; after a week of
|
3. Configure zones, the UPS heat source and the windows on HAOS; after a week of
|
||||||
heating review `improvement_vs_base`, per-zone bias and which coefficients are
|
heating review Model Error `improvement_vs_base` and `quality`, per-zone bias,
|
||||||
identified. Switch off optional terms that do not help.
|
coefficient `confidence` (base layer included), `model_changes` after any
|
||||||
|
reconfiguration, and the Forecast Error counts (how many forecasts were scored
|
||||||
|
vs counterfactual). Switch off optional terms that do not help. Also try an
|
||||||
|
unavailable window sensor and a zone edit during HAOS acceptance.
|
||||||
4. Milestone 1 (rest): a dashboard example; forecast timestamps/provenance;
|
4. Milestone 1 (rest): a dashboard example; forecast timestamps/provenance;
|
||||||
explanations for the proposed action; an actual-policy forecast that simulates
|
explanations for the proposed action; an actual-policy forecast that simulates
|
||||||
Generic Thermostat.
|
Generic Thermostat.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from homeassistant.helpers.event import async_track_state_change_event
|
|||||||
|
|
||||||
from .const import CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
|
from .const import CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
|
||||||
from .weather_sources import sources_for
|
from .weather_sources import sources_for
|
||||||
from .coordinator import HeatingMPCCoordinator
|
from .coordinator import HeatingMPCCoordinator, storage_lock
|
||||||
from .zones import SUBENTRY_ZONE, ZONE_KEY, ZONE_NAME, legacy_zone_data
|
from .zones import SUBENTRY_ZONE, ZONE_KEY, ZONE_NAME, legacy_zone_data
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||||
@@ -62,7 +62,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Remove the learned model when this integration instance is deleted."""
|
"""Remove the learned model when this integration instance is deleted."""
|
||||||
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
||||||
await store.async_remove()
|
# Wait for a save still in progress, which would otherwise recreate the file.
|
||||||
|
async with storage_lock(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:
|
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)]
|
||||||
@@ -302,14 +302,23 @@ class ZoneSubentryFlow(config_entries.ConfigSubentryFlow):
|
|||||||
return await self._zone_form("reconfigure", user_input, existing, existing[ZONE_KEY])
|
return await self._zone_form("reconfigure", user_input, existing, existing[ZONE_KEY])
|
||||||
|
|
||||||
async def async_step_sensor_weights(self, user_input: dict[str, Any] | None = None):
|
async def async_step_sensor_weights(self, user_input: dict[str, Any] | None = None):
|
||||||
|
"""One weight per sensor; fields are numbered so they have translated labels."""
|
||||||
sensors = self._draft[ZONE_SENSORS]
|
sensors = self._draft[ZONE_SENSORS]
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self._draft[ZONE_SENSOR_WEIGHTS] = {s: float(user_input[f"weight__{s}"]) for s in sensors}
|
self._draft[ZONE_SENSOR_WEIGHTS] = {
|
||||||
|
s: float(user_input[f"sensor_weight_{i}"]) for i, s in enumerate(sensors, 1)}
|
||||||
return self._finish()
|
return self._finish()
|
||||||
weights = self._draft.get(ZONE_SENSOR_WEIGHTS) or {}
|
weights = self._draft.get(ZONE_SENSOR_WEIGHTS) or {}
|
||||||
return self.async_show_form(step_id="sensor_weights", data_schema=vol.Schema({
|
names = []
|
||||||
vol.Required(f"weight__{s}", default=weights.get(s, 1.0)): _number(*SENSOR_WEIGHT_RANGE, 0.1)
|
for i, sensor in enumerate(sensors, 1):
|
||||||
for s in sensors}))
|
state = self.hass.states.get(sensor)
|
||||||
|
names.append(f"{i}. {state.name if state else sensor} ({sensor})")
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="sensor_weights",
|
||||||
|
data_schema=vol.Schema({
|
||||||
|
vol.Required(f"sensor_weight_{i}", default=weights.get(s, 1.0)): _number(*SENSOR_WEIGHT_RANGE, 0.1)
|
||||||
|
for i, s in enumerate(sensors, 1)}),
|
||||||
|
description_placeholders={"sensors": "\n".join(names)})
|
||||||
|
|
||||||
def _finish(self):
|
def _finish(self):
|
||||||
data = {key: self._draft[key] for key in (
|
data = {key: self._draft[key] for key in (
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""Read existing HA entities, learn thermal response, produce shadow MPC decisions."""
|
"""Read existing HA entities, learn thermal response, produce shadow MPC decisions."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -20,8 +23,9 @@ from .const import (
|
|||||||
CONF_BAND, CONF_INTERNAL_HALF_LIFE, CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST,
|
CONF_BAND, CONF_INTERNAL_HALF_LIFE, CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST,
|
||||||
CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
|
CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
|
||||||
)
|
)
|
||||||
from .data_quality import ReportAgeHistory, freshness
|
from .compatibility import FINGERPRINT_VERSION, baseline_fingerprints, changed, reason, zone_fingerprints
|
||||||
from .evaluation import ForecastEvaluator, model_snapshot
|
from .data_quality import ReportAgeHistory, freshness, inputs_differ
|
||||||
|
from .evaluation import ForecastEvaluator, OperationalForecastLedger, model_snapshot
|
||||||
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
|
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
|
||||||
from .learning import FeatureLearner
|
from .learning import FeatureLearner
|
||||||
from .physics import (
|
from .physics import (
|
||||||
@@ -29,7 +33,9 @@ from .physics import (
|
|||||||
lag_from_time_in_state, optimize, relay_segments,
|
lag_from_time_in_state, optimize, relay_segments,
|
||||||
)
|
)
|
||||||
from .weather_sources import choose_weather_sources, sources_for, normalize
|
from .weather_sources import choose_weather_sources, sources_for, normalize
|
||||||
from .windows import SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, facing_cos, ventilation_vector
|
from .windows import (
|
||||||
|
SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, contact_open, facing_cos, ventilation_vector,
|
||||||
|
)
|
||||||
from .zone_model import ZoneModel, compile_step_model, extended_features, feature_vector
|
from .zone_model import ZoneModel, compile_step_model, extended_features, feature_vector
|
||||||
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
|
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
|
||||||
|
|
||||||
@@ -38,6 +44,16 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
MAX_RELAY_EVENTS = 256
|
MAX_RELAY_EVENTS = 256
|
||||||
# Models of zones removed in the UI, kept in case a zone is added back.
|
# Models of zones removed in the UI, kept in case a zone is added back.
|
||||||
MAX_RETIRED_ROOMS = 24
|
MAX_RETIRED_ROOMS = 24
|
||||||
|
MAX_MODEL_CHANGES = 20
|
||||||
|
BASE_TERMS = ("loss", "heat", "bias")
|
||||||
|
|
||||||
|
|
||||||
|
def storage_lock(hass: HomeAssistant, entry_id: str) -> asyncio.Lock:
|
||||||
|
"""One lock per entry, shared by successive coordinators across reloads.
|
||||||
|
|
||||||
|
A reload's new coordinator restores only after the old one's save finished.
|
||||||
|
"""
|
||||||
|
return hass.data.setdefault(f"{DOMAIN}_storage_locks", {}).setdefault(entry_id, asyncio.Lock())
|
||||||
|
|
||||||
|
|
||||||
def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
|
def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
|
||||||
@@ -94,7 +110,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors]
|
self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors]
|
||||||
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
||||||
self.sources = entry_source_list(entry, self.zones)
|
self.sources = entry_source_list(entry, self.zones)
|
||||||
self._heat_sources = HeatSourceTracker(self.sources)
|
self._baseline_prints = baseline_fingerprints(self.sources)
|
||||||
|
self._heat_sources = HeatSourceTracker(self.sources, fingerprints=self._baseline_prints)
|
||||||
# Extended model per zone: base terms plus electrical heat and adjacent zones.
|
# Extended model per zone: base terms plus electrical heat and adjacent zones.
|
||||||
self._extended_features = {
|
self._extended_features = {
|
||||||
key: extended_features(any(s.zone == key and s.enabled for s in self.sources), zone.neighbors)
|
key: extended_features(any(s.zone == key and s.enabled for s in self.sources), zone.neighbors)
|
||||||
@@ -107,9 +124,16 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
for key in self.zones}
|
for key in self.zones}
|
||||||
self.ventilation: dict[str, FeatureLearner] = {
|
self.ventilation: dict[str, FeatureLearner] = {
|
||||||
key: FeatureLearner(features) for key, features in self._ventilation_features.items() if features}
|
key: FeatureLearner(features) for key, features in self._ventilation_features.items() if features}
|
||||||
|
# What each learned component depends on; a change recalibrates only that component.
|
||||||
|
self._fingerprints = zone_fingerprints(self.zones, self.sources, self.windows,
|
||||||
|
float(self.config[CONF_RECOVERY_MINUTES]))
|
||||||
|
self._model_changes: deque[dict[str, Any]] = deque(maxlen=MAX_MODEL_CHANGES)
|
||||||
self._retired: dict[str, dict[str, Any]] = {}
|
self._retired: dict[str, dict[str, Any]] = {}
|
||||||
self._report_ages = ReportAgeHistory()
|
self._report_ages = ReportAgeHistory()
|
||||||
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
|
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
|
||||||
|
self._forecasts = OperationalForecastLedger()
|
||||||
|
# Seconds the relay was on during the last interval (None if unknown), and its length.
|
||||||
|
self._relay_interval: tuple[float | None, float | None] = (None, None)
|
||||||
# How each poll was handled since start, to explain slow learning.
|
# How each poll was handled since start, to explain slow learning.
|
||||||
self._sample_results: dict[str, int] = {}
|
self._sample_results: dict[str, int] = {}
|
||||||
self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
||||||
@@ -122,8 +146,22 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._relay_events_dropped = False
|
self._relay_events_dropped = False
|
||||||
self._on_samples = 0
|
self._on_samples = 0
|
||||||
self._off_samples = 0
|
self._off_samples = 0
|
||||||
|
# Increases with every update; an older update finishing late never publishes.
|
||||||
|
self._run = 0
|
||||||
|
|
||||||
async def async_restore_model(self) -> None:
|
async def async_restore_model(self) -> None:
|
||||||
|
async with storage_lock(self.hass, self.entry.entry_id):
|
||||||
|
await self._async_restore_model()
|
||||||
|
|
||||||
|
async def _async_save(self, outside_source: str | None) -> None:
|
||||||
|
async with storage_lock(self.hass, self.entry.entry_id):
|
||||||
|
await self._store.async_save(self._payload(outside_source))
|
||||||
|
|
||||||
|
def _stale(self, run: int) -> bool:
|
||||||
|
"""True when a newer update started or the entry is unloading."""
|
||||||
|
return run != self._run or getattr(self, "_shutdown_requested", False)
|
||||||
|
|
||||||
|
async def _async_restore_model(self) -> None:
|
||||||
stored = await self._store.async_load() or {}
|
stored = await self._store.async_load() or {}
|
||||||
if not isinstance(stored, dict) or stored.get("schema_version", 1) not in (1, 2):
|
if not isinstance(stored, dict) or stored.get("schema_version", 1) not in (1, 2):
|
||||||
_LOGGER.warning("Ignoring unsupported Heating MPC model data")
|
_LOGGER.warning("Ignoring unsupported Heating MPC model data")
|
||||||
@@ -134,7 +172,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
|
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
|
||||||
return
|
return
|
||||||
self._evaluator.load(stored.get("evaluation"))
|
self._evaluator.load(stored.get("evaluation"))
|
||||||
self._heat_sources = HeatSourceTracker(self.sources, stored.get("heat_sources"))
|
self._forecasts.load(stored.get("operational_evaluation"))
|
||||||
|
changes = stored.get("model_changes")
|
||||||
|
self._model_changes.extend(c for c in (changes if isinstance(changes, list) else []) if isinstance(c, dict))
|
||||||
|
self._heat_sources = HeatSourceTracker(self.sources, stored.get("heat_sources"), self._baseline_prints)
|
||||||
|
# None for 0.4.0 data: learned before fingerprints existed, assumed to match.
|
||||||
|
stored_prints = stored.get("fingerprints") if isinstance(stored.get("fingerprints"), dict) else None
|
||||||
saved_models = stored.get("zone_models") if isinstance(stored.get("zone_models"), dict) else {}
|
saved_models = stored.get("zone_models") if isinstance(stored.get("zone_models"), dict) else {}
|
||||||
for key, features in self._extended_features.items():
|
for key, features in self._extended_features.items():
|
||||||
if features and key in saved_models:
|
if features and key in saved_models:
|
||||||
@@ -146,8 +189,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
for key, features in self._ventilation_features.items():
|
for key, features in self._ventilation_features.items():
|
||||||
if features and key in saved_vent:
|
if features and key in saved_vent:
|
||||||
self.ventilation[key] = FeatureLearner.from_dict(saved_vent[key], features)
|
self.ventilation[key] = FeatureLearner.from_dict(saved_vent[key], features)
|
||||||
# Models carry room identity, so changing the room list is safe. Models of
|
# Models carry zone identity, so changing the zone list is safe. Models of
|
||||||
# rooms no longer configured are retained, most recently removed last.
|
# zones no longer configured are retained, most recently removed last, with
|
||||||
|
# the measurement fingerprint they were learned against.
|
||||||
|
base_prints: dict[str, str | None] = {}
|
||||||
for key in ("retired_learners", "learners"): # current learners take precedence
|
for key in ("retired_learners", "learners"): # current learners take precedence
|
||||||
saved = stored.get(key)
|
saved = stored.get(key)
|
||||||
if not isinstance(saved, dict):
|
if not isinstance(saved, dict):
|
||||||
@@ -156,14 +201,85 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
if not isinstance(room, str):
|
if not isinstance(room, str):
|
||||||
continue
|
continue
|
||||||
learner = Learner.from_dict(data)
|
learner = Learner.from_dict(data)
|
||||||
|
if key == "learners":
|
||||||
|
fingerprint = (stored_prints or {}).get(room, {}).get("base") if stored_prints is not None else None
|
||||||
|
else:
|
||||||
|
fingerprint = data.get("fingerprint") if isinstance(data, dict) else None
|
||||||
if room in self.learners:
|
if room in self.learners:
|
||||||
self.learners[room] = learner
|
self.learners[room] = learner
|
||||||
|
base_prints[room] = fingerprint
|
||||||
elif learner.count:
|
elif learner.count:
|
||||||
self._retired.pop(room, None)
|
self._retired.pop(room, None)
|
||||||
self._retired[room] = learner.as_dict()
|
self._retired[room] = {**learner.as_dict(), **({"fingerprint": fingerprint} if fingerprint else {})}
|
||||||
while len(self._retired) > MAX_RETIRED_ROOMS:
|
while len(self._retired) > MAX_RETIRED_ROOMS:
|
||||||
self._retired.pop(next(iter(self._retired)))
|
self._retired.pop(next(iter(self._retired)))
|
||||||
|
recalibrated = self._check_compatibility(stored_prints, base_prints)
|
||||||
self._update_counts()
|
self._update_counts()
|
||||||
|
first_prints = stored_prints is None and bool(stored.get("learners"))
|
||||||
|
if recalibrated or self._heat_sources.reset_baselines or first_prints:
|
||||||
|
# Save at once: a recalibration is not redone after a restart, and data
|
||||||
|
# from before fingerprints gets them now, so the next edit is detected
|
||||||
|
# even if it happens before the next learned sample.
|
||||||
|
await self._store.async_save(self._payload(stored.get("outside_source"))) # lock held
|
||||||
|
|
||||||
|
def _check_compatibility(self, stored: dict[str, Any] | None, base_prints: dict[str, str | None]) -> bool:
|
||||||
|
"""Recalibrate exactly the components whose configuration changed."""
|
||||||
|
any_change = False
|
||||||
|
for room in self.rooms:
|
||||||
|
current, old = self._fingerprints[room], (stored or {}).get(room) if stored is not None else None
|
||||||
|
resets: list[str] = []
|
||||||
|
measured = base_prints.get(room) is not None and base_prints[room] != current["base"]
|
||||||
|
if measured:
|
||||||
|
# The zone temperature is defined differently: re-learn it from a warm start.
|
||||||
|
self.learners[room].recalibrate()
|
||||||
|
self._evaluator.forget(room)
|
||||||
|
self._forecasts.forget(room)
|
||||||
|
resets.append("base")
|
||||||
|
for layer in (self.extended.get(room), self.ventilation.get(room)):
|
||||||
|
if layer is None:
|
||||||
|
continue
|
||||||
|
features = list(layer.features) if measured else changed(old, current, layer.features)
|
||||||
|
if features:
|
||||||
|
layer.reset(features, keep_values=BASE_TERMS)
|
||||||
|
# loss/heat/bias change only with the measurement, reported as "base".
|
||||||
|
resets.extend(f for f in features if f not in BASE_TERMS)
|
||||||
|
if measured:
|
||||||
|
layer.reset_counts()
|
||||||
|
if resets:
|
||||||
|
any_change = True
|
||||||
|
# A changed measurement explains every reset in the zone.
|
||||||
|
reasons = sorted({reason("base")} if measured else {reason(c) for c in resets})
|
||||||
|
self._model_changes.append({
|
||||||
|
"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
"zone": self.zones[room].name, "recalibrated": sorted(set(resets)), "reasons": reasons})
|
||||||
|
_LOGGER.info("Heating MPC: recalibrating %s for zone %s (%s)", ", ".join(sorted(set(resets))),
|
||||||
|
self.zones[room].name, "; ".join(reasons))
|
||||||
|
for key in self._heat_sources.reset_baselines:
|
||||||
|
name = next(s.name for s in self.sources if s.key == key)
|
||||||
|
self._model_changes.append({"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
"heat_source": name, "recalibrated": ["baseline"],
|
||||||
|
"reasons": ["power sensor or included sources changed"]})
|
||||||
|
return any_change
|
||||||
|
|
||||||
|
def _payload(self, outside_source: str | None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": 2,
|
||||||
|
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
|
||||||
|
# Optional keys; older 0.2.x readers ignore them and keep working.
|
||||||
|
"retired_learners": self._retired,
|
||||||
|
"zone_models": {key: model.as_dict() for key, model in self.extended.items()},
|
||||||
|
"heat_sources": self._heat_sources.as_dict(),
|
||||||
|
"ventilation_models": {key: model.as_dict() for key, model in self.ventilation.items()},
|
||||||
|
"ventilation": self._ventilation.as_dict(),
|
||||||
|
"evaluation": self._evaluator.as_dict(),
|
||||||
|
"operational_evaluation": self._forecasts.as_dict(),
|
||||||
|
"fingerprint_version": FINGERPRINT_VERSION,
|
||||||
|
"fingerprints": self._fingerprints,
|
||||||
|
"model_changes": list(self._model_changes),
|
||||||
|
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||||
|
"heater_entity": self.config[CONF_HEATER],
|
||||||
|
"outside_source": outside_source,
|
||||||
|
}
|
||||||
|
|
||||||
def _update_counts(self) -> None:
|
def _update_counts(self) -> None:
|
||||||
# Without zones (all deleted in the UI) there is nothing to count.
|
# Without zones (all deleted in the UI) there is nothing to count.
|
||||||
@@ -208,6 +324,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
tracker = self._lag
|
tracker = self._lag
|
||||||
steady = False
|
steady = False
|
||||||
lag = uncertainty = None
|
lag = uncertainty = None
|
||||||
|
self._relay_interval = (None, None) # unknown unless the relay history is known below
|
||||||
if tracker is not None and now > tracker["time"]:
|
if tracker is not None and now > tracker["time"]:
|
||||||
start = tracker["time"]
|
start = tracker["time"]
|
||||||
changes = [(when, state) for when, state in self._relay_events if start < when <= now]
|
changes = [(when, state) for when, state in self._relay_events if start < when <= now]
|
||||||
@@ -218,10 +335,13 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
segments = None if self._relay_events_dropped else relay_segments(
|
segments = None if self._relay_events_dropped else relay_segments(
|
||||||
tracker["heater_on"], start.timestamp(),
|
tracker["heater_on"], start.timestamp(),
|
||||||
[(when.timestamp(), state) for when, state in changes], now.timestamp())
|
[(when.timestamp(), state) for when, state in changes], now.timestamp())
|
||||||
|
self._relay_interval = (None, (now - start).total_seconds())
|
||||||
if segments is not None and segments[-1][0] == heater_on:
|
if segments is not None and segments[-1][0] == heater_on:
|
||||||
lag = tracker["lag"]
|
lag = tracker["lag"]
|
||||||
for command, seconds in segments:
|
for command, seconds in segments:
|
||||||
lag = advance_lag(lag, command, seconds)
|
lag = advance_lag(lag, command, seconds)
|
||||||
|
self._relay_interval = (sum(sec for command, sec in segments if command),
|
||||||
|
(now - start).total_seconds())
|
||||||
# The lag update is a contraction, so an initial error decays the same way.
|
# The lag update is a contraction, so an initial error decays the same way.
|
||||||
uncertainty = tracker["uncertainty"] * exp(
|
uncertainty = tracker["uncertainty"] * exp(
|
||||||
-(now - start).total_seconds() / LAG_TAU_SECONDS)
|
-(now - start).total_seconds() / LAG_TAU_SECONDS)
|
||||||
@@ -235,6 +355,15 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._relay_events_dropped = False
|
self._relay_events_dropped = False
|
||||||
return steady
|
return steady
|
||||||
|
|
||||||
|
def _revision(self) -> str:
|
||||||
|
"""Short identity of the model configuration a forecast was made with."""
|
||||||
|
return hashlib.sha1(json.dumps(self._fingerprints, sort_keys=True).encode()).hexdigest()[:10]
|
||||||
|
|
||||||
|
def _superseded(self, status: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Result of an update that a newer one (or unloading) overtook: keep what is shown."""
|
||||||
|
current = getattr(self, "data", None)
|
||||||
|
return current if current is not None else {**status, "status": "inputs_changed", "proposal": None}
|
||||||
|
|
||||||
def _count_result(self, status: dict[str, Any], result: str) -> None:
|
def _count_result(self, status: dict[str, Any], result: str) -> None:
|
||||||
"""Record how this poll was handled for learning, and the learning progress."""
|
"""Record how this poll was handled for learning, and the learning progress."""
|
||||||
status["sample_result"] = result
|
status["sample_result"] = result
|
||||||
@@ -302,8 +431,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
model = ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
|
model = ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
|
||||||
inputs = {"outside": outside, "lag": lag, "internal": internal[key], "temps": temps,
|
inputs = {"outside": outside, "lag": lag, "internal": internal[key], "temps": temps,
|
||||||
"ventilation": vent_inputs.get(key) or {}}
|
"ventilation": vent_inputs.get(key) or {}}
|
||||||
a, b, c = self.learners[key].theta
|
base = self.learners[key]
|
||||||
coefficients = {"base": {"loss": round(a, 6), "heat": round(b, 6), "bias": round(c, 6)}}
|
confidence = base.confidence()
|
||||||
|
coefficients = {"base": {name: {"value": round(value, 6), "confidence": confidence[name]}
|
||||||
|
for name, value in zip(("loss", "heat", "bias"), base.theta)}}
|
||||||
for layer, learner in (("extended", model.extended), ("ventilation", model.ventilation)):
|
for layer, learner in (("extended", model.extended), ("ventilation", model.ventilation)):
|
||||||
if learner is not None:
|
if learner is not None:
|
||||||
coefficients[layer] = {name: {k: info[k] for k in ("value", "std", "confidence", "identified")}
|
coefficients[layer] = {name: {k: info[k] for k in ("value", "std", "confidence", "identified")}
|
||||||
@@ -321,9 +452,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
info = self._ventilation.window_state.get(window.key, {})
|
info = self._ventilation.window_state.get(window.key, {})
|
||||||
is_open, since = info.get("open"), info.get("since")
|
is_open, since = info.get("open"), info.get("since")
|
||||||
direction = weather.get("wind_direction")
|
direction = weather.get("wind_direction")
|
||||||
|
last_known, unknown_since = info.get("last_known_open"), info.get("unknown_since")
|
||||||
windows[window.name] = {
|
windows[window.name] = {
|
||||||
"zone": self.zones[window.zone].name, "sensor": window.sensor, "enabled": window.enabled,
|
"zone": self.zones[window.zone].name, "sensor": window.sensor, "enabled": window.enabled,
|
||||||
"state": "unknown" if is_open is None else "open" if is_open else "closed",
|
"state": "unknown" if is_open is None else "open" if is_open else "closed",
|
||||||
|
"last_known_state": None if last_known is None else "open" if last_known else "closed",
|
||||||
|
"unknown_minutes": round((now - unknown_since) / 60, 1) if unknown_since else None,
|
||||||
"open_minutes": round((now - since) / 60, 1) if is_open and since else None,
|
"open_minutes": round((now - since) / 60, 1) if is_open and since else None,
|
||||||
"azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted,
|
"azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted,
|
||||||
"relative_wind_direction": info.get("relative_wind_direction"),
|
"relative_wind_direction": info.get("relative_wind_direction"),
|
||||||
@@ -349,22 +483,36 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
return {"windows": windows, "ventilation": zones,
|
return {"windows": windows, "ventilation": zones,
|
||||||
"ventilation_events": list(self._ventilation.events)[-5:]}
|
"ventilation_events": list(self._ventilation.events)[-5:]}
|
||||||
|
|
||||||
def _prediction_inputs(self) -> tuple:
|
def _input_snapshot(self) -> dict[tuple[str, str], Any]:
|
||||||
"""Values the prediction depends on, used to detect changes during calculation."""
|
"""Normalized value of every input a prediction uses, keyed by (kind, entity).
|
||||||
heater = self.config[CONF_HEATER]
|
|
||||||
entities = sorted(set(self.sensors) | set(sources_for(self.config, "temperature")) |
|
Compared (with tolerances, see data_quality.inputs_differ) before publishing,
|
||||||
{self.config[CONF_CLIMATE], heater})
|
so a result computed from inputs that have since changed is not shown.
|
||||||
values = []
|
"""
|
||||||
for entity_id in entities:
|
def value(entity_id: str, metric: str):
|
||||||
state = self.hass.states.get(entity_id)
|
state = self.hass.states.get(entity_id)
|
||||||
if state is None:
|
return normalize(metric, state.state, state.attributes.get("unit_of_measurement")) if state else None
|
||||||
values.append((entity_id, None))
|
|
||||||
continue
|
snapshot: dict[tuple[str, str], Any] = {}
|
||||||
attrs = state.attributes
|
for entity_id in set(self.sensors) | set(sources_for(self.config, "temperature")):
|
||||||
values.append((entity_id, state.state, attrs.get("unit_of_measurement"),
|
snapshot[("temperature", entity_id)] = value(entity_id, "temperature")
|
||||||
attrs.get("temperature"), attrs.get("temperature_unit"),
|
for metric in ("wind", "wind_direction", "wind_gust"):
|
||||||
state.last_changed if entity_id == heater else None))
|
for entity_id in sources_for(self.config, metric):
|
||||||
return tuple(values)
|
snapshot[(metric, entity_id)] = value(entity_id, metric)
|
||||||
|
for source in self.sources:
|
||||||
|
state = self.hass.states.get(source.sensor)
|
||||||
|
snapshot[("power", source.sensor)] = normalize_power(
|
||||||
|
state.state, state.attributes.get("unit_of_measurement")) if state else None
|
||||||
|
for window in self.windows:
|
||||||
|
state = self.hass.states.get(window.sensor)
|
||||||
|
snapshot[("window", window.sensor)] = contact_open(state.state if state else None, window.inverted)
|
||||||
|
heater = self.hass.states.get(self.config[CONF_HEATER])
|
||||||
|
snapshot[("relay", self.config[CONF_HEATER])] = (heater.state, heater.last_changed) if heater else None
|
||||||
|
climate = self.hass.states.get(self.config[CONF_CLIMATE])
|
||||||
|
snapshot[("thermostat", self.config[CONF_CLIMATE])] = (
|
||||||
|
climate.state, climate.attributes.get("temperature"), climate.attributes.get("temperature_unit")
|
||||||
|
) if climate else None
|
||||||
|
return snapshot
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, Any]:
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -378,7 +526,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
}
|
}
|
||||||
snapshot = {entity: self.hass.states.get(entity)
|
snapshot = {entity: self.hass.states.get(entity)
|
||||||
for entity in set(self.sensors) | weather_entity_ids}
|
for entity in set(self.sensors) | weather_entity_ids}
|
||||||
inputs_before = self._prediction_inputs()
|
self._run += 1
|
||||||
|
run = self._run
|
||||||
|
inputs_before = self._input_snapshot()
|
||||||
sensor_quality = {}
|
sensor_quality = {}
|
||||||
readings: dict[str, float | None] = {}
|
readings: dict[str, float | None] = {}
|
||||||
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
|
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
|
||||||
@@ -440,6 +590,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
"weather_comparison": {k: v for k, v in weather_comparisons.items() if v},
|
"weather_comparison": {k: v for k, v in weather_comparisons.items() if v},
|
||||||
"weather_model_inputs": ["temperature"],
|
"weather_model_inputs": ["temperature"],
|
||||||
"retired_zone_models": sorted(self._retired),
|
"retired_zone_models": sorted(self._retired),
|
||||||
|
"model_changes": list(self._model_changes)[-5:],
|
||||||
"excluded_zones": [room for room in self.rooms if temps[room] is None],
|
"excluded_zones": [room for room in self.rooms if temps[room] is None],
|
||||||
}
|
}
|
||||||
# Electrical heat sources: processed every poll so their responses stay continuous.
|
# Electrical heat sources: processed every poll so their responses stay continuous.
|
||||||
@@ -479,6 +630,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._lag = None
|
self._lag = None
|
||||||
self._relay_events.clear()
|
self._relay_events.clear()
|
||||||
self._relay_events_dropped = False
|
self._relay_events_dropped = False
|
||||||
|
self._relay_interval = (None, None)
|
||||||
|
# Published forecasts that are due are scored against these readings; nothing
|
||||||
|
# here is fed back into what those forecasts assumed.
|
||||||
|
self._forecasts.observe(now.timestamp(), {k: v for k, v in temps.items() if v is not None},
|
||||||
|
*self._relay_interval)
|
||||||
|
status["operational_forecast"] = self._forecasts.summary(self.rooms)
|
||||||
# Rooms without a usable reading are left out and the others carry on;
|
# Rooms without a usable reading are left out and the others carry on;
|
||||||
# only the shared inputs, or losing every room, pause the integration.
|
# only the shared inputs, or losing every room, pause the integration.
|
||||||
vals: dict[str, float] = {room: float(t) for room, t in temps.items() if t is not None}
|
vals: dict[str, float] = {room: float(t) for room, t in temps.items() if t is not None}
|
||||||
@@ -562,22 +719,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self.ventilation.get(room))) for room in vals},
|
self.ventilation.get(room))) for room in vals},
|
||||||
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals})
|
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals})
|
||||||
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
|
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
|
||||||
if learned:
|
if learned and not self._stale(run):
|
||||||
# Persist at each sample so restart does not erase hours of training.
|
# Persist at each sample so restart does not erase hours of training.
|
||||||
await self._store.async_save({
|
await self._async_save(active_sources["temperature"])
|
||||||
"schema_version": 2,
|
if self._stale(run):
|
||||||
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
|
return self._superseded(status)
|
||||||
# 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"],
|
|
||||||
})
|
|
||||||
# Comfort weights are validated when zones are parsed (invalid -> 1.0).
|
# Comfort weights are validated when zones are parsed (invalid -> 1.0).
|
||||||
weights = {r: self.zones[r].comfort_weight for r in self.rooms}
|
weights = {r: self.zones[r].comfort_weight for r in self.rooms}
|
||||||
# Left-out zones take their weight with them; the rest are re-normalized.
|
# Left-out zones take their weight with them; the rest are re-normalized.
|
||||||
@@ -596,6 +742,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
"loss": round(model.theta[0], 6),
|
"loss": round(model.theta[0], 6),
|
||||||
"heat": round(model.theta[1], 6),
|
"heat": round(model.theta[1], 6),
|
||||||
"bias": round(model.theta[2], 6),
|
"bias": round(model.theta[2], 6),
|
||||||
|
# How well the data pins each coefficient down, not how many samples.
|
||||||
|
"confidence": model.confidence(),
|
||||||
"samples": model.count,
|
"samples": model.count,
|
||||||
"on_samples": model.on_count, "off_samples": model.off_count,
|
"on_samples": model.on_count, "off_samples": model.off_count,
|
||||||
"rejected_samples": model.rejected_count,
|
"rejected_samples": model.rejected_count,
|
||||||
@@ -644,23 +792,37 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON]),
|
step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON]),
|
||||||
weather, now.timestamp()),
|
weather, now.timestamp()),
|
||||||
)
|
)
|
||||||
# Discard a result if a value used by the prediction changed while
|
|
||||||
# saving or while the worker calculated the hypothetical plan. Optional
|
|
||||||
# diagnostic weather sensors are not prediction inputs.
|
|
||||||
output = await self.hass.async_add_executor_job(job)
|
output = await self.hass.async_add_executor_job(job)
|
||||||
if self._prediction_inputs() != inputs_before:
|
if self._stale(run):
|
||||||
|
return self._superseded(status)
|
||||||
|
# Discard a result if an input it used changed meaningfully while saving
|
||||||
|
# or while the worker calculated the hypothetical plan. Not recomputed at
|
||||||
|
# once: an off-schedule poll would break the 5-minute learning interval;
|
||||||
|
# the next regular poll (at most 5 minutes) computes a fresh plan.
|
||||||
|
changed = inputs_differ(inputs_before, self._input_snapshot())
|
||||||
|
if changed:
|
||||||
status["status"] = "inputs_changed"
|
status["status"] = "inputs_changed"
|
||||||
|
status["changed_inputs"] = [f"{kind}: {entity}" for kind, entity in changed[:10]]
|
||||||
return status
|
return status
|
||||||
except (ValueError, RuntimeError, OverflowError) as err:
|
except (ValueError, RuntimeError, OverflowError) as err:
|
||||||
_LOGGER.warning("MPC prediction failed: %s", err)
|
_LOGGER.warning("MPC prediction failed: %s", err)
|
||||||
status["status"] = "prediction_error"
|
status["status"] = "prediction_error"
|
||||||
return status
|
return status
|
||||||
|
self._forecasts.record(now.timestamp(), vals, output["trajectory_15min"], output["plan_15min"],
|
||||||
|
self._revision(), {
|
||||||
|
"strategy": "mpc_plan", "outside": outside, "wind": weather.get("wind"),
|
||||||
|
"internal_half_life_minutes": float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30)),
|
||||||
|
"window_open_minutes": float(self.config[CONF_WINDOW_OPEN_MINUTES]),
|
||||||
|
"internal_kw": {room: internal[room] for room in vals},
|
||||||
|
"windows_open": [w.name for w in self.windows
|
||||||
|
if self._ventilation.window_state.get(w.key, {}).get("open")]})
|
||||||
status.update({
|
status.update({
|
||||||
"status": "ready", "proposal": output["proposal"],
|
"status": "ready", "proposal": output["proposal"],
|
||||||
"prediction_60m": output["predicted_60m"],
|
"prediction_60m": output["predicted_60m"],
|
||||||
"prediction_end": output["predicted_end"],
|
"prediction_end": output["predicted_end"],
|
||||||
"plan_15min": output["plan_15min"], "objective": output["cost"],
|
"plan_15min": output["plan_15min"], "objective": output["cost"],
|
||||||
"prediction_trajectory": output["trajectory_15min"],
|
"prediction_trajectory": output["trajectory_15min"],
|
||||||
"model_quality_note": "5-minute in-sample MAE; not a validated 60-minute forecast error",
|
"model_quality_note": ("mae_5m is the in-sample 5-minute error; Model Error sensors replay the "
|
||||||
|
"model with actual inputs; Forecast Error scores published forecasts"),
|
||||||
})
|
})
|
||||||
return status
|
return status
|
||||||
@@ -37,3 +37,37 @@ class ReportAgeHistory:
|
|||||||
else:
|
else:
|
||||||
buckets.append([hour, age_seconds])
|
buckets.append([hour, age_seconds])
|
||||||
return max(age for start, age in buckets if hour - 24 < start <= hour)
|
return max(age for start, age in buckets if hour - 24 < start <= hour)
|
||||||
|
|
||||||
|
|
||||||
|
# How much an input may move while a prediction is computed before the result no
|
||||||
|
# longer describes the current situation. Below these, changes are sensor noise.
|
||||||
|
INPUT_TOLERANCES = {
|
||||||
|
"temperature": 0.25, # °C, zone sensors and outdoor sources
|
||||||
|
"power": 50.0, # W, heat-source sensors
|
||||||
|
"wind": 1.0, # m/s
|
||||||
|
"wind_gust": 2.0, # m/s
|
||||||
|
"wind_direction": 20.0, # degrees, compared around the circle
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def inputs_differ(before: dict, after: dict) -> list:
|
||||||
|
"""Inputs whose change is meaningful: keys are (kind, entity), values normalized.
|
||||||
|
|
||||||
|
Kinds without a tolerance (relay, thermostat mode and target, window states,
|
||||||
|
configuration revision) must match exactly; a value appearing or disappearing
|
||||||
|
(for example a sensor becoming unavailable) always counts.
|
||||||
|
"""
|
||||||
|
changed = []
|
||||||
|
for key in sorted(set(before) | set(after), key=str):
|
||||||
|
old, new = before.get(key), after.get(key)
|
||||||
|
tolerance = INPUT_TOLERANCES.get(key[0])
|
||||||
|
if old is None or new is None or tolerance is None:
|
||||||
|
if old != new:
|
||||||
|
changed.append(key)
|
||||||
|
continue
|
||||||
|
difference = abs(new - old)
|
||||||
|
if key[0] == "wind_direction":
|
||||||
|
difference = min(difference, 360.0 - difference)
|
||||||
|
if difference > tolerance:
|
||||||
|
changed.append(key)
|
||||||
|
return changed
|
||||||
@@ -9,27 +9,51 @@ then (so later data is never used), the outdoor temperature held at its starting
|
|||||||
value, and the radiator lag that actually followed from the relay's real ON/OFF
|
value, and the radiator lag that actually followed from the relay's real ON/OFF
|
||||||
history. The replayed temperature is compared with the reading now.
|
history. The replayed temperature is compared with the reading now.
|
||||||
|
|
||||||
This scores the thermal model, not the MPC's proposed plan: Generic Thermostat
|
Two kinds of accuracy are kept apart:
|
||||||
controls the relay, so the plan is never executed. Two baselines are scored on
|
|
||||||
the same spans: persistence (no change) and the base 3-parameter model. The full
|
* ForecastEvaluator, **model accuracy** (retrospective): the model is replayed
|
||||||
model replays all zones together with the electrical heat, neighbour exchange and
|
with the relay, electrical heat and window history that actually happened. It
|
||||||
window openings that actually happened, so its improvement over the base model
|
answers "how well does the model reproduce the rooms under known inputs?" and
|
||||||
shows whether those features earn their keep.
|
compares the full model with the base model and with persistence. It is not a
|
||||||
|
forecast accuracy: a real forecast does not know the future inputs.
|
||||||
|
* OperationalForecastLedger, **forecast accuracy**: forecasts as published, with
|
||||||
|
the boiler plan, electrical heat and windows they assumed, compared with what
|
||||||
|
happened once their horizon has passed. A forecast assuming the MPC's plan can
|
||||||
|
only be checked when the relay actually followed that plan; otherwise it is
|
||||||
|
counted as counterfactual and not scored.
|
||||||
|
|
||||||
|
Forecasts start every 5 minutes, so consecutive ones overlap: n forecasts at a
|
||||||
|
horizon of h minutes carry roughly n x 5 / h independent samples. Metrics report
|
||||||
|
that number and a quality label, and sensors show no value while it is too small.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from math import isfinite
|
from math import isfinite, sqrt
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .physics import Learner
|
from .physics import Learner
|
||||||
from .windows import ventilation_vector
|
from .windows import ventilation_vector_for_prediction
|
||||||
from .zone_model import feature_vector
|
from .zone_model import feature_vector
|
||||||
|
|
||||||
HORIZONS = (15, 30, 60, 120) # minutes
|
HORIZONS = (15, 30, 60, 120) # minutes
|
||||||
WINDOW_DAYS = 7
|
WINDOW_DAYS = 7
|
||||||
STEP_SECONDS = 300
|
STEP_SECONDS = 300
|
||||||
|
# Independent samples (overlap-adjusted) needed before a metric is shown / trusted.
|
||||||
|
MIN_EFFECTIVE_SAMPLES = 5
|
||||||
|
GOOD_EFFECTIVE_SAMPLES = 30
|
||||||
|
|
||||||
|
|
||||||
|
def effective_samples(count: int, horizon: int, zones: int = 1) -> float:
|
||||||
|
"""Roughly independent samples among `count` forecasts started 5 minutes apart."""
|
||||||
|
return round(count / max(1, zones) * min(1.0, STEP_SECONDS / 60 / horizon), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def quality(effective: float) -> str:
|
||||||
|
if effective < MIN_EFFECTIVE_SAMPLES:
|
||||||
|
return "insufficient"
|
||||||
|
return "indicative" if effective < GOOD_EFFECTIVE_SAMPLES else "ok"
|
||||||
|
|
||||||
|
|
||||||
def replay(temp: float, outside: float, theta: tuple[float, float, float],
|
def replay(temp: float, outside: float, theta: tuple[float, float, float],
|
||||||
@@ -67,7 +91,7 @@ def snapshot_delta(snapshot: dict[str, Any], temp: float, inputs: dict[str, Any]
|
|||||||
ventilation = inputs.get("ventilation")
|
ventilation = inputs.get("ventilation")
|
||||||
if snapshot["ventilation"] and ventilation:
|
if snapshot["ventilation"] and ventilation:
|
||||||
features, theta = snapshot["ventilation"]
|
features, theta = snapshot["ventilation"]
|
||||||
x = ventilation_vector(features, temp, inputs["outside"], ventilation)
|
x = ventilation_vector_for_prediction(features, temp, inputs["outside"], ventilation)
|
||||||
if x is not None:
|
if x is not None:
|
||||||
main += _dot(theta, x)
|
main += _dot(theta, x)
|
||||||
return main
|
return main
|
||||||
@@ -94,8 +118,8 @@ class ForecastEvaluator:
|
|||||||
def __init__(self, max_lag_uncertainty: float) -> None:
|
def __init__(self, max_lag_uncertainty: float) -> None:
|
||||||
self._max_lag_uncertainty = max_lag_uncertainty
|
self._max_lag_uncertainty = max_lag_uncertainty
|
||||||
self._history: deque[dict[str, Any]] = deque(maxlen=HORIZONS[-1] * 60 // STEP_SECONDS + 1)
|
self._history: deque[dict[str, Any]] = deque(maxlen=HORIZONS[-1] * 60 // STEP_SECONDS + 1)
|
||||||
# UTC day -> horizon -> room ->
|
# UTC day -> horizon -> room -> [count, sum error, sum |error|, sum |persistence error|,
|
||||||
# [count, sum error, sum |error|, sum |persistence error|, sum |base model error|]
|
# sum |base model error|, sum error^2, count with squares (0 for 0.3/0.4 data)]
|
||||||
self._days: dict[str, dict[str, dict[str, list[float]]]] = {}
|
self._days: dict[str, dict[str, dict[str, list[float]]]] = {}
|
||||||
|
|
||||||
def reset_window(self) -> None:
|
def reset_window(self) -> None:
|
||||||
@@ -146,12 +170,14 @@ class ForecastEvaluator:
|
|||||||
error = full[room] - actual
|
error = full[room] - actual
|
||||||
base_error = replay(start, origin["outside"], theta, inputs) - actual
|
base_error = replay(start, origin["outside"], theta, inputs) - actual
|
||||||
bucket = self._days.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(
|
bucket = self._days.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(
|
||||||
room, [0, 0.0, 0.0, 0.0, 0.0])
|
room, [0, 0.0, 0.0, 0.0, 0.0, 0.0, 0])
|
||||||
bucket[0] += 1
|
bucket[0] += 1
|
||||||
bucket[1] += error
|
bucket[1] += error
|
||||||
bucket[2] += abs(error)
|
bucket[2] += abs(error)
|
||||||
bucket[3] += abs(start - actual)
|
bucket[3] += abs(start - actual)
|
||||||
bucket[4] += abs(base_error)
|
bucket[4] += abs(base_error)
|
||||||
|
bucket[5] += error * error
|
||||||
|
bucket[6] += 1
|
||||||
|
|
||||||
def _prune(self, time: datetime) -> None:
|
def _prune(self, time: datetime) -> None:
|
||||||
oldest = (time.date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
|
oldest = (time.date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
|
||||||
@@ -166,17 +192,25 @@ class ForecastEvaluator:
|
|||||||
for day in self._days.values():
|
for day in self._days.values():
|
||||||
for room, values in day.get(str(horizon), {}).items():
|
for room, values in day.get(str(horizon), {}).items():
|
||||||
if room in rooms:
|
if room in rooms:
|
||||||
totals = per_room.setdefault(room, [0, 0.0, 0.0, 0.0, 0.0])
|
totals = per_room.setdefault(room, [0, 0.0, 0.0, 0.0, 0.0, 0.0, 0])
|
||||||
for i, value in enumerate(values):
|
for i, value in enumerate(values):
|
||||||
totals[i] += value
|
totals[i] += value
|
||||||
overall = [sum(values[i] for values in per_room.values()) for i in range(5)]
|
overall = [sum(values[i] for values in per_room.values()) for i in range(7)]
|
||||||
result[horizon] = {**_stats(overall),
|
result[horizon] = {**_stats(overall, horizon, len(per_room)),
|
||||||
"rooms": {room: _stats(values) for room, values in per_room.items()}}
|
"rooms": {room: _stats(values, horizon) for room, values in per_room.items()}}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def forget(self, room: str) -> None:
|
||||||
|
"""Drop a zone's statistics, for example when its temperature is measured differently."""
|
||||||
|
for by_horizon in self._days.values():
|
||||||
|
for by_room in by_horizon.values():
|
||||||
|
by_room.pop(room, None)
|
||||||
|
for poll in self._history:
|
||||||
|
poll["temps"].pop(room, None)
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
# Rounded copy: shorter JSON on every save; in-memory sums stay exact.
|
# Rounded copy: shorter JSON on every save; in-memory sums stay exact.
|
||||||
return {"days": {day: {horizon: {room: [v[0], *(round(x, 6) for x in v[1:])]
|
return {"days": {day: {horizon: {room: [v[0], *(round(x, 6) for x in v[1:6]), v[6]]
|
||||||
for room, v in by_room.items()}
|
for room, v in by_room.items()}
|
||||||
for horizon, by_room in by_horizon.items()}
|
for horizon, by_room in by_horizon.items()}
|
||||||
for day, by_horizon in self._days.items()}}
|
for day, by_horizon in self._days.items()}}
|
||||||
@@ -201,28 +235,38 @@ class ForecastEvaluator:
|
|||||||
for room, values in by_room.items():
|
for room, values in by_room.items():
|
||||||
if _valid_bucket(room, values):
|
if _valid_bucket(room, values):
|
||||||
# 0.3 buckets had no base column: the model then was the base model.
|
# 0.3 buckets had no base column: the model then was the base model.
|
||||||
base = values[4] if len(values) == 5 else values[2]
|
# Older buckets have no squared errors: they do not count for RMSE.
|
||||||
|
base = values[4] if len(values) >= 5 else values[2]
|
||||||
|
squares = (float(values[5]), values[6]) if len(values) == 7 else (0.0, 0)
|
||||||
self._days.setdefault(day, {}).setdefault(horizon, {})[room] = [
|
self._days.setdefault(day, {}).setdefault(horizon, {})[room] = [
|
||||||
values[0], float(values[1]), float(values[2]), float(values[3]), float(base)]
|
values[0], float(values[1]), float(values[2]), float(values[3]), float(base),
|
||||||
|
*squares]
|
||||||
|
|
||||||
|
|
||||||
def _valid_bucket(room: Any, values: Any) -> bool:
|
def _valid_bucket(room: Any, values: Any) -> bool:
|
||||||
if not isinstance(room, str) or not isinstance(values, list) or len(values) not in (4, 5):
|
if not isinstance(room, str) or not isinstance(values, list) or len(values) not in (4, 5, 7):
|
||||||
|
return False
|
||||||
|
count, *sums = values[:6]
|
||||||
|
if len(values) == 7 and (type(values[6]) is not int or not 0 <= values[6] <= count):
|
||||||
return False
|
return False
|
||||||
count, *sums = values
|
|
||||||
return (type(count) is int and count >= 0
|
return (type(count) is int and count >= 0
|
||||||
and all(type(v) in (int, float) and isfinite(v) for v in sums)
|
and all(type(v) in (int, float) and isfinite(v) for v in sums)
|
||||||
and all(v >= 0 for v in sums[1:]))
|
and all(v >= 0 for v in sums[1:]))
|
||||||
|
|
||||||
|
|
||||||
def _stats(values: list[float]) -> dict[str, Any]:
|
def _stats(values: list[float], horizon: int, zones: int = 1) -> dict[str, Any]:
|
||||||
count, error, absolute, persistence, base = values
|
count, error, absolute, persistence, base, squares, square_count = values
|
||||||
|
effective = effective_samples(int(count), horizon, zones)
|
||||||
if not count:
|
if not count:
|
||||||
return {"samples": 0, "mae": None, "bias": None, "persistence_mae": None, "skill": None,
|
return {"samples": 0, "effective_samples": 0.0, "quality": "insufficient", "mae": None,
|
||||||
|
"rmse": None, "bias": None, "persistence_mae": None, "skill": None,
|
||||||
"base_model_mae": None, "improvement_vs_base": None}
|
"base_model_mae": None, "improvement_vs_base": None}
|
||||||
return {
|
return {
|
||||||
"samples": int(count),
|
"samples": int(count),
|
||||||
|
"effective_samples": effective,
|
||||||
|
"quality": quality(effective),
|
||||||
"mae": round(absolute / count, 3),
|
"mae": round(absolute / count, 3),
|
||||||
|
"rmse": round(sqrt(squares / square_count), 3) if square_count else None,
|
||||||
"bias": round(error / count, 3),
|
"bias": round(error / count, 3),
|
||||||
"persistence_mae": round(persistence / count, 3),
|
"persistence_mae": round(persistence / count, 3),
|
||||||
# Share of the persistence error removed by the model (1 = perfect, < 0 = worse).
|
# Share of the persistence error removed by the model (1 = perfect, < 0 = worse).
|
||||||
@@ -232,3 +276,191 @@ def _stats(values: list[float]) -> dict[str, Any]:
|
|||||||
# ventilation (0 = no gain, < 0 = the extra features make it worse).
|
# ventilation (0 = no gain, < 0 = the extra features make it worse).
|
||||||
"improvement_vs_base": round(1 - absolute / base, 3) if base > 0 else None,
|
"improvement_vs_base": round(1 - absolute / base, 3) if base > 0 else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# A forecast's plan counts as executed when, in every 15-minute block, the share
|
||||||
|
# of time the relay was on is within this of what the plan assumed (0 or 1).
|
||||||
|
PLAN_TOLERANCE = 0.2
|
||||||
|
DUE_TOLERANCE_SECONDS = 150 # half a poll: the poll closest to the forecast's due time
|
||||||
|
VERDICTS = ("counterfactual", "unverifiable", "missing")
|
||||||
|
|
||||||
|
|
||||||
|
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 only scored when the relay actually followed the plan up to that horizon;
|
||||||
|
otherwise it is counted as counterfactual. 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]]]] = {}
|
||||||
|
# UTC day -> horizon -> verdict -> count of forecasts not scored, and why
|
||||||
|
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
|
||||||
|
if now > due + DUE_TOLERANCE_SECONDS:
|
||||||
|
self._count(day, horizon, "missing") # no poll near the due time
|
||||||
|
continue
|
||||||
|
verdict = self._verify(forecast, horizon)
|
||||||
|
if verdict:
|
||||||
|
self._count(day, horizon, verdict)
|
||||||
|
continue
|
||||||
|
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, "missing")
|
||||||
|
continue
|
||||||
|
error = path[index] - measured
|
||||||
|
bucket = self._days.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._verdicts):
|
||||||
|
for old in [d for d in store if d < oldest]:
|
||||||
|
del store[old]
|
||||||
|
|
||||||
|
def _verify(self, forecast: dict[str, Any], horizon: int) -> str | None:
|
||||||
|
"""None when the relay followed the plan up to `horizon`, else the reason."""
|
||||||
|
for block in range(horizon // 15):
|
||||||
|
start = forecast["time"] + block * 900
|
||||||
|
end = start + 900
|
||||||
|
on = covered = 0.0
|
||||||
|
for interval_end, on_seconds, seconds in self._relay:
|
||||||
|
overlap = min(end, interval_end) - max(start, interval_end - seconds)
|
||||||
|
if overlap <= 0:
|
||||||
|
continue
|
||||||
|
if on_seconds is None:
|
||||||
|
return "unverifiable"
|
||||||
|
on += on_seconds * overlap / seconds
|
||||||
|
covered += overlap
|
||||||
|
if covered < 0.8 * 900:
|
||||||
|
return "unverifiable"
|
||||||
|
if abs(on / covered - forecast["plan"][block]) > PLAN_TOLERANCE:
|
||||||
|
return "counterfactual"
|
||||||
|
return None
|
||||||
|
|
||||||
|
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 by_horizon in self._days.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: dict[str, list[float]] = {}
|
||||||
|
verdicts = {verdict: 0 for verdict in VERDICTS}
|
||||||
|
for by_horizon in self._days.values():
|
||||||
|
for zone, values in by_horizon.get(str(horizon), {}).items():
|
||||||
|
if zone in zones:
|
||||||
|
totals = per_zone.setdefault(zone, [0, 0.0, 0.0, 0.0, 0.0])
|
||||||
|
for i, value in enumerate(values):
|
||||||
|
totals[i] += value
|
||||||
|
for by_horizon in self._verdicts.values():
|
||||||
|
for verdict, count in by_horizon.get(str(horizon), {}).items():
|
||||||
|
verdicts[verdict] = verdicts.get(verdict, 0) + count
|
||||||
|
overall = [sum(values[i] for values in per_zone.values()) for i in range(5)]
|
||||||
|
result[horizon] = {**_operational_stats(overall, horizon, len(per_zone)), **verdicts,
|
||||||
|
"zones": {zone: _operational_stats(values, horizon)
|
||||||
|
for zone, values in per_zone.items()}}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {"days": {day: {h: {zone: [v[0], *(round(x, 6) for x in v[1:])] for zone, v in by_zone.items()}
|
||||||
|
for h, by_zone in by_horizon.items()} for day, by_horizon in self._days.items()},
|
||||||
|
"verdicts": self._verdicts}
|
||||||
|
|
||||||
|
def load(self, data: Any) -> None:
|
||||||
|
self._days, self._verdicts = {}, {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
horizons = {str(h) for h in HORIZONS}
|
||||||
|
days = data.get("days") if isinstance(data.get("days"), dict) else {}
|
||||||
|
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 not in horizons or not isinstance(by_zone, dict):
|
||||||
|
continue
|
||||||
|
for zone, values in by_zone.items():
|
||||||
|
if (isinstance(zone, str) and isinstance(values, list) and len(values) == 5
|
||||||
|
and type(values[0]) is int and values[0] >= 0
|
||||||
|
and all(type(v) in (int, float) and isfinite(v) for v in values[1:])
|
||||||
|
and min(values[2:]) >= 0):
|
||||||
|
self._days.setdefault(day, {}).setdefault(horizon, {})[zone] = [
|
||||||
|
values[0], *(float(v) for v in values[1:])]
|
||||||
|
verdicts = data.get("verdicts") if isinstance(data.get("verdicts"), dict) else {}
|
||||||
|
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 VERDICTS and type(v) is int and v >= 0}
|
||||||
|
if clean:
|
||||||
|
self._verdicts.setdefault(day, {})[horizon] = clean
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
class HeatSourceTracker:
|
||||||
"""Per-poll processing of all heat sources: baselines, responses, zone inputs."""
|
"""Per-poll processing of all heat sources: baselines, responses, zone inputs."""
|
||||||
|
|
||||||
def __init__(self, sources: list[HeatSource], saved: Any = None) -> None:
|
def __init__(self, sources: list[HeatSource], saved: Any = None,
|
||||||
|
fingerprints: Mapping[str, str] | None = None) -> None:
|
||||||
self.sources = sources
|
self.sources = sources
|
||||||
|
self.fingerprints = dict(fingerprints or {})
|
||||||
saved = saved if isinstance(saved, Mapping) else {}
|
saved = saved if isinstance(saved, Mapping) else {}
|
||||||
stored = saved.get("baselines") if isinstance(saved.get("baselines"), Mapping) else {}
|
stored = saved.get("baselines") if isinstance(saved.get("baselines"), Mapping) else {}
|
||||||
self._baselines = {s.key: BaselineEstimator(stored.get(s.key) if isinstance(stored.get(s.key), list) else ())
|
old_prints = saved.get("fingerprints") if isinstance(saved.get("fingerprints"), Mapping) else None
|
||||||
for s in sources}
|
# 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._filters = {s.key: ResponseFilter(s.response_minutes) for s in sources}
|
||||||
self._held: dict[str, tuple[float, float | None]] = {} # key -> (timestamp, variable kW)
|
self._held: dict[str, tuple[float, float | None]] = {} # key -> (timestamp, variable kW)
|
||||||
self._settled_since: dict[str, float] = {}
|
self._settled_since: dict[str, float] = {}
|
||||||
@@ -301,4 +308,5 @@ class HeatSourceTracker:
|
|||||||
return total
|
return total
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {"baselines": {key: [round(v, 1) for v in est.samples] for key, est in self._baselines.items()}}
|
return {"baselines": {key: [round(v, 1) for v in est.samples] for key, est in self._baselines.items()},
|
||||||
|
"fingerprints": self.fingerprints}
|
||||||
@@ -15,7 +15,7 @@ displayed standard deviation uses a running estimate of the measurement noise.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from math import isfinite, sqrt
|
from math import isfinite, sqrt
|
||||||
from typing import Any, NamedTuple
|
from typing import Any, Iterable, NamedTuple
|
||||||
|
|
||||||
FORGETTING = 0.999 # about 3.5 days of 5-minute samples
|
FORGETTING = 0.999 # about 3.5 days of 5-minute samples
|
||||||
MAX_STEP_CHANGE = 0.8 # °C per 5 minutes: larger is a glitch, not physics
|
MAX_STEP_CHANGE = 0.8 # °C per 5 minutes: larger is a glitch, not physics
|
||||||
@@ -195,6 +195,32 @@ class FeatureLearner:
|
|||||||
bounded[i] = min(limit, max(-limit, bounded[i]))
|
bounded[i] = min(limit, max(-limit, bounded[i]))
|
||||||
return bounded
|
return bounded
|
||||||
|
|
||||||
|
def reset(self, features: Iterable[str], keep_values: Iterable[str] = ()) -> None:
|
||||||
|
"""Forget what was learned for `features` (after a configuration change).
|
||||||
|
|
||||||
|
Their variance returns to the prior and their correlations with the other
|
||||||
|
coefficients are cleared; values go back to the prior unless listed in
|
||||||
|
`keep_values` (used as a warm start). Other coefficients are untouched.
|
||||||
|
"""
|
||||||
|
keep = set(keep_values)
|
||||||
|
for name in features:
|
||||||
|
if name not in self.features:
|
||||||
|
continue
|
||||||
|
i = self.features.index(name)
|
||||||
|
spec = self._specs[i]
|
||||||
|
for j in range(len(self.features)):
|
||||||
|
self.p[i][j] = self.p[j][i] = 0.0
|
||||||
|
self.p[i][i] = spec.variance
|
||||||
|
if name not in keep:
|
||||||
|
self.theta[i] = spec.prior
|
||||||
|
self.active[i] = 0
|
||||||
|
self.theta = self._bounded(self.theta)
|
||||||
|
|
||||||
|
def reset_counts(self) -> None:
|
||||||
|
"""Readiness and error statistics start over (the measured quantity changed)."""
|
||||||
|
self.count = self.rejected_count = self.on_count = self.off_count = 0
|
||||||
|
self.error_sum = self._residual_sq = self._residual_weight = 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def noise_var(self) -> float:
|
def noise_var(self) -> float:
|
||||||
"""Estimated variance of the unexplained 5-minute change (°C²)."""
|
"""Estimated variance of the unexplained 5-minute change (°C²)."""
|
||||||
|
|||||||
@@ -9,5 +9,5 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "0.4.0"
|
"version": "0.4.1"
|
||||||
}
|
}
|
||||||
@@ -122,10 +122,29 @@ class Learner:
|
|||||||
self.rejected_count += 1
|
self.rejected_count += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def recalibrate(self) -> None:
|
||||||
|
"""Re-learn after the zone's measurement changed: the coefficients stay as a
|
||||||
|
starting point, but their uncertainty and every count go back to the start,
|
||||||
|
so readiness has to be earned again."""
|
||||||
|
self.p = [list(row) for row in PRIOR_P]
|
||||||
|
self.count = self.on_count = self.off_count = self.rejected_count = 0
|
||||||
|
self.error_sum = 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mae(self) -> float | None:
|
def mae(self) -> float | None:
|
||||||
return self.error_sum / self.count if self.count else None
|
return self.error_sum / self.count if self.count else None
|
||||||
|
|
||||||
|
def confidence(self) -> dict[str, float]:
|
||||||
|
"""Per coefficient, the share of its information that comes from data (0 = prior only).
|
||||||
|
|
||||||
|
Taken from the coefficient's own uncertainty, not from the sample count: it
|
||||||
|
only grows while the data varies that coefficient's input independently of
|
||||||
|
the others. Weeks with the relay always off leave `heat` at 0; a steady
|
||||||
|
indoor-outdoor difference cannot separate `loss` from `bias`.
|
||||||
|
"""
|
||||||
|
return {name: round(1 - min(prior, max(0.0, self.p[i][i])) / prior, 3)
|
||||||
|
for i, (name, prior) in enumerate(zip(("loss", "heat", "bias"), PRIOR_VARIANCE))}
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum,
|
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum,
|
||||||
"on_count": self.on_count, "off_count": self.off_count,
|
"on_count": self.on_count, "off_count": self.off_count,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
|
|||||||
entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id),
|
entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id),
|
||||||
MPCLearningProgress(coordinator, entry.entry_id), MPCLastSample(coordinator, entry.entry_id)]
|
MPCLearningProgress(coordinator, entry.entry_id), MPCLastSample(coordinator, entry.entry_id)]
|
||||||
entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS]
|
entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS]
|
||||||
|
entities.append(MPCOperationalForecastError(coordinator, entry.entry_id))
|
||||||
# Zone forecasts belong to their zone subentry, so deleting a zone removes them.
|
# Zone forecasts belong to their zone subentry, so deleting a zone removes them.
|
||||||
linked = {}
|
linked = {}
|
||||||
for zone in coordinator.zones.values():
|
for zone in coordinator.zones.values():
|
||||||
@@ -36,7 +37,7 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
|
|||||||
# Per-source report ages change every poll; recording them adds a new
|
# Per-source report ages change every poll; recording them adds a new
|
||||||
# attribute row to the database every five minutes. Current values stay visible.
|
# attribute row to the database every five minutes. Current values stay visible.
|
||||||
_unrecorded_attributes = frozenset({"input_quality", "weather_comparison", "heat_sources", "windows",
|
_unrecorded_attributes = frozenset({"input_quality", "weather_comparison", "heat_sources", "windows",
|
||||||
"ventilation", "ventilation_events"})
|
"ventilation", "ventilation_events", "model_changes"})
|
||||||
|
|
||||||
def __init__(self, coordinator, entry_id):
|
def __init__(self, coordinator, entry_id):
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
@@ -52,7 +53,7 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
|
|||||||
# Forecast details live on their own entities.
|
# Forecast details live on their own entities.
|
||||||
return {key: value for key, value in data.items() if key not in (
|
return {key: value for key, value in data.items() if key not in (
|
||||||
"status", "prediction_60m", "prediction_end", "prediction_trajectory", "forecast_accuracy",
|
"status", "prediction_60m", "prediction_end", "prediction_trajectory", "forecast_accuracy",
|
||||||
"zone_details")}
|
"zone_details", "operational_forecast")}
|
||||||
|
|
||||||
|
|
||||||
class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
|
class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
|
||||||
@@ -133,7 +134,11 @@ class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
|
|||||||
|
|
||||||
|
|
||||||
class MPCForecastError(CoordinatorEntity, SensorEntity):
|
class MPCForecastError(CoordinatorEntity, SensorEntity):
|
||||||
"""Mean absolute error of the thermal model at one horizon, replayed against reality."""
|
"""Model accuracy at one horizon: the model replayed with the inputs that actually happened.
|
||||||
|
|
||||||
|
Not a forecast accuracy (a real forecast does not know the future relay, power or
|
||||||
|
windows); see MPCOperationalForecastError. The unique ID keeps its original name.
|
||||||
|
"""
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_device_class = SensorDeviceClass.TEMPERATURE_DELTA
|
_attr_device_class = SensorDeviceClass.TEMPERATURE_DELTA
|
||||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||||
@@ -146,7 +151,7 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
|
|||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self.horizon = horizon
|
self.horizon = horizon
|
||||||
self._attr_unique_id = f"{entry_id}_forecast_error_{horizon}min"
|
self._attr_unique_id = f"{entry_id}_forecast_error_{horizon}min"
|
||||||
self._attr_name = f"Heating MPC Forecast Error {horizon} min"
|
self._attr_name = f"Heating MPC Model Error {horizon} min"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _accuracy(self):
|
def _accuracy(self):
|
||||||
@@ -154,13 +159,20 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self):
|
def native_value(self):
|
||||||
return self._accuracy.get("mae")
|
accuracy = self._accuracy
|
||||||
|
# No number from a handful of overlapping forecasts.
|
||||||
|
return accuracy.get("mae") if accuracy.get("quality", "insufficient") != "insufficient" else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self):
|
def extra_state_attributes(self):
|
||||||
accuracy = self._accuracy
|
accuracy = self._accuracy
|
||||||
return {
|
return {
|
||||||
|
"kind": "model accuracy: replayed with the actual relay, electrical heat and window history",
|
||||||
"samples": accuracy.get("samples", 0),
|
"samples": accuracy.get("samples", 0),
|
||||||
|
"effective_samples": accuracy.get("effective_samples", 0),
|
||||||
|
"quality": accuracy.get("quality", "insufficient"),
|
||||||
|
"mae": accuracy.get("mae"),
|
||||||
|
"rmse": accuracy.get("rmse"),
|
||||||
"bias": accuracy.get("bias"),
|
"bias": accuracy.get("bias"),
|
||||||
"persistence_mae": accuracy.get("persistence_mae"),
|
"persistence_mae": accuracy.get("persistence_mae"),
|
||||||
"skill": accuracy.get("skill"),
|
"skill": accuracy.get("skill"),
|
||||||
@@ -176,6 +188,47 @@ 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 followed the plan",
|
||||||
|
"horizons": horizons,
|
||||||
|
"window_days": WINDOW_DAYS,
|
||||||
|
"unit_of_attributes": "°C",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class MPCLearningProgress(CoordinatorEntity, SensorEntity):
|
class MPCLearningProgress(CoordinatorEntity, SensorEntity):
|
||||||
"""How close the least trained room is to being used for forecasts."""
|
"""How close the least trained room is to being used for forecasts."""
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Thermal zone",
|
"title": "Thermal zone",
|
||||||
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"sensors": "Temperature sensors",
|
"sensors": "Temperature sensors",
|
||||||
@@ -159,11 +159,19 @@
|
|||||||
},
|
},
|
||||||
"sensor_weights": {
|
"sensor_weights": {
|
||||||
"title": "Sensor weights",
|
"title": "Sensor weights",
|
||||||
"description": "How much each sensor counts in the zone temperature (0.1–10)."
|
"description": "How much each sensor counts in the zone temperature (0.1–10). Equal weights give the plain average.\n\n{sensors}",
|
||||||
|
"data": {
|
||||||
|
"sensor_weight_1": "Sensor 1 weight",
|
||||||
|
"sensor_weight_2": "Sensor 2 weight",
|
||||||
|
"sensor_weight_3": "Sensor 3 weight",
|
||||||
|
"sensor_weight_4": "Sensor 4 weight",
|
||||||
|
"sensor_weight_5": "Sensor 5 weight",
|
||||||
|
"sensor_weight_6": "Sensor 6 weight"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reconfigure": {
|
"reconfigure": {
|
||||||
"title": "Thermal zone",
|
"title": "Thermal zone",
|
||||||
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"sensors": "Temperature sensors",
|
"sensors": "Temperature sensors",
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Thermal zone",
|
"title": "Thermal zone",
|
||||||
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"sensors": "Temperature sensors",
|
"sensors": "Temperature sensors",
|
||||||
@@ -159,11 +159,19 @@
|
|||||||
},
|
},
|
||||||
"sensor_weights": {
|
"sensor_weights": {
|
||||||
"title": "Sensor weights",
|
"title": "Sensor weights",
|
||||||
"description": "How much each sensor counts in the zone temperature (0.1–10)."
|
"description": "How much each sensor counts in the zone temperature (0.1–10). Equal weights give the plain average.\n\n{sensors}",
|
||||||
|
"data": {
|
||||||
|
"sensor_weight_1": "Sensor 1 weight",
|
||||||
|
"sensor_weight_2": "Sensor 2 weight",
|
||||||
|
"sensor_weight_3": "Sensor 3 weight",
|
||||||
|
"sensor_weight_4": "Sensor 4 weight",
|
||||||
|
"sensor_weight_5": "Sensor 5 weight",
|
||||||
|
"sensor_weight_6": "Sensor 6 weight"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reconfigure": {
|
"reconfigure": {
|
||||||
"title": "Thermal zone",
|
"title": "Thermal zone",
|
||||||
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"sensors": "Temperature sensors",
|
"sensors": "Temperature sensors",
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Zonă termică",
|
"title": "Zonă termică",
|
||||||
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei.",
|
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei. Dacă alegi doi sau mai mulți senzori, pasul următor cere ponderea fiecărui senzor.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Nume",
|
"name": "Nume",
|
||||||
"sensors": "Senzori de temperatură",
|
"sensors": "Senzori de temperatură",
|
||||||
@@ -159,11 +159,19 @@
|
|||||||
},
|
},
|
||||||
"sensor_weights": {
|
"sensor_weights": {
|
||||||
"title": "Ponderile senzorilor",
|
"title": "Ponderile senzorilor",
|
||||||
"description": "Cât contează fiecare senzor în temperatura zonei (0,1–10)."
|
"description": "Cât contează fiecare senzor în temperatura zonei (0,1–10). Ponderi egale dau media simplă.\n\n{sensors}",
|
||||||
|
"data": {
|
||||||
|
"sensor_weight_1": "Ponderea senzorului 1",
|
||||||
|
"sensor_weight_2": "Ponderea senzorului 2",
|
||||||
|
"sensor_weight_3": "Ponderea senzorului 3",
|
||||||
|
"sensor_weight_4": "Ponderea senzorului 4",
|
||||||
|
"sensor_weight_5": "Ponderea senzorului 5",
|
||||||
|
"sensor_weight_6": "Ponderea senzorului 6"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reconfigure": {
|
"reconfigure": {
|
||||||
"title": "Zonă termică",
|
"title": "Zonă termică",
|
||||||
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei.",
|
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei. Dacă alegi doi sau mai mulți senzori, pasul următor cere ponderea fiecărui senzor.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Nume",
|
"name": "Nume",
|
||||||
"sensors": "Senzori de temperatură",
|
"sensors": "Senzori de temperatură",
|
||||||
|
|||||||
@@ -190,6 +190,23 @@ def ventilation_vector(features: Iterable[str], temp: float, outside: float,
|
|||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def ventilation_vector_for_prediction(features: Iterable[str], temp: float, outside: float,
|
||||||
|
inputs: Mapping[str, Any]) -> list[float] | None:
|
||||||
|
"""Like ventilation_vector, but an unknown wind or gust counts as calm air.
|
||||||
|
|
||||||
|
For predictions only: an open window keeps its natural ventilation when the wind
|
||||||
|
sensor is unavailable, instead of silently being treated as closed. Learning
|
||||||
|
always uses ventilation_vector, which refuses unknown inputs.
|
||||||
|
"""
|
||||||
|
features = list(features)
|
||||||
|
x = ventilation_vector(features, temp, outside, inputs)
|
||||||
|
if x is None and inputs.get("opening") is not None:
|
||||||
|
x = ventilation_vector(features, temp, outside, {
|
||||||
|
**inputs, "wind": inputs.get("wind") or 0.0, "gust": inputs.get("gust") or inputs.get("wind") or 0.0,
|
||||||
|
"facing": inputs.get("facing") or 0.0})
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
def _iso(timestamp: float) -> str:
|
def _iso(timestamp: float) -> str:
|
||||||
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat(timespec="seconds")
|
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
@@ -206,6 +223,9 @@ class VentilationTracker:
|
|||||||
self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened
|
self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened
|
||||||
self._last_temps: dict[str, float | None] = {}
|
self._last_temps: dict[str, float | None] = {}
|
||||||
self._open_event: dict[str, dict[str, Any]] = {}
|
self._open_event: dict[str, dict[str, Any]] = {}
|
||||||
|
# window -> last known open/closed state, since when, and since when it is unknown.
|
||||||
|
self._memory: dict[str, dict[str, Any]] = {}
|
||||||
|
self._recovery_confirmed: dict[str, bool] = {}
|
||||||
saved = saved if isinstance(saved, Mapping) else {}
|
saved = saved if isinstance(saved, Mapping) else {}
|
||||||
events = saved.get("events") if isinstance(saved.get("events"), list) else []
|
events = saved.get("events") if isinstance(saved.get("events"), list) else []
|
||||||
self.events: deque[dict[str, Any]] = deque(
|
self.events: deque[dict[str, Any]] = deque(
|
||||||
@@ -215,7 +235,13 @@ class VentilationTracker:
|
|||||||
|
|
||||||
def update(self, now: float, states: Mapping[str, tuple[str | None, float | None]],
|
def update(self, now: float, states: Mapping[str, tuple[str | None, float | None]],
|
||||||
temps: Mapping[str, float | None], weather: Mapping[str, float | None]) -> None:
|
temps: Mapping[str, float | None], weather: Mapping[str, float | None]) -> None:
|
||||||
"""One poll. `states`: window key -> (binary sensor state, last_changed timestamp)."""
|
"""One poll. `states`: window key -> (binary sensor state, last_changed timestamp).
|
||||||
|
|
||||||
|
Each window is OPEN, CLOSED or UNKNOWN (unknown/unavailable/missing). Unknown
|
||||||
|
never means closed: the last known state is kept, an open window's event
|
||||||
|
stays open, and a close that happened while the sensor was unknown is
|
||||||
|
recorded as unconfirmed.
|
||||||
|
"""
|
||||||
self.window_state = {}
|
self.window_state = {}
|
||||||
zones: dict[str, dict[str, Any]] = {}
|
zones: dict[str, dict[str, Any]] = {}
|
||||||
for window in self.windows:
|
for window in self.windows:
|
||||||
@@ -228,9 +254,11 @@ class VentilationTracker:
|
|||||||
if previous and changed is not None and 0 < now - previous[0] <= 900:
|
if previous and changed is not None and 0 < now - previous[0] <= 900:
|
||||||
fraction = open_fraction(previous[1], is_open, changed, previous[0], now)
|
fraction = open_fraction(previous[1], is_open, changed, previous[0], now)
|
||||||
self._previous[window.key] = (now, is_open)
|
self._previous[window.key] = (now, is_open)
|
||||||
zone = zones.setdefault(window.zone, {"opening": 0.0, "facing_unit": 0.0, "known": True,
|
memory = self._memory.setdefault(window.key, {"last_known": None, "since": None, "unknown_since": None})
|
||||||
"open_now": False, "open_windows": [], "last_close": None,
|
zone = zones.setdefault(window.zone, {
|
||||||
"first_open": None})
|
"opening": 0.0, "facing_unit": 0.0, "known": True, "open_now": False, "possibly_open": False,
|
||||||
|
"open_windows": [], "last_close": None, "close_confirmed": True, "first_open": None,
|
||||||
|
"open_confirmed": True, "unknown_windows": []})
|
||||||
if fraction is None:
|
if fraction is None:
|
||||||
zone["known"] = False
|
zone["known"] = False
|
||||||
else:
|
else:
|
||||||
@@ -238,15 +266,31 @@ class VentilationTracker:
|
|||||||
zone["facing_unit"] += window.opening * fraction * (
|
zone["facing_unit"] += window.opening * fraction * (
|
||||||
facing_cos(weather["wind_direction"], window.azimuth)
|
facing_cos(weather["wind_direction"], window.azimuth)
|
||||||
if weather.get("wind_direction") is not None else 0.0)
|
if weather.get("wind_direction") is not None else 0.0)
|
||||||
if is_open:
|
if is_open is None:
|
||||||
zone["open_now"] = True
|
if memory["unknown_since"] is None:
|
||||||
zone["open_windows"].append(window.name)
|
memory["unknown_since"] = previous[0] if previous and previous[1] is not None else now
|
||||||
if changed is not None:
|
zone["unknown_windows"].append(window.name)
|
||||||
zone["first_open"] = min(zone["first_open"] or changed, changed)
|
if memory["last_known"]:
|
||||||
elif is_open is False and previous and changed is not None and changed > previous[0]:
|
zone["possibly_open"] = True # last seen open: still treated as open
|
||||||
zone["last_close"] = max(zone["last_close"] or 0.0, changed)
|
else:
|
||||||
|
after_outage = memory["unknown_since"] is not None
|
||||||
|
if memory["last_known"] is not is_open:
|
||||||
|
memory["since"] = changed
|
||||||
|
if is_open and after_outage:
|
||||||
|
zone["open_confirmed"] = False # opened at some point during the outage
|
||||||
|
if is_open is False and memory["last_known"] and after_outage:
|
||||||
|
zone["close_confirmed"] = False # closed at some point during the outage
|
||||||
|
memory["last_known"], memory["unknown_since"] = is_open, None
|
||||||
|
if is_open:
|
||||||
|
zone["open_now"] = True
|
||||||
|
zone["open_windows"].append(window.name)
|
||||||
|
if changed is not None:
|
||||||
|
zone["first_open"] = min(zone["first_open"] or changed, changed)
|
||||||
|
elif previous and changed is not None and changed > previous[0]:
|
||||||
|
zone["last_close"] = max(zone["last_close"] or 0.0, changed)
|
||||||
self.window_state[window.key] = {
|
self.window_state[window.key] = {
|
||||||
"open": is_open, "since": changed,
|
"open": is_open, "since": changed, "last_known_open": memory["last_known"],
|
||||||
|
"last_known_since": memory["since"], "unknown_since": memory["unknown_since"],
|
||||||
"relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1)
|
"relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1)
|
||||||
if weather.get("wind_direction") is not None else None)}
|
if weather.get("wind_direction") is not None else None)}
|
||||||
for key, zone in zones.items():
|
for key, zone in zones.items():
|
||||||
@@ -257,30 +301,46 @@ class VentilationTracker:
|
|||||||
def _update_zone(self, now: float, key: str, zone: dict[str, Any], temp: float | None,
|
def _update_zone(self, now: float, key: str, zone: dict[str, Any], temp: float | None,
|
||||||
weather: Mapping[str, float | None], zones: Mapping[str, dict[str, Any]]) -> None:
|
weather: Mapping[str, float | None], zones: Mapping[str, dict[str, Any]]) -> None:
|
||||||
was_open = key in self._open_event
|
was_open = key in self._open_event
|
||||||
|
ventilating = zone["open_now"] or zone["possibly_open"]
|
||||||
if zone["open_now"] and not was_open:
|
if zone["open_now"] and not was_open:
|
||||||
# Temperature at the previous poll approximates the room before ventilation.
|
# Temperature at the previous poll approximates the room before ventilation.
|
||||||
self._t_ref[key] = self._last_temps.get(key)
|
self._t_ref[key] = self._last_temps.get(key)
|
||||||
self._closed_at.pop(key, None)
|
self._closed_at.pop(key, None)
|
||||||
self._open_event[key] = {
|
self._open_event[key] = {
|
||||||
"zone": key, "opened": _iso(min(zone["first_open"] or now, now)),
|
"zone": key, "opened": _iso(min(zone["first_open"] or now, now)),
|
||||||
"temperature_before": self._t_ref[key],
|
"opened_confirmed": zone["open_confirmed"],
|
||||||
|
"temperature_before": self._t_ref[key] if zone["open_confirmed"] else None,
|
||||||
"outside": weather.get("temperature"), "wind": weather.get("wind"),
|
"outside": weather.get("temperature"), "wind": weather.get("wind"),
|
||||||
"wind_direction": weather.get("wind_direction"), "wind_gust": weather.get("wind_gust"),
|
"wind_direction": weather.get("wind_direction"), "wind_gust": weather.get("wind_gust"),
|
||||||
"windows": list(zone["open_windows"]),
|
"windows": list(zone["open_windows"]),
|
||||||
"other_open_windows": [n for k, z in zones.items() if k != key for n in z["open_windows"]],
|
"other_open_windows": [n for k, z in zones.items() if k != key for n in z["open_windows"]],
|
||||||
"lowest_temperature": temp,
|
"lowest_temperature": temp, "sensor_unknown_minutes": 0.0,
|
||||||
}
|
}
|
||||||
elif zone["open_now"] and temp is not None:
|
if not zone["open_confirmed"]:
|
||||||
|
self._t_ref[key] = None # the room may already have cooled before we saw it
|
||||||
|
elif ventilating and was_open:
|
||||||
event = self._open_event[key]
|
event = self._open_event[key]
|
||||||
event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None)
|
if temp is not None:
|
||||||
elif was_open and not zone["open_now"]:
|
event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None)
|
||||||
|
if zone["unknown_windows"]:
|
||||||
|
event["sensor_unknown_minutes"] = round(event["sensor_unknown_minutes"] + 5.0, 1)
|
||||||
|
elif was_open and not ventilating:
|
||||||
event = self._open_event.pop(key)
|
event = self._open_event.pop(key)
|
||||||
closed = zone["last_close"] or now
|
if zone["close_confirmed"]:
|
||||||
|
closed = zone["last_close"] or now
|
||||||
|
event.update(closed=_iso(closed), closed_confirmed=True,
|
||||||
|
duration_minutes=round((closed - _parse(event["opened"])) / 60, 1))
|
||||||
|
else:
|
||||||
|
# Closed while the sensor was unknown: no confirmed time, only a range.
|
||||||
|
closed = now
|
||||||
|
event.update(closed=None, closed_confirmed=False, closed_reported=_iso(now),
|
||||||
|
duration_minutes=None,
|
||||||
|
duration_max_minutes=round((now - _parse(event["opened"])) / 60, 1))
|
||||||
self._closed_at[key] = closed
|
self._closed_at[key] = closed
|
||||||
event.update(closed=_iso(closed), duration_minutes=round((closed - _parse(event["opened"])) / 60, 1),
|
self._recovery_confirmed[key] = zone["close_confirmed"] and event.get("opened_confirmed", True)
|
||||||
temperature_at_close=temp)
|
event["temperature_at_close"] = temp
|
||||||
self.events.append(event)
|
self.events.append(event)
|
||||||
elif not zone["open_now"] and key in self._closed_at and temp is not None:
|
elif not ventilating and key in self._closed_at and temp is not None:
|
||||||
# Fill in the temperature half an hour after closing, for recovery review.
|
# Fill in the temperature half an hour after closing, for recovery review.
|
||||||
for event in reversed(self.events):
|
for event in reversed(self.events):
|
||||||
if event.get("zone") == key and "temperature_after" not in event:
|
if event.get("zone") == key and "temperature_after" not in event:
|
||||||
@@ -289,36 +349,46 @@ class VentilationTracker:
|
|||||||
event["temperature_after_minutes"] = EVENT_AFTER_MINUTES
|
event["temperature_after_minutes"] = EVENT_AFTER_MINUTES
|
||||||
break
|
break
|
||||||
closed = self._closed_at.get(key)
|
closed = self._closed_at.get(key)
|
||||||
recovery = exp(-(now - closed) / self.tau) if closed is not None and not zone["open_now"] else 0.0
|
recovery = exp(-(now - closed) / self.tau) if closed is not None and not ventilating else 0.0
|
||||||
if recovery < RECOVERY_THRESHOLD:
|
if recovery < RECOVERY_THRESHOLD:
|
||||||
recovery = 0.0
|
recovery = 0.0
|
||||||
zone.update(recovery=recovery, t_ref=self._t_ref.get(key),
|
zone.update(recovery=recovery, t_ref=self._t_ref.get(key),
|
||||||
|
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"]))
|
other_opening=sum(z["opening"] for k, z in zones.items() if k != key and z["known"]))
|
||||||
# Learning of the closed-window model is unsafe while anything ventilation-related goes on.
|
# Learning of the closed-window model is unsafe while anything ventilation-related goes on.
|
||||||
zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or zone["open_now"] or recovery > 0
|
zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or ventilating or recovery > 0
|
||||||
|
|
||||||
def zone_inputs(self, key: str, weather: Mapping[str, float | None]) -> dict[str, Any] | None:
|
def zone_inputs(self, key: str, weather: Mapping[str, float | None]) -> dict[str, Any] | None:
|
||||||
"""Ventilation inputs of the interval that just ended; None for zones without windows."""
|
"""Ventilation inputs of the interval that just ended; None for zones without windows.
|
||||||
|
|
||||||
|
`known` is False while a window state is unknown, and during a recovery whose
|
||||||
|
start is not confirmed: those intervals are not used for any learning.
|
||||||
|
"""
|
||||||
zone = self.zone_state.get(key)
|
zone = self.zone_state.get(key)
|
||||||
if zone is None:
|
if zone is None:
|
||||||
return None
|
return None
|
||||||
return {"opening": zone["opening"] if zone["known"] else None, "facing": zone["facing_unit"],
|
known = zone["known"] and (zone["recovery"] == 0 or zone["recovery_confirmed"])
|
||||||
|
return {"opening": zone["opening"] if known else None, "facing": zone["facing_unit"],
|
||||||
"other_opening": zone["other_opening"], "recovery": zone["recovery"],
|
"other_opening": zone["other_opening"], "recovery": zone["recovery"],
|
||||||
"t_ref": zone["t_ref"], "wind": weather.get("wind"), "gust": weather.get("wind_gust"),
|
"t_ref": zone["t_ref"], "wind": weather.get("wind"), "gust": weather.get("wind_gust"),
|
||||||
"affected": zone["affected"], "known": zone["known"]}
|
"affected": zone["affected"], "known": known,
|
||||||
|
"unknown_windows": list(zone["unknown_windows"])}
|
||||||
|
|
||||||
def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None],
|
def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None],
|
||||||
now: float) -> list[dict[str, Any]]:
|
now: float) -> list[dict[str, Any]]:
|
||||||
"""Assumed ventilation inputs per forecast step.
|
"""Assumed ventilation inputs per forecast step.
|
||||||
|
|
||||||
Windows open now are assumed to close after `open_minutes` (at least one
|
Windows open now (or unknown but last seen open) are assumed to close after
|
||||||
step); after that the zone recovers. Weather is held at current values.
|
`open_minutes` (at least one step); after that the zone recovers. Weather
|
||||||
|
is held at current values.
|
||||||
"""
|
"""
|
||||||
zone = self.zone_state.get(key)
|
zone = self.zone_state.get(key)
|
||||||
if zone is None:
|
if zone is None:
|
||||||
return [{} for _ in range(steps)]
|
return [{} for _ in range(steps)]
|
||||||
windows = [w for w in self.windows if w.zone == key and w.enabled
|
windows = [w for w in self.windows if w.zone == key and w.enabled
|
||||||
and self.window_state.get(w.key, {}).get("open")]
|
and (self.window_state.get(w.key, {}).get("open")
|
||||||
|
or (self.window_state.get(w.key, {}).get("open") is None
|
||||||
|
and self.window_state.get(w.key, {}).get("last_known_open")))]
|
||||||
opening = sum(w.opening for w in windows)
|
opening = sum(w.opening for w in windows)
|
||||||
facing = sum(w.opening * facing_cos(weather["wind_direction"], w.azimuth) 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
|
if weather.get("wind_direction") is not None else 0.0
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from typing import Any, Callable, Mapping
|
|||||||
from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES
|
from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES
|
||||||
from .learning import FeatureLearner
|
from .learning import FeatureLearner
|
||||||
from .physics import Learner
|
from .physics import Learner
|
||||||
from .windows import ventilation_vector
|
from .windows import ventilation_vector_for_prediction
|
||||||
|
|
||||||
BASE_FEATURES = ["loss", "heat", "bias"]
|
BASE_FEATURES = ["loss", "heat", "bias"]
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ class ZoneModel:
|
|||||||
ventilation = inputs.get("ventilation")
|
ventilation = inputs.get("ventilation")
|
||||||
if self.ventilation is None or not ventilation:
|
if self.ventilation is None or not ventilation:
|
||||||
return 0.0
|
return 0.0
|
||||||
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation)
|
x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||||
return self.ventilation.predict(x) if x is not None else 0.0
|
return self.ventilation.predict(x) if x is not None else 0.0
|
||||||
|
|
||||||
def delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
|
def delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
|
||||||
@@ -98,7 +98,7 @@ class ZoneModel:
|
|||||||
terms = self._main_contributions(temp, inputs)
|
terms = self._main_contributions(temp, inputs)
|
||||||
ventilation = inputs.get("ventilation")
|
ventilation = inputs.get("ventilation")
|
||||||
if self.ventilation is not None and ventilation:
|
if self.ventilation is not None and ventilation:
|
||||||
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation)
|
x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||||
if x is not None:
|
if x is not None:
|
||||||
for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x):
|
for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x):
|
||||||
group = "recovery" if name == "recovery" else "ventilation"
|
group = "recovery" if name == "recovery" else "ventilation"
|
||||||
|
|||||||
+64
-11
@@ -93,9 +93,16 @@ measure the input side or lower the heat fraction accordingly.
|
|||||||
|
|
||||||
Present when a zone has an enabled window. Learned separately, from the part of
|
Present when a zone has an enabled window. Learned separately, from the part of
|
||||||
the measured change the closed-window layers cannot explain, only while a window is
|
the measured change the closed-window layers cannot explain, only while a window is
|
||||||
open, its state is unknown, or the zone is recovering. During those periods the
|
open or the zone is recovering, and only while every window state in the zone is
|
||||||
base and extended layers do **not** learn, so window events cannot inflate `loss`
|
known. While a window is open, possibly open (its sensor is unknown and it was last
|
||||||
or shift `bias`.
|
seen open) or the zone is recovering, the base and extended layers do **not**
|
||||||
|
learn, so window events cannot inflate `loss` or shift `bias`.
|
||||||
|
|
||||||
|
An unknown window state is never read as closed. A window last seen open stays
|
||||||
|
assumed open in forecasts; one last seen closed contributes no ventilation term.
|
||||||
|
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.
|
||||||
|
|
||||||
`vent_wind_dir` is bounded by `vent_wind` (|vent_wind_dir| ≤ vent_wind), so the
|
`vent_wind_dir` is bounded by `vent_wind` (|vent_wind_dir| ≤ vent_wind), so the
|
||||||
combined wind term never goes negative: a leeward window still ventilates.
|
combined wind term never goes negative: a leeward window still ventilates.
|
||||||
@@ -128,11 +135,20 @@ outlier rejection above 0.8 °C per 5 minutes.
|
|||||||
Each coefficient reports:
|
Each coefficient reports:
|
||||||
|
|
||||||
* `confidence`: the share of its information that comes from data rather than the
|
* `confidence`: the share of its information that comes from data rather than the
|
||||||
prior (0 = prior only);
|
prior (0 = prior only), from its posterior variance. It is not a sample count:
|
||||||
|
it grows only while the data varies the coefficient's input independently of
|
||||||
|
the others, and with forgetting it falls again during long periods without such
|
||||||
|
variation. The base layer reports it too (`loss`, `heat`, `bias`);
|
||||||
* `std`: its standard error, using a running estimate of the unexplained noise;
|
* `std`: its standard error, using a running estimate of the unexplained noise;
|
||||||
* `samples`: how many accepted samples actually exercised it;
|
* `samples`: how many accepted samples actually exercised it;
|
||||||
* `identified`: at least 30 exercising samples and confidence ≥ 0.8.
|
* `identified`: at least 30 exercising samples and confidence ≥ 0.8.
|
||||||
|
|
||||||
|
The base layer has no per-coefficient sample counts or standard errors; its
|
||||||
|
`confidence` alone shows identification. In the tests, two weeks of samples with
|
||||||
|
the relay always off and a steady indoor-outdoor difference leave `heat` at 0 and
|
||||||
|
`loss` and `bias` below 0.8, while four days with the relay cycling and the
|
||||||
|
weather changing bring all three above 0.8.
|
||||||
|
|
||||||
A coefficient can move without being identified. Example from the tests: two rooms
|
A coefficient can move without being identified. Example from the tests: two rooms
|
||||||
whose temperature difference stays fairly steady cannot separate exchange between
|
whose temperature difference stays fairly steady cannot separate exchange between
|
||||||
them from each room's bias; the learner finds a positive coupling but correctly
|
them from each room's bias; the learner finds a positive coupling but correctly
|
||||||
@@ -154,6 +170,27 @@ A zone learns from the interval between two polls only when:
|
|||||||
No experiments are run and the boiler is never switched to gather data: everything
|
No experiments are run and the boiler is never switched to gather data: everything
|
||||||
is learned passively.
|
is learned passively.
|
||||||
|
|
||||||
|
## Configuration changes
|
||||||
|
|
||||||
|
Every learned component stores a fingerprint of the configuration it was learned
|
||||||
|
under (`custom_components/heating_mpc/compatibility.py`). At startup, components
|
||||||
|
whose fingerprint changed are reset; the rest are kept.
|
||||||
|
|
||||||
|
| Change | Effect |
|
||||||
|
|---|---|
|
||||||
|
| A zone's sensors, or the ratio of their weights | Zone recalibrated: coefficients kept as a starting point; uncertainty, counts and accuracy statistics reset. Its extended and ventilation layers start over too (their loss, heat and bias values kept as a starting point) |
|
||||||
|
| Scaling all of a zone's weights equally | Nothing (the normalized measurement is unchanged) |
|
||||||
|
| A heat source's sensor, heat share, baseline, response time or "included in" | `internal` of its zone; the source's baseline samples if its effective power changed |
|
||||||
|
| A neighbour's sensors or weights | `couple:<neighbour>` |
|
||||||
|
| A window's sensor, polarity or opening factor | that zone's ventilation terms; `vent_cross` of the other zones |
|
||||||
|
| A window's facing direction | `vent_wind_dir` |
|
||||||
|
| Recovery time constant | `recovery` |
|
||||||
|
| Comfort weight, target offset, names | Nothing (not physical) |
|
||||||
|
|
||||||
|
Models saved before fingerprints existed (0.4.0) are assumed to match the
|
||||||
|
configuration they are loaded with, and are fingerprinted at once. Every reset is
|
||||||
|
logged in `model_changes` with its reason.
|
||||||
|
|
||||||
## Forecasts
|
## Forecasts
|
||||||
|
|
||||||
Forecasts simulate all zones together, step by step, over the MPC horizon (60–180
|
Forecasts simulate all zones together, step by step, over the MPC horizon (60–180
|
||||||
@@ -177,18 +214,34 @@ they describe what would happen under the MPC's proposed plan.
|
|||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
The Forecast Error sensors score forecasts that are valid for comparison with
|
Two separate measurements, because they answer different questions.
|
||||||
measured temperatures. At every poll the model parameters are frozen; 15, 30, 60
|
|
||||||
and 120 minutes later all zones are replayed from that poll using the relay,
|
|
||||||
electrical heat and window history that actually followed (outdoor temperature held
|
|
||||||
at its start value), and compared with the readings. The base layer and a
|
|
||||||
no-change forecast are scored on exactly the same spans:
|
|
||||||
|
|
||||||
* `mae`, `bias`: the full model's error;
|
**Model accuracy (Model Error sensors)** scores the model, not a forecast. At
|
||||||
|
every poll the model parameters are frozen; 15, 30, 60 and 120 minutes later all
|
||||||
|
zones are replayed from that poll using the relay, electrical heat and window
|
||||||
|
history that actually followed (outdoor temperature held at its start value),
|
||||||
|
and compared with the readings. The base layer and a no-change forecast are
|
||||||
|
scored on exactly the same spans:
|
||||||
|
|
||||||
|
* `mae`, `rmse`, `bias`: the full model's error;
|
||||||
* `persistence_mae`, `skill`: against assuming no change;
|
* `persistence_mae`, `skill`: against assuming no change;
|
||||||
* `base_model_mae`, `improvement_vs_base`: against the base layer alone. A feature
|
* `base_model_mae`, `improvement_vs_base`: against the base layer alone. A feature
|
||||||
that does not improve this should be switched off (for example the gust term).
|
that does not improve this should be switched off (for example the gust term).
|
||||||
|
|
||||||
|
**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 scored at a
|
||||||
|
horizon only if the relay was in the planned state for at least 12 minutes of
|
||||||
|
every 15-minute block up to it; otherwise it is counted as `counterfactual` (or
|
||||||
|
`unverifiable` when the relay history is incomplete) and never as an error.
|
||||||
|
|
||||||
|
Both report how much evidence there is. Forecasts start every 5 minutes, so at
|
||||||
|
horizon h consecutive forecasts share most of their span, and all zones share one
|
||||||
|
boiler: `effective_samples = samples × 5 / h / zones`. Below 5 the quality is
|
||||||
|
`insufficient` and the sensor's state stays unknown; below 30 it is
|
||||||
|
`indicative`.
|
||||||
|
|
||||||
Results on synthetic apartments (the tests; these check the method, not real-world
|
Results on synthetic apartments (the tests; these check the method, not real-world
|
||||||
accuracy):
|
accuracy):
|
||||||
|
|
||||||
|
|||||||
@@ -132,8 +132,11 @@ async def test_zone_subentry_add_edit_and_validation(hass, entry, inputs):
|
|||||||
"name": " Living ", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 2,
|
"name": " Living ", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 2,
|
||||||
"target_offset": 0.5, "neighbors": ["sensor.room"]})
|
"target_offset": 0.5, "neighbors": ["sensor.room"]})
|
||||||
assert result["step_id"] == "sensor_weights"
|
assert result["step_id"] == "sensor_weights"
|
||||||
result = await flows.async_configure(result["flow_id"], {
|
# Numbered, translated fields; the description says which sensor is which.
|
||||||
"weight__sensor.living_a": 3, "weight__sensor.living_b": 1})
|
assert [str(key) for key in result["data_schema"].schema] == ["sensor_weight_1", "sensor_weight_2"]
|
||||||
|
assert result["description_placeholders"]["sensors"].splitlines() == [
|
||||||
|
"1. living a (sensor.living_a)", "2. living b (sensor.living_b)"] # HA name fallback
|
||||||
|
result = await flows.async_configure(result["flow_id"], {"sensor_weight_1": 3, "sensor_weight_2": 1})
|
||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
living = next(sub for sub in zones_of(entry).values() if sub.title == "Living")
|
living = next(sub for sub in zones_of(entry).values() if sub.title == "Living")
|
||||||
@@ -149,6 +152,13 @@ async def test_zone_subentry_add_edit_and_validation(hass, entry, inputs):
|
|||||||
result = await flows.async_configure(result["flow_id"], {
|
result = await flows.async_configure(result["flow_id"], {
|
||||||
"name": "Living", "sensors": ["sensor.living_a"], "comfort_weight": 1, "target_offset": 0})
|
"name": "Living", "sensors": ["sensor.living_a"], "comfort_weight": 1, "target_offset": 0})
|
||||||
assert result["type"] is FlowResultType.ABORT and result["reason"] == "reconfigure_successful"
|
assert result["type"] is FlowResultType.ABORT and result["reason"] == "reconfigure_successful"
|
||||||
|
# Editing a multi-sensor zone shows the saved sensor weights.
|
||||||
|
result = await flows.async_init((entry.entry_id, "zone"), context={
|
||||||
|
"source": SOURCE_RECONFIGURE, "subentry_id": living.subentry_id})
|
||||||
|
result = await flows.async_configure(result["flow_id"], {
|
||||||
|
"name": "Living", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 1, "target_offset": 0})
|
||||||
|
assert result["data_schema"]({})["sensor_weight_1"] == 1.0
|
||||||
|
flows.async_abort(result["flow_id"])
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert entry.subentries[living.subentry_id].data["key"] == key
|
assert entry.subentries[living.subentry_id].data["key"] == key
|
||||||
assert hass.data[DOMAIN][entry.entry_id].zones[key].sensors == ("sensor.living_a",)
|
assert hass.data[DOMAIN][entry.entry_id].zones[key].sensors == ("sensor.living_a",)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async def test_setup_reload_unload(hass, entry, inputs):
|
|||||||
assert coordinator.config_entry is entry
|
assert coordinator.config_entry is entry
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
entities = er.async_entries_for_config_entry(registry, entry.entry_id)
|
entities = er.async_entries_for_config_entry(registry, entry.entry_id)
|
||||||
assert len(entities) == 11 # 7 core + 4 forecast-error sensors
|
assert len(entities) == 12 # 7 core + 4 model-error sensors + 1 forecast-error sensor
|
||||||
ids = {e.unique_id: e.entity_id for e in entities}
|
ids = {e.unique_id: e.entity_id for e in entities}
|
||||||
assert coordinator.data["status"] == "learning"
|
assert coordinator.data["status"] == "learning"
|
||||||
status = hass.states.get(ids[f"{entry.entry_id}_status"])
|
status = hass.states.get(ids[f"{entry.entry_id}_status"])
|
||||||
@@ -74,7 +74,7 @@ async def test_replacing_a_zone_removes_only_its_forecast(hass, entry, inputs):
|
|||||||
assert registry.async_get(old) is None
|
assert registry.async_get(old) is None
|
||||||
new = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_zone_new_predicted_60min")
|
new = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_zone_new_predicted_60min")
|
||||||
assert hass.states.get(new).attributes["friendly_name"] == "MPC New 60min Prediction"
|
assert hass.states.get(new).attributes["friendly_name"] == "MPC New 60min Prediction"
|
||||||
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 11
|
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 12
|
||||||
|
|
||||||
|
|
||||||
async def test_storage_restore_and_remove(hass, entry, inputs):
|
async def test_storage_restore_and_remove(hass, entry, inputs):
|
||||||
@@ -149,6 +149,45 @@ async def test_scheduled_sample_saves_and_unload_stops_polling(hass, entry, inpu
|
|||||||
assert coordinator.learners["sensor.room"].count == 1
|
assert coordinator.learners["sensor.room"].count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reloads_leave_one_relay_listener_and_one_polling_coordinator(hass, entry, inputs, freezer):
|
||||||
|
coordinators = [await setup(hass, entry)]
|
||||||
|
for _ in range(3):
|
||||||
|
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinators.append(hass.data[DOMAIN][entry.entry_id])
|
||||||
|
*old, current = coordinators
|
||||||
|
assert len({id(c) for c in coordinators}) == 4
|
||||||
|
# A relay change reaches only the current coordinator.
|
||||||
|
hass.states.async_set("switch.boiler", "on")
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert [state for _, state in current._relay_events] == ["on"]
|
||||||
|
assert not any(c._relay_events for c in old)
|
||||||
|
# Only the current coordinator polls; the replaced ones neither learn nor publish.
|
||||||
|
status_id = er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status")
|
||||||
|
updates = []
|
||||||
|
hass.bus.async_listen("state_changed", lambda event: updates.append(event.data["entity_id"]))
|
||||||
|
before = [(c.learners["sensor.room"].count, c.data) for c in old]
|
||||||
|
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
|
||||||
|
freezer.move_to(future)
|
||||||
|
async_fire_time_changed(hass, future)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert [(c.learners["sensor.room"].count, c.data) for c in old] == before
|
||||||
|
assert updates.count(status_id) <= 1
|
||||||
|
assert hass.states.get(status_id).state == current.data["status"]
|
||||||
|
# After unload, a relay change is recorded nowhere and nothing is published.
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
events = list(current._relay_events)
|
||||||
|
updates.clear()
|
||||||
|
hass.states.async_set("switch.boiler", "off")
|
||||||
|
future += timedelta(minutes=5)
|
||||||
|
freezer.move_to(future)
|
||||||
|
async_fire_time_changed(hass, future)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert list(current._relay_events) == events
|
||||||
|
assert status_id not in updates
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", ["broken", {"learners": []}, {"learners": {"sensor.room": {"theta": []}}}])
|
@pytest.mark.parametrize("payload", ["broken", {"learners": []}, {"learners": {"sensor.room": {"theta": []}}}])
|
||||||
async def test_corrupt_saved_payload_starts_learning(hass, entry, inputs, hass_storage, payload):
|
async def test_corrupt_saved_payload_starts_learning(hass, entry, inputs, hass_storage, payload):
|
||||||
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": payload}
|
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": payload}
|
||||||
@@ -255,13 +294,36 @@ async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs,
|
|||||||
async_fire_time_changed(hass, future)
|
async_fire_time_changed(hass, future)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
state = hass.states.get(ids[15])
|
state = hass.states.get(ids[15])
|
||||||
assert coordinator.data["forecast_accuracy"][15]["samples"] == 1
|
accuracy = coordinator.data["forecast_accuracy"][15]
|
||||||
assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0
|
assert accuracy["samples"] == 1
|
||||||
|
# One replayed forecast is no accuracy figure: the state stays unknown, the numbers
|
||||||
|
# are in the attributes with their quality.
|
||||||
|
assert state.state == "unknown"
|
||||||
|
assert state.attributes["mae"] == accuracy["mae"] > 0
|
||||||
|
assert state.attributes["quality"] == "insufficient"
|
||||||
assert state.attributes["samples"] == 1
|
assert state.attributes["samples"] == 1
|
||||||
assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name
|
assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name
|
||||||
assert state.attributes["unit_of_measurement"] == "°C"
|
assert state.attributes["unit_of_measurement"] == "°C"
|
||||||
|
assert state.attributes["friendly_name"] == "Heating MPC Model Error 15 min"
|
||||||
assert "zones" in state.state_info["unrecorded_attributes"]
|
assert "zones" in state.state_info["unrecorded_attributes"]
|
||||||
assert hass.states.get(ids[30]).state == "unknown"
|
assert hass.states.get(ids[30]).state == "unknown"
|
||||||
|
for step in range(15): # enough 15-minute spans for an indicative figure
|
||||||
|
future += timedelta(minutes=5, seconds=1)
|
||||||
|
freezer.move_to(future)
|
||||||
|
# Fresh readings, or the sensors turn stale after 30 minutes.
|
||||||
|
hass.states.async_set("sensor.room", str(20 - 0.01 * step), {"unit_of_measurement": "°C"})
|
||||||
|
hass.states.async_set("sensor.outside", "5", {"unit_of_measurement": "°C"}, force_update=True)
|
||||||
|
async_fire_time_changed(hass, future)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
accuracy = coordinator.data["forecast_accuracy"][15]
|
||||||
|
assert accuracy["quality"] == "indicative", accuracy
|
||||||
|
assert float(hass.states.get(ids[15]).state) == accuracy["mae"]
|
||||||
|
# Published forecasts are scored separately; nothing was published while learning.
|
||||||
|
operational = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_operational_forecast_error")
|
||||||
|
state = hass.states.get(operational)
|
||||||
|
assert state.state == "unknown"
|
||||||
|
assert state.attributes["friendly_name"] == "Heating MPC Forecast Error"
|
||||||
|
assert "horizons" in state.state_info["unrecorded_attributes"]
|
||||||
|
|
||||||
|
|
||||||
async def test_learning_progress_and_last_sample_sensors(hass, entry, inputs, freezer):
|
async def test_learning_progress_and_last_sample_sensors(hass, entry, inputs, freezer):
|
||||||
@@ -305,3 +367,25 @@ async def test_deleting_the_last_zone_keeps_the_entry_loaded(hass, entry, inputs
|
|||||||
add_zone(hass, entry, "sensor.room", ["sensor.room"])
|
add_zone(hass, entry, "sensor.room", ["sensor.room"])
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "learning"
|
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "learning"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zone_edits_recalibrate_only_when_the_measurement_changes(hass, entry, inputs, hass_storage):
|
||||||
|
from custom_components.heating_mpc.physics import Learner
|
||||||
|
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": {
|
||||||
|
"schema_version": 2, "heater_entity": "switch.boiler",
|
||||||
|
"learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}}
|
||||||
|
await setup(hass, entry)
|
||||||
|
zone = zone_subentry(entry, "sensor.room")
|
||||||
|
hass.config_entries.async_update_subentry(entry, zone, data={**zone.data, "comfort_weight": 3.0})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
assert coordinator.learners["sensor.room"].count == 250 # comfort is not physics
|
||||||
|
hass.states.async_set("sensor.second", "21", {"unit_of_measurement": "°C"})
|
||||||
|
hass.config_entries.async_update_subentry(entry, zone, data={
|
||||||
|
**zone.data, "sensors": ["sensor.room", "sensor.second"],
|
||||||
|
"sensor_weights": {"sensor.room": 1.0, "sensor.second": 1.0}})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
assert coordinator.learners["sensor.room"].count == 0
|
||||||
|
status = hass.states.get(er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
|
||||||
|
assert status.attributes["model_changes"][-1]["reasons"] == ["zone sensors or sensor weights changed"]
|
||||||
@@ -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 importlib.util
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -784,6 +785,11 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(details['model_in_use'], 'extended')
|
self.assertEqual(details['model_in_use'], 'extended')
|
||||||
self.assertGreater(details['contributions_c_per_h']['internal'], 0.2)
|
self.assertGreater(details['contributions_c_per_h']['internal'], 0.2)
|
||||||
self.assertTrue(details['coefficients']['extended']['internal']['identified'])
|
self.assertTrue(details['coefficients']['extended']['internal']['identified'])
|
||||||
|
# The base layer reports how well each coefficient is pinned down too.
|
||||||
|
base = details['coefficients']['base']
|
||||||
|
self.assertEqual(set(base), {'loss', 'heat', 'bias'})
|
||||||
|
self.assertEqual(base['heat']['confidence'], data['models']['office']['confidence']['heat'])
|
||||||
|
self.assertGreater(base['heat']['confidence'], 0.8)
|
||||||
# Replayed against what happened, the electrical term removes most of the base error.
|
# Replayed against what happened, the electrical term removes most of the base error.
|
||||||
accuracy = data['forecast_accuracy'][60]
|
accuracy = data['forecast_accuracy'][60]
|
||||||
self.assertLess(accuracy['mae'], 0.06)
|
self.assertLess(accuracy['mae'], 0.06)
|
||||||
@@ -1066,3 +1072,261 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await grown.async_restore_model()
|
await grown.async_restore_model()
|
||||||
self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross')
|
self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross')
|
||||||
self.assertEqual(grown.ventilation['bed'].theta[:-1], vent.theta)
|
self.assertEqual(grown.ventilation['bed'].theta[:-1], vent.theta)
|
||||||
|
|
||||||
|
async def test_unavailable_wind_during_ventilation(self):
|
||||||
|
self.c = self.bedroom_with_window()
|
||||||
|
for _ in range(2):
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||||
|
await self.sample()
|
||||||
|
self.open_window('on', 240)
|
||||||
|
self.states['sensor.wind'].state = 'unavailable'
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind_dir')
|
||||||
|
data = await self.sample()
|
||||||
|
# Learning needs the wind: nothing is fitted, the envelope model stays out too.
|
||||||
|
self.assertEqual(data['sample_result'], 'ventilation')
|
||||||
|
self.assertEqual((self.c.ventilation['bed'].count, self.c.learners['bed'].count), (0, 1))
|
||||||
|
# Predictions keep the open window, as calm air, rather than dropping it.
|
||||||
|
vent = self.c.ventilation['bed']
|
||||||
|
vent.theta[vent.features.index('vent_natural')] = 0.01
|
||||||
|
model = coordinator.ZoneModel(self.c.learners['bed'], None, vent)
|
||||||
|
inputs = self.c._ventilation.zone_inputs('bed', {'wind': None, 'wind_gust': None})
|
||||||
|
self.assertLess(model.ventilation_delta(20.0, {'outside': 5.0, 'ventilation': {**inputs, 'opening': 1.0}}), -0.1)
|
||||||
|
window = data['windows']['Bedroom window']
|
||||||
|
self.assertEqual((window['state'], window['last_known_state'], window['unknown_minutes']), ('open', 'open', None))
|
||||||
|
|
||||||
|
ZONES = [{'key': 'living', 'name': 'Living', 'sensors': ['sensor.room'], 'neighbors': ['bed']},
|
||||||
|
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.bedroom']}]
|
||||||
|
SOURCES = [{'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'living'}]
|
||||||
|
WINDOWS = [{'key': 'w', 'name': 'Bed window', 'sensor': 'binary_sensor.bed_window', 'zone': 'bed',
|
||||||
|
'azimuth': 345}]
|
||||||
|
|
||||||
|
def configured(self, zones=None, sources=None, windows=None):
|
||||||
|
self.entry.data.update(outside_wind_sources=['sensor.wind'], outside_wind_direction_sources=['sensor.wind_dir'])
|
||||||
|
self.entry.subentries = {}
|
||||||
|
for kind, items in (('zone', zones or self.ZONES), ('heat_source', self.SOURCES if sources is None else sources),
|
||||||
|
('window', self.WINDOWS if windows is None else windows)):
|
||||||
|
for i, data in enumerate(items):
|
||||||
|
self.entry.subentries[f'{kind}{i}'] = types.SimpleNamespace(
|
||||||
|
subentry_id=f'{kind}{i}', subentry_type=kind, data=data)
|
||||||
|
return coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||||
|
|
||||||
|
def trained_payload(self):
|
||||||
|
c = self.configured()
|
||||||
|
for key in ('living', 'bed'):
|
||||||
|
base = c.learners[key]
|
||||||
|
base.count, base.on_count, base.off_count, base.theta = 300, 100, 100, [0.004, 0.12, 0.001]
|
||||||
|
living = c.extended['living']
|
||||||
|
living.count, living.on_count, living.off_count = 300, 100, 100
|
||||||
|
living.theta = [0.004, 0.12, 0.001, 0.15, 0.008]
|
||||||
|
vent = c.ventilation['bed']
|
||||||
|
vent.count = 80
|
||||||
|
vent.theta[vent.features.index('vent_natural')] = 0.01
|
||||||
|
vent.theta[vent.features.index('vent_wind_dir')] = 0.0005
|
||||||
|
c._evaluator._days = {'2026-10-27': {'60': {'living': [10, 0.1, 0.2, 0.3, 0.4, 0.05, 10],
|
||||||
|
'bed': [10, 0.1, 0.2, 0.3, 0.4, 0.05, 10]}}}
|
||||||
|
c._heat_sources._baselines['ups'].samples.extend([200.0] * 24)
|
||||||
|
return c._payload('sensor.outside')
|
||||||
|
|
||||||
|
async def restored(self, payload, **config):
|
||||||
|
c = self.configured(**config)
|
||||||
|
c._store.async_load.return_value = payload
|
||||||
|
await c.async_restore_model()
|
||||||
|
return c
|
||||||
|
|
||||||
|
async def test_comfort_settings_keep_every_learned_coefficient(self):
|
||||||
|
payload = self.trained_payload()
|
||||||
|
zones = [{**self.ZONES[0], 'name': 'Lounge', 'comfort_weight': 4, 'target_offset': 1},
|
||||||
|
{**self.ZONES[1], 'comfort_weight': 0.5}]
|
||||||
|
c = await self.restored(payload, zones=zones)
|
||||||
|
self.assertEqual((c.learners['living'].count, c.extended['living'].theta, c.ventilation['bed'].count),
|
||||||
|
(300, [0.004, 0.12, 0.001, 0.15, 0.008], 80))
|
||||||
|
self.assertEqual(list(c._model_changes), [])
|
||||||
|
c._store.async_save.assert_not_called()
|
||||||
|
|
||||||
|
async def test_changed_zone_sensors_recalibrate_only_that_zone(self):
|
||||||
|
payload = self.trained_payload()
|
||||||
|
zones = [{**self.ZONES[0], 'sensors': ['sensor.room', 'sensor.second']}, self.ZONES[1]]
|
||||||
|
c = await self.restored(payload, zones=zones)
|
||||||
|
base = c.learners['living']
|
||||||
|
self.assertEqual((base.theta, base.count, base.p), ([0.004, 0.12, 0.001], 0, coordinator.Learner().p)) # prior uncertainty
|
||||||
|
living = c.extended['living']
|
||||||
|
self.assertEqual((living.count, living.theta[:3], living.coefficient('internal')), (0, [0.004, 0.12, 0.001], 0.0))
|
||||||
|
# The bedroom keeps its model; only its exchange term with the living room restarts.
|
||||||
|
self.assertEqual((c.learners['bed'].count, c.ventilation['bed'].count), (300, 80))
|
||||||
|
self.assertEqual(c.extended['bed'].coefficient('couple:living'), 0.0)
|
||||||
|
self.assertEqual(set(c._evaluator._days['2026-10-27']['60']), {'bed'}) # living measured differently now
|
||||||
|
change = next(ch for ch in c._model_changes if ch.get('zone') == 'Living')
|
||||||
|
self.assertEqual(change['reasons'], ['zone sensors or sensor weights changed'])
|
||||||
|
# Saved at once with the new fingerprints: another restart changes nothing.
|
||||||
|
saved = c._store.async_save.call_args.args[0]
|
||||||
|
again = await self.restored(saved, zones=zones)
|
||||||
|
self.assertEqual(len(again._model_changes), len(c._model_changes))
|
||||||
|
again._store.async_save.assert_not_called()
|
||||||
|
|
||||||
|
async def test_turned_window_resets_only_the_direction_term(self):
|
||||||
|
payload = self.trained_payload()
|
||||||
|
c = await self.restored(payload, windows=[{**self.WINDOWS[0], 'azimuth': 300}])
|
||||||
|
vent = c.ventilation['bed']
|
||||||
|
self.assertEqual((vent.coefficient('vent_natural'), vent.coefficient('vent_wind_dir'), vent.count),
|
||||||
|
(0.01, 0.0, 80))
|
||||||
|
self.assertEqual((c.learners['bed'].count, c.extended['living'].coefficient('internal')), (300, 0.15))
|
||||||
|
self.assertEqual(c._model_changes[-1]['reasons'], ['window orientation changed'])
|
||||||
|
|
||||||
|
async def test_moved_or_replaced_heat_source(self):
|
||||||
|
payload = self.trained_payload()
|
||||||
|
c = await self.restored(payload, sources=[{**self.SOURCES[0], 'zone': 'bed'}])
|
||||||
|
self.assertEqual(c.extended['living'].coefficient('internal'), 0.0)
|
||||||
|
self.assertEqual(c.extended['living'].coefficient('couple:bed'), 0.008) # unrelated term kept
|
||||||
|
self.assertEqual(c.extended['bed'].coefficient('internal'), 0.0) # new there, from the prior
|
||||||
|
self.assertEqual(len(c._heat_sources._baselines['ups'].samples), 24) # same sensor: same load
|
||||||
|
replaced = await self.restored(payload, sources=[{**self.SOURCES[0], 'sensor': 'sensor.ups2'}])
|
||||||
|
self.assertEqual(len(replaced._heat_sources._baselines['ups'].samples), 0)
|
||||||
|
self.assertEqual(replaced.extended['living'].coefficient('internal'), 0.0)
|
||||||
|
self.assertIn('baseline', replaced._model_changes[-1]['recalibrated'])
|
||||||
|
|
||||||
|
async def test_v040_data_without_fingerprints_is_kept_and_then_fingerprinted(self):
|
||||||
|
payload = self.trained_payload()
|
||||||
|
del payload['fingerprints']
|
||||||
|
c = await self.restored(payload)
|
||||||
|
self.assertEqual((c.learners['living'].count, c.extended['living'].coefficient('internal')), (300, 0.15))
|
||||||
|
# Fingerprinted at once, so an edit right after the upgrade is detected.
|
||||||
|
self.assertEqual(c._store.async_save.call_args.args[0]['fingerprints'], c._fingerprints)
|
||||||
|
|
||||||
|
async def test_re_added_zone_with_other_sensors_is_recalibrated(self):
|
||||||
|
payload = self.trained_payload()
|
||||||
|
without = await self.restored(payload, zones=[self.ZONES[1]], sources=[])
|
||||||
|
self.assertIn('living', without._retired)
|
||||||
|
saved = without._payload('sensor.outside')
|
||||||
|
back = await self.restored(saved, zones=[{**self.ZONES[0], 'sensors': ['sensor.other']}, self.ZONES[1]])
|
||||||
|
self.assertEqual((back.learners['living'].theta, back.learners['living'].count), ([0.004, 0.12, 0.001], 0))
|
||||||
|
|
||||||
|
def ready_office(self):
|
||||||
|
"""Office with a UPS and a window, trained enough to optimize."""
|
||||||
|
self.entry.subentries = {
|
||||||
|
'z': types.SimpleNamespace(subentry_id='z', subentry_type='zone', data={
|
||||||
|
'key': 'office', 'name': 'Office', 'sensors': ['sensor.room']}),
|
||||||
|
'h': types.SimpleNamespace(subentry_id='h', subentry_type='heat_source', data={
|
||||||
|
'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'office'}),
|
||||||
|
'w': types.SimpleNamespace(subentry_id='w', subentry_type='window', data={
|
||||||
|
'key': 'w', 'name': 'Window', 'sensor': 'binary_sensor.office_window', 'zone': 'office'})}
|
||||||
|
self.set_state('sensor.ups_power', '200', unit_of_measurement='W')
|
||||||
|
self.set_state('binary_sensor.office_window', 'off')
|
||||||
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||||
|
base = self.c.learners['office']
|
||||||
|
base.count, base.on_count, base.off_count = 200, 100, 100
|
||||||
|
|
||||||
|
async def 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())
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
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']
|
||||||
|
self.assertEqual(sum(sum(v.get('15', {}).values()) for v in stored['verdicts'].values()),
|
||||||
|
0 if follows else 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_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,6 +46,22 @@ class FreshnessTests(unittest.TestCase):
|
|||||||
self.assertEqual(history.observe('sensor.a', later, 300), 300)
|
self.assertEqual(history.observe('sensor.a', later, 300), 300)
|
||||||
self.assertEqual(history.observe('sensor.b', later, 30), 30)
|
self.assertEqual(history.observe('sensor.b', later, 30), 30)
|
||||||
|
|
||||||
|
def test_inputs_differ_ignores_noise_but_not_meaningful_changes(self):
|
||||||
|
before = {('temperature', 'sensor.room'): 20.0, ('power', 'sensor.ups'): 210.0,
|
||||||
|
('wind_direction', 'sensor.dir'): 355.0, ('wind', 'sensor.wind'): 4.0,
|
||||||
|
('window', 'binary_sensor.w'): False, ('thermostat', 'climate.t'): ('heat', 22, None)}
|
||||||
|
noise = {**before, ('temperature', 'sensor.room'): 20.1, ('power', 'sensor.ups'): 240.0,
|
||||||
|
('wind_direction', 'sensor.dir'): 5.0, ('wind', 'sensor.wind'): 4.6}
|
||||||
|
self.assertEqual(quality.inputs_differ(before, noise), []) # 10° across north is small
|
||||||
|
for key, value in ((('temperature', 'sensor.room'), 20.5), (('power', 'sensor.ups'), 610.0),
|
||||||
|
(('wind_direction', 'sensor.dir'), 40.0), (('window', 'binary_sensor.w'), True),
|
||||||
|
(('window', 'binary_sensor.w'), None), (('thermostat', 'climate.t'), ('heat', 21, None)),
|
||||||
|
(('temperature', 'sensor.room'), None)):
|
||||||
|
with self.subTest(key=key, value=value):
|
||||||
|
self.assertEqual(quality.inputs_differ(before, {**before, key: value}), [key])
|
||||||
|
self.assertEqual(quality.inputs_differ(before, {**before, ('power', 'sensor.new'): 5.0}),
|
||||||
|
[('power', 'sensor.new')])
|
||||||
|
|
||||||
def test_freshness_settings_translated(self):
|
def test_freshness_settings_translated(self):
|
||||||
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
|
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
|
||||||
data = json.loads((ROOT / name).read_text())
|
data = json.loads((ROOT / name).read_text())
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ class EvaluationTests(unittest.TestCase):
|
|||||||
self.assertEqual(uncertain.summary(['sensor.room'])[15]['samples'], 2) # k = 3 and 7 only
|
self.assertEqual(uncertain.summary(['sensor.room'])[15]['samples'], 2) # k = 3 and 7 only
|
||||||
self.assertEqual(plant(8).summary(['sensor.other'])[15]['samples'], 0)
|
self.assertEqual(plant(8).summary(['sensor.other'])[15]['samples'], 0)
|
||||||
|
|
||||||
|
def test_rmse_effective_samples_and_quality(self):
|
||||||
|
stats = plant(10, model_theta=(0.004, 0.0, 0.0005), lag_at=lambda k: 1.0).summary(['sensor.room'])
|
||||||
|
s15 = stats[15]
|
||||||
|
self.assertGreaterEqual(s15['rmse'], s15['mae'])
|
||||||
|
# 7 overlapping 15-minute forecasts started 5 minutes apart: ~2.3 independent ones.
|
||||||
|
self.assertEqual((s15['samples'], s15['effective_samples'], s15['quality']), (7, 2.3, 'insufficient'))
|
||||||
|
self.assertEqual(ev.quality(5), 'indicative')
|
||||||
|
self.assertEqual(ev.quality(30), 'ok')
|
||||||
|
self.assertEqual(ev.effective_samples(120, 60, zones=2), 5.0)
|
||||||
|
|
||||||
def test_window_keeps_seven_days(self):
|
def test_window_keeps_seven_days(self):
|
||||||
evaluator = plant(10)
|
evaluator = plant(10)
|
||||||
before = evaluator.summary(['sensor.room'])[15]['samples']
|
before = evaluator.summary(['sensor.room'])[15]['samples']
|
||||||
@@ -108,12 +118,91 @@ class EvaluationTests(unittest.TestCase):
|
|||||||
'sensor.c': [1, float('nan'), 0, 0], 'sensor.d': [2, 0.1, 0.2, 0.4],
|
'sensor.c': [1, float('nan'), 0, 0], 'sensor.d': [2, 0.1, 0.2, 0.4],
|
||||||
'sensor.e': [2, 0.1, 0.2, 0.4, 0.3], 'sensor.f': [2, 0.1, 0.2, 0.4, -1]}}}})
|
'sensor.e': [2, 0.1, 0.2, 0.4, 0.3], 'sensor.f': [2, 0.1, 0.2, 0.4, -1]}}}})
|
||||||
# A 0.3 bucket has no base-model column; its model was the base model then.
|
# A 0.3 bucket has no base-model column; its model was the base model then.
|
||||||
|
# Neither 0.3 nor 0.4 buckets have squared errors: they do not count for RMSE.
|
||||||
self.assertEqual(restored.as_dict(), {'days': {day: {'15': {
|
self.assertEqual(restored.as_dict(), {'days': {day: {'15': {
|
||||||
'sensor.d': [2, 0.1, 0.2, 0.4, 0.2], 'sensor.e': [2, 0.1, 0.2, 0.4, 0.3]}}}})
|
'sensor.d': [2, 0.1, 0.2, 0.4, 0.2, 0.0, 0], 'sensor.e': [2, 0.1, 0.2, 0.4, 0.3, 0.0, 0]}}}})
|
||||||
|
self.assertIsNone(restored.summary(['sensor.d'])[15]['rmse'])
|
||||||
for garbage in (None, [], {'days': []}):
|
for garbage in (None, [], {'days': []}):
|
||||||
restored.load(garbage)
|
restored.load(garbage)
|
||||||
self.assertEqual(restored.as_dict(), {'days': {}})
|
self.assertEqual(restored.as_dict(), {'days': {}})
|
||||||
|
|
||||||
|
|
||||||
|
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['counterfactual'], s['unverifiable']), (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]['unverifiable'], stats[60]['unverifiable']), (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]['missing'], 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]['missing']), (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)
|
||||||
|
for garbage in (None, [], {'days': {'x': {}}, 'verdicts': {'2026-10-27': {'15': {'bogus': 3}}}}):
|
||||||
|
self.assertEqual(ev.OperationalForecastLedger(garbage).as_dict(), {'days': {}, 'verdicts': {}})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -140,6 +140,31 @@ class ModelTests(unittest.TestCase):
|
|||||||
self.assertEqual(restored.count, 30000)
|
self.assertEqual(restored.count, 30000)
|
||||||
self.assertEqual(restored.p, learn.p)
|
self.assertEqual(restored.p, learn.p)
|
||||||
|
|
||||||
|
def test_confidence_reflects_excitation_not_sample_count(self):
|
||||||
|
# Two weeks of samples, relay always off, a steady 15 degC difference to outside.
|
||||||
|
steady = m.Learner()
|
||||||
|
for _ in range(4000):
|
||||||
|
steady.learn(20.0, 20.0 + 0.003 * -15 + 0.001, 5.0, 0.0, command=False)
|
||||||
|
confidence = steady.confidence()
|
||||||
|
self.assertEqual(steady.count, 4000)
|
||||||
|
self.assertEqual(confidence['heat'], 0) # never exercised
|
||||||
|
self.assertLess(confidence['loss'], 0.8) # not separable from bias
|
||||||
|
self.assertLess(confidence['bias'], 0.8)
|
||||||
|
# Fewer samples, with the relay cycling and a cold and a mild spell each day.
|
||||||
|
varied = m.Learner()
|
||||||
|
t, lag = 20.0, 0.0
|
||||||
|
for step in range(1200):
|
||||||
|
heater = int(step % 96 < 35)
|
||||||
|
lag = lag + m.HEAT_LAG * (heater - lag)
|
||||||
|
outside = -2 + 14 * ((step // 144) % 2)
|
||||||
|
value = t + 0.003 * (outside - t) + 0.055 * lag + 0.001
|
||||||
|
varied.learn(t, value, outside, lag, command=bool(heater))
|
||||||
|
t = value
|
||||||
|
self.assertTrue(all(value > 0.8 for value in varied.confidence().values()), varied.confidence())
|
||||||
|
# Recalibration starts confidence over.
|
||||||
|
varied.recalibrate()
|
||||||
|
self.assertEqual(set(varied.confidence().values()), {0})
|
||||||
|
|
||||||
def test_restore_tolerates_round_off_and_bounds_wound_up_covariance(self):
|
def test_restore_tolerates_round_off_and_bounds_wound_up_covariance(self):
|
||||||
# Shape of a v0.2.1 model after a summer: huge heat variance, tiny asymmetry.
|
# Shape of a v0.2.1 model after a summer: huge heat variance, tiny asymmetry.
|
||||||
data = m.Learner(count=40000, off_count=40000).as_dict()
|
data = m.Learner(count=40000, off_count=40000).as_dict()
|
||||||
|
|||||||
@@ -145,3 +145,111 @@ class TrackerTests(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownStateTests(unittest.TestCase):
|
||||||
|
"""OPEN, CLOSED and UNKNOWN are three states: unknown never means closed."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tracker = wm.VentilationTracker(wm.build_windows([(window('w'), None)], ['bed']), recovery_minutes=20)
|
||||||
|
self.t = 1_000_000.0
|
||||||
|
|
||||||
|
def poll(self, state, changed_ago=None, temp=20.0):
|
||||||
|
changed = self.t - changed_ago if changed_ago is not None else self.t - 100_000
|
||||||
|
self.tracker.update(self.t, {'w': (state, changed)}, {'bed': temp}, WEATHER)
|
||||||
|
inputs = self.tracker.zone_inputs('bed', WEATHER)
|
||||||
|
forecast = self.tracker.forecast('bed', 6, 15, WEATHER, self.t)
|
||||||
|
self.t += 300
|
||||||
|
return inputs, forecast, dict(self.tracker.window_state['w'])
|
||||||
|
|
||||||
|
def open_for_a_while(self):
|
||||||
|
self.poll('off')
|
||||||
|
self.poll('off', temp=21.0)
|
||||||
|
self.poll('on', changed_ago=100, temp=20.4)
|
||||||
|
self.poll('on', changed_ago=400, temp=20.0)
|
||||||
|
|
||||||
|
def test_open_to_unknown_keeps_the_event_and_assumes_still_open(self):
|
||||||
|
self.open_for_a_while()
|
||||||
|
inputs, forecast, state = self.poll('unavailable', changed_ago=60, temp=19.8)
|
||||||
|
self.assertEqual(len(self.tracker.events), 0) # no confirmed closing without evidence
|
||||||
|
self.assertIn('bed', self.tracker._open_event)
|
||||||
|
self.assertEqual((inputs['known'], inputs['affected'], inputs['recovery']), (False, True, 0.0))
|
||||||
|
self.assertEqual(forecast[0]['opening'], 1.0) # last seen open: still treated as open
|
||||||
|
self.assertEqual((state['open'], state['last_known_open']), (None, True))
|
||||||
|
self.assertIsNotNone(state['unknown_since'])
|
||||||
|
|
||||||
|
def test_unknown_to_open_continues_the_same_event(self):
|
||||||
|
self.open_for_a_while()
|
||||||
|
self.poll('unavailable', changed_ago=60)
|
||||||
|
self.poll('unavailable', changed_ago=360)
|
||||||
|
inputs, _, state = self.poll('on', changed_ago=30) # sensor back, window still open
|
||||||
|
self.assertEqual((len(self.tracker.events), state['unknown_since']), (0, None))
|
||||||
|
self.assertFalse(inputs['known']) # the interval itself was unknown
|
||||||
|
inputs, _, _ = self.poll('on', changed_ago=330)
|
||||||
|
self.assertEqual((inputs['known'], inputs['opening']), (True, 1.0))
|
||||||
|
self.poll('off', changed_ago=120, temp=20.2) # confirmed close
|
||||||
|
event = self.tracker.events[-1]
|
||||||
|
self.assertEqual((event['closed_confirmed'], event['sensor_unknown_minutes']), (True, 10.0)) # two polls
|
||||||
|
self.assertIsNotNone(event['duration_minutes'])
|
||||||
|
|
||||||
|
def test_unknown_to_closed_records_an_unconfirmed_close(self):
|
||||||
|
self.open_for_a_while()
|
||||||
|
self.poll('unavailable', changed_ago=60)
|
||||||
|
inputs, forecast, state = self.poll('off', changed_ago=20, temp=20.1)
|
||||||
|
event = self.tracker.events[-1]
|
||||||
|
self.assertEqual((event['closed'], event['closed_confirmed'], event['duration_minutes']), (None, False, None))
|
||||||
|
self.assertGreater(event['duration_max_minutes'], 0)
|
||||||
|
self.assertEqual((state['open'], state['last_known_open'], state['unknown_since']), (False, False, None))
|
||||||
|
# Recovery is assumed from when the sensor reported closed, but its timing is
|
||||||
|
# unknown, so neither the ventilation nor the envelope model learns from it.
|
||||||
|
self.assertGreater(inputs['recovery'], 0.9)
|
||||||
|
self.assertEqual((inputs['known'], inputs['affected'], inputs['opening']), (False, True, None))
|
||||||
|
self.assertEqual(forecast[0]['opening'], 0.0)
|
||||||
|
self.assertGreater(forecast[0]['recovery'], 0.0)
|
||||||
|
|
||||||
|
def test_closed_to_unknown_assumes_closed_but_blocks_learning(self):
|
||||||
|
self.poll('off')
|
||||||
|
self.poll('off')
|
||||||
|
inputs, forecast, state = self.poll('unavailable', changed_ago=120)
|
||||||
|
self.assertEqual((inputs['known'], inputs['affected']), (False, True))
|
||||||
|
self.assertEqual(forecast[0]['opening'], 0.0)
|
||||||
|
self.assertEqual((state['open'], state['last_known_open']), (None, False))
|
||||||
|
self.assertEqual(len(self.tracker.events), 0)
|
||||||
|
|
||||||
|
def test_unknown_to_open_after_a_closed_window_starts_an_unconfirmed_event(self):
|
||||||
|
self.poll('off')
|
||||||
|
self.poll('off', temp=21.0)
|
||||||
|
self.poll('unavailable', changed_ago=60, temp=20.6)
|
||||||
|
self.poll('on', changed_ago=30, temp=20.0) # opened at some point during the outage
|
||||||
|
event = self.tracker._open_event['bed']
|
||||||
|
self.assertEqual((event['opened_confirmed'], event['temperature_before']), (False, None))
|
||||||
|
self.poll('on', changed_ago=330, temp=19.8)
|
||||||
|
inputs, _, _ = self.poll('off', changed_ago=100, temp=19.9)
|
||||||
|
self.assertTrue(self.tracker.events[-1]['closed_confirmed'])
|
||||||
|
# Without the temperature before ventilation, recovery cannot be learned.
|
||||||
|
self.assertFalse(inputs['known'])
|
||||||
|
|
||||||
|
def test_recovery_after_prolonged_unavailability(self):
|
||||||
|
self.open_for_a_while()
|
||||||
|
for _ in range(72): # six hours without the sensor
|
||||||
|
self.poll('unavailable', changed_ago=60)
|
||||||
|
self.assertEqual(len(self.tracker.events), 0)
|
||||||
|
inputs, _, _ = self.poll('off', changed_ago=60)
|
||||||
|
event = self.tracker.events[-1]
|
||||||
|
self.assertFalse(event['closed_confirmed'])
|
||||||
|
self.assertGreaterEqual(event['duration_max_minutes'], 360)
|
||||||
|
self.assertEqual(event['sensor_unknown_minutes'], 360.0)
|
||||||
|
for _ in range(12): # recovery decays, learning resumes
|
||||||
|
inputs, _, _ = self.poll('off')
|
||||||
|
self.assertEqual((inputs['known'], inputs['affected'], inputs['recovery']), (True, False, 0.0))
|
||||||
|
|
||||||
|
def test_multiple_windows_with_one_unknown(self):
|
||||||
|
tracker = wm.VentilationTracker(wm.build_windows([(window('a'), None), (window('b'), None)], ['bed']))
|
||||||
|
for t, a, b in ((0, 'off', 'off'), (300, 'on', 'off'), (600, 'on', 'unavailable'),
|
||||||
|
(900, 'off', 'unavailable')):
|
||||||
|
tracker.update(float(t), {'a': (a, t - 60.0), 'b': (b, t - 60.0)}, {'bed': 20.0}, WEATHER)
|
||||||
|
# Window a closed (confirmed); b was last seen closed: the event ends, confirmed,
|
||||||
|
# but the zone stays out of learning while b's state is unknown.
|
||||||
|
self.assertTrue(tracker.events[-1]['closed_confirmed'])
|
||||||
|
inputs = tracker.zone_inputs('bed', WEATHER)
|
||||||
|
self.assertEqual((inputs['known'], inputs['unknown_windows']), (False, ['B']))
|
||||||
Reference in new issue
Block a user