From 2b9a10979beaea76e24babc820a3e3853ded6ebc Mon Sep 17 00:00:00 2001 From: Andrei Buzera Date: Mon, 26 Oct 2026 17:34:32 +0200 Subject: [PATCH] initial commit v0.2.1 --- .DS_Store | Bin 0 -> 6148 bytes AGENTS.md | 146 +++++++++++++ README.md | 75 +++++++ ROADMAP.md | 148 +++++++++++++ custom_components/heating_mpc/__init__.py | 39 ++++ .../heating_mpc/binary_sensor.py | 28 +++ custom_components/heating_mpc/config_flow.py | 184 ++++++++++++++++ custom_components/heating_mpc/const.py | 39 ++++ custom_components/heating_mpc/coordinator.py | 199 ++++++++++++++++++ custom_components/heating_mpc/manifest.json | 13 ++ custom_components/heating_mpc/physics.py | 169 +++++++++++++++ custom_components/heating_mpc/sensor.py | 90 ++++++++ custom_components/heating_mpc/strings.json | 96 +++++++++ .../heating_mpc/translations/en.json | 96 +++++++++ .../heating_mpc/translations/ro.json | 96 +++++++++ .../heating_mpc/weather_sources.py | 104 +++++++++ hacs.json | 5 + tests/test_physics.py | 65 ++++++ tests/test_weather_sources.py | 73 +++++++ 19 files changed, 1665 insertions(+) create mode 100644 .DS_Store create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 custom_components/heating_mpc/__init__.py create mode 100644 custom_components/heating_mpc/binary_sensor.py create mode 100644 custom_components/heating_mpc/config_flow.py create mode 100644 custom_components/heating_mpc/const.py create mode 100644 custom_components/heating_mpc/coordinator.py create mode 100644 custom_components/heating_mpc/manifest.json create mode 100644 custom_components/heating_mpc/physics.py create mode 100644 custom_components/heating_mpc/sensor.py create mode 100644 custom_components/heating_mpc/strings.json create mode 100644 custom_components/heating_mpc/translations/en.json create mode 100644 custom_components/heating_mpc/translations/ro.json create mode 100644 custom_components/heating_mpc/weather_sources.py create mode 100644 hacs.json create mode 100644 tests/test_physics.py create mode 100644 tests/test_weather_sources.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..153f549af5752777589bd9a394d9f36d23853d89 GIT binary patch literal 6148 zcmeHKyG{c!5S)b+k!V6k>0jUvPEq&*J|GbZUEq)|>95Ln@oCIHn$tlQ(L}S-dhGR% zEl+WK3&7Ur!!xh~u%tWU;=|m0-+g9x6)_^6XY}~M7W=?i`mX`!K9X_9fYXHk#nmU? z=_d?_VSjw<`=h7Jq<|EV0#ZN(CCg`;g}eo4i3=* z5El%GaUQ({v3Y>l6^@CF&@8FMq*}EYmUPBj<#mN)V$xyNd|2IV)uCA2&huNO!@5L` zQa}n!6}Zpk%Ip6N{g3{CO43RSNP(MDz!sa=&6-cD+B$oj*V;zk(LLvz?#6jgI7B%n jMmgrf%kfhrWnS|+_q)O|G3bm3ov5Dy*F`1;{#$_`CL0*6 literal 0 HcmV?d00001 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..75125dc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,146 @@ +# AGENTS.md — Heating MPC + +## Project mission + +Build a **local, configurable, testable Home Assistant custom integration** for model-predictive heating control on **Home Assistant OS (HAOS), Raspberry Pi 5**. The home has multiple indoor temperature sensors, configurable comfort weights, outdoor weather sensors, **one physical ON/OFF gas-boiler relay**, and an existing Home Assistant `generic_thermostat`. + +The project starts as a **read-only observer**. It learns thermal behavior and predicts possible heating decisions. Any future control of real heating equipment is a separate, explicitly authorized milestone. + +**Working agreement:** Architecture, requirements and results are discussed in ChatGPT; source-code implementation, tests and commits happen in Codex in this repository. Treat the files and current code in this repository as the implementation source of truth, not verbal descriptions of earlier ZIP releases. + +## Starting point / status + +- Baseline: **v0.2.1**, experimental custom integration (`custom_components/heating_mpc`). +- Native HA config and options flows; no Docker service, MQTT broker or direct InfluxDB connection needed. +- Reads room sensors, an existing `climate` entity, the physical relay state and prioritized weather sources. +- Uses 5-minute observations, per-room linear RC-like thermal models with a heating-lag approximation, persistent learned parameters, and an enumerative binary MPC optimizer. +- Exposes status/diagnostics, a weighted temperature, selected outdoor temperature, per-room 60-minute prediction entities and a **hypothetical** proposed-heat binary sensor. +- Weather variables other than outdoor temperature are currently for **diagnostics/comparison only**. +- No backfill from Recorder; no hourly weather forecasts; no validated 60-minute out-of-sample accuracy; no actuator control. +- Offline unit tests exist in `tests/`. Real HAOS integration and restart/unload behavior still need thorough testing. + +**Do not claim an item is implemented or validated solely because it is listed in ROADMAP.md.** Check the code and tests first. + +## Non-negotiable safety rules + +1. **Observation-only by default.** The existing `generic_thermostat` is the sole controller of the physical boiler switch until the user explicitly approves a future control milestone. Never quietly add `switch.turn_on`, `switch.turn_off`, `climate.set_temperature`, `climate.set_hvac_mode`, `climate.set_preset_mode`, relay automations, or equivalent writes. +2. **Never connect `binary_sensor.heating_mpc_proposed_heat` directly to the relay.** It is diagnostic output, not a certified control signal. +3. **One physical relay, one writer.** Any future live implementation must provide a proven handover/arbiter design; `generic_thermostat` and MPC must never compete over the same actuator. +4. **Manual `heat/off` is authoritative.** The user controls HVAC mode; do not automatically switch from `off` to `heat`, including after restart. Preserve user-selected Comfort/Away/Sleep targets (currently 22/19/21 °C) instead of resetting them. +5. **Fail safe.** On missing/stale/implausible sensor data, optimizer failure, restarts, incomplete initialization or loss of connectivity: never issue an unvalidated heat request. For future live control, define and test safe fallback/recovery before enabling it. Fallback must not create two simultaneous writers. +6. **Protect the boiler.** For future control, enforce minimum ON and OFF times, bounded setpoints, watchdog and recovery rules. Do not change gas/combustion settings, boiler flow temperature, firmware or wiring. The Immergas boiler is **non-condensing**, with conventional radiators; setting the boiler's flow temperature is outside this integration's scope. +7. **No experiments that heat the apartment without the user's opt-in.** Avoid automatically generating artificial ON cycles to accelerate model training. +8. Never introduce cloud dependencies, secrets in source control, or external network access for model inference. Default to local operation and HA entities. + +## Hardware and Home Assistant context + +- HA host: Raspberry Pi 5 with HAOS. +- Heating: non-condensing Immergas Eolo Star (exact variant to be verified), aluminum radiators, copper plumbing, two-room apartment; relay is binary ON/OFF. +- Existing thermostat platform: `generic_thermostat`; source temperature is a **weighted indoor average**. Existing Comfort/Away/Sleep targets: 22/19/21 °C; user manually selects `heat`/`off`. +- Known example switch: `switch.central_heating_switch`. Known weighted average sensor: `sensor.average_temperature`. The exact `climate` entity ID must be verified; **do not assume** `climate.thermostat`. +- Previously configured individual sensor examples (informational, not defaults): + - `sensor.living_universal_remote_temperature` + - `sensor.hue_motion_sensor_1_temperature` + - `sensor.air_purifier_temperature` + - `sensor.bedroom_universal_remote_temperature` + - `sensor.bathroom_temperature` +- Outdoor weather comes from existing HA sensors originally populated from OpenWeatherMap/InfluxDB. The user wants to swap and compare providers **in the UI**, not in code. +- The weather series includes temperature, wind, clouds, humidity, feels-like, pressure, visibility, rain and snow. Sensor units, freshness and meanings must be checked before using them to train a model. + +All example entity IDs are **examples only**. No user-specific ID, room count, provider, comfort target, weights or outdoor data source may be hard-coded. + +## Configurability contract + +- Use **Config Flow + Options Flow** for entity selection and tuning. Avoid requiring YAML edits for regular operations. +- Allow selecting, removing and replacing indoor sensors (up to the current supported limit), plus independent positive comfort weights. +- Every weather variable should accept an ordered list of HA sensor entity IDs. Select the first healthy, unit-compatible, plausible **and eventually fresh** source; expose the chosen source and alternatives in diagnostics. +- Temperature must be available; other meteorological variables must be optional and cannot break the thermal controller if absent. +- Support reconfiguration without corrupting learned models. Preserve compatible room histories; invalidate or migrate incompatible state explicitly and document why. When changing weather providers, beware of systematic sensor offsets. +- A `climate` reference is read-only during shadow mode. Its requested target/preset/HVAC mode must remain owned by the existing thermostat/user. +- Use translation keys for UI strings; maintain at least `en` and `ro` when changing exposed UI flows. +- Entity unique IDs must remain stable across safe upgrades; handle removed/replaced sensors without leaving orphan entities. + +## Architecture boundaries + +Keep separation between: + +1. **HA integration layer:** config entries, sensors, entity registry, event/update lifecycle, storage, read-only state acquisition. +2. **Data preparation:** quality/staleness checks, unit normalization, sensor alignment and handling missing data. +3. **Thermal model:** pure Python, independently testable learning/prediction algorithms for each room (eventually interactions between rooms). +4. **Optimization:** pure Python, independently testable finite-horizon ON/OFF plan evaluation with actuator constraints. +5. **Evaluation/diagnostics:** timestamps, input provenance, forecast trajectories, prediction errors and comparison to actual observed events. +6. **Optional future actuator layer:** disabled/absent until separately designed, tested and explicitly approved by the user. + +Favor the standard library and small dependencies. Heavy compute must not block Home Assistant's asyncio event loop. Use supported HA APIs and offload expensive optimization if needed. Avoid unnecessary polling, disk writes, excessive log volume and unbounded entity attributes. + +## Modeling rules + +- Current model form: `dT_5m = a * (T_out - T_room) + b * effective_heat + c`, with a smoothed heater-command proxy. Coefficients are estimates, not measured boiler efficiency. +- A relay `on` is **not proof that the gas burner is firing**. Never treat ON duration as metered gas usage or claim fuel savings without gas-consumption evidence. +- Track sample count and ON/OFF distribution. An in-sample **5-minute MAE is not** validated 60-minute forecast accuracy. +- Implement holdout / walk-forward testing for horizons of 15, 30, 60 and 120 minutes; compare to naive persistence and the existing thermostat. +- Record the plan and the input snapshot that produced a prediction, then compare it to actual temperatures only under appropriate command/exogenous-input assumptions. Distinguish **counterfactual MPC forecasts** from forecasts of the **actual** `generic_thermostat` behavior. +- Validate model identifiability, parameter stability, outlier rejection, missing observations, power outages, heater transitions within a sampling window, and multiple sensors in the same physical room. +- Weather covariates beyond temperature should be added only if they improve held-out performance; avoid treating `feels_like` as physical outdoor air temperature. Forecast values and observed values must be distinguished. +- One switch heats all rooms: optimization can trade off room comfort, but cannot independently heat a cold bathroom without influencing warmer rooms. + +## Repository conventions + +Expected structure (adapt only through deliberate refactoring): + +```text +custom_components/heating_mpc/ + __init__.py + manifest.json + config_flow.py + const.py + coordinator.py + physics.py + weather_sources.py + sensor.py + binary_sensor.py + strings.json + translations/{en,ro}.json +hacs.json +tests/ +README.md +AGENTS.md +ROADMAP.md +``` + +- Keep pure model/optimizer logic importable and testable without Home Assistant installed. +- Keep config migration and persisted-model schema backward compatible where possible; version persisted data explicitly when evolving its format. +- Follow Home Assistant conventions for async config entry setup/unload/remove, entity lifecycle, options flows and translation validation. +- Avoid speculative API usage. Verify Home Assistant behavior with a runnable test or documented supported API before depending on it. +- Add tests with every behavior change; prioritize tests for lifecycle, reload, reconfiguration, persistence and fault handling. +- Make small focused commits and record changes in a changelog or release notes. Never advance version numbers without corresponding tests/documentation. + +## Development workflow for Codex + +1. **Inspect the repository first.** Read `README.md`, `ROADMAP.md`, implementation and tests. Determine the actual current version/behavior. +2. **Audit baseline v0.2.1 before major features:** confirm shadow-only semantics, config entry migration, sensor collection, update/reload, uninstall cleanup and persisted-state correctness. Identify and fix correctness/safety bugs first. +3. For each task, state the scope, compatibility and safety implications. Keep modifications narrow; do not combine cleanup with an unauthorized live-controller implementation. +4. Run at minimum: + + ```bash + python -m unittest discover -s tests -v + python -m compileall -q custom_components/heating_mpc + ``` + + Add more integration tests, lint/type checks and HA validation as tooling is introduced. Report exactly what was and wasn't run. +5. Add/adjust tests for edge cases and migrations. Update README/ROADMAP and translations when the user-facing behavior changes. +6. Summarize touched files, tests, known limitations and how the user can safely try the change on HAOS. Do not claim HAOS/device testing unless performed. +7. **No autonomous deployment to the real boiler.** Installing code is not authorization to enable control mode. + +## Release acceptance rules + +A release is not ready simply because unit tests pass. Require: + +- No regression in the existing Generic Thermostat or its presets/manual HVAC mode. +- No unexpected service calls to heater/thermostat while in shadow mode. +- Correct handling of unavailable, stale, nonnumeric and changing input sources. +- Config/options flow, restart, reload, entity-registry cleanup, model persistence and uninstall verified. +- Documented diagnostics and limitations; no unsupported fuel-savings promises. +- For any future live control: a separate safety review, test evidence, opt-in UX, exclusive relay ownership, watchdog/fallback and explicit user approval. + +See `ROADMAP.md` for ordered milestones. Prioritize correctness, reproducibility and user control over adding features quickly. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e282615 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Heating MPC 0.2.1 — custom integration for Home Assistant OS + +**Experimental, observation-only (shadow mode).** It **never** changes the Generic Thermostat, the physical relay, the boiler's setpoint, `heat/off`, or Comfort/Away/Sleep. No Docker add-on, MQTT or InfluxDB connection is needed. + +This project targets Home Assistant OS on a Raspberry Pi 5, with one existing ON/OFF central-heating relay, `generic_thermostat`, and individual indoor temperature sensors. + +## Install / upgrade + +1. Create a Home Assistant backup. +2. Copy the entire folder `custom_components/heating_mpc` into `/config/custom_components/heating_mpc` (replace its contents when upgrading from v0.1.0). +3. Restart Home Assistant **Core**. +4. If new: **Settings → Devices & services → Add integration → Heating MPC**. If installed already: select **Heating MPC → Configure** (Options). +5. Choose the existing thermostat and physical heating switch (read-only), then individual room sensor entities. +6. Choose *ordered lists* of **outdoor sensor entity IDs**. First valid sensor is active; if missing, unavailable, nonnumeric or outside reasonable bounds, the next valid sensor is used automatically. +7. Assign a relative comfort weight (0.1–10) to each selected room. + +**No entity IDs are hard-coded.** Every room and outdoor measurement can be changed later via **Configure**, without editing YAML or Python. + +## Reconfigurable weather inputs + +| Measurement | Required? | Used in the thermal model in 0.2.0? | +|---|---|---| +| 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 | +| Cloud cover | No | No, diagnostics/source comparison only | +| Humidity | No | No, diagnostics/source comparison only | +| Feels-like temperature | No | No, diagnostics/source comparison only | +| Atmospheric pressure | No | No, diagnostics/source comparison only | +| Visibility | No | No, diagnostics/source comparison only | +| Rain / snow | No | No, diagnostics/source comparison only | + +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; no stale-data age policy exists yet. + +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. + +## Model, sampling and output + +- Every 5 minutes: reads HA sensor states and observed relay state; learns a per-room linear thermal model `dT = a*(T_out-T_room) + b*effective_heating + c`. +- Learns only from valid 5-minute transitions; uses a smoothed heating-demand proxy to approximate radiator lag. +- After at least 200 transitions and samples with both heating states, runs an ON/OFF MPC search for the next 60–180 minutes (default 120) in 15-minute command blocks. Protects against rapid simulated toggling. +- Uses room comfort weights and the **existing** Generic Thermostat's target and heat/off state. **The simulated ON/OFF demand never operates hardware**. +- Persists model parameters using HA's `.storage` so HA restarts do not discard the trained data. + +Entities (entity IDs may vary): + +- `sensor.heating_mpc_status` — learning/ready/paused status and diagnostics, including outside weather, active source, all valid alternative weather values, model coefficients, proposed 15-minute plan. +- `sensor.heating_mpc_weighted_temperature` — own room-weighted average, informative only. +- `sensor.mpc_outdoor_temperature` — selected external temp and alternatives. +- Per-room predicted temperatures after 60 minutes. +- `binary_sensor.heating_mpc_proposed_heat` — **hypothetical** heating demand (unavailable until ready). + +## Known limitations / safety + +- **Do not** connect the proposed binary sensor directly to the heating switch or build an automation from it. A production controller would need watchdog, manual override, safety handling, and an exclusive relay owner with Generic Thermostat fallback. +- The other meteorological variables besides temperature are intentionally **captured but not yet fitted into the thermal model**. This allows source tests without falsely treating weak inputs as validated improvements. Later versions can test whether wind or solar proxy reduces held-out forecast error. +- All rooms share one heating actuator. The model does not command individual radiators. +- No weather forecast: assumes outdoor temperature remains constant during simulated horizon. +- No Recorder/InfluxDB training backfill. It learns only from samples collected after installation. +- The demand switch is not a burner-flame or real gas consumption measurement. +- Full HAOS/UI runtime integration has **not** been tested; Python/model/unit/source tests are offline checks only. +- Home Assistant Core APIs and config-flow selectors can change; take a backup before trying this experimental custom integration. + +## Offline tests + +```bash +python -m unittest discover -s tests -v +``` + +## v0.2.1: integration visibility and clean uninstall + +- Corrects the manifest classification from `helper` to `service`. Existing config entries should move from the Helpers listing to **Settings → Devices & services → Integrations** after replacing the files and restarting Home Assistant Core. No deletion/reconfiguration is needed to upgrade. +- When the integration config entry is deleted from the Integrations page, Home Assistant removes its registered entities, and `async_remove_entry` removes the locally persisted learned-model data (`.storage/heating_mpc.`). The custom component source folder remains on disk until manually removed. +- MPC behavior is otherwise unchanged and remains read-only / shadow-only. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..7319e20 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,148 @@ +# ROADMAP.md — Heating MPC + +> **Planning document, 2026-10-26.** Architecture and scope are discussed in ChatGPT; implementation happens in Codex. Priorities may change based on data collected in Home Assistant. Check code/tests before marking an item complete. + +## Goal + +Deliver a **fully local Home Assistant custom integration** for HAOS on Raspberry Pi 5. The integration learns how each room warms and cools, uses selectable indoor/outdoor sensors, forecasts temperatures and — **only after validation and explicit opt-in** — can safely control one existing ON/OFF boiler relay through an exclusive-control architecture. + +Success means predictable comfort, understandable decisions, safe operation and maintainability. Lower gas consumption is a *hypothesis to measure*, not a promised result. + +## Current baseline — v0.2.1 + +**Implemented (experimental):** + +- [x] Native Home Assistant integration with UI-based Config Flow and Options Flow. +- [x] Multiple room temperature sensors and independent room comfort weights. +- [x] Ranked, swappable HA weather sensor sources with normalization/fallback and diagnostics. +- [x] Read-only reference to existing Generic Thermostat and ON/OFF relay. +- [x] Per-room online linear thermal learner, 5-minute sampling and persistent parameters. +- [x] Simple radiator-heat-lag approximation and hypothetical finite-horizon binary MPC plan. +- [x] Integration shown as `service` (not `helper`); configuration entry deletion cleans up saved model data. +- [x] Status, weighted temperature, outside temperature, individual +60-minute forecast entities, and proposed-heat diagnostic binary sensor. +- [x] Baseline pure-Python tests. + +**Not yet implemented or validated:** + +- [ ] End-to-end HAOS tests across restart, reconfiguration and deletion. +- [ ] Backfill of indoor sensors and relay history from Home Assistant Recorder. +- [ ] Proper held-out, multi-horizon forecast evaluation; plotted predictive trajectories. +- [ ] Explicit sensor freshness policies and full degradation/recovery semantics. +- [ ] Weather forecast inputs, learned effects from wind/clouds or sunshine. +- [ ] Identification of inter-room heat transfer and radiator dynamics beyond the present proxy. +- [ ] Real boiler-burner detection or direct gas-use measurement. +- [ ] **Any live actuation** (intentionally excluded from v0.2.1). + +Current behavior: **shadow mode only**. `generic_thermostat` remains the one and only controller of the physical switch. + +## Milestone 0 — Audit and reliability (v0.2.x) — FIRST + +**Objective:** Make the existing observer trustworthy before extending it. + +- [ ] Perform a source audit of v0.2.1: pure model, optimizer, config/options, coordinator update timing, persistent model and entity lifecycle. +- [ ] Add Home Assistant test harness coverage for initial setup, update, restart/reload, options changes, missing sensors, `heat/off`, target changes, teardown and uninstall. +- [ ] Verify that **zero physical-device service calls** occur in observation mode, including restart and failed update cases. +- [ ] Establish deterministic handling of 5-minute transitions, polling jitter, timestamp discontinuities, missing data and real switch transitions between samples. +- [ ] Add input freshness thresholds and diagnostics that identify exactly which source is missing/stale/invalid. +- [ ] Verify that source switching, entity renaming, adding/removing rooms and replacing the relay do not silently reuse incompatible fitted coefficients. +- [ ] Version persisted model schema; test corrupted/older state and migration/rollback behavior. +- [ ] Verify calculated sample totals and successful/failed fitting counters; expose reasons why training/prediction is unavailable. +- [ ] Add CI, linting, tests and packaging validation (including HACS metadata/translations). +- [ ] Update installation/release documentation and remove packaged `__pycache__`/`.pyc` files if present. + +**Exit criteria:** No control writes; reproducible tests covering HA lifecycle; clean upgrade/removal; clear status during outages; no significant model-data loss or silent corruption on restart. + +## Milestone 1 — Explainable forecasts and dashboards (target v0.3.0) + +**Objective:** Users can verify what the model predicts and whether it is actually accurate. + +- [ ] Expose trajectories for each room at 5–15-minute resolution for 2–3 hours, with forecast generation timestamp, horizon and input provenance. +- [ ] Expose predicted +15/+30/+60/+120 minute temperatures and full proposed ON/OFF schedule. +- [ ] Provide an HA dashboard example using native cards or a clearly documented optional chart card (do not bundle a heavyweight frontend unnecessarily). +- [ ] Make clear that the **MPC plan forecast is counterfactual** while Generic Thermostat is controlling the relay; add a separate actual-policy forecast/evaluation pathway. +- [ ] Store a bounded history of predictions with snapshots of inputs, proposed plan and eventually observed outcome. +- [ ] Implement out-of-sample/walk-forward MAE and bias at 15, 30, 60 and 120 minutes; include sample counts and confidence/quality labels. +- [ ] Compare against simple benchmarks such as current-temperature persistence and a fixed thermal baseline. +- [ ] Add inspectable explanations: expected heat loss, expected heating response, target, room weights and reason for the next proposed action. + +**Candidate quality gate:** Sustain **about 0.3–0.4°C or better 60-minute MAE** on representative held-out data *under comparable known relay input sequences*, without systematic room bias. This is a starting engineering goal to validate, not a guarantee or an arbitrary gate to waive. Compare to baselines. + +## Milestone 2 — Better thermal identification (target v0.4.0) + +**Objective:** Improve forecasts with genuinely useful features, not just more inputs. + +- [ ] Compare existing per-room linear model with a multi-zone RC model including inter-room heat exchange. +- [ ] Address multiple sensors within a single physical room; configurable room grouping, aggregation and comfort priorities. +- [ ] Distinguish air/room thermal mass and radiator lag where observable; avoid overfitting unidentifiable parameters. +- [ ] Add optional Recorder history import/backfill for **indoor sensor + physical relay** history; do not assume old HA Recorder data still exists. +- [ ] Add outdoor forecast data with future timestamps (forecast provider selectable) and compare forecast-based versus constant-outdoor assumptions. +- [ ] Experiment with wind/cloud cover as optional covariates; retain them only if held-out forecast error improves. Solar gains may require orientation/irradiance or a useful proxy. +- [ ] 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. +- [ ] 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. + +## Milestone 3 — Safety design for optional control (target v0.5.0; NOT AUTHORIZED YET) + +**Objective:** Design, simulate and review live actuation **before** any real actuator writes are enabled. + +- [ ] Specify a formal **single-owner** strategy for `switch.central_heating_switch`: no simultaneous Generic Thermostat and MPC writers. +- [ ] Design explicit user-selectable controller modes: `Observe` (default), `Advisory`, `MPC Active` (separate opt-in), `Fallback`/manual. +- [ ] Make manual `off` authoritative; never auto-enable heat. Preserve targets/presets (Comfort 22°C / Away 19°C / Sleep 21°C in current household; all configurable). +- [ ] Define watchdog, heartbeat, max-command age, minimum ON/OFF time, bounds, restart rules and an unambiguous safe behavior on invalid data. +- [ ] Test handover to/from Generic Thermostat with no overlap, stuck state, or unexpected ON request. Handover itself must be user-visible and reversible. +- [ ] Add pure simulation / hardware-in-the-loop-style tests for sensor dropouts, rapid changes, relay availability, HA restart, config reload and network loss. +- [ ] Perform dry-run comparison of live decisions versus the existing thermostat using recorded traces. +- [ ] Provide a rollback playbook and explicit separate approval step to begin live experiments. + +**Hard gate:** Do **not** add, enable or invoke real actuation merely because this milestone is on the roadmap. Get explicit user approval after the safety design and tests are reviewed. This includes indirect actuations via automations. + +## Milestone 4 — Controlled evaluation (future v0.6.x) + +Only after the Milestone 3 gate has been satisfied: + +- [ ] Conduct short, supervised and reversible trials with one active controller at a time. +- [ ] Compare comfort: time outside user-defined comfort band, degree-minutes of discomfort, overshoot and temperature variability **per room**. +- [ ] Compare actuator commands: total commanded ON time, restart count and cycle lengths. +- [ ] Where available, compare **measured burner operation and gas-meter data** normalized for outdoor weather. Relay ON time alone is not gas consumption. +- [ ] Measure manual overrides, watchdog/fallback activations and recovery behavior. +- [ ] Define clear criteria to return to the existing Generic Thermostat if predicted performance or safety degrades. + +## Milestone 5 — Stable release / HACS (future v1.0.0) + +- [ ] Document user-facing installation, config, sensor selection, tuning, compatibility, backup and uninstall. +- [ ] Provide robust migration from old config entries and stored model data. +- [ ] Stable entity IDs, diagnostics downloads where safe, translations and troubleshooting. +- [ ] CI, meaningful automated HA integration tests and supported HA version policy. +- [ ] Clean HACS repository metadata, release tags and changelog. +- [ ] Clearly document the difference between a thermostat demand command and burner/gas consumption. +- [ ] Publish only after real HAOS testing and acceptance criteria are met. + +## Non-goals (unless separately approved) + +- Replacing the boiler or modifying gas-side operation, burner modulation, flow-temperature electronics, wiring or safety circuitry. +- Adding cloud AI/LLMs for thermostat decisions. +- Creating a direct InfluxDB integration: weather data already exists as Home Assistant sensors. +- Controlling individual radiators without individually addressable actuators. +- Guaranteeing a fuel saving based solely on relay-state history. +- Auto-switching the user's HVAC mode between `off` and `heat`. + +## Priority backlog for the next Codex session + +1. Confirm the imported repository contents match v0.2.1; run existing tests and write an honest baseline audit. +2. Review HA lifecycle, polling/sample timing, options reload, storage/cleanup and failure handling; fix P0 problems before features. +3. Add a test harness (HA fixtures/mocks) and a shadow-only invariant test prohibiting all actuator service calls. +4. Add forecast recording and held-out evaluation, then expose a trajectory usable by an HA dashboard. +5. Only then consider richer weather inputs and the live-control **design**; do not implement live control without separate authorization. + +## Definition of done for each milestone + +- Documented requirements and behaviors, backward-compatibility notes and any schema changes. +- Tests for nominal, edge and failure paths, with actual commands/results stated in the PR/commit summary. +- No regressions to sensor configurability, preset behavior or manual mode control. +- Observability sufficient to explain predictions and diagnose invalid inputs. +- HAOS verification clearly differentiated from unit tests; no unsupported performance claims. +- Release notes and installation/rollback instructions updated when appropriate. + +**Guiding principle:** a reliable, locally explainable thermostat model is more valuable than a sophisticated optimizer whose predictions cannot be validated or whose failure behavior is unclear. diff --git a/custom_components/heating_mpc/__init__.py b/custom_components/heating_mpc/__init__.py new file mode 100644 index 0000000..2656285 --- /dev/null +++ b/custom_components/heating_mpc/__init__.py @@ -0,0 +1,39 @@ +"""Heating MPC custom integration, initial release shadow-only.""" +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import DOMAIN +from .coordinator import HeatingMPCCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + coordinator = HeatingMPCCoordinator(hass, entry) + await coordinator.async_restore_model() + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + return True + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if ok: + hass.data[DOMAIN].pop(entry.entry_id, None) + return ok + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove the learned model when this integration instance is deleted.""" + store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}") + await store.async_remove() diff --git a/custom_components/heating_mpc/binary_sensor.py b/custom_components/heating_mpc/binary_sensor.py new file mode 100644 index 0000000..0a53176 --- /dev/null +++ b/custom_components/heating_mpc/binary_sensor.py @@ -0,0 +1,28 @@ +"""Read-only simulated ON/OFF demand. It never changes a physical switch.""" +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([HeatingMPCProposal(coordinator, entry.entry_id)]) + + +class HeatingMPCProposal(CoordinatorEntity, BinarySensorEntity): + _attr_has_entity_name = True + _attr_name = "Heating MPC Proposed Heat" + _attr_icon = "mdi:fire" + + def __init__(self, coordinator, entry_id): + super().__init__(coordinator) + self._attr_unique_id = f"{entry_id}_proposal" + + @property + def available(self) -> bool: + return self.coordinator.last_update_success and self.coordinator.data.get("status") == "ready" + + @property + def is_on(self): + return self.coordinator.data.get("proposal") diff --git a/custom_components/heating_mpc/config_flow.py b/custom_components/heating_mpc/config_flow.py new file mode 100644 index 0000000..095ad60 --- /dev/null +++ b/custom_components/heating_mpc/config_flow.py @@ -0,0 +1,184 @@ +"""UI-only installation and reconfiguration: rooms, source priorities, weights.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.helpers import selector + +from .const import ( + DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS, + CONF_WEIGHTS, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, + CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS, + WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA, +) +from .weather_sources import sources_for + + +def _entity_picker(multiple: bool = False): + return selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", multiple=multiple, + **({"reorder": True} if multiple else {})) + ) + + +def _required(key: str, data: dict): + return vol.Required(key, default=data[key]) if key in data else vol.Required(key) + + +def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False): + data = config or {} + schema: dict[Any, Any] = { + _required(CONF_CLIMATE, data): selector.EntitySelector( + selector.EntitySelectorConfig(domain="climate")), + _required(CONF_HEATER, data): selector.EntitySelector( + selector.EntitySelectorConfig(domain="switch")), + _required(CONF_ROOMS, data): _entity_picker(multiple=True), + } + if detailed: + for key, allowed in { + CONF_HORIZON: vol.In([60, 90, 120, 150, 180]), + CONF_MIN_ON: vol.All(vol.Coerce(int), vol.Range(min=5, max=60)), + CONF_MIN_OFF: vol.All(vol.Coerce(int), vol.Range(min=5, max=60)), + CONF_SWITCH_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)), + CONF_HEAT_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)), + CONF_BAND: vol.All(vol.Coerce(float), vol.Range(min=0.05, max=0.7)), + }.items(): + schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed + return vol.Schema(schema) + + +def _weather_schema(config: dict[str, Any], metrics: tuple[str, ...]): + schema = {} + for metric in metrics: + key = WEATHER_SOURCES[metric] + selected = sources_for(config, metric) + if metric == "temperature": + # A temperature source is mandatory: other weather inputs are optional. + marker = vol.Required(key, default=selected) if selected else vol.Required(key) + else: + marker = vol.Optional(key, default=selected) + schema[marker] = _entity_picker(multiple=True) + return vol.Schema(schema) + + +def _weight_schema(rooms: list[str], existing: dict[str, float] | None = None): + weights = existing or {} + return vol.Schema({ + vol.Required(f"weight__{room}", default=weights.get(room, 1.0)): + vol.All(vol.Coerce(float), vol.Range(min=0.1, max=10)) + for room in rooms + }) + + +def _valid_rooms(rooms: list[str]) -> bool: + return bool(rooms) and len(rooms) <= 12 and len(rooms) == len(set(rooms)) + + +def _valid_weather(rooms: list[str], data: dict) -> bool: + sources = sources_for(data, "temperature") + return bool(sources) and len(sources) <= 10 and not set(sources).intersection(rooms) + + +class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Create a native observation-only MPC installation.""" + + VERSION = 2 + + def __init__(self): + self._draft: dict[str, Any] = {} + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + if user_input is not None: + if not _valid_rooms(user_input[CONF_ROOMS]): + return self.async_show_form(step_id="user", data_schema=_main_schema(), + errors={"base": "invalid_rooms"}) + await self.async_set_unique_id("home_heating") + self._abort_if_unique_id_configured() + self._draft = user_input + return await self.async_step_weather() + return self.async_show_form(step_id="user", data_schema=_main_schema()) + + async def async_step_weather(self, user_input: dict[str, Any] | None = None): + if user_input is not None: + if not _valid_weather(self._draft[CONF_ROOMS], user_input): + return self.async_show_form(step_id="weather", data_schema=_weather_schema( + user_input, WEATHER_MAIN), errors={"base": "invalid_weather"}) + self._draft.update(user_input) + return await self.async_step_weather_extra() + return self.async_show_form(step_id="weather", data_schema=_weather_schema( + self._draft, WEATHER_MAIN)) + + async def async_step_weather_extra(self, user_input: dict[str, Any] | None = None): + if user_input is not None: + self._draft.update(user_input) + return await self.async_step_weights() + return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema( + self._draft, WEATHER_EXTRA)) + + async def async_step_weights(self, user_input: dict[str, Any] | None = None): + if user_input is not None: + self._draft[CONF_WEIGHTS] = { + room: float(user_input[f"weight__{room}"]) + for room in self._draft[CONF_ROOMS] + } + return self.async_create_entry(title="Heating MPC", data=self._draft) + return self.async_show_form(step_id="weights", data_schema=_weight_schema( + self._draft[CONF_ROOMS])) + + @staticmethod + @callback + def async_get_options_flow(config_entry: config_entries.ConfigEntry): + return HeatingMPCOptionsFlow() + + +class HeatingMPCOptionsFlow(config_entries.OptionsFlow): + """Edit sensors, ranked alternative weather providers and MPC parameters.""" + + def __init__(self): + self._draft: dict[str, Any] = {} + + async def async_step_init(self, user_input: dict[str, Any] | None = None): + config = {**self.config_entry.data, **self.config_entry.options} + if user_input is not None: + if not _valid_rooms(user_input[CONF_ROOMS]): + return self.async_show_form(step_id="init", data_schema=_main_schema( + config, detailed=True), errors={"base": "invalid_rooms"}) + self._draft = user_input + return await self.async_step_weather() + return self.async_show_form(step_id="init", data_schema=_main_schema( + config, detailed=True)) + + async def async_step_weather(self, user_input: dict[str, Any] | None = None): + config = {**self.config_entry.data, **self.config_entry.options, **self._draft} + if user_input is not None: + if not _valid_weather(self._draft[CONF_ROOMS], user_input): + return self.async_show_form(step_id="weather", data_schema=_weather_schema( + user_input, WEATHER_MAIN), errors={"base": "invalid_weather"}) + self._draft.update(user_input) + return await self.async_step_weather_extra() + return self.async_show_form(step_id="weather", data_schema=_weather_schema( + config, WEATHER_MAIN)) + + async def async_step_weather_extra(self, user_input: dict[str, Any] | None = None): + config = {**self.config_entry.data, **self.config_entry.options, **self._draft} + if user_input is not None: + self._draft.update(user_input) + return await self.async_step_weights() + return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema( + config, WEATHER_EXTRA)) + + async def async_step_weights(self, user_input: dict[str, Any] | None = None): + if user_input is not None: + self._draft[CONF_WEIGHTS] = { + room: float(user_input[f"weight__{room}"]) + for room in self._draft[CONF_ROOMS] + } + # Prevent an old single-temperature sensor from overriding new sources. + self._draft.pop(CONF_OUTSIDE, None) + return self.async_create_entry(title="", data=self._draft) + config = {**self.config_entry.data, **self.config_entry.options} + return self.async_show_form(step_id="weights", data_schema=_weight_schema( + self._draft[CONF_ROOMS], config.get(CONF_WEIGHTS, {}))) diff --git a/custom_components/heating_mpc/const.py b/custom_components/heating_mpc/const.py new file mode 100644 index 0000000..6bc9c87 --- /dev/null +++ b/custom_components/heating_mpc/const.py @@ -0,0 +1,39 @@ +"""Configuration constants for Heating MPC.""" +DOMAIN = "heating_mpc" +CONF_CLIMATE = "climate_entity" +CONF_HEATER = "heater_entity" +CONF_OUTSIDE = "outside_entity" # v0.1 legacy single temperature source +CONF_ROOMS = "room_entities" +CONF_WEIGHTS = "weights" +CONF_HORIZON = "horizon_minutes" +CONF_MIN_ON = "minimum_on_minutes" +CONF_MIN_OFF = "minimum_off_minutes" +CONF_SWITCH_COST = "switching_cost" +CONF_HEAT_COST = "heating_cost" +CONF_BAND = "comfort_band" + +# Each source list is ordered: first valid sensor wins; remaining are fallbacks. +WEATHER_SOURCES = { + "temperature": "outside_temperature_sources", + "wind": "outside_wind_sources", + "clouds": "outside_clouds_sources", + "humidity": "outside_humidity_sources", + "feels_like": "outside_feelslike_sources", + "pressure": "outside_pressure_sources", + "visibility": "outside_visibility_sources", + "rain": "outside_rain_sources", + "snow": "outside_snow_sources", +} +WEATHER_MAIN = ("temperature", "wind", "clouds") +WEATHER_EXTRA = ("humidity", "feels_like", "pressure", "visibility", "rain", "snow") + +DEFAULTS = { + CONF_HORIZON: 120, + CONF_MIN_ON: 15, + CONF_MIN_OFF: 10, + CONF_SWITCH_COST: 0.15, + CONF_HEAT_COST: 0.025, + CONF_BAND: 0.15, +} +SAMPLE_MINUTES = 5 +BLOCK_MINUTES = 15 diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py new file mode 100644 index 0000000..8ae5015 --- /dev/null +++ b/custom_components/heating_mpc/coordinator.py @@ -0,0 +1,199 @@ +"""Read existing HA entities, learn thermal response, produce shadow MPC decisions.""" +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from functools import partial +from math import isfinite +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, WEATHER_SOURCES, + CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST, + CONF_BAND, DEFAULTS, +) +from .physics import HEAT_LAG, Learner, optimize +from .weather_sources import choose_weather_sources, sources_for, normalize + +_LOGGER = logging.getLogger(__name__) + + +class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """The existing generic thermostat stays the only actuator owner.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=5)) + self.entry = entry + self.config = {**DEFAULTS, **entry.data, **entry.options} + self.rooms: list[str] = self.config[CONF_ROOMS] + self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms} + self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}") + self._previous: dict[str, Any] | None = None + self._heat_lag: float | None = None + self._on_samples = 0 + self._off_samples = 0 + + async def async_restore_model(self) -> None: + stored = await self._store.async_load() or {} + # Replacing the actual heating system invalidates heating coefficients. + # Switching among weather providers does not discard months of learning. + if stored.get("heater_entity", self.config[CONF_HEATER]) != self.config[CONF_HEATER]: + _LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model") + return + # Models carry room identity, so changing the room list is safe. + for room, data in stored.get("learners", {}).items(): + if room in self.learners: + self.learners[room] = Learner.from_dict(data) + self._on_samples = max(0, int(stored.get("on_samples", 0))) + self._off_samples = max(0, int(stored.get("off_samples", 0))) + + def _value(self, entity_id: str, low: float, high: float) -> float | None: + state = self.hass.states.get(entity_id) + if state is None or state.state in ("unknown", "unavailable"): + return None + value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement")) + if value is None or not low <= value <= high: + return None + return value + + async def _async_update_data(self) -> dict[str, Any]: + now = datetime.now(timezone.utc) + heater = self.hass.states.get(self.config[CONF_HEATER]) + climate = self.hass.states.get(self.config[CONF_CLIMATE]) + # Choose each weather variable independently from ordered sensor sources. + # Sensor priorities are editable in Options without touching integration code. + weather_entity_ids = { + entity_id for metric in WEATHER_SOURCES + for entity_id in sources_for(self.config, metric) + } + weather_states = { + entity_id: (state.state, state.attributes.get("unit_of_measurement")) + for entity_id in weather_entity_ids + if (state := self.hass.states.get(entity_id)) is not None + } + weather, active_sources, weather_comparisons = choose_weather_sources( + self.config, weather_states + ) + outside = weather["temperature"] + temps = {room: self._value(room, 0.0, 45.0) for room in self.rooms} + status: dict[str, Any] = { + "status": "waiting_for_sensors", "proposal": None, "prediction_60m": {}, + "temperatures": {r: t for r, t in temps.items() if t is not None}, + "on_samples": self._on_samples, "off_samples": self._off_samples, + "samples": min(learner.count for learner in self.learners.values()), + "mode": climate.state if climate else "unavailable", + "weather": {k: v for k, v in weather.items() if v is not None}, + "weather_active_sources": {k: v for k, v in active_sources.items() if v}, + "weather_comparison": {k: v for k, v in weather_comparisons.items() if v}, + "weather_model_inputs": ["temperature"], + } + if heater is None or heater.state not in ("on", "off") or climate is None: + self._previous = None + self._heat_lag = None + return status + if outside is None or any(temp is None for temp in temps.values()): + self._previous = None + self._heat_lag = None + return status + + # No casting surprises: all rooms are valid here. + vals: dict[str, float] = {room: float(temps[room]) for room in self.rooms} + heater_on = heater.state == "on" + if self._heat_lag is None: + self._heat_lag = float(heater_on) + if self._previous is not None: + dt = (now - self._previous["time"]).total_seconds() + # Do not fit one transition across a provider/fallback switch. + if (240 <= dt <= 420 and + self._previous["outside_source"] == active_sources["temperature"]): + next_lag = self._heat_lag + HEAT_LAG * (float(self._previous["heater_on"]) - self._heat_lag) + for room in self.rooms: + self.learners[room].learn( + self._previous["temps"][room], vals[room], + self._previous["outside"], next_lag + ) + self._heat_lag = next_lag + if self._previous["heater_on"]: + self._on_samples += 1 + else: + self._off_samples += 1 + # Persist at each sample so restart does not erase hours of training. + await self._store.async_save({ + "learners": {room: learner.as_dict() for room, learner in self.learners.items()}, + "on_samples": self._on_samples, "off_samples": self._off_samples, + "heater_entity": self.config[CONF_HEATER], + "outside_source": active_sources["temperature"], + }) + else: + # Model lag state becomes uncertain after long gaps. + self._heat_lag = float(heater_on) + + self._previous = { + "time": now, "temps": vals, "outside": outside, "heater_on": heater_on, + "outside_source": active_sources["temperature"], + } + weights = self.config.get(CONF_WEIGHTS, {}) + weights = {r: float(weights.get(r, 1.0)) for r in self.rooms} + weighted_temp = sum(weights[r] * vals[r] for r in self.rooms) / sum(weights.values()) + status.update({ + "weighted_temperature": round(weighted_temp, 3), + "outside_temperature": outside, + "reference_heat": heater_on, + "on_samples": self._on_samples, + "off_samples": self._off_samples, + "samples": min(learner.count for learner in self.learners.values()), + "models": { + room: {"loss": round(model.theta[0], 6), + "heat": round(model.theta[1], 6), + "bias": round(model.theta[2], 6), + "mae_5m": round(model.mae, 4) if model.mae is not None else None} + for room, model in self.learners.items() + }, + }) + if climate.state != "heat": + status["status"] = "paused" + return status + try: + target = float(climate.attributes["temperature"]) + if not isfinite(target) or not 5 <= target <= 35: + raise ValueError + except (TypeError, KeyError, ValueError): + status["status"] = "waiting_for_target" + return status + status["target"] = target + # Do not act on a cold model: need ~17 hours of samples plus both conditions. + if status["samples"] < 200 or self._on_samples < 30 or self._off_samples < 30: + status["status"] = "learning" + return status + try: + # 180-minute exhaustive search can take several seconds on RPi5; + # never block Home Assistant's event loop with CPU work. + job = partial( + optimize, + vals, outside, self._heat_lag, target, weights, self.learners, + heater_on, (now - heater.last_changed).total_seconds(), + horizon_minutes=int(self.config[CONF_HORIZON]), + minimum_on_minutes=int(self.config[CONF_MIN_ON]), + minimum_off_minutes=int(self.config[CONF_MIN_OFF]), + switching_cost=float(self.config[CONF_SWITCH_COST]), + heating_cost=float(self.config[CONF_HEAT_COST]), + comfort_band=float(self.config[CONF_BAND]), + ) + output = await self.hass.async_add_executor_job(job) + except (ValueError, RuntimeError, OverflowError) as err: + _LOGGER.warning("MPC prediction failed: %s", err) + status["status"] = "prediction_error" + return status + status.update({ + "status": "ready", "proposal": output["proposal"], + "prediction_60m": output["predicted_60m"], + "prediction_end": output["predicted_end"], + "plan_15min": output["plan_15min"], "objective": output["cost"], + "model_quality_note": "5-minute in-sample MAE; not a validated 60-minute forecast error", + }) + return status diff --git a/custom_components/heating_mpc/manifest.json b/custom_components/heating_mpc/manifest.json new file mode 100644 index 0000000..b6bd947 --- /dev/null +++ b/custom_components/heating_mpc/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "heating_mpc", + "name": "Heating MPC", + "codeowners": [], + "config_flow": true, + "dependencies": [], + "documentation": "https://www.home-assistant.io/docs/configuration/", + "integration_type": "service", + "iot_class": "local_polling", + "requirements": [], + "single_config_entry": true, + "version": "0.2.1" +} diff --git a/custom_components/heating_mpc/physics.py b/custom_components/heating_mpc/physics.py new file mode 100644 index 0000000..16525b1 --- /dev/null +++ b/custom_components/heating_mpc/physics.py @@ -0,0 +1,169 @@ +"""Pure-Python small grey-box thermal model and binary model predictive control. + +No Home Assistant imports; this module can be tested in isolation. Step=5 minutes. +This is an experimental thermal model, not a certified boiler controller. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from itertools import product +from math import exp, isfinite +from typing import Any + +STEP_MINUTES = 5 +HEAT_LAG = 1.0 - exp(-STEP_MINUTES / 20.0) + + +def bound(value: float, lo: float, hi: float) -> float: + return max(lo, min(hi, value)) + + +@dataclass +class Learner: + """Recursive least squares for dT=a*(Tout-T)+b*effective_heat+c.""" + + theta: list[float] = field(default_factory=lambda: [0.002, 0.045, 0.0]) + p: list[list[float]] = field(default_factory=lambda: [[0.0005, 0., 0.], [0., 0.8, 0.], [0., 0., 0.08]]) + count: int = 0 + error_sum: float = 0.0 + + def predict_delta(self, temp: float, outside: float, heat: float) -> float: + a, b, c = self.theta + return a * (outside - temp) + b * heat + c + + def learn(self, temp_before: float, temp_after: float, outside: float, effective_heat: float) -> None: + if not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat)): + return + y = temp_after - temp_before + if abs(y) > 0.8: # sensor glitch/door opened/restarted device + return + x = [outside - temp_before, effective_heat, 1.0] + predicted = sum(t * v for t, v in zip(self.theta, x)) + residual = y - predicted + px = [sum(self.p[i][j] * x[j] for j in range(3)) for i in range(3)] + forgetting = 0.999 + denominator = forgetting + sum(x[i] * px[i] for i in range(3)) + if denominator <= 0: + return + k = [value / denominator for value in px] + self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)] + for i in range(3): + for j in range(3): + self.p[i][j] = (self.p[i][j] - k[i] * px[j]) / forgetting + # Physical constraints; avoids unstable temperature predictions. + self.theta = [ + bound(self.theta[0], 0.0, 0.04), + bound(self.theta[1], 0.0, 0.25), + bound(self.theta[2], -0.06, 0.06), + ] + self.count += 1 + self.error_sum += abs(residual) + + @property + def mae(self) -> float | None: + return self.error_sum / self.count if self.count else None + + def as_dict(self) -> dict[str, Any]: + return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum} + + @classmethod + def from_dict(cls, source: dict[str, Any]) -> "Learner": + obj = cls() + try: + theta = [float(v) for v in source["theta"]] + p = [[float(v) for v in r] for r in source["p"]] + if len(theta) != 3 or len(p) != 3 or any(len(r) != 3 for r in p): + return obj + if not all(isfinite(v) for v in theta + [v for row in p for v in row]): + return obj + obj.theta, obj.p = theta, p + obj.count = max(0, int(source.get("count", 0))) + obj.error_sum = max(0., float(source.get("error_sum", 0))) + except (KeyError, TypeError, ValueError): + return cls() + return obj + + +def simulate_step(temps: dict[str, float], outside: float, lag: float, + command: bool, learners: dict[str, Learner]) -> tuple[dict[str, float], float]: + next_lag = lag + HEAT_LAG * (float(command) - lag) + new = {name: value + learners[name].predict_delta(value, outside, next_lag) + for name, value in temps.items()} + return new, next_lag + + +def optimize( + temps: dict[str, float], outside: float, effective_heat: float, + target: float, weights: dict[str, float], learners: dict[str, Learner], + is_on: bool, seconds_since_change: float, horizon_minutes: int = 120, + minimum_on_minutes: int = 15, minimum_off_minutes: int = 10, + switching_cost: float = .15, heating_cost: float = .025, + comfort_band: float = .15, +) -> dict[str, Any]: + """Enumerate 15min ON/OFF plans; honor minimum ON/OFF durations. + + 120min = 256 plans (fast on Raspberry Pi 5). All rooms share one burner demand. + """ + if not temps or set(temps) != set(learners): + raise ValueError("Missing room temperatures or learner") + if not 30 <= horizon_minutes <= 180 or horizon_minutes % 15: + raise ValueError("Horizon must be 30..180 minutes in 15-minute steps") + if not isfinite(target) or not isfinite(outside): + raise ValueError("Invalid temperature") + if any(not isfinite(t) for t in temps.values()): + raise ValueError("Invalid room temperature") + if any(float(weights.get(room, 0)) <= 0 for room in temps): + raise ValueError("All rooms need a positive weight") + total_weight = sum(float(weights[room]) for room in temps) + norm = {room: float(weights[room]) / total_weight for room in temps} + blocks = horizon_minutes // 15 + nsteps_per_block = 3 + best = float("inf") + result = None + min_on_s = minimum_on_minutes * 60 + min_off_s = minimum_off_minutes * 60 + elapsed_initial = max(0.0, seconds_since_change) + # More sequences than 4096 are deliberately excluded by horizon validation. + for bits in product((False, True), repeat=blocks): + current = bool(is_on) + elapsed_s = elapsed_initial + state = dict(temps) + lag = effective_heat + cost = 0.0 + at_60 = None + feasible = True + for idx, bit in enumerate(bits): + if bit != current: + minimum = min_on_s if current else min_off_s + if elapsed_s < minimum: + feasible = False + break + cost += switching_cost + elapsed_s = 0.0 + current = bit + for tick in range(nsteps_per_block): + state, lag = simulate_step(state, outside, lag, bit, learners) + mismatch = sum( + norm[room] * max(0.0, abs(value - target) - comfort_band) ** 2 + for room, value in state.items() + ) + # A lower bound on weighted average comfort also matters. + avg = sum(norm[r] * value for r, value in state.items()) + mismatch += max(0.0, abs(avg - target) - comfort_band) ** 2 + cost += 8.0 * mismatch + (heating_cost if bit else 0.0) + if idx * 15 + (tick + 1) * STEP_MINUTES == 60: + at_60 = dict(state) + elapsed_s += 15 * 60 + if feasible and cost < best: + best = cost + result = (bits, at_60 or dict(state), dict(state)) + if result is None: + raise RuntimeError("No feasible schedule") + plan, temps_60m, temps_end = result + return { + "proposal": bool(plan[0]), + "plan_15min": [int(v) for v in plan], + "predicted_60m": {r: round(t, 3) for r, t in temps_60m.items()}, + "predicted_end": {r: round(t, 3) for r, t in temps_end.items()}, + "cost": round(best, 4), + } diff --git a/custom_components/heating_mpc/sensor.py b/custom_components/heating_mpc/sensor.py new file mode 100644 index 0000000..9437294 --- /dev/null +++ b/custom_components/heating_mpc/sensor.py @@ -0,0 +1,90 @@ +"""Diagnostics and predicted temperatures exposed as HA sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorDeviceClass +from homeassistant.const import UnitOfTemperature +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + coordinator = hass.data[DOMAIN][entry.entry_id] + entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id)] + entities += [MPCRoomPrediction(coordinator, entry.entry_id, room) for room in coordinator.rooms] + async_add_entities(entities) + + +class MPCStatus(CoordinatorEntity, SensorEntity): + _attr_has_entity_name = True + _attr_name = "Heating MPC Status" + _attr_icon = "mdi:brain" + + def __init__(self, coordinator, entry_id): + super().__init__(coordinator) + self._attr_unique_id = f"{entry_id}_status" + + @property + def native_value(self): + return self.coordinator.data.get("status") + + @property + def extra_state_attributes(self): + data = self.coordinator.data + return {key: value for key, value in data.items() if key not in ("status", "prediction_60m", "prediction_end")} + + +class MPCWeightedTemp(CoordinatorEntity, SensorEntity): + _attr_has_entity_name = True + _attr_name = "Heating MPC Weighted Temperature" + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + def __init__(self, coordinator, entry_id): + super().__init__(coordinator) + self._attr_unique_id = f"{entry_id}_weighted_temp" + + @property + def native_value(self): + return self.coordinator.data.get("weighted_temperature") + + +class MPCRoomPrediction(CoordinatorEntity, SensorEntity): + _attr_has_entity_name = True + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_icon = "mdi:chart-line" + + def __init__(self, coordinator, entry_id, room): + super().__init__(coordinator) + self.room = room + self._attr_unique_id = f"{entry_id}_{room}_predicted_60min" + self._attr_name = f"MPC {room} 60min Prediction" + + @property + def native_value(self): + return self.coordinator.data.get("prediction_60m", {}).get(self.room) + +class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity): + """Display chosen outdoor temperature and all currently valid alternatives.""" + _attr_has_entity_name = True + _attr_name = "MPC Outdoor Temperature" + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_icon = "mdi:thermometer" + + def __init__(self, coordinator, entry_id): + super().__init__(coordinator) + self._attr_unique_id = f"{entry_id}_outside_temperature" + + @property + def native_value(self): + return self.coordinator.data.get("weather", {}).get("temperature") + + @property + def extra_state_attributes(self): + data = self.coordinator.data + return { + "active_source": data.get("weather_active_sources", {}).get("temperature"), + "alternatives": data.get("weather_comparison", {}).get("temperature", {}), + } diff --git a/custom_components/heating_mpc/strings.json b/custom_components/heating_mpc/strings.json new file mode 100644 index 0000000..400d151 --- /dev/null +++ b/custom_components/heating_mpc/strings.json @@ -0,0 +1,96 @@ +{ + "title": "Heating MPC", + "config": { + "step": { + "user": { + "title": "Heating MPC — room inputs", + "description": "Choose thermostat, read-only relay and room sensors. This integration NEVER commands the heater.", + "data": { + "climate_entity": "Existing Generic Thermostat", + "heater_entity": "Physical heating switch (read-only)", + "room_entities": "Individual room temperature sensors" + } + }, + "weather": { + "title": "Primary weather sources", + "description": "Choose as many sensors per metric as needed and arrange them in priority order. If the preferred source is unavailable, the next one is used automatically. Outdoor temperature is mandatory; wind and cloud coverage are diagnostic-only in this version.", + "data": { + "outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)", + "outside_wind_sources": "Wind speed sources (optional)", + "outside_clouds_sources": "Cloud coverage sources (optional)" + } + }, + "weather_extra": { + "title": "Additional weather sources", + "description": "Additional weather inputs are optional and used only for comparison/diagnostics in v0.2; they do not yet change MPC predictions.", + "data": { + "outside_humidity_sources": "Outdoor humidity sources (optional)", + "outside_feelslike_sources": "Feels-like temperature sources (optional)", + "outside_pressure_sources": "Atmospheric pressure sources (optional)", + "outside_visibility_sources": "Visibility sources (optional)", + "outside_rain_sources": "Rain sources (optional)", + "outside_snow_sources": "Snow sources (optional)" + } + }, + "weights": { + "title": "Room priorities", + "description": "Each room has a relative comfort weight between 0.1 and 10; sensor entity is shown by each field." + } + }, + "error": { + "invalid_rooms": "Select 1–12 distinct room sensors.", + "invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." + }, + "abort": { + "already_configured": "Heating MPC is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Heating MPC options", + "description": "Change room sensors and controller settings. This integration remains shadow-only.", + "data": { + "climate_entity": "Existing Generic Thermostat", + "heater_entity": "Physical heating switch (read-only)", + "room_entities": "Individual room temperature sensors", + "horizon_minutes": "Prediction horizon (minutes)", + "minimum_on_minutes": "Minimum ON time (minutes)", + "minimum_off_minutes": "Minimum OFF time (minutes)", + "switching_cost": "Switching cost", + "heating_cost": "Heating runtime cost", + "comfort_band": "Comfort tolerance (°C)" + } + }, + "weather": { + "title": "Weather source priority", + "description": "Choose as many sensors per metric as needed and arrange them in priority order. If the preferred source is unavailable, the next one is used automatically. Outdoor temperature is mandatory; wind and cloud coverage are diagnostic-only in this version.", + "data": { + "outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)", + "outside_wind_sources": "Wind speed sources (optional)", + "outside_clouds_sources": "Cloud coverage sources (optional)" + } + }, + "weather_extra": { + "title": "Additional weather sensors", + "description": "Additional weather inputs are optional and used only for comparison/diagnostics in v0.2; they do not yet change MPC predictions.", + "data": { + "outside_humidity_sources": "Outdoor humidity sources (optional)", + "outside_feelslike_sources": "Feels-like temperature sources (optional)", + "outside_pressure_sources": "Atmospheric pressure sources (optional)", + "outside_visibility_sources": "Visibility sources (optional)", + "outside_rain_sources": "Rain sources (optional)", + "outside_snow_sources": "Snow sources (optional)" + } + }, + "weights": { + "title": "Room priorities", + "description": "Weights determine relative comfort importance, not physics coefficients." + } + }, + "error": { + "invalid_rooms": "Select 1–12 distinct room sensors.", + "invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." + } + } +} diff --git a/custom_components/heating_mpc/translations/en.json b/custom_components/heating_mpc/translations/en.json new file mode 100644 index 0000000..400d151 --- /dev/null +++ b/custom_components/heating_mpc/translations/en.json @@ -0,0 +1,96 @@ +{ + "title": "Heating MPC", + "config": { + "step": { + "user": { + "title": "Heating MPC — room inputs", + "description": "Choose thermostat, read-only relay and room sensors. This integration NEVER commands the heater.", + "data": { + "climate_entity": "Existing Generic Thermostat", + "heater_entity": "Physical heating switch (read-only)", + "room_entities": "Individual room temperature sensors" + } + }, + "weather": { + "title": "Primary weather sources", + "description": "Choose as many sensors per metric as needed and arrange them in priority order. If the preferred source is unavailable, the next one is used automatically. Outdoor temperature is mandatory; wind and cloud coverage are diagnostic-only in this version.", + "data": { + "outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)", + "outside_wind_sources": "Wind speed sources (optional)", + "outside_clouds_sources": "Cloud coverage sources (optional)" + } + }, + "weather_extra": { + "title": "Additional weather sources", + "description": "Additional weather inputs are optional and used only for comparison/diagnostics in v0.2; they do not yet change MPC predictions.", + "data": { + "outside_humidity_sources": "Outdoor humidity sources (optional)", + "outside_feelslike_sources": "Feels-like temperature sources (optional)", + "outside_pressure_sources": "Atmospheric pressure sources (optional)", + "outside_visibility_sources": "Visibility sources (optional)", + "outside_rain_sources": "Rain sources (optional)", + "outside_snow_sources": "Snow sources (optional)" + } + }, + "weights": { + "title": "Room priorities", + "description": "Each room has a relative comfort weight between 0.1 and 10; sensor entity is shown by each field." + } + }, + "error": { + "invalid_rooms": "Select 1–12 distinct room sensors.", + "invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." + }, + "abort": { + "already_configured": "Heating MPC is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Heating MPC options", + "description": "Change room sensors and controller settings. This integration remains shadow-only.", + "data": { + "climate_entity": "Existing Generic Thermostat", + "heater_entity": "Physical heating switch (read-only)", + "room_entities": "Individual room temperature sensors", + "horizon_minutes": "Prediction horizon (minutes)", + "minimum_on_minutes": "Minimum ON time (minutes)", + "minimum_off_minutes": "Minimum OFF time (minutes)", + "switching_cost": "Switching cost", + "heating_cost": "Heating runtime cost", + "comfort_band": "Comfort tolerance (°C)" + } + }, + "weather": { + "title": "Weather source priority", + "description": "Choose as many sensors per metric as needed and arrange them in priority order. If the preferred source is unavailable, the next one is used automatically. Outdoor temperature is mandatory; wind and cloud coverage are diagnostic-only in this version.", + "data": { + "outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)", + "outside_wind_sources": "Wind speed sources (optional)", + "outside_clouds_sources": "Cloud coverage sources (optional)" + } + }, + "weather_extra": { + "title": "Additional weather sensors", + "description": "Additional weather inputs are optional and used only for comparison/diagnostics in v0.2; they do not yet change MPC predictions.", + "data": { + "outside_humidity_sources": "Outdoor humidity sources (optional)", + "outside_feelslike_sources": "Feels-like temperature sources (optional)", + "outside_pressure_sources": "Atmospheric pressure sources (optional)", + "outside_visibility_sources": "Visibility sources (optional)", + "outside_rain_sources": "Rain sources (optional)", + "outside_snow_sources": "Snow sources (optional)" + } + }, + "weights": { + "title": "Room priorities", + "description": "Weights determine relative comfort importance, not physics coefficients." + } + }, + "error": { + "invalid_rooms": "Select 1–12 distinct room sensors.", + "invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors." + } + } +} diff --git a/custom_components/heating_mpc/translations/ro.json b/custom_components/heating_mpc/translations/ro.json new file mode 100644 index 0000000..9368c70 --- /dev/null +++ b/custom_components/heating_mpc/translations/ro.json @@ -0,0 +1,96 @@ +{ + "title": "Heating MPC", + "config": { + "step": { + "user": { + "title": "Heating MPC — configurare camere", + "description": "Selectează termostatul, releul de monitorizat și senzorii camerelor. Integrarea NU comandă centrala.", + "data": { + "climate_entity": "Termostatul Generic Thermostat existent", + "heater_entity": "Releul fizic (numai citire)", + "room_entities": "Senzorii individuali din camere" + } + }, + "weather": { + "title": "Surse meteo principale", + "description": "Alege câți senzori dorești pentru fiecare variabilă și ordonează-i după prioritate. Când prima sursă este indisponibilă, integrarea încearcă automat următoarea. Temperatura exterioară este obligatorie; vântul și nebulozitatea sunt acum doar diagnostice.", + "data": { + "outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)", + "outside_wind_sources": "Senzori viteză vânt (opțional)", + "outside_clouds_sources": "Senzori nebulozitate (opțional)" + } + }, + "weather_extra": { + "title": "Surse meteo suplimentare", + "description": "Datele suplimentare sunt opționale și doar pentru afișare și comparație în v0.2, nu intră încă în optimizarea MPC.", + "data": { + "outside_humidity_sources": "Senzori umiditate exterioară (opțional)", + "outside_feelslike_sources": "Senzori temperatură resimțită (opțional)", + "outside_pressure_sources": "Senzori presiune atmosferică (opțional)", + "outside_visibility_sources": "Senzori vizibilitate (opțional)", + "outside_rain_sources": "Senzori precipitații ploaie (opțional)", + "outside_snow_sources": "Senzori precipitații ninsoare (opțional)" + } + }, + "weights": { + "title": "Ponderile camerelor", + "description": "Fiecare cameră are o pondere de confort între 0,1 și 10; senzorul este indicat în numele câmpului." + } + }, + "error": { + "invalid_rooms": "Selectează 1–12 senzori diferiți.", + "invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor." + }, + "abort": { + "already_configured": "Heating MPC este deja configurat." + } + }, + "options": { + "step": { + "init": { + "title": "Configurare Heating MPC", + "description": "Modifică senzorii, ponderile și optimizatorul. Rămâne activ doar modul de observație.", + "data": { + "climate_entity": "Termostatul Generic Thermostat existent", + "heater_entity": "Releul fizic (numai citire)", + "room_entities": "Senzorii individuali din camere", + "horizon_minutes": "Orizontul predicției (minute)", + "minimum_on_minutes": "Durată minimă ON (minute)", + "minimum_off_minutes": "Durată minimă OFF (minute)", + "switching_cost": "Penalizare comutări", + "heating_cost": "Penalizare durată încălzire", + "comfort_band": "Toleranță confort (°C)" + } + }, + "weather": { + "title": "Prioritatea surselor meteo", + "description": "Alege câți senzori dorești pentru fiecare variabilă și ordonează-i după prioritate. Când prima sursă este indisponibilă, integrarea încearcă automat următoarea. Temperatura exterioară este obligatorie; vântul și nebulozitatea sunt acum doar diagnostice.", + "data": { + "outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)", + "outside_wind_sources": "Senzori viteză vânt (opțional)", + "outside_clouds_sources": "Senzori nebulozitate (opțional)" + } + }, + "weather_extra": { + "title": "Senzori meteo suplimentari", + "description": "Datele suplimentare sunt opționale și doar pentru afișare și comparație în v0.2, nu intră încă în optimizarea MPC.", + "data": { + "outside_humidity_sources": "Senzori umiditate exterioară (opțional)", + "outside_feelslike_sources": "Senzori temperatură resimțită (opțional)", + "outside_pressure_sources": "Senzori presiune atmosferică (opțional)", + "outside_visibility_sources": "Senzori vizibilitate (opțional)", + "outside_rain_sources": "Senzori precipitații ploaie (opțional)", + "outside_snow_sources": "Senzori precipitații ninsoare (opțional)" + } + }, + "weights": { + "title": "Ponderi pentru camere", + "description": "Ponderile afectează importanța confortului, nu coeficienții fizici." + } + }, + "error": { + "invalid_rooms": "Selectează 1–12 senzori diferiți.", + "invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor." + } + } +} diff --git a/custom_components/heating_mpc/weather_sources.py b/custom_components/heating_mpc/weather_sources.py new file mode 100644 index 0000000..a385a66 --- /dev/null +++ b/custom_components/heating_mpc/weather_sources.py @@ -0,0 +1,104 @@ +"""Read-only weather source selection and unit normalization. + +Pure-Python helpers so source and unit behavior can be tested without HAOS. +Optional weather variables are observability inputs in v0.2, not MPC features. +""" +from __future__ import annotations + +from math import isfinite + +from .const import CONF_OUTSIDE, WEATHER_SOURCES + + +LIMITS = { + "temperature": (-45.0, 55.0), + "feels_like": (-65.0, 65.0), + "wind": (0.0, 90.0), # m/s + "clouds": (0.0, 100.0), # percentage + "humidity": (0.0, 100.0), # percentage + "pressure": (800.0, 1100.0), # hPa + "visibility": (0.0, 100000.0), # metres + "rain": (0.0, 500.0), # mm (sampling period defined by upstream sensor) + "snow": (0.0, 500.0), +} + + +def sources_for(config: dict, metric: str) -> list[str]: + """Return ordered de-duplicated entity IDs, including v0.1 migration.""" + key = WEATHER_SOURCES[metric] + sources = config.get(key) + if sources is None and metric == "temperature": + sources = config.get(CONF_OUTSIDE) + if isinstance(sources, str): + sources = [sources] + if not isinstance(sources, (list, tuple)): + return [] + return list(dict.fromkeys(s for s in sources if isinstance(s, str) and s.startswith("sensor."))) + + +def normalize(metric: str, raw: object, unit: str | None = None) -> float | None: + """Validate and normalize common HA units; reject unknown/mismatched units.""" + try: + value = float(raw) + except (TypeError, ValueError): + return None + if not isfinite(value): + return None + unit = (unit or "").strip().lower().replace(" ", "") + if metric in ("temperature", "feels_like"): + if unit in ("°f", "f", "fahrenheit"): + value = (value - 32.0) * 5.0 / 9.0 + elif unit not in ("", "°c", "c", "celsius"): + return None + elif metric == "wind": + if unit in ("km/h", "kmh", "kph"): + value /= 3.6 + elif unit in ("mph",): + value *= 0.44704 + elif unit not in ("", "m/s", "mps"): + return None + elif metric == "pressure": + if unit == "pa": + value /= 100.0 + elif unit == "kpa": + value *= 10.0 + elif unit in ("inhg",): + value *= 33.8638867 + elif unit not in ("", "hpa", "mbar"): + return None + elif metric == "visibility": + if unit in ("km",): + value *= 1000.0 + elif unit not in ("", "m"): + return None + elif metric in ("humidity", "clouds"): + if unit not in ("", "%"): + return None + elif metric in ("rain", "snow"): + if unit not in ("", "mm", "mm/h"): + return None + lo, hi = LIMITS[metric] + return round(value, 4) if lo <= value <= hi else None + + +def choose_weather_sources( + config: dict, states: dict[str, tuple[object, str | None]] +) -> tuple[dict[str, float | None], dict[str, str | None], dict[str, dict[str, float]]]: + """Choose first valid sensor per metric, expose alternatives for comparisons. + + `states` maps entity ID -> (value, unit). In HA these are current state snapshots. + """ + values: dict[str, float | None] = {} + active: dict[str, str | None] = {} + comparisons: dict[str, dict[str, float]] = {} + for metric in WEATHER_SOURCES: + valid: dict[str, float] = {} + for entity_id in sources_for(config, metric): + raw, unit = states.get(entity_id, (None, None)) + value = normalize(metric, raw, unit) + if value is not None: + valid[entity_id] = value + comparisons[metric] = valid + active[metric] = next(iter(valid), None) + values[metric] = valid[active[metric]] if active[metric] else None + return values, active, comparisons diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..62f634d --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Heating MPC", + "render_readme": true, + "homeassistant": "2025.12.0" +} diff --git a/tests/test_physics.py b/tests/test_physics.py new file mode 100644 index 0000000..e925c8a --- /dev/null +++ b/tests/test_physics.py @@ -0,0 +1,65 @@ +"""Offline tests: run python -m unittest discover -s tests -v.""" +import importlib.util +import unittest +from pathlib import Path + +path = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc' / 'physics.py' +spec = importlib.util.spec_from_file_location('heating_mpc_physics', path) +m = importlib.util.module_from_spec(spec) +import sys +sys.modules[spec.name] = m +spec.loader.exec_module(m) + + +class ModelTests(unittest.TestCase): + def test_fitting_and_restore(self): + learn = m.Learner() + t = 20.0 + lag = 0.0 + for step in range(1000): + heater = int(step % 96 < 35) + lag = lag + m.HEAT_LAG * (heater - lag) + value = t + 0.003 * (8 - t) + 0.055 * lag + 0.001 + learn.learn(t, value, 8, lag) + t = value + self.assertGreaterEqual(learn.count, 950) + self.assertAlmostEqual(learn.theta[0], 0.003, delta=0.003) + self.assertAlmostEqual(learn.theta[1], 0.055, delta=0.03) + restored = m.Learner.from_dict(learn.as_dict()) + self.assertEqual(restored.count, learn.count) + self.assertEqual(restored.theta, learn.theta) + + def test_optimize_warm_room_prefers_off(self): + zones = {'living': 23.5, 'bedroom': 23.4} + learners = {'living': m.Learner(), 'bedroom': m.Learner()} + out = m.optimize(zones, 12, 0, 22, {'living': 2, 'bedroom': 1}, + learners, False, 3600, horizon_minutes=60) + self.assertFalse(out['proposal']) + self.assertEqual(len(out['plan_15min']), 4) + + def test_optimize_cold_room_prefers_heat(self): + zones = {'living': 19.5, 'bedroom': 19.9} + learners = {'living': m.Learner(), 'bedroom': m.Learner()} + out = m.optimize(zones, 4, 0, 22, {'living': 2, 'bedroom': 1}, + learners, False, 3600, horizon_minutes=60) + self.assertTrue(out['proposal']) + + def test_minimum_duration_on_and_off(self): + learners = {'r': m.Learner()} + # Could not switch OFF during first 15 minutes if just turned ON. + out = m.optimize({'r': 24}, 12, 1, 22, {'r': 1}, learners, True, 120, + minimum_on_minutes=15, horizon_minutes=60) + self.assertTrue(out['proposal']) + # Could not switch ON during first 10 minutes if just turned OFF. + out = m.optimize({'r': 18}, 0, 0, 22, {'r': 1}, learners, False, 120, + minimum_off_minutes=10, horizon_minutes=60) + self.assertFalse(out['proposal']) + + def test_invalid_room_weights_rejected(self): + with self.assertRaises(ValueError): + m.optimize({'r': 20}, 10, 0, 22, {'r': 0}, {'r': m.Learner()}, + False, 1000, horizon_minutes=60) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_weather_sources.py b/tests/test_weather_sources.py new file mode 100644 index 0000000..949589a --- /dev/null +++ b/tests/test_weather_sources.py @@ -0,0 +1,73 @@ +"""Offline weather inputs selection, converter, fallback, migration and source order.""" +import importlib.util +from pathlib import Path +import sys +import types +import unittest + +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)] +for name in ('const', 'weather_sources'): + spec = importlib.util.spec_from_file_location(f'heating_mpc.{name}', COMPONENTS / f'{name}.py') + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + +from heating_mpc import weather_sources as w + + +class WeatherInputs(unittest.TestCase): + def test_priority_fallback_and_compare(self): + data = {"outside_temperature_sources": ["sensor.owm", "sensor.alt"]} + states = { + "sensor.owm": ("unavailable", "°C"), + "sensor.alt": (53.6, "°F"), + } + values, active, comparisons = w.choose_weather_sources(data, states) + self.assertEqual(values["temperature"], 12.0) + self.assertEqual(active["temperature"], "sensor.alt") + self.assertEqual(comparisons["temperature"], {"sensor.alt": 12.0}) + states["sensor.owm"] = (10.3, "°C") + values, active, comparisons = w.choose_weather_sources(data, states) + self.assertEqual(values["temperature"], 10.3) + self.assertEqual(active["temperature"], "sensor.owm") + self.assertEqual(comparisons["temperature"]["sensor.alt"], 12.0) + + def test_source_reorder_changes_selected_source(self): + data = {"outside_temperature_sources": ["sensor.alt", "sensor.owm"]} + states = {"sensor.owm": (11.0, "°C"), "sensor.alt": (12.0, "°C")} + values, active, _ = w.choose_weather_sources(data, states) + self.assertEqual(active["temperature"], "sensor.alt") + self.assertEqual(values["temperature"], 12.0) + + def test_legacy_v01_temperature_migration(self): + self.assertEqual(w.sources_for({"outside_entity": "sensor.temperature"}, "temperature"), + ["sensor.temperature"]) + self.assertEqual(w.sources_for({"outside_temperature_sources": ["sensor.b", "sensor.b"]}, "temperature"), + ["sensor.b"]) + + def test_optional_weather_and_unit_conversions(self): + data = {"outside_temperature_sources": ["sensor.t"], + "outside_wind_sources": ["sensor.wind"], + "outside_pressure_sources": ["sensor.pressure"], + "outside_clouds_sources": ["sensor.clouds"]} + states = {"sensor.t": (12, "°C"), "sensor.wind": (36, "km/h"), + "sensor.pressure": (101325, "Pa"), "sensor.clouds": (40, "%")} + values, _, _ = w.choose_weather_sources(data, states) + self.assertEqual(values["wind"], 10.0) + self.assertEqual(values["pressure"], 1013.25) + self.assertEqual(values["clouds"], 40.0) + self.assertIsNone(values["snow"]) + + def test_bad_values_rejected(self): + for value in (None, 'unavailable', 'nan', 'inf', 1000): + self.assertIsNone(w.normalize("temperature", value, "°C")) + self.assertIsNone(w.normalize("temperature", 22.0, "km/h")) + self.assertIsNone(w.normalize("wind", -3, "m/s")) + self.assertIsNone(w.normalize("clouds", 150, "%")) + + +if __name__ == "__main__": + unittest.main()