Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
@@ -135,3 +135,137 @@ 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.
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
# Changelog
|
||||
|
||||
Engineering findings and test evidence behind each change are in [AUDIT.md](AUDIT.md).
|
||||
|
||||
## 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.3.0 — 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,18 +7,20 @@ 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.
|
||||
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.
|
||||
|
||||
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 room and outdoor measurement can be changed later via **Configure**, without editing YAML or Python.
|
||||
|
||||
## 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 |
|
||||
@@ -38,18 +40,29 @@ Switching only the **weather-provider sensors** does not reset learned thermal p
|
||||
## 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.
|
||||
- Learns only from valid 5-minute transitions; uses a smoothed heating-demand proxy to approximate radiator lag. Relay changes are timestamped by a read-only state listener, so the lag estimate follows every switch between polls.
|
||||
- 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.
|
||||
- Persists model parameters using HA's `.storage` so HA restarts do not discard the trained data. Models of rooms removed in Configure are kept (up to 24) and restored if the room 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.mpc_outdoor_temperature` — selected external temp and alternatives.
|
||||
- Per-room predicted temperatures after 60 minutes.
|
||||
- Per-room predicted temperatures after 60 minutes. Attributes `forecast_15min` (the
|
||||
temperature every 15 minutes over the horizon) and `plan_15min` (the proposed
|
||||
ON/OFF plan). These 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 room 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 Forecast Error 15/30/60/120 min** — how far the thermal model's
|
||||
forecasts were from what actually happened (see *Forecast accuracy*).
|
||||
|
||||
## Known limitations / safety
|
||||
|
||||
@@ -59,111 +72,170 @@ Entities (entity IDs may vary):
|
||||
- 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 room, 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`,
|
||||
`learning`, `paused` (thermostat not in `heat`), `waiting_for_target`,
|
||||
`invalid_weights`, `ready`, `inputs_changed` or `prediction_error`. Proposed heat
|
||||
is available only while it is `ready`. Useful attributes:
|
||||
|
||||
- `input_quality` — for each room and each 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). Rooms also have `used`:
|
||||
whether that room's reading was used in this poll.
|
||||
- `excluded_rooms` and `weight_coverage` — rooms left out of this poll, and the
|
||||
share of the total comfort weight still covered by the rooms used (1.0 = all).
|
||||
- `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`,
|
||||
`learned`, `partly_rejected` (an outlier in some room), `interval_out_of_range`,
|
||||
`relay_changed`, `outside_source_changed` or `lag_uncertain`.
|
||||
- `models` — per-room coefficients, accepted sample counts and the in-sample
|
||||
5-minute error (`mae_5m`), which is **not** a forecast accuracy.
|
||||
- `retired_room_models` — removed rooms whose learned models are kept.
|
||||
|
||||
`input_quality` and `weather_comparison` change every poll, so they 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 room sensor that is older than its limit, unavailable, missing or invalid is
|
||||
**left out**: the weighted temperature, learning and forecasts continue with the
|
||||
other rooms, whose weights are re-normalized. Status shows it in `excluded_rooms`.
|
||||
Learning and forecasts pause only when no room is usable, or when the relay,
|
||||
thermostat or outdoor temperature is missing. A room 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
|
||||
|
||||
The four **Forecast Error** sensors measure the thermal model against reality,
|
||||
continuously and out of sample. At every poll the integration records each room's
|
||||
reading, the outdoor temperature, the radiator-lag estimate and the model's
|
||||
parameters. 15, 30, 60 and 120 minutes later it replays that room from the earlier
|
||||
reading using the parameters it had **then**, the outdoor temperature held at its
|
||||
starting value, and the radiator heat that actually followed from the relay's real
|
||||
ON/OFF history. The result is compared with the current reading.
|
||||
|
||||
This tests the thermal model, not the MPC plan, because the plan is never
|
||||
executed. Each sensor's state is the mean absolute error (°C) over the last 7 days,
|
||||
across all rooms. Attributes, all in °C except counts:
|
||||
|
||||
- `samples` — number of scored forecasts. Consecutive forecasts overlap, so they
|
||||
are not independent; treat small counts as indicative only.
|
||||
- `bias` — mean error: negative means the model predicts too cold.
|
||||
- `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.
|
||||
- `rooms` — the same numbers per room (not written to the Recorder).
|
||||
|
||||
Forecasts are scored only where the inputs were known: polls 4.5–5.5 minutes
|
||||
apart, a usable reading at the start and the end, and a well-known radiator-lag
|
||||
estimate throughout. A missing relay, outdoor temperature or all rooms restarts the
|
||||
replay window, so scores resume 15–120 minutes later. Statistics are kept with the
|
||||
model and survive restarts; deleting the model resets them.
|
||||
|
||||
What it does not cover: 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
|
||||
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
The brand icons in `custom_components/heating_mpc/brand/` are drawn by
|
||||
`python scripts/make_brand_icons.py` (uses Pillow from the test requirements);
|
||||
the package check verifies their sizes and transparency.
|
||||
|
||||
HA tests cover setup, config/options forms, migration, reload and entity cleanup,
|
||||
scheduled sampling and poll cancellation, 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.
|
||||
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.
|
||||
|
||||
`.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.
|
||||
+43
-16
@@ -10,6 +10,9 @@ Success means predictable comfort, understandable decisions, safe operation and
|
||||
|
||||
## Current baseline — v0.2.1
|
||||
|
||||
> **Current release: 0.3.0** — Milestone 0 reliability work and the first part of Milestone 1 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,29 @@ 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.
|
||||
|
||||
## 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.
|
||||
- [ ] Make clear that the **MPC plan forecast is counterfactual** while Generic Thermostat is controlling the relay; add a separate actual-policy forecast/evaluation pathway. *(0.3.0: plan forecasts labelled counterfactual; evaluation uses the actual relay history. An actual-policy forecast, simulating Generic Thermostat, is still open.)*
|
||||
- [ ] Store a bounded history of predictions with snapshots of inputs, proposed plan and eventually observed outcome.
|
||||
- [ ] 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.
|
||||
- [x] Implement out-of-sample/walk-forward MAE and bias at 15, 30, 60 and 120 minutes; include sample counts and confidence/quality labels. *(0.3.0: Forecast Error sensors; replay with parameters frozen at the forecast start and the actual relay history; `skill` against persistence. No confidence intervals yet.)*
|
||||
- [ ] 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.
|
||||
@@ -146,13 +161,25 @@ 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. Milestone 1 (rest, 0.3.x): let the Forecast Error sensors collect a few weeks
|
||||
of heating data and review per-room bias; a dashboard example; forecast
|
||||
timestamps/provenance; a fixed-parameter baseline; explanations for the proposed
|
||||
action; an actual-policy forecast that simulates Generic Thermostat.
|
||||
4. Only then consider richer weather inputs and the live-control **design**; do not
|
||||
implement live control without separate authorization.
|
||||
|
||||
## Definition of done for each milestone
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ from homeassistant.config_entries import ConfigEntry
|
||||
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, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
|
||||
from .weather_sources import sources_for
|
||||
from .coordinator import HeatingMPCCoordinator
|
||||
|
||||
@@ -17,13 +18,19 @@ 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.
|
||||
unsub_relay()
|
||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
raise
|
||||
entry.async_on_unload(unsub_relay)
|
||||
# Remove only obsolete room forecasts belonging to this entry/platform.
|
||||
registry = er.async_get(hass)
|
||||
expected = {f"{entry.entry_id}_{room}_predicted_60min" for room in coordinator.rooms}
|
||||
|
||||
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 |
@@ -9,7 +9,7 @@ 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,
|
||||
@@ -41,6 +41,8 @@ def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
||||
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]),
|
||||
@@ -63,11 +65,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({
|
||||
@@ -110,14 +122,14 @@ 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))
|
||||
@@ -161,7 +173,7 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||
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(
|
||||
config, WEATHER_MAIN))
|
||||
@@ -169,7 +181,7 @@ 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)
|
||||
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(
|
||||
config, WEATHER_EXTRA))
|
||||
|
||||
@@ -12,6 +12,9 @@ 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"
|
||||
|
||||
# Each source list is ordered: first valid sensor wins; remaining are fallbacks.
|
||||
@@ -32,6 +35,7 @@ WEATHER_EXTRA = ("humidity", "feels_like", "pressure", "visibility", "rain", "sn
|
||||
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,
|
||||
@@ -39,5 +43,16 @@ DEFAULTS = {
|
||||
CONF_HEAT_COST: 0.025,
|
||||
CONF_BAND: 0.15,
|
||||
}
|
||||
# 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", "learned", "partly_rejected",
|
||||
"interval_out_of_range", "relay_changed", "outside_source_changed", "lag_uncertain",
|
||||
)
|
||||
BLOCK_MINUTES = 15
|
||||
@@ -2,41 +2,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import partial
|
||||
from math import exp, isfinite
|
||||
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, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
|
||||
)
|
||||
from .data_quality import ReportAgeHistory, freshness
|
||||
from .evaluation import ForecastEvaluator
|
||||
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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
# Relay changes kept between polls; more than this in five minutes is chatter.
|
||||
MAX_RELAY_EVENTS = 256
|
||||
# Models of rooms removed in Options, kept in case a room is added back.
|
||||
MAX_RETIRED_ROOMS = 24
|
||||
|
||||
|
||||
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.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
||||
self._retired: dict[str, dict[str, Any]] = {}
|
||||
self._report_ages = ReportAgeHistory()
|
||||
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
|
||||
# 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
|
||||
|
||||
@@ -50,13 +70,24 @@ 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"))
|
||||
# Models carry room identity, so changing the room list is safe. Models of
|
||||
# rooms no longer configured are retained, most recently removed last.
|
||||
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 room in self.learners:
|
||||
self.learners[room] = learner
|
||||
elif learner.count:
|
||||
self._retired.pop(room, None)
|
||||
self._retired[room] = learner.as_dict()
|
||||
while len(self._retired) > MAX_RETIRED_ROOMS:
|
||||
self._retired.pop(next(iter(self._retired)))
|
||||
self._update_counts()
|
||||
|
||||
def _update_counts(self) -> None:
|
||||
@@ -70,8 +101,91 @@ 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."""
|
||||
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
|
||||
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())
|
||||
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)
|
||||
# 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 _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()), 1)
|
||||
|
||||
def _prediction_inputs(self) -> tuple:
|
||||
"""Values the prediction depends on, used to detect changes during calculation."""
|
||||
heater = self.config[CONF_HEATER]
|
||||
entities = sorted(set(self.rooms) | set(sources_for(self.config, "temperature")) |
|
||||
{self.config[CONF_CLIMATE], heater})
|
||||
values = []
|
||||
for entity_id in entities:
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is None:
|
||||
values.append((entity_id, None))
|
||||
continue
|
||||
attrs = state.attributes
|
||||
values.append((entity_id, state.state, attrs.get("unit_of_measurement"),
|
||||
attrs.get("temperature"), attrs.get("temperature_unit"),
|
||||
state.last_changed if entity_id == heater else None))
|
||||
return tuple(values)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -83,20 +197,26 @@ 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}
|
||||
snapshot = {entity: self.hass.states.get(entity)
|
||||
for entity in set(self.rooms) | weather_entity_ids}
|
||||
inputs_before = self._prediction_inputs()
|
||||
room_quality = {}
|
||||
temps = {}
|
||||
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
|
||||
for room in self.rooms:
|
||||
quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE])
|
||||
value = None
|
||||
if quality["status"] == "valid":
|
||||
# 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[room]
|
||||
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
|
||||
quality["used"] = value is not None
|
||||
room_quality[room] = quality
|
||||
temps[room] = value
|
||||
weather_states = {}
|
||||
@@ -130,52 +250,79 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"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_room_models": sorted(self._retired),
|
||||
"excluded_rooms": [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:
|
||||
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
|
||||
# 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")
|
||||
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 room learns only across a poll pair where it was usable at both ends.
|
||||
pairs = [room for room in vals if self._previous and room in self._previous["temps"]]
|
||||
learned = False
|
||||
if not pairs:
|
||||
status["sample_result"] = "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:
|
||||
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._previous["outside"], self._lag["lag"], dt, heater_on)
|
||||
for room in pairs
|
||||
]
|
||||
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
|
||||
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)
|
||||
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"],
|
||||
}
|
||||
# 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})
|
||||
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
|
||||
if learned:
|
||||
# Persist at each sample so restart does not erase hours of training.
|
||||
await self._store.async_save({
|
||||
"schema_version": 2,
|
||||
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
|
||||
# Optional keys; older 0.2.x readers ignore them and keep working.
|
||||
"retired_learners": self._retired,
|
||||
"evaluation": self._evaluator.as_dict(),
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
"heater_entity": self.config[CONF_HEATER],
|
||||
"outside_source": active_sources["temperature"],
|
||||
})
|
||||
weights = self.config.get(CONF_WEIGHTS, {})
|
||||
try:
|
||||
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms}
|
||||
@@ -184,9 +331,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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())
|
||||
# Left-out rooms 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,
|
||||
@@ -198,6 +348,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"bias": round(model.theta[2], 6),
|
||||
"samples": model.count,
|
||||
"on_samples": model.on_count, "off_samples": model.off_count,
|
||||
"rejected_samples": model.rejected_count,
|
||||
"mae_5m": round(model.mae, 4) if model.mae is not None else None}
|
||||
for room, model in self.learners.items()
|
||||
},
|
||||
@@ -215,8 +366,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 +379,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]),
|
||||
@@ -233,10 +389,11 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
heating_cost=float(self.config[CONF_HEAT_COST]),
|
||||
comfort_band=float(self.config[CONF_BAND]),
|
||||
)
|
||||
# HA State objects are immutable snapshots. Discard a result if any
|
||||
# input changed while the worker was calculating the hypothetical plan.
|
||||
# Discard a result if a value used by the prediction changed while
|
||||
# saving or while the worker calculated the hypothetical plan. Optional
|
||||
# diagnostic weather sensors are not prediction inputs.
|
||||
output = await self.hass.async_add_executor_job(job)
|
||||
if any(self.hass.states.get(entity) is not state for entity, state in snapshot.items()):
|
||||
if self._prediction_inputs() != inputs_before:
|
||||
status["status"] = "inputs_changed"
|
||||
return status
|
||||
except (ValueError, RuntimeError, OverflowError) as err:
|
||||
@@ -248,6 +405,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"prediction_60m": output["predicted_60m"],
|
||||
"prediction_end": output["predicted_end"],
|
||||
"plan_15min": output["plan_15min"], "objective": output["cost"],
|
||||
"prediction_trajectory": output["trajectory_15min"],
|
||||
"model_quality_note": "5-minute in-sample MAE; not a validated 60-minute forecast error",
|
||||
})
|
||||
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,23 @@ 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)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""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.
|
||||
|
||||
This scores the thermal model, not the MPC's proposed plan: Generic Thermostat
|
||||
controls the relay, so the plan is never executed. A persistence forecast (no
|
||||
change) is scored alongside as the baseline to beat.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from .physics import Learner
|
||||
|
||||
HORIZONS = (15, 30, 60, 120) # minutes
|
||||
WINDOW_DAYS = 7
|
||||
STEP_SECONDS = 300
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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|]
|
||||
self._days: 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]]) -> None:
|
||||
"""Record a poll, then score every forecast that has reached its horizon."""
|
||||
if self._history and not 270 <= (time - self._history[-1]["time"]).total_seconds() <= 330:
|
||||
self._history.clear() # irregular poll spacing: replays would be unreliable
|
||||
self._history.append({"time": time, "temps": dict(temps), "outside": outside, "lag": lag,
|
||||
"lag_uncertainty": lag_uncertainty, "theta": dict(theta)})
|
||||
self._score()
|
||||
self._prune(time)
|
||||
|
||||
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):]
|
||||
# The actual heat input must be known along the way.
|
||||
if any(poll["lag_uncertainty"] > self._max_lag_uncertainty for poll in path[1:]):
|
||||
continue
|
||||
origin = path[0]
|
||||
inputs = [(poll["lag"], (poll["time"] - before["time"]).total_seconds())
|
||||
for before, poll in zip(path, path[1:])]
|
||||
for room, actual in latest["temps"].items():
|
||||
start, theta = origin["temps"].get(room), origin["theta"].get(room)
|
||||
if start is None or theta is None:
|
||||
continue
|
||||
error = replay(start, origin["outside"], theta, inputs) - actual
|
||||
bucket = self._days.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(
|
||||
room, [0, 0.0, 0.0, 0.0])
|
||||
bucket[0] += 1
|
||||
bucket[1] += error
|
||||
bucket[2] += abs(error)
|
||||
bucket[3] += abs(start - actual)
|
||||
|
||||
def _prune(self, time: datetime) -> None:
|
||||
oldest = (time.date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
|
||||
for day in [day for day in self._days if day < oldest]:
|
||||
del self._days[day]
|
||||
|
||||
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])
|
||||
for i, value in enumerate(values):
|
||||
totals[i] += value
|
||||
overall = [sum(values[i] for values in per_room.values()) for i in range(4)]
|
||||
result[horizon] = {**_stats(overall),
|
||||
"rooms": {room: _stats(values) for room, values in per_room.items()}}
|
||||
return result
|
||||
|
||||
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:])]
|
||||
for room, v in by_room.items()}
|
||||
for horizon, by_room in by_horizon.items()}
|
||||
for day, by_horizon in self._days.items()}}
|
||||
|
||||
def load(self, data: Any) -> None:
|
||||
"""Restore stored statistics, keeping only well-formed entries."""
|
||||
self._days = {}
|
||||
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):
|
||||
self._days.setdefault(day, {}).setdefault(horizon, {})[room] = [
|
||||
values[0], float(values[1]), float(values[2]), float(values[3])]
|
||||
|
||||
|
||||
def _valid_bucket(room: Any, values: Any) -> bool:
|
||||
if not isinstance(room, str) or not isinstance(values, list) or len(values) != 4:
|
||||
return False
|
||||
count, *sums = values
|
||||
return (type(count) is int and count >= 0
|
||||
and all(type(v) in (int, float) and isfinite(v) for v in sums)
|
||||
and sums[1] >= 0 and sums[2] >= 0)
|
||||
|
||||
|
||||
def _stats(values: list[float]) -> dict[str, Any]:
|
||||
count, error, absolute, persistence = values
|
||||
if not count:
|
||||
return {"samples": 0, "mae": None, "bias": None, "persistence_mae": None, "skill": None}
|
||||
return {
|
||||
"samples": int(count),
|
||||
"mae": round(absolute / count, 3),
|
||||
"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,
|
||||
}
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "0.2.1"
|
||||
"version": "0.3.0"
|
||||
}
|
||||
@@ -11,23 +11,87 @@ from math import exp, isfinite
|
||||
from typing import Any
|
||||
|
||||
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))
|
||||
|
||||
|
||||
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:
|
||||
"""Keep P symmetric and no less certain than the prior, in place.
|
||||
|
||||
With forgetting, unexcited directions (e.g. heat input during a summer with the
|
||||
relay off) grow by 1/forgetting each step without limit. Scaling row and column i
|
||||
together is a congruence transform, so positive definiteness is preserved.
|
||||
"""
|
||||
for i in range(3):
|
||||
for j in range(i):
|
||||
p[i][j] = p[j][i] = (p[i][j] + p[j][i]) / 2
|
||||
for i in range(3):
|
||||
if p[i][i] > PRIOR_P[i][i]:
|
||||
scale = (PRIOR_P[i][i] / p[i][i]) ** .5
|
||||
for j in range(3):
|
||||
p[i][j] *= scale
|
||||
for j in range(3):
|
||||
p[j][i] *= scale
|
||||
p[i][i] = PRIOR_P[i][i]
|
||||
|
||||
|
||||
@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,15 +99,13 @@ 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
|
||||
@@ -51,12 +113,13 @@ class Learner:
|
||||
forgetting = 0.999
|
||||
denominator = forgetting + sum(x[i] * px[i] for i in range(3))
|
||||
if not isfinite(denominator) or denominator <= 0:
|
||||
return False
|
||||
return self._reject()
|
||||
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
|
||||
_bound_covariance(self.p)
|
||||
# Physical constraints; avoids unstable temperature predictions.
|
||||
self.theta = [
|
||||
bound(self.theta[0], 0.0, 0.04),
|
||||
@@ -72,13 +135,18 @@ class Learner:
|
||||
self.off_count += 1
|
||||
return True
|
||||
|
||||
def _reject(self) -> bool:
|
||||
self.rejected_count += 1
|
||||
return False
|
||||
|
||||
@property
|
||||
def mae(self) -> float | None:
|
||||
return self.error_sum / self.count if self.count else None
|
||||
|
||||
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,12 +162,17 @@ class Learner:
|
||||
return obj
|
||||
if not (0 <= theta[0] <= .04 and 0 <= theta[1] <= .25 and -.06 <= theta[2] <= .06):
|
||||
return obj
|
||||
# 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):
|
||||
if abs(p[i][j] - p[j][i]) > 1e-6 * max(1.0, abs(p[i][j]), abs(p[j][i])):
|
||||
return obj
|
||||
_bound_covariance(p)
|
||||
# A covariance must be symmetric positive definite (Cholesky check).
|
||||
lower = [[0.0] * 3 for _ in range(3)]
|
||||
for i in range(3):
|
||||
for j in range(i + 1):
|
||||
if abs(p[i][j] - p[j][i]) > 1e-8:
|
||||
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):
|
||||
@@ -107,14 +180,15 @@ class Learner:
|
||||
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")]
|
||||
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
|
||||
|
||||
@@ -204,10 +278,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 bit in plan:
|
||||
for _ in range(nsteps_per_block):
|
||||
state, lag = simulate_step(state, outside, lag, bit, learners)
|
||||
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,17 +1,22 @@
|
||||
"""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 = [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 += [MPCRoomPrediction(coordinator, entry.entry_id, room) for room in coordinator.rooms]
|
||||
entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -19,6 +24,9 @@ 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"})
|
||||
|
||||
def __init__(self, coordinator, entry_id):
|
||||
super().__init__(coordinator)
|
||||
@@ -31,7 +39,9 @@ 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")}
|
||||
|
||||
|
||||
class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
|
||||
@@ -61,10 +71,22 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
|
||||
self._attr_unique_id = f"{entry_id}_{room}_predicted_60min"
|
||||
self._attr_name = f"MPC {room} 60min Prediction"
|
||||
|
||||
# Change every poll; the 60-minute value itself is recorded.
|
||||
_unrecorded_attributes = frozenset({"forecast_15min", "plan_15min"})
|
||||
|
||||
@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
|
||||
return {
|
||||
"forecast_15min": data.get("prediction_trajectory", {}).get(self.room),
|
||||
"plan_15min": data.get("plan_15min"),
|
||||
"basis": "proposed MPC plan; counterfactual while Generic Thermostat controls the relay",
|
||||
}
|
||||
|
||||
class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
|
||||
"""Display chosen outdoor temperature and all currently valid alternatives."""
|
||||
_attr_has_entity_name = True
|
||||
@@ -88,3 +110,95 @@ class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
|
||||
"active_source": data.get("weather_active_sources", {}).get("temperature"),
|
||||
"alternatives": data.get("weather_comparison", {}).get("temperature", {}),
|
||||
}
|
||||
|
||||
|
||||
class MPCForecastError(CoordinatorEntity, SensorEntity):
|
||||
"""Mean absolute error of the thermal model at one horizon, replayed against reality."""
|
||||
_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({"rooms"})
|
||||
|
||||
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 Forecast Error {horizon} min"
|
||||
|
||||
@property
|
||||
def _accuracy(self):
|
||||
return self.coordinator.data.get("forecast_accuracy", {}).get(self.horizon, {})
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return self._accuracy.get("mae")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
accuracy = self._accuracy
|
||||
return {
|
||||
"samples": accuracy.get("samples", 0),
|
||||
"bias": accuracy.get("bias"),
|
||||
"persistence_mae": accuracy.get("persistence_mae"),
|
||||
"skill": accuracy.get("skill"),
|
||||
"rooms": accuracy.get("rooms", {}),
|
||||
"window_days": WINDOW_DAYS,
|
||||
"unit_of_attributes": "°C",
|
||||
"basis": ("thermal model replayed with the actual relay history; outdoor "
|
||||
"temperature held at the start value"),
|
||||
}
|
||||
|
||||
|
||||
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": {
|
||||
@@ -63,7 +67,11 @@
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
@@ -96,5 +104,21 @@
|
||||
"invalid_rooms": "Select 1–12 distinct room sensors.",
|
||||
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_sample": {
|
||||
"state": {
|
||||
"waiting_for_inputs": "Waiting for inputs",
|
||||
"first_sample": "First sample",
|
||||
"learned": "Learned",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
@@ -63,7 +67,11 @@
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
@@ -96,5 +104,21 @@
|
||||
"invalid_rooms": "Select 1–12 distinct room sensors.",
|
||||
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_sample": {
|
||||
"state": {
|
||||
"waiting_for_inputs": "Waiting for inputs",
|
||||
"first_sample": "First sample",
|
||||
"learned": "Learned",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
@@ -63,7 +67,11 @@
|
||||
"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ă"
|
||||
},
|
||||
"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": {
|
||||
@@ -96,5 +104,21 @@
|
||||
"invalid_rooms": "Selectează 1–12 senzori diferiți.",
|
||||
"invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_sample": {
|
||||
"state": {
|
||||
"waiting_for_inputs": "Așteaptă datele de intrare",
|
||||
"first_sample": "Prima măsurătoare",
|
||||
"learned": "Învățat",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
@@ -4,6 +4,7 @@ from homeassistant.data_entry_flow import FlowResultType
|
||||
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
|
||||
|
||||
|
||||
async def test_config_flow(hass, inputs):
|
||||
@@ -14,6 +15,7 @@ async def test_config_flow(hass, inputs):
|
||||
validated = result["data_schema"](main)
|
||||
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(
|
||||
@@ -34,7 +36,7 @@ async def test_options_flow_keeps_settings(hass, entry, inputs):
|
||||
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})
|
||||
"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"]}))
|
||||
@@ -43,7 +45,14 @@ async def test_options_flow_keeps_settings(hass, entry, inputs):
|
||||
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
|
||||
# The next options form shows the saved choice.
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["data_schema"]({"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
||||
"room_entities": ["sensor.room"]})["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):
|
||||
@@ -56,3 +65,35 @@ async def test_v1_entry_migrates_on_setup(hass, inputs):
|
||||
assert entry.version == 2
|
||||
assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
|
||||
assert "outside_entity" not in entry.data
|
||||
|
||||
|
||||
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={
|
||||
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
||||
"room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"],
|
||||
"outside_wind_sources": ["sensor.wind"], "outside_pressure_sources": ["sensor.pressure"],
|
||||
"weights": {"sensor.room": 1.0}})
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
flow = hass.config_entries.options
|
||||
result = await flow.async_init(entry.entry_id)
|
||||
result = await flow.async_configure(result["flow_id"], result["data_schema"]({
|
||||
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
||||
"room_entities": ["sensor.room"]}))
|
||||
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"]}))
|
||||
result = await flow.async_configure(result["flow_id"], {"weight__sensor.room": 1})
|
||||
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"]
|
||||
+122
-2
@@ -21,11 +21,17 @@ 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) == 11 # 7 core + 4 forecast-error sensors
|
||||
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"])
|
||||
assert status.attributes["input_quality"]["rooms"]["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)
|
||||
@@ -49,7 +55,7 @@ async def test_options_reload_removes_only_old_room(hass, entry, inputs):
|
||||
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.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
|
||||
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 11
|
||||
|
||||
|
||||
async def test_storage_restore_and_remove(hass, entry, inputs):
|
||||
@@ -63,6 +69,12 @@ 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_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
|
||||
@@ -146,3 +158,111 @@ 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)
|
||||
hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]})
|
||||
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
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, options={"room_entities": ["sensor.room", "sensor.new"]})
|
||||
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])
|
||||
assert coordinator.data["forecast_accuracy"][15]["samples"] == 1
|
||||
assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0
|
||||
assert state.attributes["samples"] == 1
|
||||
assert state.attributes["rooms"]["sensor.room"]["samples"] == 1
|
||||
assert state.attributes["unit_of_measurement"] == "°C"
|
||||
assert "rooms" in state.state_info["unrecorded_attributes"]
|
||||
assert hass.states.get(ids[30]).state == "unknown"
|
||||
|
||||
|
||||
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}
|
||||
+388
-1
@@ -3,7 +3,9 @@ import importlib.util
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import exp, pi, sin
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
@@ -34,9 +36,13 @@ def load_integration():
|
||||
'homeassistant', 'homeassistant.const', 'homeassistant.core',
|
||||
'homeassistant.config_entries', 'homeassistant.helpers',
|
||||
'homeassistant.helpers.storage', 'homeassistant.helpers.update_coordinator',
|
||||
'homeassistant.helpers.entity_registry')}
|
||||
'homeassistant.helpers.entity_registry', 'homeassistant.helpers.event')}
|
||||
modules['homeassistant.const'].Platform = types.SimpleNamespace(SENSOR='sensor', BINARY_SENSOR='binary_sensor')
|
||||
modules['homeassistant.core'].HomeAssistant = object
|
||||
modules['homeassistant.core'].Event = object
|
||||
modules['homeassistant.core'].callback = lambda func: func
|
||||
modules['homeassistant.helpers.event'].async_track_state_change_event = (
|
||||
lambda hass, entity_ids, action: hass.track_state(entity_ids, action))
|
||||
modules['homeassistant.config_entries'].ConfigEntry = object
|
||||
modules['homeassistant.helpers.storage'].Store = Store
|
||||
modules['homeassistant.helpers.update_coordinator'].DataUpdateCoordinator = CoordinatorBase
|
||||
@@ -63,6 +69,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
data={}, states=types.SimpleNamespace(get=self.states.get),
|
||||
config=types.SimpleNamespace(units=types.SimpleNamespace(temperature_unit='°C')),
|
||||
services=types.SimpleNamespace(async_call=Mock(side_effect=AssertionError('No service calls permitted'))),
|
||||
track_state=Mock(return_value=Mock(name='unsub_relay')),
|
||||
registry=types.SimpleNamespace(entries=[], async_remove=Mock()),
|
||||
config_entries=types.SimpleNamespace(
|
||||
async_forward_entry_setups=AsyncMock(), async_unload_platforms=AsyncMock(return_value=True),
|
||||
@@ -92,6 +99,12 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.now += timedelta(seconds=seconds)
|
||||
return data
|
||||
|
||||
def switch(self, state, seconds_before_next_sample):
|
||||
"""Change the relay between polls; `self.now` is already the next poll time."""
|
||||
heater = self.states['switch.boiler']
|
||||
heater.state = state
|
||||
heater.last_changed = self.now - timedelta(seconds=seconds_before_next_sample)
|
||||
|
||||
def ready(self):
|
||||
m = self.c.learners['sensor.room']
|
||||
m.count, m.on_count, m.off_count = 200, 100, 100
|
||||
@@ -198,6 +211,10 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
kept = types.SimpleNamespace(platform='heating_mpc', unique_id='entry_sensor.room_predicted_60min', entity_id='sensor.forecast')
|
||||
self.hass.registry.entries = [obsolete, kept]
|
||||
self.assertTrue(await integration.async_setup_entry(self.hass, self.entry))
|
||||
self.hass.track_state.assert_called_once_with(['switch.boiler'], unittest.mock.ANY)
|
||||
unsub = self.hass.track_state.return_value
|
||||
self.entry.async_on_unload.assert_any_call(unsub)
|
||||
unsub.assert_not_called()
|
||||
self.hass.registry.async_remove.assert_called_once_with('sensor.old_forecast')
|
||||
await integration.async_reload_entry(self.hass, self.entry)
|
||||
self.hass.config_entries.async_reload.assert_awaited_once_with('entry')
|
||||
@@ -214,6 +231,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
with self.assertRaises(RuntimeError):
|
||||
await integration.async_setup_entry(self.hass, self.entry)
|
||||
self.assertNotIn('entry', self.hass.data['heating_mpc'])
|
||||
self.hass.track_state.return_value.assert_called_once_with()
|
||||
|
||||
async def test_uninstall_removes_storage(self):
|
||||
store = Store()
|
||||
@@ -310,3 +328,372 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['input_quality']['weather']['temperature']['sensor.outside']['status'],
|
||||
'invalid_value_or_unit')
|
||||
|
||||
async def test_lag_is_continuous_across_relay_transitions(self):
|
||||
advance = coordinator.advance_lag
|
||||
await self.sample()
|
||||
lag = self.c._lag['lag']
|
||||
self.switch('on', 150)
|
||||
data = await self.sample()
|
||||
lag = advance(advance(lag, False, 150), True, 150)
|
||||
self.assertEqual(data['sample_result'], 'relay_changed')
|
||||
self.assertAlmostEqual(self.c._lag['lag'], lag)
|
||||
self.assertLess(lag, 0.2) # radiators have only started warming, not 1.0
|
||||
data = await self.sample()
|
||||
lag = advance(lag, True, 300)
|
||||
self.assertEqual(data['sample_result'], 'learned')
|
||||
self.assertAlmostEqual(self.c._lag['lag'], lag)
|
||||
self.switch('off', 100)
|
||||
await self.sample()
|
||||
lag = advance(advance(lag, True, 200), False, 100)
|
||||
self.assertAlmostEqual(self.c._lag['lag'], lag)
|
||||
self.assertGreater(lag, 0.3) # residual radiator heat is kept, not 0.0
|
||||
|
||||
async def test_prediction_starts_from_tracked_lag(self):
|
||||
self.ready()
|
||||
self.set_state('switch.boiler', 'on')
|
||||
await self.sample()
|
||||
self.switch('off', 60)
|
||||
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['status'], 'ready')
|
||||
self.assertAlmostEqual(spy.call_args.args[2], self.c._lag['lag'])
|
||||
self.assertGreater(spy.call_args.args[2], 0.9)
|
||||
|
||||
async def test_round_trip_between_polls_restarts_lag_estimate(self):
|
||||
await self.sample()
|
||||
self.switch('off', 60) # off -> on -> off; only the last change is visible
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['sample_result'], 'relay_changed')
|
||||
self.assertEqual(data['heat_lag_uncertainty'], round(exp(-60 / 1200) / 2, 3))
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['sample_result'], 'lag_uncertain')
|
||||
self.assertEqual(self.c.learners['sensor.room'].count, 0)
|
||||
|
||||
async def test_learning_waits_until_lag_settles_after_restart(self):
|
||||
self.states['switch.boiler'].last_changed = self.now # HA restart resets last_changed
|
||||
results = []
|
||||
for _ in range(8):
|
||||
for entity in ('sensor.room', 'sensor.outside'):
|
||||
self.states[entity].last_reported = self.now
|
||||
results.append((await self.sample())['sample_result'])
|
||||
self.assertEqual(results[0], 'first_sample')
|
||||
self.assertEqual(results[1:7], ['lag_uncertain'] * 6)
|
||||
self.assertEqual(results[7], 'learned')
|
||||
|
||||
async def test_room_outage_keeps_relay_lag_tracking(self):
|
||||
await self.sample()
|
||||
self.switch('on', 150)
|
||||
self.states['sensor.room'].state = 'unavailable'
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['status'], 'waiting_for_sensors')
|
||||
self.assertLess(data['heat_lag'], 0.2)
|
||||
|
||||
async def test_relay_outage_restarts_lag_estimate(self):
|
||||
await self.sample()
|
||||
self.states['switch.boiler'].state = 'unavailable'
|
||||
data = await self.sample()
|
||||
self.assertNotIn('heat_lag', data)
|
||||
self.switch('off', 30)
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['sample_result'], 'first_sample')
|
||||
self.assertGreater(data['heat_lag_uncertainty'], 0.4)
|
||||
|
||||
async def test_diagnostic_input_changes_do_not_discard_prediction(self):
|
||||
self.ready()
|
||||
self.c.config['outside_pressure_sources'] = ['sensor.pressure']
|
||||
self.set_state('sensor.pressure', '1010', unit_of_measurement='hPa')
|
||||
self.set_state('climate.reference', 'heat', temperature=22, current_temperature=20.0)
|
||||
async def changed(job):
|
||||
output = job()
|
||||
self.set_state('sensor.pressure', '1011', unit_of_measurement='hPa')
|
||||
self.set_state('climate.reference', 'heat', temperature=22, current_temperature=20.1)
|
||||
return output
|
||||
self.hass.async_add_executor_job = changed
|
||||
self.assertEqual((await self.sample())['status'], 'ready')
|
||||
|
||||
async def test_heat_gain_identified_with_mid_interval_switching(self):
|
||||
"""Synthetic plant with radiator lag, cycled by a hysteresis thermostat."""
|
||||
a, b, c = 0.004, 0.12, 0.0005
|
||||
advance = coordinator.advance_lag
|
||||
self.states['climate.reference'].state = 'off' # learn only
|
||||
temp, lag, on = 21.0, 0.0, False
|
||||
for k in range(1500):
|
||||
outside = 5 + 5 * sin(2 * pi * k / 288)
|
||||
for entity, value in (('sensor.room', temp), ('sensor.outside', outside)):
|
||||
self.states[entity].state = f'{value:.5f}'
|
||||
self.states[entity].last_reported = self.now
|
||||
data = await self.sample()
|
||||
command = temp < 20.7 or (on and temp < 21.3)
|
||||
if command != on: # relay switches 120 s after this poll
|
||||
self.switch('on' if command else 'off', 180)
|
||||
lag = advance(advance(lag, on, 120), command, 180)
|
||||
else:
|
||||
lag = advance(lag, on, 300)
|
||||
on = command
|
||||
temp += a * (outside - temp) + b * lag + c
|
||||
model = self.c.learners['sensor.room']
|
||||
self.assertGreater(model.on_count, 100)
|
||||
self.assertAlmostEqual(model.theta[1], b, delta=0.1 * b)
|
||||
self.assertAlmostEqual(model.theta[0], a, delta=0.25 * a)
|
||||
# Walk-forward replay with the real relay history beats persistence clearly.
|
||||
accuracy = data['forecast_accuracy'][60]
|
||||
self.assertGreater(accuracy['samples'], 1400)
|
||||
self.assertLess(accuracy['mae'], 0.06)
|
||||
self.assertGreater(accuracy['skill'], 0.85)
|
||||
|
||||
async def test_several_relay_changes_between_polls_are_integrated(self):
|
||||
advance = coordinator.advance_lag
|
||||
await self.sample()
|
||||
lag, uncertainty = self.c._lag['lag'], self.c._lag['uncertainty']
|
||||
start = self.now - timedelta(seconds=300)
|
||||
# The listener saw on at +60 s and off at +200 s; the state reads off again.
|
||||
self.c.record_relay_state(start + timedelta(seconds=60), 'on')
|
||||
self.c.record_relay_state(start + timedelta(seconds=200), 'off')
|
||||
self.switch('off', 100)
|
||||
data = await self.sample()
|
||||
lag = advance(advance(advance(lag, False, 60), True, 140), False, 100)
|
||||
self.assertEqual(data['sample_result'], 'relay_changed')
|
||||
self.assertAlmostEqual(self.c._lag['lag'], lag)
|
||||
self.assertAlmostEqual(self.c._lag['uncertainty'], uncertainty * exp(-300 / 1200))
|
||||
self.assertEqual(len(self.c._relay_events), 0)
|
||||
self.assertEqual((await self.sample())['sample_result'], 'learned')
|
||||
|
||||
async def test_queued_relay_event_is_reconciled_from_current_state(self):
|
||||
advance = coordinator.advance_lag
|
||||
await self.sample()
|
||||
lag = self.c._lag['lag']
|
||||
start = self.now - timedelta(seconds=300)
|
||||
# Only the first change reached the listener before this poll.
|
||||
self.c.record_relay_state(start + timedelta(seconds=60), 'on')
|
||||
self.switch('off', 100)
|
||||
await self.sample()
|
||||
lag = advance(advance(advance(lag, False, 60), True, 140), False, 100)
|
||||
self.assertAlmostEqual(self.c._lag['lag'], lag)
|
||||
# The late event for the change already accounted for is ignored next time.
|
||||
self.c.record_relay_state(start + timedelta(seconds=200), 'off')
|
||||
self.assertEqual((await self.sample())['sample_result'], 'learned')
|
||||
|
||||
async def test_relay_outage_or_dropped_events_restart_lag_estimate(self):
|
||||
for events in ((('unavailable', 60), ('off', 120)), ((None, 0),)):
|
||||
with self.subTest(events=events):
|
||||
self.c._lag = self.c._previous = None
|
||||
await self.sample()
|
||||
start = self.now - timedelta(seconds=300)
|
||||
if events[0][0] is None:
|
||||
self.c._relay_events_dropped = True
|
||||
for state, offset in events:
|
||||
if state:
|
||||
self.c.record_relay_state(start + timedelta(seconds=offset), state)
|
||||
self.switch('off', 180)
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['sample_result'], 'relay_changed')
|
||||
self.assertAlmostEqual(data['heat_lag_uncertainty'], round(exp(-180 / 1200) / 2, 3))
|
||||
self.assertFalse(self.c._relay_events_dropped)
|
||||
|
||||
async def test_relay_listener_records_only_state_changes(self):
|
||||
def event(old, new, when):
|
||||
return types.SimpleNamespace(time_fired=when, data={
|
||||
'old_state': old and types.SimpleNamespace(state=old),
|
||||
'new_state': new and types.SimpleNamespace(state=new, last_changed=when)})
|
||||
self.c.async_relay_changed(event('off', 'on', self.now))
|
||||
self.c.async_relay_changed(event('on', 'on', self.now)) # attribute-only update
|
||||
self.c.async_relay_changed(event('on', None, self.now)) # entity removed
|
||||
self.assertEqual(list(self.c._relay_events), [(self.now, 'on'), (self.now, 'removed')])
|
||||
for _ in range(coordinator.MAX_RELAY_EVENTS):
|
||||
self.c.record_relay_state(self.now, 'on')
|
||||
self.assertTrue(self.c._relay_events_dropped)
|
||||
|
||||
async def test_removed_room_model_is_kept_and_restored(self):
|
||||
old = coordinator.Learner(count=500, on_count=250, off_count=250)
|
||||
self.c._store.async_load.return_value = {'schema_version': 2, 'learners': {
|
||||
'sensor.room': coordinator.Learner(count=7).as_dict(), 'sensor.gone': old.as_dict()}}
|
||||
await self.c.async_restore_model()
|
||||
self.assertEqual(self.c.learners['sensor.room'].count, 7)
|
||||
await self.sample()
|
||||
self.assertEqual((await self.sample())['retired_room_models'], ['sensor.gone'])
|
||||
payload = self.c._store.async_save.call_args.args[0]
|
||||
self.assertEqual(set(payload['learners']), {'sensor.room'})
|
||||
self.assertEqual(payload['retired_learners']['sensor.gone']['count'], 500)
|
||||
self.entry.options['room_entities'] = ['sensor.room', 'sensor.gone']
|
||||
new = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||
new._store.async_load.return_value = payload
|
||||
await new.async_restore_model()
|
||||
self.assertEqual(new.learners['sensor.gone'].count, 500)
|
||||
self.assertEqual(new.learners['sensor.room'].count, 8)
|
||||
self.assertEqual(new._retired, {})
|
||||
|
||||
async def test_retired_room_models_are_bounded_and_validated(self):
|
||||
retired = {f'sensor.old{i}': coordinator.Learner(count=i + 1).as_dict() for i in range(30)}
|
||||
retired.update({'sensor.bad': 'broken', 'sensor.empty': coordinator.Learner().as_dict()})
|
||||
self.c._store.async_load.return_value = {'retired_learners': retired}
|
||||
await self.c.async_restore_model()
|
||||
self.assertEqual(list(self.c._retired), [f'sensor.old{i}' for i in range(6, 30)])
|
||||
|
||||
async def test_report_gap_maximum_is_exposed_per_sensor(self):
|
||||
await self.sample(2400) # the room then stays silent for 40 minutes
|
||||
self.states['sensor.outside'].last_reported = self.now
|
||||
data = await self.sample()
|
||||
room = data['input_quality']['rooms']['sensor.room']
|
||||
self.assertEqual((room['status'], room['max_age_seconds_24h']), ('stale', 2400))
|
||||
self.states['sensor.room'].last_reported = self.now
|
||||
self.states['sensor.outside'].last_reported = self.now
|
||||
data = await self.sample()
|
||||
room = data['input_quality']['rooms']['sensor.room']
|
||||
self.assertEqual((room['status'], room['age_seconds'], room['max_age_seconds_24h']),
|
||||
('valid', 0, 2400))
|
||||
weather = data['input_quality']['weather']['temperature']['sensor.outside']
|
||||
self.assertEqual(weather['max_age_seconds_24h'], 0) # kept reporting; tracked separately
|
||||
|
||||
def two_rooms(self, keep_last=False):
|
||||
self.entry.data['room_entities'] = ['sensor.room', 'sensor.bedroom']
|
||||
self.entry.data['weights'] = {'sensor.room': 3, 'sensor.bedroom': 1}
|
||||
self.entry.data['room_stale_use_last_value'] = keep_last
|
||||
self.set_state('sensor.bedroom', '18', unit_of_measurement='°C')
|
||||
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||
|
||||
def fresh(self, *entities):
|
||||
for entity in entities or ('sensor.room', 'sensor.bedroom', 'sensor.outside'):
|
||||
self.states[entity].last_reported = self.now
|
||||
|
||||
async def test_stale_room_is_left_out_and_other_rooms_continue(self):
|
||||
self.two_rooms()
|
||||
await self.sample()
|
||||
self.fresh('sensor.room', 'sensor.outside')
|
||||
self.states['sensor.bedroom'].last_reported = self.now - timedelta(minutes=31)
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['status'], 'learning')
|
||||
self.assertEqual(data['excluded_rooms'], ['sensor.bedroom'])
|
||||
rooms = data['input_quality']['rooms']
|
||||
self.assertEqual((rooms['sensor.bedroom']['status'], rooms['sensor.bedroom']['used']), ('stale', False))
|
||||
self.assertTrue(rooms['sensor.room']['used'])
|
||||
self.assertEqual((data['weighted_temperature'], data['weight_coverage']), (20, 0.75))
|
||||
self.assertEqual(data['sample_result'], 'learned')
|
||||
counts = lambda: (self.c.learners['sensor.room'].count, self.c.learners['sensor.bedroom'].count)
|
||||
self.assertEqual(counts(), (1, 0))
|
||||
self.fresh() # bedroom reports again: no learning across its gap
|
||||
data = await self.sample()
|
||||
self.assertEqual((data['excluded_rooms'], data['weight_coverage']), ([], 1.0))
|
||||
self.assertEqual(data['weighted_temperature'], 19.5)
|
||||
self.assertEqual(counts(), (2, 0))
|
||||
self.fresh()
|
||||
await self.sample()
|
||||
self.assertEqual(counts(), (3, 1))
|
||||
|
||||
async def test_left_out_room_is_not_used_for_prediction(self):
|
||||
self.two_rooms()
|
||||
model = self.c.learners['sensor.room'] # the bedroom stays untrained
|
||||
model.count, model.on_count, model.off_count = 200, 100, 100
|
||||
self.states['sensor.bedroom'].state = 'unavailable'
|
||||
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['status'], 'ready')
|
||||
temps, weights, learners = (spy.call_args.args[i] for i in (0, 4, 5))
|
||||
self.assertEqual((set(temps), weights, set(learners)),
|
||||
({'sensor.room'}, {'sensor.room': 3.0}, {'sensor.room'}))
|
||||
self.assertEqual(set(data['prediction_60m']), {'sensor.room'})
|
||||
|
||||
async def test_all_rooms_unusable_still_pauses(self):
|
||||
self.two_rooms()
|
||||
for room in ('sensor.room', 'sensor.bedroom'):
|
||||
self.states[room].last_reported = self.now - timedelta(minutes=31)
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['status'], 'waiting_for_sensors')
|
||||
self.assertEqual(data['excluded_rooms'], ['sensor.room', 'sensor.bedroom'])
|
||||
self.assertIsNone(data['proposal'])
|
||||
|
||||
async def test_keep_last_value_option_uses_stale_room_for_up_to_a_day(self):
|
||||
self.two_rooms(keep_last=True)
|
||||
self.states['sensor.bedroom'].last_reported = self.now - timedelta(hours=2)
|
||||
await self.sample()
|
||||
self.fresh('sensor.room', 'sensor.outside')
|
||||
data = await self.sample()
|
||||
bedroom = data['input_quality']['rooms']['sensor.bedroom']
|
||||
self.assertEqual((bedroom['status'], bedroom['used']), ('stale', True))
|
||||
self.assertEqual((data['excluded_rooms'], data['temperatures']['sensor.bedroom']), ([], 18))
|
||||
self.assertEqual(self.c.learners['sensor.bedroom'].count, 1)
|
||||
for state, age in (('18', timedelta(hours=24, seconds=1)), ('unavailable', timedelta(0))):
|
||||
with self.subTest(state=state):
|
||||
self.fresh('sensor.room', 'sensor.outside')
|
||||
self.states['sensor.bedroom'].state = state
|
||||
self.states['sensor.bedroom'].last_reported = self.now - age
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['excluded_rooms'], ['sensor.bedroom'])
|
||||
self.assertEqual(data['status'], 'learning')
|
||||
|
||||
async def test_poll_outcomes_and_rejections_are_counted(self):
|
||||
await self.sample()
|
||||
self.states['sensor.room'].state = '25' # implausible 5 °C jump in five minutes
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['sample_result'], 'partly_rejected')
|
||||
self.assertEqual(data['models']['sensor.room']['rejected_samples'], 1)
|
||||
self.fresh('sensor.room', 'sensor.outside')
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['sample_results_since_start'],
|
||||
{'first_sample': 1, 'partly_rejected': 1, 'learned': 1})
|
||||
|
||||
async def test_restores_model_saved_by_v021(self):
|
||||
# Exact shape written by 0.2.1: no schema version, per-room counts or new keys.
|
||||
legacy = {'learners': {'sensor.room': {
|
||||
'theta': [0.004, 0.11, 0.0005], 'p': [[1e-4, 0, 0], [0, 0.01, 0], [0, 0, 1e-3]],
|
||||
'count': 900, 'error_sum': 27.0}},
|
||||
'on_samples': 300, 'off_samples': 600, 'heater_entity': 'switch.boiler',
|
||||
'outside_source': 'sensor.outside'}
|
||||
self.c._store.async_load.return_value = legacy
|
||||
await self.c.async_restore_model()
|
||||
model = self.c.learners['sensor.room']
|
||||
self.assertEqual((model.theta, model.count), ([0.004, 0.11, 0.0005], 900))
|
||||
self.assertEqual((model.on_count, model.off_count, model.rejected_count), (0, 0, 0))
|
||||
|
||||
async def test_saved_model_stays_readable_by_v021(self):
|
||||
"""Rollback contract: the keys and types 0.2.1's restore code reads."""
|
||||
await self.sample()
|
||||
await self.sample()
|
||||
payload = self.c._store.async_save.call_args.args[0]
|
||||
self.assertIsInstance(payload['heater_entity'], str)
|
||||
for key in ('on_samples', 'off_samples'):
|
||||
self.assertIs(type(payload[key]), int)
|
||||
for data in payload['learners'].values():
|
||||
self.assertEqual(len(data['theta']), 3)
|
||||
self.assertTrue(all(len(row) == 3 for row in data['p']))
|
||||
self.assertIs(type(data['count']), int)
|
||||
float(data['error_sum'])
|
||||
[float(v) for v in data['theta'] + [v for row in data['p'] for v in row]]
|
||||
|
||||
async def test_forecasts_are_scored_saved_and_restored(self):
|
||||
for _ in range(5):
|
||||
self.fresh('sensor.room', 'sensor.outside')
|
||||
data = await self.sample()
|
||||
accuracy = data['forecast_accuracy']
|
||||
self.assertEqual((accuracy[15]['samples'], accuracy[30]['samples'], accuracy[120]['samples']),
|
||||
(2, 0, 0))
|
||||
# The default parameters predict cooling while the room held 20 °C.
|
||||
self.assertLess(accuracy[15]['rooms']['sensor.room']['bias'], 0)
|
||||
self.assertEqual(accuracy[15]['persistence_mae'], 0)
|
||||
payload = self.c._store.async_save.call_args.args[0]
|
||||
restored = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||
restored._store.async_load.return_value = payload
|
||||
await restored.async_restore_model()
|
||||
self.assertEqual(restored._evaluator.summary(['sensor.room']), self.c._evaluator.summary(['sensor.room']))
|
||||
# A poll without a usable relay state breaks the replay window.
|
||||
self.states['switch.boiler'].state = 'unavailable'
|
||||
self.fresh('sensor.room', 'sensor.outside')
|
||||
await self.sample()
|
||||
self.states['switch.boiler'].state = 'off'
|
||||
for _ in range(3):
|
||||
self.fresh('sensor.room', 'sensor.outside')
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['forecast_accuracy'][15]['samples'], 2)
|
||||
self.fresh('sensor.room', 'sensor.outside')
|
||||
self.assertEqual((await self.sample())['forecast_accuracy'][15]['samples'], 3)
|
||||
|
||||
async def test_learning_progress_and_waiting_result(self):
|
||||
data = await self.sample()
|
||||
self.assertEqual((data['sample_result'], data['learning_progress']), ('first_sample', 0))
|
||||
self.ready()
|
||||
self.fresh('sensor.room', 'sensor.outside')
|
||||
self.assertEqual((await self.sample())['learning_progress'], 100)
|
||||
self.states['sensor.outside'].state = 'unavailable'
|
||||
data = await self.sample()
|
||||
self.assertEqual(data['sample_result'], 'waiting_for_inputs')
|
||||
self.assertEqual(data['sample_results_since_start']['waiting_for_inputs'], 1)
|
||||
@@ -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,27 @@ 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_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')):
|
||||
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,97 @@
|
||||
"""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_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_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]}}}})
|
||||
self.assertEqual(restored.as_dict(), {'days': {day: {'15': {'sensor.d': [2, 0.1, 0.2, 0.4]}}}})
|
||||
for garbage in (None, [], {'days': []}):
|
||||
restored.load(garbage)
|
||||
self.assertEqual(restored.as_dict(), {'days': {}})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+58
-2
@@ -76,7 +76,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 +87,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 +116,55 @@ 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_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()
|
||||
Reference in new issue
Block a user