Compare commits

..
20 Commits
Author SHA1 Message Date
buzeraandClaude Opus 5.5 e59c08f586 Release 0.3.0
Tests / tests (requirements-test-latest.txt) (push) Successful in 1m30s
Tests / tests (requirements-test.txt) (push) Successful in 1m39s
First part of Milestone 1: Forecast Error sensors scoring the thermal
model against reality, forecast trajectories, Learning Progress and Last
Sample sensors, rejected-sample and poll-outcome counters, plus lint
and rollback-contract tests. README explains forecast accuracy, the
30-minute learning wait after a restart and where Developer Tools now
lives. ROADMAP marks the items now backed by tests; AUDIT records the
evaluation design and evidence. Still observation-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:39:25 +02:00
buzeraandClaude Opus 5.5 d2bd398a0f Show learning activity with Learning Progress and Last Sample sensors
During a first test the Status entity only said "learning" for the
first half hour (the radiator-lag wait after a restart), and the
attributes explaining why were hard to find. Add two sensors:

- Learning Progress (%): for the least trained room, the mean of its
  three readiness requirements (200 samples, 30 ON, 30 OFF), each capped
  at 100 %; 100 % means ready. Attributes show the counts.
- Last Sample: an enum of the last poll's learning outcome, with English
  and Romanian state names (e.g. "Waiting for radiators to settle"). Its
  history shows when learning happens.

Polls blocked by missing shared inputs now report waiting_for_inputs.
Readiness thresholds move to const.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:39:25 +02:00
buzeraandClaude Opus 5.5 3c949333bd Stop tracking macOS .DS_Store files
Finder metadata was tracked at the repository root and nearly got
packaged inside the integration. Ignore .DS_Store everywhere, untrack
the root copy (the local file stays) and make the package check reject
it under custom_components.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:35:19 +02:00
buzeraandClaude Opus 5.5 3afb6688a5 Score forecasts against reality; expose forecast trajectories
There was no evidence of how accurate the thermal model is: the only
number was an in-sample 5-minute error.

At every poll, record each room's reading, the outdoor temperature, the
radiator-lag estimate and the model parameters. 15, 30, 60 and 120
minutes later, replay the room from that poll with the parameters it
had then (out of sample), the outdoor temperature held constant and the
radiator lag that actually followed from the relay's real history, and
compare with the reading now. A no-change forecast is scored alongside.
Only spans with regular polls and a well-known lag are scored. 7 days
of per-room statistics are saved with the model (optional "evaluation"
payload key; about 18 KB at most).

Four new sensors, Heating MPC Forecast Error 15/30/60/120 min, show the
mean absolute error with bias, persistence error, skill and per-room
detail. On the synthetic lagged plant the 60-minute error is 0.03 °C
against 0.39 °C for persistence.

Room forecast entities gain forecast_15min and plan_15min attributes
and are labelled counterfactual. Volatile attributes are unrecorded.
The model is now saved after scoring, so saved statistics are current.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:35:13 +02:00
buzeraandClaude Opus 5.5 43915ba810 Count rejected samples and poll outcomes; pin rollback contract
Status showed accepted sample counts but not why learning was slow.
Each room's model now counts samples it rejected (outliers, implausible
inputs), persisted as an optional rejected_count key and shown as
models.<room>.rejected_samples. Status also counts every poll outcome
since start (sample_results_since_start).

Add tests that a model in the exact shape 0.2.1 wrote restores with its
coefficients, and that today's saved model still has the keys and types
0.2.1's restore code reads, so rolling back keeps the learned model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:30:16 +02:00
buzeraandClaude Opus 5.5 ace6d0061b Add bug-finding lint to the checks
Run ruff with pyflakes, syntax-error and bugbear rules only (no style
rules) over the integration, tests and scripts as part of check.sh and
CI. The codebase passes as is. Target Python 3.9 because the offline
tests are also run on older interpreters, which also leaves out the
zip(strict=) rule that needs 3.10. ruff 0.14.0 pinned in both test
requirement files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:29:20 +02:00
buzeraandClaude Opus 5.5 ec3a0738fe Add integration icon
Home Assistant 2026.3+ shows custom integrations' own brand images from
custom_components/<domain>/brand/. Add a radiator with a forecast curve
(solid past, "now" marker, dashed prediction) as icon.png/icon@2x.png
(256/512 px, transparent) plus dark-theme variants, so the integration
is easy to spot in Devices & services. Older HA versions ignore the
folder and show the generic placeholder; the integration is unaffected.

scripts/make_brand_icons.py regenerates the images with Pillow from the
test requirements; the package check verifies sizes and transparency.
Listed under 0.2.3, which has not been deployed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:13:20 +02:00
buzeraandClaude Opus 5.5 c79fdbd71b Release 0.2.3
Bump the manifest to 0.2.3 for the stale-room policy: unusable room
sensors are left out with weights re-normalized, and an opt-in checkbox
keeps a stale sensor's last value for up to 24 hours. 0.2.2 was never
deployed, so the changelog marks it as such and the 0.2.3 notes point
0.2.1 users at both entries. ROADMAP and AUDIT record the decision and
the open control-design questions (minimum weight coverage, outdoor
temperature outages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:07:52 +02:00
buzeraandClaude Opus 5.5 3076beebfb Leave out unusable rooms instead of pausing; add keep-last-value option
A single stale, unavailable, missing or invalid room sensor paused
learning and forecasts for every room. That is not acceptable once the
MPC may control heating.

Now such a room is left out: the weighted temperature, learning and the
optimizer use the remaining rooms with their weights re-normalized, and
a room learns only across poll pairs where it was usable at both ends.
Readiness considers only the rooms used. Everything pauses only when no
room is usable or a shared input (relay, thermostat, outdoor
temperature) is missing.

A new setup/Configure checkbox, room_stale_use_last_value (default off),
keeps using a stale room sensor's last value instead, for sensors that
report only on change. It never applies to unavailable or invalid
sensors, or to a sensor silent for more than 24 hours.

Status adds excluded_rooms, weight_coverage and a per-room "used" flag.
English and Romanian labels and help text added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:07:18 +02:00
buzeraandClaude Opus 5.5 cd8b65ac2f Release 0.2.2
Bump the manifest to 0.2.2 and add CHANGELOG.md with upgrade, reset and
rollback notes covering all reliability work since 0.2.1. The README
drops its accumulated "Unreleased" sections in favor of the changelog
and gains a Diagnostics section explaining the Status attributes and how
to choose freshness limits. The package check now fails if the manifest
version has no changelog entry. ROADMAP marks the Milestone 0 items now
backed by tests and resets the next-session backlog; AUDIT.md records
this round's findings.

Still observation-only. Tested in the HA Core harness with HA 2025.12.0
and 2026.2.3; not on HAOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:55:02 +02:00
buzeraandClaude Opus 5.5 67fbda79a6 Test against HA 2026.2.3 as well as 2025.12.0 in CI
Add requirements-test-latest.txt pinning the newest published
pytest-homeassistant-custom-component (0.13.316, HA 2026.2.3, Python
3.13) and run the workflow as a matrix over both requirement files. No
DNS-library pin is needed for the newer release. Both environments pass
scripts/check.sh locally, with no HA deprecation warnings logged for
this integration. Remote runner execution is still unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:52:11 +02:00
buzeraandClaude Opus 5.5 4daba5028b Show each sensor's longest report gap; stop recording volatile attributes
Choosing room and weather freshness limits required knowing how long
each sensor normally goes without reporting, which HA does not show.
Add max_age_seconds_24h to every source in input_quality: the largest
report age seen at polls over the last 24 hours (hourly buckets, reset
on reload, underestimates by up to one poll interval).

input_quality and weather_comparison change every poll, so the Recorder
stored a new attribute row every five minutes. Mark them unrecorded;
they remain visible on the Status entity. Model coefficients and other
attributes are still recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:51:35 +02:00
buzeraandClaude Opus 5.5 c0e4de832b Keep models of rooms removed in Options
Saving wrote only the currently configured rooms, so removing a room in
Configure (even by accident) discarded its learned model at the next
save, and adding it back started from scratch.

Keep validated models of removed rooms (at most 24, oldest dropped
first) in an optional retired_learners payload key and restore them when
the room is added back. Payload schema_version stays 2: the key is
additive and older 0.2.x readers ignore it. Replacing the relay still
starts a fresh model, as before. Status lists retired_room_models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:50:36 +02:00
buzeraandClaude Opus 5.5 955c679f5e Timestamp relay changes with a read-only state listener
Polling only sees the relay's current state and last change, so two or
more changes between polls (e.g. an on/off round trip) made the lag
estimate unknown and paused learning for about half an hour.

Subscribe to the relay's state changes (read-only) and integrate every
recorded change between polls. Because HA delivers these events one
loop iteration late, the current state stays authoritative and fills in
a change not yet recorded. Unknown periods (non on/off states, missed
transitions, event overflow) still restart the estimate with an
explicit error bound. Without the listener, behavior is as before.

HA skips unload callbacks after unexpected setup errors, so setup now
removes the listener itself on any failure and registers it for unload
only once setup succeeds.

Tested on HA Core 2025.12.0 and 2026.2.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:49:34 +02:00
buzeraandClaude Opus 5.5 af0a564279 Document second audit pass
Record findings, evidence, compatibility notes and remaining gaps in
AUDIT.md; add user-facing upgrade notes to README and update test
counts; note progress against Milestone 0 in ROADMAP. No version bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:21:43 +02:00
buzeraandClaude Opus 5.5 df69dc9645 Add static shadow-only tripwire test
The HA suite rejects service calls at runtime, but only on exercised
paths. Parse the integration source and fail on service calls or
registration, actuator method names, service-name strings, or any
platform other than sensor/binary_sensor (AGENTS.md safety rule 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:21:43 +02:00
buzeraandClaude Opus 5.5 30f78fb6a9 Allow clearing optional weather sources in Options
Optional weather source fields used the current selection as the form
default. A cleared field that the frontend omits was refilled with the
old sources by voluptuous, and an options key missing from the
submission fell back to the original setup data. Optional providers
could therefore not be removed in the UI.

Pre-fill with suggested values instead, and save omitted optional lists
as empty so they override older entry data. No UI strings changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:21:38 +02:00
buzeraandClaude Opus 5.5 2a0a94c640 Discard predictions only when their inputs change
A finished plan was discarded whenever any watched State object changed
during the save or calculation, including diagnostic-only weather
sensors and the thermostat's current_temperature/hvac_action. The
proposed-heat entity then went unavailable for five minutes without
cause. Compare only the values the prediction uses: room and outdoor
temperature states and units, thermostat mode and target, and relay
state and last change.

Also pass config_entry to the coordinator explicitly instead of relying
on HA's ContextVar fallback, which HA reports as deprecated usage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:21:25 +02:00
buzeraandClaude Opus 5.5 c3646ba876 Track radiator lag across relay changes
The lag proxy was reset to the new relay command at every relay change
and after any skipped interval, so it read 1.0 straight after ON and 0.0
straight after OFF. This disabled the lag model around every boiler
cycle: on a synthetic lagged plant cycled by a hysteresis thermostat the
learner's heat gain came out 34% low (0.079 vs 0.12) and the loss term
43% low, and the optimizer started post-transition plans from the wrong
lag.

Track the lag at every poll with a valid relay state, independently of
room sensors. Integrate a single change between polls at its
last_changed time. For ambiguous cases (round trip between polls, relay
outage, restart, clock jump) restart from time-in-state with an explicit
error half-width, and learn only once it is at most 0.1 (about 32 min
after an HA restart). The same synthetic test now learns 0.1195/0.00389
(true 0.12/0.004).

Expose heat_lag, heat_lag_uncertainty and sample_result on Status so
users can see why a poll was or was not learned. The HA test fixture
relay now has an hour-old timestamp; a new test covers a freshly
written relay state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:21:14 +02:00
buzeraandClaude Opus 5.5 387929b01b Bound learner covariance to stop RLS windup
With forgetting 0.999 and no heat excitation (summer, long OFF periods),
the heat-coefficient variance grew by 1/0.999 per sample without limit:
about 8.7e12 after 104 simulated OFF days. The first ON samples could
then swing coefficients to their bounds, and accumulated asymmetry
approached the absolute 1e-8 restore tolerance, past which a restart
silently reset the whole model.

Symmetrize P and bound its diagonal by the prior after every update
(row/column scaling keeps it positive definite). Restore uses a relative
round-off tolerance and applies the same bound, so wound-up v0.2.1
models load instead of being discarded. Payload schema is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:20:50 +02:00
37 changed files with 2188 additions and 224 deletions

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+6 -1
View File
@@ -6,6 +6,11 @@ on:
jobs: jobs:
tests: tests:
runs-on: bs-node 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: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
# uv supplies standalone Python builds for the Debian job container. # uv supplies standalone Python builds for the Debian job container.
@@ -17,7 +22,7 @@ jobs:
- name: Create Python environment - name: Create Python environment
run: uv venv --python 3.13 --seed .venv run: uv venv --python 3.13 --seed .venv
- name: Install test dependencies - 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 - name: Run checks
run: | run: |
. .venv/bin/activate . .venv/bin/activate
+1
View File
@@ -4,3 +4,4 @@ __pycache__/
.coverage .coverage
htmlcov/ htmlcov/
.venv/ .venv/
.DS_Store
+134
View File
@@ -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. 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 The runner label and integration runtime are unchanged. Remote execution of the
corrected workflow remains to be verified. 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
View File
@@ -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.
+174 -102
View File
@@ -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. **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 ## Install / upgrade
1. Create a Home Assistant backup. 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**. 3. Restart Home Assistant **Core**.
4. If new: **Settings → Devices & services → Add integration → Heating MPC**. If installed already: select **Heating MPC → Configure** (Options). 4. If new: **Settings → Devices & services → Add integration → Heating MPC**. If installed already: select **Heating MPC → Configure** (Options).
5. Choose the existing thermostat and physical heating switch (read-only), then individual room sensor entities. 5. Choose the existing thermostat and physical heating switch (read-only), then 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. 6. Choose *ordered lists* of **outdoor sensor entity IDs**. First valid sensor is active; if missing, unavailable, nonnumeric or outside reasonable bounds, the next valid sensor is used automatically.
7. Assign a relative comfort weight (0.1–10) to each selected room. 7. Assign a relative comfort weight (0.1–10) to each selected 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. **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 ## 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 | | Outdoor temperature | **Yes** (1–10 prioritized sensors) | **Yes**: estimates thermal losses; constant over the 2 h forecast window |
| Wind speed | No | No, diagnostics/source comparison only | | Wind speed | No | 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 ## 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`. - 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. - 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**. - 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): 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_status` — learning/ready/paused status and diagnostics, including outside weather, active source, all valid alternative weather values, model coefficients, proposed 15-minute plan.
- `sensor.heating_mpc_weighted_temperature` — own room-weighted average, informative only. - `sensor.heating_mpc_weighted_temperature` — own room-weighted average, informative only.
- `sensor.mpc_outdoor_temperature` — selected external temp and alternatives. - `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). - `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 ## Known limitations / safety
@@ -59,111 +72,170 @@ Entities (entity IDs may vary):
- No weather forecast: assumes outdoor temperature remains constant during simulated horizon. - No weather forecast: assumes outdoor temperature remains constant during simulated horizon.
- No Recorder/InfluxDB training backfill. It learns only from samples collected after installation. - 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. - 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. - 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 ```bash
python -m unittest discover -s tests -v python -m unittest discover -s tests -v
``` ```
## v0.2.1: integration visibility and clean uninstall The brand icons in `custom_components/heating_mpc/brand/` are drawn by
`python scripts/make_brand_icons.py` (uses Pillow from the test requirements);
- 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. the package check verifies their sizes and transparency.
- 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.
HA tests cover setup, config/options forms, migration, reload and entity cleanup, HA tests cover setup, config/options forms, migration, reload and entity cleanup,
scheduled sampling and poll cancellation, stored model restoration/deletion, scheduled sampling and poll cancellation, the relay listener, stored model
missing inputs, manual off/presets and optimizer failure. A fixture rejects all restoration, retention and deletion, missing inputs, manual off/presets, optimizer
HA service calls throughout each test. HA Core runs locally with simulated input failure and Recorder exclusions. A fixture rejects all HA service calls throughout
entities and mocked storage I/O; this does not validate HAOS, physical devices, each test, and a static test fails if actuator service calls are added to the
disk failures or full process restarts. The original standalone unittest command source. HA Core runs locally with simulated input entities and mocked storage I/O;
still works without installing HA. 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 `.gitea/workflows/tests.yml` runs the same checks for both HA versions on pushes
the dedicated `bs-node` Actions runner label (`bs-node:docker://node:22-bookworm`). Repository Actions must be enabled. and pull requests, on the dedicated `bs-node` Actions runner label
The runner must support the checkout and setup-uv actions. CI uses pinned uv (`bs-node:docker://node:22-bookworm`). Repository Actions must be enabled, and the
0.9.6 to create a Python 3.13 virtual environment from standalone Python builds; 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. `setup-python` cannot provision this version in the Debian Bookworm job image.
The environment includes pip for the existing check script. Action caching is Action caching is disabled to avoid requiring GitHub cache services on Gitea.
explicitly disabled to avoid requiring GitHub cache services on Gitea.
Dependencies, Python builds and action downloads need internet access in CI; Dependencies, Python builds and action downloads need internet access in CI;
model inference does not. Remote CI execution has not yet been verified. Local 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. validation used macOS.
No runtime integration behavior or manifest version changed in this test commit.
+43 -16
View File
@@ -10,6 +10,9 @@ Success means predictable comfort, understandable decisions, safe operation and
## Current baseline — v0.2.1 ## 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):** **Implemented (experimental):**
- [x] Native Home Assistant integration with UI-based Config Flow and Options Flow. - [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] 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. - [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. - [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.)*
- [ ] Establish deterministic handling of 5-minute transitions, polling jitter, timestamp discontinuities, missing data and real switch transitions between samples. - [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. - [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. - [ ] 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). - [ ] 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. **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 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. 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) ## Milestone 1 — Explainable forecasts and dashboards (target v0.3.0)
**Objective:** Users can verify what the model predicts and whether it is actually accurate. **Objective:** Users can verify what the model predicts and whether it is actually accurate.
- [ ] Expose trajectories for each room at 5–15-minute resolution for 2–3 hours, with forecast generation timestamp, horizon and input provenance. - [ ] Expose trajectories for each room at 5–15-minute resolution for 2–3 hours, with forecast generation timestamp, horizon and input provenance. *(0.3.0: 15-minute trajectory over the horizon; generation timestamp and provenance still to add.)*
- [ ] Expose predicted +15/+30/+60/+120 minute temperatures and full proposed ON/OFF schedule. - [x] Expose predicted +15/+30/+60/+120 minute temperatures and full proposed ON/OFF schedule. *(0.3.0: `forecast_15min` and `plan_15min` on each room forecast entity.)*
- [ ] Provide an HA dashboard example using native cards or a clearly documented optional chart card (do not bundle a heavyweight frontend unnecessarily). - [ ] Provide an HA dashboard example using native cards or a clearly documented optional chart card (do not bundle a heavyweight frontend unnecessarily).
- [ ] Make clear that the **MPC plan forecast is counterfactual** while Generic Thermostat is controlling the relay; add a separate actual-policy forecast/evaluation pathway. - [ ] 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. - [ ] 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. - [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. - [ ] Compare against simple benchmarks such as current-temperature persistence and a fixed thermal baseline. *(0.3.0: persistence done; fixed baseline open.)*
- [ ] Add inspectable explanations: expected heat loss, expected heating response, target, room weights and reason for the next proposed action. - [ ] Add inspectable explanations: expected heat loss, expected heating response, target, room weights and reason for the next proposed action.
**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. **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. - Guaranteeing a fuel saving based solely on relay-state history.
- Auto-switching the user's HVAC mode between `off` and `heat`. - 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. Items 1–3 of the original backlog are done (see AUDIT.md and CHANGELOG.md 0.2.2).
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. 1. **HAOS acceptance of 0.2.3 by the user:** install, restart, options reload, room
4. Add forecast recording and held-out evaluation, then expose a trajectory usable by an HA dashboard. removal and re-adding, manual heat/off, a stale and an unavailable room sensor
5. Only then consider richer weather inputs and the live-control **design**; do not implement live control without separate authorization. (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 ## Definition of done for each milestone
+11 -4
View File
@@ -6,8 +6,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.helpers import entity_registry as er 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 .weather_sources import sources_for
from .coordinator import HeatingMPCCoordinator 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = HeatingMPCCoordinator(hass, entry) coordinator = HeatingMPCCoordinator(hass, entry)
await coordinator.async_restore_model() await coordinator.async_restore_model()
# 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() await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
try:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except Exception: except BaseException:
hass.data[DOMAIN].pop(entry.entry_id, None) # HA skips unload callbacks for unexpected setup errors, so clean up here.
unsub_relay()
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
raise raise
entry.async_on_unload(unsub_relay)
# Remove only obsolete room forecasts belonging to this entry/platform. # Remove only obsolete room forecasts belonging to this entry/platform.
registry = er.async_get(hass) registry = er.async_get(hass)
expected = {f"{entry.entry_id}_{room}_predicted_60min" for room in coordinator.rooms} 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

+18 -6
View File
@@ -9,7 +9,7 @@ from homeassistant.core import callback
from homeassistant.helpers import selector from homeassistant.helpers import selector
from .const import ( 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, DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
CONF_WEIGHTS, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_WEIGHTS, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS, 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): for key in (CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE):
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = vol.All( schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = vol.All(
vol.Coerce(int), vol.Range(min=5, max=1440)) 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: if detailed:
for key, allowed in { for key, allowed in {
CONF_HORIZON: vol.In([60, 90, 120, 150, 180]), 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. # A temperature source is mandatory: other weather inputs are optional.
marker = vol.Required(key, default=selected) if selected else vol.Required(key) marker = vol.Required(key, default=selected) if selected else vol.Required(key)
else: 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) schema[marker] = _entity_picker(multiple=True)
return vol.Schema(schema) 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): def _weight_schema(rooms: list[str], existing: dict[str, float] | None = None):
weights = existing or {} weights = existing or {}
return vol.Schema({ return vol.Schema({
@@ -110,14 +122,14 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if not _valid_weather(self._draft[CONF_ROOMS], user_input): if not _valid_weather(self._draft[CONF_ROOMS], user_input):
return self.async_show_form(step_id="weather", data_schema=_weather_schema( return self.async_show_form(step_id="weather", data_schema=_weather_schema(
user_input, WEATHER_MAIN), errors={"base": "invalid_weather"}) 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 await self.async_step_weather_extra()
return self.async_show_form(step_id="weather", data_schema=_weather_schema( return self.async_show_form(step_id="weather", data_schema=_weather_schema(
self._draft, WEATHER_MAIN)) self._draft, WEATHER_MAIN))
async def async_step_weather_extra(self, user_input: dict[str, Any] | None = None): async def async_step_weather_extra(self, user_input: dict[str, Any] | None = None):
if user_input is not 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 await self.async_step_weights()
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema( return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
self._draft, WEATHER_EXTRA)) self._draft, WEATHER_EXTRA))
@@ -161,7 +173,7 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
if not _valid_weather(self._draft[CONF_ROOMS], user_input): if not _valid_weather(self._draft[CONF_ROOMS], user_input):
return self.async_show_form(step_id="weather", data_schema=_weather_schema( return self.async_show_form(step_id="weather", data_schema=_weather_schema(
user_input, WEATHER_MAIN), errors={"base": "invalid_weather"}) 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 await self.async_step_weather_extra()
return self.async_show_form(step_id="weather", data_schema=_weather_schema( return self.async_show_form(step_id="weather", data_schema=_weather_schema(
config, WEATHER_MAIN)) 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): 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} config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
if user_input is not 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 await self.async_step_weights()
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema( return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
config, WEATHER_EXTRA)) config, WEATHER_EXTRA))
+15
View File
@@ -12,6 +12,9 @@ CONF_SWITCH_COST = "switching_cost"
CONF_HEAT_COST = "heating_cost" CONF_HEAT_COST = "heating_cost"
CONF_ROOM_MAX_AGE = "room_max_age_minutes" CONF_ROOM_MAX_AGE = "room_max_age_minutes"
CONF_WEATHER_MAX_AGE = "weather_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" CONF_BAND = "comfort_band"
# Each source list is ordered: first valid sensor wins; remaining are fallbacks. # 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 = { DEFAULTS = {
CONF_ROOM_MAX_AGE: 30, CONF_ROOM_MAX_AGE: 30,
CONF_WEATHER_MAX_AGE: 120, CONF_WEATHER_MAX_AGE: 120,
CONF_ROOM_STALE_KEEP: False,
CONF_HORIZON: 120, CONF_HORIZON: 120,
CONF_MIN_ON: 15, CONF_MIN_ON: 15,
CONF_MIN_OFF: 10, CONF_MIN_OFF: 10,
@@ -39,5 +43,16 @@ DEFAULTS = {
CONF_HEAT_COST: 0.025, CONF_HEAT_COST: 0.025,
CONF_BAND: 0.15, 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 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 BLOCK_MINUTES = 15
+214 -56
View File
@@ -2,41 +2,61 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections import deque
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import partial from functools import partial
from math import exp, isfinite from math import exp, isfinite
from typing import Any from typing import Any
from homeassistant.core import HomeAssistant from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import ( 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, 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_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 from .weather_sources import choose_weather_sources, sources_for, normalize
_LOGGER = logging.getLogger(__name__) _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]]): class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""The existing generic thermostat stays the only actuator owner.""" """The existing generic thermostat stays the only actuator owner."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: 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.entry = entry
self.config = {**DEFAULTS, **entry.data, **entry.options} self.config = {**DEFAULTS, **entry.data, **entry.options}
self.rooms: list[str] = self.config[CONF_ROOMS] self.rooms: list[str] = self.config[CONF_ROOMS]
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms} self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
self._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._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
self._previous: dict[str, Any] | None = None 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._on_samples = 0
self._off_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]: if stored.get("heater_entity", self.config[CONF_HEATER]) != self.config[CONF_HEATER]:
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model") _LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
return return
# Models carry room identity, so changing the room list is safe. self._evaluator.load(stored.get("evaluation"))
saved = stored.get("learners", {}) # 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): if not isinstance(saved, dict):
return continue
for room, data in saved.items(): for room, data in saved.items():
if not isinstance(room, str):
continue
learner = Learner.from_dict(data)
if room in self.learners: if room in self.learners:
self.learners[room] = Learner.from_dict(data) 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() self._update_counts()
def _update_counts(self) -> None: def _update_counts(self) -> None:
@@ -70,8 +101,91 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if state.state in ("unknown", "unavailable"): if state.state in ("unknown", "unavailable"):
return {"status": state.state, "age_seconds": None} return {"status": state.state, "age_seconds": None}
# last_updated is a conservative compatibility fallback for older HA. # last_updated is a conservative compatibility fallback for older HA.
return freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)), quality = freshness(getattr(state, "last_reported", getattr(state, "last_updated", None)),
now, limit * 60) 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]: async def _async_update_data(self) -> dict[str, Any]:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -83,20 +197,26 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
entity_id for metric in WEATHER_SOURCES entity_id for metric in WEATHER_SOURCES
for entity_id in sources_for(self.config, metric) for entity_id in sources_for(self.config, metric)
} }
watched = set(self.rooms) | weather_entity_ids | { snapshot = {entity: self.hass.states.get(entity)
self.config[CONF_CLIMATE], self.config[CONF_HEATER]} for entity in set(self.rooms) | weather_entity_ids}
snapshot = {entity: self.hass.states.get(entity) for entity in watched} inputs_before = self._prediction_inputs()
room_quality = {} room_quality = {}
temps = {} temps = {}
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
for room in self.rooms: for room in self.rooms:
quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE]) quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE])
value = None 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] state = snapshot[room]
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement")) value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
if value is None or not 0 <= value <= 45: if value is None or not 0 <= value <= 45:
quality["status"] = "invalid_value_or_unit" quality["status"] = "invalid_value_or_unit"
value = None value = None
quality["used"] = value is not None
room_quality[room] = quality room_quality[room] = quality
temps[room] = value temps[room] = value
weather_states = {} 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_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_comparison": {k: v for k, v in weather_comparisons.items() if v},
"weather_model_inputs": ["temperature"], "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._previous = None
self._heat_lag = None self._evaluator.reset_window()
return status status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
if outside is None or any(temp is None for temp in temps.values()): self._count_result(status, "waiting_for_inputs")
self._previous = None
self._heat_lag = None
return status return status
# No casting surprises: all rooms are valid here. # A room learns only across a poll pair where it was usable at both ends.
vals: dict[str, float] = {room: float(temps[room]) for room in self.rooms} pairs = [room for room in vals if self._previous and room in self._previous["temps"]]
heater_on = heater.state == "on" learned = False
if self._heat_lag is None: if not pairs:
self._heat_lag = float(heater_on) status["sample_result"] = "first_sample"
if self._previous is not None: else:
dt = (now - self._previous["time"]).total_seconds() dt = (now - self._previous["time"]).total_seconds()
# Do not fit one transition across a provider/fallback switch. # Do not fit across a relay change, a provider/fallback switch or an
if (270 <= dt <= 330 and # uncertain lag estimate (restart, relay outage, several changes).
heater.last_changed <= self._previous["time"] and if not 270 <= dt <= 330:
heater_on == self._previous["heater_on"] and status["sample_result"] = "interval_out_of_range"
self._previous["outside_source"] == active_sources["temperature"]): elif not steady or heater_on != self._previous["heater_on"]:
next_lag = self._heat_lag + (1 - exp(-dt / 1200)) * (float(self._previous["heater_on"]) - self._heat_lag) status["sample_result"] = "relay_changed"
for room in self.rooms: 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.learners[room].learn(
self._previous["temps"][room], vals[room], self._previous["temps"][room], vals[room],
self._previous["outside"], next_lag, dt, self._previous["heater_on"] self._previous["outside"], self._lag["lag"], dt, heater_on)
) for room in pairs
self._heat_lag = next_lag ]
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
self._update_counts() self._update_counts()
# Persist at each sample so restart does not erase hours of training. learned = True
await self._store.async_save({
"schema_version": 2,
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
"on_samples": self._on_samples, "off_samples": self._off_samples,
"heater_entity": self.config[CONF_HEATER],
"outside_source": active_sources["temperature"],
})
else:
# Model lag state becomes uncertain after long gaps.
self._heat_lag = float(heater_on)
self._count_result(status, status["sample_result"])
self._previous = { self._previous = {
"time": now, "temps": vals, "outside": outside, "heater_on": heater_on, "time": now, "temps": vals, "outside": outside, "heater_on": heater_on,
"outside_source": active_sources["temperature"], "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, {}) weights = self.config.get(CONF_WEIGHTS, {})
try: try:
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms} 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): except (AttributeError, TypeError, ValueError, OverflowError):
status["status"] = "invalid_weights" status["status"] = "invalid_weights"
return status 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({ status.update({
"weighted_temperature": round(weighted_temp, 3), "weighted_temperature": round(weighted_temp, 3),
"weight_coverage": round(sum(used_weights.values()) / sum(weights.values()), 3),
"outside_temperature": outside, "outside_temperature": outside,
"reference_heat": heater_on, "reference_heat": heater_on,
"on_samples": self._on_samples, "on_samples": self._on_samples,
@@ -198,6 +348,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"bias": round(model.theta[2], 6), "bias": round(model.theta[2], 6),
"samples": model.count, "samples": model.count,
"on_samples": model.on_count, "off_samples": model.off_count, "on_samples": model.on_count, "off_samples": model.off_count,
"rejected_samples": model.rejected_count,
"mae_5m": round(model.mae, 4) if model.mae is not None else None} "mae_5m": round(model.mae, 4) if model.mae is not None else None}
for room, model in self.learners.items() for room, model in self.learners.items()
}, },
@@ -215,8 +366,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
status["status"] = "waiting_for_target" status["status"] = "waiting_for_target"
return status return status
status["target"] = target status["target"] = target
# Do not act on a cold model: need ~17 hours of samples plus both conditions. # Do not act on a cold model: every room used needs ~17 hours of samples
if status["samples"] < 200 or self._on_samples < 30 or self._off_samples < 30: # 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" status["status"] = "learning"
return status return status
try: try:
@@ -224,7 +379,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# never block Home Assistant's event loop with CPU work. # never block Home Assistant's event loop with CPU work.
job = partial( job = partial(
optimize, 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(), heater_on, (now - heater.last_changed).total_seconds(),
horizon_minutes=int(self.config[CONF_HORIZON]), horizon_minutes=int(self.config[CONF_HORIZON]),
minimum_on_minutes=int(self.config[CONF_MIN_ON]), 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]), heating_cost=float(self.config[CONF_HEAT_COST]),
comfort_band=float(self.config[CONF_BAND]), comfort_band=float(self.config[CONF_BAND]),
) )
# HA State objects are immutable snapshots. Discard a result if any # Discard a result if a value used by the prediction changed while
# input changed while the worker was calculating the hypothetical plan. # saving or while the worker calculated the hypothetical plan. Optional
# diagnostic weather sensors are not prediction inputs.
output = await self.hass.async_add_executor_job(job) output = await self.hass.async_add_executor_job(job)
if 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" status["status"] = "inputs_changed"
return status return status
except (ValueError, RuntimeError, OverflowError) as err: except (ValueError, RuntimeError, OverflowError) as err:
@@ -248,6 +405,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"prediction_60m": output["predicted_60m"], "prediction_60m": output["predicted_60m"],
"prediction_end": output["predicted_end"], "prediction_end": output["predicted_end"],
"plan_15min": output["plan_15min"], "objective": output["cost"], "plan_15min": output["plan_15min"], "objective": output["cost"],
"prediction_trajectory": output["trajectory_15min"],
"model_quality_note": "5-minute in-sample MAE; not a validated 60-minute forecast error", "model_quality_note": "5-minute in-sample MAE; not a validated 60-minute forecast error",
}) })
return status return status
@@ -1,6 +1,7 @@
"""Pure timestamp validation for reported sensor snapshots.""" """Pure timestamp validation for reported sensor snapshots."""
from __future__ import annotations from __future__ import annotations
from collections import deque
from datetime import datetime from datetime import datetime
from math import isfinite 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": "future_timestamp", "age_seconds": round(age, 1)}
return {"status": "stale" if age > max_age_seconds else "valid", return {"status": "stale" if age > max_age_seconds else "valid",
"age_seconds": round(age, 1)} "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)
+161
View File
@@ -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,
}
+1 -1
View File
@@ -9,5 +9,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "0.2.1" "version": "0.3.0"
} }
+99 -16
View File
@@ -11,23 +11,87 @@ from math import exp, isfinite
from typing import Any from typing import Any
STEP_MINUTES = 5 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: def bound(value: float, lo: float, hi: float) -> float:
return max(lo, min(hi, value)) 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 @dataclass
class Learner: class Learner:
"""Recursive least squares for dT=a*(Tout-T)+b*effective_heat+c.""" """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]) 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 count: int = 0
error_sum: float = 0.0 error_sum: float = 0.0
on_count: int = 0 on_count: int = 0
off_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: def predict_delta(self, temp: float, outside: float, heat: float) -> float:
a, b, c = self.theta 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, def learn(self, temp_before: float, temp_after: float, outside: float, effective_heat: float,
duration_seconds: float = 300, command: bool | None = None) -> bool: duration_seconds: float = 300, command: bool | None = None) -> bool:
if not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat)): if (not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat))
return False or not 0 <= effective_heat <= 1
if not 0 <= effective_heat <= 1: or not isfinite(duration_seconds) or not 270 <= duration_seconds <= 330):
return False return self._reject()
if not isfinite(duration_seconds) or not 270 <= duration_seconds <= 330:
return False
y = (temp_after - temp_before) * 300 / duration_seconds y = (temp_after - temp_before) * 300 / duration_seconds
if abs(y) > 0.8: # sensor glitch/door opened/restarted device if abs(y) > 0.8: # sensor glitch/door opened/restarted device
return False return self._reject()
x = [outside - temp_before, effective_heat, 1.0] x = [outside - temp_before, effective_heat, 1.0]
predicted = sum(t * v for t, v in zip(self.theta, x)) predicted = sum(t * v for t, v in zip(self.theta, x))
residual = y - predicted residual = y - predicted
@@ -51,12 +113,13 @@ class Learner:
forgetting = 0.999 forgetting = 0.999
denominator = forgetting + sum(x[i] * px[i] for i in range(3)) denominator = forgetting + sum(x[i] * px[i] for i in range(3))
if not isfinite(denominator) or denominator <= 0: if not isfinite(denominator) or denominator <= 0:
return False return self._reject()
k = [value / denominator for value in px] k = [value / denominator for value in px]
self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)] self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)]
for i in range(3): for i in range(3):
for j in range(3): for j in range(3):
self.p[i][j] = (self.p[i][j] - k[i] * px[j]) / forgetting self.p[i][j] = (self.p[i][j] - k[i] * px[j]) / forgetting
_bound_covariance(self.p)
# Physical constraints; avoids unstable temperature predictions. # Physical constraints; avoids unstable temperature predictions.
self.theta = [ self.theta = [
bound(self.theta[0], 0.0, 0.04), bound(self.theta[0], 0.0, 0.04),
@@ -72,13 +135,18 @@ class Learner:
self.off_count += 1 self.off_count += 1
return True return True
def _reject(self) -> bool:
self.rejected_count += 1
return False
@property @property
def mae(self) -> float | None: def mae(self) -> float | None:
return self.error_sum / self.count if self.count else None return self.error_sum / self.count if self.count else None
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum, return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum,
"on_count": self.on_count, "off_count": self.off_count} "on_count": self.on_count, "off_count": self.off_count,
"rejected_count": self.rejected_count}
@classmethod @classmethod
def from_dict(cls, source: dict[str, Any]) -> "Learner": def from_dict(cls, source: dict[str, Any]) -> "Learner":
@@ -94,12 +162,17 @@ class Learner:
return obj return obj
if not (0 <= theta[0] <= .04 and 0 <= theta[1] <= .25 and -.06 <= theta[2] <= .06): if not (0 <= theta[0] <= .04 and 0 <= theta[1] <= .25 and -.06 <= theta[2] <= .06):
return obj 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). # A covariance must be symmetric positive definite (Cholesky check).
lower = [[0.0] * 3 for _ in range(3)] lower = [[0.0] * 3 for _ in range(3)]
for i in range(3): for i in range(3):
for j in range(i + 1): 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)) value = p[i][j] - sum(lower[i][k] * lower[j][k] for k in range(j))
if i == j: if i == j:
if value <= 0 or not isfinite(value): if value <= 0 or not isfinite(value):
@@ -107,14 +180,15 @@ class Learner:
lower[i][j] = value ** .5 lower[i][j] = value ** .5
else: else:
lower[i][j] = value / lower[j][j] 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): if any(type(v) is not int or v < 0 for v in counts):
return obj return obj
count, on_count, off_count = counts count, on_count, off_count, rejected_count = counts
error_sum = float(source.get("error_sum", 0)) error_sum = float(source.get("error_sum", 0))
if not isfinite(error_sum) or error_sum < 0 or on_count + off_count > count: if not isfinite(error_sum) or error_sum < 0 or on_count + off_count > count:
return obj 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): except (KeyError, TypeError, ValueError, OverflowError):
return obj return obj
@@ -204,10 +278,19 @@ def optimize(
if result is None: if result is None:
raise RuntimeError("No feasible schedule") raise RuntimeError("No feasible schedule")
plan, temps_60m, temps_end = result 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 { return {
"proposal": bool(plan[0]), "proposal": bool(plan[0]),
"plan_15min": [int(v) for v in plan], "plan_15min": [int(v) for v in plan],
"predicted_60m": {r: round(t, 3) for r, t in temps_60m.items()}, "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()}, "predicted_end": {r: round(t, 3) for r, t in temps_end.items()},
"trajectory_15min": trajectory,
"cost": round(best, 4), "cost": round(best, 4),
} }
+119 -5
View File
@@ -1,17 +1,22 @@
"""Diagnostics and predicted temperatures exposed as HA sensors.""" """Diagnostics and predicted temperatures exposed as HA sensors."""
from __future__ import annotations from __future__ import annotations
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.const import UnitOfTemperature from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.helpers.update_coordinator import CoordinatorEntity 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: async def async_setup_entry(hass, entry, async_add_entities) -> None:
coordinator = hass.data[DOMAIN][entry.entry_id] 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 += [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) async_add_entities(entities)
@@ -19,6 +24,9 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = "Heating MPC Status" _attr_name = "Heating MPC Status"
_attr_icon = "mdi:brain" _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): def __init__(self, coordinator, entry_id):
super().__init__(coordinator) super().__init__(coordinator)
@@ -31,7 +39,9 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
@property @property
def extra_state_attributes(self): def extra_state_attributes(self):
data = self.coordinator.data 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): class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
@@ -61,10 +71,22 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
self._attr_unique_id = f"{entry_id}_{room}_predicted_60min" self._attr_unique_id = f"{entry_id}_{room}_predicted_60min"
self._attr_name = f"MPC {room} 60min Prediction" 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 @property
def native_value(self): def native_value(self):
return self.coordinator.data.get("prediction_60m", {}).get(self.room) 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): class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
"""Display chosen outdoor temperature and all currently valid alternatives.""" """Display chosen outdoor temperature and all currently valid alternatives."""
_attr_has_entity_name = True _attr_has_entity_name = True
@@ -88,3 +110,95 @@ class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
"active_source": data.get("weather_active_sources", {}).get("temperature"), "active_source": data.get("weather_active_sources", {}).get("temperature"),
"alternatives": data.get("weather_comparison", {}).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", {})}
+26 -2
View File
@@ -10,7 +10,11 @@
"heater_entity": "Physical heating switch (read-only)", "heater_entity": "Physical heating switch (read-only)",
"room_entities": "Individual room temperature sensors", "room_entities": "Individual room temperature sensors",
"room_max_age_minutes": "Maximum room sensor report age (minutes)", "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": { "weather": {
@@ -63,7 +67,11 @@
"heating_cost": "Heating runtime cost", "heating_cost": "Heating runtime cost",
"comfort_band": "Comfort tolerance (°C)", "comfort_band": "Comfort tolerance (°C)",
"room_max_age_minutes": "Maximum room sensor report age (minutes)", "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": { "weather": {
@@ -96,5 +104,21 @@
"invalid_rooms": "Select 1–12 distinct room sensors.", "invalid_rooms": "Select 1–12 distinct room sensors.",
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." "invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
} }
},
"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)", "heater_entity": "Physical heating switch (read-only)",
"room_entities": "Individual room temperature sensors", "room_entities": "Individual room temperature sensors",
"room_max_age_minutes": "Maximum room sensor report age (minutes)", "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": { "weather": {
@@ -63,7 +67,11 @@
"heating_cost": "Heating runtime cost", "heating_cost": "Heating runtime cost",
"comfort_band": "Comfort tolerance (°C)", "comfort_band": "Comfort tolerance (°C)",
"room_max_age_minutes": "Maximum room sensor report age (minutes)", "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": { "weather": {
@@ -96,5 +104,21 @@
"invalid_rooms": "Select 1–12 distinct room sensors.", "invalid_rooms": "Select 1–12 distinct room sensors.",
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." "invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
} }
},
"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)", "heater_entity": "Releul fizic (numai citire)",
"room_entities": "Senzorii individuali din camere", "room_entities": "Senzorii individuali din camere",
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)", "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": { "weather": {
@@ -63,7 +67,11 @@
"heating_cost": "Penalizare durată încălzire", "heating_cost": "Penalizare durată încălzire",
"comfort_band": "Toleranță confort (°C)", "comfort_band": "Toleranță confort (°C)",
"room_max_age_minutes": "Vârsta maximă a raportării senzorilor interiori (minute)", "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": { "weather": {
@@ -96,5 +104,21 @@
"invalid_rooms": "Selectează 1–12 senzori diferiți.", "invalid_rooms": "Selectează 1–12 senzori diferiți.",
"invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor." "invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor."
} }
},
"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"
}
}
}
} }
} }
+5
View File
@@ -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
View File
@@ -2,3 +2,5 @@
pytest-homeassistant-custom-component==0.13.298 pytest-homeassistant-custom-component==0.13.298
# aiodns 3.5 (HA's pin) uses APIs removed in pycares 5. # aiodns 3.5 (HA's pin) uses APIs removed in pycares 5.
pycares==4.11.0 pycares==4.11.0
# Bug-finding lint (see ruff.toml).
ruff==0.14.0
+6
View File
@@ -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"]
+1
View File
@@ -4,6 +4,7 @@ set -eu
# A transitive HA test dependency otherwise fetches metadata on import. # A transitive HA test dependency otherwise fetches metadata on import.
export LITELLM_LOCAL_MODEL_COST_MAP=True export LITELLM_LOCAL_MODEL_COST_MAP=True
python -m pip check python -m pip check
python -m ruff check --no-cache .
python -m unittest discover -s tests -v python -m unittest discover -s tests -v
python -m pytest tests/ha -q python -m pytest tests/ha -q
python -m compileall -q custom_components/heating_mpc python -m compileall -q custom_components/heating_mpc
+104
View File
@@ -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()
+14 -2
View File
@@ -1,6 +1,7 @@
"""Small local packaging and translation checks; not a replacement for hassfest.""" """Small local packaging and translation checks; not a replacement for hassfest."""
import json import json
from pathlib import Path from pathlib import Path
import struct
import subprocess import subprocess
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
@@ -11,6 +12,9 @@ assert manifest["config_flow"] is True
assert manifest["iot_class"] == "local_polling" assert manifest["iot_class"] == "local_polling"
assert not manifest["requirements"], "Model inference should remain dependency-free" assert not manifest["requirements"], "Model inference should remain dependency-free"
json.loads((root / "hacs.json").read_text()) 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=()): def keys(value, prefix=()):
@@ -23,6 +27,14 @@ strings = json.loads((component / "strings.json").read_text())
for language in ("en", "ro"): for language in ("en", "ro"):
translated = json.loads((component / "translations" / f"{language}.json").read_text()) translated = json.loads((component / "translations" / f"{language}.json").read_text())
assert keys(strings) == keys(translated), f"Translation keys differ: {language}" 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() 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) assert not any("__pycache__" in path or path.endswith((".pyc", ".pyo", ".DS_Store")) for path in tracked)
print("Package metadata, translations and tracked component files passed") print("Package metadata, translations, brand icons and tracked component files passed")
+3 -1
View File
@@ -3,6 +3,7 @@ from unittest.mock import patch
import pytest import pytest
from homeassistant.core import ServiceRegistry from homeassistant.core import ServiceRegistry
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -36,5 +37,6 @@ def entry():
def inputs(hass): def inputs(hass):
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C"}) 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("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"}) hass.states.async_set("climate.reference", "heat", {"temperature": 22, "preset_mode": "comfort"})
+43 -2
View File
@@ -4,6 +4,7 @@ from homeassistant.data_entry_flow import FlowResultType
from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.heating_mpc.const import DOMAIN 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): async def test_config_flow(hass, inputs):
@@ -14,6 +15,7 @@ async def test_config_flow(hass, inputs):
validated = result["data_schema"](main) validated = result["data_schema"](main)
assert validated["room_max_age_minutes"] == 30 assert validated["room_max_age_minutes"] == 30
assert validated["weather_max_age_minutes"] == 120 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) result = await hass.config_entries.flow.async_configure(result["flow_id"], validated)
assert result["step_id"] == "weather" assert result["step_id"] == "weather"
result = await hass.config_entries.flow.async_configure( 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) result = await hass.config_entries.options.async_init(entry.entry_id)
main = result["data_schema"]({"climate_entity": "climate.reference", main = result["data_schema"]({"climate_entity": "climate.reference",
"heater_entity": "switch.boiler", "room_entities": ["sensor.room"], "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"], main)
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.outside"]})) 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 assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done() await hass.async_block_till_done()
assert entry.options["room_max_age_minutes"] == 45 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): 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.version == 2
assert entry.data["outside_temperature_sources"] == ["sensor.outside"] assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
assert "outside_entity" not in entry.data 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
View File
@@ -21,11 +21,17 @@ async def setup(hass, entry):
async def test_setup_reload_unload(hass, entry, inputs): async def test_setup_reload_unload(hass, entry, inputs):
coordinator = await setup(hass, entry) coordinator = await setup(hass, entry)
assert coordinator.config_entry is entry
registry = er.async_get(hass) registry = er.async_get(hass)
entities = er.async_entries_for_config_entry(registry, entry.entry_id) entities = er.async_entries_for_config_entry(registry, entry.entry_id)
assert len(entities) == 5 assert len(entities) == 11 # 7 core + 4 forecast-error sensors
ids = {e.unique_id: e.entity_id for e in entities} ids = {e.unique_id: e.entity_id for e in entities}
assert coordinator.data["status"] == "learning" assert coordinator.data["status"] == "learning"
status = hass.states.get(ids[f"{entry.entry_id}_status"])
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") proposal_id = registry.async_get_entity_id("binary_sensor", DOMAIN, f"{entry.entry_id}_proposal")
assert hass.states.get(proposal_id).state == "unavailable" assert hass.states.get(proposal_id).state == "unavailable"
assert await hass.config_entries.async_reload(entry.entry_id) 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 hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.new"]
assert registry.async_get(old) is None assert registry.async_get(old) is None
assert registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.new_predicted_60min") 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): 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] restored = hass.data[DOMAIN][entry.entry_id]
assert restored.learners["sensor.room"].count == 250 assert restored.learners["sensor.room"].count == 250
assert restored.data["status"] == "ready" 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) assert await hass.config_entries.async_remove(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert await Store(hass, 1, f"{DOMAIN}.{entry.entry_id}").async_load() is None 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() await coordinator.async_refresh()
assert coordinator.data["status"] == "learning" assert coordinator.data["status"] == "learning"
assert coordinator.data["samples"] == 0 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
View File
@@ -3,7 +3,9 @@ import importlib.util
import sys import sys
import types import types
import unittest import unittest
import unittest.mock
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from math import exp, pi, sin
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
@@ -34,9 +36,13 @@ def load_integration():
'homeassistant', 'homeassistant.const', 'homeassistant.core', 'homeassistant', 'homeassistant.const', 'homeassistant.core',
'homeassistant.config_entries', 'homeassistant.helpers', 'homeassistant.config_entries', 'homeassistant.helpers',
'homeassistant.helpers.storage', 'homeassistant.helpers.update_coordinator', '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.const'].Platform = types.SimpleNamespace(SENSOR='sensor', BINARY_SENSOR='binary_sensor')
modules['homeassistant.core'].HomeAssistant = object 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.config_entries'].ConfigEntry = object
modules['homeassistant.helpers.storage'].Store = Store modules['homeassistant.helpers.storage'].Store = Store
modules['homeassistant.helpers.update_coordinator'].DataUpdateCoordinator = CoordinatorBase modules['homeassistant.helpers.update_coordinator'].DataUpdateCoordinator = CoordinatorBase
@@ -63,6 +69,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
data={}, states=types.SimpleNamespace(get=self.states.get), data={}, states=types.SimpleNamespace(get=self.states.get),
config=types.SimpleNamespace(units=types.SimpleNamespace(temperature_unit='°C')), config=types.SimpleNamespace(units=types.SimpleNamespace(temperature_unit='°C')),
services=types.SimpleNamespace(async_call=Mock(side_effect=AssertionError('No service calls permitted'))), services=types.SimpleNamespace(async_call=Mock(side_effect=AssertionError('No service calls permitted'))),
track_state=Mock(return_value=Mock(name='unsub_relay')),
registry=types.SimpleNamespace(entries=[], async_remove=Mock()), registry=types.SimpleNamespace(entries=[], async_remove=Mock()),
config_entries=types.SimpleNamespace( config_entries=types.SimpleNamespace(
async_forward_entry_setups=AsyncMock(), async_unload_platforms=AsyncMock(return_value=True), async_forward_entry_setups=AsyncMock(), async_unload_platforms=AsyncMock(return_value=True),
@@ -92,6 +99,12 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.now += timedelta(seconds=seconds) self.now += timedelta(seconds=seconds)
return data 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): def ready(self):
m = self.c.learners['sensor.room'] m = self.c.learners['sensor.room']
m.count, m.on_count, m.off_count = 200, 100, 100 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') kept = types.SimpleNamespace(platform='heating_mpc', unique_id='entry_sensor.room_predicted_60min', entity_id='sensor.forecast')
self.hass.registry.entries = [obsolete, kept] self.hass.registry.entries = [obsolete, kept]
self.assertTrue(await integration.async_setup_entry(self.hass, self.entry)) 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') self.hass.registry.async_remove.assert_called_once_with('sensor.old_forecast')
await integration.async_reload_entry(self.hass, self.entry) await integration.async_reload_entry(self.hass, self.entry)
self.hass.config_entries.async_reload.assert_awaited_once_with('entry') self.hass.config_entries.async_reload.assert_awaited_once_with('entry')
@@ -214,6 +231,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
await integration.async_setup_entry(self.hass, self.entry) await integration.async_setup_entry(self.hass, self.entry)
self.assertNotIn('entry', self.hass.data['heating_mpc']) 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): async def test_uninstall_removes_storage(self):
store = Store() store = Store()
@@ -310,3 +328,372 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
data = await self.sample() data = await self.sample()
self.assertEqual(data['input_quality']['weather']['temperature']['sensor.outside']['status'], self.assertEqual(data['input_quality']['weather']['temperature']['sensor.outside']['status'],
'invalid_value_or_unit') '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)
+21 -1
View File
@@ -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') spec = importlib.util.spec_from_file_location('quality', ROOT / 'data_quality.py')
quality = importlib.util.module_from_spec(spec) quality = importlib.util.module_from_spec(spec)
spec.loader.exec_module(quality) 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): class FreshnessTests(unittest.TestCase):
@@ -31,10 +34,27 @@ class FreshnessTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
quality.freshness(now, now, limit) 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): def test_freshness_settings_translated(self):
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'): for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
data = json.loads((ROOT / name).read_text()) data = json.loads((ROOT / name).read_text())
for section, step in (('config', 'user'), ('options', 'init')): for section, step in (('config', 'user'), ('options', 'init')):
labels = data[section]['step'][step]['data'] 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]) 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))
+97
View File
@@ -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
View File
@@ -76,7 +76,9 @@ class ModelTests(unittest.TestCase):
temps, lag = m.simulate_step(temps, 5, lag, bit, learners) temps, lag = m.simulate_step(temps, 5, lag, bit, learners)
if index == 3: if index == 3:
self.assertEqual(out['predicted_60m']['r'], round(temps['r'], 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(out['predicted_end']['r'], round(temps['r'], 3))
self.assertEqual(len(out['trajectory_15min']['r']), 6)
def test_jitter_normalized_and_rejection_counted(self): def test_jitter_normalized_and_rejection_counted(self):
nominal, delayed = m.Learner(), m.Learner() nominal, delayed = m.Learner(), m.Learner()
@@ -85,12 +87,17 @@ class ModelTests(unittest.TestCase):
for a, b in zip(nominal.theta, delayed.theta): for a, b in zip(nominal.theta, delayed.theta):
self.assertAlmostEqual(a, b) self.assertAlmostEqual(a, b)
self.assertFalse(delayed.learn(20, 25, 5, .5, command=False)) 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): 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]]}, 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')}, {'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): with self.subTest(changes=changes):
data = m.Learner().as_dict() data = m.Learner().as_dict()
data.update(changes) data.update(changes)
@@ -109,6 +116,55 @@ class ModelTests(unittest.TestCase):
with self.subTest(key=key), self.assertRaises(ValueError): with self.subTest(key=key), self.assertRaises(ValueError):
m.optimize(**args) 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+59
View File
@@ -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()