Compare commits
46
Commits
43f30dab2b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d027f367a | ||
|
|
c4035951df | ||
|
|
0b82a8e26c | ||
|
|
2cfba024ff | ||
|
|
13cb972566 | ||
|
|
49565d610a | ||
|
|
28ff953ddd | ||
|
|
87437cbd48 | ||
|
|
35370958fc | ||
|
|
8eef04c385 | ||
|
|
4911b32c5e | ||
|
|
a44163a57d | ||
|
|
26c415fa6c | ||
|
|
d73e32a4b9 | ||
|
|
099c63cd02 | ||
|
|
9bb64e79ca | ||
|
|
2bddc74d37 | ||
|
|
17dbe76223 | ||
|
|
dc92e2bac5 | ||
|
|
e62798dc4f | ||
|
|
b1577a0c78 | ||
|
|
8d197fabcd | ||
|
|
ed8f9fe2e2 | ||
|
|
00e70c612b | ||
|
|
46587af8a3 | ||
|
|
220ac570d2 | ||
|
|
e59c08f586 | ||
|
|
d2bd398a0f | ||
|
|
3c949333bd | ||
|
|
3afb6688a5 | ||
|
|
43915ba810 | ||
|
|
ace6d0061b | ||
|
|
ec3a0738fe | ||
|
|
c79fdbd71b | ||
|
|
3076beebfb | ||
|
|
cd8b65ac2f | ||
|
|
67fbda79a6 | ||
|
|
4daba5028b | ||
|
|
c0e4de832b | ||
|
|
955c679f5e | ||
|
|
af0a564279 | ||
|
|
df69dc9645 | ||
|
|
30f78fb6a9 | ||
|
|
2a0a94c640 | ||
|
|
c3646ba876 | ||
|
|
387929b01b |
No files matched your search
@@ -6,6 +6,11 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: bs-node
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Minimum supported HA (hacs.json) and the newest published test fixtures.
|
||||
requirements: [requirements-test.txt, requirements-test-latest.txt]
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
# uv supplies standalone Python builds for the Debian job container.
|
||||
@@ -17,7 +22,7 @@ jobs:
|
||||
- name: Create Python environment
|
||||
run: uv venv --python 3.13 --seed .venv
|
||||
- name: Install test dependencies
|
||||
run: uv pip install --python .venv/bin/python -r requirements-test.txt
|
||||
run: uv pip install --python .venv/bin/python -r ${{ matrix.requirements }}
|
||||
- name: Run checks
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
|
||||
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.venv/
|
||||
.DS_Store
|
||||
@@ -83,6 +83,7 @@ Favor the standard library and small dependencies. Heavy compute must not block
|
||||
- Validate model identifiability, parameter stability, outlier rejection, missing observations, power outages, heater transitions within a sampling window, and multiple sensors in the same physical room.
|
||||
- Weather covariates beyond temperature should be added only if they improve held-out performance; avoid treating `feels_like` as physical outdoor air temperature. Forecast values and observed values must be distinguished.
|
||||
- One switch heats all rooms: optimization can trade off room comfort, but cannot independently heat a cold bathroom without influencing warmer rooms.
|
||||
- Keep the base 3-parameter model as fallback and comparison. New model terms start at a zero prior, are physically bounded, report confidence, learn only when their inputs are known, and must show `improvement_vs_base` in walk-forward replays before being relied on (see docs/MODEL.md).
|
||||
|
||||
## Repository conventions
|
||||
|
||||
@@ -90,22 +91,33 @@ Expected structure (adapt only through deliberate refactoring):
|
||||
|
||||
```text
|
||||
custom_components/heating_mpc/
|
||||
__init__.py
|
||||
__init__.py setup, unload, migration
|
||||
manifest.json
|
||||
config_flow.py
|
||||
config_flow.py config, options and subentry flows (zones, heat sources, windows)
|
||||
const.py
|
||||
coordinator.py
|
||||
physics.py
|
||||
weather_sources.py
|
||||
coordinator.py HA state acquisition, learning schedule, diagnostics
|
||||
data_quality.py freshness checks
|
||||
weather_sources.py source priorities and unit normalization
|
||||
zones.py zone parsing and sensor aggregation (pure)
|
||||
heat_sources.py electrical heat: baseline, response, overlap (pure)
|
||||
windows.py windows, wind geometry, ventilation, events (pure)
|
||||
learning.py named-feature RLS (pure)
|
||||
physics.py base model, radiator lag, optimizer (pure)
|
||||
zone_model.py layered zone model, compiled forecast step (pure)
|
||||
evaluation.py walk-forward forecast scoring (pure)
|
||||
sensor.py
|
||||
binary_sensor.py
|
||||
strings.json
|
||||
translations/{en,ro}.json
|
||||
brand/ integration icons (scripts/make_brand_icons.py)
|
||||
docs/MODEL.md model equations, units, learning conditions, validation
|
||||
hacs.json
|
||||
tests/
|
||||
tests/ offline tests; tests/ha for the HA Core harness
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
AGENTS.md
|
||||
ROADMAP.md
|
||||
AUDIT.md
|
||||
```
|
||||
|
||||
- Keep pure model/optimizer logic importable and testable without Home Assistant installed.
|
||||
|
||||
@@ -135,3 +135,335 @@ Python 3.13 virtual environment, then runs the same dependency and test checks.
|
||||
GitHub cache services are disabled and no Gitea token is supplied to setup-uv.
|
||||
The runner label and integration runtime are unchanged. Remote execution of the
|
||||
corrected workflow remains to be verified.
|
||||
|
||||
|
||||
## Follow-up: second source audit — lag, learner stability, options
|
||||
|
||||
Scope as above: repository code, offline tests and the HA Core 2025.12.0 suite.
|
||||
No HAOS, physical relay, thermostat or deployment testing was performed. The
|
||||
manifest remains 0.2.1 and no actuator code was added.
|
||||
|
||||
| Area | Finding | Repair / evidence |
|
||||
|---|---|---|
|
||||
| Learning and prediction, high | The radiator-lag proxy was reset to the new relay command at every relay change and after any skipped interval: it read 1.0 straight after ON and 0.0 straight after OFF, so the lag model was effectively disabled around every boiler cycle. On a synthetic lagged plant cycled by a hysteresis thermostat, the original code learned a heat gain 34 % low (0.079 vs 0.12) and a loss coefficient 43 % low. The optimizer also started each post-transition plan from the wrong lag. | Lag is tracked at every poll with a valid relay state, independently of room sensors. One change between polls is integrated at its `last_changed` time. Ambiguous cases (on/off round trip between polls, relay outage, restart, clock jump) restart from time-in-state with an explicit error half-width; learning waits until it is at most 0.1 (about 32 minutes after an HA restart). The same synthetic test now learns 0.1195 / 0.00389 (true 0.12 / 0.004). |
|
||||
| Learning and persistence, high | RLS covariance windup: with forgetting 0.999 and no heat excitation (summer, long OFF periods) the heat variance grew by 1/0.999 per sample without limit, about 8.7×10¹² after 104 simulated OFF days. The first ON samples afterwards could swing coefficients to their bounds, and accumulated asymmetry (9×10⁻⁹ there) approached the absolute 10⁻⁸ restore tolerance, beyond which a restart would silently reset the model. | Covariance is symmetrized and its diagonal bounded by the prior after every update; row/column scaling preserves positive definiteness. Restore uses a relative round-off tolerance and applies the same bound, so wound-up older models load rather than reset. |
|
||||
| Configuration, medium | Optional weather source fields used the current selection as a form default. A cleared field omitted from the submission re-inserted the old sources, and an options key absent from the submission fell back to the original setup data, so optional providers could not be removed in the UI. | Suggested values pre-fill the form instead; omitted optional lists are saved as empty and override older entry data. Covered by an HA Core options-flow test. |
|
||||
| Prediction availability, medium | A finished plan was discarded when any watched State object changed during the calculation, including diagnostic-only weather sensors and the thermostat's own `current_temperature`/`hvac_action`. The proposed-heat entity could go unavailable for five minutes without cause. | Compare only values the prediction uses: room and outdoor-temperature states and units, thermostat mode and target, relay state and last change. |
|
||||
| Lifecycle, low | The coordinator relied on HA's config-entry ContextVar; HA reports this, though it does not enforce it for custom integrations. | Pass `config_entry` explicitly. |
|
||||
| Tests, medium | The shadow-only rule was checked only at runtime on exercised paths. | Static AST tripwire over the integration source: no service calls or registration, no actuator method names or service-name strings, and only sensor/binary_sensor platforms. |
|
||||
|
||||
New Status attributes: `heat_lag`, `heat_lag_uncertainty` and `sample_result`
|
||||
(`first_sample`, `learned`, `partly_rejected`, `interval_out_of_range`,
|
||||
`relay_changed`, `outside_source_changed`, `lag_uncertain`). They explain why the
|
||||
sample counters are or are not increasing.
|
||||
|
||||
**Compatibility.** Persisted payload schema stays 2; existing models load and get
|
||||
the covariance bound on load. Coefficients learned before this fix were fitted
|
||||
with the wrong lag. With forgetting 0.999 they should re-converge over roughly
|
||||
three to four days of heating data, but sample counts are kept, so `ready` does
|
||||
not reflect this. Treat earlier forecasts as uncalibrated. For a clean model,
|
||||
delete and re-add the integration (this deletes the learned data).
|
||||
|
||||
**Remaining gaps found in this pass**, in addition to those above:
|
||||
|
||||
1. Relay changes are still inferred from `last_changed` at each poll. An odd
|
||||
number of changes greater than one between polls is treated as one change.
|
||||
A read-only state-change listener for the relay would remove this.
|
||||
2. The lag estimate is not persisted, so an options reload restarts it from
|
||||
time-in-state (exact for a long-steady relay, approximate otherwise).
|
||||
3. Learners of rooms removed in Options are dropped at the next save; re-adding a
|
||||
room, or reverting an accidental relay change, starts fresh.
|
||||
4. Status attributes change every poll (`age_seconds`, MAE), so Recorder stores a
|
||||
new attribute row every five minutes. Consider `_unrecorded_attributes`.
|
||||
5. The HA Core suite pins 2025.12.0; current HA releases are untested.
|
||||
|
||||
**Verification.** `sh scripts/check.sh` in a Python 3.13 environment with
|
||||
`requirements-test.txt`: pip check, 56 offline tests, 16 HA Core tests,
|
||||
compilation, package/translation validation and `git diff --check` all pass.
|
||||
`python3 -m unittest discover -s tests` also passes on Python 3.9.6. The new
|
||||
synthetic-plant and windup tests were also run against the original code to confirm
|
||||
they fail there.
|
||||
|
||||
|
||||
## 0.2.2 release follow-up
|
||||
|
||||
Scope as above; HA Core tests now run on 2025.12.0 and 2026.2.3 (Python 3.13).
|
||||
No HAOS, device or deployment testing. Still no actuator code.
|
||||
|
||||
| Area | Finding | Repair / evidence |
|
||||
|---|---|---|
|
||||
| Learning, medium | Polling saw only the relay's current state and last change. Two or more changes between polls (an on/off round trip) left the lag unknown and paused learning for about 30 minutes each time. | Read-only `async_track_state_change_event` listener records each change; all changes between polls are integrated. HA delivers these events one loop iteration late, so the current state stays authoritative and fills in a change not yet recorded. HA Core test: a round trip keeps the error bound below 0.05 (0.46 with the listener disabled). |
|
||||
| Lifecycle, medium | HA runs unload callbacks for `ConfigEntryNotReady`/`ConfigEntryError` but not for unexpected exceptions during setup, so anything registered with `async_on_unload` before such an error would leak. | Setup removes the listener and coordinator itself on any failure and registers the listener for unload only after success. Offline test with a failing platform setup. |
|
||||
| Persistence, medium | Saving wrote only configured rooms, so removing a room in Options (even briefly) discarded its model. | Validated models of removed rooms are kept (at most 24) under an optional `retired_learners` key and restored on re-adding. HA Core options round-trip test. Payload `schema_version` stays 2; v0.2.1 was checked by hand to read the new payload. |
|
||||
| Diagnostics, low | Choosing freshness limits required knowing each sensor's report interval, which HA does not show. | `max_age_seconds_24h` per source (largest age at polls, hourly buckets, reset on reload). |
|
||||
| Resources, low | `input_quality` changes every poll (report ages), creating a new Recorder attribute row every five minutes. | `input_quality` and `weather_comparison` marked unrecorded; HA Core test checks `state_info`. |
|
||||
| Compatibility | Only HA 2025.12.0 was tested. | Added the newest published fixtures (HA 2026.2.3) as a CI matrix entry; all checks pass locally with no deprecation reports for this integration. Newer HA releases remain untested. |
|
||||
|
||||
Not changed, by decision: a stale room still pauses learning and forecasts for all
|
||||
rooms (needs a policy decision); the lag estimate is not persisted (an HA restart
|
||||
resets the relay's `last_changed`, so continuity cannot be verified anyway).
|
||||
|
||||
**Verification.** `sh scripts/check.sh` passes in both test environments (pip
|
||||
check, 65 offline tests, 18 HA Core tests, compilation, package/translation/changelog
|
||||
checks, `git diff --check`). The offline tests also pass on Python 3.9.6.
|
||||
|
||||
|
||||
## 0.2.3 — stale-room policy
|
||||
|
||||
Decided by the user: pausing everything because one room sensor is unusable is
|
||||
not acceptable for future control. An unusable room is now left out and the other
|
||||
rooms' weights re-normalized; an opt-in checkbox keeps a stale sensor's last value
|
||||
for sensors that report only on change. Two safeguards were added: the keep-last
|
||||
option stops after 24 hours without a report, and Status exposes `excluded_rooms`
|
||||
and `weight_coverage`, so a forecast based on a small share of the comfort weight
|
||||
is visible.
|
||||
|
||||
Open for the control design: a minimum `weight_coverage` below which the MPC must
|
||||
not decide, and the outdoor-temperature outage policy (still pauses). Simulated
|
||||
two-hour bedroom outage: the other room kept learning (7 → 25 samples, previously
|
||||
frozen at 7) and the bedroom resumed one poll after reporting again.
|
||||
|
||||
**Verification.** `sh scripts/check.sh` passes for HA 2025.12.0 and 2026.2.3 (69
|
||||
offline tests, 18 HA Core tests). The offline tests also pass on Python 3.9.6.
|
||||
|
||||
|
||||
## 0.3.0 — forecast evaluation
|
||||
|
||||
Scope: repository code and tests (HA 2025.12.0 and 2026.2.3 harness); no HAOS.
|
||||
|
||||
Design choices and why:
|
||||
|
||||
- **Score the thermal model, not the MPC plan.** Generic Thermostat controls the
|
||||
relay, so the plan is never executed and cannot be checked. Each forecast is
|
||||
replayed with the radiator-lag values that the relay's actual history produced,
|
||||
which tests the model under known heat input.
|
||||
- **Out of sample.** Each replay uses the parameters stored at the forecast start;
|
||||
later learning never influences the forecast it is scored on.
|
||||
- **Only known inputs.** Spans with irregular polls, an uncertain lag estimate, or a
|
||||
missing relay/outdoor/room reading are not scored, and a missing shared input
|
||||
restarts the window.
|
||||
- **Baseline.** Persistence (no change) is scored on exactly the same spans; `skill`
|
||||
reports the share of its error the model removes.
|
||||
- **Known limits.** The outdoor temperature is held at its start value (as the MPC
|
||||
does); consecutive forecasts overlap, so samples are not independent; the 7-day
|
||||
window includes learning-phase forecasts.
|
||||
|
||||
Evidence: on the synthetic lagged plant (5 simulated days, thermostat cycling,
|
||||
outdoor ±5 °C daily swing) the 60-minute error is 0.03 °C against 0.39 °C for
|
||||
persistence (skill 0.92); the 120-minute error grows to 0.09 °C from the
|
||||
constant-outdoor assumption. A model matching the plant exactly scores zero error;
|
||||
a model without heat gain shows the expected negative bias. These are checks of
|
||||
the evaluation itself, not of real-world accuracy.
|
||||
|
||||
Also in this release: per-room rejected-sample counters and per-outcome poll
|
||||
counters (Milestone 0 "failed fitting counters"), a rollback contract test against
|
||||
the 0.2.1 reader, ruff bug-finding lint, and removal of tracked `.DS_Store` files.
|
||||
|
||||
User feedback during testing: after a reset the Status entity only said
|
||||
`learning`, and with Developer Tools moved under Settings in recent HA versions the
|
||||
attributes were hard to find, so learning activity was invisible for the first ~30
|
||||
minutes. Two sensors now show it directly: Learning Progress (%) and Last Sample
|
||||
(an enum with translated states, including *Waiting for radiators to settle*). A
|
||||
poll with missing shared inputs now reports `waiting_for_inputs` instead of nothing.
|
||||
|
||||
**Verification.** `sh scripts/check.sh` passes in both environments: lint, 80
|
||||
offline tests, 20 HA Core tests, compilation, package checks. The offline tests
|
||||
also pass on Python 3.9.6.
|
||||
|
||||
|
||||
## 0.4.0 — zones, electrical heat, ventilation, wind, inter-zone exchange
|
||||
|
||||
Scope: repository code and tests (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.
|
||||
|
||||
Design decisions and why:
|
||||
|
||||
- **Zones as config subentries.** HA's native pattern for several items under one
|
||||
integration gives per-item add/edit/delete and removes a zone's entities with it.
|
||||
Config entry version 3; migrating from 2 keeps each room's sensor ID as the zone
|
||||
key so learned models, entities and statistics carry over. Rollback needs the
|
||||
backup.
|
||||
- **Layered model per zone.** The 0.3 base model is kept unchanged as fallback and
|
||||
as the comparison every forecast is scored against; the extended and ventilation
|
||||
layers learn only from intervals where their inputs are known, start at 0 with
|
||||
priors sized in "typical samples", and report confidence. This follows the
|
||||
roadmap rule to retain the simpler model and justify complexity by measured
|
||||
improvement.
|
||||
- **Ventilation separated from envelope loss.** Open, unknown or recovering windows
|
||||
exclude a zone from base and extended learning; a separate learner fits the
|
||||
residual. On the synthetic week, not doing this produced a −0.013 °C/5 min bias
|
||||
and an 11 % inflated boiler coefficient.
|
||||
- **Baseline-centred electrical heat.** The persistent load is already in the bias
|
||||
term; only variation above a 24-hour 20th percentile is modeled, after a
|
||||
first-order response driven by the power held over each interval.
|
||||
- **Bounded directional wind term.** |vent_wind_dir| ≤ vent_wind, so leeward
|
||||
windows still ventilate; angles use wrap-around differences.
|
||||
- **Forecast assumptions** are single, conservative scenarios (electrical heat
|
||||
fades with a half-life, open windows close after a set time) and are
|
||||
configurable; no uncertainty band yet.
|
||||
- **Exact compiled step** for the plan search: all terms are affine in temperatures
|
||||
and lag, so coefficients are read off the real model (verified to 1e-12); the
|
||||
180-minute search is 4.5× faster.
|
||||
|
||||
Evidence (synthetic apartments, 60-minute error, full vs base vs no change): gaming
|
||||
office 0.031 / 0.182 / 0.40 °C; ventilation bedroom 0.026 / 0.218 / 0.52 °C; two
|
||||
coupled rooms 0.024 / 0.037 / 0.29 °C. Coefficients recovered: electrical, boiler
|
||||
and loss within 10 %; combined natural+wind ventilation within 15 %; recovery within
|
||||
15 %; wind direction within 30 %. Coupling is found but correctly reported as not
|
||||
identified when the rooms' difference is steady.
|
||||
|
||||
Not done, by decision: learned response time constants (configurable; compare
|
||||
candidates out of sample next), a wall-mass state, forecast scenarios/uncertainty,
|
||||
weather forecasts. Learners of removed zones keep only the base model.
|
||||
|
||||
**Verification.** `sh scripts/check.sh` passes in both environments (lint, 125
|
||||
offline tests, 26 HA Core tests, compilation, package/translation/changelog checks).
|
||||
The offline tests also pass on Python 3.9.6.
|
||||
|
||||
## 0.4.1 — stabilization after 0.4.0 — 2026-10-27
|
||||
|
||||
Scope: correctness of 0.4.0, no new features. Repository code
|
||||
and tests only (HA Core 2025.12.0 and 2026.2.3 harness); no HAOS, device or
|
||||
deployment testing. Still no actuator code; the static and runtime service-call
|
||||
guards pass.
|
||||
|
||||
Confirmed issues and fixes:
|
||||
|
||||
1. **Unknown window states read as closed** (2bddc74). Reproduced: an open window
|
||||
whose sensor became unavailable ended the ventilation event, started recovery
|
||||
at full weight and let the forecast assume a closed window. Windows are now
|
||||
tri-state with a remembered last known state: last seen open → possibly open
|
||||
(base and extended layers do not learn, the forecast assumes open); a close
|
||||
while unknown is logged as unconfirmed with its longest possible duration;
|
||||
recovery is learned only after a confirmed close. Seven transition tests
|
||||
(open/closed/unknown in every order, restart while unknown, polarity).
|
||||
2. **Learned coefficients reused after incompatible changes** (099c63c, 8eef04c).
|
||||
By construction: changing a zone's sensors or weight ratio, a heat source, a
|
||||
window or the recovery time kept every coefficient. New
|
||||
`compatibility.py` fingerprints each component's configuration; on restore
|
||||
only changed components are reset (a measurement change recalibrates the zone:
|
||||
coefficients kept as a starting point, uncertainty, counts and accuracy
|
||||
statistics reset). Comfort weight, target offset and names never invalidate.
|
||||
Heat-source baseline samples are dropped when the effective power's definition
|
||||
changes. 0.4.0 data has no fingerprints, is assumed compatible, and is
|
||||
fingerprinted at the first start. Resets are logged in `model_changes`.
|
||||
3. **Plans published from changed inputs** (d73e32a). The pre/post-optimization
|
||||
check covered only temperatures, relay and thermostat, and an overtaken update
|
||||
could still publish. The input snapshot now covers zone and outdoor
|
||||
temperatures, electrical power, wind, windows, relay and thermostat, with
|
||||
tolerances so sensor noise does not discard every plan; a run counter keeps an
|
||||
older run from overwriting a newer result; shutdown is checked after saving
|
||||
and after the worker; a per-entry storage lock serializes save, restore and
|
||||
delete (also fixing a delete-during-save race that could recreate the file).
|
||||
4. **Model accuracy reported as forecast accuracy** (26c415f). The Forecast Error
|
||||
sensors replayed the model with the actual future relay, electrical heat and
|
||||
windows. They are now **Model Error** (unique IDs unchanged) with RMSE,
|
||||
effective samples and a quality label, and their state stays unknown below 5
|
||||
effective samples. A new **Forecast Error** sensor scores published forecasts
|
||||
with the plan and assumptions of the moment, only when the relay followed the
|
||||
plan (≥ 12 of every 15 minutes in the planned state); otherwise the forecast is
|
||||
counted as counterfactual, unverifiable or missing, never as error.
|
||||
|
||||
Also reviewed:
|
||||
|
||||
- **Confidence** (a44163a). Extended and ventilation coefficients already used
|
||||
posterior variance. Base coefficients now report it too; a test shows two weeks
|
||||
of non-exciting data (relay off, steady difference) keep `heat` at 0 and `loss`
|
||||
and `bias` below 0.8, while four varied days bring all three above 0.8.
|
||||
Readiness still needs sample counts; it is a gate, not a confidence.
|
||||
- **Lifecycle** (4911b32). Three reloads leave one relay listener and one polling
|
||||
coordinator; after unload nothing is recorded or published. The test was
|
||||
checked to fail with the unsubscribe removed.
|
||||
- **Sensor weights UI** (9bb64e7, user report). Per-sensor weights existed but
|
||||
were an unlabelled second step; they are now numbered fields with the sensors
|
||||
listed, in English and Romanian.
|
||||
|
||||
Remaining risks:
|
||||
|
||||
- In observation mode the relay follows the MPC plan mainly when Generic
|
||||
Thermostat happens to agree, so Forecast Error may stay `insufficient` for a
|
||||
long time, and the scored subset is biased towards those periods.
|
||||
- `effective_samples` corrects for overlapping horizons and shared zones only; it
|
||||
is not an autocorrelation estimate. Quality thresholds (5, 30) are judgement.
|
||||
- Configuration changes made before this update cannot be detected: 0.4.0 models
|
||||
are fingerprinted as they are. A sensor replaced under the same entity ID, or a
|
||||
weather provider swap with a systematic offset, does not change any fingerprint.
|
||||
- Input-change tolerances are fixed constants, not options.
|
||||
- Published forecasts awaiting their horizon are lost on restart.
|
||||
- None of this has run on HAOS or with real devices.
|
||||
|
||||
**Verification.** In both environments (HA 2025.12.0 and 2026.2.3, Python 3.13):
|
||||
ruff, 159 offline tests, 28 HA Core tests; the offline tests also pass on Python
|
||||
3.9.6. `sh scripts/check.sh` additionally validates packaging, translations and
|
||||
the changelog.
|
||||
|
||||
## 0.4.2 — final stability fixes after 0.4.1 — 2026-10-27
|
||||
|
||||
Scope: four issues from an independent review of 0.4.1, plus a user-requested
|
||||
*Reset learned model* action. Repository code and tests only (HA Core 2025.12.0
|
||||
and 2026.2.3 harness); no HAOS, device or deployment testing. Still no actuator
|
||||
code; the static and runtime service-call guards pass. Released as 0.4.2.
|
||||
|
||||
Confirmed issues, root causes and fixes:
|
||||
|
||||
1. **Model Error scored spans with unknown ventilation inputs** (2cfba02).
|
||||
Reproduced: a window unknown for a whole 15-minute span gave a Model Error
|
||||
sample (MAE 0.76 °C). Root cause: the replay used the prediction path, which
|
||||
drops the ventilation term when the opening is unknown, i.e. assumes closed.
|
||||
Fix: per zone and step, `inputs_complete` checks every input the zone's model
|
||||
used (window opening and confirmed recovery; wind, direction and other zones'
|
||||
windows while a window is open; electrical heat and neighbours for the extended
|
||||
layer, including the neighbour's own completeness). Incomplete spans are
|
||||
counted, not scored, with the base model's error kept apart; unreplayable
|
||||
spans are counted as skipped. Learning also stopped substituting a zero facing
|
||||
term or zero cross-ventilation input for unknown values.
|
||||
2. **Window unknown at startup treated as closed** (13cb972). Reproduced
|
||||
(0 % assumed opening). Root cause: last known states lived only in memory, and
|
||||
"never observed" fell through to the closed case; a first reading of an open
|
||||
window was also logged as a confirmed opening. Fix: confirmation only for
|
||||
witnessed transitions; last known states, open events and recovery saved (also
|
||||
on every window state change) and restored as unknown; per-window forecast
|
||||
assumptions (observed / last known open, bounded by the open-window time after
|
||||
it was last seen / last known closed / unobserved), exposed in Status and on
|
||||
zone forecasts; durations as ranges when an end was not seen. Chosen over
|
||||
open/closed scenario pairs to avoid a second plan simulation per poll.
|
||||
3. **Lifecycle and storage races** (28ff953). Confirmed by reading HA's unload
|
||||
order: the coordinator's own shutdown runs only after platforms unload, and the
|
||||
save checked for shutdown before waiting for the storage lock (check-then-act).
|
||||
Found during testing: unloading cancels a running scheduled update, which
|
||||
released the lock while the file write continued in a worker thread, so a
|
||||
deletion could run first and be undone. Fix: closing mark at the start of
|
||||
unload (restored if unload fails); checks after acquiring the lock, including a
|
||||
removed-model mark set under the lock by removal; the write runs as a shielded,
|
||||
HA-tracked task that holds the lock until it finishes; failed setup shuts the
|
||||
coordinator down; queued relay callbacks are ignored once closing. Mutation
|
||||
checks: removing the locked check or the shield makes the deletion tests fail;
|
||||
removing the early closing mark fails the ordering test (the HA-level unload
|
||||
test cannot distinguish it, as HA's own shutdown also lands before release).
|
||||
4. **Forecast Error accepted materially different relay behaviour** (0b82a8e).
|
||||
Reproduced: 12 of 15 planned minutes was scored. Root cause: a per-block
|
||||
on-share tolerance of 20 %, blind to timing within a block. Fix: deviation =
|
||||
time in another state than planned, block by block from the relay's switch
|
||||
times, with unobserved time as possible deviation; matched ≤ 2 min (scored),
|
||||
approximate ≤ 5 min (separate), counterfactual > 5 min observed, otherwise
|
||||
incomplete with a reason; forecasts made with assumed inputs are incomplete.
|
||||
Ledger statistics are versioned; 0.4.1 figures are dropped.
|
||||
|
||||
Added: *Reset learned model* (49565d6): unload, strip learned keys from the
|
||||
stored model under the lock, set up again; configuration, heat-source baselines
|
||||
and window history kept.
|
||||
|
||||
Tests added (by area): Model Error incomplete/skipped (7), plan matching (10),
|
||||
window startup/restart/mixed/strict inputs (9), coordinator window reload and
|
||||
uncertain forecast (2), lifecycle offline (5) and in HA (4), reset flow (1).
|
||||
|
||||
Remaining limitations: unknown-but-last-closed and never-seen windows still
|
||||
produce a closed-window forecast (flagged, never scored); the 2 and 5 minute
|
||||
limits rest on typical learned heat coefficients, not a per-forecast calculation;
|
||||
spans lost when the replay window restarts are not counted; saved window state can
|
||||
be up to one save old, which only makes the restored unknown period longer.
|
||||
|
||||
**Verification.** `sh scripts/check.sh` passes in both environments (pip check,
|
||||
ruff, 192 offline tests, 33 HA Core tests, compilation, package/translation/
|
||||
changelog checks, diff whitespace); the offline tests also pass on Python 3.9.6.
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
# Changelog
|
||||
|
||||
Engineering findings and test evidence behind each change are in [AUDIT.md](AUDIT.md).
|
||||
|
||||
## 0.4.2 — 2026-10-27
|
||||
|
||||
Four correctness fixes from an independent review of 0.4.1, and a **Reset learned
|
||||
model** action. Still **observation-only**: no service calls; the boiler and
|
||||
thermostat are untouched.
|
||||
Tested in the Home Assistant Core harness with HA 2025.12.0 and 2026.2.3; not on
|
||||
HAOS.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- No reconfiguration needed. Zones, sensor and comfort weights, weather sources,
|
||||
heat sources, windows, learned coefficients and fingerprints carry over; entity
|
||||
IDs are unchanged.
|
||||
- **Configure now opens a menu**: *Settings* (the previous form) or *Reset learned
|
||||
model*.
|
||||
- **Forecast Error statistics restart.** 0.4.1 accepted plans the relay followed
|
||||
within 20 % per block; those figures are not comparable with the new rule and
|
||||
are dropped on the first start (they covered at most a day).
|
||||
- Model Error statistics carry over. Those from 0.4.0/0.4.1 may include a few
|
||||
spans with unknown window states; they age out within 7 days.
|
||||
|
||||
### Added
|
||||
|
||||
- **Reset learned model** (Configure, with a confirmation): forgets every zone's
|
||||
learned coefficients, models kept for removed zones, and the Model Error and
|
||||
Forecast Error statistics; keeps the configuration, heat-source baselines and the
|
||||
window event log, and logs the reset in `model_changes`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Model Error no longer scores spans with unknown inputs.** A window unknown
|
||||
during a span was replayed as closed and scored. Spans where a window state, an
|
||||
open window's weather input, or the extended layer's electrical heat or
|
||||
neighbour was unknown are now counted as `incomplete` (with the base model's
|
||||
error kept apart as `incomplete_base_model_mae`); spans that cannot be replayed
|
||||
are counted as `skipped`.
|
||||
- **Window sensors unavailable at startup are unknown, not closed.** Last known
|
||||
states, open events and recovery are saved and restored as unknown until the
|
||||
sensor reports; openings and closings count as confirmed only when seen; an
|
||||
unknown window last seen open is assumed open only until the open-window time
|
||||
after it was last seen; other unknown windows are flagged as uncertain in
|
||||
forecasts (`uncertain_windows`) and in Status (`forecast_assumption`). Event
|
||||
durations are given as a range when an end was not seen.
|
||||
- **Unload, reload and removal cannot publish or save stale work.** The
|
||||
coordinator is marked closing before platforms unload; the model save checks
|
||||
this, and a removed-model mark, after taking the storage lock and holds the lock
|
||||
until the file write finishes, so a deletion can no longer be undone by a save
|
||||
that was waiting or cancelled mid-write. A failed setup shuts the coordinator
|
||||
down; relay callbacks queued before unload are ignored.
|
||||
- **Forecast Error compares the relay's actual timing with the plan.** The
|
||||
deviation is the time the relay spent in another state than planned (unobserved
|
||||
time counts as possible deviation). Up to 2 minutes: matched and scored; up to 5:
|
||||
approximate, scored separately; more: counterfactual; otherwise incomplete, with
|
||||
a reason (relay history, assumed inputs, no poll at the due time, missing
|
||||
reading). Forecasts made with an unknown window or electrical heat are never
|
||||
scored.
|
||||
|
||||
### Changed
|
||||
|
||||
- Learning no longer substitutes values for an open window: samples where the
|
||||
wind direction (for the directional term) or another zone's windows (for
|
||||
cross-ventilation) were unknown are not learned. With every window closed, the
|
||||
opening terms need no weather input, so recovery can be learned without wind.
|
||||
- The gust option's hint refers to Model Error.
|
||||
|
||||
### Persisted data
|
||||
|
||||
- `schema_version` stays 2. `ventilation` gains `state` (last known window
|
||||
states, open events, recovery); `evaluation` gains `excluded` (incomplete and
|
||||
skipped spans); `operational_evaluation` gains `version` 2 and `approximate`.
|
||||
Missing optional keys load as empty.
|
||||
|
||||
## 0.4.1 — 2026-10-27
|
||||
|
||||
Stability and correctness: fixes four problems found in 0.4.0 and separates model
|
||||
accuracy from forecast accuracy. No new product features. Still
|
||||
**observation-only**. Tested in the Home Assistant Core harness
|
||||
with HA 2025.12.0 and 2026.2.3; not yet on HAOS.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- **Forecast Error 15/30/60/120 min are renamed Model Error.** Unique IDs and,
|
||||
on existing installs, entity IDs are unchanged, so history continues. Their
|
||||
state now stays *unknown* until there are at least 5 roughly independent spans
|
||||
(`quality` is `insufficient`); the numbers stay in the attributes meanwhile.
|
||||
- **A new Heating MPC Forecast Error sensor** scores forecasts as they were
|
||||
published. It stays unknown until the relay has followed enough MPC plans; while
|
||||
Generic Thermostat disagrees with the plan, most forecasts are counted as
|
||||
`counterfactual` instead of being scored.
|
||||
- On the first start, the saved model gets configuration fingerprints. Models
|
||||
learned under 0.4.0 are assumed to match the configuration they are loaded with;
|
||||
later changes are checked against these fingerprints.
|
||||
- Zone forecast attribute `coefficients.base` now gives `value` and `confidence`
|
||||
per coefficient instead of a bare value.
|
||||
- Downgrading to 0.4.0 keeps the learned models but drops the Model Error
|
||||
statistics (0.4.0 rejects their new format).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Unknown window states no longer pass as closed.** An unavailable window
|
||||
sensor used to end an open-window event, start recovery and let the forecast
|
||||
assume a closed window. Now the last known state is kept: a window last seen
|
||||
open is treated as possibly open (no base learning, forecast assumes open), an
|
||||
event closed while the sensor was unknown is marked unconfirmed with a bounded
|
||||
duration, and recovery is learned only after a confirmed close.
|
||||
- **Learned parameters are no longer reused after incompatible changes.** Each
|
||||
learned component has a fingerprint of the configuration it was learned under
|
||||
(see *Persisted data*). Changing a zone's sensors or the ratio of their weights
|
||||
recalibrates that zone; changing a heat source, neighbour, window, orientation
|
||||
or recovery time resets only the affected coefficients; comfort weight, target
|
||||
offset and names never invalidate anything. Every recalibration is listed in the
|
||||
Status attribute `model_changes`.
|
||||
- **Plans are no longer published from inputs that changed meanwhile.** Before
|
||||
and after the plan is computed, the integration compares every input it used
|
||||
(zone and outdoor temperatures beyond 0.25 °C, power beyond 50 W, wind, window
|
||||
states, relay and thermostat) and discards the plan if one changed meaningfully
|
||||
(`inputs_changed`, with `changed_inputs`). An older run finishing late never
|
||||
overwrites a newer result, nothing is published after unload, and saving and
|
||||
deleting the model can no longer overlap.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Model accuracy and forecast accuracy are reported separately.** Model Error
|
||||
replays the model with the inputs that actually happened (relay, electrical
|
||||
heat, windows); Forecast Error uses only what was known when the forecast was
|
||||
published, and scores it only when the relay followed the forecast's plan
|
||||
(in the planned state for at least 12 minutes of every 15-minute block). Both report MAE, RMSE, bias, the
|
||||
no-change baseline, the improvement over it, sample counts, effective
|
||||
(roughly independent) sample counts and a quality label.
|
||||
- Base-model coefficients (`loss`, `heat`, `bias`) report a confidence from their
|
||||
posterior variance, like the other layers: it reflects whether the data varied
|
||||
their inputs, not how many samples there are.
|
||||
- The zone form's sensor weights are numbered fields with the sensors listed
|
||||
above them (English and Romanian), and the zone form explains the two kinds of
|
||||
weight.
|
||||
|
||||
### Persisted data
|
||||
|
||||
- `schema_version` stays 2. New optional keys: `fingerprint_version`,
|
||||
`fingerprints`, `model_changes` (last 20), `operational_evaluation` (Forecast
|
||||
Error statistics, 7 days) and per-source baseline fingerprints in
|
||||
`heat_sources`. Retired zone models keep their fingerprint.
|
||||
- Model Error statistics gain RMSE columns; 0.3 and 0.4.0 statistics load (their
|
||||
RMSE stays empty until new forecasts are scored).
|
||||
- Pending published forecasts are kept in memory only; a restart drops those not
|
||||
yet due.
|
||||
|
||||
## 0.4.0 — 2026-10-27
|
||||
|
||||
Thermal zones, electrical heat sources, windows with ventilation and wind, and
|
||||
heat exchange between zones (Milestone 2, first part). Still **observation-only**:
|
||||
no new service calls, the boiler and thermostat are untouched. Tested in the Home
|
||||
Assistant Core harness with HA 2025.12.0 and 2026.2.3; not yet on HAOS.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- **Back up Home Assistant first.** The config entry moves to version 3: each room
|
||||
sensor becomes a one-sensor **zone** with its comfort weight, and the sensor's
|
||||
entity ID stays the zone's key, so learned models, forecast entities and
|
||||
accuracy statistics carry over unchanged. No model reset is needed.
|
||||
- **Downgrading needs the backup.** Older versions refuse a version 3 entry.
|
||||
- Configure no longer asks for room sensors or weights; manage zones with **Add
|
||||
zone** and each zone's edit button on the integration page.
|
||||
- Status attributes renamed: `input_quality.rooms` → `input_quality.zones` (each
|
||||
zone lists its sensors), `excluded_rooms` → `excluded_zones`,
|
||||
`retired_room_models` → `retired_zone_models`. The Forecast Error attribute
|
||||
`rooms` is now `zones`, keyed by zone name. The `invalid_weights` status is gone
|
||||
(invalid stored weights fall back to 1.0). Update templates that used these.
|
||||
- New layers start with no assumed effect and need data: the electrical term needs
|
||||
gaming sessions (or other load changes), the ventilation terms need window
|
||||
openings. Watch `improvement_vs_base` on the Forecast Error sensors before
|
||||
relying on them; switch off optional terms that do not help.
|
||||
|
||||
### Added
|
||||
|
||||
- **Zones** (config subentries): name, several sensors with sensor weights, comfort
|
||||
weight, target offset and adjacent zones. Sensor and comfort weights never change
|
||||
learned thermal coefficients.
|
||||
- **Heat sources**: active power (W/kW; VA and % rejected), zone, heat fraction,
|
||||
automatic 24-hour baseline or manual value, "included in" another source (no
|
||||
double counting), thermal response time, enable/disable.
|
||||
- **Windows**: contact sensor (optional inverted polarity; unknown stays unknown),
|
||||
zone, facing direction (default 345°), opening factor, enable/disable; a bounded
|
||||
log of ventilation events.
|
||||
- **Wind direction** and **wind gust** weather sources with priorities and fallback;
|
||||
knots and ft/s accepted.
|
||||
- Model options: electrical-heat forecast half-life, assumed open-window time,
|
||||
recovery time constant, and wind / gust / cross-ventilation terms.
|
||||
- Per-zone diagnostics on each zone's forecast entity: current temperature,
|
||||
+15/+30/+60/+120-minute forecast, contributions in °C/h, coefficients with
|
||||
confidence; Status lists heat sources, windows, ventilation and recent events.
|
||||
- Forecast Error sensors: `base_model_mae` and `improvement_vs_base`.
|
||||
- Poll outcomes `sensors_changed` and `ventilation`, with English and Romanian names.
|
||||
|
||||
### Changed
|
||||
|
||||
- Each zone's model is layered: the unchanged base model (fallback and
|
||||
comparison), an extended layer with electrical heat and neighbours, and a
|
||||
separate ventilation layer. Zones with an open, unknown or recovering window are
|
||||
kept out of base and extended learning.
|
||||
- Forecasts simulate all zones together, with electrical heat fading to baseline
|
||||
and open windows closing after the configured time; the plan search uses an exact
|
||||
compiled step and stays fast at 180 minutes.
|
||||
- Accuracy scoring replays all zones with the actual relay, electrical heat and
|
||||
window history, and scores the base model on the same spans.
|
||||
|
||||
### Persisted data
|
||||
|
||||
- Model payload `schema_version` stays 2 with optional new keys: `zone_models`,
|
||||
`ventilation_models`, `heat_sources` (baseline samples) and `ventilation`
|
||||
(events). Forecast statistics gain a base-model column; 0.3 statistics load.
|
||||
|
||||
### Development
|
||||
|
||||
- New pure modules: `learning.py` (named-feature RLS), `zones.py`, `heat_sources.py`,
|
||||
`windows.py`, `zone_model.py`; [docs/MODEL.md](docs/MODEL.md) documents the model.
|
||||
- Synthetic-apartment tests for gaming heat, ventilation with wind and recovery,
|
||||
and coupled rooms, each checking recovered coefficients and out-of-sample
|
||||
improvement over the base model.
|
||||
|
||||
## 0.3.0 — 2026-10-26
|
||||
|
||||
First part of Milestone 1: the integration now measures how accurate its thermal
|
||||
model is. Still **observation-only**; no new options. Upgrading from an earlier
|
||||
version: also read the notes of each version in between.
|
||||
|
||||
### Added
|
||||
|
||||
- Four sensors, **Heating MPC Forecast Error 15/30/60/120 min**: the thermal
|
||||
model's mean absolute error over the last 7 days, scored out of sample against
|
||||
what actually happened, using the relay's real ON/OFF history. Attributes show
|
||||
the bias, the error of a no-change forecast (`persistence_mae`), `skill` and
|
||||
per-room details. See README, "Forecast accuracy".
|
||||
- **Heating MPC Learning Progress** (% towards forecasts being used) and **Heating
|
||||
MPC Last Sample** (what the last poll did for learning, with English and Romanian
|
||||
state names), so learning activity is visible without opening attributes.
|
||||
- Room forecast entities: `forecast_15min` (temperature every 15 minutes over the
|
||||
horizon) and `plan_15min` (the proposed ON/OFF plan), labelled counterfactual.
|
||||
- Status: `rejected_samples` per room in `models`, `sample_results_since_start`
|
||||
counting how each poll was handled, and `sample_result: waiting_for_inputs` when
|
||||
the relay, thermostat, outdoor temperature or every room is missing.
|
||||
|
||||
### Changed
|
||||
|
||||
- The model is saved after each poll's forecasts are scored, with the forecast
|
||||
statistics (about 8 KB for five rooms, at most 18 KB).
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- Six new entities appear (four forecast-error sensors, learning progress, last
|
||||
sample). The error sensors stay unknown until forecasts can be scored: 15 minutes after
|
||||
start for the 15-minute sensor, 2 hours for the 120-minute one. Early numbers
|
||||
include the learning phase and are pessimistic.
|
||||
- Rollback to 0.2.x is safe: older versions ignore the new saved keys, and the six
|
||||
new sensors become unavailable (remove them in the entity settings if you roll back
|
||||
permanently).
|
||||
|
||||
### Development
|
||||
|
||||
- Bug-finding lint (ruff: pyflakes, bugbear) in the checks and CI.
|
||||
- Tests for restoring a model saved by 0.2.1 and for 0.2.1 being able to read
|
||||
today's saved model.
|
||||
- `.DS_Store` files are ignored and rejected by the package check.
|
||||
|
||||
## 0.2.3 — 2026-10-26
|
||||
|
||||
A stale or failed room sensor no longer pauses the whole integration. Still
|
||||
**observation-only**. Upgrading from 0.2.1 (0.2.2 was not deployed): follow the
|
||||
0.2.2 upgrade notes below as well.
|
||||
|
||||
### Changed
|
||||
|
||||
- A room sensor that is stale, unavailable, missing or invalid is now **left out**
|
||||
instead of pausing everything: the weighted temperature, learning and forecasts
|
||||
continue with the other rooms, whose comfort weights are re-normalized. Learning
|
||||
and forecasts pause only if no room is usable, or if the relay, thermostat or
|
||||
outdoor temperature is missing. Readiness considers only the rooms used.
|
||||
|
||||
### Added
|
||||
|
||||
- Checkbox **Keep using the last value of a room sensor that stopped reporting**
|
||||
(setup and Configure, default off), for sensors that report only when the value
|
||||
changes. A stale sensor's last value is then still used, for up to 24 hours
|
||||
without a report. Unavailable or invalid sensors are always left out.
|
||||
- Status attributes `excluded_rooms` and `weight_coverage` (share of the total
|
||||
comfort weight covered by the rooms used), and a per-room `used` flag in
|
||||
`input_quality`.
|
||||
- Integration icon (a radiator with a forecast curve) in **Settings → Devices &
|
||||
services**, with a dark-theme variant. Needs Home Assistant 2026.3 or newer;
|
||||
older versions show the generic placeholder.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- The new checkbox appears in **Configure**; existing installations start with it
|
||||
off. If a room is often listed in `excluded_rooms` while its temperature is
|
||||
steady, either raise that room's report-age limit or turn the checkbox on.
|
||||
- Rollback to 0.2.2 or 0.2.1 is safe; older versions ignore the new setting.
|
||||
|
||||
## 0.2.2 — 2026-10-26 (not deployed)
|
||||
|
||||
Reliability release (Milestone 0). Still **observation-only**: the integration never
|
||||
calls the relay, the thermostat or any other service. No new options or UI text;
|
||||
entity IDs and settings carry over. Tested in a Home Assistant Core test harness
|
||||
with HA 2025.12.0 and 2026.2.3; not yet tested on HAOS or with real devices.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- Back up Home Assistant, replace `custom_components/heating_mpc`, restart Core.
|
||||
- Saved models load, but coefficients learned by 0.2.1 were fitted with a broken
|
||||
radiator-lag estimate (see Fixed). Either let them re-converge over a few days of
|
||||
heating, or reset the model: disable the integration, delete
|
||||
`/config/.storage/heating_mpc.<entry_id>`, restart Core, enable it again. Deleting
|
||||
the file while the integration runs does not work; it saves the model again.
|
||||
Deleting and re-adding the integration also resets it, but loses the settings.
|
||||
- After each HA restart, learning pauses for about 30 minutes
|
||||
(`sample_result: lag_uncertain`) because the radiator state is unknown.
|
||||
Forecasts continue.
|
||||
- After a day of running, compare each sensor's `max_age_seconds_24h` in Status
|
||||
with the freshness limits in Configure (see README, "Choosing freshness limits").
|
||||
- Rollback: restore the previous folder (or the backup) and restart. 0.2.1 reads
|
||||
models saved by 0.2.2 and ignores the new keys.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The radiator-lag estimate jumped to fully hot or fully cold at every relay
|
||||
change. In simulation this learned the heating gain about a third too low, and
|
||||
forecasts made just after a boiler cycle started from the wrong state.
|
||||
- The learner's covariance grew without limit during long OFF periods (for
|
||||
example a summer), which could destabilize learning when heating resumed and
|
||||
eventually make a restart discard the saved model.
|
||||
- Optional weather sources could not be removed in Configure.
|
||||
- A finished forecast was discarded, making proposed heat unavailable for five
|
||||
minutes, when only a diagnostic weather sensor or the thermostat's current
|
||||
temperature changed during the calculation.
|
||||
- Config entries from 0.1 were not migrated.
|
||||
- Forecast entities of removed rooms stayed in the entity registry.
|
||||
- Malformed or physically implausible saved models could crash setup or be used.
|
||||
- ON/OFF readiness counts included rejected samples and other rooms' history.
|
||||
- Polls 4–7 minutes apart, or containing a relay change, were learned as normal
|
||||
5-minute samples.
|
||||
- Thermostat targets were read as Celsius on Fahrenheit installations.
|
||||
- Invalid room weights or optimizer settings could cause crashes.
|
||||
- 30/45-minute horizons reported their end point as a 60-minute forecast.
|
||||
|
||||
### Added
|
||||
|
||||
- Room and weather report-age limits in setup and Configure (5–1440 minutes,
|
||||
defaults 30 and 120). Stale outdoor sources fall back to the next one; a stale
|
||||
room pauses learning and forecasts until it reports again.
|
||||
- A read-only listener on the relay, so all relay changes between polls are
|
||||
timestamped and integrated.
|
||||
- Models of rooms removed in Configure are kept (up to 24) and restored if the
|
||||
room is added back.
|
||||
- Status attributes: `input_quality` (per-source state and report age, including
|
||||
`max_age_seconds_24h`), `heat_lag`, `heat_lag_uncertainty`, `sample_result`,
|
||||
`retired_room_models`, and per-room accepted ON/OFF counts in `models`.
|
||||
- Statuses `invalid_weights` and `inputs_changed`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Readiness now also requires 30 accepted ON and 30 accepted OFF samples per room.
|
||||
- Learning skips intervals with a relay change, an outdoor-source switch or an
|
||||
uncertain lag estimate, and polls not 270–330 seconds apart.
|
||||
- `input_quality` and `weather_comparison` are no longer written to the Recorder;
|
||||
they change every poll. Other attributes are still recorded.
|
||||
|
||||
### Persisted data
|
||||
|
||||
- Model payload `schema_version` 2 (0.2.1 wrote no version). Optional keys:
|
||||
per-room `on_count`/`off_count` and `retired_learners`. Older readers ignore them.
|
||||
|
||||
### Development
|
||||
|
||||
- Home Assistant Core test harness for HA 2025.12.0 and 2026.2.3, a Gitea Actions
|
||||
matrix over both, package and translation validation, and a static test that
|
||||
fails if actuator service calls are added.
|
||||
|
||||
## 0.2.1
|
||||
|
||||
- Corrects the manifest classification from `helper` to `service`. Existing config
|
||||
entries move from Helpers to **Settings → Devices & services → Integrations**
|
||||
after replacing the files and restarting Home Assistant Core.
|
||||
- Deleting the config entry removes its entities and the learned model
|
||||
(`.storage/heating_mpc.<entry_id>`). The component folder stays until removed
|
||||
manually.
|
||||
- MPC behavior otherwise unchanged; read-only / shadow-only.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Heating MPC 0.2.1 — custom integration for Home Assistant OS
|
||||
# Heating MPC 0.4.2 — custom integration for Home Assistant OS
|
||||
|
||||
**Experimental, observation-only (shadow mode).** It **never** changes the Generic Thermostat, the physical relay, the boiler's setpoint, `heat/off`, or Comfort/Away/Sleep. No Docker add-on, MQTT or InfluxDB connection is needed.
|
||||
|
||||
@@ -7,21 +7,98 @@ This project targets Home Assistant OS on a Raspberry Pi 5, with one existing ON
|
||||
## Install / upgrade
|
||||
|
||||
1. Create a Home Assistant backup.
|
||||
2. Copy the entire folder `custom_components/heating_mpc` into `/config/custom_components/heating_mpc` (replace its contents when upgrading from v0.1.0).
|
||||
2. Copy the entire folder `custom_components/heating_mpc` into `/config/custom_components/heating_mpc` (replace its contents when upgrading; read the upgrade notes in [CHANGELOG.md](CHANGELOG.md) first).
|
||||
3. Restart Home Assistant **Core**.
|
||||
4. If new: **Settings → Devices & services → Add integration → Heating MPC**. If installed already: select **Heating MPC → Configure** (Options).
|
||||
5. Choose the existing thermostat and physical heating switch (read-only), then individual room sensor entities.
|
||||
5. Choose the existing thermostat and physical heating switch (read-only), then room temperature sensors. Each sensor becomes a zone; group them later (see below).
|
||||
6. Choose *ordered lists* of **outdoor sensor entity IDs**. First valid sensor is active; if missing, unavailable, nonnumeric or outside reasonable bounds, the next valid sensor is used automatically.
|
||||
7. Assign a relative comfort weight (0.1–10) to each selected room.
|
||||
7. Assign a relative comfort weight (0.1–10) to each selected sensor's zone.
|
||||
8. Optionally add zones, heat sources and windows from the integration page (next section).
|
||||
|
||||
**No entity IDs are hard-coded.** Every room and outdoor measurement can be changed later via **Configure**, without editing YAML or Python.
|
||||
On Home Assistant 2026.3 or newer the integration shows its own icon (a radiator with a forecast curve) in **Devices & services**; older versions show a generic placeholder.
|
||||
|
||||
**No entity IDs are hard-coded.** Every sensor, zone, heat source, window and outdoor measurement can be changed later in the UI, without editing YAML or Python.
|
||||
|
||||
## Zones, heat sources and windows
|
||||
|
||||
On **Settings → Devices & services → Heating MPC**, the buttons **Add zone**, **Add
|
||||
heat source** and **Add window** create items that can each be edited or deleted
|
||||
later. **Configure** offers **Settings** (thermostat, relay, weather sources,
|
||||
report-age limits, MPC settings and model options) and **Reset learned model**.
|
||||
|
||||
**Zones** are rooms or areas. Each has a name, one or more temperature sensors, a
|
||||
comfort weight (how much the zone matters to the MPC), an optional target offset
|
||||
relative to the thermostat's target, and optional adjacent zones it exchanges heat
|
||||
with. A zone with several sensors gets a second step, **Sensor weights**: one
|
||||
numbered field per sensor (listed above the fields), setting how much each counts
|
||||
in the zone temperature. A sensor belongs to one zone only. Upgrading from 0.3
|
||||
turns each room sensor into a one-sensor zone with the same comfort weight; to
|
||||
group two sensors of one room, edit one zone to include both and delete the other.
|
||||
|
||||
**What a change does to the learned model.** The zone temperature is defined by
|
||||
its sensors and the ratio of their weights; changing either recalibrates that
|
||||
zone (its coefficients are kept as a starting point, but their confidence, sample
|
||||
counts and accuracy statistics start over, so *Ready* has to be earned again).
|
||||
Scaling all of a zone's sensor weights by the same factor changes nothing. Adding,
|
||||
editing or removing a heat source, a neighbour, a window, a window's orientation
|
||||
or the recovery time resets only the coefficients that depend on it. Comfort
|
||||
weight, target offset and names never touch the model. Each recalibration is
|
||||
listed in the Status attribute `model_changes`.
|
||||
|
||||
**Reset learned model** (in **Configure**, after ticking the confirmation) forgets
|
||||
everything learned: every zone's coefficients, including those kept for removed
|
||||
zones, with their confidence and sample counts, and the Model Error and Forecast
|
||||
Error statistics. Zones, sensors, weights, heat sources, windows, weather sources
|
||||
and settings stay as they are, and so do the heat sources' baselines and the window
|
||||
event log. The integration restarts and learns from the beginning (first sample
|
||||
after about 30 minutes, *Ready* after a day or more); `model_changes` records the
|
||||
reset. It never touches the boiler or the thermostat.
|
||||
|
||||
**Heat sources** are equipment whose electricity ends up as heat in a zone, such as
|
||||
a UPS feeding computers. Settings: an active-power sensor (W or kW; VA and % are
|
||||
rejected), the zone, the share of the power that becomes heat there (1.0 by
|
||||
default), the baseline (automatic: the low end of the last 24 hours; or a manual
|
||||
value in W), whether its reading is **already included in** another source (for a
|
||||
PC measured separately behind a UPS, so it is not counted twice), and the thermal
|
||||
response time (default 15 minutes). Only the variation above the baseline is
|
||||
modeled; the persistent load is already part of the zone's learned behavior.
|
||||
|
||||
**Windows** need a contact sensor (binary sensor), the zone, whether the sensor
|
||||
reports *on* when closed (inverted), the direction the outside of the window faces
|
||||
(degrees clockwise from north; default 345°, editable) and an opening factor
|
||||
(1.0 normal, lower for tilted, higher for large).
|
||||
|
||||
A window is **open**, **closed** or **unknown** (unknown, unavailable or no state);
|
||||
unknown never means closed. While any window of a zone is unknown, the zone does
|
||||
not learn. What forecasts assume for an unknown window:
|
||||
|
||||
- last seen open: open, but only until the configured open-window time after it was
|
||||
last seen (never renewed), then closed and recovering;
|
||||
- last seen closed, or never seen since setup: no ventilation, but the forecast is
|
||||
flagged (`uncertain_windows` on the zone's forecast entity, `forecast_assumption`
|
||||
in Status) and is never used to measure forecast accuracy.
|
||||
|
||||
An opening or closing is *confirmed* only when it was seen happen: the previous
|
||||
reading was the other state, with no unknown period or restart in between. Events
|
||||
report a duration only when both ends are confirmed, otherwise a minimum and, where
|
||||
possible, a maximum; recovery is learned only after a confirmed close. The last
|
||||
known states, an open event and a recovery in progress are saved, so a restart or
|
||||
reload continues them, but they come back as unknown until the sensor reports.
|
||||
|
||||
Model options in **Configure**: how fast extra electrical heat is assumed to fade in
|
||||
forecasts (half-life, default 30 minutes), how long an open window is assumed to
|
||||
stay open (default 15 minutes), the recovery time constant after closing (default
|
||||
20 minutes), and whether to use wind, wind gusts and cross-ventilation terms.
|
||||
See [docs/MODEL.md](docs/MODEL.md) for the equations and what is learned when.
|
||||
|
||||
## Reconfigurable weather inputs
|
||||
|
||||
| Measurement | Required? | Used in the thermal model in 0.2.0? |
|
||||
| Measurement | Required? | Used in the thermal model? |
|
||||
|---|---|---|
|
||||
| Outdoor temperature | **Yes** (1–10 prioritized sensors) | **Yes**: estimates thermal losses; constant over the 2 h forecast window |
|
||||
| Wind speed | No | No, diagnostics/source comparison only |
|
||||
| Wind speed | No | Yes, for open windows (ventilation), when enabled |
|
||||
| Wind direction (degrees the wind comes from) | No | Yes, with the windows' facing direction |
|
||||
| Wind gust | No | Only if the gust option is enabled |
|
||||
| Cloud cover | No | No, diagnostics/source comparison only |
|
||||
| Humidity | No | No, diagnostics/source comparison only |
|
||||
| Feels-like temperature | No | No, diagnostics/source comparison only |
|
||||
@@ -31,139 +108,309 @@ This project targets Home Assistant OS on a Raspberry Pi 5, with one existing ON
|
||||
|
||||
Choose multiple sensors for a single variable, e.g. OpenWeatherMap then another provider, in priority order. Switch order in **Configure** to run A/B comparisons on successive days, or simply let the second sensor act as fallback. The active source and all current alternative readings are visible in the **Heating MPC Status** entity attributes (`weather`, `weather_active_sources`, `weather_comparison`). The **MPC Outdoor Temperature** sensor also has an `active_source` attribute and an `alternatives` map.
|
||||
|
||||
Measurements are normalized where possible: outdoor temperatures to °C; wind to m/s; pressure to hPa; visibility to meters. Unknown units or values outside plausibility bounds are rejected in favor of the next selected sensor. Clouds and humidity are expected in %, rain/snow in mm or mm/h as represented by their upstream sensor. A fallback source may have a different sampling delay; configurable report-age limits now reject stale inputs (see below).
|
||||
Measurements are normalized where possible: outdoor temperatures to °C; wind and gusts to m/s (from km/h, mph, kn, ft/s); wind direction to degrees (360 = 0); pressure to hPa; visibility to meters. Unknown units or values outside plausibility bounds are rejected in favor of the next selected sensor. Clouds and humidity are expected in %, rain/snow in mm or mm/h as represented by their upstream sensor. A fallback source may have a different sampling delay; configurable report-age limits now reject stale inputs (see below).
|
||||
|
||||
Switching only the **weather-provider sensors** does not reset learned thermal parameters. The update is applied after the integration reloads, and it skips learning one transition when the primary temperature source changes, avoiding a jump in the training series. Nevertheless, two sensors with a persistent offset can bias predictions; compare their readings and validate the model again before relying on them. Replacing the physical heating switch discards incompatible model parameters. Existing room-specific parameters are retained if room entity IDs remain unchanged.
|
||||
Switching only the **weather-provider sensors** does not reset learned thermal parameters. The update is applied after the integration reloads, and it skips learning one transition when the primary temperature source changes, avoiding a jump in the training series. Nevertheless, two sensors with a persistent offset can bias predictions; compare their readings and validate the model again before relying on them. Replacing the physical heating switch discards incompatible model parameters. Learned zone parameters are kept as long as the zone exists (its key does not change when you edit it).
|
||||
|
||||
## Model, sampling and output
|
||||
|
||||
- Every 5 minutes: reads HA sensor states and observed relay state; learns a per-room linear thermal model `dT = a*(T_out-T_room) + b*effective_heating + c`.
|
||||
- Learns only from valid 5-minute transitions; uses a smoothed heating-demand proxy to approximate radiator lag.
|
||||
- After at least 200 transitions and samples with both heating states, runs an ON/OFF MPC search for the next 60–180 minutes (default 120) in 15-minute command blocks. Protects against rapid simulated toggling.
|
||||
- Uses room comfort weights and the **existing** Generic Thermostat's target and heat/off state. **The simulated ON/OFF demand never operates hardware**.
|
||||
- Persists model parameters using HA's `.storage` so HA restarts do not discard the trained data.
|
||||
- Every 5 minutes: reads sensor, relay, power and window states and learns a per-zone model in layers (full description in [docs/MODEL.md](docs/MODEL.md)):
|
||||
- **base**: `dT = loss*(T_out-T) + heat*lag + bias`, always learned, the fallback and comparison;
|
||||
- **extended**: adds electrical heat above baseline and exchange with adjacent zones;
|
||||
- **ventilation**: learned separately while a window is open or the room recovers, so open windows never look like poor insulation.
|
||||
- New effects start at zero and report a confidence; nothing is assumed until the data shows it. Relay changes are timestamped by a read-only state listener.
|
||||
- After 200 samples per zone, including both relay states, runs an ON/OFF MPC search for the next 60–180 minutes (default 120) in 15-minute command blocks, simulating all zones together with assumed electrical heat and window behavior.
|
||||
- Uses zone comfort weights and target offsets with the **existing** Generic Thermostat's target and heat/off state. **The simulated ON/OFF demand never operates hardware**.
|
||||
- Persists everything learned in HA's `.storage`. Models of zones removed in the UI are kept (up to 24) and restored if the zone is added back.
|
||||
|
||||
Entities (entity IDs may vary):
|
||||
|
||||
- `sensor.heating_mpc_status` — learning/ready/paused status and diagnostics, including outside weather, active source, all valid alternative weather values, model coefficients, proposed 15-minute plan.
|
||||
- `sensor.heating_mpc_weighted_temperature` — own room-weighted average, informative only.
|
||||
- `sensor.heating_mpc_weighted_temperature` — comfort-weighted average of the zones, informative only.
|
||||
- `sensor.mpc_outdoor_temperature` — selected external temp and alternatives.
|
||||
- Per-room predicted temperatures after 60 minutes.
|
||||
- One forecast entity per zone (**MPC <zone> 60min Prediction**), state = the zone's
|
||||
temperature in 60 minutes. Attributes: `current_temperature`, `forecast` (+15/+30/
|
||||
+60/+120 minutes as far as the horizon goes), `forecast_15min`, `plan_15min`,
|
||||
`model_in_use`, `contributions_c_per_h` (what drives the zone right now: loss,
|
||||
heat, bias, internal, neighbors, ventilation, recovery) and `coefficients` with
|
||||
confidence. Plan forecasts assume the MPC's plan is executed; while Generic
|
||||
Thermostat controls the relay they are **counterfactual**.
|
||||
- `binary_sensor.heating_mpc_proposed_heat` — **hypothetical** heating demand (unavailable until ready).
|
||||
- **Heating MPC Learning Progress** — how close the least trained zone is to being
|
||||
used for forecasts (100 % = ready); attributes show the sample counts and what is
|
||||
required.
|
||||
- **Heating MPC Last Sample** — what the last poll did for learning (for example
|
||||
*Learned*, *Relay switched*, *Waiting for radiators to settle*); its history shows
|
||||
when learning happens.
|
||||
- **Heating MPC Model Error 15/30/60/120 min** — how well the thermal model
|
||||
explains what happened, replayed with the inputs that actually occurred.
|
||||
- **Heating MPC Forecast Error** — how far published forecasts were from what
|
||||
happened, using only what was known when they were made (see *Forecast
|
||||
accuracy*).
|
||||
|
||||
## Known limitations / safety
|
||||
|
||||
- **Do not** connect the proposed binary sensor directly to the heating switch or build an automation from it. A production controller would need watchdog, manual override, safety handling, and an exclusive relay owner with Generic Thermostat fallback.
|
||||
- The other meteorological variables besides temperature are intentionally **captured but not yet fitted into the thermal model**. This allows source tests without falsely treating weak inputs as validated improvements. Later versions can test whether wind or solar proxy reduces held-out forecast error.
|
||||
- Weather variables other than temperature, wind and gusts are **captured but not fitted**. Wind and gusts are used only for open windows. Keep optional model terms only when **Model Error** shows they help (`improvement_vs_base`).
|
||||
- One temperature per zone (no separate wall mass), configured response times, and single-scenario forecasts of electrical heat and windows. See [docs/MODEL.md](docs/MODEL.md#limitations).
|
||||
- All rooms share one heating actuator. The model does not command individual radiators.
|
||||
- No weather forecast: assumes outdoor temperature remains constant during simulated horizon.
|
||||
- No Recorder/InfluxDB training backfill. It learns only from samples collected after installation.
|
||||
- The demand switch is not a burner-flame or real gas consumption measurement.
|
||||
- Full HAOS/UI runtime integration has **not** been tested; Python/model/unit/source tests are offline checks only.
|
||||
- Not yet tested on HAOS or with real devices. Automated tests run the integration in a Home Assistant Core test harness (HA 2025.12.0 and 2026.2.3) with simulated entities.
|
||||
- Home Assistant Core APIs and config-flow selectors can change; take a backup before trying this experimental custom integration.
|
||||
|
||||
## Offline tests
|
||||
## Diagnostics
|
||||
|
||||
Start with **Learning Progress** and **Last Sample**. For details, open the
|
||||
**Heating MPC Status** entity's attributes. In recent Home Assistant versions
|
||||
Developer Tools is at the bottom of **Settings** (or press Cmd/Ctrl+K and type
|
||||
"developer"); its **States** tab lists every attribute.
|
||||
|
||||
**Why learning starts about 30 minutes after a restart.** The model learns how
|
||||
much the radiators warm each room, so it needs to know how warm the radiators are.
|
||||
It estimates this from when the relay last switched (20-minute time constant).
|
||||
After a Home Assistant restart that time is unknown, so learning waits until the
|
||||
estimate is within 10 % (*Waiting for radiators to settle*), about 32 minutes.
|
||||
Forecasts continue meanwhile. Reloading the integration usually does not cause this
|
||||
wait, because the relay's last change is still known. *Ready* then needs 200
|
||||
accepted samples (about 17 hours) per zone, including 30 with the relay ON and 30
|
||||
with it OFF, which can take a few days depending on how often the boiler runs.
|
||||
|
||||
The **Heating MPC Status** entity's state is one of `waiting_for_sensors`,
|
||||
`no_zones` (add a zone), `learning`, `paused` (thermostat not in `heat`), `waiting_for_target`, `ready`,
|
||||
`inputs_changed` or `prediction_error`. Proposed heat is available only while it
|
||||
is `ready`. `inputs_changed` means an input moved meaningfully while the plan was
|
||||
being computed (beyond 0.25 °C, 50 W, 1 m/s wind, 2 m/s gust or 20° direction, or
|
||||
any change of a window, the relay or the thermostat); `changed_inputs` lists them,
|
||||
the plan is discarded and the next poll computes a fresh one. Useful attributes:
|
||||
|
||||
- `input_quality` — per zone (with each of its sensors) and per weather source
|
||||
(grouped by metric): `status` (`valid`, `missing`, `unknown`, `unavailable`,
|
||||
`stale`, `invalid_timestamp`, `future_timestamp` or `invalid_value_or_unit`),
|
||||
`age_seconds` (time since the sensor last reported to HA) and
|
||||
`max_age_seconds_24h` (largest age seen at polls in the last 24 hours; resets on
|
||||
reload and can read up to one poll, 5 minutes, low). Zones and sensors have `used`.
|
||||
- `excluded_zones` and `weight_coverage` — zones left out of this poll, and the
|
||||
share of the total comfort weight still covered by the zones used (1.0 = all).
|
||||
`zone_names` maps zone keys to names.
|
||||
- `heat_sources` — per source: measured and effective power (after removing
|
||||
included sources), baseline, variable power above it, thermal response, whether it
|
||||
has settled.
|
||||
- `windows` — per window: state (`open`, `closed` or `unknown`), the last known
|
||||
state and for how long the sensor has been unknown, what forecasts assume
|
||||
(`forecast_assumption`: `observed`, `last_known_open`, `last_known_closed` or
|
||||
`unobserved`; `forecast_open_minutes`), minutes open, wind direction relative to
|
||||
the window's facing and the resulting exposure (1 = wind straight at it).
|
||||
`ventilation` — per zone: current opening, recovery, temperature before opening,
|
||||
its contribution in °C/h and the ventilation coefficients. `ventilation_events` —
|
||||
the last five openings with temperatures before, lowest, at closing and 30
|
||||
minutes after, and the weather.
|
||||
- `heat_lag` and `heat_lag_uncertainty` — the radiator-lag estimate (0 cold, 1 fully
|
||||
warm) and its error bound. Learning waits while the bound is above 0.1, for
|
||||
example for about 30 minutes after an HA restart.
|
||||
- `sample_result` — why the last poll was or was not learned: `first_sample`,
|
||||
`sensors_changed` (a zone's set of usable sensors changed), `learned`,
|
||||
`ventilation` (only window-affected zones; given to the ventilation model),
|
||||
`partly_rejected` (an outlier in some zone), `interval_out_of_range`,
|
||||
`relay_changed`, `outside_source_changed`, `lag_uncertain` or `waiting_for_inputs`.
|
||||
- `models` — per-zone base coefficients with their `confidence`, accepted sample
|
||||
counts and the in-sample 5-minute error (`mae_5m`, **not** a forecast accuracy),
|
||||
`model_in_use` and the extended layer's coefficients with confidence. Confidence
|
||||
(0 to 1) is the share of a coefficient's information that comes from data rather
|
||||
than the starting assumption. It grows only while the data varies that
|
||||
coefficient's input: weeks with the relay always off leave `heat` at 0, however
|
||||
many samples there are.
|
||||
- `model_changes` — the last recalibrations: when, which zone, which coefficients
|
||||
were reset and why.
|
||||
- `retired_zone_models` — removed zones whose learned models are kept.
|
||||
|
||||
`input_quality`, `weather_comparison`, `heat_sources`, `windows`, `ventilation`,
|
||||
`ventilation_events` and `model_changes` are not written to the Recorder; the other
|
||||
attributes are.
|
||||
|
||||
### Choosing freshness limits
|
||||
|
||||
Room and weather report-age limits are set during setup and in **Configure**
|
||||
(5–1440 minutes; defaults 30 for rooms and 120 for weather).
|
||||
|
||||
A zone sensor that is older than its limit, unavailable, missing or invalid is
|
||||
**left out**; the zone uses its other sensors (without learning across that change).
|
||||
A zone with no usable sensor is left out entirely: the weighted temperature,
|
||||
learning and forecasts continue with the
|
||||
other zones, whose weights are re-normalized. Status shows it in `excluded_zones`.
|
||||
Learning and forecasts pause only when no zone is usable, or when the relay,
|
||||
thermostat or outdoor temperature is missing. A zone that reports again learns
|
||||
from its second poll onwards, so nothing is learned across its gap.
|
||||
|
||||
The checkbox **Keep using the last value of a room sensor that stopped reporting**
|
||||
changes this for stale sensors: their last value is still used, for up to 24 hours
|
||||
without a report. Use it for sensors that report only when the value changes.
|
||||
Unavailable or invalid sensors are always left out, and so is any sensor silent
|
||||
for more than 24 hours, since it is most likely dead.
|
||||
|
||||
A stale outdoor source falls back to the next one in its list; optional weather
|
||||
metrics never block.
|
||||
|
||||
Within the limit (or with the checkbox on), the last reading counts as current,
|
||||
so a frozen value can be learned for a while. A too-tight limit (with the checkbox
|
||||
off) drops a room whenever its sensor is quietly between reports. Freshness uses
|
||||
HA's `last_reported`: the time a sensor last reported to HA, even with an unchanged
|
||||
value. It is not the age of the physical measurement, so a provider re-publishing
|
||||
cached data looks fresh, and sensors that report only on change can look stale in
|
||||
a steady room.
|
||||
|
||||
After a day of running, set each limit comfortably above (about 1.5–2×) the largest
|
||||
`max_age_seconds_24h` among the sensors it covers. The relay and thermostat have no
|
||||
age limit.
|
||||
|
||||
## Forecast accuracy
|
||||
|
||||
Two different questions, two kinds of sensor:
|
||||
|
||||
- **Model Error** (four sensors, 15/30/60/120 min): *does the thermal model explain
|
||||
what happened?* Replayed with the relay, electrical heat and windows that
|
||||
actually followed. Useful for judging the model and its options. It is **not**
|
||||
a forecast accuracy: a real forecast does not know when the relay will switch or
|
||||
a window will open.
|
||||
- **Forecast Error** (one sensor): *how good were the forecasts as published?*
|
||||
Uses only what was known when each forecast was made.
|
||||
|
||||
### Model Error
|
||||
|
||||
At every poll the integration records each zone's reading, the outdoor
|
||||
temperature, the radiator-lag estimate and the model's parameters. 15, 30, 60 and
|
||||
120 minutes later it replays all zones together from that reading using the
|
||||
parameters it had **then**, the outdoor temperature held at its starting value,
|
||||
and the relay, electrical heat and window history that actually followed. The base
|
||||
model is scored on the same spans. Each sensor's state is the mean absolute error
|
||||
(°C) over the last 7 days, across all zones. Attributes, all in °C except counts:
|
||||
|
||||
- `samples` — number of scored forecasts; `effective_samples` — roughly
|
||||
independent ones. Forecasts start every 5 minutes, so 60-minute forecasts
|
||||
overlap twelvefold, and zones share one boiler, so `effective_samples` is
|
||||
`samples × 5 / horizon / zones`.
|
||||
- `quality` — `insufficient` below 5 effective samples (the state then stays
|
||||
unknown; the numbers are still in the attributes), `indicative` below 30, `ok`
|
||||
from 30.
|
||||
- `mae`, `rmse`, `bias` — mean absolute, root-mean-square and mean error
|
||||
(negative bias: the model predicts too cold).
|
||||
- `persistence_mae` — error of simply assuming no change; the baseline to beat.
|
||||
- `skill` — `1 - mae / persistence_mae`: 1 is perfect, 0 is no better than
|
||||
assuming no change, negative is worse. Empty while the temperature did not change.
|
||||
- `base_model_mae`, `improvement_vs_base` — the base model's error on the same
|
||||
spans, and the share of it the extra layers remove (0 = no gain, negative = they
|
||||
make it worse: switch that option off).
|
||||
- `incomplete` — spans left out because an input the zone's model needed was not
|
||||
observed at some step: a window state (or the start of a recovery), the wind,
|
||||
wind direction or another zone's windows while a window was open, or, for the
|
||||
extended layer, electrical heat or a neighbour (whose own span must be complete
|
||||
too). The base model needs none of these, so its error on those spans is still
|
||||
reported as `incomplete_base_model_mae`.
|
||||
- `skipped` — spans that could not be replayed: the radiator-lag estimate was
|
||||
uncertain along the way, or the zone had no reading at the end.
|
||||
- `zones` — the same numbers per zone (not written to the Recorder).
|
||||
|
||||
Spans need polls 4.5–5.5 minutes apart; a missing relay, outdoor temperature or
|
||||
all zones restarts the replay window, so scores resume 15–120 minutes later (these
|
||||
gaps are not counted). Statistics are kept with the model and survive restarts;
|
||||
recalibrating a zone or resetting the learned model clears them.
|
||||
|
||||
### Forecast Error
|
||||
|
||||
Each time a plan is published, its forecast is recorded as it was: start
|
||||
temperatures, the trajectory, the plan, the model revision and the assumptions
|
||||
(outdoor temperature and wind held, electrical heat fading, open windows closing).
|
||||
When the forecast's horizon comes, the relay's actual behaviour is compared with
|
||||
the plan, block by block, from the relay's own switch times: the **deviation** is
|
||||
how long the relay was in another state than planned, so the same on-time at other
|
||||
times than planned counts as deviation. Relay time that was not observed counts as
|
||||
possible deviation. Each forecast is then:
|
||||
|
||||
- `matched` — at most 2 minutes of deviation. Only these make the Forecast Error
|
||||
figures.
|
||||
- `approximate` — at most 5 minutes (of which at most 2 unobserved), for example
|
||||
the thermostat switching a few minutes earlier than planned. Scored separately
|
||||
in `approximate_stats`.
|
||||
- `counterfactual` — more than 5 minutes: Generic Thermostat, which controls the
|
||||
relay, did something else. Not an error of the forecast. Expected in observation
|
||||
mode whenever the two disagree.
|
||||
- `incomplete` — not comparable, with `incomplete_reasons`: `relay_history` (too
|
||||
much relay time unknown), `uncertain_inputs` (the forecast had to assume an
|
||||
unknown window or electrical heat), `no_poll` (no poll near the due time) or
|
||||
`reading` (a zone had no reading; counted per zone).
|
||||
|
||||
Why these limits: d seconds of boiler heat that the plan did not assume move a room
|
||||
by roughly *heat* × d / 300, where *heat* is the learned boiler coefficient
|
||||
(typically 0.05–0.12 °C per 5 minutes with warm radiators). 2 minutes stay below
|
||||
half a typical 0.1 °C sensor step and absorb timestamp jitter; 5 minutes are about
|
||||
one step.
|
||||
|
||||
The state is the 60-minute MAE of matched forecasts once its quality is at least
|
||||
`indicative`; the attribute `horizons` (not written to the Recorder) gives MAE,
|
||||
RMSE, bias, the no-change error, `improvement_vs_persistence`, sample counts,
|
||||
quality, the counts above, `approximate_stats` and the per-zone figures for each
|
||||
horizon. Statistics cover 7 days and survive restarts; forecasts not yet due when
|
||||
HA restarts are dropped.
|
||||
|
||||
What neither covers: outdoor temperature changes during the horizon (held
|
||||
constant, as in the MPC), and periods when the model was still learning (included,
|
||||
so early numbers are pessimistic). The roadmap's working target is about
|
||||
0.3–0.4 °C at 60 minutes with clearly positive skill, before any control is
|
||||
considered.
|
||||
|
||||
## Release notes
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for changes and upgrade notes per version, and
|
||||
[AUDIT.md](AUDIT.md) for audit findings and test evidence.
|
||||
|
||||
## Developer checks and Forgejo/Gitea Actions
|
||||
|
||||
The HA Core test suite runs against **Home Assistant 2025.12.0** (the minimum in
|
||||
`hacs.json`, `requirements-test.txt`) and **2026.2.3** (newest published test
|
||||
fixtures, `requirements-test-latest.txt`), both on Python 3.13. Use a dedicated
|
||||
development virtual environment; these test dependencies are not installed with
|
||||
the integration and do not change its local, standard-library model inference.
|
||||
|
||||
```bash
|
||||
python3.13 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install -r requirements-test.txt # or requirements-test-latest.txt
|
||||
sh scripts/check.sh
|
||||
```
|
||||
|
||||
The check script runs dependency consistency checks, the offline tests, the HA
|
||||
Core tests, compilation, package metadata, translation and changelog checks, and
|
||||
diff whitespace checks. The 2025.12.0 requirements pin a compatible DNS library
|
||||
because pycares 5 breaks that release's dependency. The script also disables a
|
||||
transitive HA test dependency's optional metadata download. The offline tests also
|
||||
run without Home Assistant installed:
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
## v0.2.1: integration visibility and clean uninstall
|
||||
The brand icons in `custom_components/heating_mpc/brand/` are drawn by
|
||||
`python scripts/make_brand_icons.py` (uses Pillow from the test requirements);
|
||||
the package check verifies their sizes and transparency.
|
||||
|
||||
- Corrects the manifest classification from `helper` to `service`. Existing config entries should move from the Helpers listing to **Settings → Devices & services → Integrations** after replacing the files and restarting Home Assistant Core. No deletion/reconfiguration is needed to upgrade.
|
||||
- When the integration config entry is deleted from the Integrations page, Home Assistant removes its registered entities, and `async_remove_entry` removes the locally persisted learned-model data (`.storage/heating_mpc.<entry_id>`). The custom component source folder remains on disk until manually removed.
|
||||
- MPC behavior is otherwise unchanged and remains read-only / shadow-only.
|
||||
HA tests cover setup, config/options forms (including the reset), migration,
|
||||
reload (a single relay listener and polling coordinator after repeated reloads),
|
||||
unload, reload and removal while an optimization or a save is still running,
|
||||
entity cleanup, scheduled sampling and poll cancellation, the relay listener, stored model
|
||||
restoration, retention and deletion, missing inputs, manual off/presets, optimizer
|
||||
failure and Recorder exclusions. A fixture rejects all HA service calls throughout
|
||||
each test, and a static test fails if actuator service calls are added to the
|
||||
source. HA Core runs locally with simulated input entities and mocked storage I/O;
|
||||
this does not validate HAOS, physical devices, disk failures or full process restarts.
|
||||
|
||||
## Unreleased: v0.2.1 reliability audit
|
||||
|
||||
See [AUDIT.md](AUDIT.md) for findings, fixes, test evidence and remaining gaps.
|
||||
No active control was added; the manifest version has not been advanced.
|
||||
|
||||
- Fix config-entry v1 migration, obsolete room forecast cleanup and coordinator
|
||||
cleanup after a raised platform setup error.
|
||||
- Validate saved models atomically and add an explicit payload schema version.
|
||||
Compatible legacy coefficients survive; readiness now requires at least 30 new
|
||||
accepted ON and 30 OFF samples **per room** when loading legacy models without
|
||||
per-room counters. The existing 200-total-sample requirement also applies.
|
||||
- Reject intervals containing relay changes or outside-provider changes. Accept
|
||||
only 270–330-second intervals and normalize small timing differences. This can
|
||||
slow learning; it never generates extra heating cycles.
|
||||
- Normalize thermostat targets to Celsius; reject invalid weights and optimizer
|
||||
parameters. Status can report `invalid_weights` or `inputs_changed`; neither
|
||||
publishes a proposal. Input changes during calculation discard the prediction.
|
||||
- Model diagnostics include each room's accepted total/ON/OFF sample counts.
|
||||
Predictions remain **counterfactual**: the proposed plan is not the actual
|
||||
Generic Thermostat's future command sequence.
|
||||
|
||||
Offline lifecycle tests use HA doubles. Actual HA/HAOS lifecycle
|
||||
validation and held-out prediction accuracy remain unfinished. Back up before
|
||||
trying these changes; follow the safe trial and rollback notes in AUDIT.md.
|
||||
|
||||
|
||||
## Unreleased: sensor freshness
|
||||
|
||||
Room and weather report-age limits are available during setup and in Options
|
||||
(5–1440 minutes). Defaults are 30 minutes for rooms and 120 minutes for weather;
|
||||
existing entries use these defaults until changed. Celsius/Fahrenheit handling,
|
||||
compatible learned models and entity IDs are unchanged.
|
||||
|
||||
Stale room readings or an absence of fresh outdoor temperature sources pause
|
||||
learning and predictions. Weather selection skips stale sources; optional weather
|
||||
outages do not block predictions. Recovery begins a new sampling interval, so no
|
||||
training spans an outage. Status attribute `input_quality` lists each room and each
|
||||
weather source by metric, with report age in seconds and a reason: `valid`,
|
||||
`missing`, `unknown`, `unavailable`, `stale`, `invalid_timestamp`,
|
||||
`future_timestamp` or `invalid_value_or_unit`.
|
||||
|
||||
Freshness uses HA `last_reported`, falling back to `last_updated` only on older HA
|
||||
versions without that field. It measures HA state reports, not the age of the
|
||||
original physical measurement: a provider republishing cached data can look fresh.
|
||||
Some integrations report only changed values; choose limits appropriate to their
|
||||
reporting behavior. The relay and thermostat are event-driven references and do
|
||||
not receive these numeric-sensor age limits. Sensor alignment and relay event
|
||||
history remain future work. These checks run on the existing five-minute poll.
|
||||
|
||||
After backing up and upgrading, check report ages in Status, adjust limits in
|
||||
Options to match your sensors, and verify fallback/recovery with an unavailable
|
||||
source. Generic Thermostat remains the sole controller. No HAOS trial was performed.
|
||||
|
||||
|
||||
## Developer checks and Forgejo/Gitea Actions
|
||||
|
||||
The HA Core test suite targets **Home Assistant 2025.12.0 on Python 3.13**,
|
||||
matching the minimum version in `hacs.json`. Run in a dedicated development
|
||||
virtual environment; these test dependencies are not installed in the integration
|
||||
and do not change its local, standard-library model inference.
|
||||
|
||||
```bash
|
||||
python3.13 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install -r requirements-test.txt
|
||||
sh scripts/check.sh
|
||||
```
|
||||
|
||||
The check script runs dependency consistency checks, 42 offline tests, 14 tests
|
||||
using the real HA Core/fixture package, compilation, lightweight package metadata
|
||||
and translation checks, and diff whitespace checks. A compatible DNS dependency
|
||||
is pinned because pycares 5 breaks the older HA dependency's import. The script
|
||||
also disables a transitive HA test dependency's optional metadata download.
|
||||
|
||||
HA tests cover setup, config/options forms, migration, reload and entity cleanup,
|
||||
scheduled sampling and poll cancellation, stored model restoration/deletion,
|
||||
missing inputs, manual off/presets and optimizer failure. A fixture rejects all
|
||||
HA service calls throughout each test. HA Core runs locally with simulated input
|
||||
entities and mocked storage I/O; this does not validate HAOS, physical devices,
|
||||
disk failures or full process restarts. The original standalone unittest command
|
||||
still works without installing HA.
|
||||
|
||||
`.gitea/workflows/tests.yml` runs the same checks for pushes and pull requests on
|
||||
the dedicated `bs-node` Actions runner label (`bs-node:docker://node:22-bookworm`). Repository Actions must be enabled.
|
||||
The runner must support the checkout and setup-uv actions. CI uses pinned uv
|
||||
0.9.6 to create a Python 3.13 virtual environment from standalone Python builds;
|
||||
`.gitea/workflows/tests.yml` runs the same checks for both HA versions on pushes
|
||||
and pull requests, on the dedicated `bs-node` Actions runner label
|
||||
(`bs-node:docker://node:22-bookworm`). Repository Actions must be enabled, and the
|
||||
runner must support the checkout and setup-uv actions. CI uses pinned uv 0.9.6 to
|
||||
create a Python 3.13 virtual environment from standalone Python builds;
|
||||
`setup-python` cannot provision this version in the Debian Bookworm job image.
|
||||
The environment includes pip for the existing check script. Action caching is
|
||||
explicitly disabled to avoid requiring GitHub cache services on Gitea.
|
||||
Action caching is disabled to avoid requiring GitHub cache services on Gitea.
|
||||
Dependencies, Python builds and action downloads need internet access in CI;
|
||||
model inference does not. Remote CI execution has not yet been verified. Local
|
||||
validation used macOS and Python 3.13; Linux runner validation remains necessary.
|
||||
No runtime integration behavior or manifest version changed in this test commit.
|
||||
model inference does not. Remote CI execution has not yet been verified; local
|
||||
validation used macOS.
|
||||
+81
-21
@@ -10,6 +10,9 @@ Success means predictable comfort, understandable decisions, safe operation and
|
||||
|
||||
## Current baseline — v0.2.1
|
||||
|
||||
> **Current release: 0.4.0** — Milestone 0 reliability work, part of Milestone 1 and the first part of Milestone 2 on top of this
|
||||
> baseline; see [CHANGELOG.md](CHANGELOG.md). The lists below describe 0.2.1.
|
||||
|
||||
**Implemented (experimental):**
|
||||
|
||||
- [x] Native Home Assistant integration with UI-based Config Flow and Options Flow.
|
||||
@@ -41,14 +44,14 @@ Current behavior: **shadow mode only**. `generic_thermostat` remains the one and
|
||||
|
||||
- [x] Perform a source audit of v0.2.1: pure model, optimizer, config/options, coordinator update timing, persistent model and entity lifecycle.
|
||||
- [x] Add Home Assistant test harness coverage for initial setup, update, restart/reload, options changes, missing sensors, `heat/off`, target changes, teardown and uninstall.
|
||||
- [ ] Verify that **zero physical-device service calls** occur in observation mode, including restart and failed update cases.
|
||||
- [ ] Establish deterministic handling of 5-minute transitions, polling jitter, timestamp discontinuities, missing data and real switch transitions between samples.
|
||||
- [x] Verify that **zero physical-device service calls** occur in observation mode, including restart and failed update cases. *(0.2.2: service-call guard on every HA Core test, including cold start with a stored model and optimizer failure, plus a static source check. Not yet observed on HAOS.)*
|
||||
- [x] Establish deterministic handling of 5-minute transitions, polling jitter, timestamp discontinuities, missing data and real switch transitions between samples. *(0.2.2: relay changes timestamped by a read-only listener; offline and HA Core tests. Sensor alignment remains open.)*
|
||||
- [x] Add input freshness thresholds and diagnostics that identify exactly which source is missing/stale/invalid.
|
||||
- [ ] Verify that source switching, entity renaming, adding/removing rooms and replacing the relay do not silently reuse incompatible fitted coefficients.
|
||||
- [x] Verify that source switching, entity renaming, adding/removing rooms and replacing the relay do not silently reuse incompatible fitted coefficients. *(0.2.2: tests for outdoor-source switching, adding, removing and re-adding rooms, and relay replacement. A renamed sensor is handled as removing one room and adding another; its old model is retained under the old ID.)*
|
||||
- [ ] Version persisted model schema; test corrupted/older state and migration/rollback behavior.
|
||||
- [ ] Verify calculated sample totals and successful/failed fitting counters; expose reasons why training/prediction is unavailable.
|
||||
- [x] Verify calculated sample totals and successful/failed fitting counters; expose reasons why training/prediction is unavailable. *(0.3.0: per-room accepted/ON/OFF/rejected counts, per-outcome poll counters, Learning Progress and Last Sample sensors, Status reasons; covered by tests.)*
|
||||
- [ ] Add CI, linting, tests and packaging validation (including HACS metadata/translations).
|
||||
- [ ] Update installation/release documentation and remove packaged `__pycache__`/`.pyc` files if present.
|
||||
- [x] Update installation/release documentation and remove packaged `__pycache__`/`.pyc` files if present. *(0.2.2: CHANGELOG with upgrade/rollback notes; package check rejects tracked bytecode.)*
|
||||
|
||||
**Exit criteria:** No control writes; reproducible tests covering HA lifecycle; clean upgrade/removal; clear status during outages; no significant model-data loss or silent corruption on restart.
|
||||
|
||||
@@ -70,17 +73,51 @@ remote runner execution remains unverified. Next improve event-based sampling an
|
||||
identity/persistence migration tests, then extend the HA version matrix. Only after those reliability checks proceed to Milestone 1's
|
||||
recorded forecasts and walk-forward evaluation. Live control remains unauthorized.
|
||||
|
||||
A second audit pass (see AUDIT.md) fixed radiator-lag resets at every relay change,
|
||||
RLS covariance windup and a restore tolerance that could reset long-running models,
|
||||
optional weather sources that could not be cleared in Options, and spurious
|
||||
prediction discards. It added `sample_result`/lag diagnostics and a static
|
||||
shadow-only tripwire test. Relay transitions between polls are now integrated at
|
||||
their timestamp; multiple changes within one poll still need event-based relay
|
||||
history before the transition item below can be checked.
|
||||
|
||||
Release 0.2.2 adds that event-based relay history (read-only listener), keeps
|
||||
models of rooms removed in Options, shows each sensor's longest report gap, stops
|
||||
recording volatile attributes, and tests against HA 2025.12.0 and 2026.2.3.
|
||||
|
||||
### Stabilization after 0.4.0 — 2026-10-27
|
||||
|
||||
See AUDIT.md. Four confirmed problems fixed, each with regression tests: unknown
|
||||
window states read as closed; learned coefficients reused after incompatible
|
||||
configuration changes (now per-component fingerprints with selective
|
||||
recalibration, logged in `model_changes`); plans published from inputs that
|
||||
changed during optimization or by an overtaken run; and model accuracy reported
|
||||
as if it were forecast accuracy. Reload/unload tests now cover repeated reloads
|
||||
(one relay listener, one polling coordinator). Released as 0.4.1; not yet tested on HAOS.
|
||||
|
||||
### Final stability fixes after 0.4.1 — 2026-10-27
|
||||
|
||||
An independent review of 0.4.1 reported four problems; three were reproduced and
|
||||
the fourth (lifecycle races) confirmed by reading Home Assistant's unload order.
|
||||
Fixed with regression tests (see AUDIT.md): Model Error spans with unknown inputs
|
||||
are now `incomplete`, not scored; window sensors unavailable at startup are
|
||||
unknown, with last known states saved and restored as unknown; unload, reload
|
||||
and removal can no longer publish stale results or recreate a deleted model;
|
||||
Forecast Error scores only forecasts whose plan the relay matched within 2
|
||||
minutes. Also added: **Reset learned model** in Configure. Released as 0.4.2; not
|
||||
yet tested on HAOS.
|
||||
|
||||
## Milestone 1 — Explainable forecasts and dashboards (target v0.3.0)
|
||||
|
||||
**Objective:** Users can verify what the model predicts and whether it is actually accurate.
|
||||
|
||||
- [ ] Expose trajectories for each room at 5–15-minute resolution for 2–3 hours, with forecast generation timestamp, horizon and input provenance.
|
||||
- [ ] Expose predicted +15/+30/+60/+120 minute temperatures and full proposed ON/OFF schedule.
|
||||
- [ ] Expose trajectories for each room at 5–15-minute resolution for 2–3 hours, with forecast generation timestamp, horizon and input provenance. *(0.3.0: 15-minute trajectory over the horizon; generation timestamp and provenance still to add.)*
|
||||
- [x] Expose predicted +15/+30/+60/+120 minute temperatures and full proposed ON/OFF schedule. *(0.3.0: `forecast_15min` and `plan_15min` on each room forecast entity.)*
|
||||
- [ ] Provide an HA dashboard example using native cards or a clearly documented optional chart card (do not bundle a heavyweight frontend unnecessarily).
|
||||
- [ ] Make clear that the **MPC plan forecast is counterfactual** while Generic Thermostat is controlling the relay; add a separate actual-policy forecast/evaluation pathway.
|
||||
- [ ] Store a bounded history of predictions with snapshots of inputs, proposed plan and eventually observed outcome.
|
||||
- [ ] Implement out-of-sample/walk-forward MAE and bias at 15, 30, 60 and 120 minutes; include sample counts and confidence/quality labels.
|
||||
- [ ] Compare against simple benchmarks such as current-temperature persistence and a fixed thermal baseline.
|
||||
- [ ] Make clear that the **MPC plan forecast is counterfactual** while Generic Thermostat is controlling the relay; add a separate actual-policy forecast/evaluation pathway. *(0.3.0: plan forecasts labelled counterfactual; evaluation uses the actual relay history. Stabilization: published forecasts are scored only when the relay followed their plan and otherwise counted as `counterfactual`. An actual-policy forecast, simulating Generic Thermostat, is still open.)*
|
||||
- [ ] Store a bounded history of predictions with snapshots of inputs, proposed plan and eventually observed outcome. *(Stabilization: each published forecast is kept with its start readings, plan, model revision and assumptions, including any it had to assume, until scored, in memory only; the scored statistics persist, split into matched, approximate, counterfactual and incomplete. No browsable history yet.)*
|
||||
- [x] Implement out-of-sample/walk-forward MAE and bias at 15, 30, 60 and 120 minutes; include sample counts and confidence/quality labels. *(0.3.0: replay with parameters frozen at the forecast start and the actual relay history; `skill` against persistence. Stabilization: these are now the Model Error sensors, with RMSE, effective sample counts and quality labels; a separate Forecast Error sensor scores published forecasts. No confidence intervals yet.)*
|
||||
- [ ] Compare against simple benchmarks such as current-temperature persistence and a fixed thermal baseline. *(0.3.0: persistence done; fixed baseline open.)*
|
||||
- [ ] Add inspectable explanations: expected heat loss, expected heating response, target, room weights and reason for the next proposed action.
|
||||
|
||||
**Candidate quality gate:** Sustain **about 0.3–0.4°C or better 60-minute MAE** on representative held-out data *under comparable known relay input sequences*, without systematic room bias. This is a starting engineering goal to validate, not a guarantee or an arbitrary gate to waive. Compare to baselines.
|
||||
@@ -89,14 +126,14 @@ recorded forecasts and walk-forward evaluation. Live control remains unauthorize
|
||||
|
||||
**Objective:** Improve forecasts with genuinely useful features, not just more inputs.
|
||||
|
||||
- [ ] Compare existing per-room linear model with a multi-zone RC model including inter-room heat exchange.
|
||||
- [ ] Address multiple sensors within a single physical room; configurable room grouping, aggregation and comfort priorities.
|
||||
- [x] Compare existing per-room linear model with a multi-zone RC model including inter-room heat exchange. *(0.4.0: layered zone model with neighbour exchange; base model scored alongside on every forecast. Exchange between zones sharing one boiler is weakly identifiable and reported as such.)*
|
||||
- [x] Address multiple sensors within a single physical room; configurable room grouping, aggregation and comfort priorities. *(0.4.0: zones with sensor weights, comfort weights and target offsets.)*
|
||||
- [ ] Distinguish air/room thermal mass and radiator lag where observable; avoid overfitting unidentifiable parameters.
|
||||
- [ ] Add optional Recorder history import/backfill for **indoor sensor + physical relay** history; do not assume old HA Recorder data still exists.
|
||||
- [ ] Add outdoor forecast data with future timestamps (forecast provider selectable) and compare forecast-based versus constant-outdoor assumptions.
|
||||
- [ ] Experiment with wind/cloud cover as optional covariates; retain them only if held-out forecast error improves. Solar gains may require orientation/irradiance or a useful proxy.
|
||||
- [ ] Experiment with wind/cloud cover as optional covariates; retain them only if held-out forecast error improves. Solar gains may require orientation/irradiance or a useful proxy. *(0.4.0: wind speed, direction and gusts for open windows, optional and judged by `improvement_vs_base`; clouds and solar still open.)*
|
||||
- [ ] Expose source reliability and provider comparison: normalized units, update age, outages and sensor offsets.
|
||||
- [ ] Handle shifts in room usage, opened windows, sensor relocation and season changes without unstable feedback.
|
||||
- [ ] Handle shifts in room usage, opened windows, sensor relocation and season changes without unstable feedback. *(0.4.0: opened windows handled by a separate ventilation layer; electrical loads by heat sources. Sensor relocation and seasons still open.)*
|
||||
- [ ] Benchmark RAM, CPU and event-loop latency on Raspberry Pi 5. Prefer standard-library/small scientific dependencies and avoid heavy inference runtimes.
|
||||
|
||||
**Exit criteria:** Model complexity justified by reproducible forecast improvements over v0.3.0; stable fitting and manageable HAOS resource usage. Retain the simpler model as fallback where it performs better.
|
||||
@@ -146,13 +183,36 @@ Only after the Milestone 3 gate has been satisfied:
|
||||
- Guaranteeing a fuel saving based solely on relay-state history.
|
||||
- Auto-switching the user's HVAC mode between `off` and `heat`.
|
||||
|
||||
## Priority backlog for the next Codex session
|
||||
## Priority backlog for the next session
|
||||
|
||||
1. Confirm the imported repository contents match v0.2.1; run existing tests and write an honest baseline audit.
|
||||
2. Review HA lifecycle, polling/sample timing, options reload, storage/cleanup and failure handling; fix P0 problems before features.
|
||||
3. Add a test harness (HA fixtures/mocks) and a shadow-only invariant test prohibiting all actuator service calls.
|
||||
4. Add forecast recording and held-out evaluation, then expose a trajectory usable by an HA dashboard.
|
||||
5. Only then consider richer weather inputs and the live-control **design**; do not implement live control without separate authorization.
|
||||
Items 1–3 of the original backlog are done (see AUDIT.md and CHANGELOG.md 0.2.2).
|
||||
|
||||
1. **HAOS acceptance of 0.2.3 by the user:** install, restart, options reload, room
|
||||
removal and re-adding, manual heat/off, a stale and an unavailable room sensor
|
||||
(with the keep-last-value checkbox off and on), uninstall on a disposable
|
||||
entry; confirm Generic Thermostat behavior is unchanged.
|
||||
2. Finish Milestone 0: committed rollback/migration fixtures; failed-fit counters;
|
||||
lint/type checks; remote CI run. Stale-room policy decided in 0.2.3 (leave the
|
||||
room out, or optionally keep its last value for up to 24 hours). Before live
|
||||
control, decide a minimum `weight_coverage` and the outdoor-temperature outage
|
||||
policy, which still pauses.
|
||||
3. Configure zones, the UPS heat source and the windows on HAOS; after a week of
|
||||
heating review Model Error `improvement_vs_base` and `quality`, per-zone bias,
|
||||
coefficient `confidence` (base layer included), `model_changes` after any
|
||||
reconfiguration, and the Forecast Error counts (matched, approximate,
|
||||
counterfactual, incomplete and why). Switch off optional terms that do not
|
||||
help. During HAOS acceptance also try: a window sensor unavailable across an HA
|
||||
restart, a zone edit, a reload while the integration is running, *Reset learned
|
||||
model* and removing a disposable entry (its `.storage/heating_mpc.<entry_id>`
|
||||
file must be gone afterwards).
|
||||
4. Milestone 1 (rest): a dashboard example; forecast timestamps/provenance;
|
||||
explanations for the proposed action; an actual-policy forecast that simulates
|
||||
Generic Thermostat.
|
||||
5. Milestone 2 (rest): learned response times (compare candidate time constants
|
||||
out of sample), a wall-mass state if recovery data shows it is needed, Recorder
|
||||
backfill, weather forecasts, and a Raspberry Pi 5 resource benchmark.
|
||||
6. Only then consider the live-control **design**; do not implement live control
|
||||
without separate authorization.
|
||||
|
||||
## Definition of done for each milestone
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
"""Heating MPC custom integration, initial release shadow-only."""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .const import 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 .coordinator import HeatingMPCCoordinator
|
||||
from .coordinator import HeatingMPCCoordinator, mark_model_removed, storage_lock
|
||||
from .zones import SUBENTRY_ZONE, ZONE_KEY, ZONE_NAME, legacy_zone_data
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||
|
||||
@@ -17,19 +21,28 @@ PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
coordinator = HeatingMPCCoordinator(hass, entry)
|
||||
await coordinator.async_restore_model()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
# Read-only: timestamps relay changes between polls for the radiator-lag estimate.
|
||||
unsub_relay = async_track_state_change_event(
|
||||
hass, [coordinator.config[CONF_HEATER]], coordinator.async_relay_changed)
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
except Exception:
|
||||
hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||
except BaseException:
|
||||
# HA skips unload callbacks for unexpected setup errors, so clean up here,
|
||||
# including a refresh scheduled by entities that were already added.
|
||||
unsub_relay()
|
||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
await coordinator.async_shutdown()
|
||||
raise
|
||||
# Remove only obsolete room forecasts belonging to this entry/platform.
|
||||
entry.async_on_unload(unsub_relay)
|
||||
# Remove only obsolete zone forecasts belonging to this entry/platform. Zones
|
||||
# deleted in the UI are also removed by HA through their subentry link.
|
||||
registry = er.async_get(hass)
|
||||
expected = {f"{entry.entry_id}_{room}_predicted_60min" for room in coordinator.rooms}
|
||||
expected = {f"{entry.entry_id}_{zone}_predicted_60min" for zone in coordinator.rooms}
|
||||
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
|
||||
if (entity.platform == DOMAIN and
|
||||
entity.unique_id.startswith(f"{entry.entry_id}_sensor.") and
|
||||
entity.unique_id.startswith(f"{entry.entry_id}_") and
|
||||
entity.unique_id.endswith("_predicted_60min") and
|
||||
entity.unique_id not in expected):
|
||||
registry.async_remove(entity.entity_id)
|
||||
@@ -42,21 +55,38 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
coordinator = hass.data.get(DOMAIN, {}).get(entry.entry_id)
|
||||
if coordinator is not None:
|
||||
# Before anything is unloaded: an update still running must not publish or save.
|
||||
coordinator.async_begin_shutdown()
|
||||
ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||
elif coordinator is not None:
|
||||
coordinator.async_cancel_shutdown()
|
||||
return ok
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Remove the learned model when this integration instance is deleted."""
|
||||
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
||||
await store.async_remove()
|
||||
# Wait for a save still in progress, which would otherwise recreate the file;
|
||||
# a save still waiting for the lock sees the mark and does not write.
|
||||
async with storage_lock(hass, entry.entry_id):
|
||||
mark_model_removed(hass, entry.entry_id)
|
||||
await store.async_remove()
|
||||
hass.data.get(f"{DOMAIN}_storage_locks", {}).pop(entry.entry_id, None)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate legacy single weather source without changing user selections."""
|
||||
if entry.version > 2:
|
||||
"""Migrate older entries without changing the user's selections.
|
||||
|
||||
1 -> 2: single outdoor sensor becomes a prioritized source list.
|
||||
2 -> 3: each room sensor becomes a one-sensor zone subentry with its comfort
|
||||
weight. Its entity ID stays the zone key, so learned models, forecast entities
|
||||
and accuracy statistics carry over. Older versions cannot load version 3.
|
||||
"""
|
||||
if entry.version > 3:
|
||||
return False
|
||||
if entry.version == 1:
|
||||
def migrate(config):
|
||||
@@ -69,4 +99,25 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry, data=migrate(entry.data), options=migrate(entry.options),
|
||||
version=2, minor_version=1,
|
||||
)
|
||||
if entry.version == 2:
|
||||
config = {**entry.data, **entry.options}
|
||||
rooms = [room for room in config.get(CONF_ROOMS, []) if isinstance(room, str)]
|
||||
registry = er.async_get(hass)
|
||||
names = {}
|
||||
for room in rooms:
|
||||
state, registered = hass.states.get(room), registry.async_get(room)
|
||||
names[room] = ((state.name if state else None)
|
||||
or (registered.name or registered.original_name if registered else None)
|
||||
or room)
|
||||
existing = {sub.unique_id for sub in entry.subentries.values()}
|
||||
for data in legacy_zone_data(rooms, config.get(CONF_WEIGHTS), names):
|
||||
if data[ZONE_KEY] not in existing:
|
||||
hass.config_entries.async_add_subentry(entry, ConfigSubentry(
|
||||
data=MappingProxyType(data), subentry_type=SUBENTRY_ZONE,
|
||||
title=data[ZONE_NAME], unique_id=data[ZONE_KEY]))
|
||||
|
||||
def strip(config):
|
||||
return {k: v for k, v in config.items() if k not in (CONF_ROOMS, CONF_WEIGHTS)}
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data=strip(entry.data), options=strip(entry.options), version=3, minor_version=1)
|
||||
return True
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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)]
|
||||
@@ -1,7 +1,8 @@
|
||||
"""UI-only installation and reconfiguration: rooms, source priorities, weights."""
|
||||
"""UI-only installation and reconfiguration: zones, source priorities, weights."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
@@ -9,13 +10,31 @@ from homeassistant.core import callback
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import (
|
||||
CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE,
|
||||
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE,
|
||||
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
|
||||
CONF_WEIGHTS, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
||||
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS,
|
||||
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
||||
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, CONF_INTERNAL_HALF_LIFE, DEFAULTS,
|
||||
CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST, CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES,
|
||||
WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA,
|
||||
)
|
||||
from .heat_sources import (
|
||||
BASELINE_AUTO, BASELINE_MANUAL, DEFAULT_RESPONSE_MINUTES, MAX_POWER_W, RESPONSE_MINUTES_RANGE,
|
||||
SOURCE_BASELINE_MODE, SOURCE_BASELINE_W, SOURCE_ENABLED, SOURCE_FRACTION, SOURCE_INCLUDED_IN,
|
||||
SOURCE_KEY, SOURCE_NAME, SOURCE_RESPONSE, SOURCE_SENSOR, SOURCE_ZONE, SUBENTRY_HEAT_SOURCE,
|
||||
HeatSource, build_sources, validate_source,
|
||||
)
|
||||
from .coordinator import async_reset_learned_model
|
||||
from .weather_sources import sources_for
|
||||
from .windows import (
|
||||
DEFAULT_AZIMUTH, OPENING_RANGE, SUBENTRY_WINDOW, WINDOW_AZIMUTH, WINDOW_ENABLED, WINDOW_INVERTED,
|
||||
WINDOW_KEY, WINDOW_NAME, WINDOW_OPENING, WINDOW_SENSOR, WINDOW_ZONE, Window, build_windows,
|
||||
validate_window,
|
||||
)
|
||||
from .zones import (
|
||||
COMFORT_WEIGHT_RANGE, SENSOR_WEIGHT_RANGE, SUBENTRY_ZONE, TARGET_OFFSET_RANGE,
|
||||
ZONE_COMFORT_WEIGHT, ZONE_KEY, ZONE_NAME, ZONE_NEIGHBORS, ZONE_SENSOR_WEIGHTS,
|
||||
ZONE_SENSORS, ZONE_TARGET_OFFSET, Zone, build_zones, legacy_zone_data, validate_zone,
|
||||
)
|
||||
|
||||
|
||||
def _entity_picker(multiple: bool = False):
|
||||
@@ -30,17 +49,21 @@ def _required(key: str, data: dict):
|
||||
|
||||
|
||||
def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
||||
"""Setup asks for room sensors (one zone each); Configure manages zones separately."""
|
||||
data = config or {}
|
||||
schema: dict[Any, Any] = {
|
||||
_required(CONF_CLIMATE, data): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="climate")),
|
||||
_required(CONF_HEATER, data): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="switch")),
|
||||
_required(CONF_ROOMS, data): _entity_picker(multiple=True),
|
||||
}
|
||||
if not detailed:
|
||||
schema[_required(CONF_ROOMS, data)] = _entity_picker(multiple=True)
|
||||
for key in (CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE):
|
||||
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = vol.All(
|
||||
vol.Coerce(int), vol.Range(min=5, max=1440))
|
||||
schema[vol.Required(CONF_ROOM_STALE_KEEP, default=bool(
|
||||
data.get(CONF_ROOM_STALE_KEEP, DEFAULTS[CONF_ROOM_STALE_KEEP])))] = selector.BooleanSelector()
|
||||
if detailed:
|
||||
for key, allowed in {
|
||||
CONF_HORIZON: vol.In([60, 90, 120, 150, 180]),
|
||||
@@ -49,8 +72,13 @@ def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
||||
CONF_SWITCH_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)),
|
||||
CONF_HEAT_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)),
|
||||
CONF_BAND: vol.All(vol.Coerce(float), vol.Range(min=0.05, max=0.7)),
|
||||
CONF_INTERNAL_HALF_LIFE: vol.All(vol.Coerce(int), vol.Range(min=0, max=720)),
|
||||
CONF_WINDOW_OPEN_MINUTES: vol.All(vol.Coerce(int), vol.Range(min=5, max=120)),
|
||||
CONF_RECOVERY_MINUTES: vol.All(vol.Coerce(int), vol.Range(min=5, max=120)),
|
||||
}.items():
|
||||
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed
|
||||
for key in (CONF_VENT_WIND, CONF_VENT_GUST, CONF_VENT_CROSS):
|
||||
schema[vol.Required(key, default=bool(data.get(key, DEFAULTS[key])))] = selector.BooleanSelector()
|
||||
return vol.Schema(schema)
|
||||
|
||||
|
||||
@@ -63,11 +91,21 @@ def _weather_schema(config: dict[str, Any], metrics: tuple[str, ...]):
|
||||
# A temperature source is mandatory: other weather inputs are optional.
|
||||
marker = vol.Required(key, default=selected) if selected else vol.Required(key)
|
||||
else:
|
||||
marker = vol.Optional(key, default=selected)
|
||||
# A default would re-insert old sources whenever a cleared field is
|
||||
# omitted from the submission; a suggested value only pre-fills the form.
|
||||
marker = vol.Optional(key, description={"suggested_value": selected})
|
||||
schema[marker] = _entity_picker(multiple=True)
|
||||
return vol.Schema(schema)
|
||||
|
||||
|
||||
def _with_cleared_weather(user_input: dict[str, Any], metrics: tuple[str, ...]) -> dict[str, Any]:
|
||||
"""Store an omitted optional source list as empty, so it overrides older data."""
|
||||
result = dict(user_input)
|
||||
for metric in metrics:
|
||||
result.setdefault(WEATHER_SOURCES[metric], [])
|
||||
return result
|
||||
|
||||
|
||||
def _weight_schema(rooms: list[str], existing: dict[str, float] | None = None):
|
||||
weights = existing or {}
|
||||
return vol.Schema({
|
||||
@@ -86,10 +124,28 @@ def _valid_weather(rooms: list[str], data: dict) -> bool:
|
||||
return bool(sources) and len(sources) <= 10 and not set(sources).intersection(rooms)
|
||||
|
||||
|
||||
def entry_zones(entry: config_entries.ConfigEntry) -> list[Zone]:
|
||||
"""Zones of a config entry, from its zone subentries."""
|
||||
return build_zones((sub.data, sub.subentry_id) for sub in entry.subentries.values()
|
||||
if sub.subentry_type == SUBENTRY_ZONE)
|
||||
|
||||
|
||||
def entry_sources(entry: config_entries.ConfigEntry) -> list[HeatSource]:
|
||||
zones = {zone.key for zone in entry_zones(entry)}
|
||||
return build_sources(((sub.data, sub.subentry_id) for sub in entry.subentries.values()
|
||||
if sub.subentry_type == SUBENTRY_HEAT_SOURCE), zones)
|
||||
|
||||
|
||||
def _number(lo: float, hi: float, step: float) -> selector.NumberSelector:
|
||||
return selector.NumberSelector(selector.NumberSelectorConfig(
|
||||
min=lo, max=hi, step=step, mode=selector.NumberSelectorMode.BOX))
|
||||
|
||||
|
||||
class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Create a native observation-only MPC installation."""
|
||||
|
||||
VERSION = 2
|
||||
# 3: rooms and comfort weights moved into zone subentries.
|
||||
VERSION = 3
|
||||
|
||||
def __init__(self):
|
||||
self._draft: dict[str, Any] = {}
|
||||
@@ -110,25 +166,31 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if not _valid_weather(self._draft[CONF_ROOMS], user_input):
|
||||
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
||||
user_input, WEATHER_MAIN), errors={"base": "invalid_weather"})
|
||||
self._draft.update(user_input)
|
||||
self._draft.update(_with_cleared_weather(user_input, WEATHER_MAIN))
|
||||
return await self.async_step_weather_extra()
|
||||
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
||||
self._draft, WEATHER_MAIN))
|
||||
|
||||
async def async_step_weather_extra(self, user_input: dict[str, Any] | None = None):
|
||||
if user_input is not None:
|
||||
self._draft.update(user_input)
|
||||
self._draft.update(_with_cleared_weather(user_input, WEATHER_EXTRA))
|
||||
return await self.async_step_weights()
|
||||
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
|
||||
self._draft, WEATHER_EXTRA))
|
||||
|
||||
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
||||
if user_input is not None:
|
||||
self._draft[CONF_WEIGHTS] = {
|
||||
room: float(user_input[f"weight__{room}"])
|
||||
for room in self._draft[CONF_ROOMS]
|
||||
}
|
||||
return self.async_create_entry(title="Heating MPC", data=self._draft)
|
||||
rooms = self._draft.pop(CONF_ROOMS)
|
||||
weights = {room: float(user_input[f"weight__{room}"]) for room in rooms}
|
||||
names = {}
|
||||
for room in rooms:
|
||||
state = self.hass.states.get(room)
|
||||
names[room] = state.name if state else room
|
||||
# Each chosen sensor becomes its own zone; zones can be edited later.
|
||||
zones = [{"data": data, "subentry_type": SUBENTRY_ZONE, "title": data[ZONE_NAME],
|
||||
"unique_id": data[ZONE_KEY]}
|
||||
for data in legacy_zone_data(rooms, weights, names)]
|
||||
return self.async_create_entry(title="Heating MPC", data=self._draft, subentries=zones)
|
||||
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
||||
self._draft[CONF_ROOMS]))
|
||||
|
||||
@@ -137,6 +199,15 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
|
||||
return HeatingMPCOptionsFlow()
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(cls, config_entry: config_entries.ConfigEntry):
|
||||
return {SUBENTRY_ZONE: ZoneSubentryFlow, SUBENTRY_HEAT_SOURCE: HeatSourceSubentryFlow,
|
||||
SUBENTRY_WINDOW: WindowSubentryFlow}
|
||||
|
||||
|
||||
CONFIRM_RESET = "confirm_reset"
|
||||
|
||||
|
||||
class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Edit sensors, ranked alternative weather providers and MPC parameters."""
|
||||
@@ -144,24 +215,37 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||
def __init__(self):
|
||||
self._draft: dict[str, Any] = {}
|
||||
|
||||
def _zone_sensors(self) -> list[str]:
|
||||
return [sensor for zone in entry_zones(self.config_entry) for sensor in zone.sensors]
|
||||
|
||||
async def async_step_init(self, user_input: dict[str, Any] | None = None):
|
||||
return self.async_show_menu(step_id="init", menu_options=["settings", "reset_model"])
|
||||
|
||||
async def async_step_settings(self, user_input: dict[str, Any] | None = None):
|
||||
config = {**self.config_entry.data, **self.config_entry.options}
|
||||
if user_input is not None:
|
||||
if not _valid_rooms(user_input[CONF_ROOMS]):
|
||||
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
||||
config, detailed=True), errors={"base": "invalid_rooms"})
|
||||
self._draft = user_input
|
||||
return await self.async_step_weather()
|
||||
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
||||
return self.async_show_form(step_id="settings", data_schema=_main_schema(
|
||||
config, detailed=True))
|
||||
|
||||
async def async_step_reset_model(self, user_input: dict[str, Any] | None = None):
|
||||
"""Forget the learned model after an explicit confirmation; settings stay."""
|
||||
if user_input is not None:
|
||||
if user_input.get(CONFIRM_RESET) is not True:
|
||||
return self.async_abort(reason="model_not_reset")
|
||||
await async_reset_learned_model(self.hass, self.config_entry)
|
||||
return self.async_abort(reason="model_reset")
|
||||
return self.async_show_form(step_id="reset_model", data_schema=vol.Schema(
|
||||
{vol.Required(CONFIRM_RESET, default=False): selector.BooleanSelector()}))
|
||||
|
||||
async def async_step_weather(self, user_input: dict[str, Any] | None = None):
|
||||
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
|
||||
if user_input is not None:
|
||||
if not _valid_weather(self._draft[CONF_ROOMS], user_input):
|
||||
if not _valid_weather(self._zone_sensors(), user_input):
|
||||
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
||||
user_input, WEATHER_MAIN), errors={"base": "invalid_weather"})
|
||||
self._draft.update(user_input)
|
||||
self._draft.update(_with_cleared_weather(user_input, WEATHER_MAIN))
|
||||
return await self.async_step_weather_extra()
|
||||
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
||||
config, WEATHER_MAIN))
|
||||
@@ -169,20 +253,220 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||
async def async_step_weather_extra(self, user_input: dict[str, Any] | None = None):
|
||||
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
|
||||
if user_input is not None:
|
||||
self._draft.update(user_input)
|
||||
return await self.async_step_weights()
|
||||
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
|
||||
config, WEATHER_EXTRA))
|
||||
|
||||
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
||||
if user_input is not None:
|
||||
self._draft[CONF_WEIGHTS] = {
|
||||
room: float(user_input[f"weight__{room}"])
|
||||
for room in self._draft[CONF_ROOMS]
|
||||
}
|
||||
self._draft.update(_with_cleared_weather(user_input, WEATHER_EXTRA))
|
||||
# Prevent an old single-temperature sensor from overriding new sources.
|
||||
self._draft.pop(CONF_OUTSIDE, None)
|
||||
return self.async_create_entry(title="", data=self._draft)
|
||||
config = {**self.config_entry.data, **self.config_entry.options}
|
||||
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
||||
self._draft[CONF_ROOMS], config.get(CONF_WEIGHTS, {})))
|
||||
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
|
||||
config, WEATHER_EXTRA))
|
||||
|
||||
|
||||
class ZoneSubentryFlow(config_entries.ConfigSubentryFlow):
|
||||
"""Add or edit a thermal zone: its sensors, comfort weight and neighbours."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._draft: dict[str, Any] = {}
|
||||
|
||||
def _other_zones(self, key: str | None) -> list[Zone]:
|
||||
return [zone for zone in entry_zones(self._get_entry()) if zone.key != key]
|
||||
|
||||
def _schema(self, data: dict[str, Any], key: str | None) -> vol.Schema:
|
||||
schema: dict[Any, Any] = {
|
||||
vol.Required(ZONE_NAME, default=data.get(ZONE_NAME, "")): selector.TextSelector(),
|
||||
vol.Required(ZONE_SENSORS, default=list(data.get(ZONE_SENSORS, []))):
|
||||
_entity_picker(multiple=True),
|
||||
vol.Required(ZONE_COMFORT_WEIGHT, default=data.get(ZONE_COMFORT_WEIGHT, 1.0)):
|
||||
_number(*COMFORT_WEIGHT_RANGE, 0.1),
|
||||
vol.Required(ZONE_TARGET_OFFSET, default=data.get(ZONE_TARGET_OFFSET, 0.0)):
|
||||
_number(*TARGET_OFFSET_RANGE, 0.1),
|
||||
}
|
||||
others = self._other_zones(key)
|
||||
if others:
|
||||
schema[vol.Optional(ZONE_NEIGHBORS, description={
|
||||
"suggested_value": [n for n in data.get(ZONE_NEIGHBORS, []) if n in {z.key for z in others}]})] = \
|
||||
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||
options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in others],
|
||||
multiple=True, mode=selector.SelectSelectorMode.LIST))
|
||||
return vol.Schema(schema)
|
||||
|
||||
async def _zone_form(self, step_id: str, user_input: dict[str, Any] | None,
|
||||
existing: dict[str, Any], key: str | None):
|
||||
if user_input is not None:
|
||||
entry = self._get_entry()
|
||||
config = {**entry.data, **entry.options}
|
||||
weather = {s for metric in ("temperature",) for s in sources_for(config, metric)}
|
||||
error = validate_zone(user_input, self._other_zones(key), weather)
|
||||
if error:
|
||||
return self.async_show_form(step_id=step_id, data_schema=self._schema(user_input, key),
|
||||
errors={"base": error})
|
||||
self._draft = {**existing, **user_input, ZONE_NAME: user_input[ZONE_NAME].strip()}
|
||||
self._draft.setdefault(ZONE_NEIGHBORS, [])
|
||||
if len(user_input[ZONE_SENSORS]) > 1:
|
||||
return await self.async_step_sensor_weights()
|
||||
self._draft[ZONE_SENSOR_WEIGHTS] = {user_input[ZONE_SENSORS][0]: 1.0}
|
||||
return self._finish()
|
||||
return self.async_show_form(step_id=step_id, data_schema=self._schema(existing, key))
|
||||
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||
if not self._draft.get(ZONE_KEY):
|
||||
self._draft = {ZONE_KEY: f"zone_{uuid4().hex[:10]}"}
|
||||
return await self._zone_form("user", user_input, self._draft, None)
|
||||
|
||||
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
|
||||
subentry = self._get_reconfigure_subentry()
|
||||
existing = dict(subentry.data)
|
||||
existing.setdefault(ZONE_KEY, subentry.subentry_id)
|
||||
return await self._zone_form("reconfigure", user_input, existing, existing[ZONE_KEY])
|
||||
|
||||
async def async_step_sensor_weights(self, user_input: dict[str, Any] | None = None):
|
||||
"""One weight per sensor; fields are numbered so they have translated labels."""
|
||||
sensors = self._draft[ZONE_SENSORS]
|
||||
if user_input is not None:
|
||||
self._draft[ZONE_SENSOR_WEIGHTS] = {
|
||||
s: float(user_input[f"sensor_weight_{i}"]) for i, s in enumerate(sensors, 1)}
|
||||
return self._finish()
|
||||
weights = self._draft.get(ZONE_SENSOR_WEIGHTS) or {}
|
||||
names = []
|
||||
for i, sensor in enumerate(sensors, 1):
|
||||
state = self.hass.states.get(sensor)
|
||||
names.append(f"{i}. {state.name if state else sensor} ({sensor})")
|
||||
return self.async_show_form(
|
||||
step_id="sensor_weights",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(f"sensor_weight_{i}", default=weights.get(s, 1.0)): _number(*SENSOR_WEIGHT_RANGE, 0.1)
|
||||
for i, s in enumerate(sensors, 1)}),
|
||||
description_placeholders={"sensors": "\n".join(names)})
|
||||
|
||||
def _finish(self):
|
||||
data = {key: self._draft[key] for key in (
|
||||
ZONE_KEY, ZONE_NAME, ZONE_SENSORS, ZONE_SENSOR_WEIGHTS, ZONE_COMFORT_WEIGHT,
|
||||
ZONE_TARGET_OFFSET, ZONE_NEIGHBORS)}
|
||||
data[ZONE_COMFORT_WEIGHT] = float(data[ZONE_COMFORT_WEIGHT])
|
||||
data[ZONE_TARGET_OFFSET] = float(data[ZONE_TARGET_OFFSET])
|
||||
if self.source == config_entries.SOURCE_RECONFIGURE:
|
||||
return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
|
||||
title=data[ZONE_NAME], data=data)
|
||||
return self.async_create_entry(title=data[ZONE_NAME], data=data, unique_id=data[ZONE_KEY])
|
||||
|
||||
|
||||
class HeatSourceSubentryFlow(config_entries.ConfigSubentryFlow):
|
||||
"""Add or edit an electrical heat source (UPS, PC, server) in a zone."""
|
||||
|
||||
def _others(self, key: str | None) -> list[HeatSource]:
|
||||
return [s for s in entry_sources(self._get_entry()) if s.key != key]
|
||||
|
||||
def _schema(self, data: dict[str, Any], key: str | None) -> vol.Schema:
|
||||
zones = entry_zones(self._get_entry())
|
||||
parents = [s for s in self._others(key) if s.included_in is None]
|
||||
schema: dict[Any, Any] = {
|
||||
vol.Required(SOURCE_NAME, default=data.get(SOURCE_NAME, "")): selector.TextSelector(),
|
||||
vol.Required(SOURCE_SENSOR, **({"default": data[SOURCE_SENSOR]} if data.get(SOURCE_SENSOR) else {})):
|
||||
selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor", device_class="power")),
|
||||
vol.Required(SOURCE_ZONE, **({"default": data[SOURCE_ZONE]} if data.get(SOURCE_ZONE) else {})):
|
||||
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||
options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in zones])),
|
||||
vol.Required(SOURCE_FRACTION, default=data.get(SOURCE_FRACTION, 1.0)): _number(0.0, 1.0, 0.05),
|
||||
vol.Required(SOURCE_BASELINE_MODE, default=data.get(SOURCE_BASELINE_MODE, BASELINE_AUTO)):
|
||||
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||
options=[BASELINE_AUTO, BASELINE_MANUAL], translation_key=SOURCE_BASELINE_MODE)),
|
||||
vol.Optional(SOURCE_BASELINE_W, description={"suggested_value": data.get(SOURCE_BASELINE_W)}):
|
||||
_number(0.0, MAX_POWER_W, 1.0),
|
||||
vol.Required(SOURCE_RESPONSE, default=data.get(SOURCE_RESPONSE, DEFAULT_RESPONSE_MINUTES)):
|
||||
_number(*RESPONSE_MINUTES_RANGE, 1.0),
|
||||
vol.Required(SOURCE_ENABLED, default=data.get(SOURCE_ENABLED, True)): selector.BooleanSelector(),
|
||||
}
|
||||
if parents:
|
||||
schema[vol.Optional(SOURCE_INCLUDED_IN, description={
|
||||
"suggested_value": data.get(SOURCE_INCLUDED_IN)})] = selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(options=[
|
||||
selector.SelectOptionDict(value=s.key, label=s.name) for s in parents]))
|
||||
return vol.Schema(schema)
|
||||
|
||||
async def _form(self, step_id: str, user_input: dict[str, Any] | None, existing: dict[str, Any]):
|
||||
key = existing[SOURCE_KEY]
|
||||
if not entry_zones(self._get_entry()):
|
||||
return self.async_abort(reason="no_zones")
|
||||
if user_input is not None:
|
||||
data = {**existing, **user_input, SOURCE_KEY: key}
|
||||
data.setdefault(SOURCE_INCLUDED_IN, None)
|
||||
error = validate_source(data, self._others(key))
|
||||
if error:
|
||||
return self.async_show_form(step_id=step_id, data_schema=self._schema(data, key),
|
||||
errors={"base": error})
|
||||
data[SOURCE_NAME] = data[SOURCE_NAME].strip()
|
||||
for number in (SOURCE_FRACTION, SOURCE_RESPONSE):
|
||||
data[number] = float(data[number])
|
||||
if data.get(SOURCE_BASELINE_W) is not None:
|
||||
data[SOURCE_BASELINE_W] = float(data[SOURCE_BASELINE_W])
|
||||
if self.source == config_entries.SOURCE_RECONFIGURE:
|
||||
return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
|
||||
title=data[SOURCE_NAME], data=data)
|
||||
return self.async_create_entry(title=data[SOURCE_NAME], data=data, unique_id=key)
|
||||
return self.async_show_form(step_id=step_id, data_schema=self._schema(existing, key))
|
||||
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||
if not hasattr(self, "_key"):
|
||||
self._key = f"source_{uuid4().hex[:10]}"
|
||||
return await self._form("user", user_input, {SOURCE_KEY: self._key})
|
||||
|
||||
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
|
||||
subentry = self._get_reconfigure_subentry()
|
||||
existing = dict(subentry.data)
|
||||
existing.setdefault(SOURCE_KEY, subentry.subentry_id)
|
||||
return await self._form("reconfigure", user_input, existing)
|
||||
|
||||
|
||||
def entry_windows(entry: config_entries.ConfigEntry) -> list[Window]:
|
||||
zones = {zone.key for zone in entry_zones(entry)}
|
||||
return build_windows(((sub.data, sub.subentry_id) for sub in entry.subentries.values()
|
||||
if sub.subentry_type == SUBENTRY_WINDOW), zones)
|
||||
|
||||
|
||||
class WindowSubentryFlow(config_entries.ConfigSubentryFlow):
|
||||
"""Add or edit a window contact sensor with its zone and orientation."""
|
||||
|
||||
def _schema(self, data: dict[str, Any]) -> vol.Schema:
|
||||
zones = entry_zones(self._get_entry())
|
||||
return vol.Schema({
|
||||
vol.Required(WINDOW_NAME, default=data.get(WINDOW_NAME, "")): selector.TextSelector(),
|
||||
vol.Required(WINDOW_SENSOR, **({"default": data[WINDOW_SENSOR]} if data.get(WINDOW_SENSOR) else {})):
|
||||
selector.EntitySelector(selector.EntitySelectorConfig(domain="binary_sensor")),
|
||||
vol.Required(WINDOW_ZONE, **({"default": data[WINDOW_ZONE]} if data.get(WINDOW_ZONE) else {})):
|
||||
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||
options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in zones])),
|
||||
vol.Required(WINDOW_INVERTED, default=data.get(WINDOW_INVERTED, False)): selector.BooleanSelector(),
|
||||
vol.Required(WINDOW_AZIMUTH, default=data.get(WINDOW_AZIMUTH, DEFAULT_AZIMUTH)):
|
||||
_number(0.0, 359.0, 1.0),
|
||||
vol.Required(WINDOW_OPENING, default=data.get(WINDOW_OPENING, 1.0)): _number(*OPENING_RANGE, 0.1),
|
||||
vol.Required(WINDOW_ENABLED, default=data.get(WINDOW_ENABLED, True)): selector.BooleanSelector(),
|
||||
})
|
||||
|
||||
async def _form(self, step_id: str, user_input: dict[str, Any] | None, existing: dict[str, Any]):
|
||||
key = existing[WINDOW_KEY]
|
||||
if not entry_zones(self._get_entry()):
|
||||
return self.async_abort(reason="no_zones")
|
||||
if user_input is not None:
|
||||
data = {**existing, **user_input, WINDOW_KEY: key}
|
||||
error = validate_window(data, [w for w in entry_windows(self._get_entry()) if w.key != key])
|
||||
if error:
|
||||
return self.async_show_form(step_id=step_id, data_schema=self._schema(data),
|
||||
errors={"base": error})
|
||||
data[WINDOW_NAME] = data[WINDOW_NAME].strip()
|
||||
data[WINDOW_AZIMUTH] = float(data[WINDOW_AZIMUTH])
|
||||
data[WINDOW_OPENING] = float(data[WINDOW_OPENING])
|
||||
if self.source == config_entries.SOURCE_RECONFIGURE:
|
||||
return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
|
||||
title=data[WINDOW_NAME], data=data)
|
||||
return self.async_create_entry(title=data[WINDOW_NAME], data=data, unique_id=key)
|
||||
return self.async_show_form(step_id=step_id, data_schema=self._schema(existing))
|
||||
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||
if not hasattr(self, "_key"):
|
||||
self._key = f"window_{uuid4().hex[:10]}"
|
||||
return await self._form("user", user_input, {WINDOW_KEY: self._key})
|
||||
|
||||
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
|
||||
subentry = self._get_reconfigure_subentry()
|
||||
existing = dict(subentry.data)
|
||||
existing.setdefault(WINDOW_KEY, subentry.subentry_id)
|
||||
return await self._form("reconfigure", user_input, existing)
|
||||
@@ -12,12 +12,28 @@ CONF_SWITCH_COST = "switching_cost"
|
||||
CONF_HEAT_COST = "heating_cost"
|
||||
CONF_ROOM_MAX_AGE = "room_max_age_minutes"
|
||||
CONF_WEATHER_MAX_AGE = "weather_max_age_minutes"
|
||||
# Stale room sensors: False leaves them out (re-weighting the other rooms),
|
||||
# True keeps using their last value for sensors that report only on change.
|
||||
CONF_ROOM_STALE_KEEP = "room_stale_use_last_value"
|
||||
CONF_BAND = "comfort_band"
|
||||
# Forecast assumption: extra electrical heat returns to baseline with this half-life.
|
||||
CONF_INTERNAL_HALF_LIFE = "internal_heat_half_life_minutes"
|
||||
# Forecast assumption: an open window closes after this many more minutes.
|
||||
CONF_WINDOW_OPEN_MINUTES = "window_open_minutes"
|
||||
# Time constant of air recovering towards the walls' temperature after closing.
|
||||
CONF_RECOVERY_MINUTES = "recovery_minutes"
|
||||
# Optional ventilation terms (each must earn its keep in the accuracy comparison).
|
||||
CONF_VENT_WIND = "ventilation_wind"
|
||||
CONF_VENT_GUST = "ventilation_gust"
|
||||
CONF_VENT_CROSS = "ventilation_cross"
|
||||
|
||||
# Each source list is ordered: first valid sensor wins; remaining are fallbacks.
|
||||
WEATHER_SOURCES = {
|
||||
"temperature": "outside_temperature_sources",
|
||||
"wind": "outside_wind_sources",
|
||||
# Meteorological: the direction the wind blows FROM, degrees clockwise from north.
|
||||
"wind_direction": "outside_wind_direction_sources",
|
||||
"wind_gust": "outside_wind_gust_sources",
|
||||
"clouds": "outside_clouds_sources",
|
||||
"humidity": "outside_humidity_sources",
|
||||
"feels_like": "outside_feelslike_sources",
|
||||
@@ -26,18 +42,36 @@ WEATHER_SOURCES = {
|
||||
"rain": "outside_rain_sources",
|
||||
"snow": "outside_snow_sources",
|
||||
}
|
||||
WEATHER_MAIN = ("temperature", "wind", "clouds")
|
||||
WEATHER_MAIN = ("temperature", "wind", "wind_direction", "wind_gust", "clouds")
|
||||
WEATHER_EXTRA = ("humidity", "feels_like", "pressure", "visibility", "rain", "snow")
|
||||
|
||||
DEFAULTS = {
|
||||
CONF_ROOM_MAX_AGE: 30,
|
||||
CONF_WEATHER_MAX_AGE: 120,
|
||||
CONF_ROOM_STALE_KEEP: False,
|
||||
CONF_HORIZON: 120,
|
||||
CONF_MIN_ON: 15,
|
||||
CONF_MIN_OFF: 10,
|
||||
CONF_SWITCH_COST: 0.15,
|
||||
CONF_HEAT_COST: 0.025,
|
||||
CONF_BAND: 0.15,
|
||||
CONF_INTERNAL_HALF_LIFE: 30,
|
||||
CONF_WINDOW_OPEN_MINUTES: 15,
|
||||
CONF_RECOVERY_MINUTES: 20,
|
||||
CONF_VENT_WIND: True,
|
||||
CONF_VENT_GUST: False,
|
||||
CONF_VENT_CROSS: False,
|
||||
}
|
||||
# A room sensor silent this long is treated as dead even when keeping last values.
|
||||
ROOM_STALE_KEEP_MAX_MINUTES = 24 * 60
|
||||
SAMPLE_MINUTES = 5
|
||||
# A room's model is trusted for forecasts after this many accepted samples.
|
||||
READY_SAMPLES = 200
|
||||
READY_ON_SAMPLES = 30
|
||||
READY_OFF_SAMPLES = 30
|
||||
# Outcome of each poll for learning (Status sample_result, Last Sample sensor).
|
||||
SAMPLE_RESULTS = (
|
||||
"waiting_for_inputs", "first_sample", "sensors_changed", "learned", "ventilation", "partly_rejected",
|
||||
"interval_out_of_range", "relay_changed", "outside_source_changed", "lag_uncertain",
|
||||
)
|
||||
BLOCK_MINUTES = 15
|
||||
@@ -1,46 +1,243 @@
|
||||
"""Read existing HA entities, learn thermal response, produce shadow MPC decisions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import partial
|
||||
from math import exp, isfinite
|
||||
from math import exp
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE,
|
||||
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE, ROOM_STALE_KEEP_MAX_MINUTES,
|
||||
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, WEATHER_SOURCES,
|
||||
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST,
|
||||
CONF_BAND, DEFAULTS,
|
||||
CONF_BAND, CONF_INTERNAL_HALF_LIFE, CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST,
|
||||
CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
|
||||
)
|
||||
from .compatibility import FINGERPRINT_VERSION, baseline_fingerprints, changed, reason, zone_fingerprints
|
||||
from .data_quality import ReportAgeHistory, freshness, inputs_differ
|
||||
from .evaluation import ForecastEvaluator, OperationalForecastLedger, model_snapshot
|
||||
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
|
||||
from .learning import FeatureLearner
|
||||
from .physics import (
|
||||
LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag,
|
||||
lag_from_time_in_state, optimize, relay_segments,
|
||||
)
|
||||
from .data_quality import freshness
|
||||
from .physics import Learner, optimize
|
||||
from .weather_sources import choose_weather_sources, sources_for, normalize
|
||||
from .windows import (
|
||||
SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, contact_open, facing_cos, ventilation_vector,
|
||||
)
|
||||
from .zone_model import ZoneModel, compile_step_model, extended_features, feature_vector
|
||||
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
# Relay changes kept between polls; more than this in five minutes is chatter.
|
||||
MAX_RELAY_EVENTS = 256
|
||||
# Models of zones removed in the UI, kept in case a zone is added back.
|
||||
MAX_RETIRED_ROOMS = 24
|
||||
MAX_MODEL_CHANGES = 20
|
||||
BASE_TERMS = ("loss", "heat", "bias")
|
||||
|
||||
|
||||
def storage_lock(hass: HomeAssistant, entry_id: str) -> asyncio.Lock:
|
||||
"""One lock per entry, shared by successive coordinators across reloads.
|
||||
|
||||
A reload's new coordinator restores only after the old one's save finished.
|
||||
"""
|
||||
return hass.data.setdefault(f"{DOMAIN}_storage_locks", {}).setdefault(entry_id, asyncio.Lock())
|
||||
|
||||
|
||||
def mark_model_removed(hass: HomeAssistant, entry_id: str) -> None:
|
||||
"""Record, under the storage lock, that the entry's model file was deleted for good."""
|
||||
hass.data.setdefault(f"{DOMAIN}_removed", set()).add(entry_id)
|
||||
|
||||
|
||||
def model_removed(hass: HomeAssistant, entry_id: str) -> bool:
|
||||
return entry_id in hass.data.get(f"{DOMAIN}_removed", ())
|
||||
|
||||
|
||||
# Stored keys holding what was learned. The rest is observation, not model: the
|
||||
# heat sources' baseline samples, the window history and the heater's identity.
|
||||
LEARNED_KEYS = ("learners", "retired_learners", "zone_models", "ventilation_models", "evaluation",
|
||||
"operational_evaluation", "fingerprint_version", "fingerprints", "on_samples", "off_samples")
|
||||
|
||||
|
||||
async def async_reset_learned_model(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Forget everything learned for this entry; the configuration is not touched.
|
||||
|
||||
The entry is unloaded first, so no running update can save the old model
|
||||
afterwards, then set up again from the stripped data. Accuracy statistics go
|
||||
too: they describe the model being discarded.
|
||||
"""
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
||||
async with storage_lock(hass, entry.entry_id):
|
||||
stored = await store.async_load()
|
||||
if isinstance(stored, dict):
|
||||
kept = {key: value for key, value in stored.items() if key not in LEARNED_KEYS}
|
||||
changes = kept.get("model_changes") if isinstance(kept.get("model_changes"), list) else []
|
||||
kept["model_changes"] = [*changes, {
|
||||
"time": datetime.now(timezone.utc).isoformat(timespec="seconds"), "zone": "all",
|
||||
"recalibrated": ["all"], "reasons": ["learned model reset by the user"]}][-MAX_MODEL_CHANGES:]
|
||||
await store.async_save(kept)
|
||||
_LOGGER.info("Heating MPC: learned model reset by the user")
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
|
||||
def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
|
||||
"""Zones from the entry's zone subentries; one zone per legacy room sensor otherwise."""
|
||||
subentries = getattr(entry, "subentries", None) or {}
|
||||
items = [(sub.data, sub.subentry_id) for sub in subentries.values()
|
||||
if sub.subentry_type == SUBENTRY_ZONE]
|
||||
if not items: # not migrated yet (or offline tests): same zones migration creates
|
||||
items = [(data, None) for data in legacy_zone_data(
|
||||
config.get(CONF_ROOMS, []), config.get(CONF_WEIGHTS))]
|
||||
return build_zones(items)
|
||||
|
||||
|
||||
def entry_window_list(entry: ConfigEntry, zones: dict[str, Zone]) -> list[Window]:
|
||||
subentries = getattr(entry, "subentries", None) or {}
|
||||
return build_windows(((sub.data, sub.subentry_id) for sub in subentries.values()
|
||||
if sub.subentry_type == SUBENTRY_WINDOW), zones)
|
||||
|
||||
|
||||
def ventilation_features(config: dict[str, Any], windows: list[Window], zone: str) -> list[str]:
|
||||
"""Ventilation terms for a zone with windows; optional ones need their inputs."""
|
||||
if not any(w.zone == zone and w.enabled for w in windows):
|
||||
return []
|
||||
wind = config.get(CONF_VENT_WIND) is True and bool(sources_for(config, "wind"))
|
||||
features = ["vent_natural", "recovery"]
|
||||
if wind:
|
||||
features.append("vent_wind")
|
||||
if sources_for(config, "wind_direction"):
|
||||
features.append("vent_wind_dir")
|
||||
if config.get(CONF_VENT_GUST) is True and sources_for(config, "wind_gust"):
|
||||
features.append("vent_gust")
|
||||
if config.get(CONF_VENT_CROSS) is True and len({w.zone for w in windows if w.enabled}) > 1:
|
||||
features.append("vent_cross")
|
||||
return features
|
||||
|
||||
|
||||
def entry_source_list(entry: ConfigEntry, zones: dict[str, Zone]) -> list[HeatSource]:
|
||||
subentries = getattr(entry, "subentries", None) or {}
|
||||
return build_sources(((sub.data, sub.subentry_id) for sub in subentries.values()
|
||||
if sub.subentry_type == SUBENTRY_HEAT_SOURCE), zones)
|
||||
|
||||
|
||||
class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""The existing generic thermostat stays the only actuator owner."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=5))
|
||||
super().__init__(hass, _LOGGER, config_entry=entry, name=DOMAIN,
|
||||
update_interval=timedelta(minutes=5))
|
||||
self.entry = entry
|
||||
self.config = {**DEFAULTS, **entry.data, **entry.options}
|
||||
self.rooms: list[str] = self.config[CONF_ROOMS]
|
||||
self.zones: dict[str, Zone] = {zone.key: zone for zone in entry_zone_list(entry, self.config)}
|
||||
# Zone keys; models, forecasts and statistics are keyed by these.
|
||||
self.rooms: list[str] = list(self.zones)
|
||||
self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors]
|
||||
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
||||
self.sources = entry_source_list(entry, self.zones)
|
||||
self._baseline_prints = baseline_fingerprints(self.sources)
|
||||
self._heat_sources = HeatSourceTracker(self.sources, fingerprints=self._baseline_prints)
|
||||
# Extended model per zone: base terms plus electrical heat and adjacent zones.
|
||||
self._extended_features = {
|
||||
key: extended_features(any(s.zone == key and s.enabled for s in self.sources), zone.neighbors)
|
||||
for key, zone in self.zones.items()}
|
||||
self.extended: dict[str, FeatureLearner] = {
|
||||
key: FeatureLearner(features) for key, features in self._extended_features.items() if features}
|
||||
self.windows = entry_window_list(entry, self.zones)
|
||||
self._ventilation = VentilationTracker(self.windows, float(self.config[CONF_RECOVERY_MINUTES]))
|
||||
self._ventilation_features = {key: ventilation_features(self.config, self.windows, key)
|
||||
for key in self.zones}
|
||||
self.ventilation: dict[str, FeatureLearner] = {
|
||||
key: FeatureLearner(features) for key, features in self._ventilation_features.items() if features}
|
||||
# What each learned component depends on; a change recalibrates only that component.
|
||||
self._fingerprints = zone_fingerprints(self.zones, self.sources, self.windows,
|
||||
float(self.config[CONF_RECOVERY_MINUTES]))
|
||||
self._model_changes: deque[dict[str, Any]] = deque(maxlen=MAX_MODEL_CHANGES)
|
||||
self._retired: dict[str, dict[str, Any]] = {}
|
||||
self._report_ages = ReportAgeHistory()
|
||||
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
|
||||
self._forecasts = OperationalForecastLedger()
|
||||
# Seconds the relay was on during the last interval (None if unknown), and its length.
|
||||
self._relay_interval: tuple[float | None, float | None] = (None, None)
|
||||
# How each poll was handled since start, to explain slow learning.
|
||||
self._sample_results: dict[str, int] = {}
|
||||
self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
||||
self._previous: dict[str, Any] | None = None
|
||||
self._heat_lag: float | None = None
|
||||
# Radiator-lag proxy at the last poll with a valid relay state. Tracked
|
||||
# independently of room sensors so lag survives room outages and transitions.
|
||||
self._lag: dict[str, Any] | None = None
|
||||
# (time, new state) of relay changes since the last poll, from a read-only listener.
|
||||
self._relay_events: deque[tuple[datetime, str]] = deque(maxlen=MAX_RELAY_EVENTS)
|
||||
self._relay_events_dropped = False
|
||||
self._on_samples = 0
|
||||
self._off_samples = 0
|
||||
# Increases with every update; an older update finishing late never publishes.
|
||||
self._run = 0
|
||||
self._closing = False
|
||||
|
||||
async def async_restore_model(self) -> None:
|
||||
async with storage_lock(self.hass, self.entry.entry_id):
|
||||
await self._async_restore_model()
|
||||
|
||||
async def _async_save(self, outside_source: str | None) -> bool:
|
||||
"""Write the model unless unloading began or the model was deleted.
|
||||
|
||||
Shielded: unloading cancels a running scheduled update, but the file write
|
||||
itself continues in a worker thread. The lock must stay held until that
|
||||
write has finished, or a deletion could run first and be undone by it.
|
||||
"""
|
||||
write = self.hass.async_create_task(self._async_write(outside_source), f"{DOMAIN} model save")
|
||||
return await asyncio.shield(write)
|
||||
|
||||
async def _async_write(self, outside_source: str | None) -> bool:
|
||||
async with storage_lock(self.hass, self.entry.entry_id):
|
||||
# Checked after acquiring the lock, not before: a save that waited for
|
||||
# a deletion to finish must not write the file again.
|
||||
if self.closing or model_removed(self.hass, self.entry.entry_id):
|
||||
return False
|
||||
await self._store.async_save(self._payload(outside_source))
|
||||
return True
|
||||
|
||||
@property
|
||||
def closing(self) -> bool:
|
||||
"""True from the start of unloading: nothing is published, saved or recorded."""
|
||||
return self._closing or getattr(self, "_shutdown_requested", False)
|
||||
|
||||
@callback
|
||||
def async_begin_shutdown(self) -> None:
|
||||
"""Mark unloading before anything is unloaded.
|
||||
|
||||
Home Assistant's own shutdown of the coordinator runs only after platforms
|
||||
are unloaded; an update still running in that window must not publish or save.
|
||||
"""
|
||||
self._closing = True
|
||||
|
||||
@callback
|
||||
def async_cancel_shutdown(self) -> None:
|
||||
"""Unloading failed and the entry stays loaded: carry on."""
|
||||
self._closing = False
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
self._closing = True
|
||||
await super().async_shutdown()
|
||||
|
||||
def _stale(self, run: int) -> bool:
|
||||
"""True when a newer update started or the entry is unloading."""
|
||||
return run != self._run or self.closing
|
||||
|
||||
async def _async_restore_model(self) -> None:
|
||||
stored = await self._store.async_load() or {}
|
||||
if not isinstance(stored, dict) or stored.get("schema_version", 1) not in (1, 2):
|
||||
_LOGGER.warning("Ignoring unsupported Heating MPC model data")
|
||||
@@ -50,18 +247,120 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if stored.get("heater_entity", self.config[CONF_HEATER]) != self.config[CONF_HEATER]:
|
||||
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
|
||||
return
|
||||
# Models carry room identity, so changing the room list is safe.
|
||||
saved = stored.get("learners", {})
|
||||
if not isinstance(saved, dict):
|
||||
return
|
||||
for room, data in saved.items():
|
||||
if room in self.learners:
|
||||
self.learners[room] = Learner.from_dict(data)
|
||||
self._evaluator.load(stored.get("evaluation"))
|
||||
self._forecasts.load(stored.get("operational_evaluation"))
|
||||
changes = stored.get("model_changes")
|
||||
self._model_changes.extend(c for c in (changes if isinstance(changes, list) else []) if isinstance(c, dict))
|
||||
self._heat_sources = HeatSourceTracker(self.sources, stored.get("heat_sources"), self._baseline_prints)
|
||||
# None for 0.4.0 data: learned before fingerprints existed, assumed to match.
|
||||
stored_prints = stored.get("fingerprints") if isinstance(stored.get("fingerprints"), dict) else None
|
||||
saved_models = stored.get("zone_models") if isinstance(stored.get("zone_models"), dict) else {}
|
||||
for key, features in self._extended_features.items():
|
||||
if features and key in saved_models:
|
||||
# Matched by feature name: a new source or neighbour retrains only its term.
|
||||
self.extended[key] = FeatureLearner.from_dict(saved_models[key], features)
|
||||
self._ventilation = VentilationTracker(self.windows, float(self.config[CONF_RECOVERY_MINUTES]),
|
||||
stored.get("ventilation"))
|
||||
saved_vent = stored.get("ventilation_models") if isinstance(stored.get("ventilation_models"), dict) else {}
|
||||
for key, features in self._ventilation_features.items():
|
||||
if features and key in saved_vent:
|
||||
self.ventilation[key] = FeatureLearner.from_dict(saved_vent[key], features)
|
||||
# Models carry zone identity, so changing the zone list is safe. Models of
|
||||
# zones no longer configured are retained, most recently removed last, with
|
||||
# the measurement fingerprint they were learned against.
|
||||
base_prints: dict[str, str | None] = {}
|
||||
for key in ("retired_learners", "learners"): # current learners take precedence
|
||||
saved = stored.get(key)
|
||||
if not isinstance(saved, dict):
|
||||
continue
|
||||
for room, data in saved.items():
|
||||
if not isinstance(room, str):
|
||||
continue
|
||||
learner = Learner.from_dict(data)
|
||||
if key == "learners":
|
||||
fingerprint = (stored_prints or {}).get(room, {}).get("base") if stored_prints is not None else None
|
||||
else:
|
||||
fingerprint = data.get("fingerprint") if isinstance(data, dict) else None
|
||||
if room in self.learners:
|
||||
self.learners[room] = learner
|
||||
base_prints[room] = fingerprint
|
||||
elif learner.count:
|
||||
self._retired.pop(room, None)
|
||||
self._retired[room] = {**learner.as_dict(), **({"fingerprint": fingerprint} if fingerprint else {})}
|
||||
while len(self._retired) > MAX_RETIRED_ROOMS:
|
||||
self._retired.pop(next(iter(self._retired)))
|
||||
recalibrated = self._check_compatibility(stored_prints, base_prints)
|
||||
self._update_counts()
|
||||
first_prints = stored_prints is None and bool(stored.get("learners"))
|
||||
if recalibrated or self._heat_sources.reset_baselines or first_prints:
|
||||
# Save at once: a recalibration is not redone after a restart, and data
|
||||
# from before fingerprints gets them now, so the next edit is detected
|
||||
# even if it happens before the next learned sample.
|
||||
await self._store.async_save(self._payload(stored.get("outside_source"))) # lock held
|
||||
|
||||
def _check_compatibility(self, stored: dict[str, Any] | None, base_prints: dict[str, str | None]) -> bool:
|
||||
"""Recalibrate exactly the components whose configuration changed."""
|
||||
any_change = False
|
||||
for room in self.rooms:
|
||||
current, old = self._fingerprints[room], (stored or {}).get(room) if stored is not None else None
|
||||
resets: list[str] = []
|
||||
measured = base_prints.get(room) is not None and base_prints[room] != current["base"]
|
||||
if measured:
|
||||
# The zone temperature is defined differently: re-learn it from a warm start.
|
||||
self.learners[room].recalibrate()
|
||||
self._evaluator.forget(room)
|
||||
self._forecasts.forget(room)
|
||||
resets.append("base")
|
||||
for layer in (self.extended.get(room), self.ventilation.get(room)):
|
||||
if layer is None:
|
||||
continue
|
||||
features = list(layer.features) if measured else changed(old, current, layer.features)
|
||||
if features:
|
||||
layer.reset(features, keep_values=BASE_TERMS)
|
||||
# loss/heat/bias change only with the measurement, reported as "base".
|
||||
resets.extend(f for f in features if f not in BASE_TERMS)
|
||||
if measured:
|
||||
layer.reset_counts()
|
||||
if resets:
|
||||
any_change = True
|
||||
# A changed measurement explains every reset in the zone.
|
||||
reasons = sorted({reason("base")} if measured else {reason(c) for c in resets})
|
||||
self._model_changes.append({
|
||||
"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"zone": self.zones[room].name, "recalibrated": sorted(set(resets)), "reasons": reasons})
|
||||
_LOGGER.info("Heating MPC: recalibrating %s for zone %s (%s)", ", ".join(sorted(set(resets))),
|
||||
self.zones[room].name, "; ".join(reasons))
|
||||
for key in self._heat_sources.reset_baselines:
|
||||
name = next(s.name for s in self.sources if s.key == key)
|
||||
self._model_changes.append({"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"heat_source": name, "recalibrated": ["baseline"],
|
||||
"reasons": ["power sensor or included sources changed"]})
|
||||
return any_change
|
||||
|
||||
def _payload(self, outside_source: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
|
||||
# Optional keys; older 0.2.x readers ignore them and keep working.
|
||||
"retired_learners": self._retired,
|
||||
"zone_models": {key: model.as_dict() for key, model in self.extended.items()},
|
||||
"heat_sources": self._heat_sources.as_dict(),
|
||||
"ventilation_models": {key: model.as_dict() for key, model in self.ventilation.items()},
|
||||
"ventilation": self._ventilation.as_dict(),
|
||||
"evaluation": self._evaluator.as_dict(),
|
||||
"operational_evaluation": self._forecasts.as_dict(),
|
||||
"fingerprint_version": FINGERPRINT_VERSION,
|
||||
"fingerprints": self._fingerprints,
|
||||
"model_changes": list(self._model_changes),
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
"heater_entity": self.config[CONF_HEATER],
|
||||
"outside_source": outside_source,
|
||||
}
|
||||
|
||||
def _update_counts(self) -> None:
|
||||
self._on_samples = min(m.on_count for m in self.learners.values())
|
||||
self._off_samples = min(m.off_count for m in self.learners.values())
|
||||
# Without zones (all deleted in the UI) there is nothing to count.
|
||||
self._on_samples = min((m.on_count for m in self.learners.values()), default=0)
|
||||
self._off_samples = min((m.off_count for m in self.learners.values()), default=0)
|
||||
|
||||
def _sensor_quality(self, entity_id: str, now: datetime, limit: float) -> dict:
|
||||
state = self.hass.states.get(entity_id)
|
||||
@@ -70,8 +369,233 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if state.state in ("unknown", "unavailable"):
|
||||
return {"status": state.state, "age_seconds": None}
|
||||
# last_updated is a conservative compatibility fallback for older HA.
|
||||
return freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)),
|
||||
now, limit * 60)
|
||||
quality = freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)),
|
||||
now, limit * 60)
|
||||
if quality["status"] in ("valid", "stale"):
|
||||
quality["max_age_seconds_24h"] = round(
|
||||
self._report_ages.observe(entity_id, now, quality["age_seconds"]), 1)
|
||||
return quality
|
||||
|
||||
@callback
|
||||
def async_relay_changed(self, event: Event) -> None:
|
||||
"""Record relay state changes as they happen; read-only state listener."""
|
||||
if self.closing:
|
||||
return # a callback queued before the listener was removed
|
||||
old, new = event.data.get("old_state"), event.data.get("new_state")
|
||||
if new is None:
|
||||
self.record_relay_state(event.time_fired, "removed")
|
||||
elif old is None or old.state != new.state:
|
||||
self.record_relay_state(new.last_changed, new.state)
|
||||
|
||||
def record_relay_state(self, when: datetime, state: str) -> None:
|
||||
if len(self._relay_events) == self._relay_events.maxlen:
|
||||
self._relay_events_dropped = True
|
||||
self._relay_events.append((when, state))
|
||||
|
||||
def _track_lag(self, now: datetime, heater_on: bool, last_changed: datetime) -> bool:
|
||||
"""Advance the lag proxy to `now`; return True if the relay held one state throughout.
|
||||
|
||||
Relay changes since the last poll are integrated at their timestamps. Anything
|
||||
ambiguous (missed changes, an outage, clock jumps) restarts the estimate from
|
||||
the time spent in the current state, with an explicit error bound.
|
||||
"""
|
||||
tracker = self._lag
|
||||
steady = False
|
||||
lag = uncertainty = None
|
||||
self._relay_interval = (None, None) # unknown unless the relay history is known below
|
||||
if tracker is not None and now > tracker["time"]:
|
||||
start = tracker["time"]
|
||||
changes = [(when, state) for when, state in self._relay_events if start < when <= now]
|
||||
# The current state is authoritative; its change event may still be queued,
|
||||
# and without a listener it is the only change visible.
|
||||
if last_changed > start and (not changes or changes[-1][0] < last_changed):
|
||||
changes.append((last_changed, "on" if heater_on else "off"))
|
||||
segments = None if self._relay_events_dropped else relay_segments(
|
||||
tracker["heater_on"], start.timestamp(),
|
||||
[(when.timestamp(), state) for when, state in changes], now.timestamp())
|
||||
self._relay_interval = (None, (now - start).total_seconds())
|
||||
if segments is not None and segments[-1][0] == heater_on:
|
||||
lag = tracker["lag"]
|
||||
for command, seconds in segments:
|
||||
lag = advance_lag(lag, command, seconds)
|
||||
self._relay_interval = (sum(sec for command, sec in segments if command),
|
||||
(now - start).total_seconds())
|
||||
# The lag update is a contraction, so an initial error decays the same way.
|
||||
uncertainty = tracker["uncertainty"] * exp(
|
||||
-(now - start).total_seconds() / LAG_TAU_SECONDS)
|
||||
steady = len(segments) == 1
|
||||
if lag is None:
|
||||
lag, uncertainty = lag_from_time_in_state(
|
||||
heater_on, (now - last_changed).total_seconds())
|
||||
self._lag = {"time": now, "heater_on": heater_on, "lag": lag, "uncertainty": uncertainty}
|
||||
self._relay_events = deque((e for e in self._relay_events if e[0] > now),
|
||||
maxlen=MAX_RELAY_EVENTS)
|
||||
self._relay_events_dropped = False
|
||||
return steady
|
||||
|
||||
def _revision(self) -> str:
|
||||
"""Short identity of the model configuration a forecast was made with."""
|
||||
return hashlib.sha1(json.dumps(self._fingerprints, sort_keys=True).encode()).hexdigest()[:10]
|
||||
|
||||
def _superseded(self, status: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Result of an update that a newer one (or unloading) overtook: keep what is shown."""
|
||||
current = getattr(self, "data", None)
|
||||
return current if current is not None else {**status, "status": "inputs_changed", "proposal": None}
|
||||
|
||||
def _count_result(self, status: dict[str, Any], result: str) -> None:
|
||||
"""Record how this poll was handled for learning, and the learning progress."""
|
||||
status["sample_result"] = result
|
||||
self._sample_results[result] = self._sample_results.get(result, 0) + 1
|
||||
status["sample_results_since_start"] = dict(self._sample_results)
|
||||
# Least trained room: mean of its three readiness requirements (100 = ready).
|
||||
status["learning_progress"] = round(100 * min(
|
||||
((min(1.0, m.count / READY_SAMPLES) + min(1.0, m.on_count / READY_ON_SAMPLES)
|
||||
+ min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3
|
||||
for m in self.learners.values()), default=0.0), 1)
|
||||
|
||||
def _extended_diagnostics(self, room: str) -> dict[str, Any]:
|
||||
model = self.extended.get(room)
|
||||
if model is None:
|
||||
return {"model_in_use": "base"}
|
||||
return {"model_in_use": "extended" if ZoneModel(self.learners[room], model).uses_extended else "base",
|
||||
"extended": {"samples": model.count, "on_samples": model.on_count,
|
||||
"off_samples": model.off_count,
|
||||
"mae_5m": round(model.mae, 4) if model.mae is not None else None,
|
||||
"coefficients": model.coefficients()}}
|
||||
|
||||
def _source_diagnostics(self, quality: dict[str, dict]) -> dict[str, Any]:
|
||||
names = {s.key: s.name for s in self.sources}
|
||||
result = {}
|
||||
for source in self.sources:
|
||||
info = self._heat_sources.state.get(source.key, {})
|
||||
|
||||
def watts(kw):
|
||||
return None if kw is None else round(kw * 1000, 1)
|
||||
result[source.name] = {
|
||||
"zone": self.zones[source.zone].name, "sensor": source.sensor,
|
||||
"status": quality[source.key]["status"], "enabled": source.enabled,
|
||||
"power_w": info.get("power_w"), "effective_w": info.get("effective_w"),
|
||||
"included_in": names.get(source.included_in),
|
||||
"baseline_w": info.get("baseline_w"), "baseline_mode": source.baseline_mode,
|
||||
"variable_w": watts(info.get("variable_kw")), "thermal_response_w": watts(info.get("response_kw")),
|
||||
"settled": info.get("settled", False), "heat_fraction": source.fraction,
|
||||
"response_minutes": source.response_minutes,
|
||||
}
|
||||
return result
|
||||
|
||||
def _step_model(self, temps: dict[str, float], outside: float, horizon: int,
|
||||
weather: dict[str, Any], now: float):
|
||||
"""Multi-zone step for the optimizer, or None when only base models apply."""
|
||||
models = {key: ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
|
||||
for key in temps}
|
||||
steps = horizon // 5
|
||||
ventilation = {key: self._ventilation.forecast(
|
||||
key, steps, float(self.config[CONF_WINDOW_OPEN_MINUTES]), weather, now) for key in temps}
|
||||
venting = any(models[key].ventilation is not None
|
||||
and any(s.get("opening") or s.get("recovery") for s in ventilation[key]) for key in temps)
|
||||
if not venting and not any(model.uses_extended for model in models.values()):
|
||||
return None
|
||||
half_life = float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30))
|
||||
forecast = {key: self._heat_sources.zone_forecast(key, steps, half_life) for key in temps}
|
||||
return compile_step_model(models, list(temps), steps, lambda key, k: {
|
||||
"outside": outside, "internal": forecast[key][k], "ventilation": ventilation[key][k]})
|
||||
|
||||
def _zone_details(self, temps: dict[str, float], outside: float, internal: dict[str, float | None],
|
||||
vent_inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""What drives each zone right now (°C/h per effect) and how sure each layer is."""
|
||||
details = {}
|
||||
lag = self._lag["lag"]
|
||||
for key, temp in temps.items():
|
||||
model = ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
|
||||
inputs = {"outside": outside, "lag": lag, "internal": internal[key], "temps": temps,
|
||||
"ventilation": vent_inputs.get(key) or {}}
|
||||
base = self.learners[key]
|
||||
confidence = base.confidence()
|
||||
coefficients = {"base": {name: {"value": round(value, 6), "confidence": confidence[name]}
|
||||
for name, value in zip(("loss", "heat", "bias"), base.theta)}}
|
||||
for layer, learner in (("extended", model.extended), ("ventilation", model.ventilation)):
|
||||
if learner is not None:
|
||||
coefficients[layer] = {name: {k: info[k] for k in ("value", "std", "confidence", "identified")}
|
||||
for name, info in learner.coefficients().items()}
|
||||
details[key] = {"temperature": round(temp, 3),
|
||||
"model_in_use": "extended" if model.uses_extended else "base",
|
||||
"contributions_c_per_h": model.contributions(temp, inputs),
|
||||
"coefficients": coefficients,
|
||||
# Windows whose state the forecast had to assume (sensor unknown).
|
||||
"uncertain_windows": list((vent_inputs.get(key) or {}).get("unknown_windows", []))}
|
||||
return details
|
||||
|
||||
def _window_diagnostics(self, now: float, weather: dict[str, Any], temps: dict[str, float | None],
|
||||
vent_inputs: dict[str, Any], internal: dict[str, float | None]) -> dict[str, Any]:
|
||||
windows = {}
|
||||
for window in self.windows:
|
||||
info = self._ventilation.window_state.get(window.key, {})
|
||||
is_open, since = info.get("open"), info.get("since")
|
||||
direction = weather.get("wind_direction")
|
||||
last_known, unknown_since = info.get("last_known_open"), info.get("unknown_since")
|
||||
assumed = self._ventilation.assumption(window, float(self.config[CONF_WINDOW_OPEN_MINUTES]), now)
|
||||
windows[window.name] = {
|
||||
"zone": self.zones[window.zone].name, "sensor": window.sensor, "enabled": window.enabled,
|
||||
"state": "unknown" if is_open is None else "open" if is_open else "closed",
|
||||
"last_known_state": None if last_known is None else "open" if last_known else "closed",
|
||||
"unknown_minutes": round((now - unknown_since) / 60, 1) if unknown_since else None,
|
||||
# What forecasts assume: observed, or a bounded guess from the last reading.
|
||||
"forecast_assumption": assumed["basis"], "forecast_open_minutes": assumed["open_minutes"],
|
||||
"open_minutes": round((now - since) / 60, 1) if is_open and since else None,
|
||||
"azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted,
|
||||
"relative_wind_direction": info.get("relative_wind_direction"),
|
||||
"wind_exposure": round(facing_cos(direction, window.azimuth), 3) if direction is not None else None,
|
||||
}
|
||||
zones = {}
|
||||
for key, inputs in vent_inputs.items():
|
||||
model = self.ventilation.get(key)
|
||||
if inputs is None or model is None:
|
||||
continue
|
||||
temp, outside = temps.get(key), weather.get("temperature")
|
||||
contributions = {}
|
||||
if temp is not None and outside is not None:
|
||||
terms = ZoneModel(self.learners[key], self.extended.get(key), model).contributions(temp, {
|
||||
"outside": outside, "lag": (self._lag or {}).get("lag", 0.0), "internal": internal[key],
|
||||
"temps": temps, "ventilation": inputs})
|
||||
contributions = {name: terms.get(name, 0.0) for name in ("ventilation", "recovery")}
|
||||
zones[self.zones[key].name] = {
|
||||
"opening": inputs["opening"], "recovery": round(inputs["recovery"], 3),
|
||||
"temperature_before": inputs["t_ref"], "affected": inputs["affected"],
|
||||
"contribution_c_per_h": contributions, "samples": model.count,
|
||||
"coefficients": model.coefficients()}
|
||||
return {"windows": windows, "ventilation": zones,
|
||||
"ventilation_events": list(self._ventilation.events)[-5:]}
|
||||
|
||||
def _input_snapshot(self) -> dict[tuple[str, str], Any]:
|
||||
"""Normalized value of every input a prediction uses, keyed by (kind, entity).
|
||||
|
||||
Compared (with tolerances, see data_quality.inputs_differ) before publishing,
|
||||
so a result computed from inputs that have since changed is not shown.
|
||||
"""
|
||||
def value(entity_id: str, metric: str):
|
||||
state = self.hass.states.get(entity_id)
|
||||
return normalize(metric, state.state, state.attributes.get("unit_of_measurement")) if state else None
|
||||
|
||||
snapshot: dict[tuple[str, str], Any] = {}
|
||||
for entity_id in set(self.sensors) | set(sources_for(self.config, "temperature")):
|
||||
snapshot[("temperature", entity_id)] = value(entity_id, "temperature")
|
||||
for metric in ("wind", "wind_direction", "wind_gust"):
|
||||
for entity_id in sources_for(self.config, metric):
|
||||
snapshot[(metric, entity_id)] = value(entity_id, metric)
|
||||
for source in self.sources:
|
||||
state = self.hass.states.get(source.sensor)
|
||||
snapshot[("power", source.sensor)] = normalize_power(
|
||||
state.state, state.attributes.get("unit_of_measurement")) if state else None
|
||||
for window in self.windows:
|
||||
state = self.hass.states.get(window.sensor)
|
||||
snapshot[("window", window.sensor)] = contact_open(state.state if state else None, window.inverted)
|
||||
heater = self.hass.states.get(self.config[CONF_HEATER])
|
||||
snapshot[("relay", self.config[CONF_HEATER])] = (heater.state, heater.last_changed) if heater else None
|
||||
climate = self.hass.states.get(self.config[CONF_CLIMATE])
|
||||
snapshot[("thermostat", self.config[CONF_CLIMATE])] = (
|
||||
climate.state, climate.attributes.get("temperature"), climate.attributes.get("temperature_unit")
|
||||
) if climate else None
|
||||
return snapshot
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -83,22 +607,41 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
entity_id for metric in WEATHER_SOURCES
|
||||
for entity_id in sources_for(self.config, metric)
|
||||
}
|
||||
watched = set(self.rooms) | weather_entity_ids | {
|
||||
self.config[CONF_CLIMATE], self.config[CONF_HEATER]}
|
||||
snapshot = {entity: self.hass.states.get(entity) for entity in watched}
|
||||
room_quality = {}
|
||||
temps = {}
|
||||
for room in self.rooms:
|
||||
quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE])
|
||||
snapshot = {entity: self.hass.states.get(entity)
|
||||
for entity in set(self.sensors) | weather_entity_ids}
|
||||
self._run += 1
|
||||
if self.closing:
|
||||
return self._superseded({"status": "inputs_changed"})
|
||||
run = self._run
|
||||
inputs_before = self._input_snapshot()
|
||||
sensor_quality = {}
|
||||
readings: dict[str, float | None] = {}
|
||||
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
|
||||
for sensor in self.sensors:
|
||||
quality = self._sensor_quality(sensor, now, self.config[CONF_ROOM_MAX_AGE])
|
||||
value = None
|
||||
if quality["status"] == "valid":
|
||||
state = snapshot[room]
|
||||
# Optionally trust sensors that report only on change, but never a
|
||||
# sensor silent for a day: it is most likely dead.
|
||||
if quality["status"] == "valid" or (
|
||||
keep_stale and quality["status"] == "stale"
|
||||
and quality["age_seconds"] <= ROOM_STALE_KEEP_MAX_MINUTES * 60):
|
||||
state = snapshot[sensor]
|
||||
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
|
||||
if value is None or not 0 <= value <= 45:
|
||||
quality["status"] = "invalid_value_or_unit"
|
||||
value = None
|
||||
room_quality[room] = quality
|
||||
temps[room] = value
|
||||
quality["used"] = value is not None
|
||||
sensor_quality[sensor] = quality
|
||||
readings[sensor] = value
|
||||
# A zone is usable while at least one of its sensors is; sensor weights
|
||||
# combine them. Which sensors were used matters for learning (see below).
|
||||
temps: dict[str, float | None] = {}
|
||||
compositions: dict[str, frozenset[str]] = {}
|
||||
zone_quality = {}
|
||||
for key, zone in self.zones.items():
|
||||
temps[key], compositions[key] = zone_temperature(zone, readings)
|
||||
zone_quality[key] = {"name": zone.name, "used": temps[key] is not None,
|
||||
"sensors": {s: sensor_quality[s] for s in zone.sensors}}
|
||||
weather_states = {}
|
||||
weather_quality = {}
|
||||
for entity_id in weather_entity_ids:
|
||||
@@ -120,88 +663,183 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
metric_quality[metric][entity_id] = quality
|
||||
outside = weather["temperature"]
|
||||
status: dict[str, Any] = {
|
||||
"input_quality": {"rooms": room_quality, "weather": metric_quality},
|
||||
"input_quality": {"zones": zone_quality, "weather": metric_quality},
|
||||
"status": "waiting_for_sensors", "proposal": None, "prediction_60m": {},
|
||||
"temperatures": {r: t for r, t in temps.items() if t is not None},
|
||||
"temperatures": {r: round(t, 3) for r, t in temps.items() if t is not None},
|
||||
"zone_names": {key: zone.name for key, zone in self.zones.items()},
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
"samples": min(learner.count for learner in self.learners.values()),
|
||||
"samples": min((learner.count for learner in self.learners.values()), default=0),
|
||||
"mode": climate.state if climate else "unavailable",
|
||||
"weather": {k: v for k, v in weather.items() if v is not None},
|
||||
"weather_active_sources": {k: v for k, v in active_sources.items() if v},
|
||||
"weather_comparison": {k: v for k, v in weather_comparisons.items() if v},
|
||||
"weather_model_inputs": ["temperature"],
|
||||
"retired_zone_models": sorted(self._retired),
|
||||
"model_changes": list(self._model_changes)[-5:],
|
||||
"excluded_zones": [room for room in self.rooms if temps[room] is None],
|
||||
}
|
||||
if heater is None or heater.state not in ("on", "off") or climate is None:
|
||||
# Electrical heat sources: processed every poll so their responses stay continuous.
|
||||
source_readings: dict[str, float | None] = {}
|
||||
source_quality = {}
|
||||
for source in self.sources:
|
||||
quality = self._sensor_quality(source.sensor, now, self.config[CONF_ROOM_MAX_AGE])
|
||||
watts = None
|
||||
if quality["status"] == "valid":
|
||||
state = self.hass.states.get(source.sensor)
|
||||
watts = normalize_power(state.state, state.attributes.get("unit_of_measurement"))
|
||||
if watts is None:
|
||||
quality["status"] = "invalid_value_or_unit"
|
||||
source_readings[source.key] = watts
|
||||
source_quality[source.key] = quality
|
||||
self._heat_sources.update(now.timestamp(), source_readings)
|
||||
internal = {key: self._heat_sources.zone_input(key) for key in self.zones}
|
||||
if self.sources:
|
||||
status["heat_sources"] = self._source_diagnostics(source_quality)
|
||||
# Windows: tracked every poll; unknown states stay unknown, never "closed".
|
||||
window_states = {}
|
||||
for window in self.windows:
|
||||
state = self.hass.states.get(window.sensor)
|
||||
window_states[window.key] = ((state.state, state.last_changed.timestamp()) if state else (None, None))
|
||||
self._ventilation.update(now.timestamp(), window_states, temps, weather)
|
||||
vent_inputs = {key: self._ventilation.zone_inputs(key, weather) for key in self.zones}
|
||||
if self.windows:
|
||||
status.update(self._window_diagnostics(now.timestamp(), weather, temps, vent_inputs, internal))
|
||||
heater_valid = heater is not None and heater.state in ("on", "off")
|
||||
heater_on = heater_valid and heater.state == "on"
|
||||
steady = False
|
||||
if heater_valid:
|
||||
steady = self._track_lag(now, heater_on, heater.last_changed)
|
||||
status["heat_lag"] = round(self._lag["lag"], 3)
|
||||
status["heat_lag_uncertainty"] = round(self._lag["uncertainty"], 3)
|
||||
else:
|
||||
self._lag = None
|
||||
self._relay_events.clear()
|
||||
self._relay_events_dropped = False
|
||||
self._relay_interval = (None, None)
|
||||
# Published forecasts that are due are scored against these readings; nothing
|
||||
# here is fed back into what those forecasts assumed.
|
||||
self._forecasts.observe(now.timestamp(), {k: v for k, v in temps.items() if v is not None},
|
||||
*self._relay_interval)
|
||||
status["operational_forecast"] = self._forecasts.summary(self.rooms)
|
||||
# Rooms without a usable reading are left out and the others carry on;
|
||||
# only the shared inputs, or losing every room, pause the integration.
|
||||
vals: dict[str, float] = {room: float(t) for room, t in temps.items() if t is not None}
|
||||
if not heater_valid or climate is None or outside is None or not vals:
|
||||
self._previous = None
|
||||
self._heat_lag = None
|
||||
return status
|
||||
if outside is None or any(temp is None for temp in temps.values()):
|
||||
self._previous = None
|
||||
self._heat_lag = None
|
||||
self._evaluator.reset_window()
|
||||
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
|
||||
self._count_result(status, "waiting_for_inputs")
|
||||
if not self.zones:
|
||||
status["status"] = "no_zones"
|
||||
return status
|
||||
|
||||
# No casting surprises: all rooms are valid here.
|
||||
vals: dict[str, float] = {room: float(temps[room]) for room in self.rooms}
|
||||
heater_on = heater.state == "on"
|
||||
if self._heat_lag is None:
|
||||
self._heat_lag = float(heater_on)
|
||||
if self._previous is not None:
|
||||
# A zone learns only across a poll pair where it was usable at both ends with
|
||||
# the same sensors: a sensor dropping out shifts the mean by its offset.
|
||||
pairs = [room for room in vals if self._previous and room in self._previous["temps"]
|
||||
and self._previous["compositions"].get(room) == compositions[room]]
|
||||
learned = False
|
||||
if not pairs:
|
||||
changed = self._previous and any(
|
||||
room in self._previous["temps"] and self._previous["compositions"].get(room)
|
||||
!= compositions[room] for room in vals)
|
||||
status["sample_result"] = "sensors_changed" if changed else "first_sample"
|
||||
else:
|
||||
dt = (now - self._previous["time"]).total_seconds()
|
||||
# Do not fit one transition across a provider/fallback switch.
|
||||
if (270 <= dt <= 330 and
|
||||
heater.last_changed <= self._previous["time"] and
|
||||
heater_on == self._previous["heater_on"] and
|
||||
self._previous["outside_source"] == active_sources["temperature"]):
|
||||
next_lag = self._heat_lag + (1 - exp(-dt / 1200)) * (float(self._previous["heater_on"]) - self._heat_lag)
|
||||
for room in self.rooms:
|
||||
# Do not fit across a relay change, a provider/fallback switch or an
|
||||
# uncertain lag estimate (restart, relay outage, several changes).
|
||||
if not 270 <= dt <= 330:
|
||||
status["sample_result"] = "interval_out_of_range"
|
||||
elif not steady or heater_on != self._previous["heater_on"]:
|
||||
status["sample_result"] = "relay_changed"
|
||||
elif self._previous["outside_source"] != active_sources["temperature"]:
|
||||
status["sample_result"] = "outside_source_changed"
|
||||
elif self._lag["uncertainty"] > LAG_LEARN_MAX_UNCERTAINTY:
|
||||
status["sample_result"] = "lag_uncertain"
|
||||
else:
|
||||
# Window open, unknown or recovering: not envelope loss; keep it out of
|
||||
# the closed-window models and give it to the ventilation model.
|
||||
vented = [room for room in pairs if (vent_inputs.get(room) or {}).get("affected")]
|
||||
clean = [room for room in pairs if room not in vented]
|
||||
accepted = [
|
||||
self.learners[room].learn(
|
||||
self._previous["temps"][room], vals[room],
|
||||
self._previous["outside"], next_lag, dt, self._previous["heater_on"]
|
||||
)
|
||||
self._heat_lag = next_lag
|
||||
self._update_counts()
|
||||
# Persist at each sample so restart does not erase hours of training.
|
||||
await self._store.async_save({
|
||||
"schema_version": 2,
|
||||
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
"heater_entity": self.config[CONF_HEATER],
|
||||
"outside_source": active_sources["temperature"],
|
||||
})
|
||||
else:
|
||||
# Model lag state becomes uncertain after long gaps.
|
||||
self._heat_lag = float(heater_on)
|
||||
self._previous["outside"], self._lag["lag"], dt, heater_on)
|
||||
for room in clean
|
||||
]
|
||||
before = {"outside": self._previous["outside"], "lag": self._lag["lag"],
|
||||
"temps": self._previous["temps"]}
|
||||
for room in pairs:
|
||||
start = self._previous["temps"][room]
|
||||
change = (vals[room] - start) * 300 / dt
|
||||
inputs = {**before, "internal": internal[room]}
|
||||
if room in clean:
|
||||
# Extended models learn the same interval when all their inputs are known.
|
||||
model = self.extended.get(room)
|
||||
x = model and feature_vector(model.features, start, inputs)
|
||||
if x:
|
||||
model.learn(x, change, heater_on)
|
||||
continue
|
||||
model = self.ventilation.get(room)
|
||||
x = model and ventilation_vector(model.features, start, before["outside"], vent_inputs[room])
|
||||
if x:
|
||||
main = ZoneModel(self.learners[room], self.extended.get(room)).main_delta(start, inputs)
|
||||
model.learn(x, change - main, heater_on)
|
||||
if clean:
|
||||
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
|
||||
self._update_counts()
|
||||
else:
|
||||
status["sample_result"] = "ventilation"
|
||||
learned = True
|
||||
|
||||
self._count_result(status, status["sample_result"])
|
||||
self._previous = {
|
||||
"time": now, "temps": vals, "outside": outside, "heater_on": heater_on,
|
||||
"outside_source": active_sources["temperature"],
|
||||
"outside_source": active_sources["temperature"], "compositions": compositions,
|
||||
}
|
||||
weights = self.config.get(CONF_WEIGHTS, {})
|
||||
try:
|
||||
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms}
|
||||
if any(not isfinite(w) or w <= 0 for w in weights.values()) or not isfinite(sum(weights.values())):
|
||||
raise ValueError
|
||||
except (AttributeError, TypeError, ValueError, OverflowError):
|
||||
status["status"] = "invalid_weights"
|
||||
return status
|
||||
weighted_temp = sum(weights[r] * vals[r] for r in self.rooms) / sum(weights.values())
|
||||
# Parameters after this poll's learning: forecasts from here use only data so far.
|
||||
self._evaluator.observe(
|
||||
now, vals, outside, self._lag["lag"], self._lag["uncertainty"],
|
||||
{room: tuple(self.learners[room].theta) for room in vals},
|
||||
models={room: model_snapshot(ZoneModel(self.learners[room], self.extended.get(room),
|
||||
self.ventilation.get(room))) for room in vals},
|
||||
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals})
|
||||
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
|
||||
# Persist at each sample so restart does not erase hours of training, and when
|
||||
# a window's state changes so a restart continues from it.
|
||||
window_changed, self._ventilation.changed = self._ventilation.changed, False
|
||||
if (learned or window_changed) and not self._stale(run):
|
||||
await self._async_save(active_sources["temperature"])
|
||||
if self._stale(run):
|
||||
return self._superseded(status)
|
||||
# Comfort weights are validated when zones are parsed (invalid -> 1.0).
|
||||
weights = {r: self.zones[r].comfort_weight for r in self.rooms}
|
||||
# Left-out zones take their weight with them; the rest are re-normalized.
|
||||
used_weights = {room: weights[room] for room in vals}
|
||||
weighted_temp = sum(used_weights[r] * vals[r] for r in vals) / sum(used_weights.values())
|
||||
status.update({
|
||||
"weighted_temperature": round(weighted_temp, 3),
|
||||
"weight_coverage": round(sum(used_weights.values()) / sum(weights.values()), 3),
|
||||
"outside_temperature": outside,
|
||||
"reference_heat": heater_on,
|
||||
"on_samples": self._on_samples,
|
||||
"off_samples": self._off_samples,
|
||||
"samples": min(learner.count for learner in self.learners.values()),
|
||||
"samples": min((learner.count for learner in self.learners.values()), default=0),
|
||||
"models": {
|
||||
room: {"loss": round(model.theta[0], 6),
|
||||
room: {"name": self.zones[room].name,
|
||||
"loss": round(model.theta[0], 6),
|
||||
"heat": round(model.theta[1], 6),
|
||||
"bias": round(model.theta[2], 6),
|
||||
# How well the data pins each coefficient down, not how many samples.
|
||||
"confidence": model.confidence(),
|
||||
"samples": model.count,
|
||||
"on_samples": model.on_count, "off_samples": model.off_count,
|
||||
"mae_5m": round(model.mae, 4) if model.mae is not None else None}
|
||||
"rejected_samples": model.rejected_count,
|
||||
"mae_5m": round(model.mae, 4) if model.mae is not None else None,
|
||||
**self._extended_diagnostics(room)}
|
||||
for room, model in self.learners.items()
|
||||
},
|
||||
})
|
||||
status["zone_details"] = self._zone_details(vals, outside, internal, vent_inputs)
|
||||
if climate.state != "heat":
|
||||
status["status"] = "paused"
|
||||
return status
|
||||
@@ -215,8 +853,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
status["status"] = "waiting_for_target"
|
||||
return status
|
||||
status["target"] = target
|
||||
# Do not act on a cold model: need ~17 hours of samples plus both conditions.
|
||||
if status["samples"] < 200 or self._on_samples < 30 or self._off_samples < 30:
|
||||
# Do not act on a cold model: every room used needs ~17 hours of samples
|
||||
# plus both relay conditions.
|
||||
used_models = [self.learners[room] for room in vals]
|
||||
if (min(m.count for m in used_models) < READY_SAMPLES
|
||||
or min(m.on_count for m in used_models) < READY_ON_SAMPLES
|
||||
or min(m.off_count for m in used_models) < READY_OFF_SAMPLES):
|
||||
status["status"] = "learning"
|
||||
return status
|
||||
try:
|
||||
@@ -224,7 +866,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# never block Home Assistant's event loop with CPU work.
|
||||
job = partial(
|
||||
optimize,
|
||||
vals, outside, self._heat_lag, target, weights, self.learners,
|
||||
vals, outside, self._lag["lag"], target, used_weights,
|
||||
{room: self.learners[room] for room in vals},
|
||||
heater_on, (now - heater.last_changed).total_seconds(),
|
||||
horizon_minutes=int(self.config[CONF_HORIZON]),
|
||||
minimum_on_minutes=int(self.config[CONF_MIN_ON]),
|
||||
@@ -232,22 +875,48 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
switching_cost=float(self.config[CONF_SWITCH_COST]),
|
||||
heating_cost=float(self.config[CONF_HEAT_COST]),
|
||||
comfort_band=float(self.config[CONF_BAND]),
|
||||
target_offsets={room: self.zones[room].target_offset for room in vals},
|
||||
step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON]),
|
||||
weather, now.timestamp()),
|
||||
)
|
||||
# HA State objects are immutable snapshots. Discard a result if any
|
||||
# input changed while the worker was calculating the hypothetical plan.
|
||||
output = await self.hass.async_add_executor_job(job)
|
||||
if any(self.hass.states.get(entity) is not state for entity, state in snapshot.items()):
|
||||
if self._stale(run):
|
||||
return self._superseded(status)
|
||||
# Discard a result if an input it used changed meaningfully while saving
|
||||
# or while the worker calculated the hypothetical plan. Not recomputed at
|
||||
# once: an off-schedule poll would break the 5-minute learning interval;
|
||||
# the next regular poll (at most 5 minutes) computes a fresh plan.
|
||||
changed = inputs_differ(inputs_before, self._input_snapshot())
|
||||
if changed:
|
||||
status["status"] = "inputs_changed"
|
||||
status["changed_inputs"] = [f"{kind}: {entity}" for kind, entity in changed[:10]]
|
||||
return status
|
||||
except (ValueError, RuntimeError, OverflowError) as err:
|
||||
_LOGGER.warning("MPC prediction failed: %s", err)
|
||||
status["status"] = "prediction_error"
|
||||
return status
|
||||
# Inputs the forecast had to assume: such forecasts are not compared with reality.
|
||||
uncertain = [f"window: {name}" for room in vals
|
||||
for name in (vent_inputs.get(room) or {}).get("unknown_windows", [])]
|
||||
uncertain += [f"electrical heat: {self.zones[room].name}" for room in vals
|
||||
if internal[room] is None and room in self.extended
|
||||
and ZoneModel(self.learners[room], self.extended[room]).uses_extended]
|
||||
self._forecasts.record(now.timestamp(), vals, output["trajectory_15min"], output["plan_15min"],
|
||||
self._revision(), {
|
||||
"strategy": "mpc_plan", "outside": outside, "wind": weather.get("wind"),
|
||||
"uncertain_inputs": uncertain,
|
||||
"internal_half_life_minutes": float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30)),
|
||||
"window_open_minutes": float(self.config[CONF_WINDOW_OPEN_MINUTES]),
|
||||
"internal_kw": {room: internal[room] for room in vals},
|
||||
"windows_open": [w.name for w in self.windows
|
||||
if self._ventilation.window_state.get(w.key, {}).get("open")]})
|
||||
status.update({
|
||||
"status": "ready", "proposal": output["proposal"],
|
||||
"prediction_60m": output["predicted_60m"],
|
||||
"prediction_end": output["predicted_end"],
|
||||
"plan_15min": output["plan_15min"], "objective": output["cost"],
|
||||
"model_quality_note": "5-minute in-sample MAE; not a validated 60-minute forecast error",
|
||||
"prediction_trajectory": output["trajectory_15min"],
|
||||
"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
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Pure timestamp validation for reported sensor snapshots."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from math import isfinite
|
||||
|
||||
@@ -16,3 +17,57 @@ def freshness(reported: datetime | None, now: datetime, max_age_seconds: float)
|
||||
return {"status": "future_timestamp", "age_seconds": round(age, 1)}
|
||||
return {"status": "stale" if age > max_age_seconds else "valid",
|
||||
"age_seconds": round(age, 1)}
|
||||
|
||||
|
||||
class ReportAgeHistory:
|
||||
"""Largest report age seen at polls per entity over the last 24 hours.
|
||||
|
||||
Helps choose freshness limits: a limit below a sensor's normal maximum
|
||||
marks it stale during ordinary operation. Hourly buckets keep memory bounded.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buckets: dict[str, deque[list[float]]] = {}
|
||||
|
||||
def observe(self, entity_id: str, now: datetime, age_seconds: float) -> float:
|
||||
hour = now.timestamp() // 3600
|
||||
buckets = self._buckets.setdefault(entity_id, deque(maxlen=25))
|
||||
if buckets and buckets[-1][0] == hour:
|
||||
buckets[-1][1] = max(buckets[-1][1], age_seconds)
|
||||
else:
|
||||
buckets.append([hour, age_seconds])
|
||||
return max(age for start, age in buckets if hour - 24 < start <= hour)
|
||||
|
||||
|
||||
# How much an input may move while a prediction is computed before the result no
|
||||
# longer describes the current situation. Below these, changes are sensor noise.
|
||||
INPUT_TOLERANCES = {
|
||||
"temperature": 0.25, # °C, zone sensors and outdoor sources
|
||||
"power": 50.0, # W, heat-source sensors
|
||||
"wind": 1.0, # m/s
|
||||
"wind_gust": 2.0, # m/s
|
||||
"wind_direction": 20.0, # degrees, compared around the circle
|
||||
}
|
||||
|
||||
|
||||
def inputs_differ(before: dict, after: dict) -> list:
|
||||
"""Inputs whose change is meaningful: keys are (kind, entity), values normalized.
|
||||
|
||||
Kinds without a tolerance (relay, thermostat mode and target, window states,
|
||||
configuration revision) must match exactly; a value appearing or disappearing
|
||||
(for example a sensor becoming unavailable) always counts.
|
||||
"""
|
||||
changed = []
|
||||
for key in sorted(set(before) | set(after), key=str):
|
||||
old, new = before.get(key), after.get(key)
|
||||
tolerance = INPUT_TOLERANCES.get(key[0])
|
||||
if old is None or new is None or tolerance is None:
|
||||
if old != new:
|
||||
changed.append(key)
|
||||
continue
|
||||
difference = abs(new - old)
|
||||
if key[0] == "wind_direction":
|
||||
difference = min(difference, 360.0 - difference)
|
||||
if difference > tolerance:
|
||||
changed.append(key)
|
||||
return changed
|
||||
@@ -0,0 +1,607 @@
|
||||
"""Walk-forward evaluation of the thermal model against what actually happened.
|
||||
|
||||
Pure Python; no Home Assistant imports.
|
||||
|
||||
At every poll the readings, the outdoor temperature, the radiator-lag estimate
|
||||
and each room's model parameters are recorded. When 15/30/60/120 minutes have
|
||||
passed, each room is replayed from that earlier poll with the parameters it had
|
||||
then (so later data is never used), the outdoor temperature held at its starting
|
||||
value, and the radiator lag that actually followed from the relay's real ON/OFF
|
||||
history. The replayed temperature is compared with the reading now.
|
||||
|
||||
Two kinds of accuracy are kept apart:
|
||||
|
||||
* ForecastEvaluator, **model accuracy** (retrospective): the model is replayed
|
||||
with the relay, electrical heat and window history that actually happened. It
|
||||
answers "how well does the model reproduce the rooms under known inputs?" and
|
||||
compares the full model with the base model and with persistence. It is not a
|
||||
forecast accuracy: a real forecast does not know the future inputs.
|
||||
* OperationalForecastLedger, **forecast accuracy**: forecasts as published, with
|
||||
the boiler plan, electrical heat and windows they assumed, compared with what
|
||||
happened once their horizon has passed. A forecast assuming the MPC's plan can
|
||||
only be checked when the relay actually followed that plan; otherwise it is
|
||||
counted as counterfactual and not scored.
|
||||
|
||||
Forecasts start every 5 minutes, so consecutive ones overlap: n forecasts at a
|
||||
horizon of h minutes carry roughly n x 5 / h independent samples. Metrics report
|
||||
that number and a quality label, and sensors show no value while it is too small.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import isfinite, sqrt
|
||||
from typing import Any
|
||||
|
||||
from .physics import Learner
|
||||
from .windows import ventilation_known, ventilation_vector_for_prediction
|
||||
from .zone_model import feature_vector
|
||||
|
||||
HORIZONS = (15, 30, 60, 120) # minutes
|
||||
WINDOW_DAYS = 7
|
||||
STEP_SECONDS = 300
|
||||
# Independent samples (overlap-adjusted) needed before a metric is shown / trusted.
|
||||
MIN_EFFECTIVE_SAMPLES = 5
|
||||
GOOD_EFFECTIVE_SAMPLES = 30
|
||||
|
||||
|
||||
def effective_samples(count: int, horizon: int, zones: int = 1) -> float:
|
||||
"""Roughly independent samples among `count` forecasts started 5 minutes apart."""
|
||||
return round(count / max(1, zones) * min(1.0, STEP_SECONDS / 60 / horizon), 1)
|
||||
|
||||
|
||||
def quality(effective: float) -> str:
|
||||
if effective < MIN_EFFECTIVE_SAMPLES:
|
||||
return "insufficient"
|
||||
return "indicative" if effective < GOOD_EFFECTIVE_SAMPLES else "ok"
|
||||
|
||||
|
||||
def replay(temp: float, outside: float, theta: tuple[float, float, float],
|
||||
steps: list[tuple[float, float]]) -> float:
|
||||
"""Advance one room through (end-of-step lag, step seconds) with fixed parameters."""
|
||||
model = Learner(theta=list(theta))
|
||||
for lag, seconds in steps:
|
||||
# Same 5-minute model and jitter normalization as learning.
|
||||
temp += model.predict_delta(temp, outside, lag) * seconds / STEP_SECONDS
|
||||
return temp
|
||||
|
||||
|
||||
def _dot(theta: tuple[float, ...], x: list[float]) -> float:
|
||||
return sum(t * v for t, v in zip(theta, x))
|
||||
|
||||
|
||||
def model_snapshot(model: Any) -> dict[str, Any]:
|
||||
"""Frozen parameters of a ZoneModel, so later learning never leaks into a replay."""
|
||||
def layer(learner):
|
||||
return (list(learner.features), tuple(learner.theta)) if learner is not None else None
|
||||
return {"base": tuple(model.base.theta), "uses_extended": model.uses_extended,
|
||||
"extended": layer(model.extended), "ventilation": layer(model.ventilation)}
|
||||
|
||||
|
||||
def snapshot_delta(snapshot: dict[str, Any], temp: float, inputs: dict[str, Any]) -> float:
|
||||
"""5-minute change predicted by a model snapshot (see zone_model.ZoneModel.delta)."""
|
||||
main = None
|
||||
if snapshot["uses_extended"] and snapshot["extended"]:
|
||||
features, theta = snapshot["extended"]
|
||||
x = feature_vector(features, temp, inputs)
|
||||
main = _dot(theta, x) if x is not None else None
|
||||
if main is None:
|
||||
a, b, c = snapshot["base"]
|
||||
main = a * (inputs["outside"] - temp) + b * inputs["lag"] + c
|
||||
ventilation = inputs.get("ventilation")
|
||||
if snapshot["ventilation"] and ventilation:
|
||||
features, theta = snapshot["ventilation"]
|
||||
x = ventilation_vector_for_prediction(features, temp, inputs["outside"], ventilation)
|
||||
if x is not None:
|
||||
main += _dot(theta, x)
|
||||
return main
|
||||
|
||||
|
||||
def inputs_complete(snapshot: dict[str, Any], temp: float, inputs: dict[str, Any],
|
||||
incomplete: set[str]) -> bool:
|
||||
"""Whether a step of a zone's model used only observed inputs.
|
||||
|
||||
Not when a window state, or a weather input an open window's terms need, was
|
||||
unknown; nor when the extended layer is in use and its electrical heat or a
|
||||
neighbour is unknown, or that neighbour's own replay is already incomplete.
|
||||
The replay itself still falls back as forecasts do; the result is just not a
|
||||
measurement of the model.
|
||||
"""
|
||||
vent_features = snapshot["ventilation"][0] if snapshot.get("ventilation") else ()
|
||||
if not ventilation_known(vent_features, inputs.get("ventilation")):
|
||||
return False
|
||||
if snapshot["uses_extended"] and snapshot["extended"]:
|
||||
features = snapshot["extended"][0]
|
||||
if feature_vector(features, temp, inputs) is None:
|
||||
return False
|
||||
if any(f.startswith("couple:") and f.split(":", 1)[1] in incomplete for f in features):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def replay_zones(origin: dict[str, Any], path: list[dict[str, Any]]) -> tuple[dict[str, float], set[str]]:
|
||||
"""All zones advanced together with the origin's models and the actual inputs.
|
||||
|
||||
Returns the replayed temperatures and the zones whose replay needed an input
|
||||
that was not observed at some step (see inputs_complete).
|
||||
"""
|
||||
state = dict(origin["temps"])
|
||||
incomplete: set[str] = set()
|
||||
for before, poll in zip(path, path[1:]):
|
||||
seconds = (poll["time"] - before["time"]).total_seconds()
|
||||
new = {}
|
||||
unknown = set(incomplete)
|
||||
for zone, temp in state.items():
|
||||
actual = poll["inputs"].get(zone, {})
|
||||
inputs = {"outside": origin["outside"], "lag": poll["lag"], "temps": state,
|
||||
"internal": actual.get("internal"), "ventilation": actual.get("ventilation")}
|
||||
snapshot = origin["models"][zone]
|
||||
if not inputs_complete(snapshot, temp, inputs, incomplete):
|
||||
unknown.add(zone)
|
||||
new[zone] = temp + snapshot_delta(snapshot, temp, inputs) * seconds / STEP_SECONDS
|
||||
state, incomplete = new, unknown
|
||||
return state, incomplete
|
||||
|
||||
|
||||
class ForecastEvaluator:
|
||||
"""Score replayed forecasts per room and horizon over the last WINDOW_DAYS days."""
|
||||
|
||||
def __init__(self, max_lag_uncertainty: float) -> None:
|
||||
self._max_lag_uncertainty = max_lag_uncertainty
|
||||
self._history: deque[dict[str, Any]] = deque(maxlen=HORIZONS[-1] * 60 // STEP_SECONDS + 1)
|
||||
# UTC day -> horizon -> room -> [count, sum error, sum |error|, sum |persistence error|,
|
||||
# sum |base model error|, sum error^2, count with squares (0 for 0.3/0.4 data)]
|
||||
self._days: dict[str, dict[str, dict[str, list[float]]]] = {}
|
||||
# UTC day -> horizon -> room -> [incomplete spans, skipped spans, sum |base model error|
|
||||
# over the incomplete ones]. Neither kind enters the statistics above.
|
||||
self._excluded: dict[str, dict[str, dict[str, list[float]]]] = {}
|
||||
|
||||
def reset_window(self) -> None:
|
||||
"""Inputs were missing: no forecast may be scored across this poll."""
|
||||
self._history.clear()
|
||||
|
||||
def observe(self, time: datetime, temps: dict[str, float], outside: float, lag: float,
|
||||
lag_uncertainty: float, theta: dict[str, tuple[float, float, float]],
|
||||
models: dict[str, dict[str, Any]] | None = None,
|
||||
inputs: dict[str, dict[str, Any]] | None = None) -> None:
|
||||
"""Record a poll, then score every forecast that has reached its horizon.
|
||||
|
||||
`models`: model_snapshot per zone (base-only when omitted); `inputs`: this
|
||||
poll's actual electrical heat and ventilation inputs per zone.
|
||||
"""
|
||||
if self._history and not 270 <= (time - self._history[-1]["time"]).total_seconds() <= 330:
|
||||
self._history.clear() # irregular poll spacing: replays would be unreliable
|
||||
models = models or {}
|
||||
snapshots = {zone: models.get(zone) or {"base": tuple(theta[zone]), "uses_extended": False,
|
||||
"extended": None, "ventilation": None}
|
||||
for zone in temps if zone in theta}
|
||||
self._history.append({"time": time, "temps": dict(temps), "outside": outside, "lag": lag,
|
||||
"lag_uncertainty": lag_uncertainty, "theta": dict(theta),
|
||||
"models": snapshots, "inputs": dict(inputs or {})})
|
||||
self._score()
|
||||
self._prune(time)
|
||||
|
||||
def _exclude(self, day: str, horizon: int, room: str, kind: int, base_error: float = 0.0) -> None:
|
||||
bucket = self._excluded.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(room, [0, 0, 0.0])
|
||||
bucket[kind] += 1
|
||||
bucket[2] += abs(base_error)
|
||||
|
||||
def _score(self) -> None:
|
||||
history = list(self._history)
|
||||
latest = history[-1]
|
||||
day = latest["time"].date().isoformat()
|
||||
for horizon in HORIZONS:
|
||||
steps = horizon * 60 // STEP_SECONDS
|
||||
if len(history) < steps + 1:
|
||||
continue
|
||||
path = history[-(steps + 1):]
|
||||
origin = path[0]
|
||||
# The actual heat input must be known along the way.
|
||||
if any(poll["lag_uncertainty"] > self._max_lag_uncertainty for poll in path[1:]):
|
||||
for room in origin["temps"]:
|
||||
self._exclude(day, horizon, room, SKIPPED)
|
||||
continue
|
||||
inputs = [(poll["lag"], (poll["time"] - before["time"]).total_seconds())
|
||||
for before, poll in zip(path, path[1:])]
|
||||
full, incomplete = replay_zones(origin, path)
|
||||
for room, start in origin["temps"].items():
|
||||
actual, theta = latest["temps"].get(room), origin["theta"].get(room)
|
||||
if actual is None or theta is None:
|
||||
self._exclude(day, horizon, room, SKIPPED) # no reading at the end
|
||||
continue
|
||||
error = full[room] - actual
|
||||
base_error = replay(start, origin["outside"], theta, inputs) - actual
|
||||
if room in incomplete:
|
||||
# The base model needs only the relay and outdoor temperature: its
|
||||
# error is still a measurement, kept apart from the main figures.
|
||||
self._exclude(day, horizon, room, INCOMPLETE, base_error)
|
||||
continue
|
||||
bucket = self._days.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(
|
||||
room, [0, 0.0, 0.0, 0.0, 0.0, 0.0, 0])
|
||||
bucket[0] += 1
|
||||
bucket[1] += error
|
||||
bucket[2] += abs(error)
|
||||
bucket[3] += abs(start - actual)
|
||||
bucket[4] += abs(base_error)
|
||||
bucket[5] += error * error
|
||||
bucket[6] += 1
|
||||
|
||||
def _prune(self, time: datetime) -> None:
|
||||
oldest = (time.date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
|
||||
for store in (self._days, self._excluded):
|
||||
for day in [day for day in store if day < oldest]:
|
||||
del store[day]
|
||||
|
||||
def summary(self, rooms: list[str]) -> dict[int, dict[str, Any]]:
|
||||
"""Error statistics per horizon, overall and per configured room."""
|
||||
result = {}
|
||||
for horizon in HORIZONS:
|
||||
per_room: dict[str, list[float]] = {}
|
||||
for day in self._days.values():
|
||||
for room, values in day.get(str(horizon), {}).items():
|
||||
if room in rooms:
|
||||
totals = per_room.setdefault(room, [0, 0.0, 0.0, 0.0, 0.0, 0.0, 0])
|
||||
for i, value in enumerate(values):
|
||||
totals[i] += value
|
||||
overall = [sum(values[i] for values in per_room.values()) for i in range(7)]
|
||||
excluded: dict[str, list[float]] = {}
|
||||
for day in self._excluded.values():
|
||||
for room, values in day.get(str(horizon), {}).items():
|
||||
if room in rooms:
|
||||
totals = excluded.setdefault(room, [0, 0, 0.0])
|
||||
for i, value in enumerate(values):
|
||||
totals[i] += value
|
||||
all_excluded = [sum(values[i] for values in excluded.values()) for i in range(3)]
|
||||
result[horizon] = {**_stats(overall, horizon, len(per_room)), **_excluded_stats(all_excluded),
|
||||
"rooms": {room: {**_stats(per_room.get(room, [0, 0.0, 0.0, 0.0, 0.0, 0.0, 0]),
|
||||
horizon),
|
||||
**_excluded_stats(excluded.get(room, [0, 0, 0.0]))}
|
||||
for room in rooms if room in per_room or room in excluded}}
|
||||
return result
|
||||
|
||||
def forget(self, room: str) -> None:
|
||||
"""Drop a zone's statistics, for example when its temperature is measured differently."""
|
||||
for store in (self._days, self._excluded):
|
||||
for by_horizon in store.values():
|
||||
for by_room in by_horizon.values():
|
||||
by_room.pop(room, None)
|
||||
for poll in self._history:
|
||||
poll["temps"].pop(room, None)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
# Rounded copy: shorter JSON on every save; in-memory sums stay exact.
|
||||
return {"days": {day: {horizon: {room: [v[0], *(round(x, 6) for x in v[1:6]), v[6]]
|
||||
for room, v in by_room.items()}
|
||||
for horizon, by_room in by_horizon.items()}
|
||||
for day, by_horizon in self._days.items()},
|
||||
"excluded": {day: {horizon: {room: [v[0], v[1], round(v[2], 6)] for room, v in by_room.items()}
|
||||
for horizon, by_room in by_horizon.items()}
|
||||
for day, by_horizon in self._excluded.items()}}
|
||||
|
||||
def load(self, data: Any) -> None:
|
||||
"""Restore stored statistics, keeping only well-formed entries."""
|
||||
self._days, self._excluded = {}, {}
|
||||
excluded = data.get("excluded") if isinstance(data, dict) else None
|
||||
for day, horizon, room, values in _entries(excluded):
|
||||
if (isinstance(values, list) and len(values) == 3 and all(type(v) is int and v >= 0 for v in values[:2])
|
||||
and type(values[2]) in (int, float) and isfinite(values[2]) and values[2] >= 0):
|
||||
self._excluded.setdefault(day, {}).setdefault(horizon, {})[room] = [
|
||||
values[0], values[1], float(values[2])]
|
||||
days = data.get("days") if isinstance(data, dict) else None
|
||||
if not isinstance(days, dict):
|
||||
return
|
||||
horizons = {str(horizon) for horizon in HORIZONS}
|
||||
for day, by_horizon in days.items():
|
||||
try:
|
||||
datetime.strptime(day, "%Y-%m-%d")
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not isinstance(by_horizon, dict):
|
||||
continue
|
||||
for horizon, by_room in by_horizon.items():
|
||||
if horizon not in horizons or not isinstance(by_room, dict):
|
||||
continue
|
||||
for room, values in by_room.items():
|
||||
if _valid_bucket(room, values):
|
||||
# 0.3 buckets had no base column: the model then was the base model.
|
||||
# Older buckets have no squared errors: they do not count for RMSE.
|
||||
base = values[4] if len(values) >= 5 else values[2]
|
||||
squares = (float(values[5]), values[6]) if len(values) == 7 else (0.0, 0)
|
||||
self._days.setdefault(day, {}).setdefault(horizon, {})[room] = [
|
||||
values[0], float(values[1]), float(values[2]), float(values[3]), float(base),
|
||||
*squares]
|
||||
|
||||
|
||||
INCOMPLETE, SKIPPED = 0, 1 # columns of the excluded-span counts
|
||||
|
||||
|
||||
def _entries(days: Any):
|
||||
"""(day, horizon, zone, values) of stored per-day statistics with a valid shape."""
|
||||
if not isinstance(days, dict):
|
||||
return
|
||||
horizons = {str(horizon) for horizon in HORIZONS}
|
||||
for day, by_horizon in days.items():
|
||||
if not _valid_day(day) or not isinstance(by_horizon, dict):
|
||||
continue
|
||||
for horizon, by_zone in by_horizon.items():
|
||||
if horizon in horizons and isinstance(by_zone, dict):
|
||||
for zone, values in by_zone.items():
|
||||
if isinstance(zone, str):
|
||||
yield day, horizon, zone, values
|
||||
|
||||
|
||||
def _excluded_stats(values: list[float]) -> dict[str, Any]:
|
||||
incomplete, skipped, base = values
|
||||
return {"incomplete": int(incomplete), "skipped": int(skipped),
|
||||
"incomplete_base_model_mae": round(base / incomplete, 3) if incomplete else None}
|
||||
|
||||
|
||||
def _valid_bucket(room: Any, values: Any) -> bool:
|
||||
if not isinstance(room, str) or not isinstance(values, list) or len(values) not in (4, 5, 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 (type(count) is int and count >= 0
|
||||
and all(type(v) in (int, float) and isfinite(v) for v in sums)
|
||||
and all(v >= 0 for v in sums[1:]))
|
||||
|
||||
|
||||
def _stats(values: list[float], horizon: int, zones: int = 1) -> dict[str, Any]:
|
||||
count, error, absolute, persistence, base, squares, square_count = values
|
||||
effective = effective_samples(int(count), horizon, zones)
|
||||
if not count:
|
||||
return {"samples": 0, "effective_samples": 0.0, "quality": "insufficient", "mae": None,
|
||||
"rmse": None, "bias": None, "persistence_mae": None, "skill": None,
|
||||
"base_model_mae": None, "improvement_vs_base": None}
|
||||
return {
|
||||
"samples": int(count),
|
||||
"effective_samples": effective,
|
||||
"quality": quality(effective),
|
||||
"mae": round(absolute / count, 3),
|
||||
"rmse": round(sqrt(squares / square_count), 3) if square_count else None,
|
||||
"bias": round(error / count, 3),
|
||||
"persistence_mae": round(persistence / count, 3),
|
||||
# Share of the persistence error removed by the model (1 = perfect, < 0 = worse).
|
||||
"skill": round(1 - absolute / persistence, 3) if persistence > 0 else None,
|
||||
"base_model_mae": round(base / count, 3),
|
||||
# Share of the base model's error removed by electrical heat, neighbours and
|
||||
# ventilation (0 = no gain, < 0 = the extra features make it worse).
|
||||
"improvement_vs_base": round(1 - absolute / base, 3) if base > 0 else None,
|
||||
}
|
||||
|
||||
|
||||
# How far the relay may depart from a forecast's plan (seconds in another state than
|
||||
# planned, up to the horizon) for the forecast to be compared with what happened.
|
||||
# d seconds of boiler heat that the plan did not assume (or the reverse) move the
|
||||
# room by roughly heat x d / 300, where heat is the learned boiler coefficient
|
||||
# (typically 0.05-0.12 °C per 5 minutes with warm radiators). Up to 2 minutes keeps
|
||||
# that below half a typical 0.1 °C sensor step and absorbs timestamp jitter;
|
||||
# matched forecasts make the Forecast Error figures. Up to 5 minutes (one poll) is
|
||||
# about one sensor step: kept apart as approximate. Beyond, the relay did
|
||||
# something else: the forecast is counterfactual and is not scored. Relay time
|
||||
# that was not observed counts as possible deviation, and more than 2 minutes of it
|
||||
# leaves the forecast incomplete rather than approximate.
|
||||
MATCH_SECONDS = 120
|
||||
APPROXIMATE_SECONDS = 300
|
||||
DUE_TOLERANCE_SECONDS = 150 # half a poll: the poll closest to the forecast's due time
|
||||
VERDICTS = ("matched", "approximate", "counterfactual", "incomplete")
|
||||
# Why a forecast (or, for a missing reading, a zone's forecast) could not be compared.
|
||||
INCOMPLETE_REASONS = ("relay_history", "uncertain_inputs", "no_poll", "reading")
|
||||
LEDGER_VERSION = 2 # 1 (0.4.1) accepted plans within 20 % per 15-minute block
|
||||
|
||||
|
||||
class OperationalForecastLedger:
|
||||
"""Forecasts as published, scored against what happened once their horizon passed.
|
||||
|
||||
Nothing is replaced with hindsight: the start temperatures, trajectory, boiler
|
||||
plan and assumptions are those of the moment the forecast was made. Because the
|
||||
plan is the MPC's proposal while Generic Thermostat controls the relay, a
|
||||
forecast is compared only when the relay actually followed that plan (see
|
||||
MATCH_SECONDS), and not at all when it was made with an input it had to
|
||||
assume. Pending forecasts live in memory; the statistics are saved.
|
||||
"""
|
||||
|
||||
def __init__(self, saved: Any = None) -> None:
|
||||
self._pending: deque[dict[str, Any]] = deque(maxlen=60)
|
||||
# (interval end, seconds the relay was on or None if unknown, interval seconds)
|
||||
self._relay: deque[tuple[float, float | None, float]] = deque(maxlen=48)
|
||||
# UTC day -> horizon -> zone -> [count, sum error, sum |error|, sum error^2, sum |persistence error|]
|
||||
self._days: dict[str, dict[str, dict[str, list[float]]]] = {} # matched forecasts
|
||||
self._approximate: dict[str, dict[str, dict[str, list[float]]]] = {}
|
||||
# UTC day -> horizon -> verdict (incomplete ones as "incomplete:<reason>") -> count
|
||||
self._verdicts: dict[str, dict[str, dict[str, int]]] = {}
|
||||
self.load(saved)
|
||||
|
||||
def record(self, time: float, start: dict[str, float], trajectory: dict[str, list[float]],
|
||||
plan: list[int], revision: str, assumptions: dict[str, Any]) -> None:
|
||||
self._pending.append({"time": time, "start": dict(start), "trajectory": dict(trajectory),
|
||||
"plan": list(plan), "revision": revision, "assumptions": assumptions,
|
||||
"done": set()})
|
||||
|
||||
def observe(self, now: float, actual: dict[str, float | None], on_seconds: float | None,
|
||||
seconds: float | None) -> None:
|
||||
"""One poll: the relay's last interval, then score forecasts that are due."""
|
||||
if seconds:
|
||||
self._relay.append((now, on_seconds, seconds))
|
||||
day = datetime.fromtimestamp(now, timezone.utc).date().isoformat()
|
||||
for forecast in list(self._pending):
|
||||
for horizon in HORIZONS:
|
||||
index = horizon // 15 - 1
|
||||
due = forecast["time"] + horizon * 60
|
||||
if horizon in forecast["done"] or now < due - DUE_TOLERANCE_SECONDS:
|
||||
continue
|
||||
forecast["done"].add(horizon)
|
||||
if index >= len(forecast["plan"]):
|
||||
continue # beyond this forecast's horizon
|
||||
verdict = self._classify(forecast, horizon, now)
|
||||
self._count(day, horizon, verdict)
|
||||
if verdict not in ("matched", "approximate"):
|
||||
continue
|
||||
store = self._days if verdict == "matched" else self._approximate
|
||||
for zone, path in forecast["trajectory"].items():
|
||||
measured, start = actual.get(zone), forecast["start"].get(zone)
|
||||
if measured is None or start is None or len(path) <= index:
|
||||
self._count(day, horizon, "incomplete:reading")
|
||||
continue
|
||||
error = path[index] - measured
|
||||
bucket = store.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(
|
||||
zone, [0, 0.0, 0.0, 0.0, 0.0])
|
||||
bucket[0] += 1
|
||||
bucket[1] += error
|
||||
bucket[2] += abs(error)
|
||||
bucket[3] += error * error
|
||||
bucket[4] += abs(start - measured)
|
||||
if len(forecast["done"]) == len(HORIZONS) or now > forecast["time"] + (HORIZONS[-1] + 5) * 60:
|
||||
self._pending.remove(forecast)
|
||||
oldest = (datetime.fromtimestamp(now, timezone.utc).date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
|
||||
for store in (self._days, self._approximate, self._verdicts):
|
||||
for old in [d for d in store if d < oldest]:
|
||||
del store[old]
|
||||
|
||||
def _classify(self, forecast: dict[str, Any], horizon: int, now: float) -> str:
|
||||
if now > forecast["time"] + horizon * 60 + DUE_TOLERANCE_SECONDS:
|
||||
return "incomplete:no_poll"
|
||||
if forecast["assumptions"].get("uncertain_inputs"):
|
||||
return "incomplete:uncertain_inputs"
|
||||
deviation, unobserved = self.deviation(forecast, horizon)
|
||||
if deviation > APPROXIMATE_SECONDS:
|
||||
return "counterfactual" # whatever happened in the unobserved part
|
||||
# Otherwise judged by the worst case: unobserved time may all have deviated.
|
||||
if deviation + unobserved <= MATCH_SECONDS:
|
||||
return "matched"
|
||||
if deviation + unobserved <= APPROXIMATE_SECONDS and unobserved <= MATCH_SECONDS:
|
||||
return "approximate"
|
||||
return "incomplete:relay_history"
|
||||
|
||||
def deviation(self, forecast: dict[str, Any], horizon: int) -> tuple[float, float]:
|
||||
"""Seconds up to `horizon` the relay was seen in another state than planned,
|
||||
and seconds its state is not known.
|
||||
|
||||
Compared per 15-minute plan block, from the relay's on-time per poll interval,
|
||||
itself integrated at the relay's own switch times. So timing counts: the same
|
||||
total on-time at other times than planned is a deviation, while a switch a few
|
||||
seconds off a block boundary is not.
|
||||
"""
|
||||
start, end = forecast["time"], forecast["time"] + horizon * 60
|
||||
deviation = covered = unobserved = 0.0
|
||||
for interval_end, on_seconds, seconds in self._relay:
|
||||
lo, hi = max(start, interval_end - seconds), min(end, interval_end)
|
||||
if hi <= lo:
|
||||
continue
|
||||
covered += hi - lo
|
||||
if on_seconds is None:
|
||||
unobserved += hi - lo
|
||||
continue
|
||||
share = on_seconds / seconds
|
||||
t = lo
|
||||
while t < hi: # split at plan block boundaries
|
||||
block = min(int((t - start) // 900), len(forecast["plan"]) - 1)
|
||||
part_end = min(hi, start + (block + 1) * 900)
|
||||
deviation += (part_end - t) * (share if not forecast["plan"][block] else 1.0 - share)
|
||||
t = part_end
|
||||
return deviation, unobserved + max(0.0, (end - start) - covered)
|
||||
|
||||
def _count(self, day: str, horizon: int, verdict: str) -> None:
|
||||
counts = self._verdicts.setdefault(day, {}).setdefault(str(horizon), {})
|
||||
counts[verdict] = counts.get(verdict, 0) + 1
|
||||
|
||||
def forget(self, zone: str) -> None:
|
||||
for store in (self._days, self._approximate):
|
||||
for by_horizon in store.values():
|
||||
for by_zone in by_horizon.values():
|
||||
by_zone.pop(zone, None)
|
||||
for forecast in self._pending:
|
||||
forecast["trajectory"].pop(zone, None)
|
||||
|
||||
@property
|
||||
def pending(self) -> int:
|
||||
return len(self._pending)
|
||||
|
||||
def summary(self, zones: list[str]) -> dict[int, dict[str, Any]]:
|
||||
result = {}
|
||||
for horizon in HORIZONS:
|
||||
per_zone = _sum_by_zone(self._days, horizon, zones)
|
||||
approximate = _sum_by_zone(self._approximate, horizon, zones)
|
||||
counts: dict[str, int] = {}
|
||||
for by_horizon in self._verdicts.values():
|
||||
for verdict, count in by_horizon.get(str(horizon), {}).items():
|
||||
counts[verdict] = counts.get(verdict, 0) + count
|
||||
reasons = {reason: counts.get(f"incomplete:{reason}", 0) for reason in INCOMPLETE_REASONS}
|
||||
overall = [sum(values[i] for values in per_zone.values()) for i in range(5)]
|
||||
approx_overall = [sum(values[i] for values in approximate.values()) for i in range(5)]
|
||||
result[horizon] = {
|
||||
**_operational_stats(overall, horizon, len(per_zone)),
|
||||
# Forecasts by how the relay compared with their plan.
|
||||
"matched": counts.get("matched", 0), "approximate": counts.get("approximate", 0),
|
||||
"counterfactual": counts.get("counterfactual", 0), "incomplete": sum(reasons.values()),
|
||||
"incomplete_reasons": reasons,
|
||||
"approximate_stats": _operational_stats(approx_overall, horizon, len(approximate)),
|
||||
"zones": {zone: _operational_stats(values, horizon) for zone, values in per_zone.items()},
|
||||
}
|
||||
return result
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
def rounded(store):
|
||||
return {day: {h: {zone: [v[0], *(round(x, 6) for x in v[1:])] for zone, v in by_zone.items()}
|
||||
for h, by_zone in by_horizon.items()} for day, by_horizon in store.items()}
|
||||
return {"version": LEDGER_VERSION, "days": rounded(self._days),
|
||||
"approximate": rounded(self._approximate), "verdicts": self._verdicts}
|
||||
|
||||
def load(self, data: Any) -> None:
|
||||
self._days, self._approximate, self._verdicts = {}, {}, {}
|
||||
# 0.4.1 statistics used a looser plan criterion: they are not comparable.
|
||||
if not isinstance(data, dict) or data.get("version") != LEDGER_VERSION:
|
||||
return
|
||||
for name, store in (("days", self._days), ("approximate", self._approximate)):
|
||||
for day, horizon, zone, values in _entries(data.get(name)):
|
||||
if (isinstance(values, list) and len(values) == 5 and type(values[0]) is int and values[0] >= 0
|
||||
and all(type(v) in (int, float) and isfinite(v) for v in values[1:])
|
||||
and min(values[2:]) >= 0):
|
||||
store.setdefault(day, {}).setdefault(horizon, {})[zone] = [
|
||||
values[0], *(float(v) for v in values[1:])]
|
||||
known = {"matched", "approximate", "counterfactual", *(f"incomplete:{r}" for r in INCOMPLETE_REASONS)}
|
||||
verdicts = data.get("verdicts") if isinstance(data.get("verdicts"), dict) else {}
|
||||
horizons = {str(h) for h in HORIZONS}
|
||||
for day, by_horizon in verdicts.items():
|
||||
if not _valid_day(day) or not isinstance(by_horizon, dict):
|
||||
continue
|
||||
for horizon, counts in by_horizon.items():
|
||||
if horizon in horizons and isinstance(counts, dict):
|
||||
clean = {k: v for k, v in counts.items() if k in known and type(v) is int and v >= 0}
|
||||
if clean:
|
||||
self._verdicts.setdefault(day, {})[horizon] = clean
|
||||
|
||||
|
||||
def _sum_by_zone(store: dict[str, dict[str, dict[str, list[float]]]], horizon: int,
|
||||
zones: list[str]) -> dict[str, list[float]]:
|
||||
totals: dict[str, list[float]] = {}
|
||||
for by_horizon in store.values():
|
||||
for zone, values in by_horizon.get(str(horizon), {}).items():
|
||||
if zone in zones:
|
||||
bucket = totals.setdefault(zone, [0, 0.0, 0.0, 0.0, 0.0])
|
||||
for i, value in enumerate(values):
|
||||
bucket[i] += value
|
||||
return totals
|
||||
|
||||
|
||||
def _valid_day(day: Any) -> bool:
|
||||
try:
|
||||
datetime.strptime(day, "%Y-%m-%d")
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _operational_stats(values: list[float], horizon: int, zones: int = 1) -> dict[str, Any]:
|
||||
count, error, absolute, squares, persistence = values
|
||||
effective = effective_samples(int(count), horizon, zones)
|
||||
if not count:
|
||||
return {"samples": 0, "effective_samples": 0.0, "quality": "insufficient", "mae": None, "rmse": None,
|
||||
"bias": None, "persistence_mae": None, "improvement_vs_persistence": None}
|
||||
return {
|
||||
"samples": int(count), "effective_samples": effective, "quality": quality(effective),
|
||||
"mae": round(absolute / count, 3), "rmse": round(sqrt(squares / count), 3),
|
||||
"bias": round(error / count, 3), "persistence_mae": round(persistence / count, 3),
|
||||
"improvement_vs_persistence": round(1 - absolute / persistence, 3) if persistence > 0 else None,
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Electrical internal heat gains (UPS, PCs, servers) per zone.
|
||||
|
||||
Pure Python; no Home Assistant imports.
|
||||
|
||||
Most electrical energy ends up as heat in the room. What matters to the thermal
|
||||
model is the *variation* around a persistent baseline: the constant part (for
|
||||
example a UPS drawing 200 W all day) is already absorbed by the model's bias
|
||||
term, so feeding it again would count it twice. Each source therefore supplies
|
||||
|
||||
variable heat = fraction x (effective power - baseline)
|
||||
|
||||
where the baseline is a robust low percentile of the last 24 hours (or a value
|
||||
set by the user), and "effective power" removes separately measured devices
|
||||
that are already included in a parent measurement (a PC behind a UPS). The
|
||||
variable heat reaches the room through a first-order response, because the air,
|
||||
furniture and equipment take time to warm.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from math import exp, isfinite
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
SUBENTRY_HEAT_SOURCE = "heat_source"
|
||||
MAX_SOURCES = 16
|
||||
MAX_POWER_W = 20000.0
|
||||
BASELINE_WINDOW = 288 # 24 h of 5-minute samples
|
||||
BASELINE_PERCENTILE = 0.2
|
||||
BASELINE_MIN_SAMPLES = 12 # 1 h before the automatic baseline is trusted
|
||||
RESPONSE_MINUTES_RANGE = (1.0, 180.0)
|
||||
DEFAULT_RESPONSE_MINUTES = 15.0
|
||||
|
||||
SOURCE_KEY = "key"
|
||||
SOURCE_NAME = "name"
|
||||
SOURCE_SENSOR = "sensor"
|
||||
SOURCE_ZONE = "zone"
|
||||
SOURCE_FRACTION = "heat_fraction"
|
||||
SOURCE_BASELINE_MODE = "baseline_mode"
|
||||
SOURCE_BASELINE_W = "baseline_w"
|
||||
SOURCE_INCLUDED_IN = "included_in"
|
||||
SOURCE_RESPONSE = "response_minutes"
|
||||
SOURCE_ENABLED = "enabled"
|
||||
BASELINE_AUTO = "auto"
|
||||
BASELINE_MANUAL = "manual"
|
||||
|
||||
# Active power only: VA (apparent power) and % (UPS load) are not heat.
|
||||
POWER_UNITS = {"w": 1.0, "kw": 1000.0, "mw": 0.001}
|
||||
|
||||
|
||||
def normalize_power(raw: Any, unit: Any) -> float | None:
|
||||
"""Active power in W, or None when the value or unit is unusable."""
|
||||
if not isinstance(unit, str) or unit.strip().lower() not in POWER_UNITS:
|
||||
return None
|
||||
try:
|
||||
value = float(raw) * POWER_UNITS[unit.strip().lower()]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if isfinite(value) and 0 <= value <= MAX_POWER_W else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HeatSource:
|
||||
key: str
|
||||
name: str
|
||||
sensor: str
|
||||
zone: str
|
||||
fraction: float = 1.0
|
||||
baseline_mode: str = BASELINE_AUTO
|
||||
baseline_w: float = 0.0
|
||||
included_in: str | None = None
|
||||
response_minutes: float = DEFAULT_RESPONSE_MINUTES
|
||||
enabled: bool = True
|
||||
subentry_id: str | None = None
|
||||
|
||||
|
||||
def _number(value: Any, default: float, lo: float, hi: float) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return number if isfinite(number) and lo <= number <= hi else default
|
||||
|
||||
|
||||
def source_from_data(data: Mapping[str, Any], subentry_id: str | None = None) -> HeatSource | None:
|
||||
key = data.get(SOURCE_KEY) or subentry_id
|
||||
sensor, zone = data.get(SOURCE_SENSOR), data.get(SOURCE_ZONE)
|
||||
if not (isinstance(key, str) and isinstance(sensor, str) and sensor.startswith("sensor.")
|
||||
and isinstance(zone, str)):
|
||||
return None
|
||||
name = data.get(SOURCE_NAME)
|
||||
parent = data.get(SOURCE_INCLUDED_IN)
|
||||
mode = data.get(SOURCE_BASELINE_MODE)
|
||||
return HeatSource(
|
||||
key=key, name=name if isinstance(name, str) and name.strip() else key, sensor=sensor, zone=zone,
|
||||
fraction=_number(data.get(SOURCE_FRACTION), 1.0, 0.0, 1.0),
|
||||
baseline_mode=mode if mode in (BASELINE_AUTO, BASELINE_MANUAL) else BASELINE_AUTO,
|
||||
baseline_w=_number(data.get(SOURCE_BASELINE_W), 0.0, 0.0, MAX_POWER_W),
|
||||
included_in=parent if isinstance(parent, str) and parent and parent != key else None,
|
||||
response_minutes=_number(data.get(SOURCE_RESPONSE), DEFAULT_RESPONSE_MINUTES, *RESPONSE_MINUTES_RANGE),
|
||||
enabled=data.get(SOURCE_ENABLED, True) is not False,
|
||||
subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
|
||||
def build_sources(items: Iterable[tuple[Mapping[str, Any], str | None]], zone_keys: Iterable[str]) -> list[HeatSource]:
|
||||
"""Valid sources: unique keys and sensors, existing zones, one level of inclusion."""
|
||||
zone_keys = set(zone_keys)
|
||||
sources: list[HeatSource] = []
|
||||
for data, subentry_id in items:
|
||||
source = source_from_data(data, subentry_id)
|
||||
if (source is None or source.zone not in zone_keys or len(sources) == MAX_SOURCES
|
||||
or any(s.key == source.key or s.sensor == source.sensor for s in sources)):
|
||||
continue
|
||||
sources.append(source)
|
||||
keys = {s.key: s for s in sources}
|
||||
# A parent must exist and must not itself be included in another source.
|
||||
return [s if s.included_in in keys and keys[s.included_in].included_in is None
|
||||
else HeatSource(**{**s.__dict__, "included_in": None}) if s.included_in else s
|
||||
for s in sources]
|
||||
|
||||
|
||||
def effective_power(sources: list[HeatSource], readings: Mapping[str, float | None]) -> dict[str, float | None]:
|
||||
"""Power of each enabled source after removing included child sources (W).
|
||||
|
||||
`readings` maps source key -> measured W (None if unavailable). A parent is
|
||||
unknown when any enabled child it contains is unknown, since the split is
|
||||
then impossible. Values below 0 (timing differences) are clamped.
|
||||
"""
|
||||
result: dict[str, float | None] = {}
|
||||
for source in sources:
|
||||
if not source.enabled:
|
||||
continue
|
||||
power = readings.get(source.key)
|
||||
children = [s for s in sources if s.included_in == source.key and s.enabled]
|
||||
if power is None or any(readings.get(c.key) is None for c in children):
|
||||
result[source.key] = None
|
||||
continue
|
||||
result[source.key] = max(0.0, power - sum(readings[c.key] for c in children))
|
||||
return result
|
||||
|
||||
|
||||
class BaselineEstimator:
|
||||
"""Robust persistent load: low percentile of the last 24 hours of 5-minute samples."""
|
||||
|
||||
def __init__(self, samples: Iterable[float] = ()) -> None:
|
||||
self.samples: deque[float] = deque(
|
||||
(float(v) for v in samples if isinstance(v, (int, float)) and isfinite(v) and 0 <= v <= MAX_POWER_W),
|
||||
maxlen=BASELINE_WINDOW)
|
||||
|
||||
def add(self, watts: float) -> None:
|
||||
self.samples.append(watts)
|
||||
|
||||
def value(self) -> float | None:
|
||||
if len(self.samples) < BASELINE_MIN_SAMPLES:
|
||||
return None
|
||||
ordered = sorted(self.samples)
|
||||
return ordered[int(BASELINE_PERCENTILE * (len(ordered) - 1))]
|
||||
|
||||
|
||||
class ResponseFilter:
|
||||
"""First-order thermal response of a heat input (kW), like the radiator lag."""
|
||||
|
||||
def __init__(self, minutes: float) -> None:
|
||||
self.tau = minutes * 60.0
|
||||
self.value: float | None = None
|
||||
|
||||
def advance(self, held_kw: float, seconds: float) -> float:
|
||||
"""Advance over an interval during which `held_kw` was dissipated."""
|
||||
if self.value is None:
|
||||
self.value = held_kw # assume the current load has been steady
|
||||
else:
|
||||
self.value += (1.0 - exp(-max(0.0, seconds) / self.tau)) * (held_kw - self.value)
|
||||
return self.value
|
||||
|
||||
def reset(self) -> None:
|
||||
self.value = None
|
||||
|
||||
|
||||
def forecast_internal(current_kw: float, steps: int, half_life_minutes: float | None,
|
||||
step_minutes: float = 5.0) -> list[float]:
|
||||
"""Variable heat assumed over a forecast: decays to baseline (0) with a half-life.
|
||||
|
||||
A gaming PC drawing 600 W now is not assumed to keep doing so for two hours.
|
||||
`half_life_minutes=None` keeps the current value (persistence).
|
||||
"""
|
||||
if half_life_minutes is None:
|
||||
return [current_kw] * steps
|
||||
if half_life_minutes <= 0:
|
||||
return [0.0] * steps
|
||||
factor = 0.5 ** (step_minutes / half_life_minutes)
|
||||
return [current_kw * factor ** (k + 1) for k in range(steps)]
|
||||
|
||||
|
||||
def validate_source(data: Mapping[str, Any], others: Iterable[HeatSource]) -> str | None:
|
||||
"""Translation key of the first problem with a heat-source form, or None."""
|
||||
others = list(others)
|
||||
if not str(data.get(SOURCE_NAME, "")).strip():
|
||||
return "source_name_required"
|
||||
if any(s.sensor == data.get(SOURCE_SENSOR) for s in others):
|
||||
return "sensor_in_other_source"
|
||||
parent = data.get(SOURCE_INCLUDED_IN)
|
||||
if parent:
|
||||
parents = {s.key: s for s in others}
|
||||
if parent not in parents or parents[parent].included_in:
|
||||
return "invalid_parent_source"
|
||||
key = data.get(SOURCE_KEY)
|
||||
if key and any(s.included_in == key for s in others):
|
||||
return "source_has_children"
|
||||
if data.get(SOURCE_BASELINE_MODE) == BASELINE_MANUAL and data.get(SOURCE_BASELINE_W) in (None, ""):
|
||||
return "baseline_required"
|
||||
if len(others) >= MAX_SOURCES:
|
||||
return "too_many_sources"
|
||||
return None
|
||||
|
||||
|
||||
def forecast_response(response_kw: float, variable_kw: float, steps: int, tau_minutes: float,
|
||||
half_life_minutes: float | None, step_minutes: float = 5.0) -> list[float]:
|
||||
"""Thermal response (kW) at the end of each forecast step.
|
||||
|
||||
The variable power returns towards baseline (see forecast_internal) and the
|
||||
response keeps following it with its own time constant.
|
||||
"""
|
||||
alpha = 1.0 - exp(-step_minutes / tau_minutes)
|
||||
held = [variable_kw] + forecast_internal(variable_kw, steps - 1, half_life_minutes, step_minutes)
|
||||
result, value = [], response_kw
|
||||
for power in held:
|
||||
value += alpha * (power - value)
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
|
||||
class HeatSourceTracker:
|
||||
"""Per-poll processing of all heat sources: baselines, responses, zone inputs."""
|
||||
|
||||
def __init__(self, sources: list[HeatSource], saved: Any = None,
|
||||
fingerprints: Mapping[str, str] | None = None) -> None:
|
||||
self.sources = sources
|
||||
self.fingerprints = dict(fingerprints or {})
|
||||
saved = saved if isinstance(saved, Mapping) else {}
|
||||
stored = saved.get("baselines") if isinstance(saved.get("baselines"), Mapping) else {}
|
||||
old_prints = saved.get("fingerprints") if isinstance(saved.get("fingerprints"), Mapping) else None
|
||||
# Samples of a replaced sensor (or a changed inclusion) describe a different load.
|
||||
self.reset_baselines = [s.key for s in sources if old_prints is not None and s.key in old_prints
|
||||
and old_prints[s.key] != self.fingerprints.get(s.key)]
|
||||
self._baselines = {s.key: BaselineEstimator(
|
||||
stored.get(s.key) if isinstance(stored.get(s.key), list) and s.key not in self.reset_baselines else ())
|
||||
for s in sources}
|
||||
self._filters = {s.key: ResponseFilter(s.response_minutes) for s in sources}
|
||||
self._held: dict[str, tuple[float, float | None]] = {} # key -> (timestamp, variable kW)
|
||||
self._settled_since: dict[str, float] = {}
|
||||
self.state: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def update(self, now: float, readings: Mapping[str, float | None]) -> None:
|
||||
"""Process one poll. `now` is a timestamp; `readings` maps key -> measured W or None."""
|
||||
effective = effective_power(self.sources, readings)
|
||||
self.state = {}
|
||||
for source in self.sources:
|
||||
info: dict[str, Any] = {"power_w": readings.get(source.key), "enabled": source.enabled}
|
||||
self.state[source.key] = info
|
||||
if not source.enabled:
|
||||
continue
|
||||
watts = effective.get(source.key)
|
||||
estimator = self._baselines[source.key]
|
||||
if watts is not None:
|
||||
estimator.add(watts)
|
||||
baseline = source.baseline_w if source.baseline_mode == BASELINE_MANUAL else estimator.value()
|
||||
variable = None if watts is None or baseline is None else source.fraction * (watts - baseline) / 1000
|
||||
# The heat during the interval just ended is what was drawn at its start.
|
||||
held = self._held.get(source.key)
|
||||
filt = self._filters[source.key]
|
||||
if held and held[1] is not None and 0 < now - held[0] <= 900:
|
||||
response = filt.advance(held[1], now - held[0])
|
||||
else:
|
||||
filt.reset()
|
||||
self._settled_since.pop(source.key, None)
|
||||
response = None if variable is None else filt.advance(variable, 0)
|
||||
if response is not None:
|
||||
self._settled_since.setdefault(source.key, now)
|
||||
self._held[source.key] = (now, variable)
|
||||
info.update(effective_w=watts, baseline_w=baseline, variable_kw=variable, response_kw=response,
|
||||
settled=response is not None
|
||||
and now - self._settled_since[source.key] >= 2 * filt.tau)
|
||||
|
||||
def zone_input(self, zone: str) -> float | None:
|
||||
"""Summed thermal response of a zone's sources (kW); None if unknown or unsettled."""
|
||||
total = 0.0
|
||||
for source in self.sources:
|
||||
if source.zone != zone or not source.enabled:
|
||||
continue
|
||||
info = self.state.get(source.key, {})
|
||||
if not info.get("settled"):
|
||||
return None
|
||||
total += info["response_kw"]
|
||||
return total
|
||||
|
||||
def zone_forecast(self, zone: str, steps: int, half_life_minutes: float | None) -> list[float]:
|
||||
"""Assumed thermal response of a zone's sources over a forecast (kW per step)."""
|
||||
total = [0.0] * steps
|
||||
for source in self.sources:
|
||||
info = self.state.get(source.key, {})
|
||||
if source.zone != zone or not source.enabled or info.get("response_kw") is None:
|
||||
continue
|
||||
variable = info["variable_kw"] if info.get("variable_kw") is not None else 0.0
|
||||
for k, value in enumerate(forecast_response(info["response_kw"], variable, steps,
|
||||
source.response_minutes, half_life_minutes)):
|
||||
total[k] += value
|
||||
return total
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"baselines": {key: [round(v, 1) for v in est.samples] for key, est in self._baselines.items()},
|
||||
"fingerprints": self.fingerprints}
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Recursive least squares over named, physically bounded features.
|
||||
|
||||
Pure Python; no Home Assistant imports. Every coefficient is a change in room
|
||||
temperature per 5-minute step per unit of its feature, so all of them share the
|
||||
same target: the measured 5-minute temperature change (or, for the ventilation
|
||||
layer, the part of it the closed-window model cannot explain).
|
||||
|
||||
New effects start at a prior of 0 ("no effect until the data shows one"). In
|
||||
this unscaled RLS form a prior variance is worth 1 / (variance x typical value^2)
|
||||
samples of data, so each prior below is chosen as "worth N typical samples": a
|
||||
handful of samples cannot produce a confident coefficient. Confidence is the share
|
||||
of a coefficient's information that comes from data rather than the prior; the
|
||||
displayed standard deviation uses a running estimate of the measurement noise.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from math import isfinite, sqrt
|
||||
from typing import Any, Iterable, NamedTuple
|
||||
|
||||
FORGETTING = 0.999 # about 3.5 days of 5-minute samples
|
||||
MAX_STEP_CHANGE = 0.8 # °C per 5 minutes: larger is a glitch, not physics
|
||||
# A coefficient counts as identified after this many exercising samples and once
|
||||
# at least 80 % of its information comes from data.
|
||||
MIN_ACTIVE_SAMPLES = 30
|
||||
IDENTIFIED_CONFIDENCE = 0.8
|
||||
INITIAL_NOISE_STD = 0.02 # °C per 5 minutes, about one sensor resolution step
|
||||
|
||||
|
||||
class FeatureSpec(NamedTuple):
|
||||
prior: float
|
||||
# Unscaled RLS prior variance: worth 1 / (variance x typical^2) typical samples.
|
||||
variance: float
|
||||
lo: float
|
||||
hi: float
|
||||
# |feature value| at or above which a sample exercises this coefficient.
|
||||
active: float
|
||||
unit: str
|
||||
|
||||
|
||||
def _worth(samples: float, typical: float) -> float:
|
||||
return 1.0 / (samples * typical * typical)
|
||||
|
||||
|
||||
# x = feature value for one zone at the start of a 5-minute step. The first three
|
||||
# match the base model's priors.
|
||||
FEATURES: dict[str, FeatureSpec] = {
|
||||
# Envelope loss: x = T_out - T (°C).
|
||||
"loss": FeatureSpec(0.002, 0.0005, 0.0, 0.04, 1.0, "°C/5 min per °C outdoor difference"),
|
||||
# Boiler: x = radiator-lag proxy (0..1).
|
||||
"heat": FeatureSpec(0.045, 0.8, 0.0, 0.25, 0.1, "°C/5 min at full radiator heat"),
|
||||
# Constant gains/losses: x = 1.
|
||||
"bias": FeatureSpec(0.0, 0.08, -0.06, 0.06, 0.5, "°C/5 min"),
|
||||
# Electrical heat above baseline after its thermal response: x in kW (typ. 0.4).
|
||||
"internal": FeatureSpec(0.0, _worth(20, 0.4), 0.0, 0.5, 0.05, "°C/5 min per kW"),
|
||||
# Exchange with an adjacent zone j: x = T_j - T (°C). Shrunk hard towards 0:
|
||||
# zones heated by one boiler move together, so this is weakly identifiable.
|
||||
"couple": FeatureSpec(0.0, _worth(200, 1.0), 0.0, 0.05, 0.3, "°C/5 min per °C difference"),
|
||||
# Ventilation, fitted to what the closed-window model cannot explain. x scales
|
||||
# with the open-window factor O and T_out - T (typ. 15 °C):
|
||||
"vent_natural": FeatureSpec(0.0, _worth(20, 15), 0.0, 0.2, 0.5, "°C/5 min per °C x opening"),
|
||||
# Wind speed v in m/s (typ. 4 m/s).
|
||||
"vent_wind": FeatureSpec(0.0, _worth(20, 60), 0.0, 0.03, 0.5, "°C/5 min per °C x opening x m/s"),
|
||||
# Directional part, bounded by vent_wind so a leeward window still ventilates.
|
||||
"vent_wind_dir": FeatureSpec(0.0, _worth(20, 60), -0.03, 0.03, 0.5,
|
||||
"°C/5 min per °C x opening x m/s x cos(angle)"),
|
||||
"vent_gust": FeatureSpec(0.0, _worth(20, 45), 0.0, 0.03, 0.5,
|
||||
"°C/5 min per °C x opening x m/s of gust above mean wind"),
|
||||
"vent_cross": FeatureSpec(0.0, _worth(100, 15), 0.0, 0.1, 0.5,
|
||||
"°C/5 min per °C x opening x openings elsewhere"),
|
||||
# After closing: x = recovery weight (1 -> 0) x (T before opening - T), typ. 0.5.
|
||||
"recovery": FeatureSpec(0.0, _worth(20, 0.5), 0.0, 0.5, 0.05,
|
||||
"°C/5 min per °C below the pre-ventilation temperature"),
|
||||
}
|
||||
|
||||
|
||||
def spec_for(name: str) -> FeatureSpec:
|
||||
"""Specification of a feature; "couple:<zone>" shares the "couple" spec."""
|
||||
return FEATURES[name.split(":", 1)[0]]
|
||||
|
||||
|
||||
def bound_covariance(p: list[list[float]], prior_variance: list[float]) -> None:
|
||||
"""Keep P symmetric and no less certain than the prior, in place.
|
||||
|
||||
With forgetting, unexcited directions grow by 1/forgetting each step without
|
||||
limit. Scaling row and column i together is a congruence transform, so positive
|
||||
definiteness is preserved.
|
||||
"""
|
||||
n = len(p)
|
||||
for i in range(n):
|
||||
for j in range(i):
|
||||
p[i][j] = p[j][i] = (p[i][j] + p[j][i]) / 2
|
||||
for i in range(n):
|
||||
if p[i][i] > prior_variance[i]:
|
||||
scale = (prior_variance[i] / p[i][i]) ** .5
|
||||
for j in range(n):
|
||||
p[i][j] *= scale
|
||||
for j in range(n):
|
||||
p[j][i] *= scale
|
||||
p[i][i] = prior_variance[i]
|
||||
|
||||
|
||||
def positive_definite(p: list[list[float]]) -> bool:
|
||||
"""Cholesky check for a symmetric matrix."""
|
||||
n = len(p)
|
||||
lower = [[0.0] * n for _ in range(n)]
|
||||
for i in range(n):
|
||||
for j in range(i + 1):
|
||||
value = p[i][j] - sum(lower[i][k] * lower[j][k] for k in range(j))
|
||||
if i == j:
|
||||
if value <= 0 or not isfinite(value):
|
||||
return False
|
||||
lower[i][j] = value ** .5
|
||||
else:
|
||||
lower[i][j] = value / lower[j][j]
|
||||
return True
|
||||
|
||||
|
||||
def rls_update(theta: list[float], p: list[list[float]], x: list[float], y: float,
|
||||
forgetting: float = FORGETTING) -> tuple[list[float], float] | None:
|
||||
"""One exponentially weighted RLS step, updating P in place.
|
||||
|
||||
Returns the new parameters and the a priori residual, or None when the update
|
||||
would be numerically invalid (P is left untouched then).
|
||||
"""
|
||||
n = len(theta)
|
||||
residual = y - sum(t * v for t, v in zip(theta, x))
|
||||
px = [sum(p[i][j] * x[j] for j in range(n)) for i in range(n)]
|
||||
denominator = forgetting + sum(x[i] * px[i] for i in range(n))
|
||||
if not isfinite(denominator) or denominator <= 0:
|
||||
return None
|
||||
gain = [value / denominator for value in px]
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
p[i][j] = (p[i][j] - gain[i] * px[j]) / forgetting
|
||||
return [t + gain[i] * residual for i, t in enumerate(theta)], residual
|
||||
|
||||
|
||||
class FeatureLearner:
|
||||
"""RLS over a configurable list of named features (see FEATURES)."""
|
||||
|
||||
def __init__(self, features: list[str]) -> None:
|
||||
if not features or len(set(features)) != len(features):
|
||||
raise ValueError("Features must be unique and non-empty")
|
||||
self.features = list(features)
|
||||
self._specs = [spec_for(name) for name in self.features]
|
||||
self.theta = [spec.prior for spec in self._specs]
|
||||
self.p = [[spec.variance if i == j else 0.0 for j in range(len(self._specs))]
|
||||
for i, spec in enumerate(self._specs)]
|
||||
self.count = 0
|
||||
self.error_sum = 0.0
|
||||
self.rejected_count = 0
|
||||
self.on_count = 0
|
||||
self.off_count = 0
|
||||
self.active = [0] * len(self.features)
|
||||
# Bias-corrected running mean of squared a priori residuals.
|
||||
self._residual_sq = 0.0
|
||||
self._residual_weight = 0.0
|
||||
|
||||
def predict(self, x: list[float]) -> float:
|
||||
return sum(t * v for t, v in zip(self.theta, x))
|
||||
|
||||
def learn(self, x: list[float], y: float, command: bool | None = None) -> bool:
|
||||
"""Fit one sample of features `x` to the 5-minute change `y`."""
|
||||
if (len(x) != len(self.features) or not all(isfinite(v) for v in x)
|
||||
or not isfinite(y) or abs(y) > MAX_STEP_CHANGE):
|
||||
self.rejected_count += 1
|
||||
return False
|
||||
result = rls_update(self.theta, self.p, x, y)
|
||||
if result is None:
|
||||
self.rejected_count += 1
|
||||
return False
|
||||
theta, residual = result
|
||||
bound_covariance(self.p, [spec.variance for spec in self._specs])
|
||||
self.theta = self._bounded(theta)
|
||||
self.count += 1
|
||||
self.error_sum += abs(residual)
|
||||
self._residual_sq = FORGETTING * self._residual_sq + (1 - FORGETTING) * residual * residual
|
||||
self._residual_weight = FORGETTING * self._residual_weight + (1 - FORGETTING)
|
||||
if command is not None:
|
||||
if command:
|
||||
self.on_count += 1
|
||||
else:
|
||||
self.off_count += 1
|
||||
for i, (value, spec) in enumerate(zip(x, self._specs)):
|
||||
if abs(value) >= spec.active:
|
||||
self.active[i] += 1
|
||||
return True
|
||||
|
||||
def _bounded(self, theta: list[float]) -> list[float]:
|
||||
bounded = [min(spec.hi, max(spec.lo, value)) for value, spec in zip(theta, self._specs)]
|
||||
if "vent_wind_dir" in self.features:
|
||||
# The directional term may at most cancel the isotropic wind term.
|
||||
limit = bounded[self.features.index("vent_wind")] if "vent_wind" in self.features else 0.0
|
||||
i = self.features.index("vent_wind_dir")
|
||||
bounded[i] = min(limit, max(-limit, bounded[i]))
|
||||
return bounded
|
||||
|
||||
def reset(self, features: Iterable[str], keep_values: Iterable[str] = ()) -> None:
|
||||
"""Forget what was learned for `features` (after a configuration change).
|
||||
|
||||
Their variance returns to the prior and their correlations with the other
|
||||
coefficients are cleared; values go back to the prior unless listed in
|
||||
`keep_values` (used as a warm start). Other coefficients are untouched.
|
||||
"""
|
||||
keep = set(keep_values)
|
||||
for name in features:
|
||||
if name not in self.features:
|
||||
continue
|
||||
i = self.features.index(name)
|
||||
spec = self._specs[i]
|
||||
for j in range(len(self.features)):
|
||||
self.p[i][j] = self.p[j][i] = 0.0
|
||||
self.p[i][i] = spec.variance
|
||||
if name not in keep:
|
||||
self.theta[i] = spec.prior
|
||||
self.active[i] = 0
|
||||
self.theta = self._bounded(self.theta)
|
||||
|
||||
def reset_counts(self) -> None:
|
||||
"""Readiness and error statistics start over (the measured quantity changed)."""
|
||||
self.count = self.rejected_count = self.on_count = self.off_count = 0
|
||||
self.error_sum = self._residual_sq = self._residual_weight = 0.0
|
||||
|
||||
@property
|
||||
def noise_var(self) -> float:
|
||||
"""Estimated variance of the unexplained 5-minute change (°C²)."""
|
||||
if self._residual_weight <= 0:
|
||||
return INITIAL_NOISE_STD ** 2
|
||||
return self._residual_sq / self._residual_weight
|
||||
|
||||
@property
|
||||
def mae(self) -> float | None:
|
||||
return self.error_sum / self.count if self.count else None
|
||||
|
||||
def coefficient(self, name: str) -> float:
|
||||
return self.theta[self.features.index(name)] if name in self.features else 0.0
|
||||
|
||||
def coefficients(self) -> dict[str, dict[str, Any]]:
|
||||
"""Value, uncertainty and support of each coefficient, for diagnostics.
|
||||
|
||||
`confidence` is the share of the coefficient's information that comes from
|
||||
data rather than the prior (0 = prior only); `std` is its standard error.
|
||||
"""
|
||||
result = {}
|
||||
for i, (name, spec) in enumerate(zip(self.features, self._specs)):
|
||||
variance = min(spec.variance, max(0.0, self.p[i][i]))
|
||||
confidence = 1 - variance / spec.variance
|
||||
result[name] = {
|
||||
"value": round(self.theta[i], 6),
|
||||
"std": round(sqrt(self.noise_var * variance), 6),
|
||||
"confidence": round(confidence, 3),
|
||||
"samples": self.active[i],
|
||||
"identified": (self.active[i] >= MIN_ACTIVE_SAMPLES
|
||||
and confidence >= IDENTIFIED_CONFIDENCE),
|
||||
}
|
||||
return result
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"features": self.features, "theta": self.theta, "p": self.p,
|
||||
"count": self.count, "error_sum": self.error_sum,
|
||||
"rejected_count": self.rejected_count, "on_count": self.on_count,
|
||||
"off_count": self.off_count, "active": self.active,
|
||||
"residual_sq": self._residual_sq, "residual_weight": self._residual_weight}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, source: Any, features: list[str]) -> "FeatureLearner":
|
||||
"""Restore what is valid for `features`; other features start from the prior.
|
||||
|
||||
Coefficients are matched by name, so adding or removing a heat source,
|
||||
window or neighbour retrains only the affected coefficients. Anything
|
||||
malformed yields a fresh learner.
|
||||
"""
|
||||
fresh = cls(features)
|
||||
try:
|
||||
if not isinstance(source, dict):
|
||||
return fresh
|
||||
names = source["features"]
|
||||
theta = [float(v) for v in source["theta"]]
|
||||
p = [[float(v) for v in row] for row in source["p"]]
|
||||
n = len(names)
|
||||
if (not all(isinstance(name, str) for name in names) or len(set(names)) != n
|
||||
or len(theta) != n or len(p) != n or any(len(row) != n for row in p)):
|
||||
return fresh
|
||||
if not all(isfinite(v) for v in theta + [v for row in p for v in row]):
|
||||
return fresh
|
||||
for i in range(n):
|
||||
for j in range(i):
|
||||
if abs(p[i][j] - p[j][i]) > 1e-6 * max(1.0, abs(p[i][j]), abs(p[j][i])):
|
||||
return fresh
|
||||
if not positive_definite(p):
|
||||
return fresh
|
||||
counts = [source.get(key, 0) for key in
|
||||
("count", "rejected_count", "on_count", "off_count")]
|
||||
active = source.get("active", [0] * n)
|
||||
if (any(type(v) is not int or v < 0 for v in counts + list(active))
|
||||
or len(active) != n or counts[2] + counts[3] > counts[0]):
|
||||
return fresh
|
||||
error_sum = float(source.get("error_sum", 0))
|
||||
residual_sq = float(source.get("residual_sq", 0.0))
|
||||
residual_weight = float(source.get("residual_weight", 0.0))
|
||||
if (not all(isfinite(v) and v >= 0 for v in (error_sum, residual_sq, residual_weight))
|
||||
or residual_weight > 1):
|
||||
return fresh
|
||||
index = {name: i for i, name in enumerate(names)}
|
||||
kept = [(j, index[name]) for j, name in enumerate(features) if name in index]
|
||||
obj = cls(features)
|
||||
for j, i in kept:
|
||||
obj.theta[j] = theta[i]
|
||||
obj.active[j] = active[i]
|
||||
for k, m in kept:
|
||||
obj.p[j][k] = p[i][m]
|
||||
obj.theta = obj._bounded(obj.theta)
|
||||
bound_covariance(obj.p, [spec.variance for spec in obj._specs])
|
||||
if not positive_definite(obj.p):
|
||||
return fresh
|
||||
obj.count, obj.rejected_count, obj.on_count, obj.off_count = counts
|
||||
obj.error_sum = error_sum
|
||||
obj._residual_sq, obj._residual_weight = residual_sq, residual_weight
|
||||
return obj
|
||||
except (KeyError, TypeError, ValueError, OverflowError):
|
||||
return fresh
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "0.2.1"
|
||||
"version": "0.4.2"
|
||||
}
|
||||
@@ -8,26 +8,81 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import product
|
||||
from math import exp, isfinite
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
# (zone temperatures, radiator lag at the end of the step, step index) -> new temperatures
|
||||
StepModel = Callable[[dict[str, float], float, int], dict[str, float]]
|
||||
|
||||
from .learning import bound_covariance, positive_definite, rls_update
|
||||
|
||||
STEP_MINUTES = 5
|
||||
HEAT_LAG = 1.0 - exp(-STEP_MINUTES / 20.0)
|
||||
# First-order radiator lag between the relay command and the heat reaching rooms.
|
||||
LAG_TAU_SECONDS = 1200.0
|
||||
HEAT_LAG = 1.0 - exp(-STEP_MINUTES * 60 / LAG_TAU_SECONDS)
|
||||
# Learn only when the lag proxy is known within this half-width.
|
||||
LAG_LEARN_MAX_UNCERTAINTY = 0.1
|
||||
# Prior covariance; also the upper bound that stops RLS windup (see _bound_covariance).
|
||||
PRIOR_P = ((0.0005, 0.0, 0.0), (0.0, 0.8, 0.0), (0.0, 0.0, 0.08))
|
||||
PRIOR_VARIANCE = [PRIOR_P[i][i] for i in range(3)]
|
||||
|
||||
|
||||
def bound(value: float, lo: float, hi: float) -> float:
|
||||
return max(lo, min(hi, value))
|
||||
|
||||
|
||||
def advance_lag(lag: float, command: bool, seconds: float) -> float:
|
||||
"""Move the lag proxy towards the relay command over `seconds`."""
|
||||
return lag + (1.0 - exp(-seconds / LAG_TAU_SECONDS)) * (float(command) - lag)
|
||||
|
||||
|
||||
def relay_segments(
|
||||
start_on: bool, start: float, changes: list[tuple[float, str]], end: float,
|
||||
) -> list[tuple[bool, float]] | None:
|
||||
"""Split [start, end] into (command, seconds) segments from ordered relay changes.
|
||||
|
||||
`changes` holds (timestamp, new_state) pairs. Return None when the command is
|
||||
unknown for part of the interval: a state other than on/off, a change to the
|
||||
state already in force (so transitions were missed), or out-of-order times.
|
||||
"""
|
||||
segments: list[tuple[bool, float]] = []
|
||||
command, since = start_on, start
|
||||
for when, state in changes:
|
||||
if state not in ("on", "off") or not since <= when <= end or (state == "on") == command:
|
||||
return None
|
||||
segments.append((command, when - since))
|
||||
command, since = state == "on", when
|
||||
segments.append((command, end - since))
|
||||
return segments
|
||||
|
||||
|
||||
def lag_from_time_in_state(command: bool, seconds_in_state: float) -> tuple[float, float]:
|
||||
"""Estimate the lag when only the time since the last relay change is known.
|
||||
|
||||
Starting from any unknown lag in [0, 1], `seconds_in_state` of a constant command
|
||||
leaves the lag inside an interval of width exp(-t/tau). Return its midpoint and
|
||||
half-width, so the error bound is explicit rather than assuming a steady state.
|
||||
"""
|
||||
width = exp(-max(0.0, seconds_in_state) / LAG_TAU_SECONDS)
|
||||
return (1.0 - width / 2) if command else width / 2, width / 2
|
||||
|
||||
|
||||
def _bound_covariance(p: list[list[float]]) -> None:
|
||||
"""Covariance bound for the 3-parameter base model (see learning.bound_covariance)."""
|
||||
bound_covariance(p, PRIOR_VARIANCE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Learner:
|
||||
"""Recursive least squares for dT=a*(Tout-T)+b*effective_heat+c."""
|
||||
|
||||
theta: list[float] = field(default_factory=lambda: [0.002, 0.045, 0.0])
|
||||
p: list[list[float]] = field(default_factory=lambda: [[0.0005, 0., 0.], [0., 0.8, 0.], [0., 0., 0.08]])
|
||||
p: list[list[float]] = field(default_factory=lambda: [list(row) for row in PRIOR_P])
|
||||
count: int = 0
|
||||
error_sum: float = 0.0
|
||||
on_count: int = 0
|
||||
off_count: int = 0
|
||||
# Samples offered but not fitted (outliers, implausible inputs).
|
||||
rejected_count: int = 0
|
||||
|
||||
def predict_delta(self, temp: float, outside: float, heat: float) -> float:
|
||||
a, b, c = self.theta
|
||||
@@ -35,28 +90,19 @@ class Learner:
|
||||
|
||||
def learn(self, temp_before: float, temp_after: float, outside: float, effective_heat: float,
|
||||
duration_seconds: float = 300, command: bool | None = None) -> bool:
|
||||
if not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat)):
|
||||
return False
|
||||
if not 0 <= effective_heat <= 1:
|
||||
return False
|
||||
if not isfinite(duration_seconds) or not 270 <= duration_seconds <= 330:
|
||||
return False
|
||||
if (not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat))
|
||||
or not 0 <= effective_heat <= 1
|
||||
or not isfinite(duration_seconds) or not 270 <= duration_seconds <= 330):
|
||||
return self._reject()
|
||||
y = (temp_after - temp_before) * 300 / duration_seconds
|
||||
if abs(y) > 0.8: # sensor glitch/door opened/restarted device
|
||||
return False
|
||||
return self._reject()
|
||||
x = [outside - temp_before, effective_heat, 1.0]
|
||||
predicted = sum(t * v for t, v in zip(self.theta, x))
|
||||
residual = y - predicted
|
||||
px = [sum(self.p[i][j] * x[j] for j in range(3)) for i in range(3)]
|
||||
forgetting = 0.999
|
||||
denominator = forgetting + sum(x[i] * px[i] for i in range(3))
|
||||
if not isfinite(denominator) or denominator <= 0:
|
||||
return False
|
||||
k = [value / denominator for value in px]
|
||||
self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)]
|
||||
for i in range(3):
|
||||
for j in range(3):
|
||||
self.p[i][j] = (self.p[i][j] - k[i] * px[j]) / forgetting
|
||||
result = rls_update(self.theta, self.p, x, y)
|
||||
if result is None:
|
||||
return self._reject()
|
||||
self.theta, residual = result
|
||||
_bound_covariance(self.p)
|
||||
# Physical constraints; avoids unstable temperature predictions.
|
||||
self.theta = [
|
||||
bound(self.theta[0], 0.0, 0.04),
|
||||
@@ -72,13 +118,37 @@ class Learner:
|
||||
self.off_count += 1
|
||||
return True
|
||||
|
||||
def _reject(self) -> bool:
|
||||
self.rejected_count += 1
|
||||
return False
|
||||
|
||||
def recalibrate(self) -> None:
|
||||
"""Re-learn after the zone's measurement changed: the coefficients stay as a
|
||||
starting point, but their uncertainty and every count go back to the start,
|
||||
so readiness has to be earned again."""
|
||||
self.p = [list(row) for row in PRIOR_P]
|
||||
self.count = self.on_count = self.off_count = self.rejected_count = 0
|
||||
self.error_sum = 0.0
|
||||
|
||||
@property
|
||||
def mae(self) -> float | None:
|
||||
return self.error_sum / self.count if self.count else None
|
||||
|
||||
def confidence(self) -> dict[str, float]:
|
||||
"""Per coefficient, the share of its information that comes from data (0 = prior only).
|
||||
|
||||
Taken from the coefficient's own uncertainty, not from the sample count: it
|
||||
only grows while the data varies that coefficient's input independently of
|
||||
the others. Weeks with the relay always off leave `heat` at 0; a steady
|
||||
indoor-outdoor difference cannot separate `loss` from `bias`.
|
||||
"""
|
||||
return {name: round(1 - min(prior, max(0.0, self.p[i][i])) / prior, 3)
|
||||
for i, (name, prior) in enumerate(zip(("loss", "heat", "bias"), PRIOR_VARIANCE))}
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum,
|
||||
"on_count": self.on_count, "off_count": self.off_count}
|
||||
"on_count": self.on_count, "off_count": self.off_count,
|
||||
"rejected_count": self.rejected_count}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, source: dict[str, Any]) -> "Learner":
|
||||
@@ -94,27 +164,25 @@ class Learner:
|
||||
return obj
|
||||
if not (0 <= theta[0] <= .04 and 0 <= theta[1] <= .25 and -.06 <= theta[2] <= .06):
|
||||
return obj
|
||||
# A covariance must be symmetric positive definite (Cholesky check).
|
||||
lower = [[0.0] * 3 for _ in range(3)]
|
||||
# Tolerate float round-off relative to magnitude (older versions could
|
||||
# accumulate it during windup), then apply the current covariance bound.
|
||||
for i in range(3):
|
||||
for j in range(i + 1):
|
||||
if abs(p[i][j] - p[j][i]) > 1e-8:
|
||||
for j in range(i):
|
||||
if abs(p[i][j] - p[j][i]) > 1e-6 * max(1.0, abs(p[i][j]), abs(p[j][i])):
|
||||
return obj
|
||||
value = p[i][j] - sum(lower[i][k] * lower[j][k] for k in range(j))
|
||||
if i == j:
|
||||
if value <= 0 or not isfinite(value):
|
||||
return obj
|
||||
lower[i][j] = value ** .5
|
||||
else:
|
||||
lower[i][j] = value / lower[j][j]
|
||||
counts = [source.get(key, 0) for key in ("count", "on_count", "off_count")]
|
||||
_bound_covariance(p)
|
||||
# A covariance must be symmetric positive definite.
|
||||
if not positive_definite(p):
|
||||
return obj
|
||||
counts = [source.get(key, 0)
|
||||
for key in ("count", "on_count", "off_count", "rejected_count")]
|
||||
if any(type(v) is not int or v < 0 for v in counts):
|
||||
return obj
|
||||
count, on_count, off_count = counts
|
||||
count, on_count, off_count, rejected_count = counts
|
||||
error_sum = float(source.get("error_sum", 0))
|
||||
if not isfinite(error_sum) or error_sum < 0 or on_count + off_count > count:
|
||||
return obj
|
||||
return cls(theta, p, count, error_sum, on_count, off_count)
|
||||
return cls(theta, p, count, error_sum, on_count, off_count, rejected_count)
|
||||
except (KeyError, TypeError, ValueError, OverflowError):
|
||||
return obj
|
||||
|
||||
@@ -133,11 +201,15 @@ def optimize(
|
||||
is_on: bool, seconds_since_change: float, horizon_minutes: int = 120,
|
||||
minimum_on_minutes: int = 15, minimum_off_minutes: int = 10,
|
||||
switching_cost: float = .15, heating_cost: float = .025,
|
||||
comfort_band: float = .15,
|
||||
comfort_band: float = .15, target_offsets: dict[str, float] | None = None,
|
||||
step_model: StepModel | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Enumerate 15min ON/OFF plans; honor minimum ON/OFF durations.
|
||||
|
||||
120min = 256 plans (fast on Raspberry Pi 5). All rooms share one burner demand.
|
||||
`target_offsets` shifts individual zones' targets (°C) relative to `target`.
|
||||
`step_model` advances all zones together (electrical heat, ventilation,
|
||||
neighbours); by default each zone uses its learner's base model.
|
||||
"""
|
||||
if not temps or set(temps) != set(learners):
|
||||
raise ValueError("Missing room temperatures or learner")
|
||||
@@ -160,8 +232,19 @@ def optimize(
|
||||
if not isfinite(total_weight):
|
||||
raise ValueError("Invalid total weight")
|
||||
norm = {room: float(weights[room]) / total_weight for room in temps}
|
||||
offsets = target_offsets or {}
|
||||
if any(not isfinite(float(offsets.get(room, 0.0))) for room in temps):
|
||||
raise ValueError("Invalid target offset")
|
||||
targets = {room: target + float(offsets.get(room, 0.0)) for room in temps}
|
||||
mean_target = sum(norm[room] * targets[room] for room in temps)
|
||||
blocks = horizon_minutes // 15
|
||||
nsteps_per_block = 3
|
||||
|
||||
def advance(state: dict[str, float], lag: float, bit: bool, k: int) -> tuple[dict[str, float], float]:
|
||||
if step_model is None:
|
||||
return simulate_step(state, outside, lag, bit, learners)
|
||||
next_lag = lag + HEAT_LAG * (float(bit) - lag)
|
||||
return step_model(state, next_lag, k), next_lag
|
||||
best = float("inf")
|
||||
result = None
|
||||
min_on_s = minimum_on_minutes * 60
|
||||
@@ -186,14 +269,16 @@ def optimize(
|
||||
elapsed_s = 0.0
|
||||
current = bit
|
||||
for tick in range(nsteps_per_block):
|
||||
state, lag = simulate_step(state, outside, lag, bit, learners)
|
||||
state, lag = advance(state, lag, bit, idx * nsteps_per_block + tick)
|
||||
if not all(isfinite(v) for v in state.values()):
|
||||
raise ValueError("Non-finite simulated temperature")
|
||||
mismatch = sum(
|
||||
norm[room] * max(0.0, abs(value - target) - comfort_band) ** 2
|
||||
norm[room] * max(0.0, abs(value - targets[room]) - comfort_band) ** 2
|
||||
for room, value in state.items()
|
||||
)
|
||||
# A lower bound on weighted average comfort also matters.
|
||||
avg = sum(norm[r] * value for r, value in state.items())
|
||||
mismatch += max(0.0, abs(avg - target) - comfort_band) ** 2
|
||||
mismatch += max(0.0, abs(avg - mean_target) - comfort_band) ** 2
|
||||
cost += 8.0 * mismatch + (heating_cost if bit else 0.0)
|
||||
if idx * 15 + (tick + 1) * STEP_MINUTES == 60:
|
||||
at_60 = dict(state)
|
||||
@@ -204,10 +289,19 @@ def optimize(
|
||||
if result is None:
|
||||
raise RuntimeError("No feasible schedule")
|
||||
plan, temps_60m, temps_end = result
|
||||
# Re-simulate the chosen plan for its trajectory, keeping the search loop lean.
|
||||
trajectory: dict[str, list[float]] = {room: [] for room in temps}
|
||||
state, lag = dict(temps), effective_heat
|
||||
for idx, bit in enumerate(plan):
|
||||
for tick in range(nsteps_per_block):
|
||||
state, lag = advance(state, lag, bit, idx * nsteps_per_block + tick)
|
||||
for room, value in state.items():
|
||||
trajectory[room].append(round(value, 3))
|
||||
return {
|
||||
"proposal": bool(plan[0]),
|
||||
"plan_15min": [int(v) for v in plan],
|
||||
"predicted_60m": {r: round(t, 3) for r, t in temps_60m.items()},
|
||||
"predicted_end": {r: round(t, 3) for r, t in temps_end.items()},
|
||||
"trajectory_15min": trajectory,
|
||||
"cost": round(best, 4),
|
||||
}
|
||||
@@ -1,24 +1,43 @@
|
||||
"""Diagnostics and predicted temperatures exposed as HA sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
||||
from homeassistant.const import PERCENTAGE, UnitOfTemperature
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import (
|
||||
DOMAIN, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES, SAMPLE_RESULTS,
|
||||
)
|
||||
from .evaluation import HORIZONS, WINDOW_DAYS
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities) -> None:
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id)]
|
||||
entities += [MPCRoomPrediction(coordinator, entry.entry_id, room) for room in coordinator.rooms]
|
||||
entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id),
|
||||
MPCLearningProgress(coordinator, entry.entry_id), MPCLastSample(coordinator, entry.entry_id)]
|
||||
entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS]
|
||||
entities.append(MPCOperationalForecastError(coordinator, entry.entry_id))
|
||||
# Zone forecasts belong to their zone subentry, so deleting a zone removes them.
|
||||
linked = {}
|
||||
for zone in coordinator.zones.values():
|
||||
entity = MPCRoomPrediction(coordinator, entry.entry_id, zone.key)
|
||||
if zone.subentry_id:
|
||||
linked.setdefault(zone.subentry_id, []).append(entity)
|
||||
else:
|
||||
entities.append(entity)
|
||||
async_add_entities(entities)
|
||||
for subentry_id, zone_entities in linked.items():
|
||||
async_add_entities(zone_entities, config_subentry_id=subentry_id)
|
||||
|
||||
|
||||
class MPCStatus(CoordinatorEntity, SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Heating MPC Status"
|
||||
_attr_icon = "mdi:brain"
|
||||
# Per-source report ages change every poll; recording them adds a new
|
||||
# attribute row to the database every five minutes. Current values stay visible.
|
||||
_unrecorded_attributes = frozenset({"input_quality", "weather_comparison", "heat_sources", "windows",
|
||||
"ventilation", "ventilation_events", "model_changes"})
|
||||
|
||||
def __init__(self, coordinator, entry_id):
|
||||
super().__init__(coordinator)
|
||||
@@ -31,7 +50,10 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
data = self.coordinator.data
|
||||
return {key: value for key, value in data.items() if key not in ("status", "prediction_60m", "prediction_end")}
|
||||
# Forecast details live on their own entities.
|
||||
return {key: value for key, value in data.items() if key not in (
|
||||
"status", "prediction_60m", "prediction_end", "prediction_trajectory", "forecast_accuracy",
|
||||
"zone_details", "operational_forecast")}
|
||||
|
||||
|
||||
class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
|
||||
@@ -59,12 +81,35 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
|
||||
super().__init__(coordinator)
|
||||
self.room = room
|
||||
self._attr_unique_id = f"{entry_id}_{room}_predicted_60min"
|
||||
self._attr_name = f"MPC {room} 60min Prediction"
|
||||
self._attr_name = f"MPC {coordinator.zones[room].name} 60min Prediction"
|
||||
|
||||
# Change every poll; the 60-minute value itself is recorded.
|
||||
_unrecorded_attributes = frozenset({"forecast_15min", "plan_15min", "forecast", "contributions_c_per_h",
|
||||
"coefficients", "current_temperature", "model_in_use"})
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return self.coordinator.data.get("prediction_60m", {}).get(self.room)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
data = self.coordinator.data
|
||||
trajectory = data.get("prediction_trajectory", {}).get(self.room) or []
|
||||
details = data.get("zone_details", {}).get(self.room, {})
|
||||
return {
|
||||
"current_temperature": details.get("temperature"),
|
||||
"forecast": {f"{minutes}_min": trajectory[minutes // 15 - 1]
|
||||
for minutes in (15, 30, 60, 120) if len(trajectory) >= minutes // 15},
|
||||
"forecast_15min": trajectory or None,
|
||||
"plan_15min": data.get("plan_15min"),
|
||||
"basis": "proposed MPC plan; counterfactual while Generic Thermostat controls the relay",
|
||||
# Windows with an unknown state: the forecast assumes their state (see Status).
|
||||
"uncertain_windows": details.get("uncertain_windows") or [],
|
||||
"model_in_use": details.get("model_in_use"),
|
||||
"contributions_c_per_h": details.get("contributions_c_per_h"),
|
||||
"coefficients": details.get("coefficients"),
|
||||
}
|
||||
|
||||
class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
|
||||
"""Display chosen outdoor temperature and all currently valid alternatives."""
|
||||
_attr_has_entity_name = True
|
||||
@@ -88,3 +133,157 @@ class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
|
||||
"active_source": data.get("weather_active_sources", {}).get("temperature"),
|
||||
"alternatives": data.get("weather_comparison", {}).get("temperature", {}),
|
||||
}
|
||||
|
||||
|
||||
class MPCForecastError(CoordinatorEntity, SensorEntity):
|
||||
"""Model accuracy at one horizon: the model replayed with the inputs that actually happened.
|
||||
|
||||
Not a forecast accuracy (a real forecast does not know the future relay, power or
|
||||
windows); see MPCOperationalForecastError. The unique ID keeps its original name.
|
||||
"""
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE_DELTA
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_suggested_display_precision = 2
|
||||
_attr_icon = "mdi:target"
|
||||
_unrecorded_attributes = frozenset({"zones"})
|
||||
|
||||
def __init__(self, coordinator, entry_id, horizon):
|
||||
super().__init__(coordinator)
|
||||
self.horizon = horizon
|
||||
self._attr_unique_id = f"{entry_id}_forecast_error_{horizon}min"
|
||||
self._attr_name = f"Heating MPC Model Error {horizon} min"
|
||||
|
||||
@property
|
||||
def _accuracy(self):
|
||||
return self.coordinator.data.get("forecast_accuracy", {}).get(self.horizon, {})
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
accuracy = self._accuracy
|
||||
# No number from a handful of overlapping forecasts.
|
||||
return accuracy.get("mae") if accuracy.get("quality", "insufficient") != "insufficient" else None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
accuracy = self._accuracy
|
||||
return {
|
||||
"kind": "model accuracy: replayed with the actual relay, electrical heat and window history",
|
||||
"samples": accuracy.get("samples", 0),
|
||||
"effective_samples": accuracy.get("effective_samples", 0),
|
||||
"quality": accuracy.get("quality", "insufficient"),
|
||||
"mae": accuracy.get("mae"),
|
||||
"rmse": accuracy.get("rmse"),
|
||||
"bias": accuracy.get("bias"),
|
||||
"persistence_mae": accuracy.get("persistence_mae"),
|
||||
"skill": accuracy.get("skill"),
|
||||
"base_model_mae": accuracy.get("base_model_mae"),
|
||||
"improvement_vs_base": accuracy.get("improvement_vs_base"),
|
||||
# Spans left out of the figures above: an input the model needed was unknown
|
||||
# (the base model's error on them is still measured), or no replay was possible.
|
||||
"incomplete": accuracy.get("incomplete", 0),
|
||||
"incomplete_base_model_mae": accuracy.get("incomplete_base_model_mae"),
|
||||
"skipped": accuracy.get("skipped", 0),
|
||||
# Per zone, by name.
|
||||
"zones": {self.coordinator.zones[key].name if key in self.coordinator.zones else key: stats
|
||||
for key, stats in accuracy.get("rooms", {}).items()},
|
||||
"window_days": WINDOW_DAYS,
|
||||
"unit_of_attributes": "°C",
|
||||
"basis": ("all zones replayed together with the actual relay, electrical heat and "
|
||||
"window history; outdoor temperature held at the start value"),
|
||||
}
|
||||
|
||||
|
||||
class MPCOperationalForecastError(CoordinatorEntity, SensorEntity):
|
||||
"""Accuracy of forecasts as published, using only what was known when they were made.
|
||||
|
||||
A forecast assumes the MPC's plan; it is scored only when the relay actually
|
||||
followed that plan, otherwise counted as counterfactual. State: the 60-minute
|
||||
error; all horizons in the attributes.
|
||||
"""
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Heating MPC Forecast Error"
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE_DELTA
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_suggested_display_precision = 2
|
||||
_attr_icon = "mdi:crystal-ball"
|
||||
_unrecorded_attributes = frozenset({"horizons"})
|
||||
|
||||
def __init__(self, coordinator, entry_id):
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{entry_id}_operational_forecast_error"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
hour = self.coordinator.data.get("operational_forecast", {}).get(60, {})
|
||||
return hour.get("mae") if hour.get("quality", "insufficient") != "insufficient" else None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
summary = self.coordinator.data.get("operational_forecast", {})
|
||||
names = {key: zone.name for key, zone in self.coordinator.zones.items()}
|
||||
horizons = {}
|
||||
for horizon, stats in summary.items():
|
||||
horizons[f"{horizon}_min"] = {**{k: v for k, v in stats.items() if k != "zones"},
|
||||
"zones": {names.get(z, z): v for z, v in stats.get("zones", {}).items()}}
|
||||
return {
|
||||
"kind": ("forecast accuracy: published forecasts, scored only where the relay matched the plan "
|
||||
"(at most 2 minutes in another state); approximate matches (up to 5 minutes) apart"),
|
||||
"horizons": horizons,
|
||||
"window_days": WINDOW_DAYS,
|
||||
"unit_of_attributes": "°C",
|
||||
}
|
||||
|
||||
|
||||
class MPCLearningProgress(CoordinatorEntity, SensorEntity):
|
||||
"""How close the least trained room is to being used for forecasts."""
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Heating MPC Learning Progress"
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_suggested_display_precision = 0
|
||||
_attr_icon = "mdi:school-outline"
|
||||
|
||||
def __init__(self, coordinator, entry_id):
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{entry_id}_learning_progress"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return self.coordinator.data.get("learning_progress")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
data = self.coordinator.data
|
||||
return {
|
||||
"samples": data.get("samples"),
|
||||
"on_samples": data.get("on_samples"),
|
||||
"off_samples": data.get("off_samples"),
|
||||
"required": {"samples": READY_SAMPLES, "on_samples": READY_ON_SAMPLES,
|
||||
"off_samples": READY_OFF_SAMPLES},
|
||||
}
|
||||
|
||||
|
||||
class MPCLastSample(CoordinatorEntity, SensorEntity):
|
||||
"""What the last poll did for learning; its history shows when learning happens."""
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Heating MPC Last Sample"
|
||||
_attr_translation_key = "last_sample" # translated state names
|
||||
_attr_device_class = SensorDeviceClass.ENUM
|
||||
_attr_options = list(SAMPLE_RESULTS)
|
||||
_attr_icon = "mdi:clipboard-pulse-outline"
|
||||
_unrecorded_attributes = frozenset({"counts_since_start"})
|
||||
|
||||
def __init__(self, coordinator, entry_id):
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{entry_id}_last_sample"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return self.coordinator.data.get("sample_result")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
return {"counts_since_start": self.coordinator.data.get("sample_results_since_start", {})}
|
||||
@@ -10,7 +10,11 @@
|
||||
"heater_entity": "Physical heating switch (read-only)",
|
||||
"room_entities": "Individual room temperature sensors",
|
||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)",
|
||||
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting"
|
||||
},
|
||||
"data_description": {
|
||||
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable."
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
@@ -19,7 +23,9 @@
|
||||
"data": {
|
||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||
"outside_wind_sources": "Wind speed sources (optional)",
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)",
|
||||
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
|
||||
"outside_wind_gust_sources": "Wind gust sources (optional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
@@ -36,7 +42,7 @@
|
||||
},
|
||||
"weights": {
|
||||
"title": "Room priorities",
|
||||
"description": "Each room has a relative comfort weight between 0.1 and 10; sensor entity is shown by each field."
|
||||
"description": "Each selected sensor becomes its own zone with this comfort weight. Later, group the sensors of one room into one zone with Add zone or by editing a zone."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -50,12 +56,18 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Heating MPC",
|
||||
"menu_options": {
|
||||
"settings": "Settings",
|
||||
"reset_model": "Reset learned model"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Heating MPC options",
|
||||
"description": "Change room sensors and controller settings. This integration remains shadow-only.",
|
||||
"data": {
|
||||
"climate_entity": "Existing Generic Thermostat",
|
||||
"heater_entity": "Physical heating switch (read-only)",
|
||||
"room_entities": "Individual room temperature sensors",
|
||||
"horizon_minutes": "Prediction horizon (minutes)",
|
||||
"minimum_on_minutes": "Minimum ON time (minutes)",
|
||||
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
||||
@@ -63,7 +75,30 @@
|
||||
"heating_cost": "Heating runtime cost",
|
||||
"comfort_band": "Comfort tolerance (°C)",
|
||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)",
|
||||
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting",
|
||||
"internal_heat_half_life_minutes": "Extra electrical heat: forecast half-life (minutes)",
|
||||
"window_open_minutes": "Open window: assumed remaining open time (minutes)",
|
||||
"recovery_minutes": "Recovery after closing a window (minutes)",
|
||||
"ventilation_wind": "Ventilation: use wind speed and direction",
|
||||
"ventilation_gust": "Ventilation: use wind gusts",
|
||||
"ventilation_cross": "Ventilation: cross-ventilation between zones"
|
||||
},
|
||||
"data_description": {
|
||||
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable.",
|
||||
"internal_heat_half_life_minutes": "Forecasts assume heat above the baseline fades with this half-life, for example when a gaming session ends. 0 assumes it stops now.",
|
||||
"window_open_minutes": "Forecasts assume an open window closes after this many more minutes, then the room recovers.",
|
||||
"recovery_minutes": "Time constant of the air warming back towards the walls' temperature.",
|
||||
"ventilation_wind": "Needs wind sources; direction also needs wind direction sources.",
|
||||
"ventilation_gust": "Adds a term for gusts above the mean wind. Keep it only if Model Error improves.",
|
||||
"ventilation_cross": "Adds a term for windows open in several zones at once. Needs windows in two or more zones."
|
||||
}
|
||||
},
|
||||
"reset_model": {
|
||||
"title": "Reset learned model",
|
||||
"description": "Forgets everything the model has learned: every zone's thermal coefficients (also those kept for removed zones), their confidence and sample counts, and the Model Error and Forecast Error statistics.\n\nKept: zones, sensors and weights, heat sources, windows, weather sources and all settings, plus the heat sources' baselines and the window event log.\n\nThe integration restarts and learns from the beginning: the first sample after about 30 minutes, forecasts after a day or more. This cannot be undone. The boiler and the thermostat are not affected.",
|
||||
"data": {
|
||||
"confirm_reset": "Yes, forget the learned model"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
@@ -72,7 +107,9 @@
|
||||
"data": {
|
||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||
"outside_wind_sources": "Wind speed sources (optional)",
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)",
|
||||
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
|
||||
"outside_wind_gust_sources": "Wind gust sources (optional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
@@ -86,15 +123,222 @@
|
||||
"outside_rain_sources": "Rain sources (optional)",
|
||||
"outside_snow_sources": "Snow sources (optional)"
|
||||
}
|
||||
},
|
||||
"weights": {
|
||||
"title": "Room priorities",
|
||||
"description": "Weights determine relative comfort importance, not physics coefficients."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_rooms": "Select 1–12 distinct room sensors.",
|
||||
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
||||
},
|
||||
"abort": {
|
||||
"model_reset": "The learned model was reset. Learning starts again from the beginning.",
|
||||
"model_not_reset": "Nothing was changed: the reset was not confirmed."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_sample": {
|
||||
"state": {
|
||||
"waiting_for_inputs": "Waiting for inputs",
|
||||
"first_sample": "First sample",
|
||||
"sensors_changed": "Zone sensors changed",
|
||||
"learned": "Learned",
|
||||
"ventilation": "Window open or recovering",
|
||||
"partly_rejected": "Partly rejected (outlier)",
|
||||
"interval_out_of_range": "Irregular interval",
|
||||
"relay_changed": "Relay switched",
|
||||
"outside_source_changed": "Outdoor source changed",
|
||||
"lag_uncertain": "Waiting for radiators to settle"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"zone": {
|
||||
"entry_type": "Zone",
|
||||
"initiate_flow": {
|
||||
"user": "Add zone",
|
||||
"reconfigure": "Edit zone"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Thermal zone",
|
||||
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensors": "Temperature sensors",
|
||||
"comfort_weight": "Comfort weight",
|
||||
"target_offset": "Target offset (°C)",
|
||||
"neighbors": "Adjacent zones (heat exchange)"
|
||||
},
|
||||
"data_description": {
|
||||
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||
}
|
||||
},
|
||||
"sensor_weights": {
|
||||
"title": "Sensor weights",
|
||||
"description": "How much each sensor counts in the zone temperature (0.1–10). Equal weights give the plain average.\n\n{sensors}",
|
||||
"data": {
|
||||
"sensor_weight_1": "Sensor 1 weight",
|
||||
"sensor_weight_2": "Sensor 2 weight",
|
||||
"sensor_weight_3": "Sensor 3 weight",
|
||||
"sensor_weight_4": "Sensor 4 weight",
|
||||
"sensor_weight_5": "Sensor 5 weight",
|
||||
"sensor_weight_6": "Sensor 6 weight"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Thermal zone",
|
||||
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensors": "Temperature sensors",
|
||||
"comfort_weight": "Comfort weight",
|
||||
"target_offset": "Target offset (°C)",
|
||||
"neighbors": "Adjacent zones (heat exchange)"
|
||||
},
|
||||
"data_description": {
|
||||
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"zone_name_required": "Enter a name.",
|
||||
"zone_sensors": "Choose 1–6 different sensors.",
|
||||
"sensor_in_other_zone": "A chosen sensor already belongs to another zone.",
|
||||
"sensor_is_weather_source": "A chosen sensor is used as an outdoor temperature source.",
|
||||
"too_many_zones": "At most 12 zones are supported."
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "The zone was updated."
|
||||
}
|
||||
},
|
||||
"heat_source": {
|
||||
"entry_type": "Heat source",
|
||||
"initiate_flow": {
|
||||
"user": "Add heat source",
|
||||
"reconfigure": "Edit heat source"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Electrical heat source",
|
||||
"description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensor": "Active power sensor",
|
||||
"zone": "Zone",
|
||||
"heat_fraction": "Share of power that becomes heat in the zone",
|
||||
"baseline_mode": "Baseline",
|
||||
"baseline_w": "Manual baseline (W)",
|
||||
"included_in": "Already included in the measurement of",
|
||||
"response_minutes": "Thermal response time (minutes)",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"data_description": {
|
||||
"heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.",
|
||||
"baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).",
|
||||
"included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.",
|
||||
"response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Electrical heat source",
|
||||
"description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensor": "Active power sensor",
|
||||
"zone": "Zone",
|
||||
"heat_fraction": "Share of power that becomes heat in the zone",
|
||||
"baseline_mode": "Baseline",
|
||||
"baseline_w": "Manual baseline (W)",
|
||||
"included_in": "Already included in the measurement of",
|
||||
"response_minutes": "Thermal response time (minutes)",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"data_description": {
|
||||
"heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.",
|
||||
"baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).",
|
||||
"included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.",
|
||||
"response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"source_name_required": "Enter a name.",
|
||||
"sensor_in_other_source": "This power sensor is already used by another heat source.",
|
||||
"invalid_parent_source": "The parent must be an existing source that is not itself included in another.",
|
||||
"source_has_children": "Other sources are included in this one, so it cannot be included in another.",
|
||||
"baseline_required": "Enter the manual baseline in W.",
|
||||
"too_many_sources": "At most 16 heat sources are supported."
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "The heat source was updated.",
|
||||
"no_zones": "Add a zone first."
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"entry_type": "Window",
|
||||
"initiate_flow": {
|
||||
"user": "Add window",
|
||||
"reconfigure": "Edit window"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Window",
|
||||
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensor": "Contact sensor",
|
||||
"zone": "Zone",
|
||||
"inverted": "Sensor reports on when closed",
|
||||
"azimuth": "Facing direction (°)",
|
||||
"opening_factor": "Opening factor",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"data_description": {
|
||||
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
|
||||
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
|
||||
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Window",
|
||||
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensor": "Contact sensor",
|
||||
"zone": "Zone",
|
||||
"inverted": "Sensor reports on when closed",
|
||||
"azimuth": "Facing direction (°)",
|
||||
"opening_factor": "Opening factor",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"data_description": {
|
||||
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
|
||||
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
|
||||
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"window_name_required": "Enter a name.",
|
||||
"sensor_in_other_window": "This sensor is already used by another window.",
|
||||
"too_many_windows": "At most 24 windows are supported."
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "The window was updated.",
|
||||
"no_zones": "Add a zone first."
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"baseline_mode": {
|
||||
"options": {
|
||||
"auto": "Automatic (24-hour low)",
|
||||
"manual": "Manual value"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@
|
||||
"heater_entity": "Physical heating switch (read-only)",
|
||||
"room_entities": "Individual room temperature sensors",
|
||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)",
|
||||
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting"
|
||||
},
|
||||
"data_description": {
|
||||
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable."
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
@@ -19,7 +23,9 @@
|
||||
"data": {
|
||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||
"outside_wind_sources": "Wind speed sources (optional)",
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)",
|
||||
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
|
||||
"outside_wind_gust_sources": "Wind gust sources (optional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
@@ -36,7 +42,7 @@
|
||||
},
|
||||
"weights": {
|
||||
"title": "Room priorities",
|
||||
"description": "Each room has a relative comfort weight between 0.1 and 10; sensor entity is shown by each field."
|
||||
"description": "Each selected sensor becomes its own zone with this comfort weight. Later, group the sensors of one room into one zone with Add zone or by editing a zone."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -50,12 +56,18 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Heating MPC",
|
||||
"menu_options": {
|
||||
"settings": "Settings",
|
||||
"reset_model": "Reset learned model"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Heating MPC options",
|
||||
"description": "Change room sensors and controller settings. This integration remains shadow-only.",
|
||||
"data": {
|
||||
"climate_entity": "Existing Generic Thermostat",
|
||||
"heater_entity": "Physical heating switch (read-only)",
|
||||
"room_entities": "Individual room temperature sensors",
|
||||
"horizon_minutes": "Prediction horizon (minutes)",
|
||||
"minimum_on_minutes": "Minimum ON time (minutes)",
|
||||
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
||||
@@ -63,7 +75,30 @@
|
||||
"heating_cost": "Heating runtime cost",
|
||||
"comfort_band": "Comfort tolerance (°C)",
|
||||
"room_max_age_minutes": "Maximum room sensor report age (minutes)",
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)"
|
||||
"weather_max_age_minutes": "Maximum weather sensor report age (minutes)",
|
||||
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting",
|
||||
"internal_heat_half_life_minutes": "Extra electrical heat: forecast half-life (minutes)",
|
||||
"window_open_minutes": "Open window: assumed remaining open time (minutes)",
|
||||
"recovery_minutes": "Recovery after closing a window (minutes)",
|
||||
"ventilation_wind": "Ventilation: use wind speed and direction",
|
||||
"ventilation_gust": "Ventilation: use wind gusts",
|
||||
"ventilation_cross": "Ventilation: cross-ventilation between zones"
|
||||
},
|
||||
"data_description": {
|
||||
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable.",
|
||||
"internal_heat_half_life_minutes": "Forecasts assume heat above the baseline fades with this half-life, for example when a gaming session ends. 0 assumes it stops now.",
|
||||
"window_open_minutes": "Forecasts assume an open window closes after this many more minutes, then the room recovers.",
|
||||
"recovery_minutes": "Time constant of the air warming back towards the walls' temperature.",
|
||||
"ventilation_wind": "Needs wind sources; direction also needs wind direction sources.",
|
||||
"ventilation_gust": "Adds a term for gusts above the mean wind. Keep it only if Model Error improves.",
|
||||
"ventilation_cross": "Adds a term for windows open in several zones at once. Needs windows in two or more zones."
|
||||
}
|
||||
},
|
||||
"reset_model": {
|
||||
"title": "Reset learned model",
|
||||
"description": "Forgets everything the model has learned: every zone's thermal coefficients (also those kept for removed zones), their confidence and sample counts, and the Model Error and Forecast Error statistics.\n\nKept: zones, sensors and weights, heat sources, windows, weather sources and all settings, plus the heat sources' baselines and the window event log.\n\nThe integration restarts and learns from the beginning: the first sample after about 30 minutes, forecasts after a day or more. This cannot be undone. The boiler and the thermostat are not affected.",
|
||||
"data": {
|
||||
"confirm_reset": "Yes, forget the learned model"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
@@ -72,7 +107,9 @@
|
||||
"data": {
|
||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||
"outside_wind_sources": "Wind speed sources (optional)",
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)",
|
||||
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
|
||||
"outside_wind_gust_sources": "Wind gust sources (optional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
@@ -86,15 +123,222 @@
|
||||
"outside_rain_sources": "Rain sources (optional)",
|
||||
"outside_snow_sources": "Snow sources (optional)"
|
||||
}
|
||||
},
|
||||
"weights": {
|
||||
"title": "Room priorities",
|
||||
"description": "Weights determine relative comfort importance, not physics coefficients."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_rooms": "Select 1–12 distinct room sensors.",
|
||||
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
||||
},
|
||||
"abort": {
|
||||
"model_reset": "The learned model was reset. Learning starts again from the beginning.",
|
||||
"model_not_reset": "Nothing was changed: the reset was not confirmed."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_sample": {
|
||||
"state": {
|
||||
"waiting_for_inputs": "Waiting for inputs",
|
||||
"first_sample": "First sample",
|
||||
"sensors_changed": "Zone sensors changed",
|
||||
"learned": "Learned",
|
||||
"ventilation": "Window open or recovering",
|
||||
"partly_rejected": "Partly rejected (outlier)",
|
||||
"interval_out_of_range": "Irregular interval",
|
||||
"relay_changed": "Relay switched",
|
||||
"outside_source_changed": "Outdoor source changed",
|
||||
"lag_uncertain": "Waiting for radiators to settle"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"zone": {
|
||||
"entry_type": "Zone",
|
||||
"initiate_flow": {
|
||||
"user": "Add zone",
|
||||
"reconfigure": "Edit zone"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Thermal zone",
|
||||
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensors": "Temperature sensors",
|
||||
"comfort_weight": "Comfort weight",
|
||||
"target_offset": "Target offset (°C)",
|
||||
"neighbors": "Adjacent zones (heat exchange)"
|
||||
},
|
||||
"data_description": {
|
||||
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||
}
|
||||
},
|
||||
"sensor_weights": {
|
||||
"title": "Sensor weights",
|
||||
"description": "How much each sensor counts in the zone temperature (0.1–10). Equal weights give the plain average.\n\n{sensors}",
|
||||
"data": {
|
||||
"sensor_weight_1": "Sensor 1 weight",
|
||||
"sensor_weight_2": "Sensor 2 weight",
|
||||
"sensor_weight_3": "Sensor 3 weight",
|
||||
"sensor_weight_4": "Sensor 4 weight",
|
||||
"sensor_weight_5": "Sensor 5 weight",
|
||||
"sensor_weight_6": "Sensor 6 weight"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Thermal zone",
|
||||
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties. If you choose two or more sensors, the next step asks for each sensor's weight.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensors": "Temperature sensors",
|
||||
"comfort_weight": "Comfort weight",
|
||||
"target_offset": "Target offset (°C)",
|
||||
"neighbors": "Adjacent zones (heat exchange)"
|
||||
},
|
||||
"data_description": {
|
||||
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"zone_name_required": "Enter a name.",
|
||||
"zone_sensors": "Choose 1–6 different sensors.",
|
||||
"sensor_in_other_zone": "A chosen sensor already belongs to another zone.",
|
||||
"sensor_is_weather_source": "A chosen sensor is used as an outdoor temperature source.",
|
||||
"too_many_zones": "At most 12 zones are supported."
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "The zone was updated."
|
||||
}
|
||||
},
|
||||
"heat_source": {
|
||||
"entry_type": "Heat source",
|
||||
"initiate_flow": {
|
||||
"user": "Add heat source",
|
||||
"reconfigure": "Edit heat source"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Electrical heat source",
|
||||
"description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensor": "Active power sensor",
|
||||
"zone": "Zone",
|
||||
"heat_fraction": "Share of power that becomes heat in the zone",
|
||||
"baseline_mode": "Baseline",
|
||||
"baseline_w": "Manual baseline (W)",
|
||||
"included_in": "Already included in the measurement of",
|
||||
"response_minutes": "Thermal response time (minutes)",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"data_description": {
|
||||
"heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.",
|
||||
"baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).",
|
||||
"included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.",
|
||||
"response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Electrical heat source",
|
||||
"description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensor": "Active power sensor",
|
||||
"zone": "Zone",
|
||||
"heat_fraction": "Share of power that becomes heat in the zone",
|
||||
"baseline_mode": "Baseline",
|
||||
"baseline_w": "Manual baseline (W)",
|
||||
"included_in": "Already included in the measurement of",
|
||||
"response_minutes": "Thermal response time (minutes)",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"data_description": {
|
||||
"heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.",
|
||||
"baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).",
|
||||
"included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.",
|
||||
"response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"source_name_required": "Enter a name.",
|
||||
"sensor_in_other_source": "This power sensor is already used by another heat source.",
|
||||
"invalid_parent_source": "The parent must be an existing source that is not itself included in another.",
|
||||
"source_has_children": "Other sources are included in this one, so it cannot be included in another.",
|
||||
"baseline_required": "Enter the manual baseline in W.",
|
||||
"too_many_sources": "At most 16 heat sources are supported."
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "The heat source was updated.",
|
||||
"no_zones": "Add a zone first."
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"entry_type": "Window",
|
||||
"initiate_flow": {
|
||||
"user": "Add window",
|
||||
"reconfigure": "Edit window"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Window",
|
||||
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensor": "Contact sensor",
|
||||
"zone": "Zone",
|
||||
"inverted": "Sensor reports on when closed",
|
||||
"azimuth": "Facing direction (°)",
|
||||
"opening_factor": "Opening factor",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"data_description": {
|
||||
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
|
||||
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
|
||||
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Window",
|
||||
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"sensor": "Contact sensor",
|
||||
"zone": "Zone",
|
||||
"inverted": "Sensor reports on when closed",
|
||||
"azimuth": "Facing direction (°)",
|
||||
"opening_factor": "Opening factor",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"data_description": {
|
||||
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
|
||||
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
|
||||
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"window_name_required": "Enter a name.",
|
||||
"sensor_in_other_window": "This sensor is already used by another window.",
|
||||
"too_many_windows": "At most 24 windows are supported."
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "The window was updated.",
|
||||
"no_zones": "Add a zone first."
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"baseline_mode": {
|
||||
"options": {
|
||||
"auto": "Automatic (24-hour low)",
|
||||
"manual": "Manual value"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@
|
||||
"heater_entity": "Releul fizic (numai citire)",
|
||||
"room_entities": "Senzorii individuali din camere",
|
||||
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)",
|
||||
"weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)"
|
||||
"weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)",
|
||||
"room_stale_use_last_value": "Folosește în continuare ultima valoare a unui senzor de cameră care nu mai raportează"
|
||||
},
|
||||
"data_description": {
|
||||
"room_stale_use_last_value": "Dezactivat: un senzor de cameră mai vechi decât limita de raportare este ignorat, iar ponderile celorlalte camere sunt recalculate. Activat: ultima lui valoare este folosită în continuare, cel mult 24 de ore fără raportare; pentru senzorii care raportează doar la schimbarea valorii. Senzorii de cameră indisponibili sau invalizi sunt mereu ignorați. Nimic nu se oprește decât dacă nicio cameră nu poate fi folosită."
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
@@ -19,7 +23,9 @@
|
||||
"data": {
|
||||
"outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)",
|
||||
"outside_wind_sources": "Senzori viteză vânt (opțional)",
|
||||
"outside_clouds_sources": "Senzori nebulozitate (opțional)"
|
||||
"outside_clouds_sources": "Senzori nebulozitate (opțional)",
|
||||
"outside_wind_direction_sources": "Surse pentru direcția vântului (opțional; de unde bate, în grade)",
|
||||
"outside_wind_gust_sources": "Surse pentru rafale de vânt (opțional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
@@ -36,7 +42,7 @@
|
||||
},
|
||||
"weights": {
|
||||
"title": "Ponderile camerelor",
|
||||
"description": "Fiecare cameră are o pondere de confort între 0,1 și 10; senzorul este indicat în numele câmpului."
|
||||
"description": "Fiecare senzor ales devine o zonă proprie, cu această pondere de confort. Ulterior poți grupa senzorii unei camere într-o singură zonă cu Adaugă zonă sau modificând o zonă."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -50,12 +56,18 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Heating MPC",
|
||||
"menu_options": {
|
||||
"settings": "Setări",
|
||||
"reset_model": "Resetează modelul învățat"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurare Heating MPC",
|
||||
"description": "Modifică senzorii, ponderile și optimizatorul. Rămâne activ doar modul de observație.",
|
||||
"data": {
|
||||
"climate_entity": "Termostatul Generic Thermostat existent",
|
||||
"heater_entity": "Releul fizic (numai citire)",
|
||||
"room_entities": "Senzorii individuali din camere",
|
||||
"horizon_minutes": "Orizontul predicției (minute)",
|
||||
"minimum_on_minutes": "Durată minimă ON (minute)",
|
||||
"minimum_off_minutes": "Durată minimă OFF (minute)",
|
||||
@@ -63,7 +75,30 @@
|
||||
"heating_cost": "Penalizare durată încălzire",
|
||||
"comfort_band": "Toleranță confort (°C)",
|
||||
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)",
|
||||
"weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)"
|
||||
"weather_max_age_minutes": "Vârsta maximă a raportării senzorilor meteo (minute)",
|
||||
"room_stale_use_last_value": "Folosește în continuare ultima valoare a unui senzor de cameră care nu mai raportează",
|
||||
"internal_heat_half_life_minutes": "Căldură electrică suplimentară: timp de înjumătățire în prognoză (minute)",
|
||||
"window_open_minutes": "Fereastră deschisă: timp rămas presupus (minute)",
|
||||
"recovery_minutes": "Revenire după închiderea ferestrei (minute)",
|
||||
"ventilation_wind": "Aerisire: folosește viteza și direcția vântului",
|
||||
"ventilation_gust": "Aerisire: folosește rafalele de vânt",
|
||||
"ventilation_cross": "Aerisire: curent între zone"
|
||||
},
|
||||
"data_description": {
|
||||
"room_stale_use_last_value": "Dezactivat: un senzor de cameră mai vechi decât limita de raportare este ignorat, iar ponderile celorlalte camere sunt recalculate. Activat: ultima lui valoare este folosită în continuare, cel mult 24 de ore fără raportare; pentru senzorii care raportează doar la schimbarea valorii. Senzorii de cameră indisponibili sau invalizi sunt mereu ignorați. Nimic nu se oprește decât dacă nicio cameră nu poate fi folosită.",
|
||||
"internal_heat_half_life_minutes": "Prognozele presupun că puterea peste consumul de bază scade cu acest timp de înjumătățire, de exemplu la finalul unei sesiuni de jocuri. 0 presupune că se oprește acum.",
|
||||
"window_open_minutes": "Prognozele presupun că o fereastră deschisă se închide după atâtea minute, apoi camera revine.",
|
||||
"recovery_minutes": "Constanta de timp a aerului care se încălzește din nou spre temperatura pereților.",
|
||||
"ventilation_wind": "Necesită surse de vânt; direcția necesită și surse pentru direcția vântului.",
|
||||
"ventilation_gust": "Adaugă un termen pentru rafalele peste vântul mediu. Păstrează-l doar dacă Model Error scade.",
|
||||
"ventilation_cross": "Adaugă un termen pentru ferestre deschise în mai multe zone deodată. Necesită ferestre în cel puțin două zone."
|
||||
}
|
||||
},
|
||||
"reset_model": {
|
||||
"title": "Resetează modelul învățat",
|
||||
"description": "Șterge tot ce a învățat modelul: coeficienții termici ai fiecărei zone (inclusiv cei păstrați pentru zonele eliminate), încrederea și numărul de eșantioane, precum și statisticile Model Error și Forecast Error.\n\nSe păstrează: zonele, senzorii și ponderile, sursele de căldură, ferestrele, sursele meteo și toate setările, plus nivelurile de bază ale surselor de căldură și jurnalul ferestrelor.\n\nIntegrarea repornește și învață de la început: primul eșantion după aproximativ 30 de minute, prognozele după o zi sau mai mult. Operația nu poate fi anulată. Centrala și termostatul nu sunt afectate.",
|
||||
"data": {
|
||||
"confirm_reset": "Da, șterge modelul învățat"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
@@ -72,7 +107,9 @@
|
||||
"data": {
|
||||
"outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)",
|
||||
"outside_wind_sources": "Senzori viteză vânt (opțional)",
|
||||
"outside_clouds_sources": "Senzori nebulozitate (opțional)"
|
||||
"outside_clouds_sources": "Senzori nebulozitate (opțional)",
|
||||
"outside_wind_direction_sources": "Surse pentru direcția vântului (opțional; de unde bate, în grade)",
|
||||
"outside_wind_gust_sources": "Surse pentru rafale de vânt (opțional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
@@ -86,15 +123,222 @@
|
||||
"outside_rain_sources": "Senzori precipitații ploaie (opțional)",
|
||||
"outside_snow_sources": "Senzori precipitații ninsoare (opțional)"
|
||||
}
|
||||
},
|
||||
"weights": {
|
||||
"title": "Ponderi pentru camere",
|
||||
"description": "Ponderile afectează importanța confortului, nu coeficienții fizici."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_rooms": "Selectează 1–12 senzori diferiți.",
|
||||
"invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor."
|
||||
},
|
||||
"abort": {
|
||||
"model_reset": "Modelul învățat a fost resetat. Învățarea reîncepe de la zero.",
|
||||
"model_not_reset": "Nu s-a modificat nimic: resetarea nu a fost confirmată."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_sample": {
|
||||
"state": {
|
||||
"waiting_for_inputs": "Așteaptă datele de intrare",
|
||||
"first_sample": "Prima măsurătoare",
|
||||
"sensors_changed": "Senzorii zonei s-au schimbat",
|
||||
"learned": "Învățat",
|
||||
"ventilation": "Fereastră deschisă sau revenire după aerisire",
|
||||
"partly_rejected": "Respins parțial (valoare aberantă)",
|
||||
"interval_out_of_range": "Interval neregulat",
|
||||
"relay_changed": "Releul a comutat",
|
||||
"outside_source_changed": "Sursa exterioară s-a schimbat",
|
||||
"lag_uncertain": "Așteaptă stabilizarea caloriferelor"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"zone": {
|
||||
"entry_type": "Zonă",
|
||||
"initiate_flow": {
|
||||
"user": "Adaugă zonă",
|
||||
"reconfigure": "Modifică zona"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Zonă termică",
|
||||
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei. Dacă alegi doi sau mai mulți senzori, pasul următor cere ponderea fiecărui senzor.",
|
||||
"data": {
|
||||
"name": "Nume",
|
||||
"sensors": "Senzori de temperatură",
|
||||
"comfort_weight": "Pondere de confort",
|
||||
"target_offset": "Abatere de la țintă (°C)",
|
||||
"neighbors": "Zone învecinate (schimb de căldură)"
|
||||
},
|
||||
"data_description": {
|
||||
"comfort_weight": "Importanța relativă pentru MPC (0,1–10).",
|
||||
"target_offset": "Se adaugă la temperatura țintă a termostatului pentru această zonă.",
|
||||
"neighbors": "Zonele cu care această zonă face schimb de căldură, de exemplu printr-un perete sau o ușă deschisă. Coeficientul este învățat doar dacă datele îl susțin."
|
||||
}
|
||||
},
|
||||
"sensor_weights": {
|
||||
"title": "Ponderile senzorilor",
|
||||
"description": "Cât contează fiecare senzor în temperatura zonei (0,1–10). Ponderi egale dau media simplă.\n\n{sensors}",
|
||||
"data": {
|
||||
"sensor_weight_1": "Ponderea senzorului 1",
|
||||
"sensor_weight_2": "Ponderea senzorului 2",
|
||||
"sensor_weight_3": "Ponderea senzorului 3",
|
||||
"sensor_weight_4": "Ponderea senzorului 4",
|
||||
"sensor_weight_5": "Ponderea senzorului 5",
|
||||
"sensor_weight_6": "Ponderea senzorului 6"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Zonă termică",
|
||||
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei. Dacă alegi doi sau mai mulți senzori, pasul următor cere ponderea fiecărui senzor.",
|
||||
"data": {
|
||||
"name": "Nume",
|
||||
"sensors": "Senzori de temperatură",
|
||||
"comfort_weight": "Pondere de confort",
|
||||
"target_offset": "Abatere de la țintă (°C)",
|
||||
"neighbors": "Zone învecinate (schimb de căldură)"
|
||||
},
|
||||
"data_description": {
|
||||
"comfort_weight": "Importanța relativă pentru MPC (0,1–10).",
|
||||
"target_offset": "Se adaugă la temperatura țintă a termostatului pentru această zonă.",
|
||||
"neighbors": "Zonele cu care această zonă face schimb de căldură, de exemplu printr-un perete sau o ușă deschisă. Coeficientul este învățat doar dacă datele îl susțin."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"zone_name_required": "Introdu un nume.",
|
||||
"zone_sensors": "Alege între 1 și 6 senzori diferiți.",
|
||||
"sensor_in_other_zone": "Un senzor ales aparține deja altei zone.",
|
||||
"sensor_is_weather_source": "Un senzor ales este folosit ca sursă de temperatură exterioară.",
|
||||
"too_many_zones": "Sunt permise cel mult 12 zone."
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "Zona a fost actualizată."
|
||||
}
|
||||
},
|
||||
"heat_source": {
|
||||
"entry_type": "Sursă de căldură",
|
||||
"initiate_flow": {
|
||||
"user": "Adaugă sursă de căldură",
|
||||
"reconfigure": "Modifică sursa de căldură"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Sursă electrică de căldură",
|
||||
"description": "Echipamente a căror putere electrică ajunge căldură într-o zonă, de exemplu un UPS cu calculatoare. Se modelează doar variația peste un consum de bază persistent: partea constantă face deja parte din comportamentul învățat al zonei. Folosește putere activă (W sau kW), nu VA sau procent de încărcare.",
|
||||
"data": {
|
||||
"name": "Nume",
|
||||
"sensor": "Senzor de putere activă",
|
||||
"zone": "Zonă",
|
||||
"heat_fraction": "Partea din putere care devine căldură în zonă",
|
||||
"baseline_mode": "Consum de bază",
|
||||
"baseline_w": "Consum de bază manual (W)",
|
||||
"included_in": "Inclusă deja în măsurătoarea pentru",
|
||||
"response_minutes": "Timp de răspuns termic (minute)",
|
||||
"enabled": "Activă"
|
||||
},
|
||||
"data_description": {
|
||||
"heat_fraction": "1,0 când echipamentul este în această zonă. Micșorează valoarea dacă o parte din căldură ajunge în altă parte, de exemplu pe o ventilație.",
|
||||
"baseline_mode": "Automat folosește partea de jos a ultimelor 24 de ore (consumul persistent).",
|
||||
"included_in": "Alege UPS-ul sau contorul a cărui citire conține deja acest dispozitiv, ca puterea să nu fie numărată de două ori.",
|
||||
"response_minutes": "Cât de repede simte zona o schimbare de putere: aerul, mobila și echipamentele au nevoie de timp să se încălzească."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Sursă electrică de căldură",
|
||||
"description": "Echipamente a căror putere electrică ajunge căldură într-o zonă, de exemplu un UPS cu calculatoare. Se modelează doar variația peste un consum de bază persistent: partea constantă face deja parte din comportamentul învățat al zonei. Folosește putere activă (W sau kW), nu VA sau procent de încărcare.",
|
||||
"data": {
|
||||
"name": "Nume",
|
||||
"sensor": "Senzor de putere activă",
|
||||
"zone": "Zonă",
|
||||
"heat_fraction": "Partea din putere care devine căldură în zonă",
|
||||
"baseline_mode": "Consum de bază",
|
||||
"baseline_w": "Consum de bază manual (W)",
|
||||
"included_in": "Inclusă deja în măsurătoarea pentru",
|
||||
"response_minutes": "Timp de răspuns termic (minute)",
|
||||
"enabled": "Activă"
|
||||
},
|
||||
"data_description": {
|
||||
"heat_fraction": "1,0 când echipamentul este în această zonă. Micșorează valoarea dacă o parte din căldură ajunge în altă parte, de exemplu pe o ventilație.",
|
||||
"baseline_mode": "Automat folosește partea de jos a ultimelor 24 de ore (consumul persistent).",
|
||||
"included_in": "Alege UPS-ul sau contorul a cărui citire conține deja acest dispozitiv, ca puterea să nu fie numărată de două ori.",
|
||||
"response_minutes": "Cât de repede simte zona o schimbare de putere: aerul, mobila și echipamentele au nevoie de timp să se încălzească."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"source_name_required": "Introdu un nume.",
|
||||
"sensor_in_other_source": "Acest senzor de putere este folosit deja de altă sursă.",
|
||||
"invalid_parent_source": "Sursa părinte trebuie să existe și să nu fie ea însăși inclusă în alta.",
|
||||
"source_has_children": "Alte surse sunt incluse în aceasta, deci nu poate fi inclusă în alta.",
|
||||
"baseline_required": "Introdu consumul de bază manual în W.",
|
||||
"too_many_sources": "Sunt permise cel mult 16 surse de căldură."
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "Sursa de căldură a fost actualizată.",
|
||||
"no_zones": "Adaugă mai întâi o zonă."
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"entry_type": "Fereastră",
|
||||
"initiate_flow": {
|
||||
"user": "Adaugă fereastră",
|
||||
"reconfigure": "Modifică fereastra"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Fereastră",
|
||||
"description": "Un senzor de contact pentru fereastră. Perioadele cu fereastra deschisă nu intră în învățarea pierderilor normale de căldură ale zonei și sunt modelate separat, ca aerisire. Stările necunoscute sau indisponibile sunt tratate ca necunoscute, niciodată ca închise.",
|
||||
"data": {
|
||||
"name": "Nume",
|
||||
"sensor": "Senzor de contact",
|
||||
"zone": "Zonă",
|
||||
"inverted": "Senzorul raportează pornit când e închis",
|
||||
"azimuth": "Orientare (°)",
|
||||
"opening_factor": "Factor de deschidere",
|
||||
"enabled": "Activă"
|
||||
},
|
||||
"data_description": {
|
||||
"inverted": "Activează pentru senzorii cu polaritate inversă față de un senzor de fereastră obișnuit.",
|
||||
"azimuth": "Direcția spre care privește exteriorul ferestrei, în sensul acelor de ceasornic față de nord (0 = N, 90 = E, 180 = S, 270 = V). Se folosește împreună cu direcția vântului.",
|
||||
"opening_factor": "Aerisirea relativă când e deschisă: 1,0 pentru o fereastră obișnuită, mai puțin pentru rabatată, mai mult pentru una mare sau dublă."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Fereastră",
|
||||
"description": "Un senzor de contact pentru fereastră. Perioadele cu fereastra deschisă nu intră în învățarea pierderilor normale de căldură ale zonei și sunt modelate separat, ca aerisire. Stările necunoscute sau indisponibile sunt tratate ca necunoscute, niciodată ca închise.",
|
||||
"data": {
|
||||
"name": "Nume",
|
||||
"sensor": "Senzor de contact",
|
||||
"zone": "Zonă",
|
||||
"inverted": "Senzorul raportează pornit când e închis",
|
||||
"azimuth": "Orientare (°)",
|
||||
"opening_factor": "Factor de deschidere",
|
||||
"enabled": "Activă"
|
||||
},
|
||||
"data_description": {
|
||||
"inverted": "Activează pentru senzorii cu polaritate inversă față de un senzor de fereastră obișnuit.",
|
||||
"azimuth": "Direcția spre care privește exteriorul ferestrei, în sensul acelor de ceasornic față de nord (0 = N, 90 = E, 180 = S, 270 = V). Se folosește împreună cu direcția vântului.",
|
||||
"opening_factor": "Aerisirea relativă când e deschisă: 1,0 pentru o fereastră obișnuită, mai puțin pentru rabatată, mai mult pentru una mare sau dublă."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"window_name_required": "Introdu un nume.",
|
||||
"sensor_in_other_window": "Acest senzor este folosit deja de altă fereastră.",
|
||||
"too_many_windows": "Sunt permise cel mult 24 de ferestre."
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "Fereastra a fost actualizată.",
|
||||
"no_zones": "Adaugă mai întâi o zonă."
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"baseline_mode": {
|
||||
"options": {
|
||||
"auto": "Automat (minimul ultimelor 24 de ore)",
|
||||
"manual": "Valoare manuală"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ LIMITS = {
|
||||
"temperature": (-45.0, 55.0),
|
||||
"feels_like": (-65.0, 65.0),
|
||||
"wind": (0.0, 90.0), # m/s
|
||||
"wind_gust": (0.0, 120.0), # m/s
|
||||
"wind_direction": (0.0, 360.0), # degrees the wind comes from
|
||||
"clouds": (0.0, 100.0), # percentage
|
||||
"humidity": (0.0, 100.0), # percentage
|
||||
"pressure": (800.0, 1100.0), # hPa
|
||||
@@ -52,11 +54,20 @@ def normalize(metric: str, raw: object, unit: str | None = None) -> float | None
|
||||
value = (value - 32.0) * 5.0 / 9.0
|
||||
elif unit not in ("", "°c", "c", "celsius"):
|
||||
return None
|
||||
elif metric == "wind":
|
||||
elif metric == "wind_direction":
|
||||
if unit not in ("", "°", "deg", "degree", "degrees"):
|
||||
return None
|
||||
if value == 360.0:
|
||||
value = 0.0
|
||||
elif metric in ("wind", "wind_gust"):
|
||||
if unit in ("km/h", "kmh", "kph"):
|
||||
value /= 3.6
|
||||
elif unit in ("mph",):
|
||||
value *= 0.44704
|
||||
elif unit in ("kn", "kt", "knots"):
|
||||
value *= 0.514444
|
||||
elif unit in ("ft/s",):
|
||||
value *= 0.3048
|
||||
elif unit not in ("", "m/s", "mps"):
|
||||
return None
|
||||
elif metric == "pressure":
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Window contact sensors, ventilation, wind exposure and recovery per zone.
|
||||
|
||||
Pure Python; no Home Assistant imports.
|
||||
|
||||
Opening a window adds heat exchange with outdoor air on top of the normal
|
||||
envelope loss. Mixing the two would make a well-insulated room look leaky, so:
|
||||
|
||||
* zones with an open window, an unknown window state, or still recovering after
|
||||
closing are left out of the closed-window model's learning;
|
||||
* a separate ventilation model is fitted to what the closed-window model cannot
|
||||
explain during those periods.
|
||||
|
||||
Its terms, per 5-minute step, with O = sum of open windows' opening factors
|
||||
(weighted by the share of the interval they were open):
|
||||
|
||||
vent_natural x O (T_out - T) buoyancy, always present
|
||||
vent_wind x O v (T_out - T) wind speed v (m/s)
|
||||
vent_wind_dir x sum_w O_w v cos(a_w) (T_out - T) a_w: wind direction vs the
|
||||
window's facing; bounded so a
|
||||
leeward window still ventilates
|
||||
vent_gust x O max(0, gust - v) (T_out - T) optional
|
||||
vent_cross x O O_other (T_out - T) optional: windows open elsewhere
|
||||
recovery x R (T_before - T) after closing, R decays 1 -> 0;
|
||||
air recovers towards the walls
|
||||
|
||||
Wind direction is meteorological (where the wind comes FROM, clockwise from north).
|
||||
A window's azimuth is the direction its outside face points to, so wind from the
|
||||
azimuth blows straight at it (cos = 1) and wind from behind the building gives -1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from math import atan2, cos, degrees, exp, hypot, isfinite, radians, sin
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
SUBENTRY_WINDOW = "window"
|
||||
MAX_WINDOWS = 24
|
||||
DEFAULT_AZIMUTH = 345.0
|
||||
OPENING_RANGE = (0.1, 3.0)
|
||||
DEFAULT_RECOVERY_MINUTES = 20.0
|
||||
# Recovery below this weight no longer counts as ventilation-affected (~3 time constants).
|
||||
RECOVERY_THRESHOLD = 0.05
|
||||
MAX_EVENTS = 20
|
||||
EVENT_AFTER_MINUTES = 30
|
||||
|
||||
WINDOW_KEY = "key"
|
||||
WINDOW_NAME = "name"
|
||||
WINDOW_SENSOR = "sensor"
|
||||
WINDOW_ZONE = "zone"
|
||||
WINDOW_INVERTED = "inverted"
|
||||
WINDOW_AZIMUTH = "azimuth"
|
||||
WINDOW_OPENING = "opening_factor"
|
||||
WINDOW_ENABLED = "enabled"
|
||||
|
||||
VENTILATION_FEATURES = ("vent_natural", "vent_wind", "vent_wind_dir", "vent_gust", "vent_cross", "recovery")
|
||||
|
||||
|
||||
def angle_difference(a: float, b: float) -> float:
|
||||
"""Signed smallest difference a - b in degrees, in [-180, 180)."""
|
||||
return (a - b + 180.0) % 360.0 - 180.0
|
||||
|
||||
|
||||
def facing_cos(wind_from: float, azimuth: float) -> float:
|
||||
"""1 when the wind blows straight at a window facing `azimuth`, -1 from behind."""
|
||||
return cos(radians(angle_difference(wind_from, azimuth)))
|
||||
|
||||
|
||||
def circular_mean(angles: Iterable[float], weights: Iterable[float] | None = None) -> float | None:
|
||||
"""Mean direction in [0, 360); None when the directions cancel out."""
|
||||
angles = list(angles)
|
||||
weights = list(weights) if weights is not None else [1.0] * len(angles)
|
||||
x = sum(w * cos(radians(a)) for a, w in zip(angles, weights))
|
||||
y = sum(w * sin(radians(a)) for a, w in zip(angles, weights))
|
||||
if hypot(x, y) < 1e-9 * max(1.0, sum(abs(w) for w in weights)):
|
||||
return None
|
||||
return degrees(atan2(y, x)) % 360.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Window:
|
||||
key: str
|
||||
name: str
|
||||
sensor: str
|
||||
zone: str
|
||||
inverted: bool = False
|
||||
azimuth: float = DEFAULT_AZIMUTH
|
||||
opening: float = 1.0
|
||||
enabled: bool = True
|
||||
subentry_id: str | None = None
|
||||
|
||||
|
||||
def window_from_data(data: Mapping[str, Any], subentry_id: str | None = None) -> Window | None:
|
||||
key = data.get(WINDOW_KEY) or subentry_id
|
||||
sensor, zone = data.get(WINDOW_SENSOR), data.get(WINDOW_ZONE)
|
||||
if not (isinstance(key, str) and isinstance(sensor, str) and sensor.startswith("binary_sensor.")
|
||||
and isinstance(zone, str)):
|
||||
return None
|
||||
name = data.get(WINDOW_NAME)
|
||||
try:
|
||||
azimuth = float(data.get(WINDOW_AZIMUTH, DEFAULT_AZIMUTH)) % 360.0
|
||||
opening = float(data.get(WINDOW_OPENING, 1.0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not isfinite(azimuth) or not isfinite(opening) or not OPENING_RANGE[0] <= opening <= OPENING_RANGE[1]:
|
||||
return None
|
||||
return Window(key=key, name=name if isinstance(name, str) and name.strip() else key, sensor=sensor,
|
||||
zone=zone, inverted=data.get(WINDOW_INVERTED) is True, azimuth=azimuth, opening=opening,
|
||||
enabled=data.get(WINDOW_ENABLED, True) is not False, subentry_id=subentry_id)
|
||||
|
||||
|
||||
def build_windows(items: Iterable[tuple[Mapping[str, Any], str | None]], zone_keys: Iterable[str]) -> list[Window]:
|
||||
zone_keys = set(zone_keys)
|
||||
windows: list[Window] = []
|
||||
for data, subentry_id in items:
|
||||
window = window_from_data(data, subentry_id)
|
||||
if (window is None or window.zone not in zone_keys or len(windows) == MAX_WINDOWS
|
||||
or any(w.key == window.key or w.sensor == window.sensor for w in windows)):
|
||||
continue
|
||||
windows.append(window)
|
||||
return windows
|
||||
|
||||
|
||||
def contact_open(state: str | None, inverted: bool = False) -> bool | None:
|
||||
"""Open/closed from a binary sensor state; unknown and unavailable stay unknown."""
|
||||
if state not in ("on", "off"):
|
||||
return None
|
||||
return (state == "on") != inverted
|
||||
|
||||
|
||||
def open_fraction(was_open: bool | None, is_open: bool | None, last_changed: float,
|
||||
start: float, end: float) -> float | None:
|
||||
"""Share of [start, end] a window was open; None when that is unknown.
|
||||
|
||||
One change inside the interval is located by its timestamp; a change to the
|
||||
same state (an open/close round trip) or a missing state is unknown.
|
||||
"""
|
||||
if was_open is None or is_open is None or end <= start:
|
||||
return None
|
||||
if last_changed <= start:
|
||||
return float(is_open) if was_open == is_open else None
|
||||
if was_open != is_open and last_changed <= end:
|
||||
share = (end - last_changed) / (end - start)
|
||||
return share if is_open else 1.0 - share
|
||||
return None
|
||||
|
||||
|
||||
def ventilation_vector(features: Iterable[str], temp: float, outside: float,
|
||||
inputs: Mapping[str, Any]) -> list[float] | None:
|
||||
"""Ventilation feature values for one zone and step; None if an input is unknown.
|
||||
|
||||
`inputs`: opening, facing (sum of opening x cos; None when the wind direction is
|
||||
unknown), other_opening (None when another zone's windows are unknown),
|
||||
recovery, t_ref (pre-ventilation temperature), wind, gust. With every window
|
||||
closed the opening terms are 0 whatever the weather, so only the opening and,
|
||||
while recovering, the reference temperature must be known.
|
||||
"""
|
||||
difference = outside - temp
|
||||
opening = inputs.get("opening")
|
||||
if opening is None:
|
||||
return None
|
||||
values = []
|
||||
for name in features:
|
||||
if name == "vent_natural":
|
||||
values.append(opening * difference)
|
||||
elif name in ("vent_wind", "vent_wind_dir", "vent_gust", "vent_cross") and opening == 0:
|
||||
values.append(0.0)
|
||||
elif name in ("vent_wind", "vent_wind_dir", "vent_gust"):
|
||||
wind = inputs.get("wind")
|
||||
if wind is None:
|
||||
return None
|
||||
if name == "vent_wind":
|
||||
values.append(opening * wind * difference)
|
||||
elif name == "vent_wind_dir":
|
||||
facing = inputs.get("facing")
|
||||
if facing is None:
|
||||
return None
|
||||
values.append(facing * wind * difference)
|
||||
else:
|
||||
gust = inputs.get("gust")
|
||||
if gust is None:
|
||||
return None
|
||||
values.append(opening * max(0.0, gust - wind) * difference)
|
||||
elif name == "vent_cross":
|
||||
other = inputs.get("other_opening")
|
||||
if other is None:
|
||||
return None
|
||||
values.append(opening * other * difference)
|
||||
elif name == "recovery":
|
||||
recovery = inputs.get("recovery") or 0.0
|
||||
t_ref = inputs.get("t_ref")
|
||||
if recovery > 0 and t_ref is None:
|
||||
return None
|
||||
values.append(recovery * ((t_ref if t_ref is not None else temp) - temp))
|
||||
else:
|
||||
raise KeyError(name)
|
||||
return values
|
||||
|
||||
|
||||
def ventilation_vector_for_prediction(features: Iterable[str], temp: float, outside: float,
|
||||
inputs: Mapping[str, Any]) -> list[float] | None:
|
||||
"""Like ventilation_vector, but an unknown wind or gust counts as calm air.
|
||||
|
||||
For predictions only: an open window keeps its natural ventilation when the wind
|
||||
sensor is unavailable, instead of silently being treated as closed. Learning
|
||||
always uses ventilation_vector, which refuses unknown inputs.
|
||||
"""
|
||||
features = list(features)
|
||||
x = ventilation_vector(features, temp, outside, inputs)
|
||||
if x is None and inputs.get("opening") is not None:
|
||||
x = ventilation_vector(features, temp, outside, {
|
||||
**inputs, "wind": inputs.get("wind") or 0.0, "gust": inputs.get("gust") or inputs.get("wind") or 0.0,
|
||||
"facing": inputs.get("facing") or 0.0, "other_opening": inputs.get("other_opening") or 0.0})
|
||||
return x
|
||||
|
||||
|
||||
def ventilation_known(features: Iterable[str], inputs: Mapping[str, Any] | None) -> bool:
|
||||
"""True when a zone's ventilation term follows from observed inputs alone.
|
||||
|
||||
False while a window state is unknown or a recovery's start is unconfirmed,
|
||||
or while a window is open and a term it activates lacks its weather input.
|
||||
Zones without windows (`inputs` None or empty) need nothing.
|
||||
"""
|
||||
if not inputs:
|
||||
return True
|
||||
return ventilation_vector(list(features), 0.0, 0.0, inputs) is not None
|
||||
|
||||
|
||||
def _iso(timestamp: float) -> str:
|
||||
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class VentilationTracker:
|
||||
"""Window states, ventilation inputs per zone, recovery after closing, event log.
|
||||
|
||||
Each window is OPEN, CLOSED or UNKNOWN (unknown, unavailable or missing).
|
||||
Unknown never means closed. The last known state is kept apart from the
|
||||
current one, and an opening or closing counts as confirmed only when it was
|
||||
seen: the previous reading was the other state, with no unknown period in
|
||||
between. Last known states, open events and recovery are saved so a restart
|
||||
or reload continues them; they come back as unknown until the sensor reports.
|
||||
"""
|
||||
|
||||
def __init__(self, windows: list[Window], recovery_minutes: float = DEFAULT_RECOVERY_MINUTES,
|
||||
saved: Any = None) -> None:
|
||||
self.windows = windows
|
||||
self.tau = max(1.0, recovery_minutes) * 60.0
|
||||
self._previous: dict[str, tuple[float, bool | None]] = {} # window -> (poll time, open)
|
||||
self._closed_at: dict[str, float] = {} # zone -> when its last window closed
|
||||
self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened
|
||||
self._last_temps: dict[str, float | None] = {}
|
||||
self._open_event: dict[str, dict[str, Any]] = {}
|
||||
# window -> last known open/closed state, since when, when it was last seen
|
||||
# and since when it is unknown (None while known).
|
||||
self._memory: dict[str, dict[str, Any]] = {}
|
||||
self._recovery_confirmed: dict[str, bool] = {}
|
||||
self._last_update: float | None = None
|
||||
self._restored_at: float | None = None
|
||||
self.changed = False # something worth saving happened since the last save
|
||||
saved = saved if isinstance(saved, Mapping) else {}
|
||||
events = saved.get("events") if isinstance(saved.get("events"), list) else []
|
||||
self.events: deque[dict[str, Any]] = deque(
|
||||
(e for e in events if isinstance(e, dict)), maxlen=MAX_EVENTS)
|
||||
self.window_state: dict[str, dict[str, Any]] = {}
|
||||
self.zone_state: dict[str, dict[str, Any]] = {}
|
||||
self._restore(saved.get("state"))
|
||||
|
||||
def update(self, now: float, states: Mapping[str, tuple[str | None, float | None]],
|
||||
temps: Mapping[str, float | None], weather: Mapping[str, float | None]) -> None:
|
||||
"""One poll. `states`: window key -> (binary sensor state, last_changed timestamp)."""
|
||||
self.window_state = {}
|
||||
zones: dict[str, dict[str, Any]] = {}
|
||||
for window in self.windows:
|
||||
if not window.enabled:
|
||||
continue
|
||||
state, changed = states.get(window.key, (None, None))
|
||||
is_open = contact_open(state, window.inverted)
|
||||
previous = self._previous.get(window.key)
|
||||
fraction = None
|
||||
if previous and changed is not None and 0 < now - previous[0] <= 900:
|
||||
fraction = open_fraction(previous[1], is_open, changed, previous[0], now)
|
||||
self._previous[window.key] = (now, is_open)
|
||||
memory = self._memory.setdefault(window.key, {"last_known": None, "since": None, "seen": None,
|
||||
"unknown_since": None})
|
||||
zone = zones.setdefault(window.zone, {
|
||||
"opening": 0.0, "facing_unit": 0.0, "known": True, "open_now": False, "possibly_open": False,
|
||||
"open_windows": [], "last_close": None, "close_confirmed": True, "first_open": None,
|
||||
"open_confirmed": True, "unknown_windows": [], "facing_known": True})
|
||||
if fraction is None:
|
||||
zone["known"] = False
|
||||
else:
|
||||
zone["opening"] += window.opening * fraction
|
||||
zone["facing_unit"] += window.opening * fraction * (
|
||||
facing_cos(weather["wind_direction"], window.azimuth)
|
||||
if weather.get("wind_direction") is not None else 0.0)
|
||||
if fraction > 0 and weather.get("wind_direction") is None:
|
||||
zone["facing_known"] = False
|
||||
if is_open is None:
|
||||
if memory["unknown_since"] is None:
|
||||
memory["unknown_since"] = memory["seen"] if memory["seen"] is not None else now
|
||||
self.changed = True
|
||||
zone["unknown_windows"].append(window.name)
|
||||
if memory["last_known"]:
|
||||
zone["possibly_open"] = True # last seen open: still treated as open
|
||||
else:
|
||||
if memory["last_known"] is not is_open: # a change, or the first reading
|
||||
memory["since"] = changed
|
||||
# Seen only if the previous reading was the other state, with no gap.
|
||||
witnessed = memory["last_known"] is not None and memory["unknown_since"] is None
|
||||
if not witnessed:
|
||||
zone["open_confirmed" if is_open else "close_confirmed"] = False
|
||||
self.changed = True
|
||||
elif memory["unknown_since"] is not None:
|
||||
self.changed = True # the same state again after a gap
|
||||
memory.update(last_known=is_open, seen=now, unknown_since=None)
|
||||
if is_open:
|
||||
zone["open_now"] = True
|
||||
zone["open_windows"].append(window.name)
|
||||
if changed is not None:
|
||||
zone["first_open"] = min(zone["first_open"] or changed, changed)
|
||||
elif previous and changed is not None and changed > previous[0]:
|
||||
zone["last_close"] = max(zone["last_close"] or 0.0, changed)
|
||||
self.window_state[window.key] = {
|
||||
"open": is_open, "since": changed, "last_known_open": memory["last_known"],
|
||||
"last_known_since": memory["since"], "seen": memory["seen"],
|
||||
"unknown_since": memory["unknown_since"],
|
||||
"relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1)
|
||||
if weather.get("wind_direction") is not None else None)}
|
||||
# Time the integration was not running counts as unknown for events it interrupted.
|
||||
gap = now - self._restored_at if self._restored_at is not None else 0.0
|
||||
self._restored_at = None
|
||||
for key, zone in zones.items():
|
||||
if gap > 330 and key in self._open_event:
|
||||
event = self._open_event[key]
|
||||
event["sensor_unknown_minutes"] = round(event.get("sensor_unknown_minutes", 0.0) + gap / 60, 1)
|
||||
self._update_zone(now, key, zone, temps.get(key), weather, zones)
|
||||
self.zone_state = zones
|
||||
self._last_temps = dict(temps)
|
||||
self._last_update = now
|
||||
|
||||
def _update_zone(self, now: float, key: str, zone: dict[str, Any], temp: float | None,
|
||||
weather: Mapping[str, float | None], zones: Mapping[str, dict[str, Any]]) -> None:
|
||||
was_open = key in self._open_event
|
||||
ventilating = zone["open_now"] or zone["possibly_open"]
|
||||
if zone["open_now"] and not was_open:
|
||||
# Temperature at the previous poll approximates the room before ventilation.
|
||||
self._t_ref[key] = self._last_temps.get(key)
|
||||
self._closed_at.pop(key, None)
|
||||
self._open_event[key] = {
|
||||
"zone": key, "opened": _iso(min(zone["first_open"] or now, now)),
|
||||
"opened_confirmed": zone["open_confirmed"],
|
||||
"temperature_before": self._t_ref[key] if zone["open_confirmed"] else None,
|
||||
"outside": weather.get("temperature"), "wind": weather.get("wind"),
|
||||
"wind_direction": weather.get("wind_direction"), "wind_gust": weather.get("wind_gust"),
|
||||
"windows": list(zone["open_windows"]),
|
||||
"other_open_windows": [n for k, z in zones.items() if k != key for n in z["open_windows"]],
|
||||
"lowest_temperature": temp, "sensor_unknown_minutes": 0.0, "last_seen_open": _iso(now),
|
||||
}
|
||||
if not zone["open_confirmed"]:
|
||||
self._t_ref[key] = None # the room may already have cooled before we saw it
|
||||
self.changed = True
|
||||
elif ventilating and was_open:
|
||||
event = self._open_event[key]
|
||||
if temp is not None:
|
||||
event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None)
|
||||
if zone["unknown_windows"]:
|
||||
event["sensor_unknown_minutes"] = round(event["sensor_unknown_minutes"] + 5.0, 1)
|
||||
if zone["open_now"]:
|
||||
event["last_seen_open"] = _iso(now)
|
||||
elif was_open and not ventilating:
|
||||
event = self._open_event.pop(key)
|
||||
opened = _parse(event["opened"]) # the confirmed opening, or when it was first seen open
|
||||
seen_open = _parse(event.get("last_seen_open", event["opened"]))
|
||||
closed = zone["last_close"] or now if zone["close_confirmed"] else now
|
||||
confirmed = zone["close_confirmed"] and event.get("opened_confirmed", True)
|
||||
# Uncertain periods give a range instead of a duration.
|
||||
event.update(
|
||||
closed=_iso(closed) if zone["close_confirmed"] else None,
|
||||
closed_confirmed=zone["close_confirmed"],
|
||||
duration_minutes=round((closed - opened) / 60, 1) if confirmed else None,
|
||||
duration_min_minutes=round(((closed if zone["close_confirmed"] else seen_open) - opened) / 60, 1),
|
||||
duration_max_minutes=(round((now - opened) / 60, 1)
|
||||
if not zone["close_confirmed"] and event.get("opened_confirmed", True)
|
||||
else None))
|
||||
if not zone["close_confirmed"]:
|
||||
event["closed_reported"] = _iso(now) # closed at some point before this
|
||||
self._closed_at[key] = closed
|
||||
self._recovery_confirmed[key] = confirmed
|
||||
event["temperature_at_close"] = temp
|
||||
self.events.append(event)
|
||||
self.changed = True
|
||||
elif not ventilating and key in self._closed_at and temp is not None:
|
||||
# Fill in the temperature half an hour after closing, for recovery review.
|
||||
for event in reversed(self.events):
|
||||
if event.get("zone") == key and "temperature_after" not in event:
|
||||
if now - self._closed_at[key] >= EVENT_AFTER_MINUTES * 60:
|
||||
event["temperature_after"] = temp
|
||||
event["temperature_after_minutes"] = EVENT_AFTER_MINUTES
|
||||
break
|
||||
closed = self._closed_at.get(key)
|
||||
recovery = exp(-(now - closed) / self.tau) if closed is not None and not ventilating else 0.0
|
||||
if recovery < RECOVERY_THRESHOLD:
|
||||
recovery = 0.0
|
||||
zone.update(recovery=recovery, t_ref=self._t_ref.get(key),
|
||||
recovery_confirmed=self._recovery_confirmed.get(key, True),
|
||||
other_opening=sum(z["opening"] for k, z in zones.items() if k != key and z["known"]),
|
||||
others_known=all(z["known"] for k, z in zones.items() if k != key))
|
||||
# Learning of the closed-window model is unsafe while anything ventilation-related goes on.
|
||||
zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or ventilating or recovery > 0
|
||||
|
||||
def zone_inputs(self, key: str, weather: Mapping[str, float | None]) -> dict[str, Any] | None:
|
||||
"""Ventilation inputs of the interval that just ended; None for zones without windows.
|
||||
|
||||
`known` is False while a window state is unknown, and during a recovery whose
|
||||
start is not confirmed: those intervals are not used for any learning.
|
||||
"""
|
||||
zone = self.zone_state.get(key)
|
||||
if zone is None:
|
||||
return None
|
||||
known = zone["known"] and (zone["recovery"] == 0 or zone["recovery_confirmed"])
|
||||
return {"opening": zone["opening"] if known else None,
|
||||
"facing": zone["facing_unit"] if zone["facing_known"] else None,
|
||||
"other_opening": zone["other_opening"] if zone["others_known"] else None,
|
||||
"recovery": zone["recovery"],
|
||||
"t_ref": zone["t_ref"], "wind": weather.get("wind"), "gust": weather.get("wind_gust"),
|
||||
"affected": zone["affected"], "known": known,
|
||||
"unknown_windows": list(zone["unknown_windows"])}
|
||||
|
||||
def assumption(self, window: Window, open_minutes: float, now: float) -> dict[str, Any]:
|
||||
"""What forecasts assume for one window, and on what basis.
|
||||
|
||||
Observed open: open for `open_minutes` more. Unknown but last seen open: open
|
||||
until `open_minutes` after it was last seen, never renewed, so an unavailable
|
||||
sensor does not keep a window open forever. Unknown otherwise: no ventilation,
|
||||
marked uncertain (`last_known_closed`, or `unobserved` with no reading at all).
|
||||
"""
|
||||
state = self.window_state.get(window.key, {})
|
||||
if state.get("open"):
|
||||
return {"basis": "observed", "open_minutes": float(open_minutes), "uncertain": False}
|
||||
if state.get("open") is False:
|
||||
return {"basis": "observed", "open_minutes": 0.0, "uncertain": False}
|
||||
if state.get("last_known_open"):
|
||||
seen = state.get("seen")
|
||||
left = open_minutes - (now - seen) / 60 if seen is not None else 0.0
|
||||
return {"basis": "last_known_open", "open_minutes": round(max(0.0, left), 1), "uncertain": True,
|
||||
"assumed_closed_at": seen + open_minutes * 60 if seen is not None else now}
|
||||
basis = "last_known_closed" if state.get("last_known_open") is False else "unobserved"
|
||||
return {"basis": basis, "open_minutes": 0.0, "uncertain": True}
|
||||
|
||||
def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None],
|
||||
now: float) -> list[dict[str, Any]]:
|
||||
"""Assumed ventilation inputs per forecast step (see `assumption`).
|
||||
|
||||
Each window open now, or assumed open, closes after its assumed time; once
|
||||
the last one has closed the zone recovers. Weather is held at current values.
|
||||
"""
|
||||
zone = self.zone_state.get(key)
|
||||
if zone is None:
|
||||
return [{} for _ in range(steps)]
|
||||
windows = [w for w in self.windows if w.zone == key and w.enabled]
|
||||
assumed = {w.key: self.assumption(w, open_minutes, now) for w in windows}
|
||||
# Steps each window stays open: at least one for an observed open window.
|
||||
open_steps = {w.key: (max(1, round(open_minutes / 5)) if self.window_state.get(w.key, {}).get("open")
|
||||
else round(assumed[w.key]["open_minutes"] / 5)) for w in windows}
|
||||
direction = weather.get("wind_direction")
|
||||
last_open = max(open_steps.values(), default=0)
|
||||
if last_open:
|
||||
start = now + last_open * 300.0
|
||||
else:
|
||||
# Recovery from the last close: observed, or assumed for a window last seen open.
|
||||
closes = [a["assumed_closed_at"] for a in assumed.values() if "assumed_closed_at" in a]
|
||||
closed_at = self._closed_at.get(key)
|
||||
start = max([t for t in (closed_at, *closes) if t is not None], default=None)
|
||||
result = []
|
||||
for k in range(steps):
|
||||
t = now + (k + 1) * 300.0
|
||||
open_now = [w for w in windows if k < open_steps[w.key]]
|
||||
if open_now:
|
||||
result.append({
|
||||
"opening": sum(w.opening for w in open_now),
|
||||
"facing": (sum(w.opening * facing_cos(direction, w.azimuth) for w in open_now)
|
||||
if direction is not None else 0.0),
|
||||
"recovery": 0.0, "t_ref": zone["t_ref"], "other_opening": zone["other_opening"],
|
||||
"wind": weather.get("wind") or 0.0,
|
||||
"gust": weather.get("wind_gust") or weather.get("wind") or 0.0})
|
||||
continue
|
||||
recovery = exp(-(t - start) / self.tau) if start is not None and t > start else 0.0
|
||||
result.append({"opening": 0.0, "facing": 0.0, "other_opening": 0.0, "wind": 0.0, "gust": 0.0,
|
||||
"recovery": recovery if recovery >= RECOVERY_THRESHOLD else 0.0,
|
||||
"t_ref": zone["t_ref"]})
|
||||
return result
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Events, plus the state a restart or reload needs to continue them."""
|
||||
windows = {w.key: w for w in self.windows if w.enabled}
|
||||
state = {
|
||||
"time": self._last_update,
|
||||
"windows": {key: {"sensor": windows[key].sensor, "inverted": windows[key].inverted,
|
||||
"last_known": m["last_known"], "since": m["since"], "seen": m["seen"]}
|
||||
for key, m in self._memory.items()
|
||||
if key in windows and m["last_known"] is not None and m["seen"] is not None},
|
||||
"zones": {zone: {"open_event": self._open_event.get(zone), "closed_at": self._closed_at.get(zone),
|
||||
"recovery_confirmed": self._recovery_confirmed.get(zone, True),
|
||||
"t_ref": self._t_ref.get(zone)}
|
||||
for zone in {w.zone for w in windows.values()}
|
||||
if zone in self._open_event or zone in self._closed_at},
|
||||
}
|
||||
return {"events": list(self.events), "state": state}
|
||||
|
||||
def _restore(self, state: Any) -> None:
|
||||
"""Continue from saved state. Nothing restored is live: every window is unknown
|
||||
from when it was last seen until its sensor reports again."""
|
||||
if not isinstance(state, Mapping):
|
||||
return
|
||||
windows = {w.key: w for w in self.windows if w.enabled}
|
||||
saved_windows = state.get("windows") if isinstance(state.get("windows"), Mapping) else {}
|
||||
for key, item in saved_windows.items():
|
||||
window = windows.get(key)
|
||||
if (window is None or not isinstance(item, Mapping) or item.get("sensor") != window.sensor
|
||||
or item.get("inverted") is not window.inverted or not isinstance(item.get("last_known"), bool)
|
||||
or not _finite(item.get("seen"))):
|
||||
continue # another sensor now, or polarity flipped: nothing carries over
|
||||
seen = float(item["seen"])
|
||||
self._memory[key] = {"last_known": item["last_known"], "seen": seen, "unknown_since": seen,
|
||||
"since": float(item["since"]) if _finite(item.get("since")) else None}
|
||||
remembered_open = {windows[key].zone for key, m in self._memory.items() if m["last_known"]}
|
||||
saved_zones = state.get("zones") if isinstance(state.get("zones"), Mapping) else {}
|
||||
for zone, item in saved_zones.items():
|
||||
if zone not in {w.zone for w in windows.values()} or not isinstance(item, Mapping):
|
||||
continue
|
||||
event = item.get("open_event")
|
||||
# An open event continues only while one of its windows is remembered open.
|
||||
if isinstance(event, dict) and zone in remembered_open and _valid_iso(event.get("opened")):
|
||||
self._open_event[zone] = dict(event)
|
||||
if _finite(item.get("closed_at")):
|
||||
self._closed_at[zone] = float(item["closed_at"])
|
||||
self._recovery_confirmed[zone] = item.get("recovery_confirmed") is True
|
||||
if _finite(item.get("t_ref")):
|
||||
self._t_ref[zone] = float(item["t_ref"])
|
||||
if self._open_event and _finite(state.get("time")):
|
||||
self._restored_at = float(state["time"])
|
||||
|
||||
|
||||
def _finite(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value)
|
||||
|
||||
|
||||
def _valid_iso(value: Any) -> bool:
|
||||
try:
|
||||
datetime.fromisoformat(value)
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _parse(iso: str) -> float:
|
||||
return datetime.fromisoformat(iso).timestamp()
|
||||
|
||||
|
||||
def validate_window(data: Mapping[str, Any], others: Iterable[Window]) -> str | None:
|
||||
others = list(others)
|
||||
if not str(data.get(WINDOW_NAME, "")).strip():
|
||||
return "window_name_required"
|
||||
if any(w.sensor == data.get(WINDOW_SENSOR) for w in others):
|
||||
return "sensor_in_other_window"
|
||||
if len(others) >= MAX_WINDOWS:
|
||||
return "too_many_windows"
|
||||
return None
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Per-zone thermal model: base model plus optional extended features.
|
||||
|
||||
Pure Python; no Home Assistant imports. Per 5-minute step, for zone i:
|
||||
|
||||
dT_i = loss x (T_out - T_i) + heat x lag + bias (base, always learned)
|
||||
+ internal x q_i (electrical heat above baseline, kW)
|
||||
+ sum_j couple:j x (T_j - T_i) (adjacent zones)
|
||||
|
||||
The base model is the 3-parameter model of earlier versions, kept as fallback
|
||||
and as the comparison that shows whether the extra features earn their keep.
|
||||
The extended model learns the same terms plus the configured extras, only from
|
||||
samples where all of its inputs are known.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES
|
||||
from .learning import FeatureLearner
|
||||
from .physics import Learner
|
||||
from .windows import ventilation_vector_for_prediction
|
||||
|
||||
BASE_FEATURES = ["loss", "heat", "bias"]
|
||||
|
||||
|
||||
def ready(model: Learner | FeatureLearner) -> bool:
|
||||
return (model.count >= READY_SAMPLES and model.on_count >= READY_ON_SAMPLES
|
||||
and model.off_count >= READY_OFF_SAMPLES)
|
||||
|
||||
|
||||
def extended_features(has_internal: bool, neighbors: tuple[str, ...]) -> list[str]:
|
||||
"""Features of a zone's extended model; empty when it would equal the base."""
|
||||
extra = (["internal"] if has_internal else []) + [f"couple:{n}" for n in neighbors]
|
||||
return BASE_FEATURES + extra if extra else []
|
||||
|
||||
|
||||
def feature_vector(features: list[str], temp: float, inputs: Mapping[str, Any]) -> list[float] | None:
|
||||
"""Values of `features` at the start of a step; None if an input is unknown.
|
||||
|
||||
`inputs`: outside, lag, internal (kW, may be None) and temps (all zones).
|
||||
"""
|
||||
values = []
|
||||
for name in features:
|
||||
if name == "loss":
|
||||
values.append(inputs["outside"] - temp)
|
||||
elif name == "heat":
|
||||
values.append(inputs["lag"])
|
||||
elif name == "bias":
|
||||
values.append(1.0)
|
||||
elif name == "internal":
|
||||
if inputs.get("internal") is None:
|
||||
return None
|
||||
values.append(inputs["internal"])
|
||||
elif name.startswith("couple:"):
|
||||
neighbor = inputs.get("temps", {}).get(name.split(":", 1)[1])
|
||||
if neighbor is None:
|
||||
return None
|
||||
values.append(neighbor - temp)
|
||||
else:
|
||||
raise KeyError(name)
|
||||
return values
|
||||
|
||||
|
||||
class ZoneModel:
|
||||
def __init__(self, base: Learner | None = None, extended: FeatureLearner | None = None,
|
||||
ventilation: FeatureLearner | None = None) -> None:
|
||||
self.base = base or Learner()
|
||||
self.extended = extended
|
||||
self.ventilation = ventilation
|
||||
|
||||
@property
|
||||
def uses_extended(self) -> bool:
|
||||
"""Forecast with the extended model once it is as trained as the base needs to be."""
|
||||
return self.extended is not None and ready(self.extended)
|
||||
|
||||
def main_delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
|
||||
"""Closed-window change. Unknown extended inputs fall back to the base model."""
|
||||
if self.uses_extended:
|
||||
x = feature_vector(self.extended.features, temp, inputs)
|
||||
if x is not None:
|
||||
return self.extended.predict(x)
|
||||
return self.base.predict_delta(temp, inputs["outside"], inputs["lag"])
|
||||
|
||||
def ventilation_delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
|
||||
"""Extra change from open windows and recovery; 0 without windows or inputs."""
|
||||
ventilation = inputs.get("ventilation")
|
||||
if self.ventilation is None or not ventilation:
|
||||
return 0.0
|
||||
x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||
return self.ventilation.predict(x) if x is not None else 0.0
|
||||
|
||||
def delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
|
||||
"""Predicted 5-minute change of the zone."""
|
||||
return self.main_delta(temp, inputs) + self.ventilation_delta(temp, inputs)
|
||||
|
||||
def contributions(self, temp: float, inputs: Mapping[str, Any]) -> dict[str, float]:
|
||||
"""Each term's share of the predicted change, in °C per hour."""
|
||||
terms = self._main_contributions(temp, inputs)
|
||||
ventilation = inputs.get("ventilation")
|
||||
if self.ventilation is not None and ventilation:
|
||||
x = ventilation_vector_for_prediction(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||
if x is not None:
|
||||
for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x):
|
||||
group = "recovery" if name == "recovery" else "ventilation"
|
||||
terms[group] = round(terms.get(group, 0.0) + 12 * theta * value, 3)
|
||||
return terms
|
||||
|
||||
def _main_contributions(self, temp: float, inputs: Mapping[str, Any]) -> dict[str, float]:
|
||||
if self.uses_extended:
|
||||
x = feature_vector(self.extended.features, temp, inputs)
|
||||
if x is not None:
|
||||
terms: dict[str, float] = {}
|
||||
for name, theta, value in zip(self.extended.features, self.extended.theta, x):
|
||||
group = "neighbors" if name.startswith("couple:") else name
|
||||
terms[group] = terms.get(group, 0.0) + 12 * theta * value
|
||||
return {name: round(value, 3) for name, value in terms.items()}
|
||||
a, b, c = self.base.theta
|
||||
return {"loss": round(12 * a * (inputs["outside"] - temp), 3),
|
||||
"heat": round(12 * b * inputs["lag"], 3), "bias": round(12 * c, 3)}
|
||||
|
||||
|
||||
def _probe(model: ZoneModel, key: str, inputs: Mapping[str, Any], state: Mapping[str, float], lag: float) -> float:
|
||||
return model.delta(state[key], {**inputs, "temps": state, "lag": lag})
|
||||
|
||||
|
||||
def compile_step_model(models: Mapping[str, ZoneModel], keys: list[str], steps: int,
|
||||
inputs_at: Callable[[str, int], Mapping[str, Any]]):
|
||||
"""A fast, exact multi-zone step for the optimizer's plan search.
|
||||
|
||||
Every term of ZoneModel.delta is affine in the zone temperatures and the
|
||||
radiator lag (loss, neighbours, ventilation and recovery all scale with
|
||||
temperature differences), and which layer applies depends only on which
|
||||
inputs exist, not on their values. So per zone and step the change is
|
||||
a + sum_j b_j T_j + h lag, with the coefficients read off the real model at
|
||||
a few probe points. The search then does a handful of multiply-adds per step
|
||||
instead of rebuilding feature vectors for every candidate plan.
|
||||
"""
|
||||
zero = {key: 0.0 for key in keys}
|
||||
terms: dict[str, list[tuple[float, list[tuple[str, float]], float]]] = {}
|
||||
for key in keys:
|
||||
model = models[key]
|
||||
related = [key] + ([name.split(":", 1)[1] for name in model.extended.features
|
||||
if name.startswith("couple:") and name.split(":", 1)[1] in zero]
|
||||
if model.extended is not None else [])
|
||||
per_step = []
|
||||
for k in range(steps):
|
||||
inputs = dict(inputs_at(key, k))
|
||||
a = _probe(model, key, inputs, zero, 0.0)
|
||||
per_step.append((a, [(j, _probe(model, key, inputs, {**zero, j: 1.0}, 0.0) - a) for j in related],
|
||||
_probe(model, key, inputs, zero, 1.0) - a))
|
||||
terms[key] = per_step
|
||||
|
||||
def step(state: dict[str, float], lag: float, k: int) -> dict[str, float]:
|
||||
k = min(k, steps - 1)
|
||||
new = {}
|
||||
for key, value in state.items():
|
||||
a, coefficients, h = terms[key][k]
|
||||
new[key] = value + a + h * lag + sum(b * state[j] for j, b in coefficients)
|
||||
return new
|
||||
return step
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Thermal zones: rooms or areas, each with one or more temperature sensors.
|
||||
|
||||
Pure Python; no Home Assistant imports.
|
||||
|
||||
Two kinds of weight are kept apart on purpose:
|
||||
|
||||
* sensor weights combine several sensors in one zone into one temperature;
|
||||
* the comfort weight says how much the zone matters to the MPC.
|
||||
|
||||
Neither is a physical coefficient. A cold bedroom can get a higher comfort
|
||||
weight without its learned heat loss changing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from math import isfinite
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
SUBENTRY_ZONE = "zone"
|
||||
MAX_ZONES = 12
|
||||
MAX_SENSORS_PER_ZONE = 6
|
||||
COMFORT_WEIGHT_RANGE = (0.1, 10.0)
|
||||
SENSOR_WEIGHT_RANGE = (0.1, 10.0)
|
||||
TARGET_OFFSET_RANGE = (-3.0, 3.0)
|
||||
|
||||
# Subentry data keys.
|
||||
ZONE_KEY = "key"
|
||||
ZONE_NAME = "name"
|
||||
ZONE_SENSORS = "sensors"
|
||||
ZONE_SENSOR_WEIGHTS = "sensor_weights"
|
||||
ZONE_COMFORT_WEIGHT = "comfort_weight"
|
||||
ZONE_TARGET_OFFSET = "target_offset"
|
||||
ZONE_NEIGHBORS = "neighbors"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Zone:
|
||||
# Stable identity for learned models and entity unique IDs. Zones migrated from
|
||||
# 0.3 keep their sensor's entity ID; new zones get a generated key.
|
||||
key: str
|
||||
name: str
|
||||
sensors: tuple[str, ...]
|
||||
sensor_weights: Mapping[str, float] = field(default_factory=dict)
|
||||
comfort_weight: float = 1.0
|
||||
target_offset: float = 0.0
|
||||
neighbors: tuple[str, ...] = ()
|
||||
subentry_id: str | None = None
|
||||
|
||||
def sensor_weight(self, sensor: str) -> float:
|
||||
return float(self.sensor_weights.get(sensor, 1.0))
|
||||
|
||||
|
||||
def _number(value: Any, default: float, lo: float, hi: float) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return number if isfinite(number) and lo <= number <= hi else default
|
||||
|
||||
|
||||
def zone_from_data(data: Mapping[str, Any], subentry_id: str | None = None) -> Zone | None:
|
||||
"""Parse one zone's stored data; None if it cannot be used."""
|
||||
key = data.get(ZONE_KEY) or subentry_id
|
||||
sensors = data.get(ZONE_SENSORS)
|
||||
if not isinstance(key, str) or not isinstance(sensors, (list, tuple)):
|
||||
return None
|
||||
sensors = tuple(dict.fromkeys(s for s in sensors if isinstance(s, str) and s.startswith("sensor.")))
|
||||
if not sensors:
|
||||
return None
|
||||
raw_weights = data.get(ZONE_SENSOR_WEIGHTS)
|
||||
raw_weights = raw_weights if isinstance(raw_weights, Mapping) else {}
|
||||
weights = {s: _number(raw_weights.get(s), 1.0, *SENSOR_WEIGHT_RANGE) for s in sensors}
|
||||
neighbors = data.get(ZONE_NEIGHBORS)
|
||||
neighbors = tuple(n for n in neighbors if isinstance(n, str) and n != key) \
|
||||
if isinstance(neighbors, (list, tuple)) else ()
|
||||
name = data.get(ZONE_NAME)
|
||||
return Zone(
|
||||
key=key,
|
||||
name=name if isinstance(name, str) and name.strip() else key,
|
||||
sensors=sensors,
|
||||
sensor_weights=weights,
|
||||
comfort_weight=_number(data.get(ZONE_COMFORT_WEIGHT), 1.0, *COMFORT_WEIGHT_RANGE),
|
||||
target_offset=_number(data.get(ZONE_TARGET_OFFSET), 0.0, *TARGET_OFFSET_RANGE),
|
||||
neighbors=neighbors,
|
||||
subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
|
||||
def legacy_zone_data(rooms: Iterable[str], weights: Mapping[str, Any] | None,
|
||||
names: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
|
||||
"""Zone data for 0.3 configurations: one zone per room sensor.
|
||||
|
||||
The sensor's entity ID becomes the zone key, so learned models, forecast
|
||||
entities and accuracy statistics carry over unchanged.
|
||||
"""
|
||||
weights = weights if isinstance(weights, Mapping) else {}
|
||||
names = names or {}
|
||||
result = []
|
||||
for room in dict.fromkeys(r for r in rooms if isinstance(r, str)):
|
||||
result.append({
|
||||
ZONE_KEY: room,
|
||||
ZONE_NAME: names.get(room) or room,
|
||||
ZONE_SENSORS: [room],
|
||||
ZONE_SENSOR_WEIGHTS: {room: 1.0},
|
||||
ZONE_COMFORT_WEIGHT: _number(weights.get(room), 1.0, *COMFORT_WEIGHT_RANGE),
|
||||
ZONE_TARGET_OFFSET: 0.0,
|
||||
ZONE_NEIGHBORS: [],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def build_zones(items: Iterable[tuple[Mapping[str, Any], str | None]]) -> list[Zone]:
|
||||
"""Valid zones from (data, subentry_id) pairs: unique keys, each sensor in one zone."""
|
||||
zones: list[Zone] = []
|
||||
keys: set[str] = set()
|
||||
taken: set[str] = set()
|
||||
for data, subentry_id in items:
|
||||
zone = zone_from_data(data, subentry_id)
|
||||
if zone is None or zone.key in keys or taken.intersection(zone.sensors):
|
||||
continue
|
||||
zones.append(zone)
|
||||
keys.add(zone.key)
|
||||
taken.update(zone.sensors)
|
||||
if len(zones) == MAX_ZONES:
|
||||
break
|
||||
# Neighbours must exist; exchange is symmetric, so record both directions.
|
||||
linked = {zone.key: set(n for n in zone.neighbors if n in keys) for zone in zones}
|
||||
for key, others in list(linked.items()):
|
||||
for other in others:
|
||||
linked[other].add(key)
|
||||
return [Zone(**{**zone.__dict__, "neighbors": tuple(sorted(linked[zone.key]))}) for zone in zones]
|
||||
|
||||
|
||||
def zone_temperature(zone: Zone, readings: Mapping[str, float | None]) -> tuple[float | None, frozenset[str]]:
|
||||
"""Sensor-weighted mean of the zone's usable readings, and which sensors it used.
|
||||
|
||||
The set of sensors matters for learning: when a sensor drops out or rejoins,
|
||||
the mean jumps by the offset between sensors, which is not a real change.
|
||||
"""
|
||||
used = [s for s in zone.sensors if readings.get(s) is not None]
|
||||
if not used:
|
||||
return None, frozenset()
|
||||
total = sum(zone.sensor_weight(s) for s in used)
|
||||
value = sum(zone.sensor_weight(s) * float(readings[s]) for s in used) / total
|
||||
return value, frozenset(used)
|
||||
|
||||
|
||||
def validate_zone(data: Mapping[str, Any], others: Iterable[Zone], weather_sensors: Iterable[str]) -> str | None:
|
||||
"""Translation key of the first problem with a zone form, or None."""
|
||||
sensors = data.get(ZONE_SENSORS) or []
|
||||
if not str(data.get(ZONE_NAME, "")).strip():
|
||||
return "zone_name_required"
|
||||
if not sensors or len(sensors) > MAX_SENSORS_PER_ZONE or len(set(sensors)) != len(sensors):
|
||||
return "zone_sensors"
|
||||
others = list(others)
|
||||
if any(set(sensors).intersection(zone.sensors) for zone in others):
|
||||
return "sensor_in_other_zone"
|
||||
if set(sensors).intersection(weather_sensors):
|
||||
return "sensor_is_weather_source"
|
||||
if len(others) >= MAX_ZONES:
|
||||
return "too_many_zones"
|
||||
return None
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
# Heating MPC thermal model
|
||||
|
||||
This document describes the model used from version 0.4.0: what it computes, the
|
||||
units and meaning of every coefficient, when each part learns, what the forecasts
|
||||
assume, and how the model is validated. It is observational: nothing here controls
|
||||
the boiler.
|
||||
|
||||
## Time step and inputs
|
||||
|
||||
The model works in 5-minute steps, the polling interval. For each zone *i*, each
|
||||
poll provides:
|
||||
|
||||
| Symbol | Meaning | Unit | Source |
|
||||
|---|---|---|---|
|
||||
| T_i | Zone temperature: sensor-weighted mean of its usable sensors | °C | zone sensors |
|
||||
| T_out | Outdoor temperature (first fresh, valid source) | °C | weather sources |
|
||||
| lag | Radiator-lag proxy, 0 (cold) to 1 (fully warm); 20-minute first-order response to the relay, integrated at the relay's actual switch times | – | relay state + read-only listener |
|
||||
| q_i | Electrical heat above baseline after its thermal response | kW | heat sources |
|
||||
| O_i | Sum of open windows' opening factors, weighted by the share of the interval they were open | – | window sensors |
|
||||
| F_i | Same, each window also weighted by cos(angle between wind direction and the window's facing) | – | windows + wind direction |
|
||||
| v, g | Wind speed and gust speed | m/s | weather sources |
|
||||
| R_i | Recovery weight after the zone's last window closed: exp(−t / τ_rec), 0 below 0.05 | – | window sensors |
|
||||
| T_ref,i | Zone temperature at the poll before the window opened | °C | zone sensors |
|
||||
|
||||
## Equations
|
||||
|
||||
Per 5-minute step, the predicted change of zone *i* is the sum of layers:
|
||||
|
||||
```text
|
||||
ΔT_i = loss × (T_out − T_i) envelope heat loss
|
||||
+ heat × lag boiler, through the radiators
|
||||
+ bias constant gains and losses
|
||||
+ internal × q_i electrical heat above baseline (extended layer)
|
||||
+ Σ_j couple:j × (T_j − T_i) exchange with adjacent zones (extended layer)
|
||||
+ vent_natural × O_i (T_out − T_i) (ventilation layer)
|
||||
+ vent_wind × O_i v (T_out − T_i)
|
||||
+ vent_wind_dir × F_i v (T_out − T_i)
|
||||
+ vent_gust × O_i max(0, g − v) (T_out − T_i) (optional)
|
||||
+ vent_cross × O_i O_other (T_out − T_i) (optional)
|
||||
+ recovery × R_i (T_ref,i − T_i)
|
||||
```
|
||||
|
||||
All coefficients are in °C per 5 minutes per unit of their term. They are
|
||||
statistical estimates, not measured physical properties: `heat` is not boiler
|
||||
efficiency, and a relay that is on does not prove the burner is firing.
|
||||
|
||||
In conceptual continuous-time form this corresponds to
|
||||
|
||||
```text
|
||||
C_i dT_i/dt = UA_i (T_out − T_i) + Q_boiler + Q_internal + Q_ventilation + Σ_j K_ij (T_j − T_i) + Q_other
|
||||
```
|
||||
|
||||
divided through by the zone's effective heat capacity C_i and discretized to 5
|
||||
minutes. The model is first-order (one temperature per zone). Thermal inertia
|
||||
enters through the radiator lag, the electrical response filter and the recovery
|
||||
term; a separate wall-mass state is not modeled (see Limitations).
|
||||
|
||||
### Base layer
|
||||
|
||||
The base layer is the 3-parameter model of earlier versions (loss, heat, bias),
|
||||
learned per zone. It is always kept: it is the fallback whenever another layer's
|
||||
inputs are unknown, and the comparison that shows whether the other layers help.
|
||||
|
||||
### Extended layer
|
||||
|
||||
Present when a zone has an enabled heat source or an adjacent zone. It learns all
|
||||
base terms plus `internal` and `couple:<zone>` and replaces the base layer in
|
||||
forecasts once it is as trained as the base layer needs to be (200 samples, 30 with
|
||||
the relay on, 30 off). If any of its inputs is unknown, the base layer is used.
|
||||
|
||||
### Electrical heat
|
||||
|
||||
For each enabled source:
|
||||
|
||||
```text
|
||||
effective power = measured W − measured W of enabled sources included in it (≥ 0)
|
||||
baseline = 20th percentile of the last 24 h of effective power, or the manual value
|
||||
variable heat = heat fraction × (effective power − baseline) / 1000 (kW)
|
||||
thermal response : r ← r + (1 − exp(−5 min / τ)) × (variable heat held during the interval − r)
|
||||
```
|
||||
|
||||
q_i is the sum of the responses of the zone's sources. Centring on the baseline
|
||||
matters: the persistent load (for example a UPS drawing 200 W all day) is already
|
||||
part of the bias term, so feeding it again would count it twice. The baseline
|
||||
needs one hour of data; after startup or an outage a source's response must run
|
||||
for two time constants before it is used for learning.
|
||||
|
||||
Only active power counts (W, kW, mW). VA (apparent power) and % (UPS load) are
|
||||
rejected. A UPS output sensor does not include the UPS's own losses; if they matter,
|
||||
measure the input side or lower the heat fraction accordingly.
|
||||
|
||||
### Ventilation layer
|
||||
|
||||
Present when a zone has an enabled window. Learned separately, from the part of
|
||||
the measured change the closed-window layers cannot explain, only while a window is
|
||||
open or the zone is recovering, and only while every window state in the zone is
|
||||
known. While a window is open, possibly open (its sensor is unknown and it was last
|
||||
seen open) or the zone is recovering, the base and extended layers do **not**
|
||||
learn, so window events cannot inflate `loss` or shift `bias`.
|
||||
|
||||
An unknown window state is never read as closed, and an opening or closing counts
|
||||
as confirmed only when it was seen: the previous reading was the other state with
|
||||
no unknown period (or restart) in between. If a window closes while its sensor was
|
||||
unknown, the event's duration is reported as a range and the recovery term is not
|
||||
learned from it, because the closing time is not known. Last known states, open
|
||||
events and recovery are saved and restored across restarts, as unknown until the
|
||||
sensor reports again.
|
||||
|
||||
An input that a term needs is never filled in for learning or for Model Error:
|
||||
while a window is open, the wind, its direction (for `vent_wind_dir`) and the other
|
||||
zones' windows (for `vent_cross`) must be known. With every window closed, the
|
||||
opening terms are 0 and need no weather. Forecasts, which must produce a number,
|
||||
assume calm air for a missing wind input.
|
||||
|
||||
`vent_wind_dir` is bounded by `vent_wind` (|vent_wind_dir| ≤ vent_wind), so the
|
||||
combined wind term never goes negative: a leeward window still ventilates.
|
||||
|
||||
Wind direction is meteorological: the direction the wind comes **from**, degrees
|
||||
clockwise from north. A window's azimuth is the direction its outside faces. Wind
|
||||
from the azimuth blows straight at the window (cos = 1); wind from behind the
|
||||
building gives −1. Angle differences wrap around north correctly.
|
||||
|
||||
## Priors, bounds and confidence
|
||||
|
||||
| Coefficient | Prior | Bounds | Prior worth |
|
||||
|---|---|---|---|
|
||||
| loss | 0.002 | 0 … 0.04 | as in earlier versions |
|
||||
| heat | 0.045 | 0 … 0.25 | as in earlier versions |
|
||||
| bias | 0 | −0.06 … 0.06 | as in earlier versions |
|
||||
| internal | 0 | 0 … 0.5 | 20 samples at 0.4 kW |
|
||||
| couple:j | 0 | 0 … 0.05 | 200 samples at 1 °C difference |
|
||||
| vent_natural | 0 | 0 … 0.2 | 20 samples at opening 1, 15 °C difference |
|
||||
| vent_wind, vent_wind_dir | 0 | 0 … 0.03 / ±vent_wind | 20 samples at 4 m/s |
|
||||
| vent_gust | 0 | 0 … 0.03 | 20 samples |
|
||||
| vent_cross | 0 | 0 … 0.1 | 100 samples |
|
||||
| recovery | 0 | 0 … 0.5 | 20 samples at 0.5 °C deficit |
|
||||
|
||||
New effects start at 0: no effect is assumed until the data shows one. Learning is
|
||||
recursive least squares with exponential forgetting (about 3.5 days of samples),
|
||||
a covariance bound that stops windup during long periods without excitation, and
|
||||
outlier rejection above 0.8 °C per 5 minutes.
|
||||
|
||||
Each coefficient reports:
|
||||
|
||||
* `confidence`: the share of its information that comes from data rather than the
|
||||
prior (0 = prior only), from its posterior variance. It is not a sample count:
|
||||
it grows only while the data varies the coefficient's input independently of
|
||||
the others, and with forgetting it falls again during long periods without such
|
||||
variation. The base layer reports it too (`loss`, `heat`, `bias`);
|
||||
* `std`: its standard error, using a running estimate of the unexplained noise;
|
||||
* `samples`: how many accepted samples actually exercised it;
|
||||
* `identified`: at least 30 exercising samples and confidence ≥ 0.8.
|
||||
|
||||
The base layer has no per-coefficient sample counts or standard errors; its
|
||||
`confidence` alone shows identification. In the tests, two weeks of samples with
|
||||
the relay always off and a steady indoor-outdoor difference leave `heat` at 0 and
|
||||
`loss` and `bias` below 0.8, while four days with the relay cycling and the
|
||||
weather changing bring all three above 0.8.
|
||||
|
||||
A coefficient can move without being identified. Example from the tests: two rooms
|
||||
whose temperature difference stays fairly steady cannot separate exchange between
|
||||
them from each room's bias; the learner finds a positive coupling but correctly
|
||||
reports it as not identified.
|
||||
|
||||
## Learning conditions
|
||||
|
||||
A zone learns from the interval between two polls only when:
|
||||
|
||||
* the polls are 4.5–5.5 minutes apart, the relay held one state throughout and the
|
||||
radiator-lag estimate is known within 0.1 (for example ~32 minutes after an HA
|
||||
restart);
|
||||
* the outdoor source did not change and the zone used the same sensors at both
|
||||
polls;
|
||||
* for the extended layer, every source in the zone is settled and every adjacent
|
||||
zone was usable;
|
||||
* for the ventilation layer, every window state in the zone is known.
|
||||
|
||||
No experiments are run and the boiler is never switched to gather data: everything
|
||||
is learned passively.
|
||||
|
||||
## Configuration changes
|
||||
|
||||
Every learned component stores a fingerprint of the configuration it was learned
|
||||
under (`custom_components/heating_mpc/compatibility.py`). At startup, components
|
||||
whose fingerprint changed are reset; the rest are kept.
|
||||
|
||||
| Change | Effect |
|
||||
|---|---|
|
||||
| A zone's sensors, or the ratio of their weights | Zone recalibrated: coefficients kept as a starting point; uncertainty, counts and accuracy statistics reset. Its extended and ventilation layers start over too (their loss, heat and bias values kept as a starting point) |
|
||||
| Scaling all of a zone's weights equally | Nothing (the normalized measurement is unchanged) |
|
||||
| A heat source's sensor, heat share, baseline, response time or "included in" | `internal` of its zone; the source's baseline samples if its effective power changed |
|
||||
| A neighbour's sensors or weights | `couple:<neighbour>` |
|
||||
| A window's sensor, polarity or opening factor | that zone's ventilation terms; `vent_cross` of the other zones |
|
||||
| A window's facing direction | `vent_wind_dir` |
|
||||
| Recovery time constant | `recovery` |
|
||||
| Comfort weight, target offset, names | Nothing (not physical) |
|
||||
|
||||
Models saved before fingerprints existed (0.4.0) are assumed to match the
|
||||
configuration they are loaded with, and are fingerprinted at once. Every reset is
|
||||
logged in `model_changes` with its reason.
|
||||
|
||||
## Forecasts
|
||||
|
||||
Forecasts simulate all zones together, step by step, over the MPC horizon (60–180
|
||||
minutes):
|
||||
|
||||
* **Weather:** held at current values.
|
||||
* **Electrical heat:** the variable part fades towards baseline with a configurable
|
||||
half-life (default 30 minutes; 0 = stops now), and the room keeps following it with
|
||||
its response time. A gaming PC drawing 600 W now is not assumed to run on for two
|
||||
hours.
|
||||
* **Windows:** an open window is assumed to stay open for a configurable time
|
||||
(default 15 minutes), then close; the zone then recovers. A window whose sensor
|
||||
is unknown but was last seen open is assumed open only until that time after it
|
||||
was last seen, never renewed. One last seen closed, or never seen since setup, is
|
||||
assumed closed, and the forecast is flagged as uncertain (`uncertain_windows`).
|
||||
* **Relay:** the candidate plan being evaluated.
|
||||
|
||||
For the plan search the step is compiled into exact per-zone coefficients (every
|
||||
term is linear in the temperatures and the lag), which keeps the 180-minute search
|
||||
fast on a Raspberry Pi.
|
||||
|
||||
Plan forecasts are **counterfactual** while Generic Thermostat controls the relay:
|
||||
they describe what would happen under the MPC's proposed plan.
|
||||
|
||||
## Validation
|
||||
|
||||
Two separate measurements, because they answer different questions.
|
||||
|
||||
**Model accuracy (Model Error sensors)** scores the model, not a forecast. At
|
||||
every poll the model parameters are frozen; 15, 30, 60 and 120 minutes later all
|
||||
zones are replayed from that poll using the relay, electrical heat and window
|
||||
history that actually followed (outdoor temperature held at its start value),
|
||||
and compared with the readings. The base layer and a no-change forecast are
|
||||
scored on exactly the same spans:
|
||||
|
||||
* `mae`, `rmse`, `bias`: the full model's error;
|
||||
* `persistence_mae`, `skill`: against assuming no change;
|
||||
* `base_model_mae`, `improvement_vs_base`: against the base layer alone. A feature
|
||||
that does not improve this should be switched off (for example the gust term).
|
||||
|
||||
A span counts only if every input the zone's model used was observed at every
|
||||
step (see *Ventilation layer*; for the extended layer also electrical heat and
|
||||
neighbours, whose own replay must be complete). Otherwise it is counted as
|
||||
`incomplete`, with the base model's error on it kept apart
|
||||
(`incomplete_base_model_mae`), since the base model needs only the relay and the
|
||||
outdoor temperature. Spans that cannot be replayed (uncertain radiator lag, no
|
||||
reading at the end) are counted as `skipped`.
|
||||
|
||||
**Forecast accuracy (Forecast Error sensor)** scores forecasts as published: the
|
||||
start readings, trajectory, plan, model revision and assumptions of the moment
|
||||
they were made, nothing replaced with hindsight. Because the plan is the MPC's
|
||||
proposal while Generic Thermostat controls the relay, a forecast is compared only
|
||||
when the relay followed it. The deviation is the time, up to the horizon, the
|
||||
relay spent in another state than planned, compared block by block from the
|
||||
relay's own switch times; unobserved relay time counts as possible deviation.
|
||||
|
||||
| Deviation (worst case) | Class | Used for |
|
||||
|---|---|---|
|
||||
| ≤ 2 min | matched | Forecast Error figures |
|
||||
| ≤ 5 min, ≤ 2 min unobserved | approximate | `approximate_stats` only |
|
||||
| > 5 min observed | counterfactual | counted, never an error |
|
||||
| otherwise | incomplete | counted, with a reason |
|
||||
|
||||
Forecasts made with an assumed input (an unknown window or electrical heat) are
|
||||
incomplete as well. The limits follow from the model: d seconds of unplanned
|
||||
boiler heat change a room by about heat × d / 300 with the learned `heat`
|
||||
coefficient (typically 0.05–0.12 °C per 5 minutes), so 2 minutes stay below half a
|
||||
0.1 °C sensor step and 5 minutes are about one step.
|
||||
|
||||
Both report how much evidence there is. Forecasts start every 5 minutes, so at
|
||||
horizon h consecutive forecasts share most of their span, and all zones share one
|
||||
boiler: `effective_samples = samples × 5 / h / zones`. Below 5 the quality is
|
||||
`insufficient` and the sensor's state stays unknown; below 30 it is
|
||||
`indicative`.
|
||||
|
||||
Results on synthetic apartments (the tests; these check the method, not real-world
|
||||
accuracy):
|
||||
|
||||
| Scenario | 60-min error, full | base | no change |
|
||||
|---|---|---|---|
|
||||
| Office, UPS 200 W + 400 W gaming sessions, thermostat cycling | 0.031 °C | 0.182 °C | 0.40 °C |
|
||||
| Bedroom, 15–20 min ventilation every 4 h, wind and recovery | 0.026 °C | 0.218 °C | 0.52 °C |
|
||||
| Two adjacent rooms, true exchange 0.01 | 0.024 °C | 0.037 °C | 0.29 °C |
|
||||
|
||||
In the ventilation scenario, the same data without the window configured produced
|
||||
a −0.013 °C/5 min cooling bias and an 11 % inflated boiler coefficient, which is
|
||||
the contamination the separate ventilation layer prevents.
|
||||
|
||||
## Limitations
|
||||
|
||||
* One temperature per zone: walls and air are not separate states. The recovery
|
||||
term approximates the air warming back after ventilation.
|
||||
* Response times (electrical heat, recovery) are configured, not learned. Compare
|
||||
settings with `improvement_vs_base`.
|
||||
* Coupling between zones heated by one boiler is weakly identifiable; it is shrunk
|
||||
towards 0 and usually reported as not identified.
|
||||
* Natural and wind ventilation terms both scale with the opening; with little wind
|
||||
variation their split is uncertain even when their combined effect is accurate.
|
||||
* Forecasts hold weather constant and use one scenario for electrical heat and
|
||||
windows; no forecast uncertainty band is computed yet.
|
||||
* Window sensors with a permanently unknown state keep their zone out of learning
|
||||
and its Model Error spans incomplete; disable such a window.
|
||||
* A window that is unknown and was last seen closed (or never seen) is assumed
|
||||
closed in forecasts, flagged as uncertain; no open/closed scenario pair is
|
||||
computed.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Newest published fixtures: Home Assistant 2026.2.3; requires Python 3.13.
|
||||
# Update together with the README's tested-versions note.
|
||||
pytest-homeassistant-custom-component==0.13.316
|
||||
# Bug-finding lint (see ruff.toml).
|
||||
ruff==0.14.0
|
||||
@@ -2,3 +2,5 @@
|
||||
pytest-homeassistant-custom-component==0.13.298
|
||||
# aiodns 3.5 (HA's pin) uses APIs removed in pycares 5.
|
||||
pycares==4.11.0
|
||||
# Bug-finding lint (see ruff.toml).
|
||||
ruff==0.14.0
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bug-finding lint only (pyflakes, syntax errors, bugbear); no style rules.
|
||||
# py39: the offline tests also run on older Pythons, so e.g. zip(strict=) is not available.
|
||||
target-version = "py39"
|
||||
|
||||
[lint]
|
||||
select = ["F", "E9", "B"]
|
||||
@@ -4,6 +4,7 @@ set -eu
|
||||
# A transitive HA test dependency otherwise fetches metadata on import.
|
||||
export LITELLM_LOCAL_MODEL_COST_MAP=True
|
||||
python -m pip check
|
||||
python -m ruff check --no-cache .
|
||||
python -m unittest discover -s tests -v
|
||||
python -m pytest tests/ha -q
|
||||
python -m compileall -q custom_components/heating_mpc
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Render the integration's brand icons (radiator with a forecast curve).
|
||||
|
||||
Home Assistant 2026.3+ shows custom_components/<domain>/brand/ images in
|
||||
Settings -> Devices & services. Development-only: needs Pillow, which the HA test
|
||||
requirements already install. Run: python scripts/make_brand_icons.py
|
||||
"""
|
||||
from math import hypot
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
OUT = Path(__file__).resolve().parents[1] / "custom_components" / "heating_mpc" / "brand"
|
||||
DESIGN = 512 # coordinates below are in a 512x512 design space
|
||||
SUPERSAMPLE = 8
|
||||
THEMES = {
|
||||
"": {"radiator": "#F4511E", "forecast": "#1E88E5"}, # for light backgrounds
|
||||
"dark_": {"radiator": "#FF7043", "forecast": "#64B5F6"}, # for dark backgrounds
|
||||
}
|
||||
|
||||
|
||||
def bezier(p0, p1, p2, p3, steps=400):
|
||||
points = []
|
||||
for i in range(steps + 1):
|
||||
t = i / steps
|
||||
u = 1 - t
|
||||
points.append(tuple(u**3 * a + 3 * u * u * t * b + 3 * u * t * t * c + t**3 * d
|
||||
for a, b, c, d in zip(p0, p1, p2, p3)))
|
||||
return points
|
||||
|
||||
|
||||
def stroke(draw, points, width, color):
|
||||
"""Smooth round-capped stroke: overlapping discs along the path.
|
||||
|
||||
Pillow's wide polylines show seams between many short segments.
|
||||
"""
|
||||
r = width / 2
|
||||
for (x0, y0), (x1, y1) in zip(points, points[1:]):
|
||||
steps = max(1, int(hypot(x1 - x0, y1 - y0) / (r / 8)))
|
||||
for i in range(steps + 1):
|
||||
x, y = x0 + (x1 - x0) * i / steps, y0 + (y1 - y0) * i / steps
|
||||
draw.ellipse((x - r, y - r, x + r, y + r), fill=color)
|
||||
|
||||
|
||||
def split_dashes(points, dash, gap):
|
||||
"""Cut a polyline into dash polylines of the given arc lengths."""
|
||||
dashes, current, travelled, drawing = [], [points[0]], 0.0, True
|
||||
for (x0, y0), (x1, y1) in zip(points, points[1:]):
|
||||
travelled += hypot(x1 - x0, y1 - y0)
|
||||
if drawing:
|
||||
current.append((x1, y1))
|
||||
limit = dash if drawing else gap
|
||||
if travelled >= limit:
|
||||
if drawing and len(current) > 1:
|
||||
dashes.append(current)
|
||||
current, travelled, drawing = [(x1, y1)], 0.0, not drawing
|
||||
if drawing and len(current) > 1:
|
||||
dashes.append(current)
|
||||
return dashes
|
||||
|
||||
|
||||
def render(colors):
|
||||
s = SUPERSAMPLE
|
||||
image = Image.new("RGBA", (DESIGN * s, DESIGN * s), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
scale = lambda pts: [(x * s, y * s) for x, y in pts]
|
||||
|
||||
# Radiator: five rounded columns joined by two headers.
|
||||
fins, left, right, top, bottom = 5, 12, 500, 250, 500
|
||||
gap = 22
|
||||
width = (right - left - gap * (fins - 1)) / fins
|
||||
for i in range(fins):
|
||||
x0 = left + i * (width + gap)
|
||||
draw.rounded_rectangle((x0 * s, top * s, (x0 + width) * s, bottom * s),
|
||||
radius=width / 2 * s, fill=colors["radiator"])
|
||||
for y in (296, 420):
|
||||
draw.rectangle(((left + width / 2) * s, y * s, (right - width / 2) * s, (y + 34) * s),
|
||||
fill=colors["radiator"])
|
||||
|
||||
# Forecast: measured past (solid), "now" marker, predicted future (dashed).
|
||||
line = 40
|
||||
curve = bezier((32, 192), (170, 192), (300, 146), (480, 32))
|
||||
now = next(i for i, (x, _) in enumerate(curve) if x >= 220)
|
||||
stroke(draw, scale(curve[:now + 1]), line * s, colors["forecast"])
|
||||
for dash in split_dashes(curve[now:], 64, 44)[1:]:
|
||||
stroke(draw, scale(dash), line * s, colors["forecast"])
|
||||
x, y = curve[now]
|
||||
radius = 40
|
||||
draw.ellipse(((x - radius) * s, (y - radius) * s, (x + radius) * s, (y + radius) * s),
|
||||
fill=colors["forecast"])
|
||||
return image
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(exist_ok=True)
|
||||
for prefix, colors in THEMES.items():
|
||||
image = render(colors)
|
||||
for suffix, size in (("", 256), ("@2x", 512)):
|
||||
path = OUT / f"{prefix}icon{suffix}.png"
|
||||
image.resize((size, size), Image.LANCZOS).save(path, optimize=True)
|
||||
print(f"wrote {path.relative_to(OUT.parents[2])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Small local packaging and translation checks; not a replacement for hassfest."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
@@ -11,6 +12,9 @@ assert manifest["config_flow"] is True
|
||||
assert manifest["iot_class"] == "local_polling"
|
||||
assert not manifest["requirements"], "Model inference should remain dependency-free"
|
||||
json.loads((root / "hacs.json").read_text())
|
||||
# A version bump needs release notes (AGENTS.md: no version without documentation).
|
||||
assert f"## {manifest['version']} " in (root / "CHANGELOG.md").read_text(), \
|
||||
f"CHANGELOG.md has no entry for {manifest['version']}"
|
||||
|
||||
|
||||
def keys(value, prefix=()):
|
||||
@@ -23,6 +27,14 @@ strings = json.loads((component / "strings.json").read_text())
|
||||
for language in ("en", "ro"):
|
||||
translated = json.loads((component / "translations" / f"{language}.json").read_text())
|
||||
assert keys(strings) == keys(translated), f"Translation keys differ: {language}"
|
||||
# Brand icons (HA 2026.3+): square transparent PNGs; see scripts/make_brand_icons.py.
|
||||
for name, size in (("icon.png", 256), ("icon@2x.png", 512),
|
||||
("dark_icon.png", 256), ("dark_icon@2x.png", 512)):
|
||||
png = (component / "brand" / name).read_bytes()
|
||||
assert png[:8] == b"\x89PNG\r\n\x1a\n" and png[12:16] == b"IHDR", f"{name} is not a PNG"
|
||||
width, height, _depth, color_type = struct.unpack(">IIBB", png[16:26])
|
||||
assert (width, height) == (size, size), f"{name} is {width}x{height}, expected {size}x{size}"
|
||||
assert color_type == 6, f"{name} needs an alpha channel"
|
||||
tracked = subprocess.check_output(["git", "ls-files", "custom_components"], cwd=root, text=True).splitlines()
|
||||
assert not any("__pycache__" in path or path.endswith((".pyc", ".pyo")) for path in tracked)
|
||||
print("Package metadata, translations and tracked component files passed")
|
||||
assert not any("__pycache__" in path or path.endswith((".pyc", ".pyo", ".DS_Store")) for path in tracked)
|
||||
print("Package metadata, translations, brand icons and tracked component files passed")
|
||||
@@ -3,6 +3,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.core import ServiceRegistry
|
||||
from homeassistant.util import dt as dt_util
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
|
||||
@@ -36,5 +37,6 @@ def entry():
|
||||
def inputs(hass):
|
||||
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C"})
|
||||
hass.states.async_set("sensor.outside", "5", {"unit_of_measurement": "°C"})
|
||||
hass.states.async_set("switch.boiler", "off")
|
||||
# The relay has held its state for an hour, so the radiator-lag estimate is settled.
|
||||
hass.states.async_set("switch.boiler", "off", timestamp=dt_util.utcnow().timestamp() - 3600)
|
||||
hass.states.async_set("climate.reference", "heat", {"temperature": 22, "preset_mode": "comfort"})
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Unload, reload and removal while work is still in flight, in the real HA lifecycle."""
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util import dt as dt_util
|
||||
from pytest_homeassistant_custom_component.common import async_fire_time_changed
|
||||
|
||||
from custom_components.heating_mpc import coordinator as coordinator_module
|
||||
from custom_components.heating_mpc.const import DOMAIN
|
||||
|
||||
REAL_OPTIMIZE = coordinator_module.optimize
|
||||
|
||||
|
||||
async def setup(hass, entry):
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
|
||||
def trained(coordinator):
|
||||
model = coordinator.learners["sensor.room"]
|
||||
model.count, model.on_count, model.off_count = 250, 125, 125
|
||||
coordinator._update_counts()
|
||||
|
||||
|
||||
class BlockedOptimizer:
|
||||
"""Holds the optimizer in its worker thread until released."""
|
||||
|
||||
def __init__(self):
|
||||
self.started, self.release = threading.Event(), threading.Event()
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
self.started.set()
|
||||
self.release.wait(5)
|
||||
return REAL_OPTIMIZE(*args, **kwargs)
|
||||
|
||||
async def wait_started(self):
|
||||
# Polled on the event loop: the test executor may run one job at a time.
|
||||
for _ in range(500):
|
||||
if self.started.is_set():
|
||||
return True
|
||||
await asyncio.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
def status_id(hass, entry):
|
||||
return er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status")
|
||||
|
||||
|
||||
async def test_unload_during_optimization_publishes_nothing(hass, entry, inputs):
|
||||
coordinator = await setup(hass, entry)
|
||||
trained(coordinator)
|
||||
shown = coordinator.data
|
||||
blocked = BlockedOptimizer()
|
||||
with patch.object(coordinator_module, "optimize", blocked):
|
||||
refresh = hass.async_create_task(coordinator.async_refresh())
|
||||
assert await blocked.wait_started()
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
state_after_unload = hass.states.get(status_id(hass, entry))
|
||||
blocked.release.set()
|
||||
await refresh
|
||||
await hass.async_block_till_done()
|
||||
# The plan finished after unloading began: kept out of the data and the states.
|
||||
assert coordinator.data is shown
|
||||
assert coordinator.data["status"] == "learning"
|
||||
assert hass.states.get(status_id(hass, entry)) == state_after_unload
|
||||
|
||||
|
||||
async def test_reload_during_optimization_shows_only_the_new_coordinator(hass, entry, inputs):
|
||||
old = await setup(hass, entry)
|
||||
trained(old) # only in memory: the reloaded coordinator is not ready
|
||||
blocked = BlockedOptimizer()
|
||||
with patch.object(coordinator_module, "optimize", blocked):
|
||||
refresh = hass.async_create_task(old.async_refresh())
|
||||
assert await blocked.wait_started()
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
new = hass.data[DOMAIN][entry.entry_id]
|
||||
blocked.release.set()
|
||||
await refresh
|
||||
await hass.async_block_till_done()
|
||||
assert new is not old and old.closing
|
||||
assert old.data["status"] != "ready"
|
||||
assert hass.states.get(status_id(hass, entry)).state == new.data["status"] == "learning"
|
||||
|
||||
|
||||
async def test_removal_during_a_save_leaves_no_model_behind(hass, entry, inputs, freezer):
|
||||
coordinator = await setup(hass, entry)
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
write = coordinator._store.async_save
|
||||
|
||||
async def slow_save(data):
|
||||
started.set()
|
||||
await release.wait()
|
||||
await write(data)
|
||||
coordinator._store.async_save = slow_save
|
||||
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
|
||||
freezer.move_to(future)
|
||||
async_fire_time_changed(hass, future) # a learning poll, which saves
|
||||
await asyncio.wait_for(started.wait(), 5)
|
||||
removal = hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
assert not removal.done() # deletion waits for the save in progress
|
||||
release.set()
|
||||
assert await removal
|
||||
await hass.async_block_till_done()
|
||||
assert await Store(hass, 1, f"{DOMAIN}.{entry.entry_id}").async_load() is None
|
||||
|
||||
|
||||
async def test_repeated_setup_and_unload_leave_nothing_running(hass, entry, inputs, freezer):
|
||||
entry.add_to_hass(hass)
|
||||
coordinators = []
|
||||
for _ in range(3):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinators.append(hass.data[DOMAIN][entry.entry_id])
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.entry_id not in hass.data[DOMAIN]
|
||||
assert all(c.closing and c._unsub_refresh is None and not c._listeners for c in coordinators)
|
||||
counts = [c.learners["sensor.room"].count for c in coordinators]
|
||||
hass.states.async_set("switch.boiler", "on")
|
||||
future = dt_util.utcnow() + timedelta(minutes=10)
|
||||
freezer.move_to(future)
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
assert not any(c._relay_events for c in coordinators)
|
||||
assert [c.learners["sensor.room"].count for c in coordinators] == counts
|
||||
+278
-24
@@ -1,19 +1,83 @@
|
||||
"""Validate actual HA form schemas, options and entry migration."""
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
"""Validate actual HA form schemas, options, zone subentries and entry migration."""
|
||||
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.heating_mpc.const import DOMAIN
|
||||
from custom_components.heating_mpc.weather_sources import sources_for
|
||||
|
||||
MAIN = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler"}
|
||||
|
||||
|
||||
async def test_config_flow(hass, inputs):
|
||||
def zones_of(entry):
|
||||
return {sub.unique_id: sub for sub in entry.subentries.values() if sub.subentry_type == "zone"}
|
||||
|
||||
|
||||
async def setup_entry(hass, entry):
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
|
||||
async def open_settings(hass, entry):
|
||||
"""Configure opens a menu; Settings is the first choice."""
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
assert result["menu_options"] == ["settings", "reset_model"]
|
||||
return await hass.config_entries.options.async_configure(result["flow_id"], {"next_step_id": "settings"})
|
||||
|
||||
|
||||
async def open_reset(hass, entry):
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
return await hass.config_entries.options.async_configure(result["flow_id"], {"next_step_id": "reset_model"})
|
||||
|
||||
|
||||
async def test_reset_learned_model_keeps_configuration_and_observations(hass, entry, inputs, hass_storage):
|
||||
coordinator = await setup_entry(hass, entry)
|
||||
model = coordinator.learners["sensor.room"]
|
||||
model.count, model.on_count, model.off_count = 250, 125, 125
|
||||
payload = coordinator._payload("sensor.outside")
|
||||
payload["retired_learners"] = {"sensor.gone": {**model.as_dict()}}
|
||||
payload["ventilation"] = {"events": [{"zone": "sensor.room", "opened": "2026-10-27T10:00:00+00:00"}]}
|
||||
payload["heat_sources"] = {"samples": {"ups": [[1.0, 200.0]]}}
|
||||
await coordinator._store.async_save(payload)
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "ready"
|
||||
before = (dict(entry.data), dict(entry.options), dict(entry.subentries))
|
||||
# Not confirmed: nothing happens.
|
||||
result = await open_reset(hass, entry)
|
||||
assert (result["type"], result["step_id"]) == (FlowResultType.FORM, "reset_model")
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], {"confirm_reset": False})
|
||||
assert (result["type"], result["reason"]) == (FlowResultType.ABORT, "model_not_reset")
|
||||
assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250
|
||||
# Confirmed: a fresh model, the same configuration.
|
||||
result = await open_reset(hass, entry)
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], {"confirm_reset": True})
|
||||
assert (result["type"], result["reason"]) == (FlowResultType.ABORT, "model_reset")
|
||||
await hass.async_block_till_done()
|
||||
fresh = hass.data[DOMAIN][entry.entry_id]
|
||||
assert fresh is not coordinator and fresh.learners["sensor.room"].count == 0
|
||||
assert fresh.data["status"] == "learning" and not fresh._retired
|
||||
assert (dict(entry.data), dict(entry.options), dict(entry.subentries)) == before
|
||||
stored = hass_storage[f"{DOMAIN}.{entry.entry_id}"]["data"]
|
||||
assert not {"learners", "retired_learners", "evaluation", "operational_evaluation", "fingerprints"} & set(stored)
|
||||
assert stored["ventilation"]["events"][0]["opened"] == "2026-10-27T10:00:00+00:00"
|
||||
assert stored["heat_sources"] == {"samples": {"ups": [[1.0, 200.0]]}}
|
||||
assert stored["model_changes"][-1]["reasons"] == ["learned model reset by the user"]
|
||||
assert fresh.data["model_changes"][-1]["recalibrated"] == ["all"]
|
||||
|
||||
|
||||
async def test_config_flow_creates_one_zone_per_sensor(hass, inputs):
|
||||
hass.states.async_set("sensor.bedroom", "19", {"unit_of_measurement": "°C", "friendly_name": "Bedroom"})
|
||||
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
main = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
||||
"room_entities": ["sensor.room"]}
|
||||
validated = result["data_schema"](main)
|
||||
validated = result["data_schema"]({**MAIN, "room_entities": ["sensor.room", "sensor.bedroom"]})
|
||||
assert validated["room_max_age_minutes"] == 30
|
||||
assert validated["weather_max_age_minutes"] == 120
|
||||
assert validated["room_stale_use_last_value"] is False
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], validated)
|
||||
assert result["step_id"] == "weather"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
@@ -21,38 +85,228 @@ async def test_config_flow(hass, inputs):
|
||||
assert result["step_id"] == "weather_extra"
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], result["data_schema"]({}))
|
||||
assert result["step_id"] == "weights"
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {"weight__sensor.room": 2})
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"weight__sensor.room": 2, "weight__sensor.bedroom": 1})
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"]["weights"] == {"sensor.room": 2}
|
||||
await hass.async_block_till_done()
|
||||
entry = result["result"]
|
||||
assert entry.version == 3
|
||||
assert "room_entities" not in entry.data and "weights" not in entry.data
|
||||
zones = zones_of(entry)
|
||||
assert {key: (sub.title, sub.data["comfort_weight"]) for key, sub in zones.items()} == {
|
||||
"sensor.room": ("room", 2.0), "sensor.bedroom": ("Bedroom", 1.0)} # HA's name fallback
|
||||
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.room", "sensor.bedroom"]
|
||||
|
||||
|
||||
async def test_options_flow_keeps_settings(hass, entry, inputs):
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
main = result["data_schema"]({"climate_entity": "climate.reference",
|
||||
"heater_entity": "switch.boiler", "room_entities": ["sensor.room"],
|
||||
"room_max_age_minutes": 45})
|
||||
async def test_options_flow_keeps_settings_without_rooms(hass, entry, inputs):
|
||||
await setup_entry(hass, entry)
|
||||
result = await open_settings(hass, entry)
|
||||
assert "room_entities" not in {str(key) for key in result["data_schema"].schema}
|
||||
main = result["data_schema"]({**MAIN, "room_max_age_minutes": 45, "room_stale_use_last_value": True})
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], main)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]}))
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"]({}))
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], {"weight__sensor.room": 3})
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
await hass.async_block_till_done()
|
||||
assert entry.options["room_max_age_minutes"] == 45
|
||||
assert hass.data[DOMAIN][entry.entry_id].config["room_max_age_minutes"] == 45
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.config["room_max_age_minutes"] == 45
|
||||
assert coordinator.config["room_stale_use_last_value"] is True
|
||||
assert coordinator.rooms == ["sensor.room"] # zones are untouched by Configure
|
||||
result = await open_settings(hass, entry)
|
||||
assert result["data_schema"](MAIN)["room_stale_use_last_value"] is True
|
||||
hass.config_entries.options.async_abort(result["flow_id"])
|
||||
|
||||
|
||||
async def test_v1_entry_migrates_on_setup(hass, inputs):
|
||||
async def test_weather_source_may_not_be_a_zone_sensor(hass, entry, inputs):
|
||||
await setup_entry(hass, entry)
|
||||
result = await open_settings(hass, entry)
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"](MAIN))
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.room"]}))
|
||||
assert result["errors"] == {"base": "invalid_weather"}
|
||||
|
||||
|
||||
async def test_v1_entry_migrates_to_zones_on_setup(hass, inputs):
|
||||
entry = MockConfigEntry(domain=DOMAIN, version=1, data={
|
||||
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
||||
"room_entities": ["sensor.room"], "outside_entity": "sensor.outside"})
|
||||
**MAIN, "room_entities": ["sensor.room"], "outside_entity": "sensor.outside"})
|
||||
await setup_entry(hass, entry)
|
||||
assert entry.version == 3
|
||||
assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
|
||||
assert "outside_entity" not in entry.data and "room_entities" not in entry.data
|
||||
assert set(zones_of(entry)) == {"sensor.room"}
|
||||
|
||||
|
||||
async def test_v2_migration_keeps_weights_models_and_entities(hass, entry, inputs, hass_storage):
|
||||
from custom_components.heating_mpc.physics import Learner
|
||||
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C", "friendly_name": "Living room"})
|
||||
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()}}}
|
||||
entry.add_to_hass(hass)
|
||||
# A forecast entity created by 0.3 for this room.
|
||||
registry = er.async_get(hass)
|
||||
old = registry.async_get_or_create("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min",
|
||||
config_entry=entry, suggested_object_id="mpc_living_forecast")
|
||||
hass.config_entries.async_update_entry(entry, options={"weights": {"sensor.room": 4.0}})
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.version == 2
|
||||
assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
|
||||
assert "outside_entity" not in entry.data
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
zone = zones_of(entry)["sensor.room"]
|
||||
assert (zone.title, zone.data["comfort_weight"], zone.data["sensors"]) == ("Living room", 4.0, ["sensor.room"])
|
||||
assert "weights" not in entry.options
|
||||
assert coordinator.learners["sensor.room"].count == 250
|
||||
# Same entity, now linked to its zone, so deleting the zone removes it.
|
||||
migrated = registry.async_get(old.entity_id)
|
||||
assert migrated.config_subentry_id == zone.subentry_id
|
||||
assert hass.config_entries.async_remove_subentry(entry, zone.subentry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get(old.entity_id) is None
|
||||
|
||||
|
||||
async def test_zone_subentry_add_edit_and_validation(hass, entry, inputs):
|
||||
await setup_entry(hass, entry)
|
||||
for entity, value in (("sensor.living_a", "21"), ("sensor.living_b", "22")):
|
||||
hass.states.async_set(entity, value, {"unit_of_measurement": "°C"})
|
||||
flows = hass.config_entries.subentries
|
||||
result = await flows.async_init((entry.entry_id, "zone"), context={"source": SOURCE_USER})
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
# A sensor of another zone, or an outdoor source, is refused.
|
||||
for sensors, error in ((["sensor.room"], "sensor_in_other_zone"), (["sensor.outside"], "sensor_is_weather_source")):
|
||||
result = await flows.async_configure(result["flow_id"], {
|
||||
"name": "Living", "sensors": sensors, "comfort_weight": 2, "target_offset": 0})
|
||||
assert result["errors"] == {"base": error}
|
||||
result = await flows.async_configure(result["flow_id"], {
|
||||
"name": " Living ", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 2,
|
||||
"target_offset": 0.5, "neighbors": ["sensor.room"]})
|
||||
assert result["step_id"] == "sensor_weights"
|
||||
# Numbered, translated fields; the description says which sensor is which.
|
||||
assert [str(key) for key in result["data_schema"].schema] == ["sensor_weight_1", "sensor_weight_2"]
|
||||
assert result["description_placeholders"]["sensors"].splitlines() == [
|
||||
"1. living a (sensor.living_a)", "2. living b (sensor.living_b)"] # HA name fallback
|
||||
result = await flows.async_configure(result["flow_id"], {"sensor_weight_1": 3, "sensor_weight_2": 1})
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
await hass.async_block_till_done()
|
||||
living = next(sub for sub in zones_of(entry).values() if sub.title == "Living")
|
||||
assert living.data["sensor_weights"] == {"sensor.living_a": 3.0, "sensor.living_b": 1.0}
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
key = living.data["key"]
|
||||
assert coordinator.zones[key].neighbors == ("sensor.room",)
|
||||
assert coordinator.zones["sensor.room"].neighbors == (key,) # symmetric
|
||||
assert coordinator.data["temperatures"][key] == 21.25
|
||||
# Edit: drop a sensor; the key (and so the learned model) is kept.
|
||||
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"], "comfort_weight": 1, "target_offset": 0})
|
||||
assert result["type"] is FlowResultType.ABORT and result["reason"] == "reconfigure_successful"
|
||||
# Editing a multi-sensor zone shows the saved sensor weights.
|
||||
result = await flows.async_init((entry.entry_id, "zone"), context={
|
||||
"source": SOURCE_RECONFIGURE, "subentry_id": living.subentry_id})
|
||||
result = await flows.async_configure(result["flow_id"], {
|
||||
"name": "Living", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 1, "target_offset": 0})
|
||||
assert result["data_schema"]({})["sensor_weight_1"] == 1.0
|
||||
flows.async_abort(result["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
assert entry.subentries[living.subentry_id].data["key"] == key
|
||||
assert hass.data[DOMAIN][entry.entry_id].zones[key].sensors == ("sensor.living_a",)
|
||||
|
||||
|
||||
async def test_options_flow_clears_optional_weather_sources(hass, inputs):
|
||||
"""Swapping providers in the UI must allow removing an optional source list."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, version=2, unique_id="home_heating", data={
|
||||
**MAIN, "room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"],
|
||||
"outside_wind_sources": ["sensor.wind"], "outside_pressure_sources": ["sensor.pressure"],
|
||||
"weights": {"sensor.room": 1.0}})
|
||||
await setup_entry(hass, entry)
|
||||
flow = hass.config_entries.options
|
||||
result = await open_settings(hass, entry)
|
||||
result = await flow.async_configure(result["flow_id"], result["data_schema"](MAIN))
|
||||
markers = {str(key): key for key in result["data_schema"].schema}
|
||||
assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]}
|
||||
# The user removes the wind provider; a cleared field may be omitted on submit.
|
||||
result = await flow.async_configure(result["flow_id"], result["data_schema"](
|
||||
{"outside_temperature_sources": ["sensor.outside"]}))
|
||||
result = await flow.async_configure(result["flow_id"], result["data_schema"](
|
||||
{"outside_pressure_sources": ["sensor.pressure"]}))
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
await hass.async_block_till_done()
|
||||
assert entry.options["outside_wind_sources"] == []
|
||||
assert entry.options["outside_pressure_sources"] == ["sensor.pressure"]
|
||||
config = hass.data[DOMAIN][entry.entry_id].config
|
||||
assert sources_for(config, "wind") == []
|
||||
assert sources_for(config, "pressure") == ["sensor.pressure"]
|
||||
|
||||
|
||||
async def test_heat_source_subentry_flow(hass, entry, inputs):
|
||||
await setup_entry(hass, entry)
|
||||
for sensor, value in (("sensor.ups_power", "600"), ("sensor.pc_power", "400")):
|
||||
hass.states.async_set(sensor, value, {"unit_of_measurement": "W", "device_class": "power"})
|
||||
flows = hass.config_entries.subentries
|
||||
base = {"zone": "sensor.room", "heat_fraction": 1, "response_minutes": 15, "enabled": True}
|
||||
result = await flows.async_init((entry.entry_id, "heat_source"), context={"source": SOURCE_USER})
|
||||
result = await flows.async_configure(result["flow_id"], {
|
||||
**base, "name": "UPS", "sensor": "sensor.ups_power", "baseline_mode": "auto"})
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
await hass.async_block_till_done()
|
||||
ups = next(sub for sub in entry.subentries.values() if sub.subentry_type == "heat_source")
|
||||
# The PC is behind the UPS: its power is already in the UPS reading.
|
||||
result = await flows.async_init((entry.entry_id, "heat_source"), context={"source": SOURCE_USER})
|
||||
pc = {**base, "name": "Gaming PC", "sensor": "sensor.pc_power", "baseline_mode": "manual",
|
||||
"included_in": ups.unique_id, "response_minutes": 10}
|
||||
result = await flows.async_configure(result["flow_id"], pc)
|
||||
assert result["errors"] == {"base": "baseline_required"}
|
||||
result = await flows.async_configure(result["flow_id"], {**pc, "baseline_w": 0})
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
await hass.async_block_till_done()
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert [(s.name, s.included_in) for s in coordinator.sources] == [("UPS", None), ("Gaming PC", ups.unique_id)]
|
||||
assert coordinator.extended["sensor.room"].features == ["loss", "heat", "bias", "internal"]
|
||||
registry = er.async_get(hass)
|
||||
status = hass.states.get(registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
|
||||
sources = status.attributes["heat_sources"]
|
||||
assert (sources["UPS"]["power_w"], sources["UPS"]["effective_w"]) == (600, 200) # PC not counted twice
|
||||
assert (sources["Gaming PC"]["included_in"], sources["Gaming PC"]["variable_w"]) == ("UPS", 400)
|
||||
|
||||
|
||||
async def test_window_subentry_flow_and_ventilation_options(hass, entry, inputs):
|
||||
await setup_entry(hass, entry)
|
||||
hass.states.async_set("binary_sensor.bedroom_window", "off", {"device_class": "window"})
|
||||
flows = hass.config_entries.subentries
|
||||
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
|
||||
form = result["data_schema"]({"name": "Bedroom window", "sensor": "binary_sensor.bedroom_window",
|
||||
"zone": "sensor.room"})
|
||||
assert (form["azimuth"], form["opening_factor"], form["inverted"]) == (345.0, 1.0, False)
|
||||
result = await flows.async_configure(result["flow_id"], {**form, "azimuth": 340})
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
await hass.async_block_till_done()
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert [(w.name, w.azimuth) for w in coordinator.windows] == [("Bedroom window", 340.0)]
|
||||
# No wind sources yet: only the terms whose inputs exist.
|
||||
assert coordinator.ventilation["sensor.room"].features == ["vent_natural", "recovery"]
|
||||
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
|
||||
result = await flows.async_configure(result["flow_id"], {**form, "name": "Again"})
|
||||
assert result["errors"] == {"base": "sensor_in_other_window"}
|
||||
flows.async_abort(result["flow_id"])
|
||||
# Wind, direction and gust sources plus the gust option add their terms.
|
||||
for sensor, value, unit in (("sensor.wind", "4", "m/s"), ("sensor.wind_dir", "204", "°"),
|
||||
("sensor.gust", "9", "m/s")):
|
||||
hass.states.async_set(sensor, value, {"unit_of_measurement": unit})
|
||||
options = hass.config_entries.options
|
||||
result = await open_settings(hass, entry)
|
||||
result = await options.async_configure(result["flow_id"], result["data_schema"](
|
||||
{**MAIN, "ventilation_gust": True, "ventilation_cross": True}))
|
||||
result = await options.async_configure(result["flow_id"], result["data_schema"]({
|
||||
"outside_temperature_sources": ["sensor.outside"], "outside_wind_sources": ["sensor.wind"],
|
||||
"outside_wind_direction_sources": ["sensor.wind_dir"], "outside_wind_gust_sources": ["sensor.gust"]}))
|
||||
result = await options.async_configure(result["flow_id"], result["data_schema"]({}))
|
||||
await hass.async_block_till_done()
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
# Cross-ventilation needs windows in two zones, so it stays off.
|
||||
assert coordinator.ventilation["sensor.room"].features == [
|
||||
"vent_natural", "recovery", "vent_wind", "vent_wind_dir", "vent_gust"]
|
||||
status = hass.states.get(er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
|
||||
window = status.attributes["windows"]["Bedroom window"]
|
||||
assert (window["state"], window["relative_wind_direction"]) == ("closed", -136.0)
|
||||
assert {"windows", "ventilation"} <= status.state_info["unrecorded_attributes"]
|
||||
+250
-6
@@ -10,6 +10,21 @@ from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from custom_components.heating_mpc.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from types import MappingProxyType
|
||||
|
||||
|
||||
def zone_subentry(entry, key):
|
||||
return next(sub for sub in entry.subentries.values() if sub.unique_id == key)
|
||||
|
||||
|
||||
def add_zone(hass, entry, key, sensors, name=None):
|
||||
"""What the Add zone flow stores, added directly."""
|
||||
hass.config_entries.async_add_subentry(entry, ConfigSubentry(
|
||||
data=MappingProxyType({"key": key, "name": name or key, "sensors": sensors,
|
||||
"sensor_weights": {s: 1.0 for s in sensors}, "comfort_weight": 1.0,
|
||||
"target_offset": 0.0, "neighbors": []}),
|
||||
subentry_type="zone", title=name or key, unique_id=key))
|
||||
|
||||
|
||||
async def setup(hass, entry):
|
||||
@@ -21,11 +36,18 @@ async def setup(hass, entry):
|
||||
|
||||
async def test_setup_reload_unload(hass, entry, inputs):
|
||||
coordinator = await setup(hass, entry)
|
||||
assert coordinator.config_entry is entry
|
||||
registry = er.async_get(hass)
|
||||
entities = er.async_entries_for_config_entry(registry, entry.entry_id)
|
||||
assert len(entities) == 5
|
||||
assert len(entities) == 12 # 7 core + 4 model-error sensors + 1 forecast-error sensor
|
||||
ids = {e.unique_id: e.entity_id for e in entities}
|
||||
assert coordinator.data["status"] == "learning"
|
||||
status = hass.states.get(ids[f"{entry.entry_id}_status"])
|
||||
zone_quality = status.attributes["input_quality"]["zones"]["sensor.room"]
|
||||
assert zone_quality["sensors"]["sensor.room"]["max_age_seconds_24h"] == 0
|
||||
# Volatile diagnostics stay visible but are not written to the Recorder.
|
||||
assert {"input_quality", "weather_comparison"} <= status.state_info["unrecorded_attributes"]
|
||||
assert "models" not in status.state_info["unrecorded_attributes"]
|
||||
proposal_id = registry.async_get_entity_id("binary_sensor", DOMAIN, f"{entry.entry_id}_proposal")
|
||||
assert hass.states.get(proposal_id).state == "unavailable"
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
@@ -39,17 +61,20 @@ async def test_setup_reload_unload(hass, entry, inputs):
|
||||
assert coordinator._unsub_refresh is None
|
||||
|
||||
|
||||
async def test_options_reload_removes_only_old_room(hass, entry, inputs):
|
||||
async def test_replacing_a_zone_removes_only_its_forecast(hass, entry, inputs):
|
||||
await setup(hass, entry)
|
||||
registry = er.async_get(hass)
|
||||
old = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min")
|
||||
hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"})
|
||||
hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]})
|
||||
add_zone(hass, entry, "zone_new", ["sensor.new"], "New")
|
||||
await hass.async_block_till_done()
|
||||
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.new"]
|
||||
assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.data[DOMAIN][entry.entry_id].rooms == ["zone_new"]
|
||||
assert registry.async_get(old) is None
|
||||
assert registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.new_predicted_60min")
|
||||
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 5
|
||||
new = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_zone_new_predicted_60min")
|
||||
assert hass.states.get(new).attributes["friendly_name"] == "MPC New 60min Prediction"
|
||||
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 12
|
||||
|
||||
|
||||
async def test_storage_restore_and_remove(hass, entry, inputs):
|
||||
@@ -63,6 +88,16 @@ async def test_storage_restore_and_remove(hass, entry, inputs):
|
||||
restored = hass.data[DOMAIN][entry.entry_id]
|
||||
assert restored.learners["sensor.room"].count == 250
|
||||
assert restored.data["status"] == "ready"
|
||||
forecast_id = er.async_get(hass).async_get_entity_id(
|
||||
"sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min")
|
||||
forecast = hass.states.get(forecast_id)
|
||||
assert len(forecast.attributes["forecast_15min"]) == len(forecast.attributes["plan_15min"]) == 4
|
||||
assert forecast.attributes["forecast_15min"][3] == float(forecast.state)
|
||||
assert forecast.attributes["forecast"]["60_min"] == float(forecast.state)
|
||||
assert set(forecast.attributes["forecast"]) == {"15_min", "30_min", "60_min"} # 60-minute horizon
|
||||
assert set(forecast.attributes["contributions_c_per_h"]) == {"loss", "heat", "bias"}
|
||||
assert forecast.attributes["model_in_use"] == "base"
|
||||
assert {"forecast_15min", "plan_15min"} <= forecast.state_info["unrecorded_attributes"]
|
||||
assert await hass.config_entries.async_remove(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await Store(hass, 1, f"{DOMAIN}.{entry.entry_id}").async_load() is None
|
||||
@@ -114,6 +149,45 @@ async def test_scheduled_sample_saves_and_unload_stops_polling(hass, entry, inpu
|
||||
assert coordinator.learners["sensor.room"].count == 1
|
||||
|
||||
|
||||
async def test_reloads_leave_one_relay_listener_and_one_polling_coordinator(hass, entry, inputs, freezer):
|
||||
coordinators = [await setup(hass, entry)]
|
||||
for _ in range(3):
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinators.append(hass.data[DOMAIN][entry.entry_id])
|
||||
*old, current = coordinators
|
||||
assert len({id(c) for c in coordinators}) == 4
|
||||
# A relay change reaches only the current coordinator.
|
||||
hass.states.async_set("switch.boiler", "on")
|
||||
await hass.async_block_till_done()
|
||||
assert [state for _, state in current._relay_events] == ["on"]
|
||||
assert not any(c._relay_events for c in old)
|
||||
# Only the current coordinator polls; the replaced ones neither learn nor publish.
|
||||
status_id = er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status")
|
||||
updates = []
|
||||
hass.bus.async_listen("state_changed", lambda event: updates.append(event.data["entity_id"]))
|
||||
before = [(c.learners["sensor.room"].count, c.data) for c in old]
|
||||
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
|
||||
freezer.move_to(future)
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
assert [(c.learners["sensor.room"].count, c.data) for c in old] == before
|
||||
assert updates.count(status_id) <= 1
|
||||
assert hass.states.get(status_id).state == current.data["status"]
|
||||
# After unload, a relay change is recorded nowhere and nothing is published.
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
events = list(current._relay_events)
|
||||
updates.clear()
|
||||
hass.states.async_set("switch.boiler", "off")
|
||||
future += timedelta(minutes=5)
|
||||
freezer.move_to(future)
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
assert list(current._relay_events) == events
|
||||
assert status_id not in updates
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", ["broken", {"learners": []}, {"learners": {"sensor.room": {"theta": []}}}])
|
||||
async def test_corrupt_saved_payload_starts_learning(hass, entry, inputs, hass_storage, payload):
|
||||
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": payload}
|
||||
@@ -146,3 +220,173 @@ async def test_startup_missing_inputs_then_recovery(hass, entry):
|
||||
await coordinator.async_refresh()
|
||||
assert coordinator.data["status"] == "learning"
|
||||
assert coordinator.data["samples"] == 0
|
||||
|
||||
|
||||
async def test_freshly_written_relay_state_defers_learning(hass, entry, inputs, freezer):
|
||||
"""After an HA restart the relay's last_changed is new, so radiator lag is unknown."""
|
||||
hass.states.async_set("switch.boiler", "on")
|
||||
coordinator = await setup(hass, entry)
|
||||
assert coordinator.data["heat_lag"] == 0.5
|
||||
assert coordinator.data["heat_lag_uncertainty"] == 0.5
|
||||
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 coordinator.data["sample_result"] == "lag_uncertain"
|
||||
assert coordinator.learners["sensor.room"].count == 0
|
||||
|
||||
|
||||
async def test_relay_listener_integrates_changes_between_polls(hass, entry, inputs, freezer):
|
||||
coordinator = await setup(hass, entry)
|
||||
start = dt_util.utcnow()
|
||||
for offset, state in ((60, "on"), (200, "off")): # on/off round trip inside one poll
|
||||
freezer.move_to(start + timedelta(seconds=offset))
|
||||
hass.states.async_set("switch.boiler", state)
|
||||
await hass.async_block_till_done()
|
||||
for minutes, expected in ((5, "relay_changed"), (10, "learned")):
|
||||
future = start + timedelta(minutes=minutes, seconds=1)
|
||||
freezer.move_to(future)
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
assert coordinator.data["sample_result"] == expected
|
||||
# Without the listener this round trip would restart the estimate (about 0.46).
|
||||
assert coordinator.data["heat_lag_uncertainty"] < 0.05
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
hass.states.async_set("switch.boiler", "on")
|
||||
await hass.async_block_till_done()
|
||||
assert not coordinator._relay_events
|
||||
|
||||
|
||||
async def test_removed_room_model_survives_options_round_trip(hass, entry, inputs, hass_storage, freezer):
|
||||
from custom_components.heating_mpc.physics import Learner
|
||||
key = f"{DOMAIN}.{entry.entry_id}"
|
||||
hass_storage[key] = {"version": 1, "data": {
|
||||
"schema_version": 2, "heater_entity": "switch.boiler",
|
||||
"learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}}
|
||||
hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"})
|
||||
await setup(hass, entry)
|
||||
add_zone(hass, entry, "sensor.new", ["sensor.new"])
|
||||
await hass.async_block_till_done()
|
||||
assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id)
|
||||
await hass.async_block_till_done()
|
||||
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()
|
||||
saved = hass_storage[key]["data"]
|
||||
assert set(saved["learners"]) == {"sensor.new"}
|
||||
assert saved["retired_learners"]["sensor.room"]["count"] == 250
|
||||
add_zone(hass, entry, "sensor.room", ["sensor.room"])
|
||||
await hass.async_block_till_done()
|
||||
assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250
|
||||
|
||||
|
||||
async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs, freezer):
|
||||
coordinator = await setup(hass, entry)
|
||||
registry = er.async_get(hass)
|
||||
ids = {horizon: registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_forecast_error_{horizon}min")
|
||||
for horizon in (15, 30, 60, 120)}
|
||||
assert all(hass.states.get(entity).state == "unknown" for entity in ids.values())
|
||||
future = dt_util.utcnow()
|
||||
for _ in range(3): # 15 minutes of regular polls
|
||||
future += timedelta(minutes=5, seconds=1)
|
||||
freezer.move_to(future)
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(ids[15])
|
||||
accuracy = coordinator.data["forecast_accuracy"][15]
|
||||
assert accuracy["samples"] == 1
|
||||
# One replayed forecast is no accuracy figure: the state stays unknown, the numbers
|
||||
# are in the attributes with their quality.
|
||||
assert state.state == "unknown"
|
||||
assert state.attributes["mae"] == accuracy["mae"] > 0
|
||||
assert state.attributes["quality"] == "insufficient"
|
||||
assert state.attributes["samples"] == 1
|
||||
assert (state.attributes["incomplete"], state.attributes["skipped"]) == (0, 0)
|
||||
assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name
|
||||
assert state.attributes["unit_of_measurement"] == "°C"
|
||||
assert state.attributes["friendly_name"] == "Heating MPC Model Error 15 min"
|
||||
assert "zones" in state.state_info["unrecorded_attributes"]
|
||||
assert hass.states.get(ids[30]).state == "unknown"
|
||||
for step in range(15): # enough 15-minute spans for an indicative figure
|
||||
future += timedelta(minutes=5, seconds=1)
|
||||
freezer.move_to(future)
|
||||
# Fresh readings, or the sensors turn stale after 30 minutes.
|
||||
hass.states.async_set("sensor.room", str(20 - 0.01 * step), {"unit_of_measurement": "°C"})
|
||||
hass.states.async_set("sensor.outside", "5", {"unit_of_measurement": "°C"}, force_update=True)
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
accuracy = coordinator.data["forecast_accuracy"][15]
|
||||
assert accuracy["quality"] == "indicative", accuracy
|
||||
assert float(hass.states.get(ids[15]).state) == accuracy["mae"]
|
||||
# Published forecasts are scored separately; nothing was published while learning.
|
||||
operational = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_operational_forecast_error")
|
||||
state = hass.states.get(operational)
|
||||
assert state.state == "unknown"
|
||||
assert state.attributes["friendly_name"] == "Heating MPC Forecast Error"
|
||||
assert "horizons" in state.state_info["unrecorded_attributes"]
|
||||
|
||||
|
||||
async def test_learning_progress_and_last_sample_sensors(hass, entry, inputs, freezer):
|
||||
await setup(hass, entry)
|
||||
registry = er.async_get(hass)
|
||||
progress_id = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_learning_progress")
|
||||
last_id = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_last_sample")
|
||||
assert hass.states.get(progress_id).state == "0.0"
|
||||
last = hass.states.get(last_id)
|
||||
assert last.state == "first_sample"
|
||||
assert "lag_uncertain" in last.attributes["options"]
|
||||
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 hass.states.get(last_id).state == "learned"
|
||||
# One OFF sample: (1/200 + 0/30 + 1/30) / 3
|
||||
assert float(hass.states.get(progress_id).state) == 1.3
|
||||
progress = hass.states.get(progress_id).attributes
|
||||
assert (progress["samples"], progress["off_samples"], progress["required"]["samples"]) == (1, 1, 200)
|
||||
hass.states.async_set("switch.boiler", "unavailable")
|
||||
future += timedelta(minutes=5)
|
||||
freezer.move_to(future)
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
last = hass.states.get(last_id)
|
||||
assert last.state == "waiting_for_inputs"
|
||||
assert last.attributes["counts_since_start"] == {"first_sample": 1, "learned": 1, "waiting_for_inputs": 1}
|
||||
|
||||
|
||||
async def test_deleting_the_last_zone_keeps_the_entry_loaded(hass, entry, inputs, caplog):
|
||||
await setup(hass, entry)
|
||||
assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.state.name == "LOADED"
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert (coordinator.rooms, coordinator.data["status"]) == ([], "no_zones")
|
||||
assert coordinator.data["learning_progress"] == 0
|
||||
assert "Error setting up entry" not in caplog.text
|
||||
# Adding a zone back resumes normally.
|
||||
add_zone(hass, entry, "sensor.room", ["sensor.room"])
|
||||
await hass.async_block_till_done()
|
||||
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "learning"
|
||||
|
||||
|
||||
async def test_zone_edits_recalibrate_only_when_the_measurement_changes(hass, entry, inputs, hass_storage):
|
||||
from custom_components.heating_mpc.physics import Learner
|
||||
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": {
|
||||
"schema_version": 2, "heater_entity": "switch.boiler",
|
||||
"learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}}
|
||||
await setup(hass, entry)
|
||||
zone = zone_subentry(entry, "sensor.room")
|
||||
hass.config_entries.async_update_subentry(entry, zone, data={**zone.data, "comfort_weight": 3.0})
|
||||
await hass.async_block_till_done()
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.learners["sensor.room"].count == 250 # comfort is not physics
|
||||
hass.states.async_set("sensor.second", "21", {"unit_of_measurement": "°C"})
|
||||
hass.config_entries.async_update_subentry(entry, zone, data={
|
||||
**zone.data, "sensors": ["sensor.room", "sensor.second"],
|
||||
"sensor_weights": {"sensor.room": 1.0, "sensor.second": 1.0}})
|
||||
await hass.async_block_till_done()
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.learners["sensor.room"].count == 0
|
||||
status = hass.states.get(er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
|
||||
assert status.attributes["model_changes"][-1]["reasons"] == ["zone sensors or sensor weights changed"]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Configuration fingerprints: what invalidates which learned coefficient."""
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
||||
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
||||
from heating_mpc import compatibility as cp, heat_sources as hs, windows as wm, zones as zn # noqa: E402
|
||||
|
||||
|
||||
def setup(zones=None, sources=None, windows=None, recovery=20):
|
||||
zones = zones or [{'key': 'living', 'name': 'Living', 'sensors': ['sensor.l1', 'sensor.l2'],
|
||||
'sensor_weights': {'sensor.l1': 1, 'sensor.l2': 1}, 'neighbors': ['bed']},
|
||||
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.b']}]
|
||||
built = {z.key: z for z in zn.build_zones((z, None) for z in zones)}
|
||||
sources = hs.build_sources(((s, None) for s in (sources if sources is not None else [
|
||||
{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living'},
|
||||
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}])), built)
|
||||
windows = wm.build_windows(((w, None) for w in (windows if windows is not None else [
|
||||
{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 345},
|
||||
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}])), built)
|
||||
return cp.zone_fingerprints(built, sources, windows, recovery), sources
|
||||
|
||||
|
||||
def differing(a, b):
|
||||
return {zone: sorted(k for k in set(a[zone]) | set(b[zone]) if a[zone].get(k) != b[zone].get(k))
|
||||
for zone in a if zone in b and a[zone] != b[zone]}
|
||||
|
||||
|
||||
class FingerprintTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.base, _ = setup()
|
||||
|
||||
def test_deterministic_and_comfort_settings_are_not_physical(self):
|
||||
self.assertEqual(setup()[0], self.base)
|
||||
zones = [{'key': 'living', 'name': 'Renamed', 'sensors': ['sensor.l1', 'sensor.l2'], 'comfort_weight': 5,
|
||||
'target_offset': 1.5, 'sensor_weights': {'sensor.l1': 2, 'sensor.l2': 2}, 'neighbors': ['bed']},
|
||||
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.b'], 'comfort_weight': 3}]
|
||||
self.assertEqual(differing(self.base, setup(zones)[0]), {}) # equal weights scaled: same average
|
||||
|
||||
def test_measurement_changes_reset_the_whole_zone_and_its_neighbour_term(self):
|
||||
for sensors, weights in ((['sensor.l1', 'sensor.l2'], {'sensor.l1': 3, 'sensor.l2': 1}), # weight ratio
|
||||
(['sensor.l1', 'sensor.l3'], {}), # replaced
|
||||
(['sensor.l1'], {})): # removed
|
||||
with self.subTest(sensors=sensors, weights=weights):
|
||||
zones = [{'key': 'living', 'sensors': sensors, 'sensor_weights': weights, 'neighbors': ['bed']},
|
||||
{'key': 'bed', 'sensors': ['sensor.b']}]
|
||||
diff = differing(self.base, setup(zones)[0])
|
||||
self.assertEqual(set(diff['living']), set(self.base['living']))
|
||||
self.assertEqual(diff['bed'], ['couple:living']) # the bedroom's own model is untouched
|
||||
|
||||
def test_heat_source_changes_reset_only_the_electrical_term(self):
|
||||
moved = [{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'bed'},
|
||||
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}]
|
||||
self.assertEqual(differing(self.base, setup(sources=moved)[0]), {'living': ['internal'], 'bed': ['internal']})
|
||||
for change in ({'sensor': 'sensor.ups2'}, {'heat_fraction': 0.5}, {'response_minutes': 30},
|
||||
{'baseline_mode': 'manual', 'baseline_w': 150}):
|
||||
with self.subTest(change=change):
|
||||
sources = [{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living', **change},
|
||||
{'key': 'pc', 'sensor': 'sensor.pc', 'zone': 'living', 'included_in': 'ups'}]
|
||||
self.assertEqual(differing(self.base, setup(sources=sources)[0]), {'living': ['internal']})
|
||||
|
||||
def test_window_changes_are_selective(self):
|
||||
turned = [{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 300},
|
||||
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}]
|
||||
self.assertEqual(differing(self.base, setup(windows=turned)[0]), {'bed': ['vent_wind_dir']})
|
||||
bigger = [{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 345, 'opening_factor': 2},
|
||||
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}]
|
||||
diff = differing(self.base, setup(windows=bigger)[0])
|
||||
self.assertEqual(diff['bed'], ['recovery', 'vent_cross', 'vent_gust', 'vent_natural', 'vent_wind',
|
||||
'vent_wind_dir'])
|
||||
self.assertEqual(diff['living'], ['vent_cross']) # sees the bedroom's windows
|
||||
# A flipped polarity reverses what every term saw as open, the neighbour's too.
|
||||
flipped = [{'key': 'w1', 'sensor': 'binary_sensor.w1', 'zone': 'bed', 'azimuth': 345, 'inverted': True},
|
||||
{'key': 'w2', 'sensor': 'binary_sensor.w2', 'zone': 'living', 'azimuth': 345}]
|
||||
diff = differing(self.base, setup(windows=flipped)[0])
|
||||
self.assertIn('vent_natural', diff['bed'])
|
||||
self.assertEqual(diff['living'], ['vent_cross'])
|
||||
self.assertEqual(differing(self.base, setup(recovery=40)[0]), {'living': ['recovery'], 'bed': ['recovery']})
|
||||
|
||||
def test_baseline_prints_and_change_detection(self):
|
||||
_, sources = setup()
|
||||
prints = cp.baseline_fingerprints(sources)
|
||||
_, without_pc = setup(sources=[{'key': 'ups', 'sensor': 'sensor.ups', 'zone': 'living'}])
|
||||
self.assertNotEqual(prints['ups'], cp.baseline_fingerprints(without_pc)['ups'])
|
||||
self.assertEqual(cp.changed(None, self.base['living'], ['base']), []) # 0.4.0 data: assumed to match
|
||||
self.assertEqual(cp.changed({'base': 'x'}, self.base['living'], ['base', 'internal']), ['base'])
|
||||
self.assertEqual(cp.reason('couple:bed'), "a neighbouring zone's sensors changed")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+1158
-8
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,9 @@ ROOT = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||
spec = importlib.util.spec_from_file_location('quality', ROOT / 'data_quality.py')
|
||||
quality = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(quality)
|
||||
spec = importlib.util.spec_from_file_location('heating_mpc_const', ROOT / 'const.py')
|
||||
const = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(const)
|
||||
|
||||
|
||||
class FreshnessTests(unittest.TestCase):
|
||||
@@ -31,10 +34,48 @@ class FreshnessTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
quality.freshness(now, now, limit)
|
||||
|
||||
def test_report_age_history_keeps_24_hour_maximum(self):
|
||||
history = quality.ReportAgeHistory()
|
||||
start = datetime(2026, 10, 26, tzinfo=timezone.utc)
|
||||
self.assertEqual(history.observe('sensor.a', start, 2400), 2400)
|
||||
self.assertEqual(history.observe('sensor.b', start, 60), 60)
|
||||
for minutes in range(5, 24 * 60, 5):
|
||||
result = history.observe('sensor.a', start + timedelta(minutes=minutes), 300)
|
||||
self.assertEqual(result, 2400) # still inside the 24-hour window
|
||||
later = start + timedelta(hours=24, minutes=5)
|
||||
self.assertEqual(history.observe('sensor.a', later, 300), 300)
|
||||
self.assertEqual(history.observe('sensor.b', later, 30), 30)
|
||||
|
||||
def test_inputs_differ_ignores_noise_but_not_meaningful_changes(self):
|
||||
before = {('temperature', 'sensor.room'): 20.0, ('power', 'sensor.ups'): 210.0,
|
||||
('wind_direction', 'sensor.dir'): 355.0, ('wind', 'sensor.wind'): 4.0,
|
||||
('window', 'binary_sensor.w'): False, ('thermostat', 'climate.t'): ('heat', 22, None)}
|
||||
noise = {**before, ('temperature', 'sensor.room'): 20.1, ('power', 'sensor.ups'): 240.0,
|
||||
('wind_direction', 'sensor.dir'): 5.0, ('wind', 'sensor.wind'): 4.6}
|
||||
self.assertEqual(quality.inputs_differ(before, noise), []) # 10° across north is small
|
||||
for key, value in ((('temperature', 'sensor.room'), 20.5), (('power', 'sensor.ups'), 610.0),
|
||||
(('wind_direction', 'sensor.dir'), 40.0), (('window', 'binary_sensor.w'), True),
|
||||
(('window', 'binary_sensor.w'), None), (('thermostat', 'climate.t'), ('heat', 21, None)),
|
||||
(('temperature', 'sensor.room'), None)):
|
||||
with self.subTest(key=key, value=value):
|
||||
self.assertEqual(quality.inputs_differ(before, {**before, key: value}), [key])
|
||||
self.assertEqual(quality.inputs_differ(before, {**before, ('power', 'sensor.new'): 5.0}),
|
||||
[('power', 'sensor.new')])
|
||||
|
||||
def test_freshness_settings_translated(self):
|
||||
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
|
||||
data = json.loads((ROOT / name).read_text())
|
||||
for section, step in (('config', 'user'), ('options', 'init')):
|
||||
# Configure opens a menu: settings, or resetting the learned model.
|
||||
options = data['options']
|
||||
self.assertEqual(set(options['step']['init']['menu_options']), {'settings', 'reset_model'})
|
||||
self.assertTrue(options['step']['reset_model']['data']['confirm_reset'])
|
||||
self.assertEqual(set(options['abort']), {'model_reset', 'model_not_reset'})
|
||||
for section, step in (('config', 'user'), ('options', 'settings')):
|
||||
labels = data[section]['step'][step]['data']
|
||||
for key in ('room_max_age_minutes', 'weather_max_age_minutes'):
|
||||
for key in ('room_max_age_minutes', 'weather_max_age_minutes', 'room_stale_use_last_value'):
|
||||
self.assertTrue(labels[key])
|
||||
help_text = data[section]['step'][step]['data_description']
|
||||
self.assertIn('24', help_text['room_stale_use_last_value'])
|
||||
# Every poll outcome has a translated state name.
|
||||
states = data['entity']['sensor']['last_sample']['state']
|
||||
self.assertEqual(set(states), set(const.SAMPLE_RESULTS))
|
||||
@@ -0,0 +1,413 @@
|
||||
"""Walk-forward forecast evaluation, without Home Assistant."""
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||
# Fake only the package container, to avoid importing Home Assistant from __init__.
|
||||
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
||||
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
||||
from heating_mpc import evaluation as ev # noqa: E402
|
||||
|
||||
THETA = (0.004, 0.12, 0.0005)
|
||||
START = datetime(2026, 10, 26, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def plant(polls, theta=THETA, model_theta=None, outside=5.0, lag_at=lambda k: 1.0 if k % 30 < 12 else 0.0,
|
||||
evaluator=None, uncertainty_at=lambda k: 0.0, start=START, step=300):
|
||||
"""Feed polls of a room that follows the 5-minute model exactly with `theta`."""
|
||||
a, b, c = theta
|
||||
evaluator = evaluator or ev.ForecastEvaluator(0.1)
|
||||
temp = 20.0
|
||||
for k in range(polls):
|
||||
lag = lag_at(k)
|
||||
if k:
|
||||
temp += a * (outside - temp) + b * lag + c
|
||||
evaluator.observe(start + timedelta(seconds=k * step), {'sensor.room': temp}, outside, lag,
|
||||
uncertainty_at(k), {'sensor.room': model_theta or theta})
|
||||
return evaluator
|
||||
|
||||
|
||||
class EvaluationTests(unittest.TestCase):
|
||||
def test_exact_model_scores_zero_error_and_beats_persistence(self):
|
||||
summary = plant(60).summary(['sensor.room'])
|
||||
for horizon, expected_samples in ((15, 57), (30, 54), (60, 48), (120, 36)):
|
||||
with self.subTest(horizon=horizon):
|
||||
stats = summary[horizon]
|
||||
self.assertEqual(stats['samples'], expected_samples)
|
||||
self.assertEqual(stats['rooms']['sensor.room']['samples'], expected_samples)
|
||||
self.assertAlmostEqual(stats['mae'], 0, places=6)
|
||||
self.assertGreater(stats['persistence_mae'], 0.05)
|
||||
self.assertEqual(stats['skill'], 1)
|
||||
|
||||
def test_parameters_from_the_forecast_start_are_used(self):
|
||||
# A model without heat gain under-predicts warming: negative bias.
|
||||
stats = plant(10, model_theta=(0.004, 0.0, 0.0005), lag_at=lambda k: 1.0).summary(['sensor.room'])
|
||||
self.assertLess(stats[15]['bias'], -0.3)
|
||||
self.assertEqual(stats[15]['bias'], -stats[15]['mae'])
|
||||
|
||||
def test_replay_matches_hand_calculation(self):
|
||||
temp = 20.0
|
||||
for _ in range(3):
|
||||
temp += 0.004 * (5 - temp) + 0.12 * 0.5 + 0.0005
|
||||
self.assertAlmostEqual(ev.replay(20.0, 5.0, THETA, [(0.5, 300)] * 3), temp)
|
||||
# Jitter is normalized like learning: 330 s counts as 1.1 steps.
|
||||
self.assertAlmostEqual(ev.replay(20.0, 5.0, (0, 0.1, 0), [(1.0, 330)]), 20.11)
|
||||
|
||||
def test_breaks_uncertain_lag_and_missing_rooms_are_not_scored(self):
|
||||
evaluator = plant(4)
|
||||
self.assertEqual(evaluator.summary(['sensor.room'])[15]['samples'], 1)
|
||||
evaluator.reset_window()
|
||||
plant(3, evaluator=evaluator, start=START + timedelta(minutes=20))
|
||||
self.assertEqual(evaluator.summary(['sensor.room'])[15]['samples'], 1)
|
||||
# Irregular spacing restarts the window as well.
|
||||
jittery = plant(8, step=400)
|
||||
self.assertEqual(jittery.summary(['sensor.room'])[15]['samples'], 0)
|
||||
uncertain = plant(8, uncertainty_at=lambda k: 0.5 if k == 4 else 0.0)
|
||||
self.assertEqual(uncertain.summary(['sensor.room'])[15]['samples'], 2) # k = 3 and 7 only
|
||||
self.assertEqual(plant(8).summary(['sensor.other'])[15]['samples'], 0)
|
||||
|
||||
def test_rmse_effective_samples_and_quality(self):
|
||||
stats = plant(10, model_theta=(0.004, 0.0, 0.0005), lag_at=lambda k: 1.0).summary(['sensor.room'])
|
||||
s15 = stats[15]
|
||||
self.assertGreaterEqual(s15['rmse'], s15['mae'])
|
||||
# 7 overlapping 15-minute forecasts started 5 minutes apart: ~2.3 independent ones.
|
||||
self.assertEqual((s15['samples'], s15['effective_samples'], s15['quality']), (7, 2.3, 'insufficient'))
|
||||
self.assertEqual(ev.quality(5), 'indicative')
|
||||
self.assertEqual(ev.quality(30), 'ok')
|
||||
self.assertEqual(ev.effective_samples(120, 60, zones=2), 5.0)
|
||||
|
||||
def test_window_keeps_seven_days(self):
|
||||
evaluator = plant(10)
|
||||
before = evaluator.summary(['sensor.room'])[15]['samples']
|
||||
plant(10, evaluator=evaluator, start=START + timedelta(days=7))
|
||||
self.assertEqual(evaluator.summary(['sensor.room'])[15]['samples'], 7)
|
||||
self.assertEqual(before, 7)
|
||||
|
||||
def test_full_model_replays_actual_inputs_and_is_compared_with_base(self):
|
||||
base = (0.004, 0.12, 0.0005)
|
||||
full = {'base': base, 'uses_extended': True,
|
||||
'extended': (['loss', 'heat', 'bias', 'internal'], base + (0.15,)),
|
||||
'ventilation': (['vent_natural'], (0.01,))}
|
||||
evaluator, temp = ev.ForecastEvaluator(0.1), 20.0
|
||||
for k in range(40):
|
||||
internal = 0.4 if (k // 8) % 2 else 0.0 # gaming on and off
|
||||
opening = 1.0 if k % 10 in (3, 4) else 0.0 # short ventilation
|
||||
if k:
|
||||
temp += 0.004 * (5 - temp) + 0.12 * 0.5 + 0.0005 + 0.15 * internal + 0.01 * opening * (5 - temp)
|
||||
evaluator.observe(START + timedelta(seconds=300 * k), {'r': temp}, 5.0, 0.5, 0.0, {'r': base},
|
||||
models={'r': full},
|
||||
inputs={'r': {'internal': internal, 'ventilation': {'opening': opening}}})
|
||||
stats = evaluator.summary(['r'])[60]
|
||||
self.assertAlmostEqual(stats['mae'], 0, places=6)
|
||||
self.assertGreater(stats['base_model_mae'], 0.05)
|
||||
self.assertEqual(stats['improvement_vs_base'], 1.0)
|
||||
|
||||
def test_saved_statistics_round_trip_and_reject_garbage(self):
|
||||
evaluator = plant(10)
|
||||
restored = ev.ForecastEvaluator(0.1)
|
||||
restored.load(evaluator.as_dict())
|
||||
self.assertEqual(restored.summary(['sensor.room']), evaluator.summary(['sensor.room']))
|
||||
day = START.date().isoformat()
|
||||
restored.load({'days': {
|
||||
'not-a-day': {'15': {'sensor.room': [1, 0.0, 0.1, 0.1]}},
|
||||
day: {'45': {'sensor.room': [1, 0.0, 0.1, 0.1]},
|
||||
'15': {'sensor.a': [1, 0.0, -0.1, 0.1], 'sensor.b': [True, 0, 0, 0],
|
||||
'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]}}},
|
||||
'excluded': {day: {'15': {'sensor.d': [1, 2, 0.3], 'sensor.x': [1, -2, 0.3], 'sensor.y': [1, 2]},
|
||||
'45': {'sensor.d': [1, 2, 0.3]}}}})
|
||||
# A 0.3 bucket has no base-model column; its model was the base model then.
|
||||
# Neither 0.3 nor 0.4 buckets have squared errors: they do not count for RMSE.
|
||||
# 0.4 data has no excluded-span counts.
|
||||
self.assertEqual(restored.as_dict(), {'days': {day: {'15': {
|
||||
'sensor.d': [2, 0.1, 0.2, 0.4, 0.2, 0.0, 0], 'sensor.e': [2, 0.1, 0.2, 0.4, 0.3, 0.0, 0]}}},
|
||||
'excluded': {day: {'15': {'sensor.d': [1, 2, 0.3]}}}})
|
||||
self.assertIsNone(restored.summary(['sensor.d'])[15]['rmse'])
|
||||
self.assertEqual({k: restored.summary(['sensor.d'])[15][k] for k in ('incomplete', 'skipped',
|
||||
'incomplete_base_model_mae')},
|
||||
{'incomplete': 1, 'skipped': 2, 'incomplete_base_model_mae': 0.3})
|
||||
for garbage in (None, [], {'days': []}):
|
||||
restored.load(garbage)
|
||||
self.assertEqual(restored.as_dict(), {'days': {}, 'excluded': {}})
|
||||
|
||||
|
||||
T0 = START.timestamp()
|
||||
|
||||
|
||||
def run_ledger(plan, relay_on, temps, trajectory, start=20.0, polls=25, unknown_relay_at=None, skip=()):
|
||||
"""One forecast at T0, then a poll every 5 minutes with the given relay state per poll."""
|
||||
ledger = ev.OperationalForecastLedger()
|
||||
ledger.observe(T0, {'r': start}, None, None)
|
||||
ledger.record(T0, {'r': start}, {'r': trajectory}, plan, 'rev1', {'strategy': 'mpc_plan'})
|
||||
for k in range(1, polls):
|
||||
if k in skip:
|
||||
continue
|
||||
on = None if k == unknown_relay_at else (300.0 if relay_on(k) else 0.0)
|
||||
ledger.observe(T0 + 300 * k, {'r': temps(k)}, on, 300.0)
|
||||
return ledger.summary(['r'])
|
||||
|
||||
|
||||
class OperationalForecastTests(unittest.TestCase):
|
||||
PLAN_OFF = [0] * 8
|
||||
TRAJECTORY = [19.9, 19.8, 19.7, 19.6, 19.5, 19.4, 19.3, 19.2] # every 15 minutes
|
||||
|
||||
def test_followed_plan_is_scored_at_every_horizon_against_persistence(self):
|
||||
stats = run_ledger(self.PLAN_OFF, lambda k: False, lambda k: 20.0 - k / 30, self.TRAJECTORY)
|
||||
for horizon, actual in ((15, 19.9), (30, 19.8), (60, 19.6), (120, 19.2)):
|
||||
with self.subTest(horizon=horizon):
|
||||
s = stats[horizon]
|
||||
self.assertEqual((s['samples'], s['matched'], s['counterfactual'], s['incomplete']), (1, 1, 0, 0))
|
||||
self.assertAlmostEqual(s['mae'], 0, places=3)
|
||||
self.assertAlmostEqual(s['persistence_mae'], 20.0 - actual, places=3)
|
||||
self.assertEqual(s['improvement_vs_persistence'], 1.0)
|
||||
self.assertEqual(s['quality'], 'insufficient') # one forecast proves little
|
||||
|
||||
def test_plan_not_followed_is_counterfactual_not_scored(self):
|
||||
stats = run_ledger([1] * 8, lambda k: False, lambda k: 20.0, self.TRAJECTORY)
|
||||
for horizon in (15, 30, 60, 120):
|
||||
self.assertEqual((stats[horizon]['samples'], stats[horizon]['counterfactual']), (0, 1))
|
||||
self.assertIsNone(stats[horizon]['mae'])
|
||||
|
||||
def test_plan_followed_for_a_while(self):
|
||||
# The thermostat switches the boiler on in the 4th block (after 45 minutes).
|
||||
stats = run_ledger(self.PLAN_OFF, lambda k: k > 9, lambda k: 20.0, self.TRAJECTORY)
|
||||
self.assertEqual([stats[h]['samples'] for h in (15, 30, 60, 120)], [1, 1, 0, 0])
|
||||
self.assertEqual([stats[h]['counterfactual'] for h in (15, 30, 60, 120)], [0, 0, 1, 1])
|
||||
|
||||
def test_unknown_relay_history_or_missing_observations(self):
|
||||
stats = run_ledger(self.PLAN_OFF, lambda k: False, lambda k: 20.0, self.TRAJECTORY, unknown_relay_at=2)
|
||||
self.assertEqual((stats[15]['samples'], stats[15]['incomplete_reasons']['relay_history'],
|
||||
stats[60]['incomplete']), (0, 1, 1))
|
||||
missing = run_ledger(self.PLAN_OFF, lambda k: False, lambda k: None if k == 12 else 20.0, self.TRAJECTORY)
|
||||
self.assertEqual((missing[60]['samples'], missing[60]['incomplete_reasons']['reading'],
|
||||
missing[30]['samples']), (0, 1, 1))
|
||||
# No poll near the due time (polls 11 to 13 missing): the 60-minute point cannot be scored.
|
||||
gap = run_ledger(self.PLAN_OFF, lambda k: False, lambda k: 20.0, self.TRAJECTORY, skip=(11, 12, 13))
|
||||
self.assertEqual((gap[60]['samples'], gap[60]['incomplete_reasons']['no_poll']), (0, 1))
|
||||
|
||||
def test_due_time_alignment(self):
|
||||
ledger = ev.OperationalForecastLedger()
|
||||
ledger.record(T0, {'r': 20.0}, {'r': self.TRAJECTORY}, self.PLAN_OFF, 'rev1', {})
|
||||
for offset in (300, 600):
|
||||
ledger.observe(T0 + offset, {'r': 20.0}, 0.0, 300.0)
|
||||
ledger.observe(T0 + 900 - 200, {'r': 20.0}, 0.0, 100.0) # too early for +15 min
|
||||
self.assertEqual(ledger.summary(['r'])[15]['samples'], 0)
|
||||
ledger.observe(T0 + 900 + 100, {'r': 19.9}, 0.0, 300.0) # within half a poll of the due time
|
||||
self.assertEqual(ledger.summary(['r'])[15]['samples'], 1)
|
||||
|
||||
def test_round_trip_forget_and_garbage(self):
|
||||
stats_ledger = ev.OperationalForecastLedger()
|
||||
stats_ledger.record(T0, {'r': 20.0}, {'r': self.TRAJECTORY}, [1] * 8, 'rev1', {})
|
||||
stats_ledger.record(T0, {'r': 20.0}, {'r': self.TRAJECTORY}, self.PLAN_OFF, 'rev1', {})
|
||||
for k in range(1, 4):
|
||||
stats_ledger.observe(T0 + 300 * k, {'r': 19.9}, 0.0, 300.0)
|
||||
restored = ev.OperationalForecastLedger(stats_ledger.as_dict())
|
||||
self.assertEqual(restored.summary(['r']), stats_ledger.summary(['r']))
|
||||
self.assertEqual(restored.summary(['r'])[15]['counterfactual'], 1)
|
||||
restored.forget('r')
|
||||
self.assertEqual(restored.summary(['r'])[15]['samples'], 0)
|
||||
empty = {'version': 2, 'days': {}, 'approximate': {}, 'verdicts': {}}
|
||||
for garbage in (None, [], {'version': 2, 'days': {'x': {}}, 'verdicts': {'2026-10-27': {'15': {'bogus': 3}}}}):
|
||||
self.assertEqual(ev.OperationalForecastLedger(garbage).as_dict(), empty)
|
||||
# 0.4.1 statistics accepted plans within 20 % per block: not comparable, dropped.
|
||||
old = {**stats_ledger.as_dict(), 'version': None}
|
||||
old.pop('version')
|
||||
self.assertEqual(ev.OperationalForecastLedger(old).as_dict(), empty)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
BASE = (0.004, 0.12, 0.0005)
|
||||
|
||||
|
||||
def ventilated(observed, actual=None, wind=4.0, features=('vent_natural',), zones=('r',), internal=None,
|
||||
extended=None, lag_uncertain_at=(), missing_end_at=()):
|
||||
"""Zones cooled by real ventilation; `observed[k]` is what the window sensor said
|
||||
for the interval ending at poll k (None: unknown). Returns the summary at 15 min."""
|
||||
actual = actual if actual is not None else [1.0 if o is None else o for o in observed]
|
||||
snapshot = {'base': BASE, 'uses_extended': extended is not None, 'extended': extended,
|
||||
'ventilation': (list(features), (0.02,) * len(features))}
|
||||
evaluator, temps = ev.ForecastEvaluator(0.1), {zone: 20.0 for zone in zones}
|
||||
for k, seen in enumerate(observed):
|
||||
if k:
|
||||
temps = {zone: t + 0.004 * (5 - t) + 0.12 * 0.5 + 0.0005
|
||||
+ 0.02 * actual[k] * (5 - t) * (1 + (wind or 0) * ('vent_wind' in features))
|
||||
for zone, t in temps.items()}
|
||||
vent = {'opening': seen, 'facing': 0.0, 'other_opening': 0.0, 'recovery': 0.0, 't_ref': None,
|
||||
'wind': wind}
|
||||
readings = {z: t for z, t in temps.items() if not (k in missing_end_at and z == zones[0])}
|
||||
evaluator.observe(START + timedelta(seconds=300 * k), readings, 5.0, 0.5,
|
||||
0.5 if k in lag_uncertain_at else 0.0, {z: BASE for z in zones},
|
||||
models={z: snapshot for z in zones},
|
||||
inputs={z: {'internal': internal, 'ventilation': vent if z == zones[0] else
|
||||
{**vent, 'opening': actual[k]}} for z in zones})
|
||||
return evaluator.summary(list(zones))[15]
|
||||
|
||||
|
||||
class IncompleteInputTests(unittest.TestCase):
|
||||
"""Unknown ventilation or extended inputs never become Model Error samples."""
|
||||
|
||||
def test_window_unknown_for_the_whole_span_is_incomplete(self):
|
||||
stats = ventilated([0.0, None, None, None])
|
||||
self.assertEqual((stats['samples'], stats['mae'], stats['incomplete']), (0, None, 1))
|
||||
# The base model needs no window input: its error is still kept, apart.
|
||||
self.assertGreater(stats['incomplete_base_model_mae'], 0.1)
|
||||
|
||||
def test_window_unknown_halfway_or_recovering_during_the_span(self):
|
||||
self.assertEqual(ventilated([0.0, 1.0, None, 1.0])['incomplete'], 1) # unavailable halfway
|
||||
stats = ventilated([0.0, None, 1.0, 1.0, 1.0, 1.0, 1.0]) # back after one poll
|
||||
# Spans ending at polls 3-6: only the first contains the unknown interval.
|
||||
self.assertEqual((stats['incomplete'], stats['samples']), (1, 3))
|
||||
self.assertAlmostEqual(stats['mae'], 0, places=6) # the known spans are exact
|
||||
|
||||
def test_one_zone_unknown_leaves_the_other_zone_scored(self):
|
||||
stats = ventilated([0.0, None, None, None], zones=('a', 'b'))
|
||||
self.assertEqual((stats['rooms']['a']['incomplete'], stats['rooms']['a']['samples']), (1, 0))
|
||||
self.assertEqual((stats['rooms']['b']['incomplete'], stats['rooms']['b']['samples']), (0, 1))
|
||||
|
||||
def test_missing_wind_matters_only_while_a_window_is_open(self):
|
||||
features = ('vent_natural', 'vent_wind')
|
||||
open_span = ventilated([0.0, 1.0, 1.0, 1.0], wind=None, features=features)
|
||||
closed_span = ventilated([0.0, 0.0, 0.0, 0.0], wind=None, features=features)
|
||||
self.assertEqual((open_span['incomplete'], open_span['samples']), (1, 0))
|
||||
self.assertEqual((closed_span['incomplete'], closed_span['samples']), (0, 1))
|
||||
self.assertEqual(ventilated([0.0, 1.0, 1.0, 1.0], features=features)['samples'], 1)
|
||||
|
||||
def test_unknown_electrical_heat_makes_the_extended_replay_incomplete(self):
|
||||
extended = (['loss', 'heat', 'bias', 'internal'], BASE + (0.15,))
|
||||
stats = ventilated([0.0] * 4, extended=extended, internal=None)
|
||||
self.assertEqual((stats['incomplete'], stats['samples']), (1, 0))
|
||||
self.assertIsNotNone(stats['incomplete_base_model_mae'])
|
||||
self.assertEqual(ventilated([0.0] * 4, extended=extended, internal=0.0)['samples'], 1)
|
||||
|
||||
def test_a_neighbour_with_unknown_inputs_spoils_a_coupled_zone(self):
|
||||
coupled = {'base': BASE, 'uses_extended': True, 'ventilation': None,
|
||||
'extended': (['loss', 'heat', 'bias', 'couple:a'], BASE + (0.01,))}
|
||||
plain = {'base': BASE, 'uses_extended': False, 'extended': None,
|
||||
'ventilation': (['vent_natural'], (0.02,))}
|
||||
origin = {'temps': {'a': 20.0, 'b': 20.0}, 'outside': 5.0, 'models': {'a': plain, 'b': coupled}}
|
||||
path = [origin] + [{'time': START + timedelta(seconds=300 * k), 'lag': 0.5,
|
||||
'inputs': {'a': {'ventilation': {'opening': None}}, 'b': {}}} for k in range(4)]
|
||||
origin['time'] = START - timedelta(seconds=300)
|
||||
_, incomplete = ev.replay_zones(origin, path)
|
||||
self.assertEqual(incomplete, {'a', 'b'})
|
||||
path = [origin] + [{**poll, 'inputs': {'a': {'ventilation': {'opening': 0.0}}, 'b': {}}} for poll in path[1:]]
|
||||
self.assertEqual(ev.replay_zones(origin, path)[1], set())
|
||||
|
||||
def test_skipped_spans_are_counted_and_samples_stay_correct(self):
|
||||
stats = ventilated([0.0] * 9, lag_uncertain_at=(4,))
|
||||
# Spans ending at 3..8: those crossing poll 4 (ending 4, 5, 6) are skipped.
|
||||
self.assertEqual((stats['samples'], stats['skipped'], stats['incomplete']), (3, 3, 0))
|
||||
stats = ventilated([0.0] * 5, missing_end_at=(4,), zones=('a', 'b'))
|
||||
self.assertEqual((stats['rooms']['a']['samples'], stats['rooms']['a']['skipped']), (1, 1))
|
||||
self.assertEqual(stats['rooms']['b']['samples'], 2)
|
||||
|
||||
|
||||
def ledger_with(plan, on_seconds, temps=None, uncertain=None, polls=None):
|
||||
"""One forecast at T0; `on_seconds[k-1]` is the relay's on-time in the interval ending
|
||||
at poll k (None: unknown). Returns the ledger."""
|
||||
ledger = ev.OperationalForecastLedger()
|
||||
trajectory = [20.0 + 0.1 * (b + 1) for b in range(len(plan))]
|
||||
ledger.observe(T0, {'r': 20.0}, None, None)
|
||||
ledger.record(T0, {'r': 20.0}, {'r': trajectory}, plan, 'rev1',
|
||||
{'strategy': 'mpc_plan', 'uncertain_inputs': uncertain or []})
|
||||
for k in range(1, (polls or len(on_seconds)) + 1):
|
||||
on = on_seconds[k - 1] if k - 1 < len(on_seconds) else 0.0
|
||||
ledger.observe(T0 + 300 * k, {'r': (temps or (lambda k: 20.0 + k / 30))(k)}, on, 300.0)
|
||||
return ledger
|
||||
|
||||
|
||||
def verdict(ledger, horizon):
|
||||
stats = ledger.summary(['r'])[horizon]
|
||||
for name in ('matched', 'approximate', 'counterfactual'):
|
||||
if stats[name]:
|
||||
return name
|
||||
return next(f'incomplete:{r}' for r, n in stats['incomplete_reasons'].items() if n)
|
||||
|
||||
|
||||
class PlanMatchTests(unittest.TestCase):
|
||||
"""Forecasts are compared with reality only when the relay did what their plan assumed."""
|
||||
|
||||
ON, OFF = 300.0, 0.0
|
||||
|
||||
def test_exact_match_is_scored(self):
|
||||
ledger = ledger_with([1, 1, 0, 0], [self.ON] * 6 + [self.OFF] * 6)
|
||||
self.assertEqual([verdict(ledger, h) for h in (15, 30, 60)], ['matched'] * 3)
|
||||
self.assertEqual(ledger.deviation({'time': T0, 'plan': [1, 1, 0, 0]}, 60), (0.0, 0.0))
|
||||
stats = ledger.summary(['r'])[60]
|
||||
self.assertEqual((stats['samples'], stats['approximate_stats']['samples']), (1, 0))
|
||||
|
||||
def test_small_transition_jitter_still_matches(self):
|
||||
# Planned off at 30 minutes; the relay switched off 40 s later.
|
||||
ledger = ledger_with([1, 1, 0, 0], [self.ON] * 6 + [40.0] + [self.OFF] * 5)
|
||||
self.assertEqual(ledger.deviation({'time': T0, 'plan': [1, 1, 0, 0]}, 60), (40.0, 0.0))
|
||||
self.assertEqual(verdict(ledger, 60), 'matched')
|
||||
|
||||
def test_twelve_of_fifteen_planned_minutes_is_only_approximate(self):
|
||||
ledger = ledger_with([1, 0], [self.ON, self.ON, 120.0, self.OFF, self.OFF, self.OFF])
|
||||
self.assertEqual(verdict(ledger, 15), 'approximate')
|
||||
stats = ledger.summary(['r'])[15]
|
||||
# Not in the main figures; kept apart.
|
||||
self.assertEqual((stats['samples'], stats['mae'], stats['approximate']), (0, None, 1))
|
||||
self.assertEqual(stats['approximate_stats']['samples'], 1)
|
||||
|
||||
def test_same_on_time_at_another_time_is_counterfactual(self):
|
||||
# 15 minutes on as planned, but in the second block instead of the first.
|
||||
ledger = ledger_with([1, 0, 0, 0], [self.OFF] * 3 + [self.ON] * 3 + [self.OFF] * 6)
|
||||
self.assertEqual(ledger.deviation({'time': T0, 'plan': [1, 0, 0, 0]}, 30), (1800.0, 0.0))
|
||||
self.assertEqual([verdict(ledger, h) for h in (15, 30, 60)], ['counterfactual'] * 3)
|
||||
|
||||
def test_early_shutdown_and_unexpected_activation(self):
|
||||
early = ledger_with([1, 1, 1, 1], [self.ON] * 4 + [self.OFF] * 8) # off after 20 of 60 minutes
|
||||
self.assertEqual([verdict(early, h) for h in (15, 30, 60)], ['matched', 'counterfactual', 'counterfactual'])
|
||||
brief = ledger_with([0, 0, 0, 0], [self.OFF] * 4 + [240.0] + [self.OFF] * 7) # 4 minutes on
|
||||
self.assertEqual([verdict(brief, h) for h in (15, 30)], ['matched', 'approximate'])
|
||||
long = ledger_with([0, 0, 0, 0], [self.OFF] * 4 + [self.ON, self.ON] + [self.OFF] * 6)
|
||||
self.assertEqual(verdict(long, 30), 'counterfactual')
|
||||
|
||||
def test_several_cycles_followed_closely_match(self):
|
||||
plan = [1, 0, 1, 0, 1, 0, 1, 0]
|
||||
relay = []
|
||||
for block in plan:
|
||||
relay += [self.ON] * 3 if block else [30.0, self.OFF, self.OFF] # each off 30 s late
|
||||
ledger = ledger_with(plan, relay)
|
||||
self.assertEqual(ledger.deviation({'time': T0, 'plan': plan}, 120)[0], 120.0) # four transitions
|
||||
self.assertEqual(verdict(ledger, 60), 'matched')
|
||||
self.assertEqual(verdict(ledger, 120), 'matched')
|
||||
|
||||
def test_missing_relay_history_is_incomplete_not_matched(self):
|
||||
ledger = ledger_with([0, 0, 0, 0], [self.OFF, None] + [self.OFF] * 10)
|
||||
self.assertEqual([verdict(ledger, h) for h in (15, 60)], ['incomplete:relay_history'] * 2)
|
||||
# A plan already contradicted is counterfactual whatever the gap hides.
|
||||
ledger = ledger_with([1, 1, 1, 1], [None] + [self.OFF] * 11)
|
||||
self.assertEqual(verdict(ledger, 30), 'counterfactual')
|
||||
|
||||
def test_thermostat_disagreeing_is_counterfactual_never_an_error(self):
|
||||
ledger = ledger_with([1] * 4, [self.OFF] * 12, temps=lambda k: 20.0)
|
||||
stats = ledger.summary(['r'])
|
||||
self.assertEqual([stats[h]['counterfactual'] for h in (15, 30, 60)], [1, 1, 1])
|
||||
self.assertEqual([stats[h]['samples'] for h in (15, 30, 60)], [0, 0, 0])
|
||||
|
||||
def test_forecasts_made_with_assumed_inputs_are_incomplete(self):
|
||||
ledger = ledger_with([0] * 4, [self.OFF] * 12, uncertain=['window: Bedroom'])
|
||||
self.assertEqual(verdict(ledger, 15), 'incomplete:uncertain_inputs')
|
||||
self.assertEqual(ledger.summary(['r'])[15]['samples'], 0)
|
||||
|
||||
def test_counts_over_several_forecasts(self):
|
||||
ledger = ev.OperationalForecastLedger()
|
||||
ledger.observe(T0, {'r': 20.0}, None, None)
|
||||
for k, plan in enumerate(([0, 0], [1, 1], [0, 0])): # one forecast per poll, relay always off
|
||||
ledger.record(T0 + 300 * k, {'r': 20.0}, {'r': [20.0, 20.0]}, plan, 'rev1', {})
|
||||
ledger.observe(T0 + 300 * (k + 1), {'r': 20.0}, 0.0, 300.0)
|
||||
for k in range(4, 12):
|
||||
ledger.observe(T0 + 300 * k, {'r': 20.0}, 0.0, 300.0)
|
||||
stats = ledger.summary(['r'])[15]
|
||||
self.assertEqual((stats['matched'], stats['counterfactual'], stats['samples'], stats['incomplete']),
|
||||
(2, 1, 2, 0))
|
||||
restored = ev.OperationalForecastLedger(ledger.as_dict())
|
||||
self.assertEqual(restored.summary(['r']), ledger.summary(['r']))
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Electrical heat sources: units, overlap, baseline, thermal response, forecasts."""
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from math import exp
|
||||
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 heat_sources as hs # noqa: E402
|
||||
|
||||
UPS = {'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'office'}
|
||||
PC = {'key': 'pc', 'name': 'Gaming PC', 'sensor': 'sensor.pc_power', 'zone': 'office', 'included_in': 'ups'}
|
||||
|
||||
|
||||
def sources(*items, zones=('office', 'living')):
|
||||
return hs.build_sources(((item, None) for item in items), zones)
|
||||
|
||||
|
||||
def run(tracker, powers, start=0.0, step=300.0):
|
||||
"""Feed one reading per poll; return the tracker states after each poll."""
|
||||
states = []
|
||||
for k, readings in enumerate(powers):
|
||||
tracker.update(start + k * step, readings)
|
||||
states.append({key: dict(info) for key, info in tracker.state.items()})
|
||||
return states
|
||||
|
||||
|
||||
class PowerTests(unittest.TestCase):
|
||||
def test_units_are_normalized_and_non_heat_units_rejected(self):
|
||||
self.assertEqual(hs.normalize_power('600', 'W'), 600)
|
||||
self.assertEqual(hs.normalize_power(0.6, ' kW '), 600)
|
||||
self.assertEqual(hs.normalize_power(600000, 'mW'), 600)
|
||||
for raw, unit in (('600', 'VA'), ('35', '%'), ('600', None), ('600', ''), ('-5', 'W'),
|
||||
('nan', 'W'), ('abc', 'W'), ('30000', 'W'), ('600', 7)):
|
||||
with self.subTest(raw=raw, unit=unit):
|
||||
self.assertIsNone(hs.normalize_power(raw, unit))
|
||||
|
||||
def test_build_sources_validates_zones_duplicates_and_inclusion(self):
|
||||
built = sources(UPS, PC, {**UPS, 'key': 'ups2'}, {**PC, 'key': 'pc2', 'sensor': 'sensor.x', 'zone': 'gone'},
|
||||
{'key': 'nas', 'sensor': 'sensor.nas', 'zone': 'office', 'included_in': 'pc'},
|
||||
{'key': 'tv', 'sensor': 'sensor.tv', 'zone': 'living', 'included_in': 'missing'})
|
||||
self.assertEqual([(s.key, s.included_in) for s in built], [('ups', None), ('pc', 'ups'), ('nas', None),
|
||||
('tv', None)])
|
||||
|
||||
def test_included_child_is_not_double_counted(self):
|
||||
built = sources(UPS, PC)
|
||||
self.assertEqual(hs.effective_power(built, {'ups': 600, 'pc': 400}), {'ups': 200, 'pc': 400})
|
||||
# Without the child reading the split is unknown; timing noise is clamped.
|
||||
self.assertEqual(hs.effective_power(built, {'ups': 600, 'pc': None}), {'ups': None, 'pc': None})
|
||||
self.assertEqual(hs.effective_power(built, {'ups': 380, 'pc': 400})['ups'], 0)
|
||||
# A disabled child stays part of its parent's measurement.
|
||||
built = sources(UPS, {**PC, 'enabled': False})
|
||||
self.assertEqual(hs.effective_power(built, {'ups': 600, 'pc': None}), {'ups': 600})
|
||||
|
||||
def test_baseline_is_the_persistent_load_not_the_mean(self):
|
||||
estimator = hs.BaselineEstimator()
|
||||
for _ in range(11):
|
||||
estimator.add(200)
|
||||
self.assertIsNone(estimator.value()) # less than an hour of data
|
||||
for k in range(288):
|
||||
estimator.add(600 if k % 10 < 3 else 200) # gaming 30 % of the time
|
||||
self.assertEqual(estimator.value(), 200)
|
||||
self.assertEqual(len(estimator.samples), 288)
|
||||
self.assertEqual(hs.BaselineEstimator([200, 'x', -5, float('nan'), 300]).samples, hs.deque([200.0, 300.0]))
|
||||
|
||||
def test_response_and_forecasts(self):
|
||||
filt = hs.ResponseFilter(15)
|
||||
self.assertEqual(filt.advance(0.0, 0), 0.0)
|
||||
self.assertAlmostEqual(filt.advance(0.4, 900), 0.4 * (1 - exp(-1)))
|
||||
self.assertEqual(hs.forecast_internal(0.4, 3, None), [0.4] * 3)
|
||||
self.assertEqual(hs.forecast_internal(0.4, 2, 0), [0.0, 0.0])
|
||||
decay = hs.forecast_internal(0.4, 12, 30)
|
||||
self.assertAlmostEqual(decay[5], 0.2) # one half-life after 30 minutes
|
||||
response = hs.forecast_response(0.0, 0.4, 24, 15, 30)
|
||||
self.assertLess(max(response), 0.4) # the PC is not assumed to run on at full power
|
||||
self.assertLess(response[-1], max(response))
|
||||
|
||||
|
||||
class TrackerTests(unittest.TestCase):
|
||||
def test_gaming_session_drives_zone_input_with_lag_and_settling(self):
|
||||
tracker = hs.HeatSourceTracker(sources(UPS))
|
||||
idle = [{'ups': 200.0}] * 12
|
||||
states = run(tracker, idle + [{'ups': 600.0}] * 6 + idle[:6])
|
||||
self.assertIsNone(states[10]['ups']['variable_kw']) # baseline needs an hour of data
|
||||
self.assertEqual(states[11]['ups']['baseline_w'], 200)
|
||||
# The poll that sees 600 W has not yet had any extra heat: power is held over the interval.
|
||||
self.assertEqual((states[12]['ups']['variable_kw'], states[12]['ups']['response_kw']), (0.4, 0.0))
|
||||
self.assertAlmostEqual(states[13]['ups']['response_kw'], 0.4 * (1 - exp(-1 / 3)))
|
||||
self.assertGreater(states[18]['ups']['response_kw'], states[19]['ups']['response_kw']) # PC off: decays
|
||||
self.assertGreater(states[19]['ups']['response_kw'], 0.1) # but the room still holds the heat
|
||||
self.assertEqual(tracker.zone_input('office'), states[-1]['ups']['response_kw'])
|
||||
self.assertEqual(tracker.zone_input('living'), 0.0)
|
||||
|
||||
def test_short_spike_barely_moves_response_or_baseline(self):
|
||||
tracker = hs.HeatSourceTracker(sources(UPS), {'baselines': {'ups': [200.0] * 24}})
|
||||
states = run(tracker, [{'ups': 200.0}] * 3 + [{'ups': 900.0}] + [{'ups': 200.0}] * 4)
|
||||
peak = max(s['ups']['response_kw'] for s in states)
|
||||
self.assertAlmostEqual(peak, 0.7 * (1 - exp(-1 / 3))) # one 5-minute interval of +700 W
|
||||
self.assertLess(peak, 0.2)
|
||||
self.assertEqual(states[-1]['ups']['baseline_w'], 200)
|
||||
|
||||
def test_unknown_power_resets_until_settled_again(self):
|
||||
tracker = hs.HeatSourceTracker(sources(UPS), {'baselines': {'ups': [200.0] * 24}})
|
||||
run(tracker, [{'ups': 200.0}] * 7)
|
||||
self.assertEqual(tracker.zone_input('office'), 0.0) # saved baseline: usable immediately
|
||||
tracker.update(7 * 300, {'ups': 200.0})
|
||||
tracker.update(8 * 300, {'ups': None})
|
||||
self.assertFalse(tracker.state['ups']['settled'] and tracker.state['ups']['response_kw'] is None)
|
||||
tracker.update(9 * 300, {'ups': 200.0})
|
||||
self.assertIsNone(tracker.zone_input('office')) # restarted: 2 time constants to settle
|
||||
run(tracker, [{'ups': 200.0}] * 6, start=10 * 300)
|
||||
self.assertEqual(tracker.zone_input('office'), 0.0)
|
||||
|
||||
def test_manual_baseline_fraction_and_round_trip(self):
|
||||
built = sources({**UPS, 'baseline_mode': 'manual', 'baseline_w': 150, 'heat_fraction': 0.5})
|
||||
tracker = hs.HeatSourceTracker(built)
|
||||
tracker.update(0, {'ups': 350.0})
|
||||
self.assertEqual(tracker.state['ups']['variable_kw'], 0.1)
|
||||
restored = hs.HeatSourceTracker(built, tracker.as_dict())
|
||||
self.assertEqual(list(restored._baselines['ups'].samples), [350.0])
|
||||
|
||||
def test_form_validation(self):
|
||||
others = sources(UPS)
|
||||
cases = {
|
||||
'source_name_required': {'name': ' '},
|
||||
'sensor_in_other_source': {'name': 'PC', 'sensor': 'sensor.ups_power'},
|
||||
'invalid_parent_source': {'name': 'NAS', 'sensor': 'sensor.nas', 'included_in': 'missing'},
|
||||
'baseline_required': {'name': 'PC', 'sensor': 'sensor.pc', 'baseline_mode': 'manual'},
|
||||
None: {'name': 'PC', 'sensor': 'sensor.pc', 'included_in': 'ups'},
|
||||
}
|
||||
for expected, data in cases.items():
|
||||
with self.subTest(expected=expected):
|
||||
self.assertEqual(hs.validate_source(data, others), expected)
|
||||
nested = sources(UPS, PC)
|
||||
self.assertEqual(hs.validate_source({'name': 'NAS', 'sensor': 'sensor.nas', 'included_in': 'pc'}, nested),
|
||||
'invalid_parent_source')
|
||||
self.assertEqual(hs.validate_source({'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power',
|
||||
'included_in': 'other'}, [s for s in nested if s.key != 'ups']
|
||||
+ sources({'key': 'other', 'sensor': 'sensor.o', 'zone': 'office'})),
|
||||
'source_has_children')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Generic named-feature RLS learner, without Home Assistant."""
|
||||
import random
|
||||
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 learning as lm # noqa: E402
|
||||
|
||||
FEATURES = ['loss', 'heat', 'bias', 'internal']
|
||||
TRUE = {'loss': 0.004, 'heat': 0.12, 'bias': 0.0005, 'internal': 0.15}
|
||||
|
||||
|
||||
def synthetic(learner, steps=3000, seed=1, noise=0.003, gaming=True):
|
||||
"""Samples from dT = sum(theta*x) + noise with realistic, varied inputs."""
|
||||
rng = random.Random(seed)
|
||||
for k in range(steps):
|
||||
x = {'loss': -20 + 20 * rng.random(), 'heat': (k // 7) % 3 / 2,
|
||||
'bias': 1.0, 'internal': 0.4 if gaming and (k // 40) % 5 == 0 else 0.0}
|
||||
y = sum(TRUE[f] * x[f] for f in TRUE) + rng.gauss(0, noise)
|
||||
learner.learn([x[f] for f in learner.features], y, command=x['heat'] > 0)
|
||||
return learner
|
||||
|
||||
|
||||
class FeatureLearnerTests(unittest.TestCase):
|
||||
def test_recovers_coefficients_and_reports_confidence(self):
|
||||
learner = synthetic(lm.FeatureLearner(FEATURES))
|
||||
for name, value in TRUE.items():
|
||||
with self.subTest(feature=name):
|
||||
self.assertAlmostEqual(learner.coefficient(name), value, delta=0.1 * value + 0.001)
|
||||
info = learner.coefficients()
|
||||
self.assertTrue(info['internal']['identified'])
|
||||
self.assertGreater(info['internal']['confidence'], 0.8)
|
||||
# Standard error in °C/5 min per kW: small against the coefficient.
|
||||
self.assertLess(info['internal']['std'], 0.01)
|
||||
self.assertAlmostEqual(learner.noise_var ** .5, 0.003, delta=0.001)
|
||||
self.assertEqual(learner.count + learner.rejected_count, 3000)
|
||||
|
||||
def test_unexercised_feature_stays_at_prior_without_confidence(self):
|
||||
learner = synthetic(lm.FeatureLearner(FEATURES), gaming=False)
|
||||
info = learner.coefficients()['internal']
|
||||
self.assertEqual((info['value'], info['samples'], info['identified']), (0.0, 0, False))
|
||||
self.assertEqual(info['confidence'], 0)
|
||||
# Windup bound: the unexcited variance never exceeds the prior.
|
||||
i = learner.features.index('internal')
|
||||
self.assertLessEqual(learner.p[i][i], lm.spec_for('internal').variance)
|
||||
|
||||
def test_bounds_and_linked_directional_wind(self):
|
||||
learner = lm.FeatureLearner(['internal'])
|
||||
for _ in range(200):
|
||||
learner.learn([1.0], -0.2) # cooling with more power: unphysical
|
||||
self.assertEqual(learner.coefficient('internal'), 0.0)
|
||||
wind = lm.FeatureLearner(['vent_wind', 'vent_wind_dir'])
|
||||
for k in range(500):
|
||||
x = [-50.0, -50.0 * (1 if k % 2 else -1)]
|
||||
wind.learn(x, 0.02 * x[1]) # only directional signal, of the "wrong" sign
|
||||
self.assertLessEqual(abs(wind.coefficient('vent_wind_dir')), wind.coefficient('vent_wind') + 1e-12)
|
||||
|
||||
def test_invalid_samples_rejected(self):
|
||||
learner = lm.FeatureLearner(['loss', 'bias'])
|
||||
for x, y in (([1.0], 0.0), ([float('nan'), 1.0], 0.0), ([1.0, 1.0], 0.9),
|
||||
([1.0, 1.0], float('inf'))):
|
||||
self.assertFalse(learner.learn(x, y))
|
||||
self.assertEqual((learner.count, learner.rejected_count), (0, 4))
|
||||
with self.assertRaises(ValueError):
|
||||
lm.FeatureLearner(['loss', 'loss'])
|
||||
with self.assertRaises(KeyError):
|
||||
lm.FeatureLearner(['unknown'])
|
||||
|
||||
def test_round_trip_and_feature_remapping(self):
|
||||
learner = synthetic(lm.FeatureLearner(FEATURES), steps=800)
|
||||
same = lm.FeatureLearner.from_dict(learner.as_dict(), FEATURES)
|
||||
self.assertEqual((same.theta, same.p, same.count, same.active, same.noise_var),
|
||||
(learner.theta, learner.p, learner.count, learner.active, learner.noise_var))
|
||||
# Adding a neighbour keeps every learned coefficient; the new one starts at its prior.
|
||||
grown = lm.FeatureLearner.from_dict(learner.as_dict(), FEATURES + ['couple:bedroom'])
|
||||
for name in FEATURES:
|
||||
self.assertEqual(grown.coefficient(name), learner.coefficient(name))
|
||||
self.assertEqual(grown.coefficient('couple:bedroom'), 0.0)
|
||||
self.assertEqual(grown.p[4][4], lm.spec_for('couple').variance)
|
||||
self.assertEqual(grown.p[0][1], learner.p[0][1])
|
||||
# Removing the heat source keeps the rest, including their covariance.
|
||||
shrunk = lm.FeatureLearner.from_dict(learner.as_dict(), ['loss', 'heat', 'bias'])
|
||||
self.assertEqual(shrunk.theta, learner.theta[:3])
|
||||
self.assertEqual(shrunk.p[2][0], learner.p[2][0])
|
||||
|
||||
def test_malformed_saved_data_starts_fresh(self):
|
||||
good = synthetic(lm.FeatureLearner(['loss', 'bias']), steps=50).as_dict()
|
||||
fresh = lm.FeatureLearner(['loss', 'bias'])
|
||||
for change in ({'features': ['loss', 'loss']}, {'theta': [0.1]}, {'p': [[1, 0], [0, -1]]},
|
||||
{'p': [[1, 0.5], [0, 1]]}, {'count': -1}, {'active': [1]},
|
||||
{'theta': [float('nan'), 0]}, {'on_count': 99}, {'residual_sq': -1},
|
||||
{'residual_weight': 2}):
|
||||
with self.subTest(change=change):
|
||||
data = dict(good)
|
||||
data.update(change)
|
||||
restored = lm.FeatureLearner.from_dict(data, ['loss', 'bias'])
|
||||
self.assertEqual((restored.theta, restored.count), (fresh.theta, 0))
|
||||
for garbage in (None, [], 'x', {}):
|
||||
self.assertEqual(lm.FeatureLearner.from_dict(garbage, ['loss']).count, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+98
-8
@@ -1,14 +1,15 @@
|
||||
"""Offline tests: run python -m unittest discover -s tests -v."""
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc' / 'physics.py'
|
||||
spec = importlib.util.spec_from_file_location('heating_mpc_physics', path)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
import sys
|
||||
sys.modules[spec.name] = m
|
||||
spec.loader.exec_module(m)
|
||||
import types
|
||||
|
||||
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||
# Fake only the package container, to avoid importing Home Assistant from __init__.
|
||||
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
||||
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
||||
from heating_mpc import physics as m # noqa: E402
|
||||
|
||||
|
||||
class ModelTests(unittest.TestCase):
|
||||
@@ -44,6 +45,14 @@ class ModelTests(unittest.TestCase):
|
||||
learners, False, 3600, horizon_minutes=60)
|
||||
self.assertTrue(out['proposal'])
|
||||
|
||||
def test_zone_target_offsets_shift_comfort(self):
|
||||
learners = {'r': m.Learner()}
|
||||
args = ({'r': 20.8}, 8, 0, 22, {'r': 1}, learners, False, 3600)
|
||||
self.assertTrue(m.optimize(*args, horizon_minutes=60)['proposal'])
|
||||
self.assertFalse(m.optimize(*args, horizon_minutes=60, target_offsets={'r': -1.5})['proposal'])
|
||||
with self.assertRaises(ValueError):
|
||||
m.optimize(*args, horizon_minutes=60, target_offsets={'r': float('nan')})
|
||||
|
||||
def test_minimum_duration_on_and_off(self):
|
||||
learners = {'r': m.Learner()}
|
||||
# Could not switch OFF during first 15 minutes if just turned ON.
|
||||
@@ -76,7 +85,9 @@ class ModelTests(unittest.TestCase):
|
||||
temps, lag = m.simulate_step(temps, 5, lag, bit, learners)
|
||||
if index == 3:
|
||||
self.assertEqual(out['predicted_60m']['r'], round(temps['r'], 3))
|
||||
self.assertEqual(out['trajectory_15min']['r'][index], round(temps['r'], 3))
|
||||
self.assertEqual(out['predicted_end']['r'], round(temps['r'], 3))
|
||||
self.assertEqual(len(out['trajectory_15min']['r']), 6)
|
||||
|
||||
def test_jitter_normalized_and_rejection_counted(self):
|
||||
nominal, delayed = m.Learner(), m.Learner()
|
||||
@@ -85,12 +96,17 @@ class ModelTests(unittest.TestCase):
|
||||
for a, b in zip(nominal.theta, delayed.theta):
|
||||
self.assertAlmostEqual(a, b)
|
||||
self.assertFalse(delayed.learn(20, 25, 5, .5, command=False))
|
||||
self.assertEqual((delayed.count, delayed.on_count, delayed.off_count), (1, 1, 0))
|
||||
self.assertFalse(delayed.learn(20, 20.1, 5, 1.5, command=False))
|
||||
self.assertEqual((delayed.count, delayed.on_count, delayed.off_count, delayed.rejected_count),
|
||||
(1, 1, 0, 2))
|
||||
self.assertEqual(m.Learner.from_dict(delayed.as_dict()).rejected_count, 2)
|
||||
|
||||
def test_corrupt_model_restore_is_atomic(self):
|
||||
for changes in ({'theta': [-1, .1, 0]}, {'p': [[-1, 0, 0], [0, 1, 0], [0, 0, 1]]},
|
||||
{'count': float('inf')}, {'count': '5'}, {'error_sum': float('nan')},
|
||||
{'off_count': 1}, {'theta': [float('nan'), 0, 0]}, {'p': None}):
|
||||
{'off_count': 1}, {'theta': [float('nan'), 0, 0]}, {'p': None},
|
||||
{'p': [[1, .5, 0], [0, 1, 0], [0, 0, 1]]}, {'rejected_count': -1},
|
||||
{'rejected_count': '3'}):
|
||||
with self.subTest(changes=changes):
|
||||
data = m.Learner().as_dict()
|
||||
data.update(changes)
|
||||
@@ -109,6 +125,80 @@ class ModelTests(unittest.TestCase):
|
||||
with self.subTest(key=key), self.assertRaises(ValueError):
|
||||
m.optimize(**args)
|
||||
|
||||
def test_covariance_stays_bounded_without_heat_excitation(self):
|
||||
learn = m.Learner()
|
||||
t = 22.0
|
||||
for _ in range(30000): # about 104 days with the relay off
|
||||
value = t + 0.003 * (15 - t)
|
||||
self.assertTrue(learn.learn(t, value, 15, 0.0, command=False))
|
||||
t = value
|
||||
for i in range(3):
|
||||
self.assertLessEqual(learn.p[i][i], m.PRIOR_P[i][i])
|
||||
for j in range(3):
|
||||
self.assertEqual(learn.p[i][j], learn.p[j][i])
|
||||
restored = m.Learner.from_dict(learn.as_dict())
|
||||
self.assertEqual(restored.count, 30000)
|
||||
self.assertEqual(restored.p, learn.p)
|
||||
|
||||
def test_confidence_reflects_excitation_not_sample_count(self):
|
||||
# Two weeks of samples, relay always off, a steady 15 degC difference to outside.
|
||||
steady = m.Learner()
|
||||
for _ in range(4000):
|
||||
steady.learn(20.0, 20.0 + 0.003 * -15 + 0.001, 5.0, 0.0, command=False)
|
||||
confidence = steady.confidence()
|
||||
self.assertEqual(steady.count, 4000)
|
||||
self.assertEqual(confidence['heat'], 0) # never exercised
|
||||
self.assertLess(confidence['loss'], 0.8) # not separable from bias
|
||||
self.assertLess(confidence['bias'], 0.8)
|
||||
# Fewer samples, with the relay cycling and a cold and a mild spell each day.
|
||||
varied = m.Learner()
|
||||
t, lag = 20.0, 0.0
|
||||
for step in range(1200):
|
||||
heater = int(step % 96 < 35)
|
||||
lag = lag + m.HEAT_LAG * (heater - lag)
|
||||
outside = -2 + 14 * ((step // 144) % 2)
|
||||
value = t + 0.003 * (outside - t) + 0.055 * lag + 0.001
|
||||
varied.learn(t, value, outside, lag, command=bool(heater))
|
||||
t = value
|
||||
self.assertTrue(all(value > 0.8 for value in varied.confidence().values()), varied.confidence())
|
||||
# Recalibration starts confidence over.
|
||||
varied.recalibrate()
|
||||
self.assertEqual(set(varied.confidence().values()), {0})
|
||||
|
||||
def test_restore_tolerates_round_off_and_bounds_wound_up_covariance(self):
|
||||
# Shape of a v0.2.1 model after a summer: huge heat variance, tiny asymmetry.
|
||||
data = m.Learner(count=40000, off_count=40000).as_dict()
|
||||
data['p'] = [[0.03, 1.0, 1e-3], [1.0 + 1e-7, 8.7e12, 0.0], [1e-3, 0.0, 0.0012]]
|
||||
restored = m.Learner.from_dict(data)
|
||||
self.assertEqual(restored.count, 40000)
|
||||
self.assertEqual(restored.p[1][1], m.PRIOR_P[1][1])
|
||||
self.assertEqual(restored.p[0][0], m.PRIOR_P[0][0])
|
||||
self.assertEqual(restored.p[0][1], restored.p[1][0])
|
||||
|
||||
def test_lag_helpers(self):
|
||||
self.assertAlmostEqual(m.advance_lag(0, True, 300), m.HEAT_LAG)
|
||||
self.assertAlmostEqual(m.advance_lag(1, False, 1e9), 0)
|
||||
self.assertEqual(m.lag_from_time_in_state(True, 0), (0.5, 0.5))
|
||||
lag, half_width = m.lag_from_time_in_state(False, 3600)
|
||||
self.assertAlmostEqual(lag, half_width)
|
||||
self.assertLess(half_width, 0.03)
|
||||
# Any starting lag in [0, 1] ends inside the stated interval.
|
||||
for start in (0.0, 0.3, 1.0):
|
||||
for command in (False, True):
|
||||
lag, half_width = m.lag_from_time_in_state(command, 900)
|
||||
self.assertLessEqual(abs(m.advance_lag(start, command, 900) - lag), half_width + 1e-12)
|
||||
|
||||
def test_relay_segments(self):
|
||||
seg = m.relay_segments
|
||||
self.assertEqual(seg(False, 0, [], 300), [(False, 300)])
|
||||
self.assertEqual(seg(False, 0, [(60, 'on'), (200, 'off')], 300),
|
||||
[(False, 60), (True, 140), (False, 100)])
|
||||
self.assertEqual(seg(True, 0, [(300, 'off')], 300), [(True, 300), (False, 0)])
|
||||
for changes in ([(60, 'unavailable')], [(60, 'off')], [(200, 'on'), (60, 'off')],
|
||||
[(-1, 'on')], [(301, 'on')]):
|
||||
with self.subTest(changes=changes):
|
||||
self.assertIsNone(seg(False, 0, changes, 300))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Static tripwire for the observation-only rule (AGENTS.md safety rule 1).
|
||||
|
||||
The HA suite rejects service calls at runtime; this check also covers code paths
|
||||
no test reaches. A failure here means actuator code was added: that needs the
|
||||
separately approved control milestone, not an edit to this list.
|
||||
"""
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||
|
||||
# Calling, registering or forwarding HA services, and entity write methods.
|
||||
FORBIDDEN_ATTRIBUTES = {
|
||||
'services', 'async_call', 'async_register', 'call_service',
|
||||
'async_turn_on', 'async_turn_off', 'async_toggle', 'turn_on', 'turn_off', 'toggle',
|
||||
'async_set_temperature', 'async_set_hvac_mode', 'async_set_preset_mode',
|
||||
'set_temperature', 'set_hvac_mode', 'set_preset_mode',
|
||||
}
|
||||
FORBIDDEN_STRINGS = {
|
||||
'turn_on', 'turn_off', 'toggle', 'set_temperature', 'set_hvac_mode',
|
||||
'set_preset_mode', 'call_service',
|
||||
}
|
||||
READ_ONLY_PLATFORMS = {'SENSOR', 'BINARY_SENSOR'}
|
||||
|
||||
|
||||
class ShadowOnlyInvariant(unittest.TestCase):
|
||||
def test_no_actuator_code_in_integration(self):
|
||||
problems = []
|
||||
for path in sorted(ROOT.glob('*.py')):
|
||||
for node in ast.walk(ast.parse(path.read_text(), str(path))):
|
||||
where = f'{path.name}:{getattr(node, "lineno", "?")}'
|
||||
if isinstance(node, ast.Attribute) and node.attr in FORBIDDEN_ATTRIBUTES:
|
||||
problems.append(f'{where} uses .{node.attr}')
|
||||
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in FORBIDDEN_ATTRIBUTES:
|
||||
problems.append(f'{where} defines {node.name}()')
|
||||
elif isinstance(node, ast.Constant) and node.value in FORBIDDEN_STRINGS:
|
||||
problems.append(f'{where} names service {node.value!r}')
|
||||
elif (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name)
|
||||
and node.value.id == 'Platform' and node.attr not in READ_ONLY_PLATFORMS):
|
||||
problems.append(f'{where} forwards writable platform {node.attr}')
|
||||
self.assertEqual(problems, [])
|
||||
|
||||
def test_tripwire_detects_a_service_call(self):
|
||||
tree = ast.parse('await hass.services.async_call("switch", "turn_on", {})')
|
||||
attrs = {n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute)}
|
||||
strings = {n.value for n in ast.walk(tree) if isinstance(n, ast.Constant)}
|
||||
self.assertTrue(attrs & FORBIDDEN_ATTRIBUTES)
|
||||
self.assertTrue(strings & FORBIDDEN_STRINGS)
|
||||
|
||||
def test_only_read_only_entity_platform_files(self):
|
||||
platforms = {'sensor', 'binary_sensor', 'switch', 'climate', 'number', 'select',
|
||||
'button', 'water_heater', 'valve', 'fan', 'light'}
|
||||
present = {p.stem for p in ROOT.glob('*.py')} & platforms
|
||||
self.assertEqual(present, {'sensor', 'binary_sensor'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Windows, wind exposure, ventilation inputs and recovery, without Home Assistant."""
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from math import cos, exp, radians
|
||||
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 windows as wm # noqa: E402
|
||||
|
||||
WEATHER = {'temperature': 2.0, 'wind': 4.0, 'wind_direction': 204.0, 'wind_gust': 9.0}
|
||||
|
||||
|
||||
def window(key, zone='bed', **extra):
|
||||
return {'key': key, 'name': key.title(), 'sensor': f'binary_sensor.{key}', 'zone': zone, **extra}
|
||||
|
||||
|
||||
class GeometryTests(unittest.TestCase):
|
||||
def test_wind_relative_to_facade(self):
|
||||
# The apartment's façade faces about 345° (NNW); OpenWeatherMap wind from 204°.
|
||||
self.assertAlmostEqual(wm.angle_difference(204, 345), -141)
|
||||
self.assertAlmostEqual(wm.facing_cos(204, 345), cos(radians(141)))
|
||||
self.assertAlmostEqual(wm.facing_cos(345, 345), 1) # blowing straight at the window
|
||||
self.assertAlmostEqual(wm.facing_cos(165, 345), -1) # from behind the building
|
||||
self.assertAlmostEqual(wm.facing_cos(75, 345), 0, places=9) # crosswind
|
||||
# Around north, differences wrap instead of jumping by 360°.
|
||||
self.assertAlmostEqual(wm.angle_difference(5, 355), 10)
|
||||
self.assertAlmostEqual(wm.angle_difference(355, 5), -10)
|
||||
self.assertAlmostEqual(wm.facing_cos(5, 355), cos(radians(10)))
|
||||
|
||||
def test_circular_mean(self):
|
||||
self.assertAlmostEqual(wm.circular_mean([350, 10]) % 360 if wm.circular_mean([350, 10]) < 359 else 0, 0)
|
||||
self.assertAlmostEqual(wm.circular_mean([350, 20], [1, 1]), 5)
|
||||
self.assertIsNone(wm.circular_mean([90, 270]))
|
||||
self.assertAlmostEqual(wm.circular_mean([0, 90], [3, 1]), 18.4349488, places=5)
|
||||
|
||||
def test_contact_states_and_open_fraction(self):
|
||||
self.assertEqual([wm.contact_open(s) for s in ('on', 'off', 'unknown', 'unavailable', None)],
|
||||
[True, False, None, None, None])
|
||||
self.assertEqual([wm.contact_open(s, inverted=True) for s in ('on', 'off')], [False, True])
|
||||
self.assertEqual(wm.open_fraction(True, True, 0, 100, 400), 1.0)
|
||||
self.assertEqual(wm.open_fraction(False, False, 0, 100, 400), 0.0)
|
||||
self.assertAlmostEqual(wm.open_fraction(False, True, 220, 100, 400), 0.6) # opened 120 s in
|
||||
self.assertAlmostEqual(wm.open_fraction(True, False, 220, 100, 400), 0.4) # closed 120 s in
|
||||
self.assertIsNone(wm.open_fraction(False, False, 220, 100, 400)) # round trip: unknown
|
||||
self.assertIsNone(wm.open_fraction(None, True, 0, 100, 400))
|
||||
|
||||
def test_ventilation_vector(self):
|
||||
features = list(wm.VENTILATION_FEATURES)
|
||||
inputs = {'opening': 0.5, 'facing': -0.3, 'other_opening': 1.0, 'recovery': 0.0, 't_ref': None,
|
||||
'wind': 4.0, 'gust': 9.0}
|
||||
for value, expected in zip(wm.ventilation_vector(features, 20.0, 2.0, inputs),
|
||||
[-9.0, -36.0, 21.6, -45.0, -9.0, 0.0]):
|
||||
self.assertAlmostEqual(value, expected)
|
||||
self.assertIsNone(wm.ventilation_vector(['vent_wind'], 20, 2, {**inputs, 'wind': None}))
|
||||
# Calm air: wind terms vanish, natural ventilation stays.
|
||||
calm = wm.ventilation_vector(features, 20.0, 2.0, {**inputs, 'wind': 0.0, 'gust': 0.0})
|
||||
self.assertEqual([round(v, 9) for v in calm[1:4]], [0.0, 0.0, 0.0])
|
||||
self.assertEqual(calm[0], -9.0)
|
||||
self.assertEqual(wm.ventilation_vector(['vent_natural'], 20, 2, {**inputs, 'wind': None}), [-9.0])
|
||||
self.assertIsNone(wm.ventilation_vector(['recovery'], 20, 2, {**inputs, 'recovery': 0.5}))
|
||||
self.assertEqual(wm.ventilation_vector(['recovery'], 19, 2, {**inputs, 'recovery': 0.5, 't_ref': 21}),
|
||||
[1.0])
|
||||
|
||||
def test_window_config_validation(self):
|
||||
built = wm.build_windows([
|
||||
(window('a', azimuth=705), None), (window('b', opening_factor=9), None),
|
||||
(window('c', zone='gone'), None), (window('a'), None),
|
||||
({**window('d'), 'sensor': 'sensor.d'}, None), (window('e', inverted=True), 's5')], ['bed'])
|
||||
self.assertEqual([(w.key, w.azimuth, w.inverted) for w in built], [('a', 345.0, False), ('e', 345.0, True)])
|
||||
self.assertEqual(wm.validate_window({'name': ' '}, built), 'window_name_required')
|
||||
self.assertEqual(wm.validate_window({'name': 'x', 'sensor': 'binary_sensor.a'}, built),
|
||||
'sensor_in_other_window')
|
||||
self.assertIsNone(wm.validate_window({'name': 'x', 'sensor': 'binary_sensor.x'}, built))
|
||||
|
||||
|
||||
class TrackerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tracker = wm.VentilationTracker(wm.build_windows([(window('bed_window'), None)], ['bed']),
|
||||
recovery_minutes=20)
|
||||
self.t = 1_000_000.0
|
||||
self.changed = 0.0
|
||||
|
||||
def poll(self, state, temp, changed_offset=None, weather=WEATHER):
|
||||
if changed_offset is not None:
|
||||
self.changed = self.t - changed_offset
|
||||
self.tracker.update(self.t, {'bed_window': (state, self.changed)}, {'bed': temp}, weather)
|
||||
inputs = self.tracker.zone_inputs('bed', weather)
|
||||
self.t += 300
|
||||
return inputs
|
||||
|
||||
def test_ventilation_event_and_recovery(self):
|
||||
self.assertFalse(self.poll('off', 21.0)['known']) # no previous poll yet
|
||||
closed = self.poll('off', 21.0)
|
||||
self.assertEqual((closed['opening'], closed['affected']), (0.0, False))
|
||||
opened = self.poll('on', 20.6, changed_offset=180) # opened 3 minutes ago
|
||||
self.assertAlmostEqual(opened['opening'], 0.6)
|
||||
self.assertAlmostEqual(opened['facing'], 0.6 * cos(radians(141)))
|
||||
self.assertTrue(opened['affected'])
|
||||
self.assertEqual(opened['t_ref'], 21.0) # temperature at the poll before opening
|
||||
for temp in (20.2, 19.9):
|
||||
self.assertEqual(self.poll('on', temp)['opening'], 1.0)
|
||||
closing = self.poll('off', 20.0, changed_offset=60) # closed a minute ago
|
||||
self.assertAlmostEqual(closing['opening'], 0.8)
|
||||
self.assertAlmostEqual(closing['recovery'], exp(-60 / 1200))
|
||||
after = [self.poll('off', 20.0 + 0.1 * k) for k in range(12)]
|
||||
self.assertTrue(after[0]['affected'] and after[0]['recovery'] > 0.7)
|
||||
self.assertEqual((after[-1]['recovery'], after[-1]['affected']), (0.0, False)) # ~3 time constants
|
||||
event = self.tracker.events[-1]
|
||||
self.assertEqual((event['temperature_before'], event['lowest_temperature'], event['temperature_at_close']),
|
||||
(21.0, 19.9, 20.0))
|
||||
self.assertEqual((event['duration_minutes'], event['wind_direction'], event['windows']), (17.0, 204.0, ['Bed_Window'])) # 3 min + 15 min - 1 min
|
||||
self.assertEqual(event['temperature_after_minutes'], 30)
|
||||
restored = wm.VentilationTracker(self.tracker.windows, saved=self.tracker.as_dict())
|
||||
self.assertEqual(list(restored.events), list(self.tracker.events))
|
||||
|
||||
def test_unknown_state_blocks_learning_and_multiple_windows_add(self):
|
||||
tracker = wm.VentilationTracker(wm.build_windows(
|
||||
[(window('w1'), None), (window('w2', opening_factor=0.5), None), (window('w3', zone='living'), None)],
|
||||
['bed', 'living']))
|
||||
for t in (0.0, 300.0):
|
||||
tracker.update(t, {'w1': ('on', -10), 'w2': ('on', -10), 'w3': ('on', -10)}, {'bed': 20, 'living': 21},
|
||||
WEATHER)
|
||||
bed = tracker.zone_inputs('bed', WEATHER)
|
||||
self.assertEqual((bed['opening'], bed['other_opening']), (1.5, 1.0)) # living's window is open too
|
||||
tracker.update(600.0, {'w1': ('unavailable', 550), 'w2': ('on', -10), 'w3': ('off', 400)},
|
||||
{'bed': 20, 'living': 21}, WEATHER)
|
||||
bed = tracker.zone_inputs('bed', WEATHER)
|
||||
self.assertEqual((bed['opening'], bed['known'], bed['affected']), (None, False, True))
|
||||
self.assertIsNone(tracker.zone_inputs('kitchen', WEATHER))
|
||||
|
||||
def test_forecast_assumes_closing_then_recovery(self):
|
||||
self.poll('off', 21.0)
|
||||
self.poll('off', 21.0)
|
||||
self.poll('on', 20.5, changed_offset=100)
|
||||
steps = self.tracker.forecast('bed', 12, open_minutes=15, weather=WEATHER, now=self.t)
|
||||
self.assertEqual([s['opening'] for s in steps[:4]], [1.0, 1.0, 1.0, 0.0])
|
||||
self.assertAlmostEqual(steps[0]['facing'], cos(radians(141)))
|
||||
self.assertAlmostEqual(steps[3]['recovery'], exp(-300 / 1200))
|
||||
self.assertGreater(steps[3]['recovery'], steps[6]['recovery'])
|
||||
self.assertEqual(steps[5]['t_ref'], 21.0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class UnknownStateTests(unittest.TestCase):
|
||||
"""OPEN, CLOSED and UNKNOWN are three states: unknown never means closed."""
|
||||
|
||||
def setUp(self):
|
||||
self.tracker = wm.VentilationTracker(wm.build_windows([(window('w'), None)], ['bed']), recovery_minutes=20)
|
||||
self.t = 1_000_000.0
|
||||
|
||||
def poll(self, state, changed_ago=None, temp=20.0):
|
||||
changed = self.t - changed_ago if changed_ago is not None else self.t - 100_000
|
||||
self.tracker.update(self.t, {'w': (state, changed)}, {'bed': temp}, WEATHER)
|
||||
inputs = self.tracker.zone_inputs('bed', WEATHER)
|
||||
forecast = self.tracker.forecast('bed', 6, 15, WEATHER, self.t)
|
||||
self.t += 300
|
||||
return inputs, forecast, dict(self.tracker.window_state['w'])
|
||||
|
||||
def open_for_a_while(self):
|
||||
self.poll('off')
|
||||
self.poll('off', temp=21.0)
|
||||
self.poll('on', changed_ago=100, temp=20.4)
|
||||
self.poll('on', changed_ago=400, temp=20.0)
|
||||
|
||||
def test_open_to_unknown_keeps_the_event_and_assumes_still_open(self):
|
||||
self.open_for_a_while()
|
||||
inputs, forecast, state = self.poll('unavailable', changed_ago=60, temp=19.8)
|
||||
self.assertEqual(len(self.tracker.events), 0) # no confirmed closing without evidence
|
||||
self.assertIn('bed', self.tracker._open_event)
|
||||
self.assertEqual((inputs['known'], inputs['affected'], inputs['recovery']), (False, True, 0.0))
|
||||
self.assertEqual(forecast[0]['opening'], 1.0) # last seen open: still treated as open
|
||||
self.assertEqual((state['open'], state['last_known_open']), (None, True))
|
||||
self.assertIsNotNone(state['unknown_since'])
|
||||
|
||||
def test_unknown_to_open_continues_the_same_event(self):
|
||||
self.open_for_a_while()
|
||||
self.poll('unavailable', changed_ago=60)
|
||||
self.poll('unavailable', changed_ago=360)
|
||||
inputs, _, state = self.poll('on', changed_ago=30) # sensor back, window still open
|
||||
self.assertEqual((len(self.tracker.events), state['unknown_since']), (0, None))
|
||||
self.assertFalse(inputs['known']) # the interval itself was unknown
|
||||
inputs, _, _ = self.poll('on', changed_ago=330)
|
||||
self.assertEqual((inputs['known'], inputs['opening']), (True, 1.0))
|
||||
self.poll('off', changed_ago=120, temp=20.2) # confirmed close
|
||||
event = self.tracker.events[-1]
|
||||
self.assertEqual((event['closed_confirmed'], event['sensor_unknown_minutes']), (True, 10.0)) # two polls
|
||||
self.assertIsNotNone(event['duration_minutes'])
|
||||
|
||||
def test_unknown_to_closed_records_an_unconfirmed_close(self):
|
||||
self.open_for_a_while()
|
||||
self.poll('unavailable', changed_ago=60)
|
||||
inputs, forecast, state = self.poll('off', changed_ago=20, temp=20.1)
|
||||
event = self.tracker.events[-1]
|
||||
self.assertEqual((event['closed'], event['closed_confirmed'], event['duration_minutes']), (None, False, None))
|
||||
self.assertGreater(event['duration_max_minutes'], 0)
|
||||
self.assertEqual((state['open'], state['last_known_open'], state['unknown_since']), (False, False, None))
|
||||
# Recovery is assumed from when the sensor reported closed, but its timing is
|
||||
# unknown, so neither the ventilation nor the envelope model learns from it.
|
||||
self.assertGreater(inputs['recovery'], 0.9)
|
||||
self.assertEqual((inputs['known'], inputs['affected'], inputs['opening']), (False, True, None))
|
||||
self.assertEqual(forecast[0]['opening'], 0.0)
|
||||
self.assertGreater(forecast[0]['recovery'], 0.0)
|
||||
|
||||
def test_closed_to_unknown_assumes_closed_but_blocks_learning(self):
|
||||
self.poll('off')
|
||||
self.poll('off')
|
||||
inputs, forecast, state = self.poll('unavailable', changed_ago=120)
|
||||
self.assertEqual((inputs['known'], inputs['affected']), (False, True))
|
||||
self.assertEqual(forecast[0]['opening'], 0.0)
|
||||
self.assertEqual((state['open'], state['last_known_open']), (None, False))
|
||||
self.assertEqual(len(self.tracker.events), 0)
|
||||
|
||||
def test_unknown_to_open_after_a_closed_window_starts_an_unconfirmed_event(self):
|
||||
self.poll('off')
|
||||
self.poll('off', temp=21.0)
|
||||
self.poll('unavailable', changed_ago=60, temp=20.6)
|
||||
self.poll('on', changed_ago=30, temp=20.0) # opened at some point during the outage
|
||||
event = self.tracker._open_event['bed']
|
||||
self.assertEqual((event['opened_confirmed'], event['temperature_before']), (False, None))
|
||||
self.poll('on', changed_ago=330, temp=19.8)
|
||||
inputs, _, _ = self.poll('off', changed_ago=100, temp=19.9)
|
||||
self.assertTrue(self.tracker.events[-1]['closed_confirmed'])
|
||||
# Without the temperature before ventilation, recovery cannot be learned.
|
||||
self.assertFalse(inputs['known'])
|
||||
|
||||
def test_recovery_after_prolonged_unavailability(self):
|
||||
self.open_for_a_while()
|
||||
for _ in range(72): # six hours without the sensor
|
||||
self.poll('unavailable', changed_ago=60)
|
||||
self.assertEqual(len(self.tracker.events), 0)
|
||||
inputs, _, _ = self.poll('off', changed_ago=60)
|
||||
event = self.tracker.events[-1]
|
||||
self.assertFalse(event['closed_confirmed'])
|
||||
self.assertGreaterEqual(event['duration_max_minutes'], 360)
|
||||
self.assertEqual(event['sensor_unknown_minutes'], 360.0)
|
||||
for _ in range(12): # recovery decays, learning resumes
|
||||
inputs, _, _ = self.poll('off')
|
||||
self.assertEqual((inputs['known'], inputs['affected'], inputs['recovery']), (True, False, 0.0))
|
||||
|
||||
def test_multiple_windows_with_one_unknown(self):
|
||||
tracker = wm.VentilationTracker(wm.build_windows([(window('a'), None), (window('b'), None)], ['bed']))
|
||||
for t, a, b in ((0, 'off', 'off'), (300, 'on', 'off'), (600, 'on', 'unavailable'),
|
||||
(900, 'off', 'unavailable')):
|
||||
tracker.update(float(t), {'a': (a, t - 60.0), 'b': (b, t - 60.0)}, {'bed': 20.0}, WEATHER)
|
||||
# Window a closed (confirmed); b was last seen closed: the event ends, confirmed,
|
||||
# but the zone stays out of learning while b's state is unknown.
|
||||
self.assertTrue(tracker.events[-1]['closed_confirmed'])
|
||||
inputs = tracker.zone_inputs('bed', WEATHER)
|
||||
self.assertEqual((inputs['known'], inputs['unknown_windows']), (False, ['B']))
|
||||
|
||||
|
||||
class StartupAndRestartTests(unittest.TestCase):
|
||||
"""Unknown at startup is not closed; saved state comes back as unknown, never live."""
|
||||
|
||||
WINDOWS = wm.build_windows([(window('w'), None)], ['bed'])
|
||||
|
||||
def setUp(self):
|
||||
self.t = 2_000_000.0
|
||||
|
||||
def poll(self, tracker, state, changed_ago=100_000, temp=20.0):
|
||||
tracker.update(self.t, {'w': (state, self.t - changed_ago)}, {'bed': temp}, WEATHER)
|
||||
result = (tracker.zone_inputs('bed', WEATHER), tracker.forecast('bed', 6, 15, WEATHER, self.t),
|
||||
tracker.assumption(self.WINDOWS[0], 15, self.t))
|
||||
self.t += 300
|
||||
return result
|
||||
|
||||
def saved_with(self, *states):
|
||||
tracker = wm.VentilationTracker(self.WINDOWS)
|
||||
for state in states:
|
||||
self.poll(tracker, state, changed_ago=200 if state == 'on' else 100_000, temp=21.0)
|
||||
return tracker, tracker.as_dict()
|
||||
|
||||
def test_unavailable_from_startup_is_unobserved_not_closed(self):
|
||||
tracker = wm.VentilationTracker(self.WINDOWS)
|
||||
for _ in range(3):
|
||||
inputs, forecast, assumed = self.poll(tracker, 'unavailable')
|
||||
self.assertEqual(assumed, {'basis': 'unobserved', 'open_minutes': 0.0, 'uncertain': True})
|
||||
self.assertEqual((inputs['known'], inputs['affected'], inputs['unknown_windows']), (False, True, ['W']))
|
||||
self.assertEqual(forecast[0]['opening'], 0.0) # no ventilation assumed, but flagged uncertain
|
||||
self.assertEqual((len(tracker.events), tracker._open_event), (0, {}))
|
||||
|
||||
def test_first_reading_open_is_an_unconfirmed_opening(self):
|
||||
tracker = wm.VentilationTracker(self.WINDOWS)
|
||||
self.poll(tracker, 'on', changed_ago=30) # we never saw it closed
|
||||
event = tracker._open_event['bed']
|
||||
self.assertEqual((event['opened_confirmed'], event['temperature_before']), (False, None))
|
||||
self.poll(tracker, 'on')
|
||||
self.poll(tracker, 'off', changed_ago=60)
|
||||
event = tracker.events[-1]
|
||||
self.assertTrue(event['closed_confirmed'])
|
||||
# Opened at some unknown time before it was first seen: a minimum duration only.
|
||||
self.assertEqual((event['duration_minutes'], event['duration_max_minutes']), (None, None))
|
||||
self.assertEqual(event['duration_min_minutes'], 9.5)
|
||||
|
||||
def test_restart_with_saved_open_window_and_sensor_unavailable(self):
|
||||
before, saved = self.saved_with('off', 'off', 'on', 'on')
|
||||
self.assertEqual(saved['state']['windows']['w']['last_known'], True)
|
||||
self.assertIn('bed', saved['state']['zones'])
|
||||
self.t += 120 # restart takes two minutes
|
||||
tracker = wm.VentilationTracker(self.WINDOWS, saved=saved)
|
||||
self.assertEqual(tracker.window_state, {}) # nothing is live before the first reading
|
||||
inputs, forecast, assumed = self.poll(tracker, 'unavailable')
|
||||
state = tracker.window_state['w']
|
||||
self.assertEqual((state['open'], state['last_known_open']), (None, True))
|
||||
self.assertEqual(state['unknown_since'], saved['state']['windows']['w']['seen'])
|
||||
# Assumed open only until 15 minutes after it was last seen (7 minutes ago).
|
||||
self.assertEqual((assumed['basis'], assumed['open_minutes'], assumed['uncertain']),
|
||||
('last_known_open', 8.0, True))
|
||||
self.assertEqual([s['opening'] for s in forecast[:3]], [1.0, 1.0, 0.0])
|
||||
self.assertEqual((inputs['known'], inputs['affected']), (False, True))
|
||||
# The event from before the restart continues; the downtime counts as unknown.
|
||||
self.assertEqual(len(tracker.events), 0)
|
||||
event = tracker._open_event['bed']
|
||||
self.assertEqual((event['opened'], event['opened_confirmed']), (before._open_event['bed']['opened'], True))
|
||||
self.assertEqual(event['sensor_unknown_minutes'], 12.0) # 7 minutes down + one unknown poll
|
||||
# Not assumed open forever: past the bound the forecast assumes it closed.
|
||||
for _ in range(3):
|
||||
_, forecast, assumed = self.poll(tracker, 'unavailable')
|
||||
self.assertEqual((assumed['open_minutes'], forecast[0]['opening']), (0.0, 0.0))
|
||||
self.assertGreater(forecast[0]['recovery'], 0.0)
|
||||
# Back as closed: closed at an unknown time, so an unconfirmed close and recovery.
|
||||
inputs, _, _ = self.poll(tracker, 'off', changed_ago=30)
|
||||
event = tracker.events[-1]
|
||||
self.assertEqual((event['closed_confirmed'], event['duration_minutes']), (False, None))
|
||||
self.assertEqual(event['duration_min_minutes'], 8.3) # opened 3.3 min before a poll, seen open 5 min later
|
||||
self.assertEqual((inputs['known'], inputs['affected']), (False, True))
|
||||
|
||||
def test_restart_with_saved_closed_window(self):
|
||||
_, saved = self.saved_with('off', 'off')
|
||||
tracker = wm.VentilationTracker(self.WINDOWS, saved=saved)
|
||||
inputs, forecast, assumed = self.poll(tracker, 'unavailable')
|
||||
self.assertEqual((assumed['basis'], assumed['uncertain'], forecast[0]['opening']),
|
||||
('last_known_closed', True, 0.0))
|
||||
self.assertEqual((inputs['known'], len(tracker.events)), (False, 0))
|
||||
# It reports open: opened during the unknown period, so not a confirmed opening.
|
||||
self.poll(tracker, 'on', changed_ago=60)
|
||||
self.assertFalse(tracker._open_event['bed']['opened_confirmed'])
|
||||
|
||||
def test_restart_with_the_same_state_again_is_a_known_state(self):
|
||||
_, saved = self.saved_with('off', 'off')
|
||||
tracker = wm.VentilationTracker(self.WINDOWS, saved=saved)
|
||||
self.poll(tracker, 'off') # first interval after restart: unknown
|
||||
inputs, _, assumed = self.poll(tracker, 'off')
|
||||
self.assertEqual((inputs['known'], inputs['affected'], assumed['basis']), (True, False, 'observed'))
|
||||
|
||||
def test_saved_state_of_another_sensor_or_polarity_is_ignored(self):
|
||||
_, saved = self.saved_with('off', 'off', 'on', 'on')
|
||||
for changed in ({'sensor': 'binary_sensor.other'}, {'inverted': True}):
|
||||
with self.subTest(changed=changed):
|
||||
windows = wm.build_windows([(window('w', **changed), None)], ['bed'])
|
||||
tracker = wm.VentilationTracker(windows, saved=saved)
|
||||
self.assertEqual((tracker._memory, tracker._open_event), ({}, {}))
|
||||
corrupt = {'events': [], 'state': {'windows': {'w': {'sensor': 'binary_sensor.w', 'last_known': 'yes',
|
||||
'seen': 'x'}}, 'zones': {'bed': 5}}}
|
||||
self.assertEqual(wm.VentilationTracker(self.WINDOWS, saved=corrupt)._memory, {})
|
||||
|
||||
def test_mixed_windows_each_get_their_own_assumption(self):
|
||||
windows = wm.build_windows([(window('a'), None), (window('b', opening_factor=0.5), None),
|
||||
(window('c', zone='living'), None)], ['bed', 'living'])
|
||||
tracker = wm.VentilationTracker(windows)
|
||||
t = 0.0
|
||||
for a, b, c in (('off', 'off', 'off'), ('off', 'on', 'off'), ('on', 'on', 'unavailable'),
|
||||
('on', 'unavailable', 'unavailable'), ('on', 'unavailable', 'unavailable'),
|
||||
('on', 'unavailable', 'unavailable'), ('on', 'unavailable', 'unavailable')):
|
||||
t += 300
|
||||
tracker.update(t, {'a': (a, t - 200), 'b': (b, t - 200), 'c': (c, t - 200)},
|
||||
{'bed': 20.0, 'living': 21.0}, WEATHER)
|
||||
# b was last seen open 20 minutes ago: no longer assumed open; a is observed open.
|
||||
self.assertEqual(tracker.assumption(windows[1], 15, t)['open_minutes'], 0.0)
|
||||
self.assertEqual(tracker.forecast('bed', 4, 15, WEATHER, t)[0]['opening'], 1.0)
|
||||
self.assertEqual(tracker.assumption(windows[2], 15, t)['basis'], 'last_known_closed')
|
||||
# An unknown window in one zone does not stop another zone with known windows.
|
||||
tracker2 = wm.VentilationTracker(windows)
|
||||
for t in (300.0, 600.0):
|
||||
tracker2.update(t, {'a': ('off', 0), 'b': ('off', 0), 'c': ('unavailable', 0)},
|
||||
{'bed': 20.0, 'living': 21.0}, WEATHER)
|
||||
self.assertEqual((tracker2.zone_inputs('bed', WEATHER)['known'],
|
||||
tracker2.zone_inputs('living', WEATHER)['known']), (True, False))
|
||||
|
||||
def test_changes_worth_saving_are_flagged(self):
|
||||
tracker = wm.VentilationTracker(self.WINDOWS)
|
||||
self.poll(tracker, 'off')
|
||||
self.assertTrue(tracker.changed) # first reading
|
||||
tracker.changed = False
|
||||
self.poll(tracker, 'off')
|
||||
self.assertFalse(tracker.changed) # nothing new
|
||||
self.poll(tracker, 'unavailable')
|
||||
self.assertTrue(tracker.changed)
|
||||
|
||||
|
||||
class StrictInputTests(unittest.TestCase):
|
||||
"""Weather inputs of an open window are required, never assumed, outside forecasts."""
|
||||
|
||||
def test_unknown_direction_or_other_zone_while_open(self):
|
||||
windows = wm.build_windows([(window('a'), None), (window('c', zone='living'), None)], ['bed', 'living'])
|
||||
tracker = wm.VentilationTracker(windows)
|
||||
calm = {**WEATHER, 'wind_direction': None}
|
||||
for t, a, c in ((300.0, 'off', 'off'), (600.0, 'on', 'unavailable'), (900.0, 'on', 'unavailable')):
|
||||
tracker.update(t, {'a': (a, 450.0), 'c': (c, 450.0)}, {'bed': 20.0, 'living': 21.0}, calm)
|
||||
bed = tracker.zone_inputs('bed', calm)
|
||||
self.assertEqual((bed['opening'], bed['facing'], bed['other_opening']), (1.0, None, None))
|
||||
features = ['vent_natural', 'vent_wind', 'vent_wind_dir', 'vent_cross']
|
||||
self.assertFalse(wm.ventilation_known(features, bed))
|
||||
self.assertTrue(wm.ventilation_known(['vent_natural', 'vent_wind'], bed))
|
||||
self.assertIsNone(wm.ventilation_vector(features, 20.0, 2.0, bed)) # learning refuses
|
||||
self.assertIsNotNone(wm.ventilation_vector_for_prediction(features, 20.0, 2.0, bed))
|
||||
# Closed, every opening term is 0 whatever the weather: nothing is missing.
|
||||
closed = {**bed, 'opening': 0.0, 'facing': 0.0, 'wind': None, 'other_opening': None}
|
||||
self.assertEqual(wm.ventilation_vector(features, 20.0, 2.0, closed), [0.0, 0.0, 0.0, 0.0])
|
||||
self.assertTrue(wm.ventilation_known(features, closed))
|
||||
self.assertTrue(wm.ventilation_known(features, None)) # no windows
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Layered zone model: fallbacks, contributions and the compiled optimizer step."""
|
||||
import random
|
||||
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 learning, physics, zone_model as zm # noqa: E402
|
||||
|
||||
VENT = {'opening': 1.0, 'facing': -0.5, 'other_opening': 0.5, 'recovery': 0.0, 't_ref': 21.0,
|
||||
'wind': 4.0, 'gust': 7.0}
|
||||
|
||||
|
||||
def trained(features, theta):
|
||||
learner = learning.FeatureLearner(features)
|
||||
learner.theta = list(theta)
|
||||
learner.count, learner.on_count, learner.off_count = 300, 100, 100
|
||||
return learner
|
||||
|
||||
|
||||
def models():
|
||||
living = zm.ZoneModel(physics.Learner(), trained(zm.extended_features(True, ('bed',)),
|
||||
(0.004, 0.12, 0.0005, 0.15, 0.01)))
|
||||
bed = zm.ZoneModel(physics.Learner(), trained(zm.extended_features(False, ('living',)),
|
||||
(0.006, 0.09, 0.0, 0.01)),
|
||||
trained(['vent_natural', 'recovery', 'vent_wind', 'vent_wind_dir', 'vent_cross'],
|
||||
(0.01, 0.15, 0.001, 0.0006, 0.002)))
|
||||
return {'living': living, 'bed': bed}
|
||||
|
||||
|
||||
class ZoneModelTests(unittest.TestCase):
|
||||
def test_layers_and_fallbacks(self):
|
||||
self.assertEqual(zm.extended_features(False, ()), [])
|
||||
self.assertEqual(zm.extended_features(True, ('b',)), ['loss', 'heat', 'bias', 'internal', 'couple:b'])
|
||||
living = models()['living']
|
||||
inputs = {'outside': 5.0, 'lag': 0.5, 'internal': 0.4, 'temps': {'living': 21.0, 'bed': 20.0}}
|
||||
self.assertAlmostEqual(living.delta(21.0, inputs),
|
||||
0.004 * -16 + 0.06 + 0.0005 + 0.06 + 0.01 * -1)
|
||||
# A missing input (electrical heat or a neighbour) falls back to the base model.
|
||||
base = living.base.predict_delta(21.0, 5.0, 0.5)
|
||||
self.assertAlmostEqual(living.delta(21.0, {**inputs, 'internal': None}), base)
|
||||
self.assertAlmostEqual(living.delta(21.0, {**inputs, 'temps': {'living': 21.0}}), base)
|
||||
# Until the extended model is as trained as the base needs to be, the base is used.
|
||||
living.extended.count = 10
|
||||
self.assertFalse(living.uses_extended)
|
||||
self.assertAlmostEqual(living.delta(21.0, inputs), base)
|
||||
|
||||
def test_contributions_add_up_to_the_change(self):
|
||||
bed = models()['bed']
|
||||
inputs = {'outside': 5.0, 'lag': 0.5, 'temps': {'living': 21.0, 'bed': 19.0}, 'ventilation': VENT}
|
||||
terms = bed.contributions(19.0, inputs)
|
||||
self.assertEqual(set(terms), {'loss', 'heat', 'bias', 'neighbors', 'ventilation', 'recovery'})
|
||||
self.assertAlmostEqual(sum(terms.values()), 12 * bed.delta(19.0, inputs), places=2)
|
||||
self.assertLess(terms['ventilation'], 0)
|
||||
# Unknown window state: no ventilation term rather than a guess.
|
||||
self.assertAlmostEqual(bed.ventilation_delta(19.0, {**inputs, 'ventilation': {**VENT, 'opening': None}}), 0)
|
||||
|
||||
def test_compiled_step_matches_direct_model_exactly(self):
|
||||
zone_models = models()
|
||||
steps = 24
|
||||
ventilation = [{**VENT, 'opening': 1.0 if k < 3 else 0.0, 'recovery': 0.0 if k < 3 else 0.6}
|
||||
for k in range(steps)]
|
||||
|
||||
def inputs_at(key, k):
|
||||
return {'outside': 5.0, 'internal': 0.4 if key == 'living' else None,
|
||||
'ventilation': ventilation[k] if key == 'bed' else {}}
|
||||
fast = zm.compile_step_model(zone_models, ['living', 'bed'], steps, inputs_at)
|
||||
rng = random.Random(5)
|
||||
for _ in range(300):
|
||||
state = {'living': rng.uniform(15, 26), 'bed': rng.uniform(15, 26)}
|
||||
lag, k = rng.random(), rng.randrange(steps + 3) # beyond the horizon: last step repeats
|
||||
direct = {key: value + zone_models[key].delta(value, {
|
||||
**inputs_at(key, min(k, steps - 1)), 'lag': lag, 'temps': state}) for key, value in state.items()}
|
||||
compiled = fast(state, lag, k)
|
||||
for key in state:
|
||||
self.assertAlmostEqual(compiled[key], direct[key], places=12)
|
||||
# With a zone missing, its neighbour falls back to its base model, also when compiled.
|
||||
solo = zm.compile_step_model(zone_models, ['living'], steps, inputs_at)
|
||||
base = zone_models['living'].base.predict_delta(20.0, 5.0, 0.3)
|
||||
self.assertAlmostEqual(solo({'living': 20.0}, 0.3, 0)['living'], 20.0 + base, places=12)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Thermal zones: parsing, sensor weighting and validation, without Home Assistant."""
|
||||
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 zones as z # noqa: E402
|
||||
|
||||
|
||||
def zone(key, sensors, **extra):
|
||||
return {'key': key, 'name': key.title(), 'sensors': sensors, **extra}
|
||||
|
||||
|
||||
class ZoneTests(unittest.TestCase):
|
||||
def test_legacy_rooms_become_one_sensor_zones_keyed_by_entity(self):
|
||||
data = z.legacy_zone_data(['sensor.a', 'sensor.b', 'sensor.a'], {'sensor.a': 3, 'sensor.b': 'x'},
|
||||
{'sensor.a': 'Living'})
|
||||
zones = z.build_zones((d, None) for d in data)
|
||||
self.assertEqual([(q.key, q.name, q.sensors, q.comfort_weight) for q in zones],
|
||||
[('sensor.a', 'Living', ('sensor.a',), 3.0), ('sensor.b', 'sensor.b', ('sensor.b',), 1.0)])
|
||||
|
||||
def test_invalid_values_fall_back_and_bad_zones_are_skipped(self):
|
||||
parsed = z.zone_from_data(zone('bed', ['sensor.b', 'light.x', 'sensor.b'], comfort_weight=99,
|
||||
target_offset='bad', sensor_weights={'sensor.b': -1}))
|
||||
self.assertEqual((parsed.sensors, parsed.comfort_weight, parsed.target_offset,
|
||||
parsed.sensor_weight('sensor.b')), (('sensor.b',), 1.0, 0.0, 1.0))
|
||||
for bad in ({'key': 'x', 'sensors': []}, {'sensors': ['sensor.a']}, {'key': 'x', 'sensors': 'sensor.a'}):
|
||||
self.assertIsNone(z.zone_from_data(bad))
|
||||
# The subentry ID stands in for a missing key.
|
||||
self.assertEqual(z.zone_from_data({'sensors': ['sensor.a']}, 'sub1').key, 'sub1')
|
||||
|
||||
def test_duplicates_shared_sensors_and_neighbours(self):
|
||||
zones = z.build_zones([
|
||||
(zone('living', ['sensor.l1', 'sensor.l2'], neighbors=['bed', 'ghost', 'living']), 's1'),
|
||||
(zone('bed', ['sensor.b']), 's2'),
|
||||
(zone('living', ['sensor.x']), 's3'), # duplicate key
|
||||
(zone('hall', ['sensor.l2']), 's4'), # sensor already in another zone
|
||||
])
|
||||
self.assertEqual([q.key for q in zones], ['living', 'bed'])
|
||||
# Neighbours exist and are symmetric.
|
||||
self.assertEqual([q.neighbors for q in zones], [('bed',), ('living',)])
|
||||
|
||||
def test_zone_temperature_weights_and_reports_sensors_used(self):
|
||||
living = z.zone_from_data(zone('living', ['sensor.a', 'sensor.b'],
|
||||
sensor_weights={'sensor.a': 3, 'sensor.b': 1}))
|
||||
self.assertEqual(z.zone_temperature(living, {'sensor.a': 20.0, 'sensor.b': 22.0}),
|
||||
(20.5, frozenset({'sensor.a', 'sensor.b'})))
|
||||
self.assertEqual(z.zone_temperature(living, {'sensor.a': None, 'sensor.b': 22.0}),
|
||||
(22.0, frozenset({'sensor.b'})))
|
||||
self.assertEqual(z.zone_temperature(living, {}), (None, frozenset()))
|
||||
|
||||
def test_form_validation(self):
|
||||
others = [z.zone_from_data(zone('bed', ['sensor.b']))]
|
||||
cases = {
|
||||
'zone_name_required': {'name': ' ', 'sensors': ['sensor.a']},
|
||||
'zone_sensors': {'name': 'Living', 'sensors': []},
|
||||
'sensor_in_other_zone': {'name': 'Living', 'sensors': ['sensor.b']},
|
||||
'sensor_is_weather_source': {'name': 'Living', 'sensors': ['sensor.out']},
|
||||
None: {'name': 'Living', 'sensors': ['sensor.a', 'sensor.c']},
|
||||
}
|
||||
for expected, data in cases.items():
|
||||
with self.subTest(expected=expected):
|
||||
self.assertEqual(z.validate_zone(data, others, ['sensor.out']), expected)
|
||||
self.assertEqual(z.validate_zone({'name': 'x', 'sensors': [f'sensor.s{i}' for i in range(7)]}, [], []),
|
||||
'zone_sensors')
|
||||
twelve = [z.zone_from_data(zone(f'z{i}', [f'sensor.z{i}'])) for i in range(12)]
|
||||
self.assertEqual(z.validate_zone({'name': 'x', 'sensors': ['sensor.new']}, twelve, []), 'too_many_zones')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user