Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17dbe76223 | ||
|
|
dc92e2bac5 | ||
|
|
e62798dc4f | ||
|
|
b1577a0c78 | ||
|
|
8d197fabcd | ||
|
|
ed8f9fe2e2 | ||
|
|
00e70c612b | ||
|
|
46587af8a3 | ||
|
|
220ac570d2 |
No files matched your search
@@ -83,6 +83,7 @@ Favor the standard library and small dependencies. Heavy compute must not block
|
|||||||
- Validate model identifiability, parameter stability, outlier rejection, missing observations, power outages, heater transitions within a sampling window, and multiple sensors in the same physical room.
|
- Validate model identifiability, parameter stability, outlier rejection, missing observations, power outages, heater transitions within a sampling window, and multiple sensors in the same physical room.
|
||||||
- Weather covariates beyond temperature should be added only if they improve held-out performance; avoid treating `feels_like` as physical outdoor air temperature. Forecast values and observed values must be distinguished.
|
- Weather covariates beyond temperature should be added only if they improve held-out performance; avoid treating `feels_like` as physical outdoor air temperature. Forecast values and observed values must be distinguished.
|
||||||
- One switch heats all rooms: optimization can trade off room comfort, but cannot independently heat a cold bathroom without influencing warmer rooms.
|
- One switch heats all rooms: optimization can trade off room comfort, but cannot independently heat a cold bathroom without influencing warmer rooms.
|
||||||
|
- Keep the base 3-parameter model as fallback and comparison. New model terms start at a zero prior, are physically bounded, report confidence, learn only when their inputs are known, and must show `improvement_vs_base` in walk-forward replays before being relied on (see docs/MODEL.md).
|
||||||
|
|
||||||
## Repository conventions
|
## Repository conventions
|
||||||
|
|
||||||
@@ -90,22 +91,33 @@ Expected structure (adapt only through deliberate refactoring):
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
custom_components/heating_mpc/
|
custom_components/heating_mpc/
|
||||||
__init__.py
|
__init__.py setup, unload, migration
|
||||||
manifest.json
|
manifest.json
|
||||||
config_flow.py
|
config_flow.py config, options and subentry flows (zones, heat sources, windows)
|
||||||
const.py
|
const.py
|
||||||
coordinator.py
|
coordinator.py HA state acquisition, learning schedule, diagnostics
|
||||||
physics.py
|
data_quality.py freshness checks
|
||||||
weather_sources.py
|
weather_sources.py source priorities and unit normalization
|
||||||
|
zones.py zone parsing and sensor aggregation (pure)
|
||||||
|
heat_sources.py electrical heat: baseline, response, overlap (pure)
|
||||||
|
windows.py windows, wind geometry, ventilation, events (pure)
|
||||||
|
learning.py named-feature RLS (pure)
|
||||||
|
physics.py base model, radiator lag, optimizer (pure)
|
||||||
|
zone_model.py layered zone model, compiled forecast step (pure)
|
||||||
|
evaluation.py walk-forward forecast scoring (pure)
|
||||||
sensor.py
|
sensor.py
|
||||||
binary_sensor.py
|
binary_sensor.py
|
||||||
strings.json
|
strings.json
|
||||||
translations/{en,ro}.json
|
translations/{en,ro}.json
|
||||||
|
brand/ integration icons (scripts/make_brand_icons.py)
|
||||||
|
docs/MODEL.md model equations, units, learning conditions, validation
|
||||||
hacs.json
|
hacs.json
|
||||||
tests/
|
tests/ offline tests; tests/ha for the HA Core harness
|
||||||
README.md
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
ROADMAP.md
|
ROADMAP.md
|
||||||
|
AUDIT.md
|
||||||
```
|
```
|
||||||
|
|
||||||
- Keep pure model/optimizer logic importable and testable without Home Assistant installed.
|
- Keep pure model/optimizer logic importable and testable without Home Assistant installed.
|
||||||
|
|||||||
@@ -269,3 +269,54 @@ poll with missing shared inputs now reports `waiting_for_inputs` instead of noth
|
|||||||
**Verification.** `sh scripts/check.sh` passes in both environments: lint, 80
|
**Verification.** `sh scripts/check.sh` passes in both environments: lint, 80
|
||||||
offline tests, 20 HA Core tests, compilation, package checks. The offline tests
|
offline tests, 20 HA Core tests, compilation, package checks. The offline tests
|
||||||
also pass on Python 3.9.6.
|
also pass on Python 3.9.6.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.4.0 — zones, electrical heat, ventilation, wind, inter-zone exchange
|
||||||
|
|
||||||
|
Scope: repository code and tests (HA Core 2025.12.0 and 2026.2.3 harness); no
|
||||||
|
HAOS, device or deployment testing. Still no actuator code; the static and runtime
|
||||||
|
service-call guards pass.
|
||||||
|
|
||||||
|
Design decisions and why:
|
||||||
|
|
||||||
|
- **Zones as config subentries.** HA's native pattern for several items under one
|
||||||
|
integration gives per-item add/edit/delete and removes a zone's entities with it.
|
||||||
|
Config entry version 3; migrating from 2 keeps each room's sensor ID as the zone
|
||||||
|
key so learned models, entities and statistics carry over. Rollback needs the
|
||||||
|
backup.
|
||||||
|
- **Layered model per zone.** The 0.3 base model is kept unchanged as fallback and
|
||||||
|
as the comparison every forecast is scored against; the extended and ventilation
|
||||||
|
layers learn only from intervals where their inputs are known, start at 0 with
|
||||||
|
priors sized in "typical samples", and report confidence. This follows the
|
||||||
|
roadmap rule to retain the simpler model and justify complexity by measured
|
||||||
|
improvement.
|
||||||
|
- **Ventilation separated from envelope loss.** Open, unknown or recovering windows
|
||||||
|
exclude a zone from base and extended learning; a separate learner fits the
|
||||||
|
residual. On the synthetic week, not doing this produced a −0.013 °C/5 min bias
|
||||||
|
and an 11 % inflated boiler coefficient.
|
||||||
|
- **Baseline-centred electrical heat.** The persistent load is already in the bias
|
||||||
|
term; only variation above a 24-hour 20th percentile is modeled, after a
|
||||||
|
first-order response driven by the power held over each interval.
|
||||||
|
- **Bounded directional wind term.** |vent_wind_dir| ≤ vent_wind, so leeward
|
||||||
|
windows still ventilate; angles use wrap-around differences.
|
||||||
|
- **Forecast assumptions** are single, conservative scenarios (electrical heat
|
||||||
|
fades with a half-life, open windows close after a set time) and are
|
||||||
|
configurable; no uncertainty band yet.
|
||||||
|
- **Exact compiled step** for the plan search: all terms are affine in temperatures
|
||||||
|
and lag, so coefficients are read off the real model (verified to 1e-12); the
|
||||||
|
180-minute search is 4.5× faster.
|
||||||
|
|
||||||
|
Evidence (synthetic apartments, 60-minute error, full vs base vs no change): gaming
|
||||||
|
office 0.031 / 0.182 / 0.40 °C; ventilation bedroom 0.026 / 0.218 / 0.52 °C; two
|
||||||
|
coupled rooms 0.024 / 0.037 / 0.29 °C. Coefficients recovered: electrical, boiler
|
||||||
|
and loss within 10 %; combined natural+wind ventilation within 15 %; recovery within
|
||||||
|
15 %; wind direction within 30 %. Coupling is found but correctly reported as not
|
||||||
|
identified when the rooms' difference is steady.
|
||||||
|
|
||||||
|
Not done, by decision: learned response time constants (configurable; compare
|
||||||
|
candidates out of sample next), a wall-mass state, forecast scenarios/uncertainty,
|
||||||
|
weather forecasts. Learners of removed zones keep only the base model.
|
||||||
|
|
||||||
|
**Verification.** `sh scripts/check.sh` passes in both environments (lint, 125
|
||||||
|
offline tests, 26 HA Core tests, compilation, package/translation/changelog checks).
|
||||||
|
The offline tests also pass on Python 3.9.6.
|
||||||
@@ -2,6 +2,79 @@
|
|||||||
|
|
||||||
Engineering findings and test evidence behind each change are in [AUDIT.md](AUDIT.md).
|
Engineering findings and test evidence behind each change are in [AUDIT.md](AUDIT.md).
|
||||||
|
|
||||||
|
## 0.4.0 — 2026-10-27
|
||||||
|
|
||||||
|
Thermal zones, electrical heat sources, windows with ventilation and wind, and
|
||||||
|
heat exchange between zones (Milestone 2, first part). Still **observation-only**:
|
||||||
|
no new service calls, the boiler and thermostat are untouched. Tested in the Home
|
||||||
|
Assistant Core harness with HA 2025.12.0 and 2026.2.3; not yet on HAOS.
|
||||||
|
|
||||||
|
### Upgrade notes
|
||||||
|
|
||||||
|
- **Back up Home Assistant first.** The config entry moves to version 3: each room
|
||||||
|
sensor becomes a one-sensor **zone** with its comfort weight, and the sensor's
|
||||||
|
entity ID stays the zone's key, so learned models, forecast entities and
|
||||||
|
accuracy statistics carry over unchanged. No model reset is needed.
|
||||||
|
- **Downgrading needs the backup.** Older versions refuse a version 3 entry.
|
||||||
|
- Configure no longer asks for room sensors or weights; manage zones with **Add
|
||||||
|
zone** and each zone's edit button on the integration page.
|
||||||
|
- Status attributes renamed: `input_quality.rooms` → `input_quality.zones` (each
|
||||||
|
zone lists its sensors), `excluded_rooms` → `excluded_zones`,
|
||||||
|
`retired_room_models` → `retired_zone_models`. The Forecast Error attribute
|
||||||
|
`rooms` is now `zones`, keyed by zone name. The `invalid_weights` status is gone
|
||||||
|
(invalid stored weights fall back to 1.0). Update templates that used these.
|
||||||
|
- New layers start with no assumed effect and need data: the electrical term needs
|
||||||
|
gaming sessions (or other load changes), the ventilation terms need window
|
||||||
|
openings. Watch `improvement_vs_base` on the Forecast Error sensors before
|
||||||
|
relying on them; switch off optional terms that do not help.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Zones** (config subentries): name, several sensors with sensor weights, comfort
|
||||||
|
weight, target offset and adjacent zones. Sensor and comfort weights never change
|
||||||
|
learned thermal coefficients.
|
||||||
|
- **Heat sources**: active power (W/kW; VA and % rejected), zone, heat fraction,
|
||||||
|
automatic 24-hour baseline or manual value, "included in" another source (no
|
||||||
|
double counting), thermal response time, enable/disable.
|
||||||
|
- **Windows**: contact sensor (optional inverted polarity; unknown stays unknown),
|
||||||
|
zone, facing direction (default 345°), opening factor, enable/disable; a bounded
|
||||||
|
log of ventilation events.
|
||||||
|
- **Wind direction** and **wind gust** weather sources with priorities and fallback;
|
||||||
|
knots and ft/s accepted.
|
||||||
|
- Model options: electrical-heat forecast half-life, assumed open-window time,
|
||||||
|
recovery time constant, and wind / gust / cross-ventilation terms.
|
||||||
|
- Per-zone diagnostics on each zone's forecast entity: current temperature,
|
||||||
|
+15/+30/+60/+120-minute forecast, contributions in °C/h, coefficients with
|
||||||
|
confidence; Status lists heat sources, windows, ventilation and recent events.
|
||||||
|
- Forecast Error sensors: `base_model_mae` and `improvement_vs_base`.
|
||||||
|
- Poll outcomes `sensors_changed` and `ventilation`, with English and Romanian names.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Each zone's model is layered: the unchanged base model (fallback and
|
||||||
|
comparison), an extended layer with electrical heat and neighbours, and a
|
||||||
|
separate ventilation layer. Zones with an open, unknown or recovering window are
|
||||||
|
kept out of base and extended learning.
|
||||||
|
- Forecasts simulate all zones together, with electrical heat fading to baseline
|
||||||
|
and open windows closing after the configured time; the plan search uses an exact
|
||||||
|
compiled step and stays fast at 180 minutes.
|
||||||
|
- Accuracy scoring replays all zones with the actual relay, electrical heat and
|
||||||
|
window history, and scores the base model on the same spans.
|
||||||
|
|
||||||
|
### Persisted data
|
||||||
|
|
||||||
|
- Model payload `schema_version` stays 2 with optional new keys: `zone_models`,
|
||||||
|
`ventilation_models`, `heat_sources` (baseline samples) and `ventilation`
|
||||||
|
(events). Forecast statistics gain a base-model column; 0.3 statistics load.
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
- New pure modules: `learning.py` (named-feature RLS), `zones.py`, `heat_sources.py`,
|
||||||
|
`windows.py`, `zone_model.py`; [docs/MODEL.md](docs/MODEL.md) documents the model.
|
||||||
|
- Synthetic-apartment tests for gaming heat, ventilation with wind and recovery,
|
||||||
|
and coupled rooms, each checking recovered coefficients and out-of-sample
|
||||||
|
improvement over the base model.
|
||||||
|
|
||||||
## 0.3.0 — 2026-10-26
|
## 0.3.0 — 2026-10-26
|
||||||
|
|
||||||
First part of Milestone 1: the integration now measures how accurate its thermal
|
First part of Milestone 1: the integration now measures how accurate its thermal
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Heating MPC 0.3.0 — custom integration for Home Assistant OS
|
# Heating MPC 0.4.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.
|
||||||
|
|
||||||
@@ -10,20 +10,60 @@ This project targets Home Assistant OS on a Raspberry Pi 5, with one existing ON
|
|||||||
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).
|
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 room temperature sensors. Each sensor becomes a zone; group them later (see below).
|
||||||
6. Choose *ordered lists* of **outdoor sensor entity IDs**. First valid sensor is active; if missing, unavailable, nonnumeric or outside reasonable bounds, the next valid sensor is used automatically.
|
6. Choose *ordered lists* of **outdoor sensor entity IDs**. First valid sensor is active; if missing, unavailable, nonnumeric or outside reasonable bounds, the next valid sensor is used automatically.
|
||||||
7. Assign a relative comfort weight (0.1–10) to each selected room.
|
7. Assign a relative comfort weight (0.1–10) to each selected sensor's zone.
|
||||||
|
8. Optionally add zones, heat sources and windows from the integration page (next section).
|
||||||
|
|
||||||
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.
|
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 sensor, zone, heat source, window and outdoor measurement can be changed later in the UI, without editing YAML or Python.
|
||||||
|
|
||||||
|
## Zones, heat sources and windows
|
||||||
|
|
||||||
|
On **Settings → Devices & services → Heating MPC**, the buttons **Add zone**, **Add
|
||||||
|
heat source** and **Add window** create items that can each be edited or deleted
|
||||||
|
later. **Configure** holds everything else: thermostat, relay, weather sources,
|
||||||
|
report-age limits, MPC settings and model options.
|
||||||
|
|
||||||
|
**Zones** are rooms or areas. Each has a name, one or more temperature sensors with
|
||||||
|
sensor weights (how much each counts in the zone temperature), a comfort weight
|
||||||
|
(how much the zone matters to the MPC), an optional target offset relative to the
|
||||||
|
thermostat's target, and optional adjacent zones it exchanges heat with. Sensor and
|
||||||
|
comfort weights never change the learned thermal coefficients. A sensor belongs to
|
||||||
|
one zone only. Upgrading from 0.3 turns each room sensor into a one-sensor zone
|
||||||
|
with the same comfort weight; to group two sensors of one room, edit one zone to
|
||||||
|
include both and delete the other.
|
||||||
|
|
||||||
|
**Heat sources** are equipment whose electricity ends up as heat in a zone, such as
|
||||||
|
a UPS feeding computers. Settings: an active-power sensor (W or kW; VA and % are
|
||||||
|
rejected), the zone, the share of the power that becomes heat there (1.0 by
|
||||||
|
default), the baseline (automatic: the low end of the last 24 hours; or a manual
|
||||||
|
value in W), whether its reading is **already included in** another source (for a
|
||||||
|
PC measured separately behind a UPS, so it is not counted twice), and the thermal
|
||||||
|
response time (default 15 minutes). Only the variation above the baseline is
|
||||||
|
modeled; the persistent load is already part of the zone's learned behavior.
|
||||||
|
|
||||||
|
**Windows** need a contact sensor (binary sensor), the zone, whether the sensor
|
||||||
|
reports *on* when closed (inverted), the direction the outside of the window faces
|
||||||
|
(degrees clockwise from north; default 345°, editable) and an opening factor
|
||||||
|
(1.0 normal, lower for tilted, higher for large). Unknown or unavailable states are
|
||||||
|
treated as unknown, never as closed.
|
||||||
|
|
||||||
|
Model options in **Configure**: how fast extra electrical heat is assumed to fade in
|
||||||
|
forecasts (half-life, default 30 minutes), how long an open window is assumed to
|
||||||
|
stay open (default 15 minutes), the recovery time constant after closing (default
|
||||||
|
20 minutes), and whether to use wind, wind gusts and cross-ventilation terms.
|
||||||
|
See [docs/MODEL.md](docs/MODEL.md) for the equations and what is learned when.
|
||||||
|
|
||||||
## Reconfigurable weather inputs
|
## Reconfigurable weather inputs
|
||||||
|
|
||||||
| Measurement | Required? | Used in the thermal model? |
|
| 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 | Yes, for open windows (ventilation), when enabled |
|
||||||
|
| Wind direction (degrees the wind comes from) | No | Yes, with the windows' facing direction |
|
||||||
|
| Wind gust | No | Only if the gust option is enabled |
|
||||||
| Cloud cover | No | No, diagnostics/source comparison only |
|
| Cloud cover | No | No, diagnostics/source comparison only |
|
||||||
| Humidity | No | No, diagnostics/source comparison only |
|
| Humidity | No | No, diagnostics/source comparison only |
|
||||||
| Feels-like temperature | No | No, diagnostics/source comparison only |
|
| Feels-like temperature | No | No, diagnostics/source comparison only |
|
||||||
@@ -33,29 +73,35 @@ On Home Assistant 2026.3 or newer the integration shows its own icon (a radiator
|
|||||||
|
|
||||||
Choose multiple sensors for a single variable, e.g. OpenWeatherMap then another provider, in priority order. Switch order in **Configure** to run A/B comparisons on successive days, or simply let the second sensor act as fallback. The active source and all current alternative readings are visible in the **Heating MPC Status** entity attributes (`weather`, `weather_active_sources`, `weather_comparison`). The **MPC Outdoor Temperature** sensor also has an `active_source` attribute and an `alternatives` map.
|
Choose multiple sensors for a single variable, e.g. OpenWeatherMap then another provider, in priority order. Switch order in **Configure** to run A/B comparisons on successive days, or simply let the second sensor act as fallback. The active source and all current alternative readings are visible in the **Heating MPC Status** entity attributes (`weather`, `weather_active_sources`, `weather_comparison`). The **MPC Outdoor Temperature** sensor also has an `active_source` attribute and an `alternatives` map.
|
||||||
|
|
||||||
Measurements are normalized where possible: outdoor temperatures to °C; wind to m/s; pressure to hPa; visibility to meters. Unknown units or values outside plausibility bounds are rejected in favor of the next selected sensor. Clouds and humidity are expected in %, rain/snow in mm or mm/h as represented by their upstream sensor. A fallback source may have a different sampling delay; configurable report-age limits now reject stale inputs (see below).
|
Measurements are normalized where possible: outdoor temperatures to °C; wind and gusts to m/s (from km/h, mph, kn, ft/s); wind direction to degrees (360 = 0); pressure to hPa; visibility to meters. Unknown units or values outside plausibility bounds are rejected in favor of the next selected sensor. Clouds and humidity are expected in %, rain/snow in mm or mm/h as represented by their upstream sensor. A fallback source may have a different sampling delay; configurable report-age limits now reject stale inputs (see below).
|
||||||
|
|
||||||
Switching only the **weather-provider sensors** does not reset learned thermal parameters. The update is applied after the integration reloads, and it skips learning one transition when the primary temperature source changes, avoiding a jump in the training series. Nevertheless, two sensors with a persistent offset can bias predictions; compare their readings and validate the model again before relying on them. Replacing the physical heating switch discards incompatible model parameters. Existing room-specific parameters are retained if room entity IDs remain unchanged.
|
Switching only the **weather-provider sensors** does not reset learned thermal parameters. The update is applied after the integration reloads, and it skips learning one transition when the primary temperature source changes, avoiding a jump in the training series. Nevertheless, two sensors with a persistent offset can bias predictions; compare their readings and validate the model again before relying on them. Replacing the physical heating switch discards incompatible model parameters. Learned zone parameters are kept as long as the zone exists (its key does not change when you edit it).
|
||||||
|
|
||||||
## Model, sampling and output
|
## 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 sensor, relay, power and window states and learns a per-zone model in layers (full description in [docs/MODEL.md](docs/MODEL.md)):
|
||||||
- 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.
|
- **base**: `dT = loss*(T_out-T) + heat*lag + bias`, always learned, the fallback and comparison;
|
||||||
- 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.
|
- **extended**: adds electrical heat above baseline and exchange with adjacent zones;
|
||||||
- Uses room comfort weights and the **existing** Generic Thermostat's target and heat/off state. **The simulated ON/OFF demand never operates hardware**.
|
- **ventilation**: learned separately while a window is open or the room recovers, so open windows never look like poor insulation.
|
||||||
- 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.
|
- New effects start at zero and report a confidence; nothing is assumed until the data shows it. Relay changes are timestamped by a read-only state listener.
|
||||||
|
- After 200 samples per zone, including both relay states, runs an ON/OFF MPC search for the next 60–180 minutes (default 120) in 15-minute command blocks, simulating all zones together with assumed electrical heat and window behavior.
|
||||||
|
- Uses zone comfort weights and target offsets with the **existing** Generic Thermostat's target and heat/off state. **The simulated ON/OFF demand never operates hardware**.
|
||||||
|
- Persists everything learned in HA's `.storage`. Models of zones removed in the UI are kept (up to 24) and restored if the zone is added back.
|
||||||
|
|
||||||
Entities (entity IDs may vary):
|
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` — comfort-weighted average of the zones, 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. Attributes `forecast_15min` (the
|
- One forecast entity per zone (**MPC <zone> 60min Prediction**), state = the zone's
|
||||||
temperature every 15 minutes over the horizon) and `plan_15min` (the proposed
|
temperature in 60 minutes. Attributes: `current_temperature`, `forecast` (+15/+30/
|
||||||
ON/OFF plan). These assume the MPC's plan is executed; while Generic Thermostat
|
+60/+120 minutes as far as the horizon goes), `forecast_15min`, `plan_15min`,
|
||||||
controls the relay they are **counterfactual**.
|
`model_in_use`, `contributions_c_per_h` (what drives the zone right now: loss,
|
||||||
|
heat, bias, internal, neighbors, ventilation, recovery) and `coefficients` with
|
||||||
|
confidence. Plan forecasts assume the MPC's plan is executed; while Generic
|
||||||
|
Thermostat controls the relay they are **counterfactual**.
|
||||||
- `binary_sensor.heating_mpc_proposed_heat` — **hypothetical** heating demand (unavailable until ready).
|
- `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
|
- **Heating MPC Learning Progress** — how close the least trained zone is to being
|
||||||
used for forecasts (100 % = ready); attributes show the sample counts and what is
|
used for forecasts (100 % = ready); attributes show the sample counts and what is
|
||||||
required.
|
required.
|
||||||
- **Heating MPC Last Sample** — what the last poll did for learning (for example
|
- **Heating MPC Last Sample** — what the last poll did for learning (for example
|
||||||
@@ -67,7 +113,8 @@ Entities (entity IDs may vary):
|
|||||||
## Known limitations / safety
|
## Known limitations / safety
|
||||||
|
|
||||||
- **Do not** connect the proposed binary sensor directly to the heating switch or build an automation from it. A production controller would need watchdog, manual override, safety handling, and an exclusive relay owner with Generic Thermostat fallback.
|
- **Do not** connect the proposed binary sensor directly to the heating switch or build an automation from it. A production controller would need watchdog, manual override, safety handling, and an exclusive relay owner with Generic Thermostat fallback.
|
||||||
- The other meteorological variables besides temperature are intentionally **captured but not yet fitted into the thermal model**. This allows source tests without falsely treating weak inputs as validated improvements. Later versions can test whether wind or solar proxy reduces held-out forecast error.
|
- Weather variables other than temperature, wind and gusts are **captured but not fitted**. Wind and gusts are used only for open windows. Keep optional model terms only when **Forecast Error** shows they help (`improvement_vs_base`).
|
||||||
|
- One temperature per zone (no separate wall mass), configured response times, and single-scenario forecasts of electrical heat and windows. See [docs/MODEL.md](docs/MODEL.md#limitations).
|
||||||
- All rooms share one heating actuator. The model does not command individual radiators.
|
- All rooms share one heating actuator. The model does not command individual radiators.
|
||||||
- No weather forecast: assumes outdoor temperature remains constant during simulated horizon.
|
- No weather forecast: assumes outdoor temperature remains constant during simulated horizon.
|
||||||
- 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.
|
||||||
@@ -89,46 +136,61 @@ 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.
|
estimate is within 10 % (*Waiting for radiators to settle*), about 32 minutes.
|
||||||
Forecasts continue meanwhile. Reloading the integration usually does not cause this
|
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
|
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
|
accepted samples (about 17 hours) per zone, including 30 with the relay ON and 30
|
||||||
with it OFF, which can take a few days depending on how often the boiler runs.
|
with it OFF, which can take a few days depending on how often the boiler runs.
|
||||||
|
|
||||||
The **Heating MPC Status** entity's state is one of `waiting_for_sensors`,
|
The **Heating MPC Status** entity's state is one of `waiting_for_sensors`,
|
||||||
`learning`, `paused` (thermostat not in `heat`), `waiting_for_target`,
|
`no_zones` (add a zone), `learning`, `paused` (thermostat not in `heat`), `waiting_for_target`, `ready`,
|
||||||
`invalid_weights`, `ready`, `inputs_changed` or `prediction_error`. Proposed heat
|
`inputs_changed` or `prediction_error`. Proposed heat is available only while it
|
||||||
is available only while it is `ready`. Useful attributes:
|
is `ready`. Useful attributes:
|
||||||
|
|
||||||
- `input_quality` — for each room and each weather source (grouped by metric):
|
- `input_quality` — per zone (with each of its sensors) and per weather source
|
||||||
`status` (`valid`, `missing`, `unknown`, `unavailable`, `stale`,
|
(grouped by metric): `status` (`valid`, `missing`, `unknown`, `unavailable`,
|
||||||
`invalid_timestamp`, `future_timestamp` or `invalid_value_or_unit`),
|
`stale`, `invalid_timestamp`, `future_timestamp` or `invalid_value_or_unit`),
|
||||||
`age_seconds` (time since the sensor last reported to HA) and
|
`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
|
`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`:
|
reload and can read up to one poll, 5 minutes, low). Zones and sensors have `used`.
|
||||||
whether that room's reading was used in this poll.
|
- `excluded_zones` and `weight_coverage` — zones left out of this poll, and the
|
||||||
- `excluded_rooms` and `weight_coverage` — rooms left out of this poll, and the
|
share of the total comfort weight still covered by the zones used (1.0 = all).
|
||||||
share of the total comfort weight still covered by the rooms used (1.0 = all).
|
`zone_names` maps zone keys to names.
|
||||||
|
- `heat_sources` — per source: measured and effective power (after removing
|
||||||
|
included sources), baseline, variable power above it, thermal response, whether it
|
||||||
|
has settled.
|
||||||
|
- `windows` — per window: state, minutes open, wind direction relative to the
|
||||||
|
window's facing and the resulting exposure (1 = wind straight at it).
|
||||||
|
`ventilation` — per zone: current opening, recovery, temperature before opening,
|
||||||
|
its contribution in °C/h and the ventilation coefficients. `ventilation_events` —
|
||||||
|
the last five openings with temperatures before, lowest, at closing and 30
|
||||||
|
minutes after, and the weather.
|
||||||
- `heat_lag` and `heat_lag_uncertainty` — the radiator-lag estimate (0 cold, 1 fully
|
- `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
|
warm) and its error bound. Learning waits while the bound is above 0.1, for
|
||||||
example for about 30 minutes after an HA restart.
|
example for about 30 minutes after an HA restart.
|
||||||
- `sample_result` — why the last poll was or was not learned: `first_sample`,
|
- `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`,
|
`sensors_changed` (a zone's set of usable sensors changed), `learned`,
|
||||||
`relay_changed`, `outside_source_changed` or `lag_uncertain`.
|
`ventilation` (only window-affected zones; given to the ventilation model),
|
||||||
- `models` — per-room coefficients, accepted sample counts and the in-sample
|
`partly_rejected` (an outlier in some zone), `interval_out_of_range`,
|
||||||
5-minute error (`mae_5m`), which is **not** a forecast accuracy.
|
`relay_changed`, `outside_source_changed`, `lag_uncertain` or `waiting_for_inputs`.
|
||||||
- `retired_room_models` — removed rooms whose learned models are kept.
|
- `models` — per-zone base coefficients, accepted sample counts and the in-sample
|
||||||
|
5-minute error (`mae_5m`, **not** a forecast accuracy), `model_in_use` and the
|
||||||
|
extended layer's coefficients with confidence.
|
||||||
|
- `retired_zone_models` — removed zones whose learned models are kept.
|
||||||
|
|
||||||
`input_quality` and `weather_comparison` change every poll, so they are not
|
`input_quality`, `weather_comparison`, `heat_sources`, `windows`, `ventilation`
|
||||||
written to the Recorder; the other attributes are.
|
and `ventilation_events` change every poll, so they are not written to the
|
||||||
|
Recorder; the other attributes are.
|
||||||
|
|
||||||
### Choosing freshness limits
|
### Choosing freshness limits
|
||||||
|
|
||||||
Room and weather report-age limits are set during setup and in **Configure**
|
Room and weather report-age limits are set during setup and in **Configure**
|
||||||
(5–1440 minutes; defaults 30 for rooms and 120 for weather).
|
(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
|
A zone sensor that is older than its limit, unavailable, missing or invalid is
|
||||||
**left out**: the weighted temperature, learning and forecasts continue with the
|
**left out**; the zone uses its other sensors (without learning across that change).
|
||||||
other rooms, whose weights are re-normalized. Status shows it in `excluded_rooms`.
|
A zone with no usable sensor is left out entirely: the weighted temperature,
|
||||||
Learning and forecasts pause only when no room is usable, or when the relay,
|
learning and forecasts continue with the
|
||||||
thermostat or outdoor temperature is missing. A room that reports again learns
|
other zones, whose weights are re-normalized. Status shows it in `excluded_zones`.
|
||||||
|
Learning and forecasts pause only when no zone is usable, or when the relay,
|
||||||
|
thermostat or outdoor temperature is missing. A zone that reports again learns
|
||||||
from its second poll onwards, so nothing is learned across its gap.
|
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**
|
The checkbox **Keep using the last value of a room sensor that stopped reporting**
|
||||||
@@ -155,16 +217,17 @@ age limit.
|
|||||||
## Forecast accuracy
|
## Forecast accuracy
|
||||||
|
|
||||||
The four **Forecast Error** sensors measure the thermal model against reality,
|
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
|
continuously and out of sample. At every poll the integration records each zone's
|
||||||
reading, the outdoor temperature, the radiator-lag estimate and the model's
|
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
|
parameters. 15, 30, 60 and 120 minutes later it replays the zones from the earlier
|
||||||
reading using the parameters it had **then**, the outdoor temperature held at its
|
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
|
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.
|
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
|
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,
|
executed. All zones are replayed together, with the electrical heat and window
|
||||||
across all rooms. Attributes, all in °C except counts:
|
openings that actually happened, and the base model is scored on the same spans. Each sensor's state is the mean absolute error (°C) over the last 7 days,
|
||||||
|
across all zones. Attributes, all in °C except counts:
|
||||||
|
|
||||||
- `samples` — number of scored forecasts. Consecutive forecasts overlap, so they
|
- `samples` — number of scored forecasts. Consecutive forecasts overlap, so they
|
||||||
are not independent; treat small counts as indicative only.
|
are not independent; treat small counts as indicative only.
|
||||||
@@ -172,11 +235,14 @@ across all rooms. Attributes, all in °C except counts:
|
|||||||
- `persistence_mae` — error of simply assuming no change; the baseline to beat.
|
- `persistence_mae` — error of simply assuming no change; the baseline to beat.
|
||||||
- `skill` — `1 - mae / persistence_mae`: 1 is perfect, 0 is no better than
|
- `skill` — `1 - mae / persistence_mae`: 1 is perfect, 0 is no better than
|
||||||
assuming no change, negative is worse. Empty while the temperature did not change.
|
assuming no change, negative is worse. Empty while the temperature did not change.
|
||||||
- `rooms` — the same numbers per room (not written to the Recorder).
|
- `base_model_mae`, `improvement_vs_base` — the base model's error on the same
|
||||||
|
spans, and the share of it the extra layers remove (0 = no gain, negative = they
|
||||||
|
make it worse: switch that option off).
|
||||||
|
- `zones` — the same numbers per zone (not written to the Recorder).
|
||||||
|
|
||||||
Forecasts are scored only where the inputs were known: polls 4.5–5.5 minutes
|
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
|
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
|
estimate throughout. A missing relay, outdoor temperature or all zones restarts the
|
||||||
replay window, so scores resume 15–120 minutes later. Statistics are kept with the
|
replay window, so scores resume 15–120 minutes later. Statistics are kept with the
|
||||||
model and survive restarts; deleting the model resets them.
|
model and survive restarts; deleting the model resets them.
|
||||||
|
|
||||||
|
|||||||
+16
-11
@@ -10,7 +10,7 @@ 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
|
> **Current release: 0.4.0** — Milestone 0 reliability work, part of Milestone 1 and the first part of Milestone 2 on top of this
|
||||||
> baseline; see [CHANGELOG.md](CHANGELOG.md). The lists below describe 0.2.1.
|
> baseline; see [CHANGELOG.md](CHANGELOG.md). The lists below describe 0.2.1.
|
||||||
|
|
||||||
**Implemented (experimental):**
|
**Implemented (experimental):**
|
||||||
@@ -104,14 +104,14 @@ recording volatile attributes, and tests against HA 2025.12.0 and 2026.2.3.
|
|||||||
|
|
||||||
**Objective:** Improve forecasts with genuinely useful features, not just more inputs.
|
**Objective:** Improve forecasts with genuinely useful features, not just more inputs.
|
||||||
|
|
||||||
- [ ] Compare existing per-room linear model with a multi-zone RC model including inter-room heat exchange.
|
- [x] Compare existing per-room linear model with a multi-zone RC model including inter-room heat exchange. *(0.4.0: layered zone model with neighbour exchange; base model scored alongside on every forecast. Exchange between zones sharing one boiler is weakly identifiable and reported as such.)*
|
||||||
- [ ] Address multiple sensors within a single physical room; configurable room grouping, aggregation and comfort priorities.
|
- [x] Address multiple sensors within a single physical room; configurable room grouping, aggregation and comfort priorities. *(0.4.0: zones with sensor weights, comfort weights and target offsets.)*
|
||||||
- [ ] Distinguish air/room thermal mass and radiator lag where observable; avoid overfitting unidentifiable parameters.
|
- [ ] Distinguish air/room thermal mass and radiator lag where observable; avoid overfitting unidentifiable parameters.
|
||||||
- [ ] Add optional Recorder history import/backfill for **indoor sensor + physical relay** history; do not assume old HA Recorder data still exists.
|
- [ ] Add optional Recorder history import/backfill for **indoor sensor + physical relay** history; do not assume old HA Recorder data still exists.
|
||||||
- [ ] Add outdoor forecast data with future timestamps (forecast provider selectable) and compare forecast-based versus constant-outdoor assumptions.
|
- [ ] Add outdoor forecast data with future timestamps (forecast provider selectable) and compare forecast-based versus constant-outdoor assumptions.
|
||||||
- [ ] Experiment with wind/cloud cover as optional covariates; retain them only if held-out forecast error improves. Solar gains may require orientation/irradiance or a useful proxy.
|
- [ ] Experiment with wind/cloud cover as optional covariates; retain them only if held-out forecast error improves. Solar gains may require orientation/irradiance or a useful proxy. *(0.4.0: wind speed, direction and gusts for open windows, optional and judged by `improvement_vs_base`; clouds and solar still open.)*
|
||||||
- [ ] Expose source reliability and provider comparison: normalized units, update age, outages and sensor offsets.
|
- [ ] Expose source reliability and provider comparison: normalized units, update age, outages and sensor offsets.
|
||||||
- [ ] Handle shifts in room usage, opened windows, sensor relocation and season changes without unstable feedback.
|
- [ ] Handle shifts in room usage, opened windows, sensor relocation and season changes without unstable feedback. *(0.4.0: opened windows handled by a separate ventilation layer; electrical loads by heat sources. Sensor relocation and seasons still open.)*
|
||||||
- [ ] Benchmark RAM, CPU and event-loop latency on Raspberry Pi 5. Prefer standard-library/small scientific dependencies and avoid heavy inference runtimes.
|
- [ ] Benchmark RAM, CPU and event-loop latency on Raspberry Pi 5. Prefer standard-library/small scientific dependencies and avoid heavy inference runtimes.
|
||||||
|
|
||||||
**Exit criteria:** Model complexity justified by reproducible forecast improvements over v0.3.0; stable fitting and manageable HAOS resource usage. Retain the simpler model as fallback where it performs better.
|
**Exit criteria:** Model complexity justified by reproducible forecast improvements over v0.3.0; stable fitting and manageable HAOS resource usage. Retain the simpler model as fallback where it performs better.
|
||||||
@@ -174,12 +174,17 @@ Items 1–3 of the original backlog are done (see AUDIT.md and CHANGELOG.md 0.2.
|
|||||||
room out, or optionally keep its last value for up to 24 hours). Before live
|
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
|
control, decide a minimum `weight_coverage` and the outdoor-temperature outage
|
||||||
policy, which still pauses.
|
policy, which still pauses.
|
||||||
3. Milestone 1 (rest, 0.3.x): let the Forecast Error sensors collect a few weeks
|
3. Configure zones, the UPS heat source and the windows on HAOS; after a week of
|
||||||
of heating data and review per-room bias; a dashboard example; forecast
|
heating review `improvement_vs_base`, per-zone bias and which coefficients are
|
||||||
timestamps/provenance; a fixed-parameter baseline; explanations for the proposed
|
identified. Switch off optional terms that do not help.
|
||||||
action; an actual-policy forecast that simulates Generic Thermostat.
|
4. Milestone 1 (rest): a dashboard example; forecast timestamps/provenance;
|
||||||
4. Only then consider richer weather inputs and the live-control **design**; do not
|
explanations for the proposed action; an actual-policy forecast that simulates
|
||||||
implement live control without separate authorization.
|
Generic Thermostat.
|
||||||
|
5. Milestone 2 (rest): learned response times (compare candidate time constants
|
||||||
|
out of sample), a wall-mass state if recovery data shows it is needed, Recorder
|
||||||
|
backfill, weather forecasts, and a Raspberry Pi 5 resource benchmark.
|
||||||
|
6. Only then consider the live-control **design**; do not implement live control
|
||||||
|
without separate authorization.
|
||||||
|
|
||||||
## Definition of done for each milestone
|
## Definition of done for each milestone
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
"""Heating MPC custom integration, initial release shadow-only."""
|
"""Heating MPC custom integration, initial release shadow-only."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import MappingProxyType
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.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 homeassistant.helpers.event import async_track_state_change_event
|
||||||
|
|
||||||
from .const import CONF_HEATER, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
|
from .const import CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, DOMAIN, CONF_OUTSIDE, WEATHER_SOURCES
|
||||||
from .weather_sources import sources_for
|
from .weather_sources import sources_for
|
||||||
from .coordinator import HeatingMPCCoordinator
|
from .coordinator import HeatingMPCCoordinator
|
||||||
|
from .zones import SUBENTRY_ZONE, ZONE_KEY, ZONE_NAME, legacy_zone_data
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||||
|
|
||||||
@@ -31,12 +34,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||||
raise
|
raise
|
||||||
entry.async_on_unload(unsub_relay)
|
entry.async_on_unload(unsub_relay)
|
||||||
# Remove only obsolete room forecasts belonging to this entry/platform.
|
# Remove only obsolete zone forecasts belonging to this entry/platform. Zones
|
||||||
|
# deleted in the UI are also removed by HA through their subentry link.
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
expected = {f"{entry.entry_id}_{room}_predicted_60min" for room in coordinator.rooms}
|
expected = {f"{entry.entry_id}_{zone}_predicted_60min" for zone in coordinator.rooms}
|
||||||
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
|
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
|
||||||
if (entity.platform == DOMAIN and
|
if (entity.platform == DOMAIN and
|
||||||
entity.unique_id.startswith(f"{entry.entry_id}_sensor.") and
|
entity.unique_id.startswith(f"{entry.entry_id}_") and
|
||||||
entity.unique_id.endswith("_predicted_60min") and
|
entity.unique_id.endswith("_predicted_60min") and
|
||||||
entity.unique_id not in expected):
|
entity.unique_id not in expected):
|
||||||
registry.async_remove(entity.entity_id)
|
registry.async_remove(entity.entity_id)
|
||||||
@@ -62,8 +66,14 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Migrate legacy single weather source without changing user selections."""
|
"""Migrate older entries without changing the user's selections.
|
||||||
if entry.version > 2:
|
|
||||||
|
1 -> 2: single outdoor sensor becomes a prioritized source list.
|
||||||
|
2 -> 3: each room sensor becomes a one-sensor zone subentry with its comfort
|
||||||
|
weight. Its entity ID stays the zone key, so learned models, forecast entities
|
||||||
|
and accuracy statistics carry over. Older versions cannot load version 3.
|
||||||
|
"""
|
||||||
|
if entry.version > 3:
|
||||||
return False
|
return False
|
||||||
if entry.version == 1:
|
if entry.version == 1:
|
||||||
def migrate(config):
|
def migrate(config):
|
||||||
@@ -76,4 +86,25 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
entry, data=migrate(entry.data), options=migrate(entry.options),
|
entry, data=migrate(entry.data), options=migrate(entry.options),
|
||||||
version=2, minor_version=1,
|
version=2, minor_version=1,
|
||||||
)
|
)
|
||||||
|
if entry.version == 2:
|
||||||
|
config = {**entry.data, **entry.options}
|
||||||
|
rooms = [room for room in config.get(CONF_ROOMS, []) if isinstance(room, str)]
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
names = {}
|
||||||
|
for room in rooms:
|
||||||
|
state, registered = hass.states.get(room), registry.async_get(room)
|
||||||
|
names[room] = ((state.name if state else None)
|
||||||
|
or (registered.name or registered.original_name if registered else None)
|
||||||
|
or room)
|
||||||
|
existing = {sub.unique_id for sub in entry.subentries.values()}
|
||||||
|
for data in legacy_zone_data(rooms, config.get(CONF_WEIGHTS), names):
|
||||||
|
if data[ZONE_KEY] not in existing:
|
||||||
|
hass.config_entries.async_add_subentry(entry, ConfigSubentry(
|
||||||
|
data=MappingProxyType(data), subentry_type=SUBENTRY_ZONE,
|
||||||
|
title=data[ZONE_NAME], unique_id=data[ZONE_KEY]))
|
||||||
|
|
||||||
|
def strip(config):
|
||||||
|
return {k: v for k, v in config.items() if k not in (CONF_ROOMS, CONF_WEIGHTS)}
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry, data=strip(entry.data), options=strip(entry.options), version=3, minor_version=1)
|
||||||
return True
|
return True
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"""UI-only installation and reconfiguration: rooms, source priorities, weights."""
|
"""UI-only installation and reconfiguration: zones, source priorities, weights."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
@@ -11,11 +12,28 @@ from homeassistant.helpers import selector
|
|||||||
from .const import (
|
from .const import (
|
||||||
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, 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_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
||||||
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS,
|
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, CONF_INTERNAL_HALF_LIFE, DEFAULTS,
|
||||||
|
CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST, CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES,
|
||||||
WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA,
|
WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA,
|
||||||
)
|
)
|
||||||
|
from .heat_sources import (
|
||||||
|
BASELINE_AUTO, BASELINE_MANUAL, DEFAULT_RESPONSE_MINUTES, MAX_POWER_W, RESPONSE_MINUTES_RANGE,
|
||||||
|
SOURCE_BASELINE_MODE, SOURCE_BASELINE_W, SOURCE_ENABLED, SOURCE_FRACTION, SOURCE_INCLUDED_IN,
|
||||||
|
SOURCE_KEY, SOURCE_NAME, SOURCE_RESPONSE, SOURCE_SENSOR, SOURCE_ZONE, SUBENTRY_HEAT_SOURCE,
|
||||||
|
HeatSource, build_sources, validate_source,
|
||||||
|
)
|
||||||
from .weather_sources import sources_for
|
from .weather_sources import sources_for
|
||||||
|
from .windows import (
|
||||||
|
DEFAULT_AZIMUTH, OPENING_RANGE, SUBENTRY_WINDOW, WINDOW_AZIMUTH, WINDOW_ENABLED, WINDOW_INVERTED,
|
||||||
|
WINDOW_KEY, WINDOW_NAME, WINDOW_OPENING, WINDOW_SENSOR, WINDOW_ZONE, Window, build_windows,
|
||||||
|
validate_window,
|
||||||
|
)
|
||||||
|
from .zones import (
|
||||||
|
COMFORT_WEIGHT_RANGE, SENSOR_WEIGHT_RANGE, SUBENTRY_ZONE, TARGET_OFFSET_RANGE,
|
||||||
|
ZONE_COMFORT_WEIGHT, ZONE_KEY, ZONE_NAME, ZONE_NEIGHBORS, ZONE_SENSOR_WEIGHTS,
|
||||||
|
ZONE_SENSORS, ZONE_TARGET_OFFSET, Zone, build_zones, legacy_zone_data, validate_zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _entity_picker(multiple: bool = False):
|
def _entity_picker(multiple: bool = False):
|
||||||
@@ -30,14 +48,16 @@ def _required(key: str, data: dict):
|
|||||||
|
|
||||||
|
|
||||||
def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
||||||
|
"""Setup asks for room sensors (one zone each); Configure manages zones separately."""
|
||||||
data = config or {}
|
data = config or {}
|
||||||
schema: dict[Any, Any] = {
|
schema: dict[Any, Any] = {
|
||||||
_required(CONF_CLIMATE, data): selector.EntitySelector(
|
_required(CONF_CLIMATE, data): selector.EntitySelector(
|
||||||
selector.EntitySelectorConfig(domain="climate")),
|
selector.EntitySelectorConfig(domain="climate")),
|
||||||
_required(CONF_HEATER, data): selector.EntitySelector(
|
_required(CONF_HEATER, data): selector.EntitySelector(
|
||||||
selector.EntitySelectorConfig(domain="switch")),
|
selector.EntitySelectorConfig(domain="switch")),
|
||||||
_required(CONF_ROOMS, data): _entity_picker(multiple=True),
|
|
||||||
}
|
}
|
||||||
|
if not detailed:
|
||||||
|
schema[_required(CONF_ROOMS, data)] = _entity_picker(multiple=True)
|
||||||
for key in (CONF_ROOM_MAX_AGE, CONF_WEATHER_MAX_AGE):
|
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))
|
||||||
@@ -51,8 +71,13 @@ def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
|||||||
CONF_SWITCH_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)),
|
CONF_SWITCH_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)),
|
||||||
CONF_HEAT_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)),
|
CONF_HEAT_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)),
|
||||||
CONF_BAND: vol.All(vol.Coerce(float), vol.Range(min=0.05, max=0.7)),
|
CONF_BAND: vol.All(vol.Coerce(float), vol.Range(min=0.05, max=0.7)),
|
||||||
|
CONF_INTERNAL_HALF_LIFE: vol.All(vol.Coerce(int), vol.Range(min=0, max=720)),
|
||||||
|
CONF_WINDOW_OPEN_MINUTES: vol.All(vol.Coerce(int), vol.Range(min=5, max=120)),
|
||||||
|
CONF_RECOVERY_MINUTES: vol.All(vol.Coerce(int), vol.Range(min=5, max=120)),
|
||||||
}.items():
|
}.items():
|
||||||
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed
|
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed
|
||||||
|
for key in (CONF_VENT_WIND, CONF_VENT_GUST, CONF_VENT_CROSS):
|
||||||
|
schema[vol.Required(key, default=bool(data.get(key, DEFAULTS[key])))] = selector.BooleanSelector()
|
||||||
return vol.Schema(schema)
|
return vol.Schema(schema)
|
||||||
|
|
||||||
|
|
||||||
@@ -98,10 +123,28 @@ def _valid_weather(rooms: list[str], data: dict) -> bool:
|
|||||||
return bool(sources) and len(sources) <= 10 and not set(sources).intersection(rooms)
|
return bool(sources) and len(sources) <= 10 and not set(sources).intersection(rooms)
|
||||||
|
|
||||||
|
|
||||||
|
def entry_zones(entry: config_entries.ConfigEntry) -> list[Zone]:
|
||||||
|
"""Zones of a config entry, from its zone subentries."""
|
||||||
|
return build_zones((sub.data, sub.subentry_id) for sub in entry.subentries.values()
|
||||||
|
if sub.subentry_type == SUBENTRY_ZONE)
|
||||||
|
|
||||||
|
|
||||||
|
def entry_sources(entry: config_entries.ConfigEntry) -> list[HeatSource]:
|
||||||
|
zones = {zone.key for zone in entry_zones(entry)}
|
||||||
|
return build_sources(((sub.data, sub.subentry_id) for sub in entry.subentries.values()
|
||||||
|
if sub.subentry_type == SUBENTRY_HEAT_SOURCE), zones)
|
||||||
|
|
||||||
|
|
||||||
|
def _number(lo: float, hi: float, step: float) -> selector.NumberSelector:
|
||||||
|
return selector.NumberSelector(selector.NumberSelectorConfig(
|
||||||
|
min=lo, max=hi, step=step, mode=selector.NumberSelectorMode.BOX))
|
||||||
|
|
||||||
|
|
||||||
class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Create a native observation-only MPC installation."""
|
"""Create a native observation-only MPC installation."""
|
||||||
|
|
||||||
VERSION = 2
|
# 3: rooms and comfort weights moved into zone subentries.
|
||||||
|
VERSION = 3
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._draft: dict[str, Any] = {}
|
self._draft: dict[str, Any] = {}
|
||||||
@@ -136,11 +179,17 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self._draft[CONF_WEIGHTS] = {
|
rooms = self._draft.pop(CONF_ROOMS)
|
||||||
room: float(user_input[f"weight__{room}"])
|
weights = {room: float(user_input[f"weight__{room}"]) for room in rooms}
|
||||||
for room in self._draft[CONF_ROOMS]
|
names = {}
|
||||||
}
|
for room in rooms:
|
||||||
return self.async_create_entry(title="Heating MPC", data=self._draft)
|
state = self.hass.states.get(room)
|
||||||
|
names[room] = state.name if state else room
|
||||||
|
# Each chosen sensor becomes its own zone; zones can be edited later.
|
||||||
|
zones = [{"data": data, "subentry_type": SUBENTRY_ZONE, "title": data[ZONE_NAME],
|
||||||
|
"unique_id": data[ZONE_KEY]}
|
||||||
|
for data in legacy_zone_data(rooms, weights, names)]
|
||||||
|
return self.async_create_entry(title="Heating MPC", data=self._draft, subentries=zones)
|
||||||
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
||||||
self._draft[CONF_ROOMS]))
|
self._draft[CONF_ROOMS]))
|
||||||
|
|
||||||
@@ -149,6 +198,12 @@ class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
|
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
|
||||||
return HeatingMPCOptionsFlow()
|
return HeatingMPCOptionsFlow()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@callback
|
||||||
|
def async_get_supported_subentry_types(cls, config_entry: config_entries.ConfigEntry):
|
||||||
|
return {SUBENTRY_ZONE: ZoneSubentryFlow, SUBENTRY_HEAT_SOURCE: HeatSourceSubentryFlow,
|
||||||
|
SUBENTRY_WINDOW: WindowSubentryFlow}
|
||||||
|
|
||||||
|
|
||||||
class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||||
"""Edit sensors, ranked alternative weather providers and MPC parameters."""
|
"""Edit sensors, ranked alternative weather providers and MPC parameters."""
|
||||||
@@ -156,12 +211,12 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._draft: dict[str, Any] = {}
|
self._draft: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def _zone_sensors(self) -> list[str]:
|
||||||
|
return [sensor for zone in entry_zones(self.config_entry) for sensor in zone.sensors]
|
||||||
|
|
||||||
async def async_step_init(self, user_input: dict[str, Any] | None = None):
|
async def async_step_init(self, user_input: dict[str, Any] | None = None):
|
||||||
config = {**self.config_entry.data, **self.config_entry.options}
|
config = {**self.config_entry.data, **self.config_entry.options}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
if not _valid_rooms(user_input[CONF_ROOMS]):
|
|
||||||
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
|
||||||
config, detailed=True), errors={"base": "invalid_rooms"})
|
|
||||||
self._draft = user_input
|
self._draft = user_input
|
||||||
return await self.async_step_weather()
|
return await self.async_step_weather()
|
||||||
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
||||||
@@ -170,7 +225,7 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
|||||||
async def async_step_weather(self, user_input: dict[str, Any] | None = None):
|
async def async_step_weather(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:
|
||||||
if not _valid_weather(self._draft[CONF_ROOMS], user_input):
|
if not _valid_weather(self._zone_sensors(), user_input):
|
||||||
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
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(_with_cleared_weather(user_input, WEATHER_MAIN))
|
self._draft.update(_with_cleared_weather(user_input, WEATHER_MAIN))
|
||||||
@@ -182,19 +237,210 @@ class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
|||||||
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(_with_cleared_weather(user_input, WEATHER_EXTRA))
|
self._draft.update(_with_cleared_weather(user_input, WEATHER_EXTRA))
|
||||||
return await self.async_step_weights()
|
|
||||||
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
|
|
||||||
config, WEATHER_EXTRA))
|
|
||||||
|
|
||||||
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
|
||||||
if user_input is not None:
|
|
||||||
self._draft[CONF_WEIGHTS] = {
|
|
||||||
room: float(user_input[f"weight__{room}"])
|
|
||||||
for room in self._draft[CONF_ROOMS]
|
|
||||||
}
|
|
||||||
# Prevent an old single-temperature sensor from overriding new sources.
|
# Prevent an old single-temperature sensor from overriding new sources.
|
||||||
self._draft.pop(CONF_OUTSIDE, None)
|
self._draft.pop(CONF_OUTSIDE, None)
|
||||||
return self.async_create_entry(title="", data=self._draft)
|
return self.async_create_entry(title="", data=self._draft)
|
||||||
config = {**self.config_entry.data, **self.config_entry.options}
|
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
|
||||||
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
config, WEATHER_EXTRA))
|
||||||
self._draft[CONF_ROOMS], config.get(CONF_WEIGHTS, {})))
|
|
||||||
|
|
||||||
|
class ZoneSubentryFlow(config_entries.ConfigSubentryFlow):
|
||||||
|
"""Add or edit a thermal zone: its sensors, comfort weight and neighbours."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._draft: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def _other_zones(self, key: str | None) -> list[Zone]:
|
||||||
|
return [zone for zone in entry_zones(self._get_entry()) if zone.key != key]
|
||||||
|
|
||||||
|
def _schema(self, data: dict[str, Any], key: str | None) -> vol.Schema:
|
||||||
|
schema: dict[Any, Any] = {
|
||||||
|
vol.Required(ZONE_NAME, default=data.get(ZONE_NAME, "")): selector.TextSelector(),
|
||||||
|
vol.Required(ZONE_SENSORS, default=list(data.get(ZONE_SENSORS, []))):
|
||||||
|
_entity_picker(multiple=True),
|
||||||
|
vol.Required(ZONE_COMFORT_WEIGHT, default=data.get(ZONE_COMFORT_WEIGHT, 1.0)):
|
||||||
|
_number(*COMFORT_WEIGHT_RANGE, 0.1),
|
||||||
|
vol.Required(ZONE_TARGET_OFFSET, default=data.get(ZONE_TARGET_OFFSET, 0.0)):
|
||||||
|
_number(*TARGET_OFFSET_RANGE, 0.1),
|
||||||
|
}
|
||||||
|
others = self._other_zones(key)
|
||||||
|
if others:
|
||||||
|
schema[vol.Optional(ZONE_NEIGHBORS, description={
|
||||||
|
"suggested_value": [n for n in data.get(ZONE_NEIGHBORS, []) if n in {z.key for z in others}]})] = \
|
||||||
|
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||||
|
options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in others],
|
||||||
|
multiple=True, mode=selector.SelectSelectorMode.LIST))
|
||||||
|
return vol.Schema(schema)
|
||||||
|
|
||||||
|
async def _zone_form(self, step_id: str, user_input: dict[str, Any] | None,
|
||||||
|
existing: dict[str, Any], key: str | None):
|
||||||
|
if user_input is not None:
|
||||||
|
entry = self._get_entry()
|
||||||
|
config = {**entry.data, **entry.options}
|
||||||
|
weather = {s for metric in ("temperature",) for s in sources_for(config, metric)}
|
||||||
|
error = validate_zone(user_input, self._other_zones(key), weather)
|
||||||
|
if error:
|
||||||
|
return self.async_show_form(step_id=step_id, data_schema=self._schema(user_input, key),
|
||||||
|
errors={"base": error})
|
||||||
|
self._draft = {**existing, **user_input, ZONE_NAME: user_input[ZONE_NAME].strip()}
|
||||||
|
self._draft.setdefault(ZONE_NEIGHBORS, [])
|
||||||
|
if len(user_input[ZONE_SENSORS]) > 1:
|
||||||
|
return await self.async_step_sensor_weights()
|
||||||
|
self._draft[ZONE_SENSOR_WEIGHTS] = {user_input[ZONE_SENSORS][0]: 1.0}
|
||||||
|
return self._finish()
|
||||||
|
return self.async_show_form(step_id=step_id, data_schema=self._schema(existing, key))
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||||
|
if not self._draft.get(ZONE_KEY):
|
||||||
|
self._draft = {ZONE_KEY: f"zone_{uuid4().hex[:10]}"}
|
||||||
|
return await self._zone_form("user", user_input, self._draft, None)
|
||||||
|
|
||||||
|
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
|
||||||
|
subentry = self._get_reconfigure_subentry()
|
||||||
|
existing = dict(subentry.data)
|
||||||
|
existing.setdefault(ZONE_KEY, subentry.subentry_id)
|
||||||
|
return await self._zone_form("reconfigure", user_input, existing, existing[ZONE_KEY])
|
||||||
|
|
||||||
|
async def async_step_sensor_weights(self, user_input: dict[str, Any] | None = None):
|
||||||
|
sensors = self._draft[ZONE_SENSORS]
|
||||||
|
if user_input is not None:
|
||||||
|
self._draft[ZONE_SENSOR_WEIGHTS] = {s: float(user_input[f"weight__{s}"]) for s in sensors}
|
||||||
|
return self._finish()
|
||||||
|
weights = self._draft.get(ZONE_SENSOR_WEIGHTS) or {}
|
||||||
|
return self.async_show_form(step_id="sensor_weights", data_schema=vol.Schema({
|
||||||
|
vol.Required(f"weight__{s}", default=weights.get(s, 1.0)): _number(*SENSOR_WEIGHT_RANGE, 0.1)
|
||||||
|
for s in sensors}))
|
||||||
|
|
||||||
|
def _finish(self):
|
||||||
|
data = {key: self._draft[key] for key in (
|
||||||
|
ZONE_KEY, ZONE_NAME, ZONE_SENSORS, ZONE_SENSOR_WEIGHTS, ZONE_COMFORT_WEIGHT,
|
||||||
|
ZONE_TARGET_OFFSET, ZONE_NEIGHBORS)}
|
||||||
|
data[ZONE_COMFORT_WEIGHT] = float(data[ZONE_COMFORT_WEIGHT])
|
||||||
|
data[ZONE_TARGET_OFFSET] = float(data[ZONE_TARGET_OFFSET])
|
||||||
|
if self.source == config_entries.SOURCE_RECONFIGURE:
|
||||||
|
return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
|
||||||
|
title=data[ZONE_NAME], data=data)
|
||||||
|
return self.async_create_entry(title=data[ZONE_NAME], data=data, unique_id=data[ZONE_KEY])
|
||||||
|
|
||||||
|
|
||||||
|
class HeatSourceSubentryFlow(config_entries.ConfigSubentryFlow):
|
||||||
|
"""Add or edit an electrical heat source (UPS, PC, server) in a zone."""
|
||||||
|
|
||||||
|
def _others(self, key: str | None) -> list[HeatSource]:
|
||||||
|
return [s for s in entry_sources(self._get_entry()) if s.key != key]
|
||||||
|
|
||||||
|
def _schema(self, data: dict[str, Any], key: str | None) -> vol.Schema:
|
||||||
|
zones = entry_zones(self._get_entry())
|
||||||
|
parents = [s for s in self._others(key) if s.included_in is None]
|
||||||
|
schema: dict[Any, Any] = {
|
||||||
|
vol.Required(SOURCE_NAME, default=data.get(SOURCE_NAME, "")): selector.TextSelector(),
|
||||||
|
vol.Required(SOURCE_SENSOR, **({"default": data[SOURCE_SENSOR]} if data.get(SOURCE_SENSOR) else {})):
|
||||||
|
selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor", device_class="power")),
|
||||||
|
vol.Required(SOURCE_ZONE, **({"default": data[SOURCE_ZONE]} if data.get(SOURCE_ZONE) else {})):
|
||||||
|
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||||
|
options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in zones])),
|
||||||
|
vol.Required(SOURCE_FRACTION, default=data.get(SOURCE_FRACTION, 1.0)): _number(0.0, 1.0, 0.05),
|
||||||
|
vol.Required(SOURCE_BASELINE_MODE, default=data.get(SOURCE_BASELINE_MODE, BASELINE_AUTO)):
|
||||||
|
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||||
|
options=[BASELINE_AUTO, BASELINE_MANUAL], translation_key=SOURCE_BASELINE_MODE)),
|
||||||
|
vol.Optional(SOURCE_BASELINE_W, description={"suggested_value": data.get(SOURCE_BASELINE_W)}):
|
||||||
|
_number(0.0, MAX_POWER_W, 1.0),
|
||||||
|
vol.Required(SOURCE_RESPONSE, default=data.get(SOURCE_RESPONSE, DEFAULT_RESPONSE_MINUTES)):
|
||||||
|
_number(*RESPONSE_MINUTES_RANGE, 1.0),
|
||||||
|
vol.Required(SOURCE_ENABLED, default=data.get(SOURCE_ENABLED, True)): selector.BooleanSelector(),
|
||||||
|
}
|
||||||
|
if parents:
|
||||||
|
schema[vol.Optional(SOURCE_INCLUDED_IN, description={
|
||||||
|
"suggested_value": data.get(SOURCE_INCLUDED_IN)})] = selector.SelectSelector(
|
||||||
|
selector.SelectSelectorConfig(options=[
|
||||||
|
selector.SelectOptionDict(value=s.key, label=s.name) for s in parents]))
|
||||||
|
return vol.Schema(schema)
|
||||||
|
|
||||||
|
async def _form(self, step_id: str, user_input: dict[str, Any] | None, existing: dict[str, Any]):
|
||||||
|
key = existing[SOURCE_KEY]
|
||||||
|
if not entry_zones(self._get_entry()):
|
||||||
|
return self.async_abort(reason="no_zones")
|
||||||
|
if user_input is not None:
|
||||||
|
data = {**existing, **user_input, SOURCE_KEY: key}
|
||||||
|
data.setdefault(SOURCE_INCLUDED_IN, None)
|
||||||
|
error = validate_source(data, self._others(key))
|
||||||
|
if error:
|
||||||
|
return self.async_show_form(step_id=step_id, data_schema=self._schema(data, key),
|
||||||
|
errors={"base": error})
|
||||||
|
data[SOURCE_NAME] = data[SOURCE_NAME].strip()
|
||||||
|
for number in (SOURCE_FRACTION, SOURCE_RESPONSE):
|
||||||
|
data[number] = float(data[number])
|
||||||
|
if data.get(SOURCE_BASELINE_W) is not None:
|
||||||
|
data[SOURCE_BASELINE_W] = float(data[SOURCE_BASELINE_W])
|
||||||
|
if self.source == config_entries.SOURCE_RECONFIGURE:
|
||||||
|
return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
|
||||||
|
title=data[SOURCE_NAME], data=data)
|
||||||
|
return self.async_create_entry(title=data[SOURCE_NAME], data=data, unique_id=key)
|
||||||
|
return self.async_show_form(step_id=step_id, data_schema=self._schema(existing, key))
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||||
|
if not hasattr(self, "_key"):
|
||||||
|
self._key = f"source_{uuid4().hex[:10]}"
|
||||||
|
return await self._form("user", user_input, {SOURCE_KEY: self._key})
|
||||||
|
|
||||||
|
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
|
||||||
|
subentry = self._get_reconfigure_subentry()
|
||||||
|
existing = dict(subentry.data)
|
||||||
|
existing.setdefault(SOURCE_KEY, subentry.subentry_id)
|
||||||
|
return await self._form("reconfigure", user_input, existing)
|
||||||
|
|
||||||
|
|
||||||
|
def entry_windows(entry: config_entries.ConfigEntry) -> list[Window]:
|
||||||
|
zones = {zone.key for zone in entry_zones(entry)}
|
||||||
|
return build_windows(((sub.data, sub.subentry_id) for sub in entry.subentries.values()
|
||||||
|
if sub.subentry_type == SUBENTRY_WINDOW), zones)
|
||||||
|
|
||||||
|
|
||||||
|
class WindowSubentryFlow(config_entries.ConfigSubentryFlow):
|
||||||
|
"""Add or edit a window contact sensor with its zone and orientation."""
|
||||||
|
|
||||||
|
def _schema(self, data: dict[str, Any]) -> vol.Schema:
|
||||||
|
zones = entry_zones(self._get_entry())
|
||||||
|
return vol.Schema({
|
||||||
|
vol.Required(WINDOW_NAME, default=data.get(WINDOW_NAME, "")): selector.TextSelector(),
|
||||||
|
vol.Required(WINDOW_SENSOR, **({"default": data[WINDOW_SENSOR]} if data.get(WINDOW_SENSOR) else {})):
|
||||||
|
selector.EntitySelector(selector.EntitySelectorConfig(domain="binary_sensor")),
|
||||||
|
vol.Required(WINDOW_ZONE, **({"default": data[WINDOW_ZONE]} if data.get(WINDOW_ZONE) else {})):
|
||||||
|
selector.SelectSelector(selector.SelectSelectorConfig(
|
||||||
|
options=[selector.SelectOptionDict(value=z.key, label=z.name) for z in zones])),
|
||||||
|
vol.Required(WINDOW_INVERTED, default=data.get(WINDOW_INVERTED, False)): selector.BooleanSelector(),
|
||||||
|
vol.Required(WINDOW_AZIMUTH, default=data.get(WINDOW_AZIMUTH, DEFAULT_AZIMUTH)):
|
||||||
|
_number(0.0, 359.0, 1.0),
|
||||||
|
vol.Required(WINDOW_OPENING, default=data.get(WINDOW_OPENING, 1.0)): _number(*OPENING_RANGE, 0.1),
|
||||||
|
vol.Required(WINDOW_ENABLED, default=data.get(WINDOW_ENABLED, True)): selector.BooleanSelector(),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _form(self, step_id: str, user_input: dict[str, Any] | None, existing: dict[str, Any]):
|
||||||
|
key = existing[WINDOW_KEY]
|
||||||
|
if not entry_zones(self._get_entry()):
|
||||||
|
return self.async_abort(reason="no_zones")
|
||||||
|
if user_input is not None:
|
||||||
|
data = {**existing, **user_input, WINDOW_KEY: key}
|
||||||
|
error = validate_window(data, [w for w in entry_windows(self._get_entry()) if w.key != key])
|
||||||
|
if error:
|
||||||
|
return self.async_show_form(step_id=step_id, data_schema=self._schema(data),
|
||||||
|
errors={"base": error})
|
||||||
|
data[WINDOW_NAME] = data[WINDOW_NAME].strip()
|
||||||
|
data[WINDOW_AZIMUTH] = float(data[WINDOW_AZIMUTH])
|
||||||
|
data[WINDOW_OPENING] = float(data[WINDOW_OPENING])
|
||||||
|
if self.source == config_entries.SOURCE_RECONFIGURE:
|
||||||
|
return self.async_update_and_abort(self._get_entry(), self._get_reconfigure_subentry(),
|
||||||
|
title=data[WINDOW_NAME], data=data)
|
||||||
|
return self.async_create_entry(title=data[WINDOW_NAME], data=data, unique_id=key)
|
||||||
|
return self.async_show_form(step_id=step_id, data_schema=self._schema(existing))
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||||
|
if not hasattr(self, "_key"):
|
||||||
|
self._key = f"window_{uuid4().hex[:10]}"
|
||||||
|
return await self._form("user", user_input, {WINDOW_KEY: self._key})
|
||||||
|
|
||||||
|
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
|
||||||
|
subentry = self._get_reconfigure_subentry()
|
||||||
|
existing = dict(subentry.data)
|
||||||
|
existing.setdefault(WINDOW_KEY, subentry.subentry_id)
|
||||||
|
return await self._form("reconfigure", user_input, existing)
|
||||||
@@ -16,11 +16,24 @@ CONF_WEATHER_MAX_AGE = "weather_max_age_minutes"
|
|||||||
# True keeps using their last value for sensors that report only on change.
|
# True keeps using their last value for sensors that report only on change.
|
||||||
CONF_ROOM_STALE_KEEP = "room_stale_use_last_value"
|
CONF_ROOM_STALE_KEEP = "room_stale_use_last_value"
|
||||||
CONF_BAND = "comfort_band"
|
CONF_BAND = "comfort_band"
|
||||||
|
# Forecast assumption: extra electrical heat returns to baseline with this half-life.
|
||||||
|
CONF_INTERNAL_HALF_LIFE = "internal_heat_half_life_minutes"
|
||||||
|
# Forecast assumption: an open window closes after this many more minutes.
|
||||||
|
CONF_WINDOW_OPEN_MINUTES = "window_open_minutes"
|
||||||
|
# Time constant of air recovering towards the walls' temperature after closing.
|
||||||
|
CONF_RECOVERY_MINUTES = "recovery_minutes"
|
||||||
|
# Optional ventilation terms (each must earn its keep in the accuracy comparison).
|
||||||
|
CONF_VENT_WIND = "ventilation_wind"
|
||||||
|
CONF_VENT_GUST = "ventilation_gust"
|
||||||
|
CONF_VENT_CROSS = "ventilation_cross"
|
||||||
|
|
||||||
# Each source list is ordered: first valid sensor wins; remaining are fallbacks.
|
# Each source list is ordered: first valid sensor wins; remaining are fallbacks.
|
||||||
WEATHER_SOURCES = {
|
WEATHER_SOURCES = {
|
||||||
"temperature": "outside_temperature_sources",
|
"temperature": "outside_temperature_sources",
|
||||||
"wind": "outside_wind_sources",
|
"wind": "outside_wind_sources",
|
||||||
|
# Meteorological: the direction the wind blows FROM, degrees clockwise from north.
|
||||||
|
"wind_direction": "outside_wind_direction_sources",
|
||||||
|
"wind_gust": "outside_wind_gust_sources",
|
||||||
"clouds": "outside_clouds_sources",
|
"clouds": "outside_clouds_sources",
|
||||||
"humidity": "outside_humidity_sources",
|
"humidity": "outside_humidity_sources",
|
||||||
"feels_like": "outside_feelslike_sources",
|
"feels_like": "outside_feelslike_sources",
|
||||||
@@ -29,7 +42,7 @@ WEATHER_SOURCES = {
|
|||||||
"rain": "outside_rain_sources",
|
"rain": "outside_rain_sources",
|
||||||
"snow": "outside_snow_sources",
|
"snow": "outside_snow_sources",
|
||||||
}
|
}
|
||||||
WEATHER_MAIN = ("temperature", "wind", "clouds")
|
WEATHER_MAIN = ("temperature", "wind", "wind_direction", "wind_gust", "clouds")
|
||||||
WEATHER_EXTRA = ("humidity", "feels_like", "pressure", "visibility", "rain", "snow")
|
WEATHER_EXTRA = ("humidity", "feels_like", "pressure", "visibility", "rain", "snow")
|
||||||
|
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
@@ -42,6 +55,12 @@ DEFAULTS = {
|
|||||||
CONF_SWITCH_COST: 0.15,
|
CONF_SWITCH_COST: 0.15,
|
||||||
CONF_HEAT_COST: 0.025,
|
CONF_HEAT_COST: 0.025,
|
||||||
CONF_BAND: 0.15,
|
CONF_BAND: 0.15,
|
||||||
|
CONF_INTERNAL_HALF_LIFE: 30,
|
||||||
|
CONF_WINDOW_OPEN_MINUTES: 15,
|
||||||
|
CONF_RECOVERY_MINUTES: 20,
|
||||||
|
CONF_VENT_WIND: True,
|
||||||
|
CONF_VENT_GUST: False,
|
||||||
|
CONF_VENT_CROSS: False,
|
||||||
}
|
}
|
||||||
# A room sensor silent this long is treated as dead even when keeping last values.
|
# A room sensor silent this long is treated as dead even when keeping last values.
|
||||||
ROOM_STALE_KEEP_MAX_MINUTES = 24 * 60
|
ROOM_STALE_KEEP_MAX_MINUTES = 24 * 60
|
||||||
@@ -52,7 +71,7 @@ READY_ON_SAMPLES = 30
|
|||||||
READY_OFF_SAMPLES = 30
|
READY_OFF_SAMPLES = 30
|
||||||
# Outcome of each poll for learning (Status sample_result, Last Sample sensor).
|
# Outcome of each poll for learning (Status sample_result, Last Sample sensor).
|
||||||
SAMPLE_RESULTS = (
|
SAMPLE_RESULTS = (
|
||||||
"waiting_for_inputs", "first_sample", "learned", "partly_rejected",
|
"waiting_for_inputs", "first_sample", "sensors_changed", "learned", "ventilation", "partly_rejected",
|
||||||
"interval_out_of_range", "relay_changed", "outside_source_changed", "lag_uncertain",
|
"interval_out_of_range", "relay_changed", "outside_source_changed", "lag_uncertain",
|
||||||
)
|
)
|
||||||
BLOCK_MINUTES = 15
|
BLOCK_MINUTES = 15
|
||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
from collections import deque
|
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
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.core import Event, HomeAssistant, callback
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
@@ -17,23 +17,69 @@ from .const import (
|
|||||||
CONF_ROOM_MAX_AGE, CONF_ROOM_STALE_KEEP, CONF_WEATHER_MAX_AGE, ROOM_STALE_KEEP_MAX_MINUTES,
|
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, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
|
CONF_BAND, CONF_INTERNAL_HALF_LIFE, CONF_RECOVERY_MINUTES, CONF_VENT_CROSS, CONF_VENT_GUST,
|
||||||
|
CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
|
||||||
)
|
)
|
||||||
from .data_quality import ReportAgeHistory, freshness
|
from .data_quality import ReportAgeHistory, freshness
|
||||||
from .evaluation import ForecastEvaluator
|
from .evaluation import ForecastEvaluator, model_snapshot
|
||||||
|
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
|
||||||
|
from .learning import FeatureLearner
|
||||||
from .physics import (
|
from .physics import (
|
||||||
LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag,
|
LAG_LEARN_MAX_UNCERTAINTY, LAG_TAU_SECONDS, Learner, advance_lag,
|
||||||
lag_from_time_in_state, optimize, relay_segments,
|
lag_from_time_in_state, optimize, relay_segments,
|
||||||
)
|
)
|
||||||
from .weather_sources import choose_weather_sources, sources_for, normalize
|
from .weather_sources import choose_weather_sources, sources_for, normalize
|
||||||
|
from .windows import SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, facing_cos, ventilation_vector
|
||||||
|
from .zone_model import ZoneModel, compile_step_model, extended_features, feature_vector
|
||||||
|
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
# Relay changes kept between polls; more than this in five minutes is chatter.
|
# Relay changes kept between polls; more than this in five minutes is chatter.
|
||||||
MAX_RELAY_EVENTS = 256
|
MAX_RELAY_EVENTS = 256
|
||||||
# Models of rooms removed in Options, kept in case a room is added back.
|
# Models of zones removed in the UI, kept in case a zone is added back.
|
||||||
MAX_RETIRED_ROOMS = 24
|
MAX_RETIRED_ROOMS = 24
|
||||||
|
|
||||||
|
|
||||||
|
def entry_zone_list(entry: ConfigEntry, config: dict[str, Any]) -> list[Zone]:
|
||||||
|
"""Zones from the entry's zone subentries; one zone per legacy room sensor otherwise."""
|
||||||
|
subentries = getattr(entry, "subentries", None) or {}
|
||||||
|
items = [(sub.data, sub.subentry_id) for sub in subentries.values()
|
||||||
|
if sub.subentry_type == SUBENTRY_ZONE]
|
||||||
|
if not items: # not migrated yet (or offline tests): same zones migration creates
|
||||||
|
items = [(data, None) for data in legacy_zone_data(
|
||||||
|
config.get(CONF_ROOMS, []), config.get(CONF_WEIGHTS))]
|
||||||
|
return build_zones(items)
|
||||||
|
|
||||||
|
|
||||||
|
def entry_window_list(entry: ConfigEntry, zones: dict[str, Zone]) -> list[Window]:
|
||||||
|
subentries = getattr(entry, "subentries", None) or {}
|
||||||
|
return build_windows(((sub.data, sub.subentry_id) for sub in subentries.values()
|
||||||
|
if sub.subentry_type == SUBENTRY_WINDOW), zones)
|
||||||
|
|
||||||
|
|
||||||
|
def ventilation_features(config: dict[str, Any], windows: list[Window], zone: str) -> list[str]:
|
||||||
|
"""Ventilation terms for a zone with windows; optional ones need their inputs."""
|
||||||
|
if not any(w.zone == zone and w.enabled for w in windows):
|
||||||
|
return []
|
||||||
|
wind = config.get(CONF_VENT_WIND) is True and bool(sources_for(config, "wind"))
|
||||||
|
features = ["vent_natural", "recovery"]
|
||||||
|
if wind:
|
||||||
|
features.append("vent_wind")
|
||||||
|
if sources_for(config, "wind_direction"):
|
||||||
|
features.append("vent_wind_dir")
|
||||||
|
if config.get(CONF_VENT_GUST) is True and sources_for(config, "wind_gust"):
|
||||||
|
features.append("vent_gust")
|
||||||
|
if config.get(CONF_VENT_CROSS) is True and len({w.zone for w in windows if w.enabled}) > 1:
|
||||||
|
features.append("vent_cross")
|
||||||
|
return features
|
||||||
|
|
||||||
|
|
||||||
|
def entry_source_list(entry: ConfigEntry, zones: dict[str, Zone]) -> list[HeatSource]:
|
||||||
|
subentries = getattr(entry, "subentries", None) or {}
|
||||||
|
return build_sources(((sub.data, sub.subentry_id) for sub in subentries.values()
|
||||||
|
if sub.subentry_type == SUBENTRY_HEAT_SOURCE), zones)
|
||||||
|
|
||||||
|
|
||||||
class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
"""The existing generic thermostat stays the only actuator owner."""
|
"""The existing generic thermostat stays the only actuator owner."""
|
||||||
|
|
||||||
@@ -42,8 +88,25 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
update_interval=timedelta(minutes=5))
|
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.zones: dict[str, Zone] = {zone.key: zone for zone in entry_zone_list(entry, self.config)}
|
||||||
|
# Zone keys; models, forecasts and statistics are keyed by these.
|
||||||
|
self.rooms: list[str] = list(self.zones)
|
||||||
|
self.sensors: list[str] = [s for zone in self.zones.values() for s in zone.sensors]
|
||||||
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
||||||
|
self.sources = entry_source_list(entry, self.zones)
|
||||||
|
self._heat_sources = HeatSourceTracker(self.sources)
|
||||||
|
# Extended model per zone: base terms plus electrical heat and adjacent zones.
|
||||||
|
self._extended_features = {
|
||||||
|
key: extended_features(any(s.zone == key and s.enabled for s in self.sources), zone.neighbors)
|
||||||
|
for key, zone in self.zones.items()}
|
||||||
|
self.extended: dict[str, FeatureLearner] = {
|
||||||
|
key: FeatureLearner(features) for key, features in self._extended_features.items() if features}
|
||||||
|
self.windows = entry_window_list(entry, self.zones)
|
||||||
|
self._ventilation = VentilationTracker(self.windows, float(self.config[CONF_RECOVERY_MINUTES]))
|
||||||
|
self._ventilation_features = {key: ventilation_features(self.config, self.windows, key)
|
||||||
|
for key in self.zones}
|
||||||
|
self.ventilation: dict[str, FeatureLearner] = {
|
||||||
|
key: FeatureLearner(features) for key, features in self._ventilation_features.items() if features}
|
||||||
self._retired: dict[str, dict[str, Any]] = {}
|
self._retired: dict[str, dict[str, Any]] = {}
|
||||||
self._report_ages = ReportAgeHistory()
|
self._report_ages = ReportAgeHistory()
|
||||||
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
|
self._evaluator = ForecastEvaluator(LAG_LEARN_MAX_UNCERTAINTY)
|
||||||
@@ -71,6 +134,18 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
|
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
|
||||||
return
|
return
|
||||||
self._evaluator.load(stored.get("evaluation"))
|
self._evaluator.load(stored.get("evaluation"))
|
||||||
|
self._heat_sources = HeatSourceTracker(self.sources, stored.get("heat_sources"))
|
||||||
|
saved_models = stored.get("zone_models") if isinstance(stored.get("zone_models"), dict) else {}
|
||||||
|
for key, features in self._extended_features.items():
|
||||||
|
if features and key in saved_models:
|
||||||
|
# Matched by feature name: a new source or neighbour retrains only its term.
|
||||||
|
self.extended[key] = FeatureLearner.from_dict(saved_models[key], features)
|
||||||
|
self._ventilation = VentilationTracker(self.windows, float(self.config[CONF_RECOVERY_MINUTES]),
|
||||||
|
stored.get("ventilation"))
|
||||||
|
saved_vent = stored.get("ventilation_models") if isinstance(stored.get("ventilation_models"), dict) else {}
|
||||||
|
for key, features in self._ventilation_features.items():
|
||||||
|
if features and key in saved_vent:
|
||||||
|
self.ventilation[key] = FeatureLearner.from_dict(saved_vent[key], features)
|
||||||
# Models carry room identity, so changing the room list is safe. Models of
|
# Models carry room identity, so changing the room list is safe. Models of
|
||||||
# rooms no longer configured are retained, most recently removed last.
|
# rooms no longer configured are retained, most recently removed last.
|
||||||
for key in ("retired_learners", "learners"): # current learners take precedence
|
for key in ("retired_learners", "learners"): # current learners take precedence
|
||||||
@@ -91,8 +166,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._update_counts()
|
self._update_counts()
|
||||||
|
|
||||||
def _update_counts(self) -> None:
|
def _update_counts(self) -> None:
|
||||||
self._on_samples = min(m.on_count for m in self.learners.values())
|
# Without zones (all deleted in the UI) there is nothing to count.
|
||||||
self._off_samples = min(m.off_count for m in self.learners.values())
|
self._on_samples = min((m.on_count for m in self.learners.values()), default=0)
|
||||||
|
self._off_samples = min((m.off_count for m in self.learners.values()), default=0)
|
||||||
|
|
||||||
def _sensor_quality(self, entity_id: str, now: datetime, limit: float) -> dict:
|
def _sensor_quality(self, entity_id: str, now: datetime, limit: float) -> dict:
|
||||||
state = self.hass.states.get(entity_id)
|
state = self.hass.states.get(entity_id)
|
||||||
@@ -166,14 +242,117 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
status["sample_results_since_start"] = dict(self._sample_results)
|
status["sample_results_since_start"] = dict(self._sample_results)
|
||||||
# Least trained room: mean of its three readiness requirements (100 = ready).
|
# Least trained room: mean of its three readiness requirements (100 = ready).
|
||||||
status["learning_progress"] = round(100 * min(
|
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.count / READY_SAMPLES) + min(1.0, m.on_count / READY_ON_SAMPLES)
|
||||||
+ min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3
|
+ min(1.0, m.off_count / READY_OFF_SAMPLES)) / 3
|
||||||
for m in self.learners.values()), 1)
|
for m in self.learners.values()), default=0.0), 1)
|
||||||
|
|
||||||
|
def _extended_diagnostics(self, room: str) -> dict[str, Any]:
|
||||||
|
model = self.extended.get(room)
|
||||||
|
if model is None:
|
||||||
|
return {"model_in_use": "base"}
|
||||||
|
return {"model_in_use": "extended" if ZoneModel(self.learners[room], model).uses_extended else "base",
|
||||||
|
"extended": {"samples": model.count, "on_samples": model.on_count,
|
||||||
|
"off_samples": model.off_count,
|
||||||
|
"mae_5m": round(model.mae, 4) if model.mae is not None else None,
|
||||||
|
"coefficients": model.coefficients()}}
|
||||||
|
|
||||||
|
def _source_diagnostics(self, quality: dict[str, dict]) -> dict[str, Any]:
|
||||||
|
names = {s.key: s.name for s in self.sources}
|
||||||
|
result = {}
|
||||||
|
for source in self.sources:
|
||||||
|
info = self._heat_sources.state.get(source.key, {})
|
||||||
|
|
||||||
|
def watts(kw):
|
||||||
|
return None if kw is None else round(kw * 1000, 1)
|
||||||
|
result[source.name] = {
|
||||||
|
"zone": self.zones[source.zone].name, "sensor": source.sensor,
|
||||||
|
"status": quality[source.key]["status"], "enabled": source.enabled,
|
||||||
|
"power_w": info.get("power_w"), "effective_w": info.get("effective_w"),
|
||||||
|
"included_in": names.get(source.included_in),
|
||||||
|
"baseline_w": info.get("baseline_w"), "baseline_mode": source.baseline_mode,
|
||||||
|
"variable_w": watts(info.get("variable_kw")), "thermal_response_w": watts(info.get("response_kw")),
|
||||||
|
"settled": info.get("settled", False), "heat_fraction": source.fraction,
|
||||||
|
"response_minutes": source.response_minutes,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _step_model(self, temps: dict[str, float], outside: float, horizon: int,
|
||||||
|
weather: dict[str, Any], now: float):
|
||||||
|
"""Multi-zone step for the optimizer, or None when only base models apply."""
|
||||||
|
models = {key: ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
|
||||||
|
for key in temps}
|
||||||
|
steps = horizon // 5
|
||||||
|
ventilation = {key: self._ventilation.forecast(
|
||||||
|
key, steps, float(self.config[CONF_WINDOW_OPEN_MINUTES]), weather, now) for key in temps}
|
||||||
|
venting = any(models[key].ventilation is not None
|
||||||
|
and any(s.get("opening") or s.get("recovery") for s in ventilation[key]) for key in temps)
|
||||||
|
if not venting and not any(model.uses_extended for model in models.values()):
|
||||||
|
return None
|
||||||
|
half_life = float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30))
|
||||||
|
forecast = {key: self._heat_sources.zone_forecast(key, steps, half_life) for key in temps}
|
||||||
|
return compile_step_model(models, list(temps), steps, lambda key, k: {
|
||||||
|
"outside": outside, "internal": forecast[key][k], "ventilation": ventilation[key][k]})
|
||||||
|
|
||||||
|
def _zone_details(self, temps: dict[str, float], outside: float, internal: dict[str, float | None],
|
||||||
|
vent_inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""What drives each zone right now (°C/h per effect) and how sure each layer is."""
|
||||||
|
details = {}
|
||||||
|
lag = self._lag["lag"]
|
||||||
|
for key, temp in temps.items():
|
||||||
|
model = ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
|
||||||
|
inputs = {"outside": outside, "lag": lag, "internal": internal[key], "temps": temps,
|
||||||
|
"ventilation": vent_inputs.get(key) or {}}
|
||||||
|
a, b, c = self.learners[key].theta
|
||||||
|
coefficients = {"base": {"loss": round(a, 6), "heat": round(b, 6), "bias": round(c, 6)}}
|
||||||
|
for layer, learner in (("extended", model.extended), ("ventilation", model.ventilation)):
|
||||||
|
if learner is not None:
|
||||||
|
coefficients[layer] = {name: {k: info[k] for k in ("value", "std", "confidence", "identified")}
|
||||||
|
for name, info in learner.coefficients().items()}
|
||||||
|
details[key] = {"temperature": round(temp, 3),
|
||||||
|
"model_in_use": "extended" if model.uses_extended else "base",
|
||||||
|
"contributions_c_per_h": model.contributions(temp, inputs),
|
||||||
|
"coefficients": coefficients}
|
||||||
|
return details
|
||||||
|
|
||||||
|
def _window_diagnostics(self, now: float, weather: dict[str, Any], temps: dict[str, float | None],
|
||||||
|
vent_inputs: dict[str, Any], internal: dict[str, float | None]) -> dict[str, Any]:
|
||||||
|
windows = {}
|
||||||
|
for window in self.windows:
|
||||||
|
info = self._ventilation.window_state.get(window.key, {})
|
||||||
|
is_open, since = info.get("open"), info.get("since")
|
||||||
|
direction = weather.get("wind_direction")
|
||||||
|
windows[window.name] = {
|
||||||
|
"zone": self.zones[window.zone].name, "sensor": window.sensor, "enabled": window.enabled,
|
||||||
|
"state": "unknown" if is_open is None else "open" if is_open else "closed",
|
||||||
|
"open_minutes": round((now - since) / 60, 1) if is_open and since else None,
|
||||||
|
"azimuth": window.azimuth, "opening_factor": window.opening, "inverted": window.inverted,
|
||||||
|
"relative_wind_direction": info.get("relative_wind_direction"),
|
||||||
|
"wind_exposure": round(facing_cos(direction, window.azimuth), 3) if direction is not None else None,
|
||||||
|
}
|
||||||
|
zones = {}
|
||||||
|
for key, inputs in vent_inputs.items():
|
||||||
|
model = self.ventilation.get(key)
|
||||||
|
if inputs is None or model is None:
|
||||||
|
continue
|
||||||
|
temp, outside = temps.get(key), weather.get("temperature")
|
||||||
|
contributions = {}
|
||||||
|
if temp is not None and outside is not None:
|
||||||
|
terms = ZoneModel(self.learners[key], self.extended.get(key), model).contributions(temp, {
|
||||||
|
"outside": outside, "lag": (self._lag or {}).get("lag", 0.0), "internal": internal[key],
|
||||||
|
"temps": temps, "ventilation": inputs})
|
||||||
|
contributions = {name: terms.get(name, 0.0) for name in ("ventilation", "recovery")}
|
||||||
|
zones[self.zones[key].name] = {
|
||||||
|
"opening": inputs["opening"], "recovery": round(inputs["recovery"], 3),
|
||||||
|
"temperature_before": inputs["t_ref"], "affected": inputs["affected"],
|
||||||
|
"contribution_c_per_h": contributions, "samples": model.count,
|
||||||
|
"coefficients": model.coefficients()}
|
||||||
|
return {"windows": windows, "ventilation": zones,
|
||||||
|
"ventilation_events": list(self._ventilation.events)[-5:]}
|
||||||
|
|
||||||
def _prediction_inputs(self) -> tuple:
|
def _prediction_inputs(self) -> tuple:
|
||||||
"""Values the prediction depends on, used to detect changes during calculation."""
|
"""Values the prediction depends on, used to detect changes during calculation."""
|
||||||
heater = self.config[CONF_HEATER]
|
heater = self.config[CONF_HEATER]
|
||||||
entities = sorted(set(self.rooms) | set(sources_for(self.config, "temperature")) |
|
entities = sorted(set(self.sensors) | set(sources_for(self.config, "temperature")) |
|
||||||
{self.config[CONF_CLIMATE], heater})
|
{self.config[CONF_CLIMATE], heater})
|
||||||
values = []
|
values = []
|
||||||
for entity_id in entities:
|
for entity_id in entities:
|
||||||
@@ -198,27 +377,36 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
for entity_id in sources_for(self.config, metric)
|
for entity_id in sources_for(self.config, metric)
|
||||||
}
|
}
|
||||||
snapshot = {entity: self.hass.states.get(entity)
|
snapshot = {entity: self.hass.states.get(entity)
|
||||||
for entity in set(self.rooms) | weather_entity_ids}
|
for entity in set(self.sensors) | weather_entity_ids}
|
||||||
inputs_before = self._prediction_inputs()
|
inputs_before = self._prediction_inputs()
|
||||||
room_quality = {}
|
sensor_quality = {}
|
||||||
temps = {}
|
readings: dict[str, float | None] = {}
|
||||||
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
|
keep_stale = self.config.get(CONF_ROOM_STALE_KEEP) is True
|
||||||
for room in self.rooms:
|
for sensor in self.sensors:
|
||||||
quality = self._sensor_quality(room, now, self.config[CONF_ROOM_MAX_AGE])
|
quality = self._sensor_quality(sensor, now, self.config[CONF_ROOM_MAX_AGE])
|
||||||
value = None
|
value = None
|
||||||
# Optionally trust sensors that report only on change, but never a
|
# Optionally trust sensors that report only on change, but never a
|
||||||
# sensor silent for a day: it is most likely dead.
|
# sensor silent for a day: it is most likely dead.
|
||||||
if quality["status"] == "valid" or (
|
if quality["status"] == "valid" or (
|
||||||
keep_stale and quality["status"] == "stale"
|
keep_stale and quality["status"] == "stale"
|
||||||
and quality["age_seconds"] <= ROOM_STALE_KEEP_MAX_MINUTES * 60):
|
and quality["age_seconds"] <= ROOM_STALE_KEEP_MAX_MINUTES * 60):
|
||||||
state = snapshot[room]
|
state = snapshot[sensor]
|
||||||
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
|
quality["used"] = value is not None
|
||||||
room_quality[room] = quality
|
sensor_quality[sensor] = quality
|
||||||
temps[room] = value
|
readings[sensor] = value
|
||||||
|
# A zone is usable while at least one of its sensors is; sensor weights
|
||||||
|
# combine them. Which sensors were used matters for learning (see below).
|
||||||
|
temps: dict[str, float | None] = {}
|
||||||
|
compositions: dict[str, frozenset[str]] = {}
|
||||||
|
zone_quality = {}
|
||||||
|
for key, zone in self.zones.items():
|
||||||
|
temps[key], compositions[key] = zone_temperature(zone, readings)
|
||||||
|
zone_quality[key] = {"name": zone.name, "used": temps[key] is not None,
|
||||||
|
"sensors": {s: sensor_quality[s] for s in zone.sensors}}
|
||||||
weather_states = {}
|
weather_states = {}
|
||||||
weather_quality = {}
|
weather_quality = {}
|
||||||
for entity_id in weather_entity_ids:
|
for entity_id in weather_entity_ids:
|
||||||
@@ -240,19 +428,46 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
metric_quality[metric][entity_id] = quality
|
metric_quality[metric][entity_id] = quality
|
||||||
outside = weather["temperature"]
|
outside = weather["temperature"]
|
||||||
status: dict[str, Any] = {
|
status: dict[str, Any] = {
|
||||||
"input_quality": {"rooms": room_quality, "weather": metric_quality},
|
"input_quality": {"zones": zone_quality, "weather": metric_quality},
|
||||||
"status": "waiting_for_sensors", "proposal": None, "prediction_60m": {},
|
"status": "waiting_for_sensors", "proposal": None, "prediction_60m": {},
|
||||||
"temperatures": {r: t for r, t in temps.items() if t is not None},
|
"temperatures": {r: round(t, 3) for r, t in temps.items() if t is not None},
|
||||||
|
"zone_names": {key: zone.name for key, zone in self.zones.items()},
|
||||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||||
"samples": min(learner.count for learner in self.learners.values()),
|
"samples": min((learner.count for learner in self.learners.values()), default=0),
|
||||||
"mode": climate.state if climate else "unavailable",
|
"mode": climate.state if climate else "unavailable",
|
||||||
"weather": {k: v for k, v in weather.items() if v is not None},
|
"weather": {k: v for k, v in weather.items() if v is not None},
|
||||||
"weather_active_sources": {k: v for k, v in active_sources.items() if v},
|
"weather_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),
|
"retired_zone_models": sorted(self._retired),
|
||||||
"excluded_rooms": [room for room in self.rooms if temps[room] is None],
|
"excluded_zones": [room for room in self.rooms if temps[room] is None],
|
||||||
}
|
}
|
||||||
|
# Electrical heat sources: processed every poll so their responses stay continuous.
|
||||||
|
source_readings: dict[str, float | None] = {}
|
||||||
|
source_quality = {}
|
||||||
|
for source in self.sources:
|
||||||
|
quality = self._sensor_quality(source.sensor, now, self.config[CONF_ROOM_MAX_AGE])
|
||||||
|
watts = None
|
||||||
|
if quality["status"] == "valid":
|
||||||
|
state = self.hass.states.get(source.sensor)
|
||||||
|
watts = normalize_power(state.state, state.attributes.get("unit_of_measurement"))
|
||||||
|
if watts is None:
|
||||||
|
quality["status"] = "invalid_value_or_unit"
|
||||||
|
source_readings[source.key] = watts
|
||||||
|
source_quality[source.key] = quality
|
||||||
|
self._heat_sources.update(now.timestamp(), source_readings)
|
||||||
|
internal = {key: self._heat_sources.zone_input(key) for key in self.zones}
|
||||||
|
if self.sources:
|
||||||
|
status["heat_sources"] = self._source_diagnostics(source_quality)
|
||||||
|
# Windows: tracked every poll; unknown states stay unknown, never "closed".
|
||||||
|
window_states = {}
|
||||||
|
for window in self.windows:
|
||||||
|
state = self.hass.states.get(window.sensor)
|
||||||
|
window_states[window.key] = ((state.state, state.last_changed.timestamp()) if state else (None, None))
|
||||||
|
self._ventilation.update(now.timestamp(), window_states, temps, weather)
|
||||||
|
vent_inputs = {key: self._ventilation.zone_inputs(key, weather) for key in self.zones}
|
||||||
|
if self.windows:
|
||||||
|
status.update(self._window_diagnostics(now.timestamp(), weather, temps, vent_inputs, internal))
|
||||||
heater_valid = heater is not None and heater.state in ("on", "off")
|
heater_valid = heater is not None and heater.state in ("on", "off")
|
||||||
heater_on = heater_valid and heater.state == "on"
|
heater_on = heater_valid and heater.state == "on"
|
||||||
steady = False
|
steady = False
|
||||||
@@ -272,13 +487,20 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._evaluator.reset_window()
|
self._evaluator.reset_window()
|
||||||
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
|
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
|
||||||
self._count_result(status, "waiting_for_inputs")
|
self._count_result(status, "waiting_for_inputs")
|
||||||
|
if not self.zones:
|
||||||
|
status["status"] = "no_zones"
|
||||||
return status
|
return status
|
||||||
|
|
||||||
# A room learns only across a poll pair where it was usable at both ends.
|
# A zone learns only across a poll pair where it was usable at both ends with
|
||||||
pairs = [room for room in vals if self._previous and room in self._previous["temps"]]
|
# the same sensors: a sensor dropping out shifts the mean by its offset.
|
||||||
|
pairs = [room for room in vals if self._previous and room in self._previous["temps"]
|
||||||
|
and self._previous["compositions"].get(room) == compositions[room]]
|
||||||
learned = False
|
learned = False
|
||||||
if not pairs:
|
if not pairs:
|
||||||
status["sample_result"] = "first_sample"
|
changed = self._previous and any(
|
||||||
|
room in self._previous["temps"] and self._previous["compositions"].get(room)
|
||||||
|
!= compositions[room] for room in vals)
|
||||||
|
status["sample_result"] = "sensors_changed" if changed else "first_sample"
|
||||||
else:
|
else:
|
||||||
dt = (now - self._previous["time"]).total_seconds()
|
dt = (now - self._previous["time"]).total_seconds()
|
||||||
# Do not fit across a relay change, a provider/fallback switch or an
|
# Do not fit across a relay change, a provider/fallback switch or an
|
||||||
@@ -292,24 +514,53 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
elif self._lag["uncertainty"] > LAG_LEARN_MAX_UNCERTAINTY:
|
elif self._lag["uncertainty"] > LAG_LEARN_MAX_UNCERTAINTY:
|
||||||
status["sample_result"] = "lag_uncertain"
|
status["sample_result"] = "lag_uncertain"
|
||||||
else:
|
else:
|
||||||
|
# Window open, unknown or recovering: not envelope loss; keep it out of
|
||||||
|
# the closed-window models and give it to the ventilation model.
|
||||||
|
vented = [room for room in pairs if (vent_inputs.get(room) or {}).get("affected")]
|
||||||
|
clean = [room for room in pairs if room not in vented]
|
||||||
accepted = [
|
accepted = [
|
||||||
self.learners[room].learn(
|
self.learners[room].learn(
|
||||||
self._previous["temps"][room], vals[room],
|
self._previous["temps"][room], vals[room],
|
||||||
self._previous["outside"], self._lag["lag"], dt, heater_on)
|
self._previous["outside"], self._lag["lag"], dt, heater_on)
|
||||||
for room in pairs
|
for room in clean
|
||||||
]
|
]
|
||||||
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
|
before = {"outside": self._previous["outside"], "lag": self._lag["lag"],
|
||||||
self._update_counts()
|
"temps": self._previous["temps"]}
|
||||||
|
for room in pairs:
|
||||||
|
start = self._previous["temps"][room]
|
||||||
|
change = (vals[room] - start) * 300 / dt
|
||||||
|
inputs = {**before, "internal": internal[room]}
|
||||||
|
if room in clean:
|
||||||
|
# Extended models learn the same interval when all their inputs are known.
|
||||||
|
model = self.extended.get(room)
|
||||||
|
x = model and feature_vector(model.features, start, inputs)
|
||||||
|
if x:
|
||||||
|
model.learn(x, change, heater_on)
|
||||||
|
continue
|
||||||
|
model = self.ventilation.get(room)
|
||||||
|
x = model and ventilation_vector(model.features, start, before["outside"], vent_inputs[room])
|
||||||
|
if x:
|
||||||
|
main = ZoneModel(self.learners[room], self.extended.get(room)).main_delta(start, inputs)
|
||||||
|
model.learn(x, change - main, heater_on)
|
||||||
|
if clean:
|
||||||
|
status["sample_result"] = "learned" if all(accepted) else "partly_rejected"
|
||||||
|
self._update_counts()
|
||||||
|
else:
|
||||||
|
status["sample_result"] = "ventilation"
|
||||||
learned = True
|
learned = True
|
||||||
|
|
||||||
self._count_result(status, status["sample_result"])
|
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"], "compositions": compositions,
|
||||||
}
|
}
|
||||||
# Parameters after this poll's learning: forecasts from here use only data so far.
|
# 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"],
|
self._evaluator.observe(
|
||||||
{room: tuple(self.learners[room].theta) for room in vals})
|
now, vals, outside, self._lag["lag"], self._lag["uncertainty"],
|
||||||
|
{room: tuple(self.learners[room].theta) for room in vals},
|
||||||
|
models={room: model_snapshot(ZoneModel(self.learners[room], self.extended.get(room),
|
||||||
|
self.ventilation.get(room))) for room in vals},
|
||||||
|
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals})
|
||||||
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
|
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
|
||||||
if learned:
|
if learned:
|
||||||
# Persist at each sample so restart does not erase hours of training.
|
# Persist at each sample so restart does not erase hours of training.
|
||||||
@@ -318,20 +569,18 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
|
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
|
||||||
# Optional keys; older 0.2.x readers ignore them and keep working.
|
# Optional keys; older 0.2.x readers ignore them and keep working.
|
||||||
"retired_learners": self._retired,
|
"retired_learners": self._retired,
|
||||||
|
"zone_models": {key: model.as_dict() for key, model in self.extended.items()},
|
||||||
|
"heat_sources": self._heat_sources.as_dict(),
|
||||||
|
"ventilation_models": {key: model.as_dict() for key, model in self.ventilation.items()},
|
||||||
|
"ventilation": self._ventilation.as_dict(),
|
||||||
"evaluation": self._evaluator.as_dict(),
|
"evaluation": self._evaluator.as_dict(),
|
||||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||||
"heater_entity": self.config[CONF_HEATER],
|
"heater_entity": self.config[CONF_HEATER],
|
||||||
"outside_source": active_sources["temperature"],
|
"outside_source": active_sources["temperature"],
|
||||||
})
|
})
|
||||||
weights = self.config.get(CONF_WEIGHTS, {})
|
# Comfort weights are validated when zones are parsed (invalid -> 1.0).
|
||||||
try:
|
weights = {r: self.zones[r].comfort_weight for r in self.rooms}
|
||||||
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms}
|
# Left-out zones take their weight with them; the rest are re-normalized.
|
||||||
if any(not isfinite(w) or w <= 0 for w in weights.values()) or not isfinite(sum(weights.values())):
|
|
||||||
raise ValueError
|
|
||||||
except (AttributeError, TypeError, ValueError, OverflowError):
|
|
||||||
status["status"] = "invalid_weights"
|
|
||||||
return status
|
|
||||||
# Left-out rooms take their weight with them; the rest are re-normalized.
|
|
||||||
used_weights = {room: weights[room] for room in vals}
|
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())
|
weighted_temp = sum(used_weights[r] * vals[r] for r in vals) / sum(used_weights.values())
|
||||||
status.update({
|
status.update({
|
||||||
@@ -341,18 +590,21 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
"reference_heat": heater_on,
|
"reference_heat": heater_on,
|
||||||
"on_samples": self._on_samples,
|
"on_samples": self._on_samples,
|
||||||
"off_samples": self._off_samples,
|
"off_samples": self._off_samples,
|
||||||
"samples": min(learner.count for learner in self.learners.values()),
|
"samples": min((learner.count for learner in self.learners.values()), default=0),
|
||||||
"models": {
|
"models": {
|
||||||
room: {"loss": round(model.theta[0], 6),
|
room: {"name": self.zones[room].name,
|
||||||
|
"loss": round(model.theta[0], 6),
|
||||||
"heat": round(model.theta[1], 6),
|
"heat": round(model.theta[1], 6),
|
||||||
"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,
|
"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,
|
||||||
|
**self._extended_diagnostics(room)}
|
||||||
for room, model in self.learners.items()
|
for room, model in self.learners.items()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
status["zone_details"] = self._zone_details(vals, outside, internal, vent_inputs)
|
||||||
if climate.state != "heat":
|
if climate.state != "heat":
|
||||||
status["status"] = "paused"
|
status["status"] = "paused"
|
||||||
return status
|
return status
|
||||||
@@ -388,6 +640,9 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
switching_cost=float(self.config[CONF_SWITCH_COST]),
|
switching_cost=float(self.config[CONF_SWITCH_COST]),
|
||||||
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]),
|
||||||
|
target_offsets={room: self.zones[room].target_offset for room in vals},
|
||||||
|
step_model=self._step_model(vals, outside, int(self.config[CONF_HORIZON]),
|
||||||
|
weather, now.timestamp()),
|
||||||
)
|
)
|
||||||
# Discard a result if a value used by the prediction changed while
|
# Discard a result if a value used by the prediction changed while
|
||||||
# saving or while the worker calculated the hypothetical plan. Optional
|
# saving or while the worker calculated the hypothetical plan. Optional
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ value, and the radiator lag that actually followed from the relay's real ON/OFF
|
|||||||
history. The replayed temperature is compared with the reading now.
|
history. The replayed temperature is compared with the reading now.
|
||||||
|
|
||||||
This scores the thermal model, not the MPC's proposed plan: Generic Thermostat
|
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
|
controls the relay, so the plan is never executed. Two baselines are scored on
|
||||||
change) is scored alongside as the baseline to beat.
|
the same spans: persistence (no change) and the base 3-parameter model. The full
|
||||||
|
model replays all zones together with the electrical heat, neighbour exchange and
|
||||||
|
window openings that actually happened, so its improvement over the base model
|
||||||
|
shows whether those features earn their keep.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -21,6 +24,8 @@ from math import isfinite
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .physics import Learner
|
from .physics import Learner
|
||||||
|
from .windows import ventilation_vector
|
||||||
|
from .zone_model import feature_vector
|
||||||
|
|
||||||
HORIZONS = (15, 30, 60, 120) # minutes
|
HORIZONS = (15, 30, 60, 120) # minutes
|
||||||
WINDOW_DAYS = 7
|
WINDOW_DAYS = 7
|
||||||
@@ -37,13 +42,60 @@ def replay(temp: float, outside: float, theta: tuple[float, float, float],
|
|||||||
return temp
|
return temp
|
||||||
|
|
||||||
|
|
||||||
|
def _dot(theta: tuple[float, ...], x: list[float]) -> float:
|
||||||
|
return sum(t * v for t, v in zip(theta, x))
|
||||||
|
|
||||||
|
|
||||||
|
def model_snapshot(model: Any) -> dict[str, Any]:
|
||||||
|
"""Frozen parameters of a ZoneModel, so later learning never leaks into a replay."""
|
||||||
|
def layer(learner):
|
||||||
|
return (list(learner.features), tuple(learner.theta)) if learner is not None else None
|
||||||
|
return {"base": tuple(model.base.theta), "uses_extended": model.uses_extended,
|
||||||
|
"extended": layer(model.extended), "ventilation": layer(model.ventilation)}
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_delta(snapshot: dict[str, Any], temp: float, inputs: dict[str, Any]) -> float:
|
||||||
|
"""5-minute change predicted by a model snapshot (see zone_model.ZoneModel.delta)."""
|
||||||
|
main = None
|
||||||
|
if snapshot["uses_extended"] and snapshot["extended"]:
|
||||||
|
features, theta = snapshot["extended"]
|
||||||
|
x = feature_vector(features, temp, inputs)
|
||||||
|
main = _dot(theta, x) if x is not None else None
|
||||||
|
if main is None:
|
||||||
|
a, b, c = snapshot["base"]
|
||||||
|
main = a * (inputs["outside"] - temp) + b * inputs["lag"] + c
|
||||||
|
ventilation = inputs.get("ventilation")
|
||||||
|
if snapshot["ventilation"] and ventilation:
|
||||||
|
features, theta = snapshot["ventilation"]
|
||||||
|
x = ventilation_vector(features, temp, inputs["outside"], ventilation)
|
||||||
|
if x is not None:
|
||||||
|
main += _dot(theta, x)
|
||||||
|
return main
|
||||||
|
|
||||||
|
|
||||||
|
def replay_zones(origin: dict[str, Any], path: list[dict[str, Any]]) -> dict[str, float]:
|
||||||
|
"""All zones advanced together with the origin's models and the actual inputs."""
|
||||||
|
state = dict(origin["temps"])
|
||||||
|
for before, poll in zip(path, path[1:]):
|
||||||
|
seconds = (poll["time"] - before["time"]).total_seconds()
|
||||||
|
new = {}
|
||||||
|
for zone, temp in state.items():
|
||||||
|
actual = poll["inputs"].get(zone, {})
|
||||||
|
inputs = {"outside": origin["outside"], "lag": poll["lag"], "temps": state,
|
||||||
|
"internal": actual.get("internal"), "ventilation": actual.get("ventilation")}
|
||||||
|
new[zone] = temp + snapshot_delta(origin["models"][zone], temp, inputs) * seconds / STEP_SECONDS
|
||||||
|
state = new
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
class ForecastEvaluator:
|
class ForecastEvaluator:
|
||||||
"""Score replayed forecasts per room and horizon over the last WINDOW_DAYS days."""
|
"""Score replayed forecasts per room and horizon over the last WINDOW_DAYS days."""
|
||||||
|
|
||||||
def __init__(self, max_lag_uncertainty: float) -> None:
|
def __init__(self, max_lag_uncertainty: float) -> None:
|
||||||
self._max_lag_uncertainty = max_lag_uncertainty
|
self._max_lag_uncertainty = max_lag_uncertainty
|
||||||
self._history: deque[dict[str, Any]] = deque(maxlen=HORIZONS[-1] * 60 // STEP_SECONDS + 1)
|
self._history: deque[dict[str, Any]] = deque(maxlen=HORIZONS[-1] * 60 // STEP_SECONDS + 1)
|
||||||
# UTC day -> horizon -> room -> [count, sum error, sum |error|, sum |persistence error|]
|
# UTC day -> horizon -> room ->
|
||||||
|
# [count, sum error, sum |error|, sum |persistence error|, sum |base model error|]
|
||||||
self._days: dict[str, dict[str, dict[str, list[float]]]] = {}
|
self._days: dict[str, dict[str, dict[str, list[float]]]] = {}
|
||||||
|
|
||||||
def reset_window(self) -> None:
|
def reset_window(self) -> None:
|
||||||
@@ -51,12 +103,23 @@ class ForecastEvaluator:
|
|||||||
self._history.clear()
|
self._history.clear()
|
||||||
|
|
||||||
def observe(self, time: datetime, temps: dict[str, float], outside: float, lag: float,
|
def observe(self, time: datetime, temps: dict[str, float], outside: float, lag: float,
|
||||||
lag_uncertainty: float, theta: dict[str, tuple[float, float, float]]) -> None:
|
lag_uncertainty: float, theta: dict[str, tuple[float, float, float]],
|
||||||
"""Record a poll, then score every forecast that has reached its horizon."""
|
models: dict[str, dict[str, Any]] | None = None,
|
||||||
|
inputs: dict[str, dict[str, Any]] | None = None) -> None:
|
||||||
|
"""Record a poll, then score every forecast that has reached its horizon.
|
||||||
|
|
||||||
|
`models`: model_snapshot per zone (base-only when omitted); `inputs`: this
|
||||||
|
poll's actual electrical heat and ventilation inputs per zone.
|
||||||
|
"""
|
||||||
if self._history and not 270 <= (time - self._history[-1]["time"]).total_seconds() <= 330:
|
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.clear() # irregular poll spacing: replays would be unreliable
|
||||||
|
models = models or {}
|
||||||
|
snapshots = {zone: models.get(zone) or {"base": tuple(theta[zone]), "uses_extended": False,
|
||||||
|
"extended": None, "ventilation": None}
|
||||||
|
for zone in temps if zone in theta}
|
||||||
self._history.append({"time": time, "temps": dict(temps), "outside": outside, "lag": lag,
|
self._history.append({"time": time, "temps": dict(temps), "outside": outside, "lag": lag,
|
||||||
"lag_uncertainty": lag_uncertainty, "theta": dict(theta)})
|
"lag_uncertainty": lag_uncertainty, "theta": dict(theta),
|
||||||
|
"models": snapshots, "inputs": dict(inputs or {})})
|
||||||
self._score()
|
self._score()
|
||||||
self._prune(time)
|
self._prune(time)
|
||||||
|
|
||||||
@@ -75,17 +138,20 @@ class ForecastEvaluator:
|
|||||||
origin = path[0]
|
origin = path[0]
|
||||||
inputs = [(poll["lag"], (poll["time"] - before["time"]).total_seconds())
|
inputs = [(poll["lag"], (poll["time"] - before["time"]).total_seconds())
|
||||||
for before, poll in zip(path, path[1:])]
|
for before, poll in zip(path, path[1:])]
|
||||||
|
full = replay_zones(origin, path)
|
||||||
for room, actual in latest["temps"].items():
|
for room, actual in latest["temps"].items():
|
||||||
start, theta = origin["temps"].get(room), origin["theta"].get(room)
|
start, theta = origin["temps"].get(room), origin["theta"].get(room)
|
||||||
if start is None or theta is None:
|
if start is None or theta is None:
|
||||||
continue
|
continue
|
||||||
error = replay(start, origin["outside"], theta, inputs) - actual
|
error = full[room] - actual
|
||||||
|
base_error = replay(start, origin["outside"], theta, inputs) - actual
|
||||||
bucket = self._days.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(
|
bucket = self._days.setdefault(day, {}).setdefault(str(horizon), {}).setdefault(
|
||||||
room, [0, 0.0, 0.0, 0.0])
|
room, [0, 0.0, 0.0, 0.0, 0.0])
|
||||||
bucket[0] += 1
|
bucket[0] += 1
|
||||||
bucket[1] += error
|
bucket[1] += error
|
||||||
bucket[2] += abs(error)
|
bucket[2] += abs(error)
|
||||||
bucket[3] += abs(start - actual)
|
bucket[3] += abs(start - actual)
|
||||||
|
bucket[4] += abs(base_error)
|
||||||
|
|
||||||
def _prune(self, time: datetime) -> None:
|
def _prune(self, time: datetime) -> None:
|
||||||
oldest = (time.date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
|
oldest = (time.date() - timedelta(days=WINDOW_DAYS - 1)).isoformat()
|
||||||
@@ -100,10 +166,10 @@ class ForecastEvaluator:
|
|||||||
for day in self._days.values():
|
for day in self._days.values():
|
||||||
for room, values in day.get(str(horizon), {}).items():
|
for room, values in day.get(str(horizon), {}).items():
|
||||||
if room in rooms:
|
if room in rooms:
|
||||||
totals = per_room.setdefault(room, [0, 0.0, 0.0, 0.0])
|
totals = per_room.setdefault(room, [0, 0.0, 0.0, 0.0, 0.0])
|
||||||
for i, value in enumerate(values):
|
for i, value in enumerate(values):
|
||||||
totals[i] += value
|
totals[i] += value
|
||||||
overall = [sum(values[i] for values in per_room.values()) for i in range(4)]
|
overall = [sum(values[i] for values in per_room.values()) for i in range(5)]
|
||||||
result[horizon] = {**_stats(overall),
|
result[horizon] = {**_stats(overall),
|
||||||
"rooms": {room: _stats(values) for room, values in per_room.items()}}
|
"rooms": {room: _stats(values) for room, values in per_room.items()}}
|
||||||
return result
|
return result
|
||||||
@@ -134,23 +200,26 @@ class ForecastEvaluator:
|
|||||||
continue
|
continue
|
||||||
for room, values in by_room.items():
|
for room, values in by_room.items():
|
||||||
if _valid_bucket(room, values):
|
if _valid_bucket(room, values):
|
||||||
|
# 0.3 buckets had no base column: the model then was the base model.
|
||||||
|
base = values[4] if len(values) == 5 else values[2]
|
||||||
self._days.setdefault(day, {}).setdefault(horizon, {})[room] = [
|
self._days.setdefault(day, {}).setdefault(horizon, {})[room] = [
|
||||||
values[0], float(values[1]), float(values[2]), float(values[3])]
|
values[0], float(values[1]), float(values[2]), float(values[3]), float(base)]
|
||||||
|
|
||||||
|
|
||||||
def _valid_bucket(room: Any, values: Any) -> bool:
|
def _valid_bucket(room: Any, values: Any) -> bool:
|
||||||
if not isinstance(room, str) or not isinstance(values, list) or len(values) != 4:
|
if not isinstance(room, str) or not isinstance(values, list) or len(values) not in (4, 5):
|
||||||
return False
|
return False
|
||||||
count, *sums = values
|
count, *sums = values
|
||||||
return (type(count) is int and count >= 0
|
return (type(count) is int and count >= 0
|
||||||
and all(type(v) in (int, float) and isfinite(v) for v in sums)
|
and all(type(v) in (int, float) and isfinite(v) for v in sums)
|
||||||
and sums[1] >= 0 and sums[2] >= 0)
|
and all(v >= 0 for v in sums[1:]))
|
||||||
|
|
||||||
|
|
||||||
def _stats(values: list[float]) -> dict[str, Any]:
|
def _stats(values: list[float]) -> dict[str, Any]:
|
||||||
count, error, absolute, persistence = values
|
count, error, absolute, persistence, base = values
|
||||||
if not count:
|
if not count:
|
||||||
return {"samples": 0, "mae": None, "bias": None, "persistence_mae": None, "skill": None}
|
return {"samples": 0, "mae": None, "bias": None, "persistence_mae": None, "skill": None,
|
||||||
|
"base_model_mae": None, "improvement_vs_base": None}
|
||||||
return {
|
return {
|
||||||
"samples": int(count),
|
"samples": int(count),
|
||||||
"mae": round(absolute / count, 3),
|
"mae": round(absolute / count, 3),
|
||||||
@@ -158,4 +227,8 @@ def _stats(values: list[float]) -> dict[str, Any]:
|
|||||||
"persistence_mae": round(persistence / count, 3),
|
"persistence_mae": round(persistence / count, 3),
|
||||||
# Share of the persistence error removed by the model (1 = perfect, < 0 = worse).
|
# Share of the persistence error removed by the model (1 = perfect, < 0 = worse).
|
||||||
"skill": round(1 - absolute / persistence, 3) if persistence > 0 else None,
|
"skill": round(1 - absolute / persistence, 3) if persistence > 0 else None,
|
||||||
|
"base_model_mae": round(base / count, 3),
|
||||||
|
# Share of the base model's error removed by electrical heat, neighbours and
|
||||||
|
# ventilation (0 = no gain, < 0 = the extra features make it worse).
|
||||||
|
"improvement_vs_base": round(1 - absolute / base, 3) if base > 0 else None,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""Electrical internal heat gains (UPS, PCs, servers) per zone.
|
||||||
|
|
||||||
|
Pure Python; no Home Assistant imports.
|
||||||
|
|
||||||
|
Most electrical energy ends up as heat in the room. What matters to the thermal
|
||||||
|
model is the *variation* around a persistent baseline: the constant part (for
|
||||||
|
example a UPS drawing 200 W all day) is already absorbed by the model's bias
|
||||||
|
term, so feeding it again would count it twice. Each source therefore supplies
|
||||||
|
|
||||||
|
variable heat = fraction x (effective power - baseline)
|
||||||
|
|
||||||
|
where the baseline is a robust low percentile of the last 24 hours (or a value
|
||||||
|
set by the user), and "effective power" removes separately measured devices
|
||||||
|
that are already included in a parent measurement (a PC behind a UPS). The
|
||||||
|
variable heat reaches the room through a first-order response, because the air,
|
||||||
|
furniture and equipment take time to warm.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from math import exp, isfinite
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
SUBENTRY_HEAT_SOURCE = "heat_source"
|
||||||
|
MAX_SOURCES = 16
|
||||||
|
MAX_POWER_W = 20000.0
|
||||||
|
BASELINE_WINDOW = 288 # 24 h of 5-minute samples
|
||||||
|
BASELINE_PERCENTILE = 0.2
|
||||||
|
BASELINE_MIN_SAMPLES = 12 # 1 h before the automatic baseline is trusted
|
||||||
|
RESPONSE_MINUTES_RANGE = (1.0, 180.0)
|
||||||
|
DEFAULT_RESPONSE_MINUTES = 15.0
|
||||||
|
|
||||||
|
SOURCE_KEY = "key"
|
||||||
|
SOURCE_NAME = "name"
|
||||||
|
SOURCE_SENSOR = "sensor"
|
||||||
|
SOURCE_ZONE = "zone"
|
||||||
|
SOURCE_FRACTION = "heat_fraction"
|
||||||
|
SOURCE_BASELINE_MODE = "baseline_mode"
|
||||||
|
SOURCE_BASELINE_W = "baseline_w"
|
||||||
|
SOURCE_INCLUDED_IN = "included_in"
|
||||||
|
SOURCE_RESPONSE = "response_minutes"
|
||||||
|
SOURCE_ENABLED = "enabled"
|
||||||
|
BASELINE_AUTO = "auto"
|
||||||
|
BASELINE_MANUAL = "manual"
|
||||||
|
|
||||||
|
# Active power only: VA (apparent power) and % (UPS load) are not heat.
|
||||||
|
POWER_UNITS = {"w": 1.0, "kw": 1000.0, "mw": 0.001}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_power(raw: Any, unit: Any) -> float | None:
|
||||||
|
"""Active power in W, or None when the value or unit is unusable."""
|
||||||
|
if not isinstance(unit, str) or unit.strip().lower() not in POWER_UNITS:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = float(raw) * POWER_UNITS[unit.strip().lower()]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return value if isfinite(value) and 0 <= value <= MAX_POWER_W else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HeatSource:
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
sensor: str
|
||||||
|
zone: str
|
||||||
|
fraction: float = 1.0
|
||||||
|
baseline_mode: str = BASELINE_AUTO
|
||||||
|
baseline_w: float = 0.0
|
||||||
|
included_in: str | None = None
|
||||||
|
response_minutes: float = DEFAULT_RESPONSE_MINUTES
|
||||||
|
enabled: bool = True
|
||||||
|
subentry_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any, default: float, lo: float, hi: float) -> float:
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return number if isfinite(number) and lo <= number <= hi else default
|
||||||
|
|
||||||
|
|
||||||
|
def source_from_data(data: Mapping[str, Any], subentry_id: str | None = None) -> HeatSource | None:
|
||||||
|
key = data.get(SOURCE_KEY) or subentry_id
|
||||||
|
sensor, zone = data.get(SOURCE_SENSOR), data.get(SOURCE_ZONE)
|
||||||
|
if not (isinstance(key, str) and isinstance(sensor, str) and sensor.startswith("sensor.")
|
||||||
|
and isinstance(zone, str)):
|
||||||
|
return None
|
||||||
|
name = data.get(SOURCE_NAME)
|
||||||
|
parent = data.get(SOURCE_INCLUDED_IN)
|
||||||
|
mode = data.get(SOURCE_BASELINE_MODE)
|
||||||
|
return HeatSource(
|
||||||
|
key=key, name=name if isinstance(name, str) and name.strip() else key, sensor=sensor, zone=zone,
|
||||||
|
fraction=_number(data.get(SOURCE_FRACTION), 1.0, 0.0, 1.0),
|
||||||
|
baseline_mode=mode if mode in (BASELINE_AUTO, BASELINE_MANUAL) else BASELINE_AUTO,
|
||||||
|
baseline_w=_number(data.get(SOURCE_BASELINE_W), 0.0, 0.0, MAX_POWER_W),
|
||||||
|
included_in=parent if isinstance(parent, str) and parent and parent != key else None,
|
||||||
|
response_minutes=_number(data.get(SOURCE_RESPONSE), DEFAULT_RESPONSE_MINUTES, *RESPONSE_MINUTES_RANGE),
|
||||||
|
enabled=data.get(SOURCE_ENABLED, True) is not False,
|
||||||
|
subentry_id=subentry_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_sources(items: Iterable[tuple[Mapping[str, Any], str | None]], zone_keys: Iterable[str]) -> list[HeatSource]:
|
||||||
|
"""Valid sources: unique keys and sensors, existing zones, one level of inclusion."""
|
||||||
|
zone_keys = set(zone_keys)
|
||||||
|
sources: list[HeatSource] = []
|
||||||
|
for data, subentry_id in items:
|
||||||
|
source = source_from_data(data, subentry_id)
|
||||||
|
if (source is None or source.zone not in zone_keys or len(sources) == MAX_SOURCES
|
||||||
|
or any(s.key == source.key or s.sensor == source.sensor for s in sources)):
|
||||||
|
continue
|
||||||
|
sources.append(source)
|
||||||
|
keys = {s.key: s for s in sources}
|
||||||
|
# A parent must exist and must not itself be included in another source.
|
||||||
|
return [s if s.included_in in keys and keys[s.included_in].included_in is None
|
||||||
|
else HeatSource(**{**s.__dict__, "included_in": None}) if s.included_in else s
|
||||||
|
for s in sources]
|
||||||
|
|
||||||
|
|
||||||
|
def effective_power(sources: list[HeatSource], readings: Mapping[str, float | None]) -> dict[str, float | None]:
|
||||||
|
"""Power of each enabled source after removing included child sources (W).
|
||||||
|
|
||||||
|
`readings` maps source key -> measured W (None if unavailable). A parent is
|
||||||
|
unknown when any enabled child it contains is unknown, since the split is
|
||||||
|
then impossible. Values below 0 (timing differences) are clamped.
|
||||||
|
"""
|
||||||
|
result: dict[str, float | None] = {}
|
||||||
|
for source in sources:
|
||||||
|
if not source.enabled:
|
||||||
|
continue
|
||||||
|
power = readings.get(source.key)
|
||||||
|
children = [s for s in sources if s.included_in == source.key and s.enabled]
|
||||||
|
if power is None or any(readings.get(c.key) is None for c in children):
|
||||||
|
result[source.key] = None
|
||||||
|
continue
|
||||||
|
result[source.key] = max(0.0, power - sum(readings[c.key] for c in children))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class BaselineEstimator:
|
||||||
|
"""Robust persistent load: low percentile of the last 24 hours of 5-minute samples."""
|
||||||
|
|
||||||
|
def __init__(self, samples: Iterable[float] = ()) -> None:
|
||||||
|
self.samples: deque[float] = deque(
|
||||||
|
(float(v) for v in samples if isinstance(v, (int, float)) and isfinite(v) and 0 <= v <= MAX_POWER_W),
|
||||||
|
maxlen=BASELINE_WINDOW)
|
||||||
|
|
||||||
|
def add(self, watts: float) -> None:
|
||||||
|
self.samples.append(watts)
|
||||||
|
|
||||||
|
def value(self) -> float | None:
|
||||||
|
if len(self.samples) < BASELINE_MIN_SAMPLES:
|
||||||
|
return None
|
||||||
|
ordered = sorted(self.samples)
|
||||||
|
return ordered[int(BASELINE_PERCENTILE * (len(ordered) - 1))]
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseFilter:
|
||||||
|
"""First-order thermal response of a heat input (kW), like the radiator lag."""
|
||||||
|
|
||||||
|
def __init__(self, minutes: float) -> None:
|
||||||
|
self.tau = minutes * 60.0
|
||||||
|
self.value: float | None = None
|
||||||
|
|
||||||
|
def advance(self, held_kw: float, seconds: float) -> float:
|
||||||
|
"""Advance over an interval during which `held_kw` was dissipated."""
|
||||||
|
if self.value is None:
|
||||||
|
self.value = held_kw # assume the current load has been steady
|
||||||
|
else:
|
||||||
|
self.value += (1.0 - exp(-max(0.0, seconds) / self.tau)) * (held_kw - self.value)
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.value = None
|
||||||
|
|
||||||
|
|
||||||
|
def forecast_internal(current_kw: float, steps: int, half_life_minutes: float | None,
|
||||||
|
step_minutes: float = 5.0) -> list[float]:
|
||||||
|
"""Variable heat assumed over a forecast: decays to baseline (0) with a half-life.
|
||||||
|
|
||||||
|
A gaming PC drawing 600 W now is not assumed to keep doing so for two hours.
|
||||||
|
`half_life_minutes=None` keeps the current value (persistence).
|
||||||
|
"""
|
||||||
|
if half_life_minutes is None:
|
||||||
|
return [current_kw] * steps
|
||||||
|
if half_life_minutes <= 0:
|
||||||
|
return [0.0] * steps
|
||||||
|
factor = 0.5 ** (step_minutes / half_life_minutes)
|
||||||
|
return [current_kw * factor ** (k + 1) for k in range(steps)]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_source(data: Mapping[str, Any], others: Iterable[HeatSource]) -> str | None:
|
||||||
|
"""Translation key of the first problem with a heat-source form, or None."""
|
||||||
|
others = list(others)
|
||||||
|
if not str(data.get(SOURCE_NAME, "")).strip():
|
||||||
|
return "source_name_required"
|
||||||
|
if any(s.sensor == data.get(SOURCE_SENSOR) for s in others):
|
||||||
|
return "sensor_in_other_source"
|
||||||
|
parent = data.get(SOURCE_INCLUDED_IN)
|
||||||
|
if parent:
|
||||||
|
parents = {s.key: s for s in others}
|
||||||
|
if parent not in parents or parents[parent].included_in:
|
||||||
|
return "invalid_parent_source"
|
||||||
|
key = data.get(SOURCE_KEY)
|
||||||
|
if key and any(s.included_in == key for s in others):
|
||||||
|
return "source_has_children"
|
||||||
|
if data.get(SOURCE_BASELINE_MODE) == BASELINE_MANUAL and data.get(SOURCE_BASELINE_W) in (None, ""):
|
||||||
|
return "baseline_required"
|
||||||
|
if len(others) >= MAX_SOURCES:
|
||||||
|
return "too_many_sources"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def forecast_response(response_kw: float, variable_kw: float, steps: int, tau_minutes: float,
|
||||||
|
half_life_minutes: float | None, step_minutes: float = 5.0) -> list[float]:
|
||||||
|
"""Thermal response (kW) at the end of each forecast step.
|
||||||
|
|
||||||
|
The variable power returns towards baseline (see forecast_internal) and the
|
||||||
|
response keeps following it with its own time constant.
|
||||||
|
"""
|
||||||
|
alpha = 1.0 - exp(-step_minutes / tau_minutes)
|
||||||
|
held = [variable_kw] + forecast_internal(variable_kw, steps - 1, half_life_minutes, step_minutes)
|
||||||
|
result, value = [], response_kw
|
||||||
|
for power in held:
|
||||||
|
value += alpha * (power - value)
|
||||||
|
result.append(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class HeatSourceTracker:
|
||||||
|
"""Per-poll processing of all heat sources: baselines, responses, zone inputs."""
|
||||||
|
|
||||||
|
def __init__(self, sources: list[HeatSource], saved: Any = None) -> None:
|
||||||
|
self.sources = sources
|
||||||
|
saved = saved if isinstance(saved, Mapping) else {}
|
||||||
|
stored = saved.get("baselines") if isinstance(saved.get("baselines"), Mapping) else {}
|
||||||
|
self._baselines = {s.key: BaselineEstimator(stored.get(s.key) if isinstance(stored.get(s.key), list) else ())
|
||||||
|
for s in sources}
|
||||||
|
self._filters = {s.key: ResponseFilter(s.response_minutes) for s in sources}
|
||||||
|
self._held: dict[str, tuple[float, float | None]] = {} # key -> (timestamp, variable kW)
|
||||||
|
self._settled_since: dict[str, float] = {}
|
||||||
|
self.state: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def update(self, now: float, readings: Mapping[str, float | None]) -> None:
|
||||||
|
"""Process one poll. `now` is a timestamp; `readings` maps key -> measured W or None."""
|
||||||
|
effective = effective_power(self.sources, readings)
|
||||||
|
self.state = {}
|
||||||
|
for source in self.sources:
|
||||||
|
info: dict[str, Any] = {"power_w": readings.get(source.key), "enabled": source.enabled}
|
||||||
|
self.state[source.key] = info
|
||||||
|
if not source.enabled:
|
||||||
|
continue
|
||||||
|
watts = effective.get(source.key)
|
||||||
|
estimator = self._baselines[source.key]
|
||||||
|
if watts is not None:
|
||||||
|
estimator.add(watts)
|
||||||
|
baseline = source.baseline_w if source.baseline_mode == BASELINE_MANUAL else estimator.value()
|
||||||
|
variable = None if watts is None or baseline is None else source.fraction * (watts - baseline) / 1000
|
||||||
|
# The heat during the interval just ended is what was drawn at its start.
|
||||||
|
held = self._held.get(source.key)
|
||||||
|
filt = self._filters[source.key]
|
||||||
|
if held and held[1] is not None and 0 < now - held[0] <= 900:
|
||||||
|
response = filt.advance(held[1], now - held[0])
|
||||||
|
else:
|
||||||
|
filt.reset()
|
||||||
|
self._settled_since.pop(source.key, None)
|
||||||
|
response = None if variable is None else filt.advance(variable, 0)
|
||||||
|
if response is not None:
|
||||||
|
self._settled_since.setdefault(source.key, now)
|
||||||
|
self._held[source.key] = (now, variable)
|
||||||
|
info.update(effective_w=watts, baseline_w=baseline, variable_kw=variable, response_kw=response,
|
||||||
|
settled=response is not None
|
||||||
|
and now - self._settled_since[source.key] >= 2 * filt.tau)
|
||||||
|
|
||||||
|
def zone_input(self, zone: str) -> float | None:
|
||||||
|
"""Summed thermal response of a zone's sources (kW); None if unknown or unsettled."""
|
||||||
|
total = 0.0
|
||||||
|
for source in self.sources:
|
||||||
|
if source.zone != zone or not source.enabled:
|
||||||
|
continue
|
||||||
|
info = self.state.get(source.key, {})
|
||||||
|
if not info.get("settled"):
|
||||||
|
return None
|
||||||
|
total += info["response_kw"]
|
||||||
|
return total
|
||||||
|
|
||||||
|
def zone_forecast(self, zone: str, steps: int, half_life_minutes: float | None) -> list[float]:
|
||||||
|
"""Assumed thermal response of a zone's sources over a forecast (kW per step)."""
|
||||||
|
total = [0.0] * steps
|
||||||
|
for source in self.sources:
|
||||||
|
info = self.state.get(source.key, {})
|
||||||
|
if source.zone != zone or not source.enabled or info.get("response_kw") is None:
|
||||||
|
continue
|
||||||
|
variable = info["variable_kw"] if info.get("variable_kw") is not None else 0.0
|
||||||
|
for k, value in enumerate(forecast_response(info["response_kw"], variable, steps,
|
||||||
|
source.response_minutes, half_life_minutes)):
|
||||||
|
total[k] += value
|
||||||
|
return total
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {"baselines": {key: [round(v, 1) for v in est.samples] for key, est in self._baselines.items()}}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""Recursive least squares over named, physically bounded features.
|
||||||
|
|
||||||
|
Pure Python; no Home Assistant imports. Every coefficient is a change in room
|
||||||
|
temperature per 5-minute step per unit of its feature, so all of them share the
|
||||||
|
same target: the measured 5-minute temperature change (or, for the ventilation
|
||||||
|
layer, the part of it the closed-window model cannot explain).
|
||||||
|
|
||||||
|
New effects start at a prior of 0 ("no effect until the data shows one"). In
|
||||||
|
this unscaled RLS form a prior variance is worth 1 / (variance x typical value^2)
|
||||||
|
samples of data, so each prior below is chosen as "worth N typical samples": a
|
||||||
|
handful of samples cannot produce a confident coefficient. Confidence is the share
|
||||||
|
of a coefficient's information that comes from data rather than the prior; the
|
||||||
|
displayed standard deviation uses a running estimate of the measurement noise.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from math import isfinite, sqrt
|
||||||
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
|
FORGETTING = 0.999 # about 3.5 days of 5-minute samples
|
||||||
|
MAX_STEP_CHANGE = 0.8 # °C per 5 minutes: larger is a glitch, not physics
|
||||||
|
# A coefficient counts as identified after this many exercising samples and once
|
||||||
|
# at least 80 % of its information comes from data.
|
||||||
|
MIN_ACTIVE_SAMPLES = 30
|
||||||
|
IDENTIFIED_CONFIDENCE = 0.8
|
||||||
|
INITIAL_NOISE_STD = 0.02 # °C per 5 minutes, about one sensor resolution step
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureSpec(NamedTuple):
|
||||||
|
prior: float
|
||||||
|
# Unscaled RLS prior variance: worth 1 / (variance x typical^2) typical samples.
|
||||||
|
variance: float
|
||||||
|
lo: float
|
||||||
|
hi: float
|
||||||
|
# |feature value| at or above which a sample exercises this coefficient.
|
||||||
|
active: float
|
||||||
|
unit: str
|
||||||
|
|
||||||
|
|
||||||
|
def _worth(samples: float, typical: float) -> float:
|
||||||
|
return 1.0 / (samples * typical * typical)
|
||||||
|
|
||||||
|
|
||||||
|
# x = feature value for one zone at the start of a 5-minute step. The first three
|
||||||
|
# match the base model's priors.
|
||||||
|
FEATURES: dict[str, FeatureSpec] = {
|
||||||
|
# Envelope loss: x = T_out - T (°C).
|
||||||
|
"loss": FeatureSpec(0.002, 0.0005, 0.0, 0.04, 1.0, "°C/5 min per °C outdoor difference"),
|
||||||
|
# Boiler: x = radiator-lag proxy (0..1).
|
||||||
|
"heat": FeatureSpec(0.045, 0.8, 0.0, 0.25, 0.1, "°C/5 min at full radiator heat"),
|
||||||
|
# Constant gains/losses: x = 1.
|
||||||
|
"bias": FeatureSpec(0.0, 0.08, -0.06, 0.06, 0.5, "°C/5 min"),
|
||||||
|
# Electrical heat above baseline after its thermal response: x in kW (typ. 0.4).
|
||||||
|
"internal": FeatureSpec(0.0, _worth(20, 0.4), 0.0, 0.5, 0.05, "°C/5 min per kW"),
|
||||||
|
# Exchange with an adjacent zone j: x = T_j - T (°C). Shrunk hard towards 0:
|
||||||
|
# zones heated by one boiler move together, so this is weakly identifiable.
|
||||||
|
"couple": FeatureSpec(0.0, _worth(200, 1.0), 0.0, 0.05, 0.3, "°C/5 min per °C difference"),
|
||||||
|
# Ventilation, fitted to what the closed-window model cannot explain. x scales
|
||||||
|
# with the open-window factor O and T_out - T (typ. 15 °C):
|
||||||
|
"vent_natural": FeatureSpec(0.0, _worth(20, 15), 0.0, 0.2, 0.5, "°C/5 min per °C x opening"),
|
||||||
|
# Wind speed v in m/s (typ. 4 m/s).
|
||||||
|
"vent_wind": FeatureSpec(0.0, _worth(20, 60), 0.0, 0.03, 0.5, "°C/5 min per °C x opening x m/s"),
|
||||||
|
# Directional part, bounded by vent_wind so a leeward window still ventilates.
|
||||||
|
"vent_wind_dir": FeatureSpec(0.0, _worth(20, 60), -0.03, 0.03, 0.5,
|
||||||
|
"°C/5 min per °C x opening x m/s x cos(angle)"),
|
||||||
|
"vent_gust": FeatureSpec(0.0, _worth(20, 45), 0.0, 0.03, 0.5,
|
||||||
|
"°C/5 min per °C x opening x m/s of gust above mean wind"),
|
||||||
|
"vent_cross": FeatureSpec(0.0, _worth(100, 15), 0.0, 0.1, 0.5,
|
||||||
|
"°C/5 min per °C x opening x openings elsewhere"),
|
||||||
|
# After closing: x = recovery weight (1 -> 0) x (T before opening - T), typ. 0.5.
|
||||||
|
"recovery": FeatureSpec(0.0, _worth(20, 0.5), 0.0, 0.5, 0.05,
|
||||||
|
"°C/5 min per °C below the pre-ventilation temperature"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def spec_for(name: str) -> FeatureSpec:
|
||||||
|
"""Specification of a feature; "couple:<zone>" shares the "couple" spec."""
|
||||||
|
return FEATURES[name.split(":", 1)[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def bound_covariance(p: list[list[float]], prior_variance: list[float]) -> None:
|
||||||
|
"""Keep P symmetric and no less certain than the prior, in place.
|
||||||
|
|
||||||
|
With forgetting, unexcited directions grow by 1/forgetting each step without
|
||||||
|
limit. Scaling row and column i together is a congruence transform, so positive
|
||||||
|
definiteness is preserved.
|
||||||
|
"""
|
||||||
|
n = len(p)
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i):
|
||||||
|
p[i][j] = p[j][i] = (p[i][j] + p[j][i]) / 2
|
||||||
|
for i in range(n):
|
||||||
|
if p[i][i] > prior_variance[i]:
|
||||||
|
scale = (prior_variance[i] / p[i][i]) ** .5
|
||||||
|
for j in range(n):
|
||||||
|
p[i][j] *= scale
|
||||||
|
for j in range(n):
|
||||||
|
p[j][i] *= scale
|
||||||
|
p[i][i] = prior_variance[i]
|
||||||
|
|
||||||
|
|
||||||
|
def positive_definite(p: list[list[float]]) -> bool:
|
||||||
|
"""Cholesky check for a symmetric matrix."""
|
||||||
|
n = len(p)
|
||||||
|
lower = [[0.0] * n for _ in range(n)]
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1):
|
||||||
|
value = p[i][j] - sum(lower[i][k] * lower[j][k] for k in range(j))
|
||||||
|
if i == j:
|
||||||
|
if value <= 0 or not isfinite(value):
|
||||||
|
return False
|
||||||
|
lower[i][j] = value ** .5
|
||||||
|
else:
|
||||||
|
lower[i][j] = value / lower[j][j]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def rls_update(theta: list[float], p: list[list[float]], x: list[float], y: float,
|
||||||
|
forgetting: float = FORGETTING) -> tuple[list[float], float] | None:
|
||||||
|
"""One exponentially weighted RLS step, updating P in place.
|
||||||
|
|
||||||
|
Returns the new parameters and the a priori residual, or None when the update
|
||||||
|
would be numerically invalid (P is left untouched then).
|
||||||
|
"""
|
||||||
|
n = len(theta)
|
||||||
|
residual = y - sum(t * v for t, v in zip(theta, x))
|
||||||
|
px = [sum(p[i][j] * x[j] for j in range(n)) for i in range(n)]
|
||||||
|
denominator = forgetting + sum(x[i] * px[i] for i in range(n))
|
||||||
|
if not isfinite(denominator) or denominator <= 0:
|
||||||
|
return None
|
||||||
|
gain = [value / denominator for value in px]
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(n):
|
||||||
|
p[i][j] = (p[i][j] - gain[i] * px[j]) / forgetting
|
||||||
|
return [t + gain[i] * residual for i, t in enumerate(theta)], residual
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureLearner:
|
||||||
|
"""RLS over a configurable list of named features (see FEATURES)."""
|
||||||
|
|
||||||
|
def __init__(self, features: list[str]) -> None:
|
||||||
|
if not features or len(set(features)) != len(features):
|
||||||
|
raise ValueError("Features must be unique and non-empty")
|
||||||
|
self.features = list(features)
|
||||||
|
self._specs = [spec_for(name) for name in self.features]
|
||||||
|
self.theta = [spec.prior for spec in self._specs]
|
||||||
|
self.p = [[spec.variance if i == j else 0.0 for j in range(len(self._specs))]
|
||||||
|
for i, spec in enumerate(self._specs)]
|
||||||
|
self.count = 0
|
||||||
|
self.error_sum = 0.0
|
||||||
|
self.rejected_count = 0
|
||||||
|
self.on_count = 0
|
||||||
|
self.off_count = 0
|
||||||
|
self.active = [0] * len(self.features)
|
||||||
|
# Bias-corrected running mean of squared a priori residuals.
|
||||||
|
self._residual_sq = 0.0
|
||||||
|
self._residual_weight = 0.0
|
||||||
|
|
||||||
|
def predict(self, x: list[float]) -> float:
|
||||||
|
return sum(t * v for t, v in zip(self.theta, x))
|
||||||
|
|
||||||
|
def learn(self, x: list[float], y: float, command: bool | None = None) -> bool:
|
||||||
|
"""Fit one sample of features `x` to the 5-minute change `y`."""
|
||||||
|
if (len(x) != len(self.features) or not all(isfinite(v) for v in x)
|
||||||
|
or not isfinite(y) or abs(y) > MAX_STEP_CHANGE):
|
||||||
|
self.rejected_count += 1
|
||||||
|
return False
|
||||||
|
result = rls_update(self.theta, self.p, x, y)
|
||||||
|
if result is None:
|
||||||
|
self.rejected_count += 1
|
||||||
|
return False
|
||||||
|
theta, residual = result
|
||||||
|
bound_covariance(self.p, [spec.variance for spec in self._specs])
|
||||||
|
self.theta = self._bounded(theta)
|
||||||
|
self.count += 1
|
||||||
|
self.error_sum += abs(residual)
|
||||||
|
self._residual_sq = FORGETTING * self._residual_sq + (1 - FORGETTING) * residual * residual
|
||||||
|
self._residual_weight = FORGETTING * self._residual_weight + (1 - FORGETTING)
|
||||||
|
if command is not None:
|
||||||
|
if command:
|
||||||
|
self.on_count += 1
|
||||||
|
else:
|
||||||
|
self.off_count += 1
|
||||||
|
for i, (value, spec) in enumerate(zip(x, self._specs)):
|
||||||
|
if abs(value) >= spec.active:
|
||||||
|
self.active[i] += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _bounded(self, theta: list[float]) -> list[float]:
|
||||||
|
bounded = [min(spec.hi, max(spec.lo, value)) for value, spec in zip(theta, self._specs)]
|
||||||
|
if "vent_wind_dir" in self.features:
|
||||||
|
# The directional term may at most cancel the isotropic wind term.
|
||||||
|
limit = bounded[self.features.index("vent_wind")] if "vent_wind" in self.features else 0.0
|
||||||
|
i = self.features.index("vent_wind_dir")
|
||||||
|
bounded[i] = min(limit, max(-limit, bounded[i]))
|
||||||
|
return bounded
|
||||||
|
|
||||||
|
@property
|
||||||
|
def noise_var(self) -> float:
|
||||||
|
"""Estimated variance of the unexplained 5-minute change (°C²)."""
|
||||||
|
if self._residual_weight <= 0:
|
||||||
|
return INITIAL_NOISE_STD ** 2
|
||||||
|
return self._residual_sq / self._residual_weight
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mae(self) -> float | None:
|
||||||
|
return self.error_sum / self.count if self.count else None
|
||||||
|
|
||||||
|
def coefficient(self, name: str) -> float:
|
||||||
|
return self.theta[self.features.index(name)] if name in self.features else 0.0
|
||||||
|
|
||||||
|
def coefficients(self) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Value, uncertainty and support of each coefficient, for diagnostics.
|
||||||
|
|
||||||
|
`confidence` is the share of the coefficient's information that comes from
|
||||||
|
data rather than the prior (0 = prior only); `std` is its standard error.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for i, (name, spec) in enumerate(zip(self.features, self._specs)):
|
||||||
|
variance = min(spec.variance, max(0.0, self.p[i][i]))
|
||||||
|
confidence = 1 - variance / spec.variance
|
||||||
|
result[name] = {
|
||||||
|
"value": round(self.theta[i], 6),
|
||||||
|
"std": round(sqrt(self.noise_var * variance), 6),
|
||||||
|
"confidence": round(confidence, 3),
|
||||||
|
"samples": self.active[i],
|
||||||
|
"identified": (self.active[i] >= MIN_ACTIVE_SAMPLES
|
||||||
|
and confidence >= IDENTIFIED_CONFIDENCE),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {"features": self.features, "theta": self.theta, "p": self.p,
|
||||||
|
"count": self.count, "error_sum": self.error_sum,
|
||||||
|
"rejected_count": self.rejected_count, "on_count": self.on_count,
|
||||||
|
"off_count": self.off_count, "active": self.active,
|
||||||
|
"residual_sq": self._residual_sq, "residual_weight": self._residual_weight}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, source: Any, features: list[str]) -> "FeatureLearner":
|
||||||
|
"""Restore what is valid for `features`; other features start from the prior.
|
||||||
|
|
||||||
|
Coefficients are matched by name, so adding or removing a heat source,
|
||||||
|
window or neighbour retrains only the affected coefficients. Anything
|
||||||
|
malformed yields a fresh learner.
|
||||||
|
"""
|
||||||
|
fresh = cls(features)
|
||||||
|
try:
|
||||||
|
if not isinstance(source, dict):
|
||||||
|
return fresh
|
||||||
|
names = source["features"]
|
||||||
|
theta = [float(v) for v in source["theta"]]
|
||||||
|
p = [[float(v) for v in row] for row in source["p"]]
|
||||||
|
n = len(names)
|
||||||
|
if (not all(isinstance(name, str) for name in names) or len(set(names)) != n
|
||||||
|
or len(theta) != n or len(p) != n or any(len(row) != n for row in p)):
|
||||||
|
return fresh
|
||||||
|
if not all(isfinite(v) for v in theta + [v for row in p for v in row]):
|
||||||
|
return fresh
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i):
|
||||||
|
if abs(p[i][j] - p[j][i]) > 1e-6 * max(1.0, abs(p[i][j]), abs(p[j][i])):
|
||||||
|
return fresh
|
||||||
|
if not positive_definite(p):
|
||||||
|
return fresh
|
||||||
|
counts = [source.get(key, 0) for key in
|
||||||
|
("count", "rejected_count", "on_count", "off_count")]
|
||||||
|
active = source.get("active", [0] * n)
|
||||||
|
if (any(type(v) is not int or v < 0 for v in counts + list(active))
|
||||||
|
or len(active) != n or counts[2] + counts[3] > counts[0]):
|
||||||
|
return fresh
|
||||||
|
error_sum = float(source.get("error_sum", 0))
|
||||||
|
residual_sq = float(source.get("residual_sq", 0.0))
|
||||||
|
residual_weight = float(source.get("residual_weight", 0.0))
|
||||||
|
if (not all(isfinite(v) and v >= 0 for v in (error_sum, residual_sq, residual_weight))
|
||||||
|
or residual_weight > 1):
|
||||||
|
return fresh
|
||||||
|
index = {name: i for i, name in enumerate(names)}
|
||||||
|
kept = [(j, index[name]) for j, name in enumerate(features) if name in index]
|
||||||
|
obj = cls(features)
|
||||||
|
for j, i in kept:
|
||||||
|
obj.theta[j] = theta[i]
|
||||||
|
obj.active[j] = active[i]
|
||||||
|
for k, m in kept:
|
||||||
|
obj.p[j][k] = p[i][m]
|
||||||
|
obj.theta = obj._bounded(obj.theta)
|
||||||
|
bound_covariance(obj.p, [spec.variance for spec in obj._specs])
|
||||||
|
if not positive_definite(obj.p):
|
||||||
|
return fresh
|
||||||
|
obj.count, obj.rejected_count, obj.on_count, obj.off_count = counts
|
||||||
|
obj.error_sum = error_sum
|
||||||
|
obj._residual_sq, obj._residual_weight = residual_sq, residual_weight
|
||||||
|
return obj
|
||||||
|
except (KeyError, TypeError, ValueError, OverflowError):
|
||||||
|
return fresh
|
||||||
@@ -9,5 +9,5 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "0.3.0"
|
"version": "0.4.0"
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,12 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from itertools import product
|
from itertools import product
|
||||||
from math import exp, isfinite
|
from math import exp, isfinite
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
# (zone temperatures, radiator lag at the end of the step, step index) -> new temperatures
|
||||||
|
StepModel = Callable[[dict[str, float], float, int], dict[str, float]]
|
||||||
|
|
||||||
|
from .learning import bound_covariance, positive_definite, rls_update
|
||||||
|
|
||||||
STEP_MINUTES = 5
|
STEP_MINUTES = 5
|
||||||
# First-order radiator lag between the relay command and the heat reaching rooms.
|
# First-order radiator lag between the relay command and the heat reaching rooms.
|
||||||
@@ -18,6 +23,7 @@ HEAT_LAG = 1.0 - exp(-STEP_MINUTES * 60 / LAG_TAU_SECONDS)
|
|||||||
LAG_LEARN_MAX_UNCERTAINTY = 0.1
|
LAG_LEARN_MAX_UNCERTAINTY = 0.1
|
||||||
# Prior covariance; also the upper bound that stops RLS windup (see _bound_covariance).
|
# Prior covariance; also the upper bound that stops RLS windup (see _bound_covariance).
|
||||||
PRIOR_P = ((0.0005, 0.0, 0.0), (0.0, 0.8, 0.0), (0.0, 0.0, 0.08))
|
PRIOR_P = ((0.0005, 0.0, 0.0), (0.0, 0.8, 0.0), (0.0, 0.0, 0.08))
|
||||||
|
PRIOR_VARIANCE = [PRIOR_P[i][i] for i in range(3)]
|
||||||
|
|
||||||
|
|
||||||
def bound(value: float, lo: float, hi: float) -> float:
|
def bound(value: float, lo: float, hi: float) -> float:
|
||||||
@@ -61,23 +67,8 @@ def lag_from_time_in_state(command: bool, seconds_in_state: float) -> tuple[floa
|
|||||||
|
|
||||||
|
|
||||||
def _bound_covariance(p: list[list[float]]) -> None:
|
def _bound_covariance(p: list[list[float]]) -> None:
|
||||||
"""Keep P symmetric and no less certain than the prior, in place.
|
"""Covariance bound for the 3-parameter base model (see learning.bound_covariance)."""
|
||||||
|
bound_covariance(p, PRIOR_VARIANCE)
|
||||||
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
|
||||||
@@ -107,18 +98,10 @@ class Learner:
|
|||||||
if abs(y) > 0.8: # sensor glitch/door opened/restarted device
|
if abs(y) > 0.8: # sensor glitch/door opened/restarted device
|
||||||
return self._reject()
|
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))
|
result = rls_update(self.theta, self.p, x, y)
|
||||||
residual = y - predicted
|
if result is None:
|
||||||
px = [sum(self.p[i][j] * x[j] for j in range(3)) for i in range(3)]
|
|
||||||
forgetting = 0.999
|
|
||||||
denominator = forgetting + sum(x[i] * px[i] for i in range(3))
|
|
||||||
if not isfinite(denominator) or denominator <= 0:
|
|
||||||
return self._reject()
|
return self._reject()
|
||||||
k = [value / denominator for value in px]
|
self.theta, residual = result
|
||||||
self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)]
|
|
||||||
for i in range(3):
|
|
||||||
for j in range(3):
|
|
||||||
self.p[i][j] = (self.p[i][j] - k[i] * px[j]) / forgetting
|
|
||||||
_bound_covariance(self.p)
|
_bound_covariance(self.p)
|
||||||
# Physical constraints; avoids unstable temperature predictions.
|
# Physical constraints; avoids unstable temperature predictions.
|
||||||
self.theta = [
|
self.theta = [
|
||||||
@@ -169,17 +152,9 @@ class Learner:
|
|||||||
if abs(p[i][j] - p[j][i]) > 1e-6 * max(1.0, abs(p[i][j]), abs(p[j][i])):
|
if abs(p[i][j] - p[j][i]) > 1e-6 * max(1.0, abs(p[i][j]), abs(p[j][i])):
|
||||||
return obj
|
return obj
|
||||||
_bound_covariance(p)
|
_bound_covariance(p)
|
||||||
# A covariance must be symmetric positive definite (Cholesky check).
|
# A covariance must be symmetric positive definite.
|
||||||
lower = [[0.0] * 3 for _ in range(3)]
|
if not positive_definite(p):
|
||||||
for i in range(3):
|
return obj
|
||||||
for j in range(i + 1):
|
|
||||||
value = p[i][j] - sum(lower[i][k] * lower[j][k] for k in range(j))
|
|
||||||
if i == j:
|
|
||||||
if value <= 0 or not isfinite(value):
|
|
||||||
return obj
|
|
||||||
lower[i][j] = value ** .5
|
|
||||||
else:
|
|
||||||
lower[i][j] = value / lower[j][j]
|
|
||||||
counts = [source.get(key, 0)
|
counts = [source.get(key, 0)
|
||||||
for key in ("count", "on_count", "off_count", "rejected_count")]
|
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):
|
||||||
@@ -207,11 +182,15 @@ def optimize(
|
|||||||
is_on: bool, seconds_since_change: float, horizon_minutes: int = 120,
|
is_on: bool, seconds_since_change: float, horizon_minutes: int = 120,
|
||||||
minimum_on_minutes: int = 15, minimum_off_minutes: int = 10,
|
minimum_on_minutes: int = 15, minimum_off_minutes: int = 10,
|
||||||
switching_cost: float = .15, heating_cost: float = .025,
|
switching_cost: float = .15, heating_cost: float = .025,
|
||||||
comfort_band: float = .15,
|
comfort_band: float = .15, target_offsets: dict[str, float] | None = None,
|
||||||
|
step_model: StepModel | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Enumerate 15min ON/OFF plans; honor minimum ON/OFF durations.
|
"""Enumerate 15min ON/OFF plans; honor minimum ON/OFF durations.
|
||||||
|
|
||||||
120min = 256 plans (fast on Raspberry Pi 5). All rooms share one burner demand.
|
120min = 256 plans (fast on Raspberry Pi 5). All rooms share one burner demand.
|
||||||
|
`target_offsets` shifts individual zones' targets (°C) relative to `target`.
|
||||||
|
`step_model` advances all zones together (electrical heat, ventilation,
|
||||||
|
neighbours); by default each zone uses its learner's base model.
|
||||||
"""
|
"""
|
||||||
if not temps or set(temps) != set(learners):
|
if not temps or set(temps) != set(learners):
|
||||||
raise ValueError("Missing room temperatures or learner")
|
raise ValueError("Missing room temperatures or learner")
|
||||||
@@ -234,8 +213,19 @@ def optimize(
|
|||||||
if not isfinite(total_weight):
|
if not isfinite(total_weight):
|
||||||
raise ValueError("Invalid total weight")
|
raise ValueError("Invalid total weight")
|
||||||
norm = {room: float(weights[room]) / total_weight for room in temps}
|
norm = {room: float(weights[room]) / total_weight for room in temps}
|
||||||
|
offsets = target_offsets or {}
|
||||||
|
if any(not isfinite(float(offsets.get(room, 0.0))) for room in temps):
|
||||||
|
raise ValueError("Invalid target offset")
|
||||||
|
targets = {room: target + float(offsets.get(room, 0.0)) for room in temps}
|
||||||
|
mean_target = sum(norm[room] * targets[room] for room in temps)
|
||||||
blocks = horizon_minutes // 15
|
blocks = horizon_minutes // 15
|
||||||
nsteps_per_block = 3
|
nsteps_per_block = 3
|
||||||
|
|
||||||
|
def advance(state: dict[str, float], lag: float, bit: bool, k: int) -> tuple[dict[str, float], float]:
|
||||||
|
if step_model is None:
|
||||||
|
return simulate_step(state, outside, lag, bit, learners)
|
||||||
|
next_lag = lag + HEAT_LAG * (float(bit) - lag)
|
||||||
|
return step_model(state, next_lag, k), next_lag
|
||||||
best = float("inf")
|
best = float("inf")
|
||||||
result = None
|
result = None
|
||||||
min_on_s = minimum_on_minutes * 60
|
min_on_s = minimum_on_minutes * 60
|
||||||
@@ -260,14 +250,16 @@ def optimize(
|
|||||||
elapsed_s = 0.0
|
elapsed_s = 0.0
|
||||||
current = bit
|
current = bit
|
||||||
for tick in range(nsteps_per_block):
|
for tick in range(nsteps_per_block):
|
||||||
state, lag = simulate_step(state, outside, lag, bit, learners)
|
state, lag = advance(state, lag, bit, idx * nsteps_per_block + tick)
|
||||||
|
if not all(isfinite(v) for v in state.values()):
|
||||||
|
raise ValueError("Non-finite simulated temperature")
|
||||||
mismatch = sum(
|
mismatch = sum(
|
||||||
norm[room] * max(0.0, abs(value - target) - comfort_band) ** 2
|
norm[room] * max(0.0, abs(value - targets[room]) - comfort_band) ** 2
|
||||||
for room, value in state.items()
|
for room, value in state.items()
|
||||||
)
|
)
|
||||||
# A lower bound on weighted average comfort also matters.
|
# A lower bound on weighted average comfort also matters.
|
||||||
avg = sum(norm[r] * value for r, value in state.items())
|
avg = sum(norm[r] * value for r, value in state.items())
|
||||||
mismatch += max(0.0, abs(avg - target) - comfort_band) ** 2
|
mismatch += max(0.0, abs(avg - mean_target) - comfort_band) ** 2
|
||||||
cost += 8.0 * mismatch + (heating_cost if bit else 0.0)
|
cost += 8.0 * mismatch + (heating_cost if bit else 0.0)
|
||||||
if idx * 15 + (tick + 1) * STEP_MINUTES == 60:
|
if idx * 15 + (tick + 1) * STEP_MINUTES == 60:
|
||||||
at_60 = dict(state)
|
at_60 = dict(state)
|
||||||
@@ -281,9 +273,9 @@ def optimize(
|
|||||||
# Re-simulate the chosen plan for its trajectory, keeping the search loop lean.
|
# Re-simulate the chosen plan for its trajectory, keeping the search loop lean.
|
||||||
trajectory: dict[str, list[float]] = {room: [] for room in temps}
|
trajectory: dict[str, list[float]] = {room: [] for room in temps}
|
||||||
state, lag = dict(temps), effective_heat
|
state, lag = dict(temps), effective_heat
|
||||||
for bit in plan:
|
for idx, bit in enumerate(plan):
|
||||||
for _ in range(nsteps_per_block):
|
for tick in range(nsteps_per_block):
|
||||||
state, lag = simulate_step(state, outside, lag, bit, learners)
|
state, lag = advance(state, lag, bit, idx * nsteps_per_block + tick)
|
||||||
for room, value in state.items():
|
for room, value in state.items():
|
||||||
trajectory[room].append(round(value, 3))
|
trajectory[room].append(round(value, 3))
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ 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)]
|
MPCLearningProgress(coordinator, entry.entry_id), MPCLastSample(coordinator, entry.entry_id)]
|
||||||
entities += [MPCRoomPrediction(coordinator, entry.entry_id, room) for room in coordinator.rooms]
|
|
||||||
entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS]
|
entities += [MPCForecastError(coordinator, entry.entry_id, horizon) for horizon in HORIZONS]
|
||||||
|
# Zone forecasts belong to their zone subentry, so deleting a zone removes them.
|
||||||
|
linked = {}
|
||||||
|
for zone in coordinator.zones.values():
|
||||||
|
entity = MPCRoomPrediction(coordinator, entry.entry_id, zone.key)
|
||||||
|
if zone.subentry_id:
|
||||||
|
linked.setdefault(zone.subentry_id, []).append(entity)
|
||||||
|
else:
|
||||||
|
entities.append(entity)
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
for subentry_id, zone_entities in linked.items():
|
||||||
|
async_add_entities(zone_entities, config_subentry_id=subentry_id)
|
||||||
|
|
||||||
|
|
||||||
class MPCStatus(CoordinatorEntity, SensorEntity):
|
class MPCStatus(CoordinatorEntity, SensorEntity):
|
||||||
@@ -26,7 +35,8 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
|
|||||||
_attr_icon = "mdi:brain"
|
_attr_icon = "mdi:brain"
|
||||||
# Per-source report ages change every poll; recording them adds a new
|
# Per-source report ages change every poll; recording them adds a new
|
||||||
# attribute row to the database every five minutes. Current values stay visible.
|
# attribute row to the database every five minutes. Current values stay visible.
|
||||||
_unrecorded_attributes = frozenset({"input_quality", "weather_comparison"})
|
_unrecorded_attributes = frozenset({"input_quality", "weather_comparison", "heat_sources", "windows",
|
||||||
|
"ventilation", "ventilation_events"})
|
||||||
|
|
||||||
def __init__(self, coordinator, entry_id):
|
def __init__(self, coordinator, entry_id):
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
@@ -41,7 +51,8 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
|
|||||||
data = self.coordinator.data
|
data = self.coordinator.data
|
||||||
# Forecast details live on their own entities.
|
# Forecast details live on their own entities.
|
||||||
return {key: value for key, value in data.items() if key not in (
|
return {key: value for key, value in data.items() if key not in (
|
||||||
"status", "prediction_60m", "prediction_end", "prediction_trajectory", "forecast_accuracy")}
|
"status", "prediction_60m", "prediction_end", "prediction_trajectory", "forecast_accuracy",
|
||||||
|
"zone_details")}
|
||||||
|
|
||||||
|
|
||||||
class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
|
class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
|
||||||
@@ -69,10 +80,11 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
|
|||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self.room = room
|
self.room = room
|
||||||
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 {coordinator.zones[room].name} 60min Prediction"
|
||||||
|
|
||||||
# Change every poll; the 60-minute value itself is recorded.
|
# Change every poll; the 60-minute value itself is recorded.
|
||||||
_unrecorded_attributes = frozenset({"forecast_15min", "plan_15min"})
|
_unrecorded_attributes = frozenset({"forecast_15min", "plan_15min", "forecast", "contributions_c_per_h",
|
||||||
|
"coefficients", "current_temperature", "model_in_use"})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self):
|
def native_value(self):
|
||||||
@@ -81,10 +93,18 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
|
|||||||
@property
|
@property
|
||||||
def extra_state_attributes(self):
|
def extra_state_attributes(self):
|
||||||
data = self.coordinator.data
|
data = self.coordinator.data
|
||||||
|
trajectory = data.get("prediction_trajectory", {}).get(self.room) or []
|
||||||
|
details = data.get("zone_details", {}).get(self.room, {})
|
||||||
return {
|
return {
|
||||||
"forecast_15min": data.get("prediction_trajectory", {}).get(self.room),
|
"current_temperature": details.get("temperature"),
|
||||||
|
"forecast": {f"{minutes}_min": trajectory[minutes // 15 - 1]
|
||||||
|
for minutes in (15, 30, 60, 120) if len(trajectory) >= minutes // 15},
|
||||||
|
"forecast_15min": trajectory or None,
|
||||||
"plan_15min": data.get("plan_15min"),
|
"plan_15min": data.get("plan_15min"),
|
||||||
"basis": "proposed MPC plan; counterfactual while Generic Thermostat controls the relay",
|
"basis": "proposed MPC plan; counterfactual while Generic Thermostat controls the relay",
|
||||||
|
"model_in_use": details.get("model_in_use"),
|
||||||
|
"contributions_c_per_h": details.get("contributions_c_per_h"),
|
||||||
|
"coefficients": details.get("coefficients"),
|
||||||
}
|
}
|
||||||
|
|
||||||
class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
|
class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
|
||||||
@@ -120,7 +140,7 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
|
|||||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||||
_attr_suggested_display_precision = 2
|
_attr_suggested_display_precision = 2
|
||||||
_attr_icon = "mdi:target"
|
_attr_icon = "mdi:target"
|
||||||
_unrecorded_attributes = frozenset({"rooms"})
|
_unrecorded_attributes = frozenset({"zones"})
|
||||||
|
|
||||||
def __init__(self, coordinator, entry_id, horizon):
|
def __init__(self, coordinator, entry_id, horizon):
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
@@ -144,11 +164,15 @@ class MPCForecastError(CoordinatorEntity, SensorEntity):
|
|||||||
"bias": accuracy.get("bias"),
|
"bias": accuracy.get("bias"),
|
||||||
"persistence_mae": accuracy.get("persistence_mae"),
|
"persistence_mae": accuracy.get("persistence_mae"),
|
||||||
"skill": accuracy.get("skill"),
|
"skill": accuracy.get("skill"),
|
||||||
"rooms": accuracy.get("rooms", {}),
|
"base_model_mae": accuracy.get("base_model_mae"),
|
||||||
|
"improvement_vs_base": accuracy.get("improvement_vs_base"),
|
||||||
|
# Per zone, by name.
|
||||||
|
"zones": {self.coordinator.zones[key].name if key in self.coordinator.zones else key: stats
|
||||||
|
for key, stats in accuracy.get("rooms", {}).items()},
|
||||||
"window_days": WINDOW_DAYS,
|
"window_days": WINDOW_DAYS,
|
||||||
"unit_of_attributes": "°C",
|
"unit_of_attributes": "°C",
|
||||||
"basis": ("thermal model replayed with the actual relay history; outdoor "
|
"basis": ("all zones replayed together with the actual relay, electrical heat and "
|
||||||
"temperature held at the start value"),
|
"window history; outdoor temperature held at the start value"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||||
"outside_wind_sources": "Wind speed sources (optional)",
|
"outside_wind_sources": "Wind speed sources (optional)",
|
||||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
"outside_clouds_sources": "Cloud coverage sources (optional)",
|
||||||
|
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
|
||||||
|
"outside_wind_gust_sources": "Wind gust sources (optional)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weather_extra": {
|
"weather_extra": {
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
},
|
},
|
||||||
"weights": {
|
"weights": {
|
||||||
"title": "Room priorities",
|
"title": "Room priorities",
|
||||||
"description": "Each room has a relative comfort weight between 0.1 and 10; sensor entity is shown by each field."
|
"description": "Each selected sensor becomes its own zone with this comfort weight. Later, group the sensors of one room into one zone with Add zone or by editing a zone."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -59,7 +61,6 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"climate_entity": "Existing Generic Thermostat",
|
"climate_entity": "Existing Generic Thermostat",
|
||||||
"heater_entity": "Physical heating switch (read-only)",
|
"heater_entity": "Physical heating switch (read-only)",
|
||||||
"room_entities": "Individual room temperature sensors",
|
|
||||||
"horizon_minutes": "Prediction horizon (minutes)",
|
"horizon_minutes": "Prediction horizon (minutes)",
|
||||||
"minimum_on_minutes": "Minimum ON time (minutes)",
|
"minimum_on_minutes": "Minimum ON time (minutes)",
|
||||||
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
||||||
@@ -68,10 +69,22 @@
|
|||||||
"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"
|
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting",
|
||||||
|
"internal_heat_half_life_minutes": "Extra electrical heat: forecast half-life (minutes)",
|
||||||
|
"window_open_minutes": "Open window: assumed remaining open time (minutes)",
|
||||||
|
"recovery_minutes": "Recovery after closing a window (minutes)",
|
||||||
|
"ventilation_wind": "Ventilation: use wind speed and direction",
|
||||||
|
"ventilation_gust": "Ventilation: use wind gusts",
|
||||||
|
"ventilation_cross": "Ventilation: cross-ventilation between zones"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"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."
|
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable.",
|
||||||
|
"internal_heat_half_life_minutes": "Forecasts assume heat above the baseline fades with this half-life, for example when a gaming session ends. 0 assumes it stops now.",
|
||||||
|
"window_open_minutes": "Forecasts assume an open window closes after this many more minutes, then the room recovers.",
|
||||||
|
"recovery_minutes": "Time constant of the air warming back towards the walls' temperature.",
|
||||||
|
"ventilation_wind": "Needs wind sources; direction also needs wind direction sources.",
|
||||||
|
"ventilation_gust": "Adds a term for gusts above the mean wind. Keep it only if Forecast Error improves.",
|
||||||
|
"ventilation_cross": "Adds a term for windows open in several zones at once. Needs windows in two or more zones."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weather": {
|
"weather": {
|
||||||
@@ -80,7 +93,9 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||||
"outside_wind_sources": "Wind speed sources (optional)",
|
"outside_wind_sources": "Wind speed sources (optional)",
|
||||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
"outside_clouds_sources": "Cloud coverage sources (optional)",
|
||||||
|
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
|
||||||
|
"outside_wind_gust_sources": "Wind gust sources (optional)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weather_extra": {
|
"weather_extra": {
|
||||||
@@ -94,14 +109,9 @@
|
|||||||
"outside_rain_sources": "Rain sources (optional)",
|
"outside_rain_sources": "Rain sources (optional)",
|
||||||
"outside_snow_sources": "Snow sources (optional)"
|
"outside_snow_sources": "Snow sources (optional)"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"weights": {
|
|
||||||
"title": "Room priorities",
|
|
||||||
"description": "Weights determine relative comfort importance, not physics coefficients."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,7 +121,9 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"waiting_for_inputs": "Waiting for inputs",
|
"waiting_for_inputs": "Waiting for inputs",
|
||||||
"first_sample": "First sample",
|
"first_sample": "First sample",
|
||||||
|
"sensors_changed": "Zone sensors changed",
|
||||||
"learned": "Learned",
|
"learned": "Learned",
|
||||||
|
"ventilation": "Window open or recovering",
|
||||||
"partly_rejected": "Partly rejected (outlier)",
|
"partly_rejected": "Partly rejected (outlier)",
|
||||||
"interval_out_of_range": "Irregular interval",
|
"interval_out_of_range": "Irregular interval",
|
||||||
"relay_changed": "Relay switched",
|
"relay_changed": "Relay switched",
|
||||||
@@ -120,5 +132,187 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"config_subentries": {
|
||||||
|
"zone": {
|
||||||
|
"entry_type": "Zone",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Add zone",
|
||||||
|
"reconfigure": "Edit zone"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Thermal zone",
|
||||||
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensors": "Temperature sensors",
|
||||||
|
"comfort_weight": "Comfort weight",
|
||||||
|
"target_offset": "Target offset (°C)",
|
||||||
|
"neighbors": "Adjacent zones (heat exchange)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||||
|
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||||
|
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sensor_weights": {
|
||||||
|
"title": "Sensor weights",
|
||||||
|
"description": "How much each sensor counts in the zone temperature (0.1–10)."
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Thermal zone",
|
||||||
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensors": "Temperature sensors",
|
||||||
|
"comfort_weight": "Comfort weight",
|
||||||
|
"target_offset": "Target offset (°C)",
|
||||||
|
"neighbors": "Adjacent zones (heat exchange)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||||
|
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||||
|
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"zone_name_required": "Enter a name.",
|
||||||
|
"zone_sensors": "Choose 1–6 different sensors.",
|
||||||
|
"sensor_in_other_zone": "A chosen sensor already belongs to another zone.",
|
||||||
|
"sensor_is_weather_source": "A chosen sensor is used as an outdoor temperature source.",
|
||||||
|
"too_many_zones": "At most 12 zones are supported."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "The zone was updated."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"heat_source": {
|
||||||
|
"entry_type": "Heat source",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Add heat source",
|
||||||
|
"reconfigure": "Edit heat source"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Electrical heat source",
|
||||||
|
"description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensor": "Active power sensor",
|
||||||
|
"zone": "Zone",
|
||||||
|
"heat_fraction": "Share of power that becomes heat in the zone",
|
||||||
|
"baseline_mode": "Baseline",
|
||||||
|
"baseline_w": "Manual baseline (W)",
|
||||||
|
"included_in": "Already included in the measurement of",
|
||||||
|
"response_minutes": "Thermal response time (minutes)",
|
||||||
|
"enabled": "Enabled"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.",
|
||||||
|
"baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).",
|
||||||
|
"included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.",
|
||||||
|
"response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Electrical heat source",
|
||||||
|
"description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensor": "Active power sensor",
|
||||||
|
"zone": "Zone",
|
||||||
|
"heat_fraction": "Share of power that becomes heat in the zone",
|
||||||
|
"baseline_mode": "Baseline",
|
||||||
|
"baseline_w": "Manual baseline (W)",
|
||||||
|
"included_in": "Already included in the measurement of",
|
||||||
|
"response_minutes": "Thermal response time (minutes)",
|
||||||
|
"enabled": "Enabled"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.",
|
||||||
|
"baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).",
|
||||||
|
"included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.",
|
||||||
|
"response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"source_name_required": "Enter a name.",
|
||||||
|
"sensor_in_other_source": "This power sensor is already used by another heat source.",
|
||||||
|
"invalid_parent_source": "The parent must be an existing source that is not itself included in another.",
|
||||||
|
"source_has_children": "Other sources are included in this one, so it cannot be included in another.",
|
||||||
|
"baseline_required": "Enter the manual baseline in W.",
|
||||||
|
"too_many_sources": "At most 16 heat sources are supported."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "The heat source was updated.",
|
||||||
|
"no_zones": "Add a zone first."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"entry_type": "Window",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Add window",
|
||||||
|
"reconfigure": "Edit window"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Window",
|
||||||
|
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensor": "Contact sensor",
|
||||||
|
"zone": "Zone",
|
||||||
|
"inverted": "Sensor reports on when closed",
|
||||||
|
"azimuth": "Facing direction (°)",
|
||||||
|
"opening_factor": "Opening factor",
|
||||||
|
"enabled": "Enabled"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
|
||||||
|
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
|
||||||
|
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Window",
|
||||||
|
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensor": "Contact sensor",
|
||||||
|
"zone": "Zone",
|
||||||
|
"inverted": "Sensor reports on when closed",
|
||||||
|
"azimuth": "Facing direction (°)",
|
||||||
|
"opening_factor": "Opening factor",
|
||||||
|
"enabled": "Enabled"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
|
||||||
|
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
|
||||||
|
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"window_name_required": "Enter a name.",
|
||||||
|
"sensor_in_other_window": "This sensor is already used by another window.",
|
||||||
|
"too_many_windows": "At most 24 windows are supported."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "The window was updated.",
|
||||||
|
"no_zones": "Add a zone first."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"baseline_mode": {
|
||||||
|
"options": {
|
||||||
|
"auto": "Automatic (24-hour low)",
|
||||||
|
"manual": "Manual value"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,9 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||||
"outside_wind_sources": "Wind speed sources (optional)",
|
"outside_wind_sources": "Wind speed sources (optional)",
|
||||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
"outside_clouds_sources": "Cloud coverage sources (optional)",
|
||||||
|
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
|
||||||
|
"outside_wind_gust_sources": "Wind gust sources (optional)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weather_extra": {
|
"weather_extra": {
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
},
|
},
|
||||||
"weights": {
|
"weights": {
|
||||||
"title": "Room priorities",
|
"title": "Room priorities",
|
||||||
"description": "Each room has a relative comfort weight between 0.1 and 10; sensor entity is shown by each field."
|
"description": "Each selected sensor becomes its own zone with this comfort weight. Later, group the sensors of one room into one zone with Add zone or by editing a zone."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -59,7 +61,6 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"climate_entity": "Existing Generic Thermostat",
|
"climate_entity": "Existing Generic Thermostat",
|
||||||
"heater_entity": "Physical heating switch (read-only)",
|
"heater_entity": "Physical heating switch (read-only)",
|
||||||
"room_entities": "Individual room temperature sensors",
|
|
||||||
"horizon_minutes": "Prediction horizon (minutes)",
|
"horizon_minutes": "Prediction horizon (minutes)",
|
||||||
"minimum_on_minutes": "Minimum ON time (minutes)",
|
"minimum_on_minutes": "Minimum ON time (minutes)",
|
||||||
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
||||||
@@ -68,10 +69,22 @@
|
|||||||
"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"
|
"room_stale_use_last_value": "Keep using the last value of a room sensor that stopped reporting",
|
||||||
|
"internal_heat_half_life_minutes": "Extra electrical heat: forecast half-life (minutes)",
|
||||||
|
"window_open_minutes": "Open window: assumed remaining open time (minutes)",
|
||||||
|
"recovery_minutes": "Recovery after closing a window (minutes)",
|
||||||
|
"ventilation_wind": "Ventilation: use wind speed and direction",
|
||||||
|
"ventilation_gust": "Ventilation: use wind gusts",
|
||||||
|
"ventilation_cross": "Ventilation: cross-ventilation between zones"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"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."
|
"room_stale_use_last_value": "Off: a room sensor older than its report-age limit is left out and the other rooms are re-weighted. On: its last value is still used, for up to 24 hours without a report; for sensors that report only when the value changes. Unavailable or invalid room sensors are always left out. Nothing pauses unless no room is usable.",
|
||||||
|
"internal_heat_half_life_minutes": "Forecasts assume heat above the baseline fades with this half-life, for example when a gaming session ends. 0 assumes it stops now.",
|
||||||
|
"window_open_minutes": "Forecasts assume an open window closes after this many more minutes, then the room recovers.",
|
||||||
|
"recovery_minutes": "Time constant of the air warming back towards the walls' temperature.",
|
||||||
|
"ventilation_wind": "Needs wind sources; direction also needs wind direction sources.",
|
||||||
|
"ventilation_gust": "Adds a term for gusts above the mean wind. Keep it only if Forecast Error improves.",
|
||||||
|
"ventilation_cross": "Adds a term for windows open in several zones at once. Needs windows in two or more zones."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weather": {
|
"weather": {
|
||||||
@@ -80,7 +93,9 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||||
"outside_wind_sources": "Wind speed sources (optional)",
|
"outside_wind_sources": "Wind speed sources (optional)",
|
||||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
"outside_clouds_sources": "Cloud coverage sources (optional)",
|
||||||
|
"outside_wind_direction_sources": "Wind direction sources (optional; degrees the wind comes from)",
|
||||||
|
"outside_wind_gust_sources": "Wind gust sources (optional)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weather_extra": {
|
"weather_extra": {
|
||||||
@@ -94,14 +109,9 @@
|
|||||||
"outside_rain_sources": "Rain sources (optional)",
|
"outside_rain_sources": "Rain sources (optional)",
|
||||||
"outside_snow_sources": "Snow sources (optional)"
|
"outside_snow_sources": "Snow sources (optional)"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"weights": {
|
|
||||||
"title": "Room priorities",
|
|
||||||
"description": "Weights determine relative comfort importance, not physics coefficients."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,7 +121,9 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"waiting_for_inputs": "Waiting for inputs",
|
"waiting_for_inputs": "Waiting for inputs",
|
||||||
"first_sample": "First sample",
|
"first_sample": "First sample",
|
||||||
|
"sensors_changed": "Zone sensors changed",
|
||||||
"learned": "Learned",
|
"learned": "Learned",
|
||||||
|
"ventilation": "Window open or recovering",
|
||||||
"partly_rejected": "Partly rejected (outlier)",
|
"partly_rejected": "Partly rejected (outlier)",
|
||||||
"interval_out_of_range": "Irregular interval",
|
"interval_out_of_range": "Irregular interval",
|
||||||
"relay_changed": "Relay switched",
|
"relay_changed": "Relay switched",
|
||||||
@@ -120,5 +132,187 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"config_subentries": {
|
||||||
|
"zone": {
|
||||||
|
"entry_type": "Zone",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Add zone",
|
||||||
|
"reconfigure": "Edit zone"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Thermal zone",
|
||||||
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensors": "Temperature sensors",
|
||||||
|
"comfort_weight": "Comfort weight",
|
||||||
|
"target_offset": "Target offset (°C)",
|
||||||
|
"neighbors": "Adjacent zones (heat exchange)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||||
|
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||||
|
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sensor_weights": {
|
||||||
|
"title": "Sensor weights",
|
||||||
|
"description": "How much each sensor counts in the zone temperature (0.1–10)."
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Thermal zone",
|
||||||
|
"description": "A room or area of the apartment. Sensor weights combine several sensors in one room; the comfort weight says how much the zone matters to the MPC. Neither changes the zone's learned thermal properties.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensors": "Temperature sensors",
|
||||||
|
"comfort_weight": "Comfort weight",
|
||||||
|
"target_offset": "Target offset (°C)",
|
||||||
|
"neighbors": "Adjacent zones (heat exchange)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Relative importance for the MPC (0.1–10).",
|
||||||
|
"target_offset": "Added to the thermostat's target temperature for this zone.",
|
||||||
|
"neighbors": "Zones this one exchanges heat with, for example through a wall or an open door. The coefficient is learned only when the data supports it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"zone_name_required": "Enter a name.",
|
||||||
|
"zone_sensors": "Choose 1–6 different sensors.",
|
||||||
|
"sensor_in_other_zone": "A chosen sensor already belongs to another zone.",
|
||||||
|
"sensor_is_weather_source": "A chosen sensor is used as an outdoor temperature source.",
|
||||||
|
"too_many_zones": "At most 12 zones are supported."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "The zone was updated."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"heat_source": {
|
||||||
|
"entry_type": "Heat source",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Add heat source",
|
||||||
|
"reconfigure": "Edit heat source"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Electrical heat source",
|
||||||
|
"description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensor": "Active power sensor",
|
||||||
|
"zone": "Zone",
|
||||||
|
"heat_fraction": "Share of power that becomes heat in the zone",
|
||||||
|
"baseline_mode": "Baseline",
|
||||||
|
"baseline_w": "Manual baseline (W)",
|
||||||
|
"included_in": "Already included in the measurement of",
|
||||||
|
"response_minutes": "Thermal response time (minutes)",
|
||||||
|
"enabled": "Enabled"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.",
|
||||||
|
"baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).",
|
||||||
|
"included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.",
|
||||||
|
"response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Electrical heat source",
|
||||||
|
"description": "Equipment whose electrical power ends up as heat in a zone, such as a UPS with computers. Only the variation above a persistent baseline is modeled: the constant part is already part of the zone's learned behavior. Use active power (W or kW), not VA or load %.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensor": "Active power sensor",
|
||||||
|
"zone": "Zone",
|
||||||
|
"heat_fraction": "Share of power that becomes heat in the zone",
|
||||||
|
"baseline_mode": "Baseline",
|
||||||
|
"baseline_w": "Manual baseline (W)",
|
||||||
|
"included_in": "Already included in the measurement of",
|
||||||
|
"response_minutes": "Thermal response time (minutes)",
|
||||||
|
"enabled": "Enabled"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"heat_fraction": "1.0 when the equipment is in this zone. Lower it if part of the heat goes elsewhere, for example out of a vent.",
|
||||||
|
"baseline_mode": "Automatic uses the low end of the last 24 hours (the persistent load).",
|
||||||
|
"included_in": "Choose the UPS or meter whose reading already contains this device, so its power is not counted twice.",
|
||||||
|
"response_minutes": "How quickly the zone feels a change in power: room air, furniture and equipment take time to warm."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"source_name_required": "Enter a name.",
|
||||||
|
"sensor_in_other_source": "This power sensor is already used by another heat source.",
|
||||||
|
"invalid_parent_source": "The parent must be an existing source that is not itself included in another.",
|
||||||
|
"source_has_children": "Other sources are included in this one, so it cannot be included in another.",
|
||||||
|
"baseline_required": "Enter the manual baseline in W.",
|
||||||
|
"too_many_sources": "At most 16 heat sources are supported."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "The heat source was updated.",
|
||||||
|
"no_zones": "Add a zone first."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"entry_type": "Window",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Add window",
|
||||||
|
"reconfigure": "Edit window"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Window",
|
||||||
|
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensor": "Contact sensor",
|
||||||
|
"zone": "Zone",
|
||||||
|
"inverted": "Sensor reports on when closed",
|
||||||
|
"azimuth": "Facing direction (°)",
|
||||||
|
"opening_factor": "Opening factor",
|
||||||
|
"enabled": "Enabled"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
|
||||||
|
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
|
||||||
|
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Window",
|
||||||
|
"description": "A window contact sensor. Open-window periods are kept out of the zone's normal heat-loss learning and modeled as ventilation instead. Unknown or unavailable states count as unknown, never as closed.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"sensor": "Contact sensor",
|
||||||
|
"zone": "Zone",
|
||||||
|
"inverted": "Sensor reports on when closed",
|
||||||
|
"azimuth": "Facing direction (°)",
|
||||||
|
"opening_factor": "Opening factor",
|
||||||
|
"enabled": "Enabled"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"inverted": "Turn on for sensors whose polarity is the reverse of a standard window sensor.",
|
||||||
|
"azimuth": "Direction the outside of the window faces, clockwise from north (0 = N, 90 = E, 180 = S, 270 = W). Used with the wind direction.",
|
||||||
|
"opening_factor": "Relative ventilation when open: 1.0 for a normal window, lower for tilted, higher for a large or double window."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"window_name_required": "Enter a name.",
|
||||||
|
"sensor_in_other_window": "This sensor is already used by another window.",
|
||||||
|
"too_many_windows": "At most 24 windows are supported."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "The window was updated.",
|
||||||
|
"no_zones": "Add a zone first."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"baseline_mode": {
|
||||||
|
"options": {
|
||||||
|
"auto": "Automatic (24-hour low)",
|
||||||
|
"manual": "Manual value"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,9 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)",
|
"outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)",
|
||||||
"outside_wind_sources": "Senzori viteză vânt (opțional)",
|
"outside_wind_sources": "Senzori viteză vânt (opțional)",
|
||||||
"outside_clouds_sources": "Senzori nebulozitate (opțional)"
|
"outside_clouds_sources": "Senzori nebulozitate (opțional)",
|
||||||
|
"outside_wind_direction_sources": "Surse pentru direcția vântului (opțional; de unde bate, în grade)",
|
||||||
|
"outside_wind_gust_sources": "Surse pentru rafale de vânt (opțional)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weather_extra": {
|
"weather_extra": {
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
},
|
},
|
||||||
"weights": {
|
"weights": {
|
||||||
"title": "Ponderile camerelor",
|
"title": "Ponderile camerelor",
|
||||||
"description": "Fiecare cameră are o pondere de confort între 0,1 și 10; senzorul este indicat în numele câmpului."
|
"description": "Fiecare senzor ales devine o zonă proprie, cu această pondere de confort. Ulterior poți grupa senzorii unei camere într-o singură zonă cu Adaugă zonă sau modificând o zonă."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -59,7 +61,6 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"climate_entity": "Termostatul Generic Thermostat existent",
|
"climate_entity": "Termostatul Generic Thermostat existent",
|
||||||
"heater_entity": "Releul fizic (numai citire)",
|
"heater_entity": "Releul fizic (numai citire)",
|
||||||
"room_entities": "Senzorii individuali din camere",
|
|
||||||
"horizon_minutes": "Orizontul predicției (minute)",
|
"horizon_minutes": "Orizontul predicției (minute)",
|
||||||
"minimum_on_minutes": "Durată minimă ON (minute)",
|
"minimum_on_minutes": "Durată minimă ON (minute)",
|
||||||
"minimum_off_minutes": "Durată minimă OFF (minute)",
|
"minimum_off_minutes": "Durată minimă OFF (minute)",
|
||||||
@@ -68,10 +69,22 @@
|
|||||||
"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ă"
|
"room_stale_use_last_value": "Folosește în continuare ultima valoare a unui senzor de cameră care nu mai raportează",
|
||||||
|
"internal_heat_half_life_minutes": "Căldură electrică suplimentară: timp de înjumătățire în prognoză (minute)",
|
||||||
|
"window_open_minutes": "Fereastră deschisă: timp rămas presupus (minute)",
|
||||||
|
"recovery_minutes": "Revenire după închiderea ferestrei (minute)",
|
||||||
|
"ventilation_wind": "Aerisire: folosește viteza și direcția vântului",
|
||||||
|
"ventilation_gust": "Aerisire: folosește rafalele de vânt",
|
||||||
|
"ventilation_cross": "Aerisire: curent între zone"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"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ă."
|
"room_stale_use_last_value": "Dezactivat: un senzor de cameră mai vechi decât limita de raportare este ignorat, iar ponderile celorlalte camere sunt recalculate. Activat: ultima lui valoare este folosită în continuare, cel mult 24 de ore fără raportare; pentru senzorii care raportează doar la schimbarea valorii. Senzorii de cameră indisponibili sau invalizi sunt mereu ignorați. Nimic nu se oprește decât dacă nicio cameră nu poate fi folosită.",
|
||||||
|
"internal_heat_half_life_minutes": "Prognozele presupun că puterea peste consumul de bază scade cu acest timp de înjumătățire, de exemplu la finalul unei sesiuni de jocuri. 0 presupune că se oprește acum.",
|
||||||
|
"window_open_minutes": "Prognozele presupun că o fereastră deschisă se închide după atâtea minute, apoi camera revine.",
|
||||||
|
"recovery_minutes": "Constanta de timp a aerului care se încălzește din nou spre temperatura pereților.",
|
||||||
|
"ventilation_wind": "Necesită surse de vânt; direcția necesită și surse pentru direcția vântului.",
|
||||||
|
"ventilation_gust": "Adaugă un termen pentru rafalele peste vântul mediu. Păstrează-l doar dacă eroarea de prognoză scade.",
|
||||||
|
"ventilation_cross": "Adaugă un termen pentru ferestre deschise în mai multe zone deodată. Necesită ferestre în cel puțin două zone."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weather": {
|
"weather": {
|
||||||
@@ -80,7 +93,9 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)",
|
"outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)",
|
||||||
"outside_wind_sources": "Senzori viteză vânt (opțional)",
|
"outside_wind_sources": "Senzori viteză vânt (opțional)",
|
||||||
"outside_clouds_sources": "Senzori nebulozitate (opțional)"
|
"outside_clouds_sources": "Senzori nebulozitate (opțional)",
|
||||||
|
"outside_wind_direction_sources": "Surse pentru direcția vântului (opțional; de unde bate, în grade)",
|
||||||
|
"outside_wind_gust_sources": "Surse pentru rafale de vânt (opțional)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weather_extra": {
|
"weather_extra": {
|
||||||
@@ -94,14 +109,9 @@
|
|||||||
"outside_rain_sources": "Senzori precipitații ploaie (opțional)",
|
"outside_rain_sources": "Senzori precipitații ploaie (opțional)",
|
||||||
"outside_snow_sources": "Senzori precipitații ninsoare (opțional)"
|
"outside_snow_sources": "Senzori precipitații ninsoare (opțional)"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"weights": {
|
|
||||||
"title": "Ponderi pentru camere",
|
|
||||||
"description": "Ponderile afectează importanța confortului, nu coeficienții fizici."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,7 +121,9 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"waiting_for_inputs": "Așteaptă datele de intrare",
|
"waiting_for_inputs": "Așteaptă datele de intrare",
|
||||||
"first_sample": "Prima măsurătoare",
|
"first_sample": "Prima măsurătoare",
|
||||||
|
"sensors_changed": "Senzorii zonei s-au schimbat",
|
||||||
"learned": "Învățat",
|
"learned": "Învățat",
|
||||||
|
"ventilation": "Fereastră deschisă sau revenire după aerisire",
|
||||||
"partly_rejected": "Respins parțial (valoare aberantă)",
|
"partly_rejected": "Respins parțial (valoare aberantă)",
|
||||||
"interval_out_of_range": "Interval neregulat",
|
"interval_out_of_range": "Interval neregulat",
|
||||||
"relay_changed": "Releul a comutat",
|
"relay_changed": "Releul a comutat",
|
||||||
@@ -120,5 +132,187 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"config_subentries": {
|
||||||
|
"zone": {
|
||||||
|
"entry_type": "Zonă",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Adaugă zonă",
|
||||||
|
"reconfigure": "Modifică zona"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Zonă termică",
|
||||||
|
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei.",
|
||||||
|
"data": {
|
||||||
|
"name": "Nume",
|
||||||
|
"sensors": "Senzori de temperatură",
|
||||||
|
"comfort_weight": "Pondere de confort",
|
||||||
|
"target_offset": "Abatere de la țintă (°C)",
|
||||||
|
"neighbors": "Zone învecinate (schimb de căldură)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Importanța relativă pentru MPC (0,1–10).",
|
||||||
|
"target_offset": "Se adaugă la temperatura țintă a termostatului pentru această zonă.",
|
||||||
|
"neighbors": "Zonele cu care această zonă face schimb de căldură, de exemplu printr-un perete sau o ușă deschisă. Coeficientul este învățat doar dacă datele îl susțin."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sensor_weights": {
|
||||||
|
"title": "Ponderile senzorilor",
|
||||||
|
"description": "Cât contează fiecare senzor în temperatura zonei (0,1–10)."
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Zonă termică",
|
||||||
|
"description": "O cameră sau o zonă a apartamentului. Ponderile senzorilor combină mai mulți senzori din aceeași cameră; ponderea de confort spune cât de importantă este zona pentru MPC. Niciuna nu modifică proprietățile termice învățate ale zonei.",
|
||||||
|
"data": {
|
||||||
|
"name": "Nume",
|
||||||
|
"sensors": "Senzori de temperatură",
|
||||||
|
"comfort_weight": "Pondere de confort",
|
||||||
|
"target_offset": "Abatere de la țintă (°C)",
|
||||||
|
"neighbors": "Zone învecinate (schimb de căldură)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"comfort_weight": "Importanța relativă pentru MPC (0,1–10).",
|
||||||
|
"target_offset": "Se adaugă la temperatura țintă a termostatului pentru această zonă.",
|
||||||
|
"neighbors": "Zonele cu care această zonă face schimb de căldură, de exemplu printr-un perete sau o ușă deschisă. Coeficientul este învățat doar dacă datele îl susțin."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"zone_name_required": "Introdu un nume.",
|
||||||
|
"zone_sensors": "Alege între 1 și 6 senzori diferiți.",
|
||||||
|
"sensor_in_other_zone": "Un senzor ales aparține deja altei zone.",
|
||||||
|
"sensor_is_weather_source": "Un senzor ales este folosit ca sursă de temperatură exterioară.",
|
||||||
|
"too_many_zones": "Sunt permise cel mult 12 zone."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "Zona a fost actualizată."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"heat_source": {
|
||||||
|
"entry_type": "Sursă de căldură",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Adaugă sursă de căldură",
|
||||||
|
"reconfigure": "Modifică sursa de căldură"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Sursă electrică de căldură",
|
||||||
|
"description": "Echipamente a căror putere electrică ajunge căldură într-o zonă, de exemplu un UPS cu calculatoare. Se modelează doar variația peste un consum de bază persistent: partea constantă face deja parte din comportamentul învățat al zonei. Folosește putere activă (W sau kW), nu VA sau procent de încărcare.",
|
||||||
|
"data": {
|
||||||
|
"name": "Nume",
|
||||||
|
"sensor": "Senzor de putere activă",
|
||||||
|
"zone": "Zonă",
|
||||||
|
"heat_fraction": "Partea din putere care devine căldură în zonă",
|
||||||
|
"baseline_mode": "Consum de bază",
|
||||||
|
"baseline_w": "Consum de bază manual (W)",
|
||||||
|
"included_in": "Inclusă deja în măsurătoarea pentru",
|
||||||
|
"response_minutes": "Timp de răspuns termic (minute)",
|
||||||
|
"enabled": "Activă"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"heat_fraction": "1,0 când echipamentul este în această zonă. Micșorează valoarea dacă o parte din căldură ajunge în altă parte, de exemplu pe o ventilație.",
|
||||||
|
"baseline_mode": "Automat folosește partea de jos a ultimelor 24 de ore (consumul persistent).",
|
||||||
|
"included_in": "Alege UPS-ul sau contorul a cărui citire conține deja acest dispozitiv, ca puterea să nu fie numărată de două ori.",
|
||||||
|
"response_minutes": "Cât de repede simte zona o schimbare de putere: aerul, mobila și echipamentele au nevoie de timp să se încălzească."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Sursă electrică de căldură",
|
||||||
|
"description": "Echipamente a căror putere electrică ajunge căldură într-o zonă, de exemplu un UPS cu calculatoare. Se modelează doar variația peste un consum de bază persistent: partea constantă face deja parte din comportamentul învățat al zonei. Folosește putere activă (W sau kW), nu VA sau procent de încărcare.",
|
||||||
|
"data": {
|
||||||
|
"name": "Nume",
|
||||||
|
"sensor": "Senzor de putere activă",
|
||||||
|
"zone": "Zonă",
|
||||||
|
"heat_fraction": "Partea din putere care devine căldură în zonă",
|
||||||
|
"baseline_mode": "Consum de bază",
|
||||||
|
"baseline_w": "Consum de bază manual (W)",
|
||||||
|
"included_in": "Inclusă deja în măsurătoarea pentru",
|
||||||
|
"response_minutes": "Timp de răspuns termic (minute)",
|
||||||
|
"enabled": "Activă"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"heat_fraction": "1,0 când echipamentul este în această zonă. Micșorează valoarea dacă o parte din căldură ajunge în altă parte, de exemplu pe o ventilație.",
|
||||||
|
"baseline_mode": "Automat folosește partea de jos a ultimelor 24 de ore (consumul persistent).",
|
||||||
|
"included_in": "Alege UPS-ul sau contorul a cărui citire conține deja acest dispozitiv, ca puterea să nu fie numărată de două ori.",
|
||||||
|
"response_minutes": "Cât de repede simte zona o schimbare de putere: aerul, mobila și echipamentele au nevoie de timp să se încălzească."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"source_name_required": "Introdu un nume.",
|
||||||
|
"sensor_in_other_source": "Acest senzor de putere este folosit deja de altă sursă.",
|
||||||
|
"invalid_parent_source": "Sursa părinte trebuie să existe și să nu fie ea însăși inclusă în alta.",
|
||||||
|
"source_has_children": "Alte surse sunt incluse în aceasta, deci nu poate fi inclusă în alta.",
|
||||||
|
"baseline_required": "Introdu consumul de bază manual în W.",
|
||||||
|
"too_many_sources": "Sunt permise cel mult 16 surse de căldură."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "Sursa de căldură a fost actualizată.",
|
||||||
|
"no_zones": "Adaugă mai întâi o zonă."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"entry_type": "Fereastră",
|
||||||
|
"initiate_flow": {
|
||||||
|
"user": "Adaugă fereastră",
|
||||||
|
"reconfigure": "Modifică fereastra"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Fereastră",
|
||||||
|
"description": "Un senzor de contact pentru fereastră. Perioadele cu fereastra deschisă nu intră în învățarea pierderilor normale de căldură ale zonei și sunt modelate separat, ca aerisire. Stările necunoscute sau indisponibile sunt tratate ca necunoscute, niciodată ca închise.",
|
||||||
|
"data": {
|
||||||
|
"name": "Nume",
|
||||||
|
"sensor": "Senzor de contact",
|
||||||
|
"zone": "Zonă",
|
||||||
|
"inverted": "Senzorul raportează pornit când e închis",
|
||||||
|
"azimuth": "Orientare (°)",
|
||||||
|
"opening_factor": "Factor de deschidere",
|
||||||
|
"enabled": "Activă"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"inverted": "Activează pentru senzorii cu polaritate inversă față de un senzor de fereastră obișnuit.",
|
||||||
|
"azimuth": "Direcția spre care privește exteriorul ferestrei, în sensul acelor de ceasornic față de nord (0 = N, 90 = E, 180 = S, 270 = V). Se folosește împreună cu direcția vântului.",
|
||||||
|
"opening_factor": "Aerisirea relativă când e deschisă: 1,0 pentru o fereastră obișnuită, mai puțin pentru rabatată, mai mult pentru una mare sau dublă."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Fereastră",
|
||||||
|
"description": "Un senzor de contact pentru fereastră. Perioadele cu fereastra deschisă nu intră în învățarea pierderilor normale de căldură ale zonei și sunt modelate separat, ca aerisire. Stările necunoscute sau indisponibile sunt tratate ca necunoscute, niciodată ca închise.",
|
||||||
|
"data": {
|
||||||
|
"name": "Nume",
|
||||||
|
"sensor": "Senzor de contact",
|
||||||
|
"zone": "Zonă",
|
||||||
|
"inverted": "Senzorul raportează pornit când e închis",
|
||||||
|
"azimuth": "Orientare (°)",
|
||||||
|
"opening_factor": "Factor de deschidere",
|
||||||
|
"enabled": "Activă"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"inverted": "Activează pentru senzorii cu polaritate inversă față de un senzor de fereastră obișnuit.",
|
||||||
|
"azimuth": "Direcția spre care privește exteriorul ferestrei, în sensul acelor de ceasornic față de nord (0 = N, 90 = E, 180 = S, 270 = V). Se folosește împreună cu direcția vântului.",
|
||||||
|
"opening_factor": "Aerisirea relativă când e deschisă: 1,0 pentru o fereastră obișnuită, mai puțin pentru rabatată, mai mult pentru una mare sau dublă."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"window_name_required": "Introdu un nume.",
|
||||||
|
"sensor_in_other_window": "Acest senzor este folosit deja de altă fereastră.",
|
||||||
|
"too_many_windows": "Sunt permise cel mult 24 de ferestre."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "Fereastra a fost actualizată.",
|
||||||
|
"no_zones": "Adaugă mai întâi o zonă."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"baseline_mode": {
|
||||||
|
"options": {
|
||||||
|
"auto": "Automat (minimul ultimelor 24 de ore)",
|
||||||
|
"manual": "Valoare manuală"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,8 @@ LIMITS = {
|
|||||||
"temperature": (-45.0, 55.0),
|
"temperature": (-45.0, 55.0),
|
||||||
"feels_like": (-65.0, 65.0),
|
"feels_like": (-65.0, 65.0),
|
||||||
"wind": (0.0, 90.0), # m/s
|
"wind": (0.0, 90.0), # m/s
|
||||||
|
"wind_gust": (0.0, 120.0), # m/s
|
||||||
|
"wind_direction": (0.0, 360.0), # degrees the wind comes from
|
||||||
"clouds": (0.0, 100.0), # percentage
|
"clouds": (0.0, 100.0), # percentage
|
||||||
"humidity": (0.0, 100.0), # percentage
|
"humidity": (0.0, 100.0), # percentage
|
||||||
"pressure": (800.0, 1100.0), # hPa
|
"pressure": (800.0, 1100.0), # hPa
|
||||||
@@ -52,11 +54,20 @@ def normalize(metric: str, raw: object, unit: str | None = None) -> float | None
|
|||||||
value = (value - 32.0) * 5.0 / 9.0
|
value = (value - 32.0) * 5.0 / 9.0
|
||||||
elif unit not in ("", "°c", "c", "celsius"):
|
elif unit not in ("", "°c", "c", "celsius"):
|
||||||
return None
|
return None
|
||||||
elif metric == "wind":
|
elif metric == "wind_direction":
|
||||||
|
if unit not in ("", "°", "deg", "degree", "degrees"):
|
||||||
|
return None
|
||||||
|
if value == 360.0:
|
||||||
|
value = 0.0
|
||||||
|
elif metric in ("wind", "wind_gust"):
|
||||||
if unit in ("km/h", "kmh", "kph"):
|
if unit in ("km/h", "kmh", "kph"):
|
||||||
value /= 3.6
|
value /= 3.6
|
||||||
elif unit in ("mph",):
|
elif unit in ("mph",):
|
||||||
value *= 0.44704
|
value *= 0.44704
|
||||||
|
elif unit in ("kn", "kt", "knots"):
|
||||||
|
value *= 0.514444
|
||||||
|
elif unit in ("ft/s",):
|
||||||
|
value *= 0.3048
|
||||||
elif unit not in ("", "m/s", "mps"):
|
elif unit not in ("", "m/s", "mps"):
|
||||||
return None
|
return None
|
||||||
elif metric == "pressure":
|
elif metric == "pressure":
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""Window contact sensors, ventilation, wind exposure and recovery per zone.
|
||||||
|
|
||||||
|
Pure Python; no Home Assistant imports.
|
||||||
|
|
||||||
|
Opening a window adds heat exchange with outdoor air on top of the normal
|
||||||
|
envelope loss. Mixing the two would make a well-insulated room look leaky, so:
|
||||||
|
|
||||||
|
* zones with an open window, an unknown window state, or still recovering after
|
||||||
|
closing are left out of the closed-window model's learning;
|
||||||
|
* a separate ventilation model is fitted to what the closed-window model cannot
|
||||||
|
explain during those periods.
|
||||||
|
|
||||||
|
Its terms, per 5-minute step, with O = sum of open windows' opening factors
|
||||||
|
(weighted by the share of the interval they were open):
|
||||||
|
|
||||||
|
vent_natural x O (T_out - T) buoyancy, always present
|
||||||
|
vent_wind x O v (T_out - T) wind speed v (m/s)
|
||||||
|
vent_wind_dir x sum_w O_w v cos(a_w) (T_out - T) a_w: wind direction vs the
|
||||||
|
window's facing; bounded so a
|
||||||
|
leeward window still ventilates
|
||||||
|
vent_gust x O max(0, gust - v) (T_out - T) optional
|
||||||
|
vent_cross x O O_other (T_out - T) optional: windows open elsewhere
|
||||||
|
recovery x R (T_before - T) after closing, R decays 1 -> 0;
|
||||||
|
air recovers towards the walls
|
||||||
|
|
||||||
|
Wind direction is meteorological (where the wind comes FROM, clockwise from north).
|
||||||
|
A window's azimuth is the direction its outside face points to, so wind from the
|
||||||
|
azimuth blows straight at it (cos = 1) and wind from behind the building gives -1.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from math import atan2, cos, degrees, exp, hypot, isfinite, radians, sin
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
SUBENTRY_WINDOW = "window"
|
||||||
|
MAX_WINDOWS = 24
|
||||||
|
DEFAULT_AZIMUTH = 345.0
|
||||||
|
OPENING_RANGE = (0.1, 3.0)
|
||||||
|
DEFAULT_RECOVERY_MINUTES = 20.0
|
||||||
|
# Recovery below this weight no longer counts as ventilation-affected (~3 time constants).
|
||||||
|
RECOVERY_THRESHOLD = 0.05
|
||||||
|
MAX_EVENTS = 20
|
||||||
|
EVENT_AFTER_MINUTES = 30
|
||||||
|
|
||||||
|
WINDOW_KEY = "key"
|
||||||
|
WINDOW_NAME = "name"
|
||||||
|
WINDOW_SENSOR = "sensor"
|
||||||
|
WINDOW_ZONE = "zone"
|
||||||
|
WINDOW_INVERTED = "inverted"
|
||||||
|
WINDOW_AZIMUTH = "azimuth"
|
||||||
|
WINDOW_OPENING = "opening_factor"
|
||||||
|
WINDOW_ENABLED = "enabled"
|
||||||
|
|
||||||
|
VENTILATION_FEATURES = ("vent_natural", "vent_wind", "vent_wind_dir", "vent_gust", "vent_cross", "recovery")
|
||||||
|
|
||||||
|
|
||||||
|
def angle_difference(a: float, b: float) -> float:
|
||||||
|
"""Signed smallest difference a - b in degrees, in [-180, 180)."""
|
||||||
|
return (a - b + 180.0) % 360.0 - 180.0
|
||||||
|
|
||||||
|
|
||||||
|
def facing_cos(wind_from: float, azimuth: float) -> float:
|
||||||
|
"""1 when the wind blows straight at a window facing `azimuth`, -1 from behind."""
|
||||||
|
return cos(radians(angle_difference(wind_from, azimuth)))
|
||||||
|
|
||||||
|
|
||||||
|
def circular_mean(angles: Iterable[float], weights: Iterable[float] | None = None) -> float | None:
|
||||||
|
"""Mean direction in [0, 360); None when the directions cancel out."""
|
||||||
|
angles = list(angles)
|
||||||
|
weights = list(weights) if weights is not None else [1.0] * len(angles)
|
||||||
|
x = sum(w * cos(radians(a)) for a, w in zip(angles, weights))
|
||||||
|
y = sum(w * sin(radians(a)) for a, w in zip(angles, weights))
|
||||||
|
if hypot(x, y) < 1e-9 * max(1.0, sum(abs(w) for w in weights)):
|
||||||
|
return None
|
||||||
|
return degrees(atan2(y, x)) % 360.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Window:
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
sensor: str
|
||||||
|
zone: str
|
||||||
|
inverted: bool = False
|
||||||
|
azimuth: float = DEFAULT_AZIMUTH
|
||||||
|
opening: float = 1.0
|
||||||
|
enabled: bool = True
|
||||||
|
subentry_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def window_from_data(data: Mapping[str, Any], subentry_id: str | None = None) -> Window | None:
|
||||||
|
key = data.get(WINDOW_KEY) or subentry_id
|
||||||
|
sensor, zone = data.get(WINDOW_SENSOR), data.get(WINDOW_ZONE)
|
||||||
|
if not (isinstance(key, str) and isinstance(sensor, str) and sensor.startswith("binary_sensor.")
|
||||||
|
and isinstance(zone, str)):
|
||||||
|
return None
|
||||||
|
name = data.get(WINDOW_NAME)
|
||||||
|
try:
|
||||||
|
azimuth = float(data.get(WINDOW_AZIMUTH, DEFAULT_AZIMUTH)) % 360.0
|
||||||
|
opening = float(data.get(WINDOW_OPENING, 1.0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not isfinite(azimuth) or not isfinite(opening) or not OPENING_RANGE[0] <= opening <= OPENING_RANGE[1]:
|
||||||
|
return None
|
||||||
|
return Window(key=key, name=name if isinstance(name, str) and name.strip() else key, sensor=sensor,
|
||||||
|
zone=zone, inverted=data.get(WINDOW_INVERTED) is True, azimuth=azimuth, opening=opening,
|
||||||
|
enabled=data.get(WINDOW_ENABLED, True) is not False, subentry_id=subentry_id)
|
||||||
|
|
||||||
|
|
||||||
|
def build_windows(items: Iterable[tuple[Mapping[str, Any], str | None]], zone_keys: Iterable[str]) -> list[Window]:
|
||||||
|
zone_keys = set(zone_keys)
|
||||||
|
windows: list[Window] = []
|
||||||
|
for data, subentry_id in items:
|
||||||
|
window = window_from_data(data, subentry_id)
|
||||||
|
if (window is None or window.zone not in zone_keys or len(windows) == MAX_WINDOWS
|
||||||
|
or any(w.key == window.key or w.sensor == window.sensor for w in windows)):
|
||||||
|
continue
|
||||||
|
windows.append(window)
|
||||||
|
return windows
|
||||||
|
|
||||||
|
|
||||||
|
def contact_open(state: str | None, inverted: bool = False) -> bool | None:
|
||||||
|
"""Open/closed from a binary sensor state; unknown and unavailable stay unknown."""
|
||||||
|
if state not in ("on", "off"):
|
||||||
|
return None
|
||||||
|
return (state == "on") != inverted
|
||||||
|
|
||||||
|
|
||||||
|
def open_fraction(was_open: bool | None, is_open: bool | None, last_changed: float,
|
||||||
|
start: float, end: float) -> float | None:
|
||||||
|
"""Share of [start, end] a window was open; None when that is unknown.
|
||||||
|
|
||||||
|
One change inside the interval is located by its timestamp; a change to the
|
||||||
|
same state (an open/close round trip) or a missing state is unknown.
|
||||||
|
"""
|
||||||
|
if was_open is None or is_open is None or end <= start:
|
||||||
|
return None
|
||||||
|
if last_changed <= start:
|
||||||
|
return float(is_open) if was_open == is_open else None
|
||||||
|
if was_open != is_open and last_changed <= end:
|
||||||
|
share = (end - last_changed) / (end - start)
|
||||||
|
return share if is_open else 1.0 - share
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ventilation_vector(features: Iterable[str], temp: float, outside: float,
|
||||||
|
inputs: Mapping[str, Any]) -> list[float] | None:
|
||||||
|
"""Ventilation feature values for one zone and step; None if an input is unknown.
|
||||||
|
|
||||||
|
`inputs`: opening, facing (sum of opening x cos), other_opening, recovery,
|
||||||
|
t_ref (pre-ventilation temperature), wind, gust.
|
||||||
|
"""
|
||||||
|
difference = outside - temp
|
||||||
|
values = []
|
||||||
|
for name in features:
|
||||||
|
opening = inputs.get("opening")
|
||||||
|
if opening is None:
|
||||||
|
return None
|
||||||
|
if name == "vent_natural":
|
||||||
|
values.append(opening * difference)
|
||||||
|
elif name in ("vent_wind", "vent_wind_dir", "vent_gust"):
|
||||||
|
wind = inputs.get("wind")
|
||||||
|
if wind is None:
|
||||||
|
return None
|
||||||
|
if name == "vent_wind":
|
||||||
|
values.append(opening * wind * difference)
|
||||||
|
elif name == "vent_wind_dir":
|
||||||
|
facing = inputs.get("facing")
|
||||||
|
if facing is None:
|
||||||
|
return None
|
||||||
|
values.append(facing * wind * difference)
|
||||||
|
else:
|
||||||
|
gust = inputs.get("gust")
|
||||||
|
if gust is None:
|
||||||
|
return None
|
||||||
|
values.append(opening * max(0.0, gust - wind) * difference)
|
||||||
|
elif name == "vent_cross":
|
||||||
|
values.append(opening * (inputs.get("other_opening") or 0.0) * difference)
|
||||||
|
elif name == "recovery":
|
||||||
|
recovery = inputs.get("recovery") or 0.0
|
||||||
|
t_ref = inputs.get("t_ref")
|
||||||
|
if recovery > 0 and t_ref is None:
|
||||||
|
return None
|
||||||
|
values.append(recovery * ((t_ref if t_ref is not None else temp) - temp))
|
||||||
|
else:
|
||||||
|
raise KeyError(name)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(timestamp: float) -> str:
|
||||||
|
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
class VentilationTracker:
|
||||||
|
"""Window states, ventilation inputs per zone, recovery after closing, event log."""
|
||||||
|
|
||||||
|
def __init__(self, windows: list[Window], recovery_minutes: float = DEFAULT_RECOVERY_MINUTES,
|
||||||
|
saved: Any = None) -> None:
|
||||||
|
self.windows = windows
|
||||||
|
self.tau = max(1.0, recovery_minutes) * 60.0
|
||||||
|
self._previous: dict[str, tuple[float, bool | None]] = {} # window -> (poll time, open)
|
||||||
|
self._closed_at: dict[str, float] = {} # zone -> when its last window closed
|
||||||
|
self._t_ref: dict[str, float | None] = {} # zone -> temperature before it opened
|
||||||
|
self._last_temps: dict[str, float | None] = {}
|
||||||
|
self._open_event: dict[str, dict[str, Any]] = {}
|
||||||
|
saved = saved if isinstance(saved, Mapping) else {}
|
||||||
|
events = saved.get("events") if isinstance(saved.get("events"), list) else []
|
||||||
|
self.events: deque[dict[str, Any]] = deque(
|
||||||
|
(e for e in events if isinstance(e, dict)), maxlen=MAX_EVENTS)
|
||||||
|
self.window_state: dict[str, dict[str, Any]] = {}
|
||||||
|
self.zone_state: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def update(self, now: float, states: Mapping[str, tuple[str | None, float | None]],
|
||||||
|
temps: Mapping[str, float | None], weather: Mapping[str, float | None]) -> None:
|
||||||
|
"""One poll. `states`: window key -> (binary sensor state, last_changed timestamp)."""
|
||||||
|
self.window_state = {}
|
||||||
|
zones: dict[str, dict[str, Any]] = {}
|
||||||
|
for window in self.windows:
|
||||||
|
if not window.enabled:
|
||||||
|
continue
|
||||||
|
state, changed = states.get(window.key, (None, None))
|
||||||
|
is_open = contact_open(state, window.inverted)
|
||||||
|
previous = self._previous.get(window.key)
|
||||||
|
fraction = None
|
||||||
|
if previous and changed is not None and 0 < now - previous[0] <= 900:
|
||||||
|
fraction = open_fraction(previous[1], is_open, changed, previous[0], now)
|
||||||
|
self._previous[window.key] = (now, is_open)
|
||||||
|
zone = zones.setdefault(window.zone, {"opening": 0.0, "facing_unit": 0.0, "known": True,
|
||||||
|
"open_now": False, "open_windows": [], "last_close": None,
|
||||||
|
"first_open": None})
|
||||||
|
if fraction is None:
|
||||||
|
zone["known"] = False
|
||||||
|
else:
|
||||||
|
zone["opening"] += window.opening * fraction
|
||||||
|
zone["facing_unit"] += window.opening * fraction * (
|
||||||
|
facing_cos(weather["wind_direction"], window.azimuth)
|
||||||
|
if weather.get("wind_direction") is not None else 0.0)
|
||||||
|
if is_open:
|
||||||
|
zone["open_now"] = True
|
||||||
|
zone["open_windows"].append(window.name)
|
||||||
|
if changed is not None:
|
||||||
|
zone["first_open"] = min(zone["first_open"] or changed, changed)
|
||||||
|
elif is_open is False and previous and changed is not None and changed > previous[0]:
|
||||||
|
zone["last_close"] = max(zone["last_close"] or 0.0, changed)
|
||||||
|
self.window_state[window.key] = {
|
||||||
|
"open": is_open, "since": changed,
|
||||||
|
"relative_wind_direction": (round(angle_difference(weather["wind_direction"], window.azimuth), 1)
|
||||||
|
if weather.get("wind_direction") is not None else None)}
|
||||||
|
for key, zone in zones.items():
|
||||||
|
self._update_zone(now, key, zone, temps.get(key), weather, zones)
|
||||||
|
self.zone_state = zones
|
||||||
|
self._last_temps = dict(temps)
|
||||||
|
|
||||||
|
def _update_zone(self, now: float, key: str, zone: dict[str, Any], temp: float | None,
|
||||||
|
weather: Mapping[str, float | None], zones: Mapping[str, dict[str, Any]]) -> None:
|
||||||
|
was_open = key in self._open_event
|
||||||
|
if zone["open_now"] and not was_open:
|
||||||
|
# Temperature at the previous poll approximates the room before ventilation.
|
||||||
|
self._t_ref[key] = self._last_temps.get(key)
|
||||||
|
self._closed_at.pop(key, None)
|
||||||
|
self._open_event[key] = {
|
||||||
|
"zone": key, "opened": _iso(min(zone["first_open"] or now, now)),
|
||||||
|
"temperature_before": self._t_ref[key],
|
||||||
|
"outside": weather.get("temperature"), "wind": weather.get("wind"),
|
||||||
|
"wind_direction": weather.get("wind_direction"), "wind_gust": weather.get("wind_gust"),
|
||||||
|
"windows": list(zone["open_windows"]),
|
||||||
|
"other_open_windows": [n for k, z in zones.items() if k != key for n in z["open_windows"]],
|
||||||
|
"lowest_temperature": temp,
|
||||||
|
}
|
||||||
|
elif zone["open_now"] and temp is not None:
|
||||||
|
event = self._open_event[key]
|
||||||
|
event["lowest_temperature"] = min(t for t in (event["lowest_temperature"], temp) if t is not None)
|
||||||
|
elif was_open and not zone["open_now"]:
|
||||||
|
event = self._open_event.pop(key)
|
||||||
|
closed = zone["last_close"] or now
|
||||||
|
self._closed_at[key] = closed
|
||||||
|
event.update(closed=_iso(closed), duration_minutes=round((closed - _parse(event["opened"])) / 60, 1),
|
||||||
|
temperature_at_close=temp)
|
||||||
|
self.events.append(event)
|
||||||
|
elif not zone["open_now"] and key in self._closed_at and temp is not None:
|
||||||
|
# Fill in the temperature half an hour after closing, for recovery review.
|
||||||
|
for event in reversed(self.events):
|
||||||
|
if event.get("zone") == key and "temperature_after" not in event:
|
||||||
|
if now - self._closed_at[key] >= EVENT_AFTER_MINUTES * 60:
|
||||||
|
event["temperature_after"] = temp
|
||||||
|
event["temperature_after_minutes"] = EVENT_AFTER_MINUTES
|
||||||
|
break
|
||||||
|
closed = self._closed_at.get(key)
|
||||||
|
recovery = exp(-(now - closed) / self.tau) if closed is not None and not zone["open_now"] else 0.0
|
||||||
|
if recovery < RECOVERY_THRESHOLD:
|
||||||
|
recovery = 0.0
|
||||||
|
zone.update(recovery=recovery, t_ref=self._t_ref.get(key),
|
||||||
|
other_opening=sum(z["opening"] for k, z in zones.items() if k != key and z["known"]))
|
||||||
|
# Learning of the closed-window model is unsafe while anything ventilation-related goes on.
|
||||||
|
zone["affected"] = (not zone["known"]) or zone["opening"] > 0 or zone["open_now"] or recovery > 0
|
||||||
|
|
||||||
|
def zone_inputs(self, key: str, weather: Mapping[str, float | None]) -> dict[str, Any] | None:
|
||||||
|
"""Ventilation inputs of the interval that just ended; None for zones without windows."""
|
||||||
|
zone = self.zone_state.get(key)
|
||||||
|
if zone is None:
|
||||||
|
return None
|
||||||
|
return {"opening": zone["opening"] if zone["known"] else None, "facing": zone["facing_unit"],
|
||||||
|
"other_opening": zone["other_opening"], "recovery": zone["recovery"],
|
||||||
|
"t_ref": zone["t_ref"], "wind": weather.get("wind"), "gust": weather.get("wind_gust"),
|
||||||
|
"affected": zone["affected"], "known": zone["known"]}
|
||||||
|
|
||||||
|
def forecast(self, key: str, steps: int, open_minutes: float, weather: Mapping[str, float | None],
|
||||||
|
now: float) -> list[dict[str, Any]]:
|
||||||
|
"""Assumed ventilation inputs per forecast step.
|
||||||
|
|
||||||
|
Windows open now are assumed to close after `open_minutes` (at least one
|
||||||
|
step); after that the zone recovers. Weather is held at current values.
|
||||||
|
"""
|
||||||
|
zone = self.zone_state.get(key)
|
||||||
|
if zone is None:
|
||||||
|
return [{} for _ in range(steps)]
|
||||||
|
windows = [w for w in self.windows if w.zone == key and w.enabled
|
||||||
|
and self.window_state.get(w.key, {}).get("open")]
|
||||||
|
opening = sum(w.opening for w in windows)
|
||||||
|
facing = sum(w.opening * facing_cos(weather["wind_direction"], w.azimuth) for w in windows) \
|
||||||
|
if weather.get("wind_direction") is not None else 0.0
|
||||||
|
open_steps = max(1, round(open_minutes / 5)) if windows else 0
|
||||||
|
closed_at = self._closed_at.get(key)
|
||||||
|
result = []
|
||||||
|
for k in range(steps):
|
||||||
|
t = now + (k + 1) * 300.0
|
||||||
|
if k < open_steps:
|
||||||
|
result.append({"opening": opening, "facing": facing, "recovery": 0.0, "t_ref": zone["t_ref"],
|
||||||
|
"other_opening": zone["other_opening"], "wind": weather.get("wind") or 0.0,
|
||||||
|
"gust": weather.get("wind_gust") or weather.get("wind") or 0.0})
|
||||||
|
continue
|
||||||
|
start = now + open_steps * 300.0 if windows else closed_at
|
||||||
|
recovery = exp(-(t - start) / self.tau) if start is not None else 0.0
|
||||||
|
result.append({"opening": 0.0, "facing": 0.0, "other_opening": 0.0, "wind": 0.0, "gust": 0.0,
|
||||||
|
"recovery": recovery if recovery >= RECOVERY_THRESHOLD else 0.0,
|
||||||
|
"t_ref": zone["t_ref"]})
|
||||||
|
return result
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {"events": list(self.events)}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(iso: str) -> float:
|
||||||
|
return datetime.fromisoformat(iso).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_window(data: Mapping[str, Any], others: Iterable[Window]) -> str | None:
|
||||||
|
others = list(others)
|
||||||
|
if not str(data.get(WINDOW_NAME, "")).strip():
|
||||||
|
return "window_name_required"
|
||||||
|
if any(w.sensor == data.get(WINDOW_SENSOR) for w in others):
|
||||||
|
return "sensor_in_other_window"
|
||||||
|
if len(others) >= MAX_WINDOWS:
|
||||||
|
return "too_many_windows"
|
||||||
|
return None
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Per-zone thermal model: base model plus optional extended features.
|
||||||
|
|
||||||
|
Pure Python; no Home Assistant imports. Per 5-minute step, for zone i:
|
||||||
|
|
||||||
|
dT_i = loss x (T_out - T_i) + heat x lag + bias (base, always learned)
|
||||||
|
+ internal x q_i (electrical heat above baseline, kW)
|
||||||
|
+ sum_j couple:j x (T_j - T_i) (adjacent zones)
|
||||||
|
|
||||||
|
The base model is the 3-parameter model of earlier versions, kept as fallback
|
||||||
|
and as the comparison that shows whether the extra features earn their keep.
|
||||||
|
The extended model learns the same terms plus the configured extras, only from
|
||||||
|
samples where all of its inputs are known.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
|
from .const import READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES
|
||||||
|
from .learning import FeatureLearner
|
||||||
|
from .physics import Learner
|
||||||
|
from .windows import ventilation_vector
|
||||||
|
|
||||||
|
BASE_FEATURES = ["loss", "heat", "bias"]
|
||||||
|
|
||||||
|
|
||||||
|
def ready(model: Learner | FeatureLearner) -> bool:
|
||||||
|
return (model.count >= READY_SAMPLES and model.on_count >= READY_ON_SAMPLES
|
||||||
|
and model.off_count >= READY_OFF_SAMPLES)
|
||||||
|
|
||||||
|
|
||||||
|
def extended_features(has_internal: bool, neighbors: tuple[str, ...]) -> list[str]:
|
||||||
|
"""Features of a zone's extended model; empty when it would equal the base."""
|
||||||
|
extra = (["internal"] if has_internal else []) + [f"couple:{n}" for n in neighbors]
|
||||||
|
return BASE_FEATURES + extra if extra else []
|
||||||
|
|
||||||
|
|
||||||
|
def feature_vector(features: list[str], temp: float, inputs: Mapping[str, Any]) -> list[float] | None:
|
||||||
|
"""Values of `features` at the start of a step; None if an input is unknown.
|
||||||
|
|
||||||
|
`inputs`: outside, lag, internal (kW, may be None) and temps (all zones).
|
||||||
|
"""
|
||||||
|
values = []
|
||||||
|
for name in features:
|
||||||
|
if name == "loss":
|
||||||
|
values.append(inputs["outside"] - temp)
|
||||||
|
elif name == "heat":
|
||||||
|
values.append(inputs["lag"])
|
||||||
|
elif name == "bias":
|
||||||
|
values.append(1.0)
|
||||||
|
elif name == "internal":
|
||||||
|
if inputs.get("internal") is None:
|
||||||
|
return None
|
||||||
|
values.append(inputs["internal"])
|
||||||
|
elif name.startswith("couple:"):
|
||||||
|
neighbor = inputs.get("temps", {}).get(name.split(":", 1)[1])
|
||||||
|
if neighbor is None:
|
||||||
|
return None
|
||||||
|
values.append(neighbor - temp)
|
||||||
|
else:
|
||||||
|
raise KeyError(name)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
class ZoneModel:
|
||||||
|
def __init__(self, base: Learner | None = None, extended: FeatureLearner | None = None,
|
||||||
|
ventilation: FeatureLearner | None = None) -> None:
|
||||||
|
self.base = base or Learner()
|
||||||
|
self.extended = extended
|
||||||
|
self.ventilation = ventilation
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uses_extended(self) -> bool:
|
||||||
|
"""Forecast with the extended model once it is as trained as the base needs to be."""
|
||||||
|
return self.extended is not None and ready(self.extended)
|
||||||
|
|
||||||
|
def main_delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
|
||||||
|
"""Closed-window change. Unknown extended inputs fall back to the base model."""
|
||||||
|
if self.uses_extended:
|
||||||
|
x = feature_vector(self.extended.features, temp, inputs)
|
||||||
|
if x is not None:
|
||||||
|
return self.extended.predict(x)
|
||||||
|
return self.base.predict_delta(temp, inputs["outside"], inputs["lag"])
|
||||||
|
|
||||||
|
def ventilation_delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
|
||||||
|
"""Extra change from open windows and recovery; 0 without windows or inputs."""
|
||||||
|
ventilation = inputs.get("ventilation")
|
||||||
|
if self.ventilation is None or not ventilation:
|
||||||
|
return 0.0
|
||||||
|
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||||
|
return self.ventilation.predict(x) if x is not None else 0.0
|
||||||
|
|
||||||
|
def delta(self, temp: float, inputs: Mapping[str, Any]) -> float:
|
||||||
|
"""Predicted 5-minute change of the zone."""
|
||||||
|
return self.main_delta(temp, inputs) + self.ventilation_delta(temp, inputs)
|
||||||
|
|
||||||
|
def contributions(self, temp: float, inputs: Mapping[str, Any]) -> dict[str, float]:
|
||||||
|
"""Each term's share of the predicted change, in °C per hour."""
|
||||||
|
terms = self._main_contributions(temp, inputs)
|
||||||
|
ventilation = inputs.get("ventilation")
|
||||||
|
if self.ventilation is not None and ventilation:
|
||||||
|
x = ventilation_vector(self.ventilation.features, temp, inputs["outside"], ventilation)
|
||||||
|
if x is not None:
|
||||||
|
for name, theta, value in zip(self.ventilation.features, self.ventilation.theta, x):
|
||||||
|
group = "recovery" if name == "recovery" else "ventilation"
|
||||||
|
terms[group] = round(terms.get(group, 0.0) + 12 * theta * value, 3)
|
||||||
|
return terms
|
||||||
|
|
||||||
|
def _main_contributions(self, temp: float, inputs: Mapping[str, Any]) -> dict[str, float]:
|
||||||
|
if self.uses_extended:
|
||||||
|
x = feature_vector(self.extended.features, temp, inputs)
|
||||||
|
if x is not None:
|
||||||
|
terms: dict[str, float] = {}
|
||||||
|
for name, theta, value in zip(self.extended.features, self.extended.theta, x):
|
||||||
|
group = "neighbors" if name.startswith("couple:") else name
|
||||||
|
terms[group] = terms.get(group, 0.0) + 12 * theta * value
|
||||||
|
return {name: round(value, 3) for name, value in terms.items()}
|
||||||
|
a, b, c = self.base.theta
|
||||||
|
return {"loss": round(12 * a * (inputs["outside"] - temp), 3),
|
||||||
|
"heat": round(12 * b * inputs["lag"], 3), "bias": round(12 * c, 3)}
|
||||||
|
|
||||||
|
|
||||||
|
def _probe(model: ZoneModel, key: str, inputs: Mapping[str, Any], state: Mapping[str, float], lag: float) -> float:
|
||||||
|
return model.delta(state[key], {**inputs, "temps": state, "lag": lag})
|
||||||
|
|
||||||
|
|
||||||
|
def compile_step_model(models: Mapping[str, ZoneModel], keys: list[str], steps: int,
|
||||||
|
inputs_at: Callable[[str, int], Mapping[str, Any]]):
|
||||||
|
"""A fast, exact multi-zone step for the optimizer's plan search.
|
||||||
|
|
||||||
|
Every term of ZoneModel.delta is affine in the zone temperatures and the
|
||||||
|
radiator lag (loss, neighbours, ventilation and recovery all scale with
|
||||||
|
temperature differences), and which layer applies depends only on which
|
||||||
|
inputs exist, not on their values. So per zone and step the change is
|
||||||
|
a + sum_j b_j T_j + h lag, with the coefficients read off the real model at
|
||||||
|
a few probe points. The search then does a handful of multiply-adds per step
|
||||||
|
instead of rebuilding feature vectors for every candidate plan.
|
||||||
|
"""
|
||||||
|
zero = {key: 0.0 for key in keys}
|
||||||
|
terms: dict[str, list[tuple[float, list[tuple[str, float]], float]]] = {}
|
||||||
|
for key in keys:
|
||||||
|
model = models[key]
|
||||||
|
related = [key] + ([name.split(":", 1)[1] for name in model.extended.features
|
||||||
|
if name.startswith("couple:") and name.split(":", 1)[1] in zero]
|
||||||
|
if model.extended is not None else [])
|
||||||
|
per_step = []
|
||||||
|
for k in range(steps):
|
||||||
|
inputs = dict(inputs_at(key, k))
|
||||||
|
a = _probe(model, key, inputs, zero, 0.0)
|
||||||
|
per_step.append((a, [(j, _probe(model, key, inputs, {**zero, j: 1.0}, 0.0) - a) for j in related],
|
||||||
|
_probe(model, key, inputs, zero, 1.0) - a))
|
||||||
|
terms[key] = per_step
|
||||||
|
|
||||||
|
def step(state: dict[str, float], lag: float, k: int) -> dict[str, float]:
|
||||||
|
k = min(k, steps - 1)
|
||||||
|
new = {}
|
||||||
|
for key, value in state.items():
|
||||||
|
a, coefficients, h = terms[key][k]
|
||||||
|
new[key] = value + a + h * lag + sum(b * state[j] for j, b in coefficients)
|
||||||
|
return new
|
||||||
|
return step
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Thermal zones: rooms or areas, each with one or more temperature sensors.
|
||||||
|
|
||||||
|
Pure Python; no Home Assistant imports.
|
||||||
|
|
||||||
|
Two kinds of weight are kept apart on purpose:
|
||||||
|
|
||||||
|
* sensor weights combine several sensors in one zone into one temperature;
|
||||||
|
* the comfort weight says how much the zone matters to the MPC.
|
||||||
|
|
||||||
|
Neither is a physical coefficient. A cold bedroom can get a higher comfort
|
||||||
|
weight without its learned heat loss changing.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from math import isfinite
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
SUBENTRY_ZONE = "zone"
|
||||||
|
MAX_ZONES = 12
|
||||||
|
MAX_SENSORS_PER_ZONE = 6
|
||||||
|
COMFORT_WEIGHT_RANGE = (0.1, 10.0)
|
||||||
|
SENSOR_WEIGHT_RANGE = (0.1, 10.0)
|
||||||
|
TARGET_OFFSET_RANGE = (-3.0, 3.0)
|
||||||
|
|
||||||
|
# Subentry data keys.
|
||||||
|
ZONE_KEY = "key"
|
||||||
|
ZONE_NAME = "name"
|
||||||
|
ZONE_SENSORS = "sensors"
|
||||||
|
ZONE_SENSOR_WEIGHTS = "sensor_weights"
|
||||||
|
ZONE_COMFORT_WEIGHT = "comfort_weight"
|
||||||
|
ZONE_TARGET_OFFSET = "target_offset"
|
||||||
|
ZONE_NEIGHBORS = "neighbors"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Zone:
|
||||||
|
# Stable identity for learned models and entity unique IDs. Zones migrated from
|
||||||
|
# 0.3 keep their sensor's entity ID; new zones get a generated key.
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
sensors: tuple[str, ...]
|
||||||
|
sensor_weights: Mapping[str, float] = field(default_factory=dict)
|
||||||
|
comfort_weight: float = 1.0
|
||||||
|
target_offset: float = 0.0
|
||||||
|
neighbors: tuple[str, ...] = ()
|
||||||
|
subentry_id: str | None = None
|
||||||
|
|
||||||
|
def sensor_weight(self, sensor: str) -> float:
|
||||||
|
return float(self.sensor_weights.get(sensor, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any, default: float, lo: float, hi: float) -> float:
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return number if isfinite(number) and lo <= number <= hi else default
|
||||||
|
|
||||||
|
|
||||||
|
def zone_from_data(data: Mapping[str, Any], subentry_id: str | None = None) -> Zone | None:
|
||||||
|
"""Parse one zone's stored data; None if it cannot be used."""
|
||||||
|
key = data.get(ZONE_KEY) or subentry_id
|
||||||
|
sensors = data.get(ZONE_SENSORS)
|
||||||
|
if not isinstance(key, str) or not isinstance(sensors, (list, tuple)):
|
||||||
|
return None
|
||||||
|
sensors = tuple(dict.fromkeys(s for s in sensors if isinstance(s, str) and s.startswith("sensor.")))
|
||||||
|
if not sensors:
|
||||||
|
return None
|
||||||
|
raw_weights = data.get(ZONE_SENSOR_WEIGHTS)
|
||||||
|
raw_weights = raw_weights if isinstance(raw_weights, Mapping) else {}
|
||||||
|
weights = {s: _number(raw_weights.get(s), 1.0, *SENSOR_WEIGHT_RANGE) for s in sensors}
|
||||||
|
neighbors = data.get(ZONE_NEIGHBORS)
|
||||||
|
neighbors = tuple(n for n in neighbors if isinstance(n, str) and n != key) \
|
||||||
|
if isinstance(neighbors, (list, tuple)) else ()
|
||||||
|
name = data.get(ZONE_NAME)
|
||||||
|
return Zone(
|
||||||
|
key=key,
|
||||||
|
name=name if isinstance(name, str) and name.strip() else key,
|
||||||
|
sensors=sensors,
|
||||||
|
sensor_weights=weights,
|
||||||
|
comfort_weight=_number(data.get(ZONE_COMFORT_WEIGHT), 1.0, *COMFORT_WEIGHT_RANGE),
|
||||||
|
target_offset=_number(data.get(ZONE_TARGET_OFFSET), 0.0, *TARGET_OFFSET_RANGE),
|
||||||
|
neighbors=neighbors,
|
||||||
|
subentry_id=subentry_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_zone_data(rooms: Iterable[str], weights: Mapping[str, Any] | None,
|
||||||
|
names: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""Zone data for 0.3 configurations: one zone per room sensor.
|
||||||
|
|
||||||
|
The sensor's entity ID becomes the zone key, so learned models, forecast
|
||||||
|
entities and accuracy statistics carry over unchanged.
|
||||||
|
"""
|
||||||
|
weights = weights if isinstance(weights, Mapping) else {}
|
||||||
|
names = names or {}
|
||||||
|
result = []
|
||||||
|
for room in dict.fromkeys(r for r in rooms if isinstance(r, str)):
|
||||||
|
result.append({
|
||||||
|
ZONE_KEY: room,
|
||||||
|
ZONE_NAME: names.get(room) or room,
|
||||||
|
ZONE_SENSORS: [room],
|
||||||
|
ZONE_SENSOR_WEIGHTS: {room: 1.0},
|
||||||
|
ZONE_COMFORT_WEIGHT: _number(weights.get(room), 1.0, *COMFORT_WEIGHT_RANGE),
|
||||||
|
ZONE_TARGET_OFFSET: 0.0,
|
||||||
|
ZONE_NEIGHBORS: [],
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_zones(items: Iterable[tuple[Mapping[str, Any], str | None]]) -> list[Zone]:
|
||||||
|
"""Valid zones from (data, subentry_id) pairs: unique keys, each sensor in one zone."""
|
||||||
|
zones: list[Zone] = []
|
||||||
|
keys: set[str] = set()
|
||||||
|
taken: set[str] = set()
|
||||||
|
for data, subentry_id in items:
|
||||||
|
zone = zone_from_data(data, subentry_id)
|
||||||
|
if zone is None or zone.key in keys or taken.intersection(zone.sensors):
|
||||||
|
continue
|
||||||
|
zones.append(zone)
|
||||||
|
keys.add(zone.key)
|
||||||
|
taken.update(zone.sensors)
|
||||||
|
if len(zones) == MAX_ZONES:
|
||||||
|
break
|
||||||
|
# Neighbours must exist; exchange is symmetric, so record both directions.
|
||||||
|
linked = {zone.key: set(n for n in zone.neighbors if n in keys) for zone in zones}
|
||||||
|
for key, others in list(linked.items()):
|
||||||
|
for other in others:
|
||||||
|
linked[other].add(key)
|
||||||
|
return [Zone(**{**zone.__dict__, "neighbors": tuple(sorted(linked[zone.key]))}) for zone in zones]
|
||||||
|
|
||||||
|
|
||||||
|
def zone_temperature(zone: Zone, readings: Mapping[str, float | None]) -> tuple[float | None, frozenset[str]]:
|
||||||
|
"""Sensor-weighted mean of the zone's usable readings, and which sensors it used.
|
||||||
|
|
||||||
|
The set of sensors matters for learning: when a sensor drops out or rejoins,
|
||||||
|
the mean jumps by the offset between sensors, which is not a real change.
|
||||||
|
"""
|
||||||
|
used = [s for s in zone.sensors if readings.get(s) is not None]
|
||||||
|
if not used:
|
||||||
|
return None, frozenset()
|
||||||
|
total = sum(zone.sensor_weight(s) for s in used)
|
||||||
|
value = sum(zone.sensor_weight(s) * float(readings[s]) for s in used) / total
|
||||||
|
return value, frozenset(used)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_zone(data: Mapping[str, Any], others: Iterable[Zone], weather_sensors: Iterable[str]) -> str | None:
|
||||||
|
"""Translation key of the first problem with a zone form, or None."""
|
||||||
|
sensors = data.get(ZONE_SENSORS) or []
|
||||||
|
if not str(data.get(ZONE_NAME, "")).strip():
|
||||||
|
return "zone_name_required"
|
||||||
|
if not sensors or len(sensors) > MAX_SENSORS_PER_ZONE or len(set(sensors)) != len(sensors):
|
||||||
|
return "zone_sensors"
|
||||||
|
others = list(others)
|
||||||
|
if any(set(sensors).intersection(zone.sensors) for zone in others):
|
||||||
|
return "sensor_in_other_zone"
|
||||||
|
if set(sensors).intersection(weather_sensors):
|
||||||
|
return "sensor_is_weather_source"
|
||||||
|
if len(others) >= MAX_ZONES:
|
||||||
|
return "too_many_zones"
|
||||||
|
return None
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
# Heating MPC thermal model
|
||||||
|
|
||||||
|
This document describes the model used from version 0.4.0: what it computes, the
|
||||||
|
units and meaning of every coefficient, when each part learns, what the forecasts
|
||||||
|
assume, and how the model is validated. It is observational: nothing here controls
|
||||||
|
the boiler.
|
||||||
|
|
||||||
|
## Time step and inputs
|
||||||
|
|
||||||
|
The model works in 5-minute steps, the polling interval. For each zone *i*, each
|
||||||
|
poll provides:
|
||||||
|
|
||||||
|
| Symbol | Meaning | Unit | Source |
|
||||||
|
|---|---|---|---|
|
||||||
|
| T_i | Zone temperature: sensor-weighted mean of its usable sensors | °C | zone sensors |
|
||||||
|
| T_out | Outdoor temperature (first fresh, valid source) | °C | weather sources |
|
||||||
|
| lag | Radiator-lag proxy, 0 (cold) to 1 (fully warm); 20-minute first-order response to the relay, integrated at the relay's actual switch times | – | relay state + read-only listener |
|
||||||
|
| q_i | Electrical heat above baseline after its thermal response | kW | heat sources |
|
||||||
|
| O_i | Sum of open windows' opening factors, weighted by the share of the interval they were open | – | window sensors |
|
||||||
|
| F_i | Same, each window also weighted by cos(angle between wind direction and the window's facing) | – | windows + wind direction |
|
||||||
|
| v, g | Wind speed and gust speed | m/s | weather sources |
|
||||||
|
| R_i | Recovery weight after the zone's last window closed: exp(−t / τ_rec), 0 below 0.05 | – | window sensors |
|
||||||
|
| T_ref,i | Zone temperature at the poll before the window opened | °C | zone sensors |
|
||||||
|
|
||||||
|
## Equations
|
||||||
|
|
||||||
|
Per 5-minute step, the predicted change of zone *i* is the sum of layers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ΔT_i = loss × (T_out − T_i) envelope heat loss
|
||||||
|
+ heat × lag boiler, through the radiators
|
||||||
|
+ bias constant gains and losses
|
||||||
|
+ internal × q_i electrical heat above baseline (extended layer)
|
||||||
|
+ Σ_j couple:j × (T_j − T_i) exchange with adjacent zones (extended layer)
|
||||||
|
+ vent_natural × O_i (T_out − T_i) (ventilation layer)
|
||||||
|
+ vent_wind × O_i v (T_out − T_i)
|
||||||
|
+ vent_wind_dir × F_i v (T_out − T_i)
|
||||||
|
+ vent_gust × O_i max(0, g − v) (T_out − T_i) (optional)
|
||||||
|
+ vent_cross × O_i O_other (T_out − T_i) (optional)
|
||||||
|
+ recovery × R_i (T_ref,i − T_i)
|
||||||
|
```
|
||||||
|
|
||||||
|
All coefficients are in °C per 5 minutes per unit of their term. They are
|
||||||
|
statistical estimates, not measured physical properties: `heat` is not boiler
|
||||||
|
efficiency, and a relay that is on does not prove the burner is firing.
|
||||||
|
|
||||||
|
In conceptual continuous-time form this corresponds to
|
||||||
|
|
||||||
|
```text
|
||||||
|
C_i dT_i/dt = UA_i (T_out − T_i) + Q_boiler + Q_internal + Q_ventilation + Σ_j K_ij (T_j − T_i) + Q_other
|
||||||
|
```
|
||||||
|
|
||||||
|
divided through by the zone's effective heat capacity C_i and discretized to 5
|
||||||
|
minutes. The model is first-order (one temperature per zone). Thermal inertia
|
||||||
|
enters through the radiator lag, the electrical response filter and the recovery
|
||||||
|
term; a separate wall-mass state is not modeled (see Limitations).
|
||||||
|
|
||||||
|
### Base layer
|
||||||
|
|
||||||
|
The base layer is the 3-parameter model of earlier versions (loss, heat, bias),
|
||||||
|
learned per zone. It is always kept: it is the fallback whenever another layer's
|
||||||
|
inputs are unknown, and the comparison that shows whether the other layers help.
|
||||||
|
|
||||||
|
### Extended layer
|
||||||
|
|
||||||
|
Present when a zone has an enabled heat source or an adjacent zone. It learns all
|
||||||
|
base terms plus `internal` and `couple:<zone>` and replaces the base layer in
|
||||||
|
forecasts once it is as trained as the base layer needs to be (200 samples, 30 with
|
||||||
|
the relay on, 30 off). If any of its inputs is unknown, the base layer is used.
|
||||||
|
|
||||||
|
### Electrical heat
|
||||||
|
|
||||||
|
For each enabled source:
|
||||||
|
|
||||||
|
```text
|
||||||
|
effective power = measured W − measured W of enabled sources included in it (≥ 0)
|
||||||
|
baseline = 20th percentile of the last 24 h of effective power, or the manual value
|
||||||
|
variable heat = heat fraction × (effective power − baseline) / 1000 (kW)
|
||||||
|
thermal response : r ← r + (1 − exp(−5 min / τ)) × (variable heat held during the interval − r)
|
||||||
|
```
|
||||||
|
|
||||||
|
q_i is the sum of the responses of the zone's sources. Centring on the baseline
|
||||||
|
matters: the persistent load (for example a UPS drawing 200 W all day) is already
|
||||||
|
part of the bias term, so feeding it again would count it twice. The baseline
|
||||||
|
needs one hour of data; after startup or an outage a source's response must run
|
||||||
|
for two time constants before it is used for learning.
|
||||||
|
|
||||||
|
Only active power counts (W, kW, mW). VA (apparent power) and % (UPS load) are
|
||||||
|
rejected. A UPS output sensor does not include the UPS's own losses; if they matter,
|
||||||
|
measure the input side or lower the heat fraction accordingly.
|
||||||
|
|
||||||
|
### Ventilation layer
|
||||||
|
|
||||||
|
Present when a zone has an enabled window. Learned separately, from the part of
|
||||||
|
the measured change the closed-window layers cannot explain, only while a window is
|
||||||
|
open, its state is unknown, or the zone is recovering. During those periods the
|
||||||
|
base and extended layers do **not** learn, so window events cannot inflate `loss`
|
||||||
|
or shift `bias`.
|
||||||
|
|
||||||
|
`vent_wind_dir` is bounded by `vent_wind` (|vent_wind_dir| ≤ vent_wind), so the
|
||||||
|
combined wind term never goes negative: a leeward window still ventilates.
|
||||||
|
|
||||||
|
Wind direction is meteorological: the direction the wind comes **from**, degrees
|
||||||
|
clockwise from north. A window's azimuth is the direction its outside faces. Wind
|
||||||
|
from the azimuth blows straight at the window (cos = 1); wind from behind the
|
||||||
|
building gives −1. Angle differences wrap around north correctly.
|
||||||
|
|
||||||
|
## Priors, bounds and confidence
|
||||||
|
|
||||||
|
| Coefficient | Prior | Bounds | Prior worth |
|
||||||
|
|---|---|---|---|
|
||||||
|
| loss | 0.002 | 0 … 0.04 | as in earlier versions |
|
||||||
|
| heat | 0.045 | 0 … 0.25 | as in earlier versions |
|
||||||
|
| bias | 0 | −0.06 … 0.06 | as in earlier versions |
|
||||||
|
| internal | 0 | 0 … 0.5 | 20 samples at 0.4 kW |
|
||||||
|
| couple:j | 0 | 0 … 0.05 | 200 samples at 1 °C difference |
|
||||||
|
| vent_natural | 0 | 0 … 0.2 | 20 samples at opening 1, 15 °C difference |
|
||||||
|
| vent_wind, vent_wind_dir | 0 | 0 … 0.03 / ±vent_wind | 20 samples at 4 m/s |
|
||||||
|
| vent_gust | 0 | 0 … 0.03 | 20 samples |
|
||||||
|
| vent_cross | 0 | 0 … 0.1 | 100 samples |
|
||||||
|
| recovery | 0 | 0 … 0.5 | 20 samples at 0.5 °C deficit |
|
||||||
|
|
||||||
|
New effects start at 0: no effect is assumed until the data shows one. Learning is
|
||||||
|
recursive least squares with exponential forgetting (about 3.5 days of samples),
|
||||||
|
a covariance bound that stops windup during long periods without excitation, and
|
||||||
|
outlier rejection above 0.8 °C per 5 minutes.
|
||||||
|
|
||||||
|
Each coefficient reports:
|
||||||
|
|
||||||
|
* `confidence`: the share of its information that comes from data rather than the
|
||||||
|
prior (0 = prior only);
|
||||||
|
* `std`: its standard error, using a running estimate of the unexplained noise;
|
||||||
|
* `samples`: how many accepted samples actually exercised it;
|
||||||
|
* `identified`: at least 30 exercising samples and confidence ≥ 0.8.
|
||||||
|
|
||||||
|
A coefficient can move without being identified. Example from the tests: two rooms
|
||||||
|
whose temperature difference stays fairly steady cannot separate exchange between
|
||||||
|
them from each room's bias; the learner finds a positive coupling but correctly
|
||||||
|
reports it as not identified.
|
||||||
|
|
||||||
|
## Learning conditions
|
||||||
|
|
||||||
|
A zone learns from the interval between two polls only when:
|
||||||
|
|
||||||
|
* the polls are 4.5–5.5 minutes apart, the relay held one state throughout and the
|
||||||
|
radiator-lag estimate is known within 0.1 (for example ~32 minutes after an HA
|
||||||
|
restart);
|
||||||
|
* the outdoor source did not change and the zone used the same sensors at both
|
||||||
|
polls;
|
||||||
|
* for the extended layer, every source in the zone is settled and every adjacent
|
||||||
|
zone was usable;
|
||||||
|
* for the ventilation layer, every window state in the zone is known.
|
||||||
|
|
||||||
|
No experiments are run and the boiler is never switched to gather data: everything
|
||||||
|
is learned passively.
|
||||||
|
|
||||||
|
## Forecasts
|
||||||
|
|
||||||
|
Forecasts simulate all zones together, step by step, over the MPC horizon (60–180
|
||||||
|
minutes):
|
||||||
|
|
||||||
|
* **Weather:** held at current values.
|
||||||
|
* **Electrical heat:** the variable part fades towards baseline with a configurable
|
||||||
|
half-life (default 30 minutes; 0 = stops now), and the room keeps following it with
|
||||||
|
its response time. A gaming PC drawing 600 W now is not assumed to run on for two
|
||||||
|
hours.
|
||||||
|
* **Windows:** an open window is assumed to stay open for a configurable time
|
||||||
|
(default 15 minutes), then close; the zone then recovers.
|
||||||
|
* **Relay:** the candidate plan being evaluated.
|
||||||
|
|
||||||
|
For the plan search the step is compiled into exact per-zone coefficients (every
|
||||||
|
term is linear in the temperatures and the lag), which keeps the 180-minute search
|
||||||
|
fast on a Raspberry Pi.
|
||||||
|
|
||||||
|
Plan forecasts are **counterfactual** while Generic Thermostat controls the relay:
|
||||||
|
they describe what would happen under the MPC's proposed plan.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
The Forecast Error sensors score forecasts that are valid for comparison with
|
||||||
|
measured temperatures. At every poll the model parameters are frozen; 15, 30, 60
|
||||||
|
and 120 minutes later all zones are replayed from that poll using the relay,
|
||||||
|
electrical heat and window history that actually followed (outdoor temperature held
|
||||||
|
at its start value), and compared with the readings. The base layer and a
|
||||||
|
no-change forecast are scored on exactly the same spans:
|
||||||
|
|
||||||
|
* `mae`, `bias`: the full model's error;
|
||||||
|
* `persistence_mae`, `skill`: against assuming no change;
|
||||||
|
* `base_model_mae`, `improvement_vs_base`: against the base layer alone. A feature
|
||||||
|
that does not improve this should be switched off (for example the gust term).
|
||||||
|
|
||||||
|
Results on synthetic apartments (the tests; these check the method, not real-world
|
||||||
|
accuracy):
|
||||||
|
|
||||||
|
| Scenario | 60-min error, full | base | no change |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Office, UPS 200 W + 400 W gaming sessions, thermostat cycling | 0.031 °C | 0.182 °C | 0.40 °C |
|
||||||
|
| Bedroom, 15–20 min ventilation every 4 h, wind and recovery | 0.026 °C | 0.218 °C | 0.52 °C |
|
||||||
|
| Two adjacent rooms, true exchange 0.01 | 0.024 °C | 0.037 °C | 0.29 °C |
|
||||||
|
|
||||||
|
In the ventilation scenario, the same data without the window configured produced
|
||||||
|
a −0.013 °C/5 min cooling bias and an 11 % inflated boiler coefficient, which is
|
||||||
|
the contamination the separate ventilation layer prevents.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
* One temperature per zone: walls and air are not separate states. The recovery
|
||||||
|
term approximates the air warming back after ventilation.
|
||||||
|
* Response times (electrical heat, recovery) are configured, not learned. Compare
|
||||||
|
settings with `improvement_vs_base`.
|
||||||
|
* Coupling between zones heated by one boiler is weakly identifiable; it is shrunk
|
||||||
|
towards 0 and usually reported as not identified.
|
||||||
|
* Natural and wind ventilation terms both scale with the opening; with little wind
|
||||||
|
variation their split is uncertain even when their combined effect is accurate.
|
||||||
|
* Forecasts hold weather constant and use one scenario for electrical heat and
|
||||||
|
windows; no forecast uncertainty band is computed yet.
|
||||||
|
* Window sensors with a permanently unknown state keep their zone out of learning;
|
||||||
|
disable such a window.
|
||||||
+188
-34
@@ -1,18 +1,31 @@
|
|||||||
"""Validate actual HA form schemas, options and entry migration."""
|
"""Validate actual HA form schemas, options, zone subentries and entry migration."""
|
||||||
from homeassistant.config_entries import SOURCE_USER
|
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
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
|
from custom_components.heating_mpc.weather_sources import sources_for
|
||||||
|
|
||||||
|
MAIN = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler"}
|
||||||
|
|
||||||
async def test_config_flow(hass, inputs):
|
|
||||||
|
def zones_of(entry):
|
||||||
|
return {sub.unique_id: sub for sub in entry.subentries.values() if sub.subentry_type == "zone"}
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_entry(hass, entry):
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
return hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_flow_creates_one_zone_per_sensor(hass, inputs):
|
||||||
|
hass.states.async_set("sensor.bedroom", "19", {"unit_of_measurement": "°C", "friendly_name": "Bedroom"})
|
||||||
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
|
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
|
||||||
assert result["type"] is FlowResultType.FORM
|
assert result["type"] is FlowResultType.FORM
|
||||||
main = {"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
validated = result["data_schema"]({**MAIN, "room_entities": ["sensor.room", "sensor.bedroom"]})
|
||||||
"room_entities": ["sensor.room"]}
|
|
||||||
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
|
assert validated["room_stale_use_last_value"] is False
|
||||||
@@ -23,65 +36,134 @@ async def test_config_flow(hass, inputs):
|
|||||||
assert result["step_id"] == "weather_extra"
|
assert result["step_id"] == "weather_extra"
|
||||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], result["data_schema"]({}))
|
result = await hass.config_entries.flow.async_configure(result["flow_id"], result["data_schema"]({}))
|
||||||
assert result["step_id"] == "weights"
|
assert result["step_id"] == "weights"
|
||||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {"weight__sensor.room": 2})
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"weight__sensor.room": 2, "weight__sensor.bedroom": 1})
|
||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
assert result["data"]["weights"] == {"sensor.room": 2}
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
entry = result["result"]
|
||||||
|
assert entry.version == 3
|
||||||
|
assert "room_entities" not in entry.data and "weights" not in entry.data
|
||||||
|
zones = zones_of(entry)
|
||||||
|
assert {key: (sub.title, sub.data["comfort_weight"]) for key, sub in zones.items()} == {
|
||||||
|
"sensor.room": ("room", 2.0), "sensor.bedroom": ("Bedroom", 1.0)} # HA's name fallback
|
||||||
|
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.room", "sensor.bedroom"]
|
||||||
|
|
||||||
|
|
||||||
async def test_options_flow_keeps_settings(hass, entry, inputs):
|
async def test_options_flow_keeps_settings_without_rooms(hass, entry, inputs):
|
||||||
entry.add_to_hass(hass)
|
await setup_entry(hass, entry)
|
||||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
main = result["data_schema"]({"climate_entity": "climate.reference",
|
assert "room_entities" not in {str(key) for key in result["data_schema"].schema}
|
||||||
"heater_entity": "switch.boiler", "room_entities": ["sensor.room"],
|
main = result["data_schema"]({**MAIN, "room_max_age_minutes": 45, "room_stale_use_last_value": True})
|
||||||
"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"]}))
|
||||||
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"]({}))
|
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"]({}))
|
||||||
result = await hass.config_entries.options.async_configure(result["flow_id"], {"weight__sensor.room": 3})
|
|
||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
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
|
||||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
assert coordinator.config["room_max_age_minutes"] == 45
|
assert coordinator.config["room_max_age_minutes"] == 45
|
||||||
assert coordinator.config["room_stale_use_last_value"] is True
|
assert coordinator.config["room_stale_use_last_value"] is True
|
||||||
# The next options form shows the saved choice.
|
assert coordinator.rooms == ["sensor.room"] # zones are untouched by Configure
|
||||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
assert result["data_schema"]({"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
assert result["data_schema"](MAIN)["room_stale_use_last_value"] is True
|
||||||
"room_entities": ["sensor.room"]})["room_stale_use_last_value"] is True
|
|
||||||
hass.config_entries.options.async_abort(result["flow_id"])
|
hass.config_entries.options.async_abort(result["flow_id"])
|
||||||
|
|
||||||
|
|
||||||
async def test_v1_entry_migrates_on_setup(hass, inputs):
|
async def test_weather_source_may_not_be_a_zone_sensor(hass, entry, inputs):
|
||||||
|
await setup_entry(hass, entry)
|
||||||
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
|
result = await hass.config_entries.options.async_configure(result["flow_id"], result["data_schema"](MAIN))
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"], result["data_schema"]({"outside_temperature_sources": ["sensor.room"]}))
|
||||||
|
assert result["errors"] == {"base": "invalid_weather"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_v1_entry_migrates_to_zones_on_setup(hass, inputs):
|
||||||
entry = MockConfigEntry(domain=DOMAIN, version=1, data={
|
entry = MockConfigEntry(domain=DOMAIN, version=1, data={
|
||||||
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
**MAIN, "room_entities": ["sensor.room"], "outside_entity": "sensor.outside"})
|
||||||
"room_entities": ["sensor.room"], "outside_entity": "sensor.outside"})
|
await setup_entry(hass, entry)
|
||||||
|
assert entry.version == 3
|
||||||
|
assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
|
||||||
|
assert "outside_entity" not in entry.data and "room_entities" not in entry.data
|
||||||
|
assert set(zones_of(entry)) == {"sensor.room"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_v2_migration_keeps_weights_models_and_entities(hass, entry, inputs, hass_storage):
|
||||||
|
from custom_components.heating_mpc.physics import Learner
|
||||||
|
hass.states.async_set("sensor.room", "20", {"unit_of_measurement": "°C", "friendly_name": "Living room"})
|
||||||
|
hass_storage[f"{DOMAIN}.{entry.entry_id}"] = {"version": 1, "data": {
|
||||||
|
"schema_version": 2, "heater_entity": "switch.boiler",
|
||||||
|
"learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}}
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
|
# A forecast entity created by 0.3 for this room.
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
old = registry.async_get_or_create("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min",
|
||||||
|
config_entry=entry, suggested_object_id="mpc_living_forecast")
|
||||||
|
hass.config_entries.async_update_entry(entry, options={"weights": {"sensor.room": 4.0}})
|
||||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert entry.version == 2
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
assert entry.data["outside_temperature_sources"] == ["sensor.outside"]
|
zone = zones_of(entry)["sensor.room"]
|
||||||
assert "outside_entity" not in entry.data
|
assert (zone.title, zone.data["comfort_weight"], zone.data["sensors"]) == ("Living room", 4.0, ["sensor.room"])
|
||||||
|
assert "weights" not in entry.options
|
||||||
|
assert coordinator.learners["sensor.room"].count == 250
|
||||||
|
# Same entity, now linked to its zone, so deleting the zone removes it.
|
||||||
|
migrated = registry.async_get(old.entity_id)
|
||||||
|
assert migrated.config_subentry_id == zone.subentry_id
|
||||||
|
assert hass.config_entries.async_remove_subentry(entry, zone.subentry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert registry.async_get(old.entity_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zone_subentry_add_edit_and_validation(hass, entry, inputs):
|
||||||
|
await setup_entry(hass, entry)
|
||||||
|
for entity, value in (("sensor.living_a", "21"), ("sensor.living_b", "22")):
|
||||||
|
hass.states.async_set(entity, value, {"unit_of_measurement": "°C"})
|
||||||
|
flows = hass.config_entries.subentries
|
||||||
|
result = await flows.async_init((entry.entry_id, "zone"), context={"source": SOURCE_USER})
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
# A sensor of another zone, or an outdoor source, is refused.
|
||||||
|
for sensors, error in ((["sensor.room"], "sensor_in_other_zone"), (["sensor.outside"], "sensor_is_weather_source")):
|
||||||
|
result = await flows.async_configure(result["flow_id"], {
|
||||||
|
"name": "Living", "sensors": sensors, "comfort_weight": 2, "target_offset": 0})
|
||||||
|
assert result["errors"] == {"base": error}
|
||||||
|
result = await flows.async_configure(result["flow_id"], {
|
||||||
|
"name": " Living ", "sensors": ["sensor.living_a", "sensor.living_b"], "comfort_weight": 2,
|
||||||
|
"target_offset": 0.5, "neighbors": ["sensor.room"]})
|
||||||
|
assert result["step_id"] == "sensor_weights"
|
||||||
|
result = await flows.async_configure(result["flow_id"], {
|
||||||
|
"weight__sensor.living_a": 3, "weight__sensor.living_b": 1})
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
living = next(sub for sub in zones_of(entry).values() if sub.title == "Living")
|
||||||
|
assert living.data["sensor_weights"] == {"sensor.living_a": 3.0, "sensor.living_b": 1.0}
|
||||||
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
key = living.data["key"]
|
||||||
|
assert coordinator.zones[key].neighbors == ("sensor.room",)
|
||||||
|
assert coordinator.zones["sensor.room"].neighbors == (key,) # symmetric
|
||||||
|
assert coordinator.data["temperatures"][key] == 21.25
|
||||||
|
# Edit: drop a sensor; the key (and so the learned model) is kept.
|
||||||
|
result = await flows.async_init((entry.entry_id, "zone"), context={
|
||||||
|
"source": SOURCE_RECONFIGURE, "subentry_id": living.subentry_id})
|
||||||
|
result = await flows.async_configure(result["flow_id"], {
|
||||||
|
"name": "Living", "sensors": ["sensor.living_a"], "comfort_weight": 1, "target_offset": 0})
|
||||||
|
assert result["type"] is FlowResultType.ABORT and result["reason"] == "reconfigure_successful"
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert entry.subentries[living.subentry_id].data["key"] == key
|
||||||
|
assert hass.data[DOMAIN][entry.entry_id].zones[key].sensors == ("sensor.living_a",)
|
||||||
|
|
||||||
|
|
||||||
async def test_options_flow_clears_optional_weather_sources(hass, inputs):
|
async def test_options_flow_clears_optional_weather_sources(hass, inputs):
|
||||||
"""Swapping providers in the UI must allow removing an optional source list."""
|
"""Swapping providers in the UI must allow removing an optional source list."""
|
||||||
entry = MockConfigEntry(domain=DOMAIN, version=2, unique_id="home_heating", data={
|
entry = MockConfigEntry(domain=DOMAIN, version=2, unique_id="home_heating", data={
|
||||||
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
**MAIN, "room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"],
|
||||||
"room_entities": ["sensor.room"], "outside_temperature_sources": ["sensor.outside"],
|
|
||||||
"outside_wind_sources": ["sensor.wind"], "outside_pressure_sources": ["sensor.pressure"],
|
"outside_wind_sources": ["sensor.wind"], "outside_pressure_sources": ["sensor.pressure"],
|
||||||
"weights": {"sensor.room": 1.0}})
|
"weights": {"sensor.room": 1.0}})
|
||||||
entry.add_to_hass(hass)
|
await setup_entry(hass, entry)
|
||||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
flow = hass.config_entries.options
|
flow = hass.config_entries.options
|
||||||
result = await flow.async_init(entry.entry_id)
|
result = await flow.async_init(entry.entry_id)
|
||||||
result = await flow.async_configure(result["flow_id"], result["data_schema"]({
|
result = await flow.async_configure(result["flow_id"], result["data_schema"](MAIN))
|
||||||
"climate_entity": "climate.reference", "heater_entity": "switch.boiler",
|
|
||||||
"room_entities": ["sensor.room"]}))
|
|
||||||
markers = {str(key): key for key in result["data_schema"].schema}
|
markers = {str(key): key for key in result["data_schema"].schema}
|
||||||
assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]}
|
assert markers["outside_wind_sources"].description == {"suggested_value": ["sensor.wind"]}
|
||||||
# The user removes the wind provider; a cleared field may be omitted on submit.
|
# The user removes the wind provider; a cleared field may be omitted on submit.
|
||||||
@@ -89,7 +171,6 @@ async def test_options_flow_clears_optional_weather_sources(hass, inputs):
|
|||||||
{"outside_temperature_sources": ["sensor.outside"]}))
|
{"outside_temperature_sources": ["sensor.outside"]}))
|
||||||
result = await flow.async_configure(result["flow_id"], result["data_schema"](
|
result = await flow.async_configure(result["flow_id"], result["data_schema"](
|
||||||
{"outside_pressure_sources": ["sensor.pressure"]}))
|
{"outside_pressure_sources": ["sensor.pressure"]}))
|
||||||
result = await flow.async_configure(result["flow_id"], {"weight__sensor.room": 1})
|
|
||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert entry.options["outside_wind_sources"] == []
|
assert entry.options["outside_wind_sources"] == []
|
||||||
@@ -97,3 +178,76 @@ async def test_options_flow_clears_optional_weather_sources(hass, inputs):
|
|||||||
config = hass.data[DOMAIN][entry.entry_id].config
|
config = hass.data[DOMAIN][entry.entry_id].config
|
||||||
assert sources_for(config, "wind") == []
|
assert sources_for(config, "wind") == []
|
||||||
assert sources_for(config, "pressure") == ["sensor.pressure"]
|
assert sources_for(config, "pressure") == ["sensor.pressure"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heat_source_subentry_flow(hass, entry, inputs):
|
||||||
|
await setup_entry(hass, entry)
|
||||||
|
for sensor, value in (("sensor.ups_power", "600"), ("sensor.pc_power", "400")):
|
||||||
|
hass.states.async_set(sensor, value, {"unit_of_measurement": "W", "device_class": "power"})
|
||||||
|
flows = hass.config_entries.subentries
|
||||||
|
base = {"zone": "sensor.room", "heat_fraction": 1, "response_minutes": 15, "enabled": True}
|
||||||
|
result = await flows.async_init((entry.entry_id, "heat_source"), context={"source": SOURCE_USER})
|
||||||
|
result = await flows.async_configure(result["flow_id"], {
|
||||||
|
**base, "name": "UPS", "sensor": "sensor.ups_power", "baseline_mode": "auto"})
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
ups = next(sub for sub in entry.subentries.values() if sub.subentry_type == "heat_source")
|
||||||
|
# The PC is behind the UPS: its power is already in the UPS reading.
|
||||||
|
result = await flows.async_init((entry.entry_id, "heat_source"), context={"source": SOURCE_USER})
|
||||||
|
pc = {**base, "name": "Gaming PC", "sensor": "sensor.pc_power", "baseline_mode": "manual",
|
||||||
|
"included_in": ups.unique_id, "response_minutes": 10}
|
||||||
|
result = await flows.async_configure(result["flow_id"], pc)
|
||||||
|
assert result["errors"] == {"base": "baseline_required"}
|
||||||
|
result = await flows.async_configure(result["flow_id"], {**pc, "baseline_w": 0})
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
assert [(s.name, s.included_in) for s in coordinator.sources] == [("UPS", None), ("Gaming PC", ups.unique_id)]
|
||||||
|
assert coordinator.extended["sensor.room"].features == ["loss", "heat", "bias", "internal"]
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
status = hass.states.get(registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
|
||||||
|
sources = status.attributes["heat_sources"]
|
||||||
|
assert (sources["UPS"]["power_w"], sources["UPS"]["effective_w"]) == (600, 200) # PC not counted twice
|
||||||
|
assert (sources["Gaming PC"]["included_in"], sources["Gaming PC"]["variable_w"]) == ("UPS", 400)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_window_subentry_flow_and_ventilation_options(hass, entry, inputs):
|
||||||
|
await setup_entry(hass, entry)
|
||||||
|
hass.states.async_set("binary_sensor.bedroom_window", "off", {"device_class": "window"})
|
||||||
|
flows = hass.config_entries.subentries
|
||||||
|
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
|
||||||
|
form = result["data_schema"]({"name": "Bedroom window", "sensor": "binary_sensor.bedroom_window",
|
||||||
|
"zone": "sensor.room"})
|
||||||
|
assert (form["azimuth"], form["opening_factor"], form["inverted"]) == (345.0, 1.0, False)
|
||||||
|
result = await flows.async_configure(result["flow_id"], {**form, "azimuth": 340})
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
assert [(w.name, w.azimuth) for w in coordinator.windows] == [("Bedroom window", 340.0)]
|
||||||
|
# No wind sources yet: only the terms whose inputs exist.
|
||||||
|
assert coordinator.ventilation["sensor.room"].features == ["vent_natural", "recovery"]
|
||||||
|
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
|
||||||
|
result = await flows.async_configure(result["flow_id"], {**form, "name": "Again"})
|
||||||
|
assert result["errors"] == {"base": "sensor_in_other_window"}
|
||||||
|
flows.async_abort(result["flow_id"])
|
||||||
|
# Wind, direction and gust sources plus the gust option add their terms.
|
||||||
|
for sensor, value, unit in (("sensor.wind", "4", "m/s"), ("sensor.wind_dir", "204", "°"),
|
||||||
|
("sensor.gust", "9", "m/s")):
|
||||||
|
hass.states.async_set(sensor, value, {"unit_of_measurement": unit})
|
||||||
|
options = hass.config_entries.options
|
||||||
|
result = await options.async_init(entry.entry_id)
|
||||||
|
result = await options.async_configure(result["flow_id"], result["data_schema"](
|
||||||
|
{**MAIN, "ventilation_gust": True, "ventilation_cross": True}))
|
||||||
|
result = await options.async_configure(result["flow_id"], result["data_schema"]({
|
||||||
|
"outside_temperature_sources": ["sensor.outside"], "outside_wind_sources": ["sensor.wind"],
|
||||||
|
"outside_wind_direction_sources": ["sensor.wind_dir"], "outside_wind_gust_sources": ["sensor.gust"]}))
|
||||||
|
result = await options.async_configure(result["flow_id"], result["data_schema"]({}))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
# Cross-ventilation needs windows in two zones, so it stays off.
|
||||||
|
assert coordinator.ventilation["sensor.room"].features == [
|
||||||
|
"vent_natural", "recovery", "vent_wind", "vent_wind_dir", "vent_gust"]
|
||||||
|
status = hass.states.get(er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
|
||||||
|
window = status.attributes["windows"]["Bedroom window"]
|
||||||
|
assert (window["state"], window["relative_wind_direction"]) == ("closed", -136.0)
|
||||||
|
assert {"windows", "ventilation"} <= status.state_info["unrecorded_attributes"]
|
||||||
+49
-10
@@ -10,6 +10,21 @@ from homeassistant.helpers import entity_registry as er
|
|||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
from custom_components.heating_mpc.const import DOMAIN
|
from custom_components.heating_mpc.const import DOMAIN
|
||||||
|
from homeassistant.config_entries import ConfigSubentry
|
||||||
|
from types import MappingProxyType
|
||||||
|
|
||||||
|
|
||||||
|
def zone_subentry(entry, key):
|
||||||
|
return next(sub for sub in entry.subentries.values() if sub.unique_id == key)
|
||||||
|
|
||||||
|
|
||||||
|
def add_zone(hass, entry, key, sensors, name=None):
|
||||||
|
"""What the Add zone flow stores, added directly."""
|
||||||
|
hass.config_entries.async_add_subentry(entry, ConfigSubentry(
|
||||||
|
data=MappingProxyType({"key": key, "name": name or key, "sensors": sensors,
|
||||||
|
"sensor_weights": {s: 1.0 for s in sensors}, "comfort_weight": 1.0,
|
||||||
|
"target_offset": 0.0, "neighbors": []}),
|
||||||
|
subentry_type="zone", title=name or key, unique_id=key))
|
||||||
|
|
||||||
|
|
||||||
async def setup(hass, entry):
|
async def setup(hass, entry):
|
||||||
@@ -28,7 +43,8 @@ async def test_setup_reload_unload(hass, entry, inputs):
|
|||||||
ids = {e.unique_id: e.entity_id for e in entities}
|
ids = {e.unique_id: e.entity_id for e in entities}
|
||||||
assert coordinator.data["status"] == "learning"
|
assert coordinator.data["status"] == "learning"
|
||||||
status = hass.states.get(ids[f"{entry.entry_id}_status"])
|
status = hass.states.get(ids[f"{entry.entry_id}_status"])
|
||||||
assert status.attributes["input_quality"]["rooms"]["sensor.room"]["max_age_seconds_24h"] == 0
|
zone_quality = status.attributes["input_quality"]["zones"]["sensor.room"]
|
||||||
|
assert zone_quality["sensors"]["sensor.room"]["max_age_seconds_24h"] == 0
|
||||||
# Volatile diagnostics stay visible but are not written to the Recorder.
|
# Volatile diagnostics stay visible but are not written to the Recorder.
|
||||||
assert {"input_quality", "weather_comparison"} <= status.state_info["unrecorded_attributes"]
|
assert {"input_quality", "weather_comparison"} <= status.state_info["unrecorded_attributes"]
|
||||||
assert "models" not in status.state_info["unrecorded_attributes"]
|
assert "models" not in status.state_info["unrecorded_attributes"]
|
||||||
@@ -45,16 +61,19 @@ async def test_setup_reload_unload(hass, entry, inputs):
|
|||||||
assert coordinator._unsub_refresh is None
|
assert coordinator._unsub_refresh is None
|
||||||
|
|
||||||
|
|
||||||
async def test_options_reload_removes_only_old_room(hass, entry, inputs):
|
async def test_replacing_a_zone_removes_only_its_forecast(hass, entry, inputs):
|
||||||
await setup(hass, entry)
|
await setup(hass, entry)
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
old = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min")
|
old = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.room_predicted_60min")
|
||||||
hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"})
|
hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"})
|
||||||
hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]})
|
add_zone(hass, entry, "zone_new", ["sensor.new"], "New")
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert hass.data[DOMAIN][entry.entry_id].rooms == ["sensor.new"]
|
assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert hass.data[DOMAIN][entry.entry_id].rooms == ["zone_new"]
|
||||||
assert registry.async_get(old) is None
|
assert registry.async_get(old) is None
|
||||||
assert registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_sensor.new_predicted_60min")
|
new = registry.async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_zone_new_predicted_60min")
|
||||||
|
assert hass.states.get(new).attributes["friendly_name"] == "MPC New 60min Prediction"
|
||||||
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 11
|
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 11
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +93,10 @@ async def test_storage_restore_and_remove(hass, entry, inputs):
|
|||||||
forecast = hass.states.get(forecast_id)
|
forecast = hass.states.get(forecast_id)
|
||||||
assert len(forecast.attributes["forecast_15min"]) == len(forecast.attributes["plan_15min"]) == 4
|
assert len(forecast.attributes["forecast_15min"]) == len(forecast.attributes["plan_15min"]) == 4
|
||||||
assert forecast.attributes["forecast_15min"][3] == float(forecast.state)
|
assert forecast.attributes["forecast_15min"][3] == float(forecast.state)
|
||||||
|
assert forecast.attributes["forecast"]["60_min"] == float(forecast.state)
|
||||||
|
assert set(forecast.attributes["forecast"]) == {"15_min", "30_min", "60_min"} # 60-minute horizon
|
||||||
|
assert set(forecast.attributes["contributions_c_per_h"]) == {"loss", "heat", "bias"}
|
||||||
|
assert forecast.attributes["model_in_use"] == "base"
|
||||||
assert {"forecast_15min", "plan_15min"} <= forecast.state_info["unrecorded_attributes"]
|
assert {"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()
|
||||||
@@ -203,7 +226,9 @@ async def test_removed_room_model_survives_options_round_trip(hass, entry, input
|
|||||||
"learners": {"sensor.room": Learner(count=250, on_count=125, off_count=125).as_dict()}}}
|
"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"})
|
hass.states.async_set("sensor.new", "21", {"unit_of_measurement": "°C"})
|
||||||
await setup(hass, entry)
|
await setup(hass, entry)
|
||||||
hass.config_entries.async_update_entry(entry, options={"room_entities": ["sensor.new"]})
|
add_zone(hass, entry, "sensor.new", ["sensor.new"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
|
future = dt_util.utcnow() + timedelta(minutes=5, seconds=1)
|
||||||
freezer.move_to(future)
|
freezer.move_to(future)
|
||||||
@@ -212,8 +237,7 @@ async def test_removed_room_model_survives_options_round_trip(hass, entry, input
|
|||||||
saved = hass_storage[key]["data"]
|
saved = hass_storage[key]["data"]
|
||||||
assert set(saved["learners"]) == {"sensor.new"}
|
assert set(saved["learners"]) == {"sensor.new"}
|
||||||
assert saved["retired_learners"]["sensor.room"]["count"] == 250
|
assert saved["retired_learners"]["sensor.room"]["count"] == 250
|
||||||
hass.config_entries.async_update_entry(
|
add_zone(hass, entry, "sensor.room", ["sensor.room"])
|
||||||
entry, options={"room_entities": ["sensor.room", "sensor.new"]})
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250
|
assert hass.data[DOMAIN][entry.entry_id].learners["sensor.room"].count == 250
|
||||||
|
|
||||||
@@ -234,9 +258,9 @@ async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs,
|
|||||||
assert coordinator.data["forecast_accuracy"][15]["samples"] == 1
|
assert coordinator.data["forecast_accuracy"][15]["samples"] == 1
|
||||||
assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0
|
assert float(state.state) == coordinator.data["forecast_accuracy"][15]["mae"] > 0
|
||||||
assert state.attributes["samples"] == 1
|
assert state.attributes["samples"] == 1
|
||||||
assert state.attributes["rooms"]["sensor.room"]["samples"] == 1
|
assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name
|
||||||
assert state.attributes["unit_of_measurement"] == "°C"
|
assert state.attributes["unit_of_measurement"] == "°C"
|
||||||
assert "rooms" in state.state_info["unrecorded_attributes"]
|
assert "zones" in state.state_info["unrecorded_attributes"]
|
||||||
assert hass.states.get(ids[30]).state == "unknown"
|
assert hass.states.get(ids[30]).state == "unknown"
|
||||||
|
|
||||||
|
|
||||||
@@ -266,3 +290,18 @@ async def test_learning_progress_and_last_sample_sensors(hass, entry, inputs, fr
|
|||||||
last = hass.states.get(last_id)
|
last = hass.states.get(last_id)
|
||||||
assert last.state == "waiting_for_inputs"
|
assert last.state == "waiting_for_inputs"
|
||||||
assert last.attributes["counts_since_start"] == {"first_sample": 1, "learned": 1, "waiting_for_inputs": 1}
|
assert last.attributes["counts_since_start"] == {"first_sample": 1, "learned": 1, "waiting_for_inputs": 1}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deleting_the_last_zone_keeps_the_entry_loaded(hass, entry, inputs, caplog):
|
||||||
|
await setup(hass, entry)
|
||||||
|
assert hass.config_entries.async_remove_subentry(entry, zone_subentry(entry, "sensor.room").subentry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert entry.state.name == "LOADED"
|
||||||
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
assert (coordinator.rooms, coordinator.data["status"]) == ([], "no_zones")
|
||||||
|
assert coordinator.data["learning_progress"] == 0
|
||||||
|
assert "Error setting up entry" not in caplog.text
|
||||||
|
# Adding a zone back resumes normally.
|
||||||
|
add_zone(hass, entry, "sensor.room", ["sensor.room"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert hass.data[DOMAIN][entry.entry_id].data["status"] == "learning"
|
||||||
+389
-20
@@ -5,7 +5,7 @@ import types
|
|||||||
import unittest
|
import unittest
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from math import exp, pi, sin
|
from math import cos, exp, pi, radians, sin
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ def load_integration():
|
|||||||
modules['homeassistant.helpers.event'].async_track_state_change_event = (
|
modules['homeassistant.helpers.event'].async_track_state_change_event = (
|
||||||
lambda hass, entity_ids, action: hass.track_state(entity_ids, action))
|
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.config_entries'].ConfigSubentry = lambda **kwargs: types.SimpleNamespace(**kwargs)
|
||||||
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
|
||||||
registry = modules['homeassistant.helpers.entity_registry']
|
registry = modules['homeassistant.helpers.entity_registry']
|
||||||
@@ -249,7 +250,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(kwargs['data']['outside_temperature_sources'], ['sensor.outside'])
|
self.assertEqual(kwargs['data']['outside_temperature_sources'], ['sensor.outside'])
|
||||||
self.assertNotIn('outside_entity', kwargs['data'])
|
self.assertNotIn('outside_entity', kwargs['data'])
|
||||||
self.assertEqual(kwargs['version'], 2)
|
self.assertEqual(kwargs['version'], 2)
|
||||||
self.entry.version = 3
|
self.entry.version = 4
|
||||||
self.assertFalse(await integration.async_migrate_entry(self.hass, self.entry))
|
self.assertFalse(await integration.async_migrate_entry(self.hass, self.entry))
|
||||||
|
|
||||||
async def test_changed_inputs_during_optimization_discard_result(self):
|
async def test_changed_inputs_during_optimization_discard_result(self):
|
||||||
@@ -263,12 +264,11 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(data['status'], 'inputs_changed')
|
self.assertEqual(data['status'], 'inputs_changed')
|
||||||
self.assertIsNone(data['proposal'])
|
self.assertIsNone(data['proposal'])
|
||||||
|
|
||||||
async def test_invalid_weights_do_not_publish_proposal(self):
|
async def test_invalid_stored_comfort_weights_fall_back_to_one(self):
|
||||||
for weight in (0, -1, 'bad', float('nan'), float('inf')):
|
for weight in (0, -1, 'bad', float('nan'), float('inf')):
|
||||||
self.c.config['weights'] = {'sensor.room': weight}
|
self.entry.data['weights'] = {'sensor.room': weight}
|
||||||
data = await self.sample()
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||||
self.assertEqual(data['status'], 'invalid_weights')
|
self.assertEqual(self.c.zones['sensor.room'].comfort_weight, 1.0)
|
||||||
self.assertIsNone(data['proposal'])
|
|
||||||
|
|
||||||
async def test_added_room_needs_its_own_excitation_evidence(self):
|
async def test_added_room_needs_its_own_excitation_evidence(self):
|
||||||
model = coordinator.Learner(count=500, on_count=250, off_count=250)
|
model = coordinator.Learner(count=500, on_count=250, off_count=250)
|
||||||
@@ -295,7 +295,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.states['sensor.room'].last_reported = self.now - timedelta(minutes=31)
|
self.states['sensor.room'].last_reported = self.now - timedelta(minutes=31)
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
self.assertEqual(data['status'], 'waiting_for_sensors')
|
self.assertEqual(data['status'], 'waiting_for_sensors')
|
||||||
self.assertEqual(data['input_quality']['rooms']['sensor.room']['status'], 'stale')
|
self.assertEqual(data['input_quality']['zones']['sensor.room']['sensors']['sensor.room']['status'], 'stale')
|
||||||
self.assertIsNone(data['proposal'])
|
self.assertIsNone(data['proposal'])
|
||||||
self.set_state('sensor.room', '20', unit_of_measurement='°C')
|
self.set_state('sensor.room', '20', unit_of_measurement='°C')
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
@@ -511,7 +511,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await self.c.async_restore_model()
|
await self.c.async_restore_model()
|
||||||
self.assertEqual(self.c.learners['sensor.room'].count, 7)
|
self.assertEqual(self.c.learners['sensor.room'].count, 7)
|
||||||
await self.sample()
|
await self.sample()
|
||||||
self.assertEqual((await self.sample())['retired_room_models'], ['sensor.gone'])
|
self.assertEqual((await self.sample())['retired_zone_models'], ['sensor.gone'])
|
||||||
payload = self.c._store.async_save.call_args.args[0]
|
payload = self.c._store.async_save.call_args.args[0]
|
||||||
self.assertEqual(set(payload['learners']), {'sensor.room'})
|
self.assertEqual(set(payload['learners']), {'sensor.room'})
|
||||||
self.assertEqual(payload['retired_learners']['sensor.gone']['count'], 500)
|
self.assertEqual(payload['retired_learners']['sensor.gone']['count'], 500)
|
||||||
@@ -534,12 +534,12 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await self.sample(2400) # the room then stays silent for 40 minutes
|
await self.sample(2400) # the room then stays silent for 40 minutes
|
||||||
self.states['sensor.outside'].last_reported = self.now
|
self.states['sensor.outside'].last_reported = self.now
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
room = data['input_quality']['rooms']['sensor.room']
|
room = data['input_quality']['zones']['sensor.room']['sensors']['sensor.room']
|
||||||
self.assertEqual((room['status'], room['max_age_seconds_24h']), ('stale', 2400))
|
self.assertEqual((room['status'], room['max_age_seconds_24h']), ('stale', 2400))
|
||||||
self.states['sensor.room'].last_reported = self.now
|
self.states['sensor.room'].last_reported = self.now
|
||||||
self.states['sensor.outside'].last_reported = self.now
|
self.states['sensor.outside'].last_reported = self.now
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
room = data['input_quality']['rooms']['sensor.room']
|
room = data['input_quality']['zones']['sensor.room']['sensors']['sensor.room']
|
||||||
self.assertEqual((room['status'], room['age_seconds'], room['max_age_seconds_24h']),
|
self.assertEqual((room['status'], room['age_seconds'], room['max_age_seconds_24h']),
|
||||||
('valid', 0, 2400))
|
('valid', 0, 2400))
|
||||||
weather = data['input_quality']['weather']['temperature']['sensor.outside']
|
weather = data['input_quality']['weather']['temperature']['sensor.outside']
|
||||||
@@ -563,17 +563,19 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.states['sensor.bedroom'].last_reported = self.now - timedelta(minutes=31)
|
self.states['sensor.bedroom'].last_reported = self.now - timedelta(minutes=31)
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
self.assertEqual(data['status'], 'learning')
|
self.assertEqual(data['status'], 'learning')
|
||||||
self.assertEqual(data['excluded_rooms'], ['sensor.bedroom'])
|
self.assertEqual(data['excluded_zones'], ['sensor.bedroom'])
|
||||||
rooms = data['input_quality']['rooms']
|
zones = data['input_quality']['zones']
|
||||||
self.assertEqual((rooms['sensor.bedroom']['status'], rooms['sensor.bedroom']['used']), ('stale', False))
|
bedroom = zones['sensor.bedroom']['sensors']['sensor.bedroom']
|
||||||
self.assertTrue(rooms['sensor.room']['used'])
|
self.assertEqual((bedroom['status'], bedroom['used'], zones['sensor.bedroom']['used']),
|
||||||
|
('stale', False, False))
|
||||||
|
self.assertTrue(zones['sensor.room']['used'])
|
||||||
self.assertEqual((data['weighted_temperature'], data['weight_coverage']), (20, 0.75))
|
self.assertEqual((data['weighted_temperature'], data['weight_coverage']), (20, 0.75))
|
||||||
self.assertEqual(data['sample_result'], 'learned')
|
self.assertEqual(data['sample_result'], 'learned')
|
||||||
counts = lambda: (self.c.learners['sensor.room'].count, self.c.learners['sensor.bedroom'].count)
|
counts = lambda: (self.c.learners['sensor.room'].count, self.c.learners['sensor.bedroom'].count)
|
||||||
self.assertEqual(counts(), (1, 0))
|
self.assertEqual(counts(), (1, 0))
|
||||||
self.fresh() # bedroom reports again: no learning across its gap
|
self.fresh() # bedroom reports again: no learning across its gap
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
self.assertEqual((data['excluded_rooms'], data['weight_coverage']), ([], 1.0))
|
self.assertEqual((data['excluded_zones'], data['weight_coverage']), ([], 1.0))
|
||||||
self.assertEqual(data['weighted_temperature'], 19.5)
|
self.assertEqual(data['weighted_temperature'], 19.5)
|
||||||
self.assertEqual(counts(), (2, 0))
|
self.assertEqual(counts(), (2, 0))
|
||||||
self.fresh()
|
self.fresh()
|
||||||
@@ -599,7 +601,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.states[room].last_reported = self.now - timedelta(minutes=31)
|
self.states[room].last_reported = self.now - timedelta(minutes=31)
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
self.assertEqual(data['status'], 'waiting_for_sensors')
|
self.assertEqual(data['status'], 'waiting_for_sensors')
|
||||||
self.assertEqual(data['excluded_rooms'], ['sensor.room', 'sensor.bedroom'])
|
self.assertEqual(data['excluded_zones'], ['sensor.room', 'sensor.bedroom'])
|
||||||
self.assertIsNone(data['proposal'])
|
self.assertIsNone(data['proposal'])
|
||||||
|
|
||||||
async def test_keep_last_value_option_uses_stale_room_for_up_to_a_day(self):
|
async def test_keep_last_value_option_uses_stale_room_for_up_to_a_day(self):
|
||||||
@@ -608,9 +610,9 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await self.sample()
|
await self.sample()
|
||||||
self.fresh('sensor.room', 'sensor.outside')
|
self.fresh('sensor.room', 'sensor.outside')
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
bedroom = data['input_quality']['rooms']['sensor.bedroom']
|
bedroom = data['input_quality']['zones']['sensor.bedroom']['sensors']['sensor.bedroom']
|
||||||
self.assertEqual((bedroom['status'], bedroom['used']), ('stale', True))
|
self.assertEqual((bedroom['status'], bedroom['used']), ('stale', True))
|
||||||
self.assertEqual((data['excluded_rooms'], data['temperatures']['sensor.bedroom']), ([], 18))
|
self.assertEqual((data['excluded_zones'], data['temperatures']['sensor.bedroom']), ([], 18))
|
||||||
self.assertEqual(self.c.learners['sensor.bedroom'].count, 1)
|
self.assertEqual(self.c.learners['sensor.bedroom'].count, 1)
|
||||||
for state, age in (('18', timedelta(hours=24, seconds=1)), ('unavailable', timedelta(0))):
|
for state, age in (('18', timedelta(hours=24, seconds=1)), ('unavailable', timedelta(0))):
|
||||||
with self.subTest(state=state):
|
with self.subTest(state=state):
|
||||||
@@ -618,7 +620,7 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.states['sensor.bedroom'].state = state
|
self.states['sensor.bedroom'].state = state
|
||||||
self.states['sensor.bedroom'].last_reported = self.now - age
|
self.states['sensor.bedroom'].last_reported = self.now - age
|
||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
self.assertEqual(data['excluded_rooms'], ['sensor.bedroom'])
|
self.assertEqual(data['excluded_zones'], ['sensor.bedroom'])
|
||||||
self.assertEqual(data['status'], 'learning')
|
self.assertEqual(data['status'], 'learning')
|
||||||
|
|
||||||
async def test_poll_outcomes_and_rejections_are_counted(self):
|
async def test_poll_outcomes_and_rejections_are_counted(self):
|
||||||
@@ -697,3 +699,370 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
data = await self.sample()
|
data = await self.sample()
|
||||||
self.assertEqual(data['sample_result'], 'waiting_for_inputs')
|
self.assertEqual(data['sample_result'], 'waiting_for_inputs')
|
||||||
self.assertEqual(data['sample_results_since_start']['waiting_for_inputs'], 1)
|
self.assertEqual(data['sample_results_since_start']['waiting_for_inputs'], 1)
|
||||||
|
|
||||||
|
def multi_sensor_zone(self):
|
||||||
|
self.entry.subentries = {'s1': types.SimpleNamespace(subentry_id='s1', subentry_type='zone', data={
|
||||||
|
'key': 'zone_living', 'name': 'Living', 'sensors': ['sensor.room', 'sensor.second'],
|
||||||
|
'sensor_weights': {'sensor.room': 3, 'sensor.second': 1}, 'comfort_weight': 2,
|
||||||
|
'target_offset': 0.5})}
|
||||||
|
self.set_state('sensor.second', '22', unit_of_measurement='°C')
|
||||||
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||||
|
|
||||||
|
async def test_multi_sensor_zone_weights_sensors_and_skips_composition_changes(self):
|
||||||
|
self.multi_sensor_zone()
|
||||||
|
data = await self.sample()
|
||||||
|
self.assertEqual((data['temperatures'], data['zone_names']), ({'zone_living': 20.5}, {'zone_living': 'Living'}))
|
||||||
|
self.fresh('sensor.room', 'sensor.second', 'sensor.outside')
|
||||||
|
self.assertEqual((await self.sample())['sample_result'], 'learned')
|
||||||
|
self.states['sensor.second'].state = 'unavailable'
|
||||||
|
self.fresh('sensor.room', 'sensor.outside')
|
||||||
|
data = await self.sample()
|
||||||
|
self.assertEqual(data['temperatures'], {'zone_living': 20})
|
||||||
|
# The mean jumped by the sensors' offset, not by a real change: not learned.
|
||||||
|
self.assertEqual(data['sample_result'], 'sensors_changed')
|
||||||
|
self.assertEqual(self.c.learners['zone_living'].count, 1)
|
||||||
|
quality = data['input_quality']['zones']['zone_living']
|
||||||
|
self.assertEqual((quality['used'], quality['sensors']['sensor.second']['status']), (True, 'unavailable'))
|
||||||
|
self.fresh('sensor.room', 'sensor.outside')
|
||||||
|
self.assertEqual((await self.sample())['sample_result'], 'learned')
|
||||||
|
|
||||||
|
async def test_zone_comfort_weight_and_target_offset_reach_optimizer(self):
|
||||||
|
self.multi_sensor_zone()
|
||||||
|
model = self.c.learners['zone_living']
|
||||||
|
model.count, model.on_count, model.off_count = 200, 100, 100
|
||||||
|
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
||||||
|
data = await self.sample()
|
||||||
|
self.assertEqual(data['status'], 'ready')
|
||||||
|
self.assertEqual(spy.call_args.args[4], {'zone_living': 2.0})
|
||||||
|
self.assertEqual(spy.call_args.kwargs['target_offsets'], {'zone_living': 0.5})
|
||||||
|
|
||||||
|
def office_with_ups(self):
|
||||||
|
self.entry.subentries = {
|
||||||
|
'z': types.SimpleNamespace(subentry_id='z', subentry_type='zone', data={
|
||||||
|
'key': 'office', 'name': 'Office', 'sensors': ['sensor.room']}),
|
||||||
|
'h': types.SimpleNamespace(subentry_id='h', subentry_type='heat_source', data={
|
||||||
|
'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'office',
|
||||||
|
'response_minutes': 15})}
|
||||||
|
self.set_state('sensor.ups_power', '200', unit_of_measurement='W')
|
||||||
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||||
|
|
||||||
|
async def test_gaming_heat_identified_separately_from_boiler(self):
|
||||||
|
"""Synthetic office: UPS 200 W idle, +400 W gaming sessions, thermostat-cycled boiler."""
|
||||||
|
self.office_with_ups()
|
||||||
|
a, b, c, d = 0.004, 0.12, 0.0005, 0.15
|
||||||
|
advance = coordinator.advance_lag
|
||||||
|
alpha = 1 - exp(-300 / 900)
|
||||||
|
self.states['climate.reference'].state = 'off' # learn only
|
||||||
|
temp, lag, on, response = 21.0, 0.0, False, 0.0
|
||||||
|
for k in range(2021): # ends 25 minutes into a session (2020 % 120 == 100)
|
||||||
|
outside = 5 + 5 * sin(2 * pi * k / 288)
|
||||||
|
gaming = (k % 120) >= 96 # two hours of every ten
|
||||||
|
power = 600.0 if gaming else 200.0
|
||||||
|
for entity, value in (('sensor.room', temp), ('sensor.outside', outside), ('sensor.ups_power', power)):
|
||||||
|
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:
|
||||||
|
self.switch('on' if command else 'off', 180)
|
||||||
|
lag = advance(advance(lag, on, 120), command, 180)
|
||||||
|
else:
|
||||||
|
lag = advance(lag, on, 300)
|
||||||
|
on = command
|
||||||
|
response += alpha * ((power - 200) / 1000 - response) # this interval's held power
|
||||||
|
temp += a * (outside - temp) + b * lag + c + d * response
|
||||||
|
model = self.c.extended['office']
|
||||||
|
for name, value in (('internal', d), ('heat', b), ('loss', a)):
|
||||||
|
with self.subTest(coefficient=name):
|
||||||
|
self.assertAlmostEqual(model.coefficient(name), value, delta=0.1 * value)
|
||||||
|
info = data['models']['office']['extended']['coefficients']['internal']
|
||||||
|
self.assertTrue(info['identified'])
|
||||||
|
source = data['heat_sources']['UPS']
|
||||||
|
self.assertEqual((source['baseline_w'], source['zone'], source['settled']), (200.0, 'Office', True))
|
||||||
|
# Mid-session the zone shows the electrical share.
|
||||||
|
details = data['zone_details']['office']
|
||||||
|
self.assertEqual(details['model_in_use'], 'extended')
|
||||||
|
self.assertGreater(details['contributions_c_per_h']['internal'], 0.2)
|
||||||
|
self.assertTrue(details['coefficients']['extended']['internal']['identified'])
|
||||||
|
# Replayed against what happened, the electrical term removes most of the base error.
|
||||||
|
accuracy = data['forecast_accuracy'][60]
|
||||||
|
self.assertLess(accuracy['mae'], 0.06)
|
||||||
|
self.assertGreater(accuracy['improvement_vs_base'], 0.6)
|
||||||
|
|
||||||
|
async def test_unknown_power_pauses_extended_learning_only(self):
|
||||||
|
self.office_with_ups()
|
||||||
|
learned = []
|
||||||
|
for k in range(30):
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
|
||||||
|
self.states['sensor.ups_power'].state = 'unavailable' if k == 20 else '200'
|
||||||
|
before = self.c.extended['office'].count
|
||||||
|
data = await self.sample()
|
||||||
|
learned.append(self.c.extended['office'].count > before)
|
||||||
|
if k == 20:
|
||||||
|
self.assertEqual(data['heat_sources']['UPS']['status'], 'unavailable')
|
||||||
|
# Baseline after 12 polls (k = 11), then 2 time constants (6 polls) to settle.
|
||||||
|
# The interval ending at the outage is known (power held from k = 19); the
|
||||||
|
# next one is not, and the response must settle again.
|
||||||
|
self.assertEqual([k for k, ok in enumerate(learned) if ok], [17, 18, 19, 20, 27, 28, 29])
|
||||||
|
self.assertEqual(self.c.learners['office'].count, 29) # the base model never pauses
|
||||||
|
|
||||||
|
async def test_forecast_uses_extended_model_with_decaying_gaming_heat(self):
|
||||||
|
self.office_with_ups()
|
||||||
|
self.states['climate.reference'].state = 'off'
|
||||||
|
for k in range(20): # an hour idle (baseline), then gaming until the response settles
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
|
||||||
|
self.states['sensor.ups_power'].state = '600' if k >= 12 else '200'
|
||||||
|
await self.sample()
|
||||||
|
base, extended = self.c.learners['office'], self.c.extended['office']
|
||||||
|
for model in (base, extended):
|
||||||
|
model.count, model.on_count, model.off_count = 200, 100, 100
|
||||||
|
extended.theta[extended.features.index('internal')] = 0.15
|
||||||
|
self.states['climate.reference'].state = 'heat'
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
|
||||||
|
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
||||||
|
data = await self.sample()
|
||||||
|
self.assertEqual((data['status'], data['models']['office']['model_in_use']), ('ready', 'extended'))
|
||||||
|
step = spy.call_args.kwargs['step_model']
|
||||||
|
now_heat = step({'office': 20.0}, 0.0, 0)['office']
|
||||||
|
later_heat = step({'office': 20.0}, 0.0, 23)['office']
|
||||||
|
self.assertGreater(now_heat - later_heat, 0.03) # the session is assumed to wind down
|
||||||
|
# With only base models trained, the optimizer keeps its default step.
|
||||||
|
extended.count = 10
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
|
||||||
|
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
||||||
|
await self.sample()
|
||||||
|
self.assertIsNone(spy.call_args.kwargs['step_model'])
|
||||||
|
|
||||||
|
def bedroom_with_window(self, with_window=True):
|
||||||
|
self.entry.data.update(outside_wind_sources=['sensor.wind'],
|
||||||
|
outside_wind_direction_sources=['sensor.wind_dir'])
|
||||||
|
self.entry.subentries = {
|
||||||
|
'z': types.SimpleNamespace(subentry_id='z', subentry_type='zone', data={
|
||||||
|
'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.room']})}
|
||||||
|
if with_window:
|
||||||
|
self.entry.subentries['w'] = types.SimpleNamespace(subentry_id='w', subentry_type='window', data={
|
||||||
|
'key': 'bed_window', 'name': 'Bedroom window', 'sensor': 'binary_sensor.bed_window',
|
||||||
|
'zone': 'bed', 'azimuth': 345})
|
||||||
|
self.set_state('sensor.wind', '4', unit_of_measurement='m/s')
|
||||||
|
self.set_state('sensor.wind_dir', '204', unit_of_measurement='°')
|
||||||
|
self.set_state('binary_sensor.bed_window', 'off')
|
||||||
|
return coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||||
|
|
||||||
|
def open_window(self, state, seconds_before_next_sample):
|
||||||
|
window = self.states['binary_sensor.bed_window']
|
||||||
|
window.state = state
|
||||||
|
window.last_changed = self.now - timedelta(seconds=seconds_before_next_sample)
|
||||||
|
|
||||||
|
async def test_open_window_goes_to_ventilation_model_not_envelope_loss(self):
|
||||||
|
self.c = self.bedroom_with_window()
|
||||||
|
self.assertEqual(self.c.ventilation['bed'].features,
|
||||||
|
['vent_natural', 'recovery', 'vent_wind', 'vent_wind_dir'])
|
||||||
|
for _ in range(2):
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||||
|
await self.sample()
|
||||||
|
self.open_window('on', 240) # opened a minute after the last poll
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||||
|
base = self.c.learners['bed'].count
|
||||||
|
data = await self.sample()
|
||||||
|
self.assertEqual(data['sample_result'], 'ventilation')
|
||||||
|
self.assertEqual((self.c.learners['bed'].count, self.c.ventilation['bed'].count), (base, 1))
|
||||||
|
window = data['windows']['Bedroom window']
|
||||||
|
self.assertEqual((window['state'], window['open_minutes'], window['relative_wind_direction']),
|
||||||
|
('open', 4.0, -141.0))
|
||||||
|
self.assertEqual(window['wind_exposure'], round(cos(radians(141)), 3))
|
||||||
|
ventilation = data['ventilation']['Bedroom']
|
||||||
|
self.assertEqual((ventilation['opening'], ventilation['affected']), (0.8, True))
|
||||||
|
# Unknown contact state: neither model learns, and it never counts as closed.
|
||||||
|
self.open_window('unavailable', 120)
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||||
|
data = await self.sample()
|
||||||
|
self.assertEqual(data['windows']['Bedroom window']['state'], 'unknown')
|
||||||
|
self.assertEqual((self.c.learners['bed'].count, self.c.ventilation['bed'].count), (base, 1))
|
||||||
|
|
||||||
|
async def test_ventilation_week_keeps_envelope_loss_clean(self):
|
||||||
|
"""Same synthetic week through a zone with and without its window configured."""
|
||||||
|
with_window = self.bedroom_with_window(True)
|
||||||
|
without = self.bedroom_with_window(False)
|
||||||
|
a, b, c = 0.004, 0.12, 0.0005
|
||||||
|
k_nat, k_wind, k_dir, k_rec = 0.01, 0.001, 0.0006, 0.15
|
||||||
|
advance = coordinator.advance_lag
|
||||||
|
self.states['climate.reference'].state = 'off'
|
||||||
|
temp, lag, on, outside = 21.0, 0.0, False, 2.0
|
||||||
|
window_open, closed_at, t_ref, prev_temp = False, None, None, temp
|
||||||
|
for k in range(2400):
|
||||||
|
speed, direction = 2 + 3 * abs(sin(k / 50)), (k * 7) % 360
|
||||||
|
outside = 2 + 4 * sin(2 * pi * k / 288) # daily swing keeps loss and bias apart
|
||||||
|
for entity, value in (('sensor.room', temp), ('sensor.outside', outside),
|
||||||
|
('sensor.wind', speed), ('sensor.wind_dir', direction)):
|
||||||
|
self.states[entity].state = f'{value:.5f}'
|
||||||
|
self.states[entity].last_reported = self.now
|
||||||
|
with patch.object(coordinator, 'datetime') as clock:
|
||||||
|
clock.now.return_value = self.now
|
||||||
|
for c_ in (with_window, without):
|
||||||
|
await c_._async_update_data()
|
||||||
|
poll = self.now
|
||||||
|
self.now += timedelta(seconds=300)
|
||||||
|
# Thermostat: switch 120 s after the poll when needed.
|
||||||
|
command = temp < 20.7 or (on and temp < 21.3)
|
||||||
|
if command != on:
|
||||||
|
self.switch('on' if command else 'off', 180)
|
||||||
|
lag = advance(advance(lag, on, 120), command, 180)
|
||||||
|
else:
|
||||||
|
lag = advance(lag, on, 300)
|
||||||
|
on = command
|
||||||
|
# Ventilation: 15-20 minutes every four hours, changes 60 s after a poll.
|
||||||
|
phase = k % 48
|
||||||
|
fraction, recovery = (1.0 if window_open else 0.0), 0.0
|
||||||
|
if phase == 0 and not window_open:
|
||||||
|
t_ref, window_open, fraction = prev_temp, True, 0.8
|
||||||
|
self.open_window('on', 240)
|
||||||
|
elif phase == 4 and window_open:
|
||||||
|
window_open, fraction, closed_at = False, 0.2, poll.timestamp() + 60
|
||||||
|
self.open_window('off', 240)
|
||||||
|
if closed_at is not None and not window_open:
|
||||||
|
recovery = exp(-(self.now.timestamp() - closed_at) / 1200)
|
||||||
|
recovery = recovery if recovery >= 0.05 else 0.0
|
||||||
|
facing = fraction * cos(radians((direction - 345 + 180) % 360 - 180))
|
||||||
|
difference = outside - temp
|
||||||
|
prev_temp = temp
|
||||||
|
temp += (a * difference + b * lag + c
|
||||||
|
+ (k_nat * fraction + k_wind * fraction * speed) * difference
|
||||||
|
+ k_dir * facing * speed * difference
|
||||||
|
+ k_rec * recovery * ((t_ref if t_ref is not None else temp) - temp))
|
||||||
|
vent = with_window.ventilation['bed']
|
||||||
|
# Natural and wind terms both scale with opening x (T_out - T); with wind only
|
||||||
|
# 2-5 m/s their split is uncertain, but their combined effect is identified.
|
||||||
|
mean_speed = 3.5
|
||||||
|
learned = vent.coefficient('vent_natural') + vent.coefficient('vent_wind') * mean_speed
|
||||||
|
self.assertAlmostEqual(learned, k_nat + k_wind * mean_speed, delta=0.15 * (k_nat + k_wind * mean_speed))
|
||||||
|
self.assertAlmostEqual(vent.coefficient('recovery'), k_rec, delta=0.15 * k_rec)
|
||||||
|
self.assertAlmostEqual(vent.coefficient('vent_wind_dir'), k_dir, delta=0.3 * k_dir)
|
||||||
|
# With the window configured the envelope model stays close to the truth...
|
||||||
|
clean = with_window.learners['bed'].theta
|
||||||
|
self.assertAlmostEqual(clean[0], a, delta=0.1 * a)
|
||||||
|
self.assertAlmostEqual(clean[1], b, delta=0.05 * b)
|
||||||
|
self.assertLess(abs(clean[2] - c), 0.003)
|
||||||
|
# ...without it, ventilation shows up as a constant cooling bias and an inflated boiler effect.
|
||||||
|
mixed = without.learners['bed'].theta
|
||||||
|
self.assertLess(mixed[2], -0.008)
|
||||||
|
self.assertGreater(mixed[1], 1.05 * b)
|
||||||
|
# Forecasts replayed with the actual window history are far better than the base model's.
|
||||||
|
configured = with_window._evaluator.summary(with_window.rooms)[60]
|
||||||
|
self.assertGreater(configured['improvement_vs_base'], 0.6)
|
||||||
|
self.assertLess(configured['mae'], 0.06)
|
||||||
|
self.assertEqual(without._evaluator.summary(without.rooms)[60]['improvement_vs_base'], 0)
|
||||||
|
|
||||||
|
async def test_forecast_assumes_window_closes_then_room_recovers(self):
|
||||||
|
self.c = self.bedroom_with_window()
|
||||||
|
self.states['climate.reference'].state = 'off'
|
||||||
|
for _ in range(2):
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||||
|
await self.sample()
|
||||||
|
self.open_window('on', 240)
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||||
|
await self.sample()
|
||||||
|
model = self.c.learners['bed']
|
||||||
|
model.count, model.on_count, model.off_count = 200, 100, 100
|
||||||
|
vent = self.c.ventilation['bed']
|
||||||
|
vent.theta[vent.features.index('vent_natural')] = 0.01
|
||||||
|
vent.theta[vent.features.index('recovery')] = 0.15
|
||||||
|
self.states['climate.reference'].state = 'heat'
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
||||||
|
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
||||||
|
data = await self.sample()
|
||||||
|
self.assertEqual(data['status'], 'ready')
|
||||||
|
step = spy.call_args.kwargs['step_model']
|
||||||
|
base = model.predict_delta(19.0, 5.0, 0.0)
|
||||||
|
open_change = step({'bed': 19.0}, 0.0, 0)['bed'] - 19.0
|
||||||
|
recovering = step({'bed': 19.0}, 0.0, 4)['bed'] - 19.0 # 15 min default: closed by step 3
|
||||||
|
self.assertLess(open_change, base - 0.1) # 0.01 x (5 - 19) = -0.14 °C/5 min
|
||||||
|
self.assertGreater(recovering, base) # air recovers towards 20 °C
|
||||||
|
|
||||||
|
async def coupled_rooms(self, coupling, bed=(0.006, 0.09, 0.0)):
|
||||||
|
"""Living room and a colder bedroom (two exterior walls), one boiler, a shared wall."""
|
||||||
|
self.entry.subentries = {
|
||||||
|
'l': types.SimpleNamespace(subentry_id='l', subentry_type='zone', data={
|
||||||
|
'key': 'living', 'name': 'Living', 'sensors': ['sensor.room'], 'neighbors': ['bed']}),
|
||||||
|
'b': types.SimpleNamespace(subentry_id='b', subentry_type='zone', data={
|
||||||
|
'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.bedroom'], 'comfort_weight': 2})}
|
||||||
|
self.set_state('sensor.bedroom', '19', unit_of_measurement='°C')
|
||||||
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||||
|
self.states['climate.reference'].state = 'off'
|
||||||
|
params = {'living': (0.003, 0.12, 0.0005), 'bed': bed}
|
||||||
|
advance = coordinator.advance_lag
|
||||||
|
temps, lag, on = {'living': 21.0, 'bed': 19.5}, 0.0, False
|
||||||
|
for k in range(2400):
|
||||||
|
outside = 3 + 5 * sin(2 * pi * k / 288)
|
||||||
|
for entity, value in (('sensor.room', temps['living']), ('sensor.bedroom', temps['bed']),
|
||||||
|
('sensor.outside', outside)):
|
||||||
|
self.states[entity].state = f'{value:.5f}'
|
||||||
|
self.states[entity].last_reported = self.now
|
||||||
|
await self.sample()
|
||||||
|
average = (temps['living'] + 2 * temps['bed']) / 3
|
||||||
|
command = average < 20.2 or (on and average < 20.8)
|
||||||
|
if command != on:
|
||||||
|
self.switch('on' if command else 'off', 180)
|
||||||
|
lag = advance(advance(lag, on, 120), command, 180)
|
||||||
|
else:
|
||||||
|
lag = advance(lag, on, 300)
|
||||||
|
on = command
|
||||||
|
new = {}
|
||||||
|
for zone, other in (('living', 'bed'), ('bed', 'living')):
|
||||||
|
a, b, c = params[zone]
|
||||||
|
new[zone] = (temps[zone] + a * (outside - temps[zone]) + b * lag + c
|
||||||
|
+ coupling * (temps[other] - temps[zone]))
|
||||||
|
temps = new
|
||||||
|
return self.c
|
||||||
|
|
||||||
|
async def test_inter_zone_transfer_learned_where_present(self):
|
||||||
|
c = await self.coupled_rooms(0.01)
|
||||||
|
self.assertEqual(self.c.zones['bed'].neighbors, ('living',)) # made symmetric
|
||||||
|
for zone, other in (('living', 'bed'), ('bed', 'living')):
|
||||||
|
with self.subTest(zone=zone):
|
||||||
|
info = c.extended[zone].coefficients()[f'couple:{other}']
|
||||||
|
self.assertGreater(info['value'], 0.004) # clearly present (true 0.01)...
|
||||||
|
self.assertLess(info['value'], 0.013)
|
||||||
|
# ...but a steady difference between rooms is collinear with each room's
|
||||||
|
# bias, so the split stays uncertain and is not reported as identified.
|
||||||
|
self.assertFalse(info['identified'])
|
||||||
|
# Joint multi-zone forecasts still beat the base model at an hour.
|
||||||
|
self.assertGreater(c._evaluator.summary(c.rooms)[60]['improvement_vs_base'], 0.15)
|
||||||
|
|
||||||
|
async def test_inter_zone_transfer_stays_near_zero_when_absent(self):
|
||||||
|
c = await self.coupled_rooms(0.0, bed=(0.004, 0.11, 0.0))
|
||||||
|
for zone, other in (('living', 'bed'), ('bed', 'living')):
|
||||||
|
with self.subTest(zone=zone):
|
||||||
|
self.assertLess(c.extended[zone].coefficient(f'couple:{other}'), 0.002)
|
||||||
|
|
||||||
|
async def test_new_model_layers_survive_a_restart(self):
|
||||||
|
self.c = self.bedroom_with_window()
|
||||||
|
self.c.entry.subentries['h'] = types.SimpleNamespace(subentry_id='h', subentry_type='heat_source', data={
|
||||||
|
'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'bed'})
|
||||||
|
self.set_state('sensor.ups_power', '200', unit_of_measurement='W')
|
||||||
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||||
|
for k in range(20):
|
||||||
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir', 'sensor.ups_power')
|
||||||
|
if k == 18:
|
||||||
|
self.open_window('on', 240)
|
||||||
|
await self.sample()
|
||||||
|
extended, vent = self.c.extended['bed'], self.c.ventilation['bed']
|
||||||
|
self.assertGreater(extended.count, 0)
|
||||||
|
self.assertGreater(vent.count, 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.extended['bed'].theta, restored.extended['bed'].count), (extended.theta, extended.count))
|
||||||
|
self.assertEqual((restored.ventilation['bed'].theta, restored.ventilation['bed'].count), (vent.theta, vent.count))
|
||||||
|
self.assertEqual(restored._heat_sources._baselines['ups'].value(), 200) # no hour of re-learning
|
||||||
|
# Adding a feature keeps what was learned for the others.
|
||||||
|
self.entry.data['ventilation_cross'] = True
|
||||||
|
self.entry.subentries['z2'] = types.SimpleNamespace(subentry_id='z2', subentry_type='zone', data={
|
||||||
|
'key': 'living', 'name': 'Living', 'sensors': ['sensor.living']})
|
||||||
|
self.entry.subentries['w2'] = types.SimpleNamespace(subentry_id='w2', subentry_type='window', data={
|
||||||
|
'key': 'living_window', 'name': 'Living window', 'sensor': 'binary_sensor.living_window', 'zone': 'living'})
|
||||||
|
grown = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
||||||
|
grown._store.async_load.return_value = payload
|
||||||
|
await grown.async_restore_model()
|
||||||
|
self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross')
|
||||||
|
self.assertEqual(grown.ventilation['bed'].theta[:-1], vent.theta)
|
||||||
@@ -76,6 +76,25 @@ class EvaluationTests(unittest.TestCase):
|
|||||||
self.assertEqual(evaluator.summary(['sensor.room'])[15]['samples'], 7)
|
self.assertEqual(evaluator.summary(['sensor.room'])[15]['samples'], 7)
|
||||||
self.assertEqual(before, 7)
|
self.assertEqual(before, 7)
|
||||||
|
|
||||||
|
def test_full_model_replays_actual_inputs_and_is_compared_with_base(self):
|
||||||
|
base = (0.004, 0.12, 0.0005)
|
||||||
|
full = {'base': base, 'uses_extended': True,
|
||||||
|
'extended': (['loss', 'heat', 'bias', 'internal'], base + (0.15,)),
|
||||||
|
'ventilation': (['vent_natural'], (0.01,))}
|
||||||
|
evaluator, temp = ev.ForecastEvaluator(0.1), 20.0
|
||||||
|
for k in range(40):
|
||||||
|
internal = 0.4 if (k // 8) % 2 else 0.0 # gaming on and off
|
||||||
|
opening = 1.0 if k % 10 in (3, 4) else 0.0 # short ventilation
|
||||||
|
if k:
|
||||||
|
temp += 0.004 * (5 - temp) + 0.12 * 0.5 + 0.0005 + 0.15 * internal + 0.01 * opening * (5 - temp)
|
||||||
|
evaluator.observe(START + timedelta(seconds=300 * k), {'r': temp}, 5.0, 0.5, 0.0, {'r': base},
|
||||||
|
models={'r': full},
|
||||||
|
inputs={'r': {'internal': internal, 'ventilation': {'opening': opening}}})
|
||||||
|
stats = evaluator.summary(['r'])[60]
|
||||||
|
self.assertAlmostEqual(stats['mae'], 0, places=6)
|
||||||
|
self.assertGreater(stats['base_model_mae'], 0.05)
|
||||||
|
self.assertEqual(stats['improvement_vs_base'], 1.0)
|
||||||
|
|
||||||
def test_saved_statistics_round_trip_and_reject_garbage(self):
|
def test_saved_statistics_round_trip_and_reject_garbage(self):
|
||||||
evaluator = plant(10)
|
evaluator = plant(10)
|
||||||
restored = ev.ForecastEvaluator(0.1)
|
restored = ev.ForecastEvaluator(0.1)
|
||||||
@@ -86,8 +105,11 @@ class EvaluationTests(unittest.TestCase):
|
|||||||
'not-a-day': {'15': {'sensor.room': [1, 0.0, 0.1, 0.1]}},
|
'not-a-day': {'15': {'sensor.room': [1, 0.0, 0.1, 0.1]}},
|
||||||
day: {'45': {'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],
|
'15': {'sensor.a': [1, 0.0, -0.1, 0.1], 'sensor.b': [True, 0, 0, 0],
|
||||||
'sensor.c': [1, float('nan'), 0, 0], 'sensor.d': [2, 0.1, 0.2, 0.4]}}}})
|
'sensor.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]}}}})
|
'sensor.e': [2, 0.1, 0.2, 0.4, 0.3], 'sensor.f': [2, 0.1, 0.2, 0.4, -1]}}}})
|
||||||
|
# A 0.3 bucket has no base-model column; its model was the base model then.
|
||||||
|
self.assertEqual(restored.as_dict(), {'days': {day: {'15': {
|
||||||
|
'sensor.d': [2, 0.1, 0.2, 0.4, 0.2], 'sensor.e': [2, 0.1, 0.2, 0.4, 0.3]}}}})
|
||||||
for garbage in (None, [], {'days': []}):
|
for garbage in (None, [], {'days': []}):
|
||||||
restored.load(garbage)
|
restored.load(garbage)
|
||||||
self.assertEqual(restored.as_dict(), {'days': {}})
|
self.assertEqual(restored.as_dict(), {'days': {}})
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Electrical heat sources: units, overlap, baseline, thermal response, forecasts."""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from math import exp
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||||
|
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
||||||
|
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
||||||
|
from heating_mpc import heat_sources as hs # noqa: E402
|
||||||
|
|
||||||
|
UPS = {'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'office'}
|
||||||
|
PC = {'key': 'pc', 'name': 'Gaming PC', 'sensor': 'sensor.pc_power', 'zone': 'office', 'included_in': 'ups'}
|
||||||
|
|
||||||
|
|
||||||
|
def sources(*items, zones=('office', 'living')):
|
||||||
|
return hs.build_sources(((item, None) for item in items), zones)
|
||||||
|
|
||||||
|
|
||||||
|
def run(tracker, powers, start=0.0, step=300.0):
|
||||||
|
"""Feed one reading per poll; return the tracker states after each poll."""
|
||||||
|
states = []
|
||||||
|
for k, readings in enumerate(powers):
|
||||||
|
tracker.update(start + k * step, readings)
|
||||||
|
states.append({key: dict(info) for key, info in tracker.state.items()})
|
||||||
|
return states
|
||||||
|
|
||||||
|
|
||||||
|
class PowerTests(unittest.TestCase):
|
||||||
|
def test_units_are_normalized_and_non_heat_units_rejected(self):
|
||||||
|
self.assertEqual(hs.normalize_power('600', 'W'), 600)
|
||||||
|
self.assertEqual(hs.normalize_power(0.6, ' kW '), 600)
|
||||||
|
self.assertEqual(hs.normalize_power(600000, 'mW'), 600)
|
||||||
|
for raw, unit in (('600', 'VA'), ('35', '%'), ('600', None), ('600', ''), ('-5', 'W'),
|
||||||
|
('nan', 'W'), ('abc', 'W'), ('30000', 'W'), ('600', 7)):
|
||||||
|
with self.subTest(raw=raw, unit=unit):
|
||||||
|
self.assertIsNone(hs.normalize_power(raw, unit))
|
||||||
|
|
||||||
|
def test_build_sources_validates_zones_duplicates_and_inclusion(self):
|
||||||
|
built = sources(UPS, PC, {**UPS, 'key': 'ups2'}, {**PC, 'key': 'pc2', 'sensor': 'sensor.x', 'zone': 'gone'},
|
||||||
|
{'key': 'nas', 'sensor': 'sensor.nas', 'zone': 'office', 'included_in': 'pc'},
|
||||||
|
{'key': 'tv', 'sensor': 'sensor.tv', 'zone': 'living', 'included_in': 'missing'})
|
||||||
|
self.assertEqual([(s.key, s.included_in) for s in built], [('ups', None), ('pc', 'ups'), ('nas', None),
|
||||||
|
('tv', None)])
|
||||||
|
|
||||||
|
def test_included_child_is_not_double_counted(self):
|
||||||
|
built = sources(UPS, PC)
|
||||||
|
self.assertEqual(hs.effective_power(built, {'ups': 600, 'pc': 400}), {'ups': 200, 'pc': 400})
|
||||||
|
# Without the child reading the split is unknown; timing noise is clamped.
|
||||||
|
self.assertEqual(hs.effective_power(built, {'ups': 600, 'pc': None}), {'ups': None, 'pc': None})
|
||||||
|
self.assertEqual(hs.effective_power(built, {'ups': 380, 'pc': 400})['ups'], 0)
|
||||||
|
# A disabled child stays part of its parent's measurement.
|
||||||
|
built = sources(UPS, {**PC, 'enabled': False})
|
||||||
|
self.assertEqual(hs.effective_power(built, {'ups': 600, 'pc': None}), {'ups': 600})
|
||||||
|
|
||||||
|
def test_baseline_is_the_persistent_load_not_the_mean(self):
|
||||||
|
estimator = hs.BaselineEstimator()
|
||||||
|
for _ in range(11):
|
||||||
|
estimator.add(200)
|
||||||
|
self.assertIsNone(estimator.value()) # less than an hour of data
|
||||||
|
for k in range(288):
|
||||||
|
estimator.add(600 if k % 10 < 3 else 200) # gaming 30 % of the time
|
||||||
|
self.assertEqual(estimator.value(), 200)
|
||||||
|
self.assertEqual(len(estimator.samples), 288)
|
||||||
|
self.assertEqual(hs.BaselineEstimator([200, 'x', -5, float('nan'), 300]).samples, hs.deque([200.0, 300.0]))
|
||||||
|
|
||||||
|
def test_response_and_forecasts(self):
|
||||||
|
filt = hs.ResponseFilter(15)
|
||||||
|
self.assertEqual(filt.advance(0.0, 0), 0.0)
|
||||||
|
self.assertAlmostEqual(filt.advance(0.4, 900), 0.4 * (1 - exp(-1)))
|
||||||
|
self.assertEqual(hs.forecast_internal(0.4, 3, None), [0.4] * 3)
|
||||||
|
self.assertEqual(hs.forecast_internal(0.4, 2, 0), [0.0, 0.0])
|
||||||
|
decay = hs.forecast_internal(0.4, 12, 30)
|
||||||
|
self.assertAlmostEqual(decay[5], 0.2) # one half-life after 30 minutes
|
||||||
|
response = hs.forecast_response(0.0, 0.4, 24, 15, 30)
|
||||||
|
self.assertLess(max(response), 0.4) # the PC is not assumed to run on at full power
|
||||||
|
self.assertLess(response[-1], max(response))
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerTests(unittest.TestCase):
|
||||||
|
def test_gaming_session_drives_zone_input_with_lag_and_settling(self):
|
||||||
|
tracker = hs.HeatSourceTracker(sources(UPS))
|
||||||
|
idle = [{'ups': 200.0}] * 12
|
||||||
|
states = run(tracker, idle + [{'ups': 600.0}] * 6 + idle[:6])
|
||||||
|
self.assertIsNone(states[10]['ups']['variable_kw']) # baseline needs an hour of data
|
||||||
|
self.assertEqual(states[11]['ups']['baseline_w'], 200)
|
||||||
|
# The poll that sees 600 W has not yet had any extra heat: power is held over the interval.
|
||||||
|
self.assertEqual((states[12]['ups']['variable_kw'], states[12]['ups']['response_kw']), (0.4, 0.0))
|
||||||
|
self.assertAlmostEqual(states[13]['ups']['response_kw'], 0.4 * (1 - exp(-1 / 3)))
|
||||||
|
self.assertGreater(states[18]['ups']['response_kw'], states[19]['ups']['response_kw']) # PC off: decays
|
||||||
|
self.assertGreater(states[19]['ups']['response_kw'], 0.1) # but the room still holds the heat
|
||||||
|
self.assertEqual(tracker.zone_input('office'), states[-1]['ups']['response_kw'])
|
||||||
|
self.assertEqual(tracker.zone_input('living'), 0.0)
|
||||||
|
|
||||||
|
def test_short_spike_barely_moves_response_or_baseline(self):
|
||||||
|
tracker = hs.HeatSourceTracker(sources(UPS), {'baselines': {'ups': [200.0] * 24}})
|
||||||
|
states = run(tracker, [{'ups': 200.0}] * 3 + [{'ups': 900.0}] + [{'ups': 200.0}] * 4)
|
||||||
|
peak = max(s['ups']['response_kw'] for s in states)
|
||||||
|
self.assertAlmostEqual(peak, 0.7 * (1 - exp(-1 / 3))) # one 5-minute interval of +700 W
|
||||||
|
self.assertLess(peak, 0.2)
|
||||||
|
self.assertEqual(states[-1]['ups']['baseline_w'], 200)
|
||||||
|
|
||||||
|
def test_unknown_power_resets_until_settled_again(self):
|
||||||
|
tracker = hs.HeatSourceTracker(sources(UPS), {'baselines': {'ups': [200.0] * 24}})
|
||||||
|
run(tracker, [{'ups': 200.0}] * 7)
|
||||||
|
self.assertEqual(tracker.zone_input('office'), 0.0) # saved baseline: usable immediately
|
||||||
|
tracker.update(7 * 300, {'ups': 200.0})
|
||||||
|
tracker.update(8 * 300, {'ups': None})
|
||||||
|
self.assertFalse(tracker.state['ups']['settled'] and tracker.state['ups']['response_kw'] is None)
|
||||||
|
tracker.update(9 * 300, {'ups': 200.0})
|
||||||
|
self.assertIsNone(tracker.zone_input('office')) # restarted: 2 time constants to settle
|
||||||
|
run(tracker, [{'ups': 200.0}] * 6, start=10 * 300)
|
||||||
|
self.assertEqual(tracker.zone_input('office'), 0.0)
|
||||||
|
|
||||||
|
def test_manual_baseline_fraction_and_round_trip(self):
|
||||||
|
built = sources({**UPS, 'baseline_mode': 'manual', 'baseline_w': 150, 'heat_fraction': 0.5})
|
||||||
|
tracker = hs.HeatSourceTracker(built)
|
||||||
|
tracker.update(0, {'ups': 350.0})
|
||||||
|
self.assertEqual(tracker.state['ups']['variable_kw'], 0.1)
|
||||||
|
restored = hs.HeatSourceTracker(built, tracker.as_dict())
|
||||||
|
self.assertEqual(list(restored._baselines['ups'].samples), [350.0])
|
||||||
|
|
||||||
|
def test_form_validation(self):
|
||||||
|
others = sources(UPS)
|
||||||
|
cases = {
|
||||||
|
'source_name_required': {'name': ' '},
|
||||||
|
'sensor_in_other_source': {'name': 'PC', 'sensor': 'sensor.ups_power'},
|
||||||
|
'invalid_parent_source': {'name': 'NAS', 'sensor': 'sensor.nas', 'included_in': 'missing'},
|
||||||
|
'baseline_required': {'name': 'PC', 'sensor': 'sensor.pc', 'baseline_mode': 'manual'},
|
||||||
|
None: {'name': 'PC', 'sensor': 'sensor.pc', 'included_in': 'ups'},
|
||||||
|
}
|
||||||
|
for expected, data in cases.items():
|
||||||
|
with self.subTest(expected=expected):
|
||||||
|
self.assertEqual(hs.validate_source(data, others), expected)
|
||||||
|
nested = sources(UPS, PC)
|
||||||
|
self.assertEqual(hs.validate_source({'name': 'NAS', 'sensor': 'sensor.nas', 'included_in': 'pc'}, nested),
|
||||||
|
'invalid_parent_source')
|
||||||
|
self.assertEqual(hs.validate_source({'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power',
|
||||||
|
'included_in': 'other'}, [s for s in nested if s.key != 'ups']
|
||||||
|
+ sources({'key': 'other', 'sensor': 'sensor.o', 'zone': 'office'})),
|
||||||
|
'source_has_children')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Generic named-feature RLS learner, without Home Assistant."""
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||||
|
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
||||||
|
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
||||||
|
from heating_mpc import learning as lm # noqa: E402
|
||||||
|
|
||||||
|
FEATURES = ['loss', 'heat', 'bias', 'internal']
|
||||||
|
TRUE = {'loss': 0.004, 'heat': 0.12, 'bias': 0.0005, 'internal': 0.15}
|
||||||
|
|
||||||
|
|
||||||
|
def synthetic(learner, steps=3000, seed=1, noise=0.003, gaming=True):
|
||||||
|
"""Samples from dT = sum(theta*x) + noise with realistic, varied inputs."""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
for k in range(steps):
|
||||||
|
x = {'loss': -20 + 20 * rng.random(), 'heat': (k // 7) % 3 / 2,
|
||||||
|
'bias': 1.0, 'internal': 0.4 if gaming and (k // 40) % 5 == 0 else 0.0}
|
||||||
|
y = sum(TRUE[f] * x[f] for f in TRUE) + rng.gauss(0, noise)
|
||||||
|
learner.learn([x[f] for f in learner.features], y, command=x['heat'] > 0)
|
||||||
|
return learner
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureLearnerTests(unittest.TestCase):
|
||||||
|
def test_recovers_coefficients_and_reports_confidence(self):
|
||||||
|
learner = synthetic(lm.FeatureLearner(FEATURES))
|
||||||
|
for name, value in TRUE.items():
|
||||||
|
with self.subTest(feature=name):
|
||||||
|
self.assertAlmostEqual(learner.coefficient(name), value, delta=0.1 * value + 0.001)
|
||||||
|
info = learner.coefficients()
|
||||||
|
self.assertTrue(info['internal']['identified'])
|
||||||
|
self.assertGreater(info['internal']['confidence'], 0.8)
|
||||||
|
# Standard error in °C/5 min per kW: small against the coefficient.
|
||||||
|
self.assertLess(info['internal']['std'], 0.01)
|
||||||
|
self.assertAlmostEqual(learner.noise_var ** .5, 0.003, delta=0.001)
|
||||||
|
self.assertEqual(learner.count + learner.rejected_count, 3000)
|
||||||
|
|
||||||
|
def test_unexercised_feature_stays_at_prior_without_confidence(self):
|
||||||
|
learner = synthetic(lm.FeatureLearner(FEATURES), gaming=False)
|
||||||
|
info = learner.coefficients()['internal']
|
||||||
|
self.assertEqual((info['value'], info['samples'], info['identified']), (0.0, 0, False))
|
||||||
|
self.assertEqual(info['confidence'], 0)
|
||||||
|
# Windup bound: the unexcited variance never exceeds the prior.
|
||||||
|
i = learner.features.index('internal')
|
||||||
|
self.assertLessEqual(learner.p[i][i], lm.spec_for('internal').variance)
|
||||||
|
|
||||||
|
def test_bounds_and_linked_directional_wind(self):
|
||||||
|
learner = lm.FeatureLearner(['internal'])
|
||||||
|
for _ in range(200):
|
||||||
|
learner.learn([1.0], -0.2) # cooling with more power: unphysical
|
||||||
|
self.assertEqual(learner.coefficient('internal'), 0.0)
|
||||||
|
wind = lm.FeatureLearner(['vent_wind', 'vent_wind_dir'])
|
||||||
|
for k in range(500):
|
||||||
|
x = [-50.0, -50.0 * (1 if k % 2 else -1)]
|
||||||
|
wind.learn(x, 0.02 * x[1]) # only directional signal, of the "wrong" sign
|
||||||
|
self.assertLessEqual(abs(wind.coefficient('vent_wind_dir')), wind.coefficient('vent_wind') + 1e-12)
|
||||||
|
|
||||||
|
def test_invalid_samples_rejected(self):
|
||||||
|
learner = lm.FeatureLearner(['loss', 'bias'])
|
||||||
|
for x, y in (([1.0], 0.0), ([float('nan'), 1.0], 0.0), ([1.0, 1.0], 0.9),
|
||||||
|
([1.0, 1.0], float('inf'))):
|
||||||
|
self.assertFalse(learner.learn(x, y))
|
||||||
|
self.assertEqual((learner.count, learner.rejected_count), (0, 4))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
lm.FeatureLearner(['loss', 'loss'])
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
lm.FeatureLearner(['unknown'])
|
||||||
|
|
||||||
|
def test_round_trip_and_feature_remapping(self):
|
||||||
|
learner = synthetic(lm.FeatureLearner(FEATURES), steps=800)
|
||||||
|
same = lm.FeatureLearner.from_dict(learner.as_dict(), FEATURES)
|
||||||
|
self.assertEqual((same.theta, same.p, same.count, same.active, same.noise_var),
|
||||||
|
(learner.theta, learner.p, learner.count, learner.active, learner.noise_var))
|
||||||
|
# Adding a neighbour keeps every learned coefficient; the new one starts at its prior.
|
||||||
|
grown = lm.FeatureLearner.from_dict(learner.as_dict(), FEATURES + ['couple:bedroom'])
|
||||||
|
for name in FEATURES:
|
||||||
|
self.assertEqual(grown.coefficient(name), learner.coefficient(name))
|
||||||
|
self.assertEqual(grown.coefficient('couple:bedroom'), 0.0)
|
||||||
|
self.assertEqual(grown.p[4][4], lm.spec_for('couple').variance)
|
||||||
|
self.assertEqual(grown.p[0][1], learner.p[0][1])
|
||||||
|
# Removing the heat source keeps the rest, including their covariance.
|
||||||
|
shrunk = lm.FeatureLearner.from_dict(learner.as_dict(), ['loss', 'heat', 'bias'])
|
||||||
|
self.assertEqual(shrunk.theta, learner.theta[:3])
|
||||||
|
self.assertEqual(shrunk.p[2][0], learner.p[2][0])
|
||||||
|
|
||||||
|
def test_malformed_saved_data_starts_fresh(self):
|
||||||
|
good = synthetic(lm.FeatureLearner(['loss', 'bias']), steps=50).as_dict()
|
||||||
|
fresh = lm.FeatureLearner(['loss', 'bias'])
|
||||||
|
for change in ({'features': ['loss', 'loss']}, {'theta': [0.1]}, {'p': [[1, 0], [0, -1]]},
|
||||||
|
{'p': [[1, 0.5], [0, 1]]}, {'count': -1}, {'active': [1]},
|
||||||
|
{'theta': [float('nan'), 0]}, {'on_count': 99}, {'residual_sq': -1},
|
||||||
|
{'residual_weight': 2}):
|
||||||
|
with self.subTest(change=change):
|
||||||
|
data = dict(good)
|
||||||
|
data.update(change)
|
||||||
|
restored = lm.FeatureLearner.from_dict(data, ['loss', 'bias'])
|
||||||
|
self.assertEqual((restored.theta, restored.count), (fresh.theta, 0))
|
||||||
|
for garbage in (None, [], 'x', {}):
|
||||||
|
self.assertEqual(lm.FeatureLearner.from_dict(garbage, ['loss']).count, 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
+15
-6
@@ -1,14 +1,15 @@
|
|||||||
"""Offline tests: run python -m unittest discover -s tests -v."""
|
"""Offline tests: run python -m unittest discover -s tests -v."""
|
||||||
import importlib.util
|
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
path = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc' / 'physics.py'
|
|
||||||
spec = importlib.util.spec_from_file_location('heating_mpc_physics', path)
|
|
||||||
m = importlib.util.module_from_spec(spec)
|
|
||||||
import sys
|
import sys
|
||||||
sys.modules[spec.name] = m
|
import types
|
||||||
spec.loader.exec_module(m)
|
|
||||||
|
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||||
|
# Fake only the package container, to avoid importing Home Assistant from __init__.
|
||||||
|
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
||||||
|
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
||||||
|
from heating_mpc import physics as m # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
class ModelTests(unittest.TestCase):
|
class ModelTests(unittest.TestCase):
|
||||||
@@ -44,6 +45,14 @@ class ModelTests(unittest.TestCase):
|
|||||||
learners, False, 3600, horizon_minutes=60)
|
learners, False, 3600, horizon_minutes=60)
|
||||||
self.assertTrue(out['proposal'])
|
self.assertTrue(out['proposal'])
|
||||||
|
|
||||||
|
def test_zone_target_offsets_shift_comfort(self):
|
||||||
|
learners = {'r': m.Learner()}
|
||||||
|
args = ({'r': 20.8}, 8, 0, 22, {'r': 1}, learners, False, 3600)
|
||||||
|
self.assertTrue(m.optimize(*args, horizon_minutes=60)['proposal'])
|
||||||
|
self.assertFalse(m.optimize(*args, horizon_minutes=60, target_offsets={'r': -1.5})['proposal'])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
m.optimize(*args, horizon_minutes=60, target_offsets={'r': float('nan')})
|
||||||
|
|
||||||
def test_minimum_duration_on_and_off(self):
|
def test_minimum_duration_on_and_off(self):
|
||||||
learners = {'r': m.Learner()}
|
learners = {'r': m.Learner()}
|
||||||
# Could not switch OFF during first 15 minutes if just turned ON.
|
# Could not switch OFF during first 15 minutes if just turned ON.
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Windows, wind exposure, ventilation inputs and recovery, without Home Assistant."""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from math import cos, exp, radians
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||||
|
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
||||||
|
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
||||||
|
from heating_mpc import windows as wm # noqa: E402
|
||||||
|
|
||||||
|
WEATHER = {'temperature': 2.0, 'wind': 4.0, 'wind_direction': 204.0, 'wind_gust': 9.0}
|
||||||
|
|
||||||
|
|
||||||
|
def window(key, zone='bed', **extra):
|
||||||
|
return {'key': key, 'name': key.title(), 'sensor': f'binary_sensor.{key}', 'zone': zone, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
class GeometryTests(unittest.TestCase):
|
||||||
|
def test_wind_relative_to_facade(self):
|
||||||
|
# The apartment's façade faces about 345° (NNW); OpenWeatherMap wind from 204°.
|
||||||
|
self.assertAlmostEqual(wm.angle_difference(204, 345), -141)
|
||||||
|
self.assertAlmostEqual(wm.facing_cos(204, 345), cos(radians(141)))
|
||||||
|
self.assertAlmostEqual(wm.facing_cos(345, 345), 1) # blowing straight at the window
|
||||||
|
self.assertAlmostEqual(wm.facing_cos(165, 345), -1) # from behind the building
|
||||||
|
self.assertAlmostEqual(wm.facing_cos(75, 345), 0, places=9) # crosswind
|
||||||
|
# Around north, differences wrap instead of jumping by 360°.
|
||||||
|
self.assertAlmostEqual(wm.angle_difference(5, 355), 10)
|
||||||
|
self.assertAlmostEqual(wm.angle_difference(355, 5), -10)
|
||||||
|
self.assertAlmostEqual(wm.facing_cos(5, 355), cos(radians(10)))
|
||||||
|
|
||||||
|
def test_circular_mean(self):
|
||||||
|
self.assertAlmostEqual(wm.circular_mean([350, 10]) % 360 if wm.circular_mean([350, 10]) < 359 else 0, 0)
|
||||||
|
self.assertAlmostEqual(wm.circular_mean([350, 20], [1, 1]), 5)
|
||||||
|
self.assertIsNone(wm.circular_mean([90, 270]))
|
||||||
|
self.assertAlmostEqual(wm.circular_mean([0, 90], [3, 1]), 18.4349488, places=5)
|
||||||
|
|
||||||
|
def test_contact_states_and_open_fraction(self):
|
||||||
|
self.assertEqual([wm.contact_open(s) for s in ('on', 'off', 'unknown', 'unavailable', None)],
|
||||||
|
[True, False, None, None, None])
|
||||||
|
self.assertEqual([wm.contact_open(s, inverted=True) for s in ('on', 'off')], [False, True])
|
||||||
|
self.assertEqual(wm.open_fraction(True, True, 0, 100, 400), 1.0)
|
||||||
|
self.assertEqual(wm.open_fraction(False, False, 0, 100, 400), 0.0)
|
||||||
|
self.assertAlmostEqual(wm.open_fraction(False, True, 220, 100, 400), 0.6) # opened 120 s in
|
||||||
|
self.assertAlmostEqual(wm.open_fraction(True, False, 220, 100, 400), 0.4) # closed 120 s in
|
||||||
|
self.assertIsNone(wm.open_fraction(False, False, 220, 100, 400)) # round trip: unknown
|
||||||
|
self.assertIsNone(wm.open_fraction(None, True, 0, 100, 400))
|
||||||
|
|
||||||
|
def test_ventilation_vector(self):
|
||||||
|
features = list(wm.VENTILATION_FEATURES)
|
||||||
|
inputs = {'opening': 0.5, 'facing': -0.3, 'other_opening': 1.0, 'recovery': 0.0, 't_ref': None,
|
||||||
|
'wind': 4.0, 'gust': 9.0}
|
||||||
|
for value, expected in zip(wm.ventilation_vector(features, 20.0, 2.0, inputs),
|
||||||
|
[-9.0, -36.0, 21.6, -45.0, -9.0, 0.0]):
|
||||||
|
self.assertAlmostEqual(value, expected)
|
||||||
|
self.assertIsNone(wm.ventilation_vector(['vent_wind'], 20, 2, {**inputs, 'wind': None}))
|
||||||
|
# Calm air: wind terms vanish, natural ventilation stays.
|
||||||
|
calm = wm.ventilation_vector(features, 20.0, 2.0, {**inputs, 'wind': 0.0, 'gust': 0.0})
|
||||||
|
self.assertEqual([round(v, 9) for v in calm[1:4]], [0.0, 0.0, 0.0])
|
||||||
|
self.assertEqual(calm[0], -9.0)
|
||||||
|
self.assertEqual(wm.ventilation_vector(['vent_natural'], 20, 2, {**inputs, 'wind': None}), [-9.0])
|
||||||
|
self.assertIsNone(wm.ventilation_vector(['recovery'], 20, 2, {**inputs, 'recovery': 0.5}))
|
||||||
|
self.assertEqual(wm.ventilation_vector(['recovery'], 19, 2, {**inputs, 'recovery': 0.5, 't_ref': 21}),
|
||||||
|
[1.0])
|
||||||
|
|
||||||
|
def test_window_config_validation(self):
|
||||||
|
built = wm.build_windows([
|
||||||
|
(window('a', azimuth=705), None), (window('b', opening_factor=9), None),
|
||||||
|
(window('c', zone='gone'), None), (window('a'), None),
|
||||||
|
({**window('d'), 'sensor': 'sensor.d'}, None), (window('e', inverted=True), 's5')], ['bed'])
|
||||||
|
self.assertEqual([(w.key, w.azimuth, w.inverted) for w in built], [('a', 345.0, False), ('e', 345.0, True)])
|
||||||
|
self.assertEqual(wm.validate_window({'name': ' '}, built), 'window_name_required')
|
||||||
|
self.assertEqual(wm.validate_window({'name': 'x', 'sensor': 'binary_sensor.a'}, built),
|
||||||
|
'sensor_in_other_window')
|
||||||
|
self.assertIsNone(wm.validate_window({'name': 'x', 'sensor': 'binary_sensor.x'}, built))
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tracker = wm.VentilationTracker(wm.build_windows([(window('bed_window'), None)], ['bed']),
|
||||||
|
recovery_minutes=20)
|
||||||
|
self.t = 1_000_000.0
|
||||||
|
self.changed = 0.0
|
||||||
|
|
||||||
|
def poll(self, state, temp, changed_offset=None, weather=WEATHER):
|
||||||
|
if changed_offset is not None:
|
||||||
|
self.changed = self.t - changed_offset
|
||||||
|
self.tracker.update(self.t, {'bed_window': (state, self.changed)}, {'bed': temp}, weather)
|
||||||
|
inputs = self.tracker.zone_inputs('bed', weather)
|
||||||
|
self.t += 300
|
||||||
|
return inputs
|
||||||
|
|
||||||
|
def test_ventilation_event_and_recovery(self):
|
||||||
|
self.assertFalse(self.poll('off', 21.0)['known']) # no previous poll yet
|
||||||
|
closed = self.poll('off', 21.0)
|
||||||
|
self.assertEqual((closed['opening'], closed['affected']), (0.0, False))
|
||||||
|
opened = self.poll('on', 20.6, changed_offset=180) # opened 3 minutes ago
|
||||||
|
self.assertAlmostEqual(opened['opening'], 0.6)
|
||||||
|
self.assertAlmostEqual(opened['facing'], 0.6 * cos(radians(141)))
|
||||||
|
self.assertTrue(opened['affected'])
|
||||||
|
self.assertEqual(opened['t_ref'], 21.0) # temperature at the poll before opening
|
||||||
|
for temp in (20.2, 19.9):
|
||||||
|
self.assertEqual(self.poll('on', temp)['opening'], 1.0)
|
||||||
|
closing = self.poll('off', 20.0, changed_offset=60) # closed a minute ago
|
||||||
|
self.assertAlmostEqual(closing['opening'], 0.8)
|
||||||
|
self.assertAlmostEqual(closing['recovery'], exp(-60 / 1200))
|
||||||
|
after = [self.poll('off', 20.0 + 0.1 * k) for k in range(12)]
|
||||||
|
self.assertTrue(after[0]['affected'] and after[0]['recovery'] > 0.7)
|
||||||
|
self.assertEqual((after[-1]['recovery'], after[-1]['affected']), (0.0, False)) # ~3 time constants
|
||||||
|
event = self.tracker.events[-1]
|
||||||
|
self.assertEqual((event['temperature_before'], event['lowest_temperature'], event['temperature_at_close']),
|
||||||
|
(21.0, 19.9, 20.0))
|
||||||
|
self.assertEqual((event['duration_minutes'], event['wind_direction'], event['windows']), (17.0, 204.0, ['Bed_Window'])) # 3 min + 15 min - 1 min
|
||||||
|
self.assertEqual(event['temperature_after_minutes'], 30)
|
||||||
|
restored = wm.VentilationTracker(self.tracker.windows, saved=self.tracker.as_dict())
|
||||||
|
self.assertEqual(list(restored.events), list(self.tracker.events))
|
||||||
|
|
||||||
|
def test_unknown_state_blocks_learning_and_multiple_windows_add(self):
|
||||||
|
tracker = wm.VentilationTracker(wm.build_windows(
|
||||||
|
[(window('w1'), None), (window('w2', opening_factor=0.5), None), (window('w3', zone='living'), None)],
|
||||||
|
['bed', 'living']))
|
||||||
|
for t in (0.0, 300.0):
|
||||||
|
tracker.update(t, {'w1': ('on', -10), 'w2': ('on', -10), 'w3': ('on', -10)}, {'bed': 20, 'living': 21},
|
||||||
|
WEATHER)
|
||||||
|
bed = tracker.zone_inputs('bed', WEATHER)
|
||||||
|
self.assertEqual((bed['opening'], bed['other_opening']), (1.5, 1.0)) # living's window is open too
|
||||||
|
tracker.update(600.0, {'w1': ('unavailable', 550), 'w2': ('on', -10), 'w3': ('off', 400)},
|
||||||
|
{'bed': 20, 'living': 21}, WEATHER)
|
||||||
|
bed = tracker.zone_inputs('bed', WEATHER)
|
||||||
|
self.assertEqual((bed['opening'], bed['known'], bed['affected']), (None, False, True))
|
||||||
|
self.assertIsNone(tracker.zone_inputs('kitchen', WEATHER))
|
||||||
|
|
||||||
|
def test_forecast_assumes_closing_then_recovery(self):
|
||||||
|
self.poll('off', 21.0)
|
||||||
|
self.poll('off', 21.0)
|
||||||
|
self.poll('on', 20.5, changed_offset=100)
|
||||||
|
steps = self.tracker.forecast('bed', 12, open_minutes=15, weather=WEATHER, now=self.t)
|
||||||
|
self.assertEqual([s['opening'] for s in steps[:4]], [1.0, 1.0, 1.0, 0.0])
|
||||||
|
self.assertAlmostEqual(steps[0]['facing'], cos(radians(141)))
|
||||||
|
self.assertAlmostEqual(steps[3]['recovery'], exp(-300 / 1200))
|
||||||
|
self.assertGreater(steps[3]['recovery'], steps[6]['recovery'])
|
||||||
|
self.assertEqual(steps[5]['t_ref'], 21.0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Layered zone model: fallbacks, contributions and the compiled optimizer step."""
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||||
|
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
||||||
|
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
||||||
|
from heating_mpc import learning, physics, zone_model as zm # noqa: E402
|
||||||
|
|
||||||
|
VENT = {'opening': 1.0, 'facing': -0.5, 'other_opening': 0.5, 'recovery': 0.0, 't_ref': 21.0,
|
||||||
|
'wind': 4.0, 'gust': 7.0}
|
||||||
|
|
||||||
|
|
||||||
|
def trained(features, theta):
|
||||||
|
learner = learning.FeatureLearner(features)
|
||||||
|
learner.theta = list(theta)
|
||||||
|
learner.count, learner.on_count, learner.off_count = 300, 100, 100
|
||||||
|
return learner
|
||||||
|
|
||||||
|
|
||||||
|
def models():
|
||||||
|
living = zm.ZoneModel(physics.Learner(), trained(zm.extended_features(True, ('bed',)),
|
||||||
|
(0.004, 0.12, 0.0005, 0.15, 0.01)))
|
||||||
|
bed = zm.ZoneModel(physics.Learner(), trained(zm.extended_features(False, ('living',)),
|
||||||
|
(0.006, 0.09, 0.0, 0.01)),
|
||||||
|
trained(['vent_natural', 'recovery', 'vent_wind', 'vent_wind_dir', 'vent_cross'],
|
||||||
|
(0.01, 0.15, 0.001, 0.0006, 0.002)))
|
||||||
|
return {'living': living, 'bed': bed}
|
||||||
|
|
||||||
|
|
||||||
|
class ZoneModelTests(unittest.TestCase):
|
||||||
|
def test_layers_and_fallbacks(self):
|
||||||
|
self.assertEqual(zm.extended_features(False, ()), [])
|
||||||
|
self.assertEqual(zm.extended_features(True, ('b',)), ['loss', 'heat', 'bias', 'internal', 'couple:b'])
|
||||||
|
living = models()['living']
|
||||||
|
inputs = {'outside': 5.0, 'lag': 0.5, 'internal': 0.4, 'temps': {'living': 21.0, 'bed': 20.0}}
|
||||||
|
self.assertAlmostEqual(living.delta(21.0, inputs),
|
||||||
|
0.004 * -16 + 0.06 + 0.0005 + 0.06 + 0.01 * -1)
|
||||||
|
# A missing input (electrical heat or a neighbour) falls back to the base model.
|
||||||
|
base = living.base.predict_delta(21.0, 5.0, 0.5)
|
||||||
|
self.assertAlmostEqual(living.delta(21.0, {**inputs, 'internal': None}), base)
|
||||||
|
self.assertAlmostEqual(living.delta(21.0, {**inputs, 'temps': {'living': 21.0}}), base)
|
||||||
|
# Until the extended model is as trained as the base needs to be, the base is used.
|
||||||
|
living.extended.count = 10
|
||||||
|
self.assertFalse(living.uses_extended)
|
||||||
|
self.assertAlmostEqual(living.delta(21.0, inputs), base)
|
||||||
|
|
||||||
|
def test_contributions_add_up_to_the_change(self):
|
||||||
|
bed = models()['bed']
|
||||||
|
inputs = {'outside': 5.0, 'lag': 0.5, 'temps': {'living': 21.0, 'bed': 19.0}, 'ventilation': VENT}
|
||||||
|
terms = bed.contributions(19.0, inputs)
|
||||||
|
self.assertEqual(set(terms), {'loss', 'heat', 'bias', 'neighbors', 'ventilation', 'recovery'})
|
||||||
|
self.assertAlmostEqual(sum(terms.values()), 12 * bed.delta(19.0, inputs), places=2)
|
||||||
|
self.assertLess(terms['ventilation'], 0)
|
||||||
|
# Unknown window state: no ventilation term rather than a guess.
|
||||||
|
self.assertAlmostEqual(bed.ventilation_delta(19.0, {**inputs, 'ventilation': {**VENT, 'opening': None}}), 0)
|
||||||
|
|
||||||
|
def test_compiled_step_matches_direct_model_exactly(self):
|
||||||
|
zone_models = models()
|
||||||
|
steps = 24
|
||||||
|
ventilation = [{**VENT, 'opening': 1.0 if k < 3 else 0.0, 'recovery': 0.0 if k < 3 else 0.6}
|
||||||
|
for k in range(steps)]
|
||||||
|
|
||||||
|
def inputs_at(key, k):
|
||||||
|
return {'outside': 5.0, 'internal': 0.4 if key == 'living' else None,
|
||||||
|
'ventilation': ventilation[k] if key == 'bed' else {}}
|
||||||
|
fast = zm.compile_step_model(zone_models, ['living', 'bed'], steps, inputs_at)
|
||||||
|
rng = random.Random(5)
|
||||||
|
for _ in range(300):
|
||||||
|
state = {'living': rng.uniform(15, 26), 'bed': rng.uniform(15, 26)}
|
||||||
|
lag, k = rng.random(), rng.randrange(steps + 3) # beyond the horizon: last step repeats
|
||||||
|
direct = {key: value + zone_models[key].delta(value, {
|
||||||
|
**inputs_at(key, min(k, steps - 1)), 'lag': lag, 'temps': state}) for key, value in state.items()}
|
||||||
|
compiled = fast(state, lag, k)
|
||||||
|
for key in state:
|
||||||
|
self.assertAlmostEqual(compiled[key], direct[key], places=12)
|
||||||
|
# With a zone missing, its neighbour falls back to its base model, also when compiled.
|
||||||
|
solo = zm.compile_step_model(zone_models, ['living'], steps, inputs_at)
|
||||||
|
base = zone_models['living'].base.predict_delta(20.0, 5.0, 0.3)
|
||||||
|
self.assertAlmostEqual(solo({'living': 20.0}, 0.3, 0)['living'], 20.0 + base, places=12)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Thermal zones: parsing, sensor weighting and validation, without Home Assistant."""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||||
|
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
||||||
|
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
||||||
|
from heating_mpc import zones as z # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def zone(key, sensors, **extra):
|
||||||
|
return {'key': key, 'name': key.title(), 'sensors': sensors, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
class ZoneTests(unittest.TestCase):
|
||||||
|
def test_legacy_rooms_become_one_sensor_zones_keyed_by_entity(self):
|
||||||
|
data = z.legacy_zone_data(['sensor.a', 'sensor.b', 'sensor.a'], {'sensor.a': 3, 'sensor.b': 'x'},
|
||||||
|
{'sensor.a': 'Living'})
|
||||||
|
zones = z.build_zones((d, None) for d in data)
|
||||||
|
self.assertEqual([(q.key, q.name, q.sensors, q.comfort_weight) for q in zones],
|
||||||
|
[('sensor.a', 'Living', ('sensor.a',), 3.0), ('sensor.b', 'sensor.b', ('sensor.b',), 1.0)])
|
||||||
|
|
||||||
|
def test_invalid_values_fall_back_and_bad_zones_are_skipped(self):
|
||||||
|
parsed = z.zone_from_data(zone('bed', ['sensor.b', 'light.x', 'sensor.b'], comfort_weight=99,
|
||||||
|
target_offset='bad', sensor_weights={'sensor.b': -1}))
|
||||||
|
self.assertEqual((parsed.sensors, parsed.comfort_weight, parsed.target_offset,
|
||||||
|
parsed.sensor_weight('sensor.b')), (('sensor.b',), 1.0, 0.0, 1.0))
|
||||||
|
for bad in ({'key': 'x', 'sensors': []}, {'sensors': ['sensor.a']}, {'key': 'x', 'sensors': 'sensor.a'}):
|
||||||
|
self.assertIsNone(z.zone_from_data(bad))
|
||||||
|
# The subentry ID stands in for a missing key.
|
||||||
|
self.assertEqual(z.zone_from_data({'sensors': ['sensor.a']}, 'sub1').key, 'sub1')
|
||||||
|
|
||||||
|
def test_duplicates_shared_sensors_and_neighbours(self):
|
||||||
|
zones = z.build_zones([
|
||||||
|
(zone('living', ['sensor.l1', 'sensor.l2'], neighbors=['bed', 'ghost', 'living']), 's1'),
|
||||||
|
(zone('bed', ['sensor.b']), 's2'),
|
||||||
|
(zone('living', ['sensor.x']), 's3'), # duplicate key
|
||||||
|
(zone('hall', ['sensor.l2']), 's4'), # sensor already in another zone
|
||||||
|
])
|
||||||
|
self.assertEqual([q.key for q in zones], ['living', 'bed'])
|
||||||
|
# Neighbours exist and are symmetric.
|
||||||
|
self.assertEqual([q.neighbors for q in zones], [('bed',), ('living',)])
|
||||||
|
|
||||||
|
def test_zone_temperature_weights_and_reports_sensors_used(self):
|
||||||
|
living = z.zone_from_data(zone('living', ['sensor.a', 'sensor.b'],
|
||||||
|
sensor_weights={'sensor.a': 3, 'sensor.b': 1}))
|
||||||
|
self.assertEqual(z.zone_temperature(living, {'sensor.a': 20.0, 'sensor.b': 22.0}),
|
||||||
|
(20.5, frozenset({'sensor.a', 'sensor.b'})))
|
||||||
|
self.assertEqual(z.zone_temperature(living, {'sensor.a': None, 'sensor.b': 22.0}),
|
||||||
|
(22.0, frozenset({'sensor.b'})))
|
||||||
|
self.assertEqual(z.zone_temperature(living, {}), (None, frozenset()))
|
||||||
|
|
||||||
|
def test_form_validation(self):
|
||||||
|
others = [z.zone_from_data(zone('bed', ['sensor.b']))]
|
||||||
|
cases = {
|
||||||
|
'zone_name_required': {'name': ' ', 'sensors': ['sensor.a']},
|
||||||
|
'zone_sensors': {'name': 'Living', 'sensors': []},
|
||||||
|
'sensor_in_other_zone': {'name': 'Living', 'sensors': ['sensor.b']},
|
||||||
|
'sensor_is_weather_source': {'name': 'Living', 'sensors': ['sensor.out']},
|
||||||
|
None: {'name': 'Living', 'sensors': ['sensor.a', 'sensor.c']},
|
||||||
|
}
|
||||||
|
for expected, data in cases.items():
|
||||||
|
with self.subTest(expected=expected):
|
||||||
|
self.assertEqual(z.validate_zone(data, others, ['sensor.out']), expected)
|
||||||
|
self.assertEqual(z.validate_zone({'name': 'x', 'sensors': [f'sensor.s{i}' for i in range(7)]}, [], []),
|
||||||
|
'zone_sensors')
|
||||||
|
twelve = [z.zone_from_data(zone(f'z{i}', [f'sensor.z{i}'])) for i in range(12)]
|
||||||
|
self.assertEqual(z.validate_zone({'name': 'x', 'sensors': ['sensor.new']}, twelve, []), 'too_many_zones')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user