diff --git a/docs/adr/0012-heat-control.md b/docs/adr/0012-heat-control.md new file mode 100644 index 000000000..ddc023006 --- /dev/null +++ b/docs/adr/0012-heat-control.md @@ -0,0 +1,365 @@ +# ADR 0012: Heat control from a target temperature and a learned thermal twin + +- Status: proposed. +- Implementation: the declaration + ([#1995](https://github.com/srcfl/ftw/pull/1995)) and the twin in shadow + ([#1996](https://github.com/srcfl/ftw/pull/1996)) are proposed as PRs. + Planned control has not shipped. +- Date: 2026-11-10 +- Decided by: Fredrik on 10 November 2026, in his + [answers on #1994](https://github.com/srcfl/ftw/pull/1994#issuecomment-6763083595). + See [Decisions](#decisions). +- Builds on: [#520](https://github.com/srcfl/ftw/issues/520), the + [`thermal`](../../go/internal/thermal/model.go) contract, driver + [`controls`](../../go/internal/drivers/catalog_controls.go), the + [operator control API](../../go/internal/api/api_driver_control.go) and the + learned heating coefficient in [`loadmodel`](../../go/internal/loadmodel/model.go). +- Roadmap: Fredrik moves heat, space heat included, ahead of + [Heat: data first, bounded control later](../roadmap.md) and updates that + section and VISION.md himself. +- Layering: [#1772](https://github.com/srcfl/ftw/pull/1772). The twin and + the planner are a layer that proposes values; Core checks them. + +## Context + +Read against `master` at `7350a3e`: + +- A driver can declare `controls` in its `DRIVER` block: an id, a label, an + evidence kind and one typed input with bounds. Heishamon declares + `set_heat_curve_offset` (−3..+3 °C, `write_ack`). +- `POST /api/drivers/{name}/control` sends a declared control, clamps it to + the declared bounds and holds it for at most 24 h. `DELETE` and expiry call + the driver's own `driver_default_mode`. The Heating view renders it. +- `thermal.DecideIntent` is a threshold rule over a temperature band and a + price. No package imports `thermal`. +- `loadmodel` learns a whole-house heating coefficient (W/°C) from load and + outdoor temperature. It has no indoor temperature, no inertia and no + control input. +- The control tick sends `battery`, `curtail` and the opt-in `solar_pv` + hint. Nothing sends a heat command. +- The Energyplan request already carries `thermal_loads` and the result + `thermal_power_w` and `thermal_state`, but protocol v1 marks thermal + inputs unsupported and Core rejects a non-empty thermal result + ([contract](../energyplan-contract.md)). + +A fixed threshold rule cannot answer the household's real question: *how +long can this house coast, and how early must it start?* That depends on the +building's inertia, the outdoor forecast and how strongly and how late the +device responds. These differ by orders of magnitude between a concrete +floor heated by a ground-source pump, a timber house with an air-to-air +unit, and a hot-water tank. An owner should not have to know or enter them. + +## Decision + +**The owner states a target temperature. A layer learns each zone's thermal +twin from what Core records: heat loss, inertia, and how the device's control +moves the temperature. A planner in the same layer uses the twin, prices, +solar and weather forecasts to propose the control value for each slot +(on/off, setpoint or offset) that keeps the predicted temperature on target +at the lowest cost, so heat joins battery and car in one plan of +flexibility. Core checks every proposed value against what Core owns: the +declared bounds, the lease, the minimum on and off times, and release to the +device when a reading is stale or missing.** Core never learns a device by +name, and Core is not what limits which devices work: support is claimed per +driver, with hardware evidence. + +### 1. What the owner sets + +A zone is one comfort sensor with its own target. A house has as many zones +as it has sensors the owner cares about. Each is off by default. The driver +names below are examples of what a site might run; Core names none: + +```yaml +thermal: + zones: + - id: living + enabled: true + comfort_sensor: { driver: wiser, metric: room_living_temp_c } + target_c: 21.0 + flex_c: 1.0 # how far the planner may move away from target + controls: + - { driver: heishamon, id: set_heat_curve_offset } # shared source + - { driver: wiser, id: set_room_living_setpoint } # this room only + - id: bedroom + enabled: true + comfort_sensor: { driver: wiser, metric: room_bedroom_temp_c } + target_c: 18.0 + flex_c: 1.5 + controls: + - { driver: heishamon, id: set_heat_curve_offset } + - { driver: wiser, id: set_room_bedroom_setpoint } + - id: hot_water + enabled: true + comfort_sensor: { driver: heishamon, metric: hp_dhw_temp_c } + target_c: 60.0 + flex_c: 5.0 # the band 55-65 °C; see the hot-water floor below + controls: + - { driver: heishamon, id: set_dhw_mode } +``` + +- `target_c` is the goal for that zone alone. An optional schedule may + change it over the day later; this ADR does not add one. +- A zone lists every control that heats it. A control listed by several + zones is a **shared source**, such as one heat pump's curve offset serving + every room. A control listed by one zone is **local**, such as a room + thermostat or a valve. +- `flex_c` is the owner's trade between comfort and savings. Around the + target it gives the hard band `[target − flex, target + flex]`, which the + learned twin may never widen. +- **A hot-water zone has a floor, with a short dip allowed.** For a zone + whose comfort sensor is declared `asset = "dhw"`, measured at the top of + the tank where water is drawn: + - The band never goes below a **floor of 50 °C**, whatever `target_c` and + `flex_c` say. Core rejects a configuration that does. + - After a draw, such as a shower, the planner may hold off reheating so it + can wait for cheaper energy. The tank may then sit under the floor for at + most **2 hours** at a time, and never under **45 °C**. + - When either limit is reached, Core releases the tank to the device, + which reheats by its own regulation. Core keeps the timer, so this does + not depend on the layer. + + The reason is Legionella. It multiplies between about 20 and 45 °C, and + the risk is water that stays lukewarm for days, not a dip of a few hours: + industry sources give a doubling time of 2–6 hours at best, around + 37–42 °C in stagnant water with biofilm, and days to weeks to reach high + counts in real systems. The Swedish building rules ask for at least 50 °C + at the tap + ([BFS 2024:8](https://www.boverket.se/sv/PBL-kunskapsbanken/regler-om-byggande/hygien-halsa-och-miljo/vatten-avlopp/legionella/), + chapter 8, section 6), and Boverket recommends 60 °C in the water heater. + German guidance for single-family houses says to avoid running below + 50 °C. The three numbers are proposed, not decided; see [Open](#open). +- Without a comfort sensor, a zone cannot be planned. The roadmap does not + let Core assume indoor temperature. + +### 2. Declaration by the driver + +Two optional fields on an existing control, an optional `sensors` list and +optional device protection timings. A control without `asset` and +`strategy` stays operator-only. + +```lua +controls = { + { + id = "set_heat_curve_offset", label = "Heat curve offset", + evidence = "write_ack", + asset = "space_heat", -- space_heat | dhw | cooling | buffer + strategy = "curve_offset", -- curve_offset | setpoint | on_off | discrete_mode + zone = "z1", + min_interval_s = 600, -- vendor rate limit or wear + device_hold_max_s = 3600, -- the device reverts by itself after this + input = { type = "number", min = -3, max = 3, step = 1, unit = "°C", default = 0 }, + }, + { + id = "set_power", label = "Heating on", + asset = "space_heat", strategy = "on_off", + min_on_s = 1200, min_off_s = 900, -- compressor protection + input = { type = "boolean" }, + }, +}, +sensors = { + { metric = "hp_indoor_temp_c", asset = "space_heat", zone = "z1" }, + { metric = "hp_dhw_temp_c", asset = "dhw" }, +}, +``` + +- `on_off` covers devices whose only lever is start/stop. The driver's + `min_on_s` and `min_off_s` are hard constraints on the plan, not + preferences. +- **The lease.** `device_hold_max_s` says the device itself keeps a value + for at most that long and then returns to its own regulation, such as a + hub's timed boost or override. Core sends each planned value with a hold + no longer than that and renews it while the plan stands. If FTW stops, + crashes or loses power, the device falls back on its own. A control + without a device-side hold may still be planned, but only within one + declared `step` of its declared `input.default`. Without both a default + and a step, it gets no planned control. + Two holds seen on hardware: a Wiser hub's room override sent with + `DurationMinutes` ends on a quarter hour and returns the room to its + manual setpoint or schedule by itself, and CTC's 1000-series registers + reset five minutes after the last write. +- Supporting a device is a driver's work: a user can write a driver for + their own device, declare these fields and test it. Support is claimed + per driver with hardware evidence, not by Core. +- `sensors` names metrics a driver already emits. Any driver can supply + them, so a room thermostat's sensor can judge a heat pump driven by + another driver. +- The parser ignores unknown fields, so existing drivers are unaffected. + +### 3. The thermal twin + +One first-order model per zone, learned in a layer beside `loadmodel` and +the per-battery response model. It reads what Core records and never holds +up the control loop: + +```text +C · dT_in/dt = G(u, T_out) − UA · (T_in − T_out) +``` + +| Learned term | Meaning | Learned from | +|---|---|---| +| `UA` | heat loss, W/°C | indoor, outdoor and heat input; seeded from `loadmodel` | +| `τ = C/UA` | inertia: how long the zone coasts | indoor temperature drift while input is steady | +| `G(u, T_out)` | how much heat the control value delivers | indoor response to past control changes | +| dead time | how late the response starts | the same, as a delay | + +For a hot-water tank or buffer, the same form describes the store's +temperature, standing loss and fill rate. + +- **Shadow first.** A new twin only predicts. It is compared with the + measured temperature and is not used for control. +- **Confidence gate.** A zone gets planned control only while its twin is + trusted: its prediction error has stayed under a limit across a stated + period. An untrusted twin gives no planned control, and the device runs + itself. The twin's state ("learning", "trusted", "drifting") and its error + are shown to the owner. +- **Learning from ordinary operation.** The twin learns from what the device + does by itself and from owner holds. The planner makes no steps only to + learn. +- **A zone's neighbours.** A zone inside a heated house loses much of its + heat to the zones around it, not straight outdoors. A tiled bathroom + with electric floor heating sits between rooms the heat pump keeps warm. + Fitted against `T_out` alone, its `UA` is an effective value that holds + only while the neighbours stay as they are; warming the house lowers + the zone's need. Where a neighbouring zone's temperature is declared, + the twin uses it as a second boundary. +- The twin is advisory, as [safety.md](../safety.md#planner-and-model-containment) + requires. It never widens the band or a declared limit, and its prediction + never counts as evidence. Only the measured temperature verifies a plan. + +### 4. The planner + +- **A planning layer proposes; Core checks.** A dynamic program over the + existing price, PV and weather horizon. Its state is the predicted zone + temperature, and the actions are the declared control values, few by + construction (for example seven offsets, or on/off). The cost is energy + at its marginal price plus a penalty for distance from the target. The + band and the `min_on_s`/`min_off_s` timings are constraints of the plan. + Core checks them again on every value it is asked to send. +- **Zones are planned together.** Every enabled zone must stay inside its + own band. A shared source is planned once for all the zones it serves, so + the zone that needs the most heat sets it. Local controls then trim each + room toward its own target, and the twin of each zone learns its response + to both. A shared source is never raised beyond what keeps the warmest + zone inside its band, unless that zone's local control can hold it back. + When no value satisfies every zone, the plan is infeasible. The shared + source is then released to the device and the conflict is shown to the + owner, not hidden by favouring one room. There is no zone ranking. + A released source stays with the device for at least 1 hour, and is + taken back only when a plan keeps every zone inside its band for that + whole hour. The owner is told once per conflict, not once per slot. A + conflict that comes and goes therefore costs at most one release and one + reclaim an hour, never one per slot. +- **Cost per kWh of heat, not of electricity.** Each heat input is + costed as electricity at the slot's price divided by its efficiency: 1 + for direct electric heat, the learned COP at that outdoor and supply + temperature for a heat pump. Storing heat in a cheap slot pays only when + the price ratio beats the efficiency ratio plus the extra loss. A tiled + floor charged with direct electric heat (COP 1) in a house that a heat + pump also heats (COP 3-4 at 4 °C outdoor) rarely pays, even at a price + spread of 1.5; the same hours spent raising the heat pump's flow can. + When two sources can warm one zone, the planner prefers the cheaper heat + per kWh, not the cheaper electricity. +- **The planner works out the buffering itself.** It preheats before an + expensive period exactly as far as the learned inertia lets the zone + coast, and stores PV surplus as heat where the twin says it will last. +- **Energyplan can take over later** through the reserved `thermal_loads` + and `thermal_state` fields, behind an explicit worker capability, as + `hourly_import_caps` did. Core then validates its trajectory against the + same bounds and keeps the Go planner as fallback. +- `thermal.DecideIntent` and the other unused `thermal` code are removed. + Without a trusted twin there is no rule-based fallback: the device runs + itself. + +### 5. Dispatch and ownership + +- A heat block in the control tick, after the solar feed, on + freshness-allowed ticks only. A stale site meter already defaults every + driver. +- Write when the planned value changes, and renew the device-side hold + before it ends. Never faster than `min_interval_s`. +- One owner per driver. Operator holds and planned commands share the + per-driver state now in `api`, moved to a package both can use. An + operator hold wins. The planner treats the held control as fixed at its + held value for every zone it serves, and plans the rest around it until + the hold ends. +- Each slot records the planned value, the command, the driver result, the + predicted temperature and the measured temperature. That is the evidence + the product rules ask for, and it is what the twin learns from. + +### 6. Safety + +Each protection follows [safety.md](../safety.md#clamp-discipline). Core +owns the first group. The layer's checks only decide what it proposes, and +Core does not rely on them. + +| Owner | Condition | Limit | Reaction and recovery | +|---|---|---|---| +| Core | Value outside the declared input | declared min/max | clamp (exists today) | +| Core | No device-side hold | one `step` from `input.default` | refuse a value beyond it | +| Core | FTW stops renewing | `device_hold_max_s` | the device reverts by itself | +| Core | Comfort reading stale or missing | the watchdog timeout | release to device; resume on a fresh reading | +| Core | Short cycling | `min_on_s`, `min_off_s` | refuse a value that breaks it | +| Core | Writes too frequent | `min_interval_s` | skip; the next due slot sends | +| Core | Hot-water zone band under the floor | 50 °C | reject the band in config | +| Core | Hot-water zone under the floor too long or too far | 2 h under 50 °C, or under 45 °C | release the tank to the device; resume above the floor | +| Layer | Measured temperature outside the band | owner band | propose release; resume back inside | +| Layer | Twin not trusted, or error over limit | confidence gate | propose nothing; the device runs itself | +| Layer | Zones conflict on a shared source | every zone's band | release the source and tell the owner | + +The site-meter and watchdog paths stay ahead of all of these. + +When the layer is down, nothing reacts to a measured temperature outside the +band. That case rests on the lease alone: a device with a device-side hold +returns to its own regulation within `device_hold_max_s`, and a control +without one can never be more than one `step` from its `input.default`. The +hot-water floor does not depend on the layer, because Core owns it. + +### Not in scope + +Temperature schedules, the Live Energy node for heat pumps (#520, part 1), +the Settings form and an Energyplan thermal capability. Each is a separate +change. + +## Decisions + +Fredrik answered on 10 November 2026: + +1. **Order.** Go ahead, starting with the twin in shadow. Heat pumps, space + heat included, belong in FTW as long as the base model is good. Planning + flexibility across battery, car and heat is the goal. +2. **Lease.** Planned control needs the device to fall back on its own, + through a device-side timed hold. Otherwise planned values stay a small + step from the device default. +3. **Untrusted twin.** No planned control; the device runs itself. + `thermal.DecideIntent` and the other unused `thermal` code are removed. +4. **Learning steps.** Not for now. The twin learns from ordinary operation + and owner holds. +5. **Zone conflicts.** Release the shared source and tell the owner. No zone + ranking. +6. **First case.** None named in Core. Build the generic base so that a user + can write and test a driver for their own device. Support is claimed per + driver, with hardware evidence. +7. **Keep the core simple.** Core owns the declared bounds, the lease, the + minimum on and off times, and release to the device when a reading is + stale or missing. The twin and the planner are a layer that proposes + values, and Core checks them. + +## Open + +- **The hot-water floor.** Proposed: a 50 °C floor, a dip under it of at + most 2 hours, and never under 45 °C, for Fredrik to confirm or change. The + growth figures behind them are industry sources, not measurements, so the + numbers lean cautious. A periodic pass to 60 °C or more against + Legionella is a later, separate change. +- **Holding off a reheat needs a control.** Many heat pumps start a + hot-water charge by themselves at their own start temperature. The planner + can only delay a reheat on a device whose driver declares a control that + blocks or lowers the charge. +- **Where the efficiency comes from.** Proposed: a control declares its + heat source (`direct_electric` or `heat_pump`); a heat pump's COP is + learned from its metered electrical input where a driver reports one, + and otherwise taken from a declared curve. Some controllers, such as the + CTC EcoZenith, report no compressor power, so the curve is all there is. +- **How long a released shared source stays released.** Proposed: at least + 1 hour, reclaimed only when a plan keeps every zone in its band for that + hour. The hour is a starting value to tune on hardware.