From 8042bd3ecbcfb1d7c53196cbd96bebea6e65a001 Mon Sep 17 00:00:00 2001 From: hannesb90 Date: Tue, 10 Nov 2026 08:23:34 +0100 Subject: [PATCH 1/7] docs(adr): propose heat control from a target temperature and a learned twin Proposes ADR 0012: per-zone target temperatures, a learned thermal twin (heat loss, inertia, control response, dead time) and a Go planner that chooses on/off, setpoint or offset per slot inside the owner's band. Drivers declare what a control does through optional fields on the existing controls block. Status is proposed; nothing ships. Refs #520 Co-Authored-By: Claude Opus 5.5 Signed-off-by: hannesb90 --- docs/adr/0012-heat-control.md | 257 ++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 docs/adr/0012-heat-control.md diff --git a/docs/adr/0012-heat-control.md b/docs/adr/0012-heat-control.md new file mode 100644 index 000000000..649ba5f32 --- /dev/null +++ b/docs/adr/0012-heat-control.md @@ -0,0 +1,257 @@ +# ADR 0012: Heat control from a target temperature and a learned thermal twin + +- Status: proposed. Nothing described here has shipped. +- Date: 2026-11-10 +- Decided by: nobody yet. The open questions at the end are for Fredrik. +- 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: [Heat: data first, bounded control later](../roadmap.md). This + proposes the shape of that later step; it does not move it earlier. + +## 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. FTW learns each zone's thermal +twin from what it measures: heat loss, inertia, and how the device's control +moves the temperature. A planner uses the twin, prices, solar and weather +forecasts to choose the control value for each slot (on/off, setpoint or +offset) that keeps the predicted temperature on target at the lowest cost. +Core validates every value against the declared bounds and the owner's +limits. It gives the device back to its own regulation whenever the twin is +not trusted, the measurement leaves the comfort range, or the lease runs +out.** Core never learns a device by name. + +### 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: + +```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: 50.0 + flex_c: 5.0 + 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. +- 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 + input = { type = "number", min = -3, max = 3, step = 1, unit = "°C" }, + }, + { + 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. +- `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 Core beside `loadmodel` and the +per-battery response model: + +```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.** Planned control starts only when the twin's + prediction error has stayed under a limit across a stated period that + includes control changes. The Heating view shows the twin's state + ("learning", "trusted", "drifting") and its error. +- **Learning needs variation.** Ordinary operation and owner holds provide + some. While a zone is still in shadow, the planner may use small steps + that stay inside the owner's band to learn the response. They are labelled + as learning steps. +- 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 + +- **Go planner in Core first.** 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 the marginal + price from `thermal.MarginalPrice` plus a penalty for distance from the + target. The band and the `min_on_s`/`min_off_s` timings are hard + constraints. +- **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. +- **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 twin and band and keeps the Go planner as fallback. +- `thermal.DecideIntent` stays as the Go rule for a zone whose twin is not + yet trusted, or it is removed. See question 3. + +### 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, plus a refresh at the lease + interval. 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): + +| Condition | Limit | Reaction and recovery | +|---|---|---| +| Value outside the declared input | declared min/max | clamp (exists today) | +| Comfort reading stale or missing | the watchdog timeout | release to device; resume on a fresh reading | +| Measured temperature outside the band | owner band | release to device; resume back inside | +| Twin not trusted, or error over limit | confidence gate | release to device, back to shadow; resume when trusted again | +| Writes too frequent | `min_interval_s` | skip; the next due slot sends | +| Short cycling | `min_on_s`, `min_off_s` | hard constraint in the plan; Core refuses a value that breaks it | +| Core stops refreshing | lease interval | driver default (see question 2) | + +The site-meter and watchdog paths stay ahead of all of these. + +### 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. + +## Open questions for Fredrik + +1. **Order.** The roadmap lists bounded thermal control as *Later*. Should + this go ahead now behind the opt-in, beginning with the twin in shadow, + which controls nothing? +2. **Lease on process death.** A hold's timer lives in the FTW process, so + a crash or power loss leaves the last value on the device. The same holds + for today's operator hold. Should planned control require a device-side + lease (for example a hub's own timed boost), accept a host lease with the + risk stated, or limit planned values close to the device default? +3. **Untrusted twin.** Should a zone without a trusted twin get the simple + `DecideIntent` rule, or no planned control at all? +4. **Learning steps.** May the planner make small steps inside the owner's + band only to learn the response, labelled as such, or should it learn only + from ordinary operation and owner holds? +5. **Zone conflicts.** When one shared source cannot keep every zone in its + band, is releasing the source and telling the owner right, or should the + owner be able to rank zones? +6. **First case.** The roadmap names a hot-water store. Heishamon's curve + offset is the only declared heat control with hardware evidence today. + Which should prove the path first? From 666644fcc67d24e6a0465e16c27c188e30ad7caa Mon Sep 17 00:00:00 2001 From: hannesb90 Date: Tue, 10 Nov 2026 10:10:27 +0100 Subject: [PATCH 2/7] docs(adr): record the owner's answers in ADR 0012 Fredrik answered the open questions on #1994. The ADR now records them: the twin and planner are a layer that proposes and Core checks; planned control needs a device-side timed hold (device_hold_max_s), otherwise one declared step from input.default; an untrusted twin gives no planned control; no learning steps; release the shared source on a zone conflict; Core names no first device. VISION.md and the roadmap are left to Fredrik. Co-Authored-By: Claude Opus 5.5 Signed-off-by: hannesb90 --- docs/adr/0012-heat-control.md | 171 ++++++++++++++++++++-------------- 1 file changed, 101 insertions(+), 70 deletions(-) diff --git a/docs/adr/0012-heat-control.md b/docs/adr/0012-heat-control.md index 649ba5f32..d480161a0 100644 --- a/docs/adr/0012-heat-control.md +++ b/docs/adr/0012-heat-control.md @@ -1,15 +1,23 @@ # ADR 0012: Heat control from a target temperature and a learned thermal twin -- Status: proposed. Nothing described here has shipped. +- Status: accepted; being built. 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: nobody yet. The open questions at the end are for Fredrik. +- 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: [Heat: data first, bounded control later](../roadmap.md). This - proposes the shape of that later step; it does not move it earlier. +- 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 @@ -42,20 +50,23 @@ unit, and a hot-water tank. An owner should not have to know or enter them. ## Decision -**The owner states a target temperature. FTW learns each zone's thermal -twin from what it measures: heat loss, inertia, and how the device's control -moves the temperature. A planner uses the twin, prices, solar and weather -forecasts to choose the control value for each slot (on/off, setpoint or -offset) that keeps the predicted temperature on target at the lowest cost. -Core validates every value against the declared bounds and the owner's -limits. It gives the device back to its own regulation whenever the twin is -not trusted, the measurement leaves the comfort range, or the lease runs -out.** Core never learns a device by name. +**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: +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: @@ -112,7 +123,8 @@ controls = { strategy = "curve_offset", -- curve_offset | setpoint | on_off | discrete_mode zone = "z1", min_interval_s = 600, -- vendor rate limit or wear - input = { type = "number", min = -3, max = 3, step = 1, unit = "°C" }, + 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", @@ -130,6 +142,17 @@ sensors = { - `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. +- 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. @@ -137,8 +160,9 @@ sensors = { ### 3. The thermal twin -One first-order model per zone, learned in Core beside `loadmodel` and the -per-battery response model: +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) @@ -156,27 +180,27 @@ 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.** Planned control starts only when the twin's - prediction error has stayed under a limit across a stated period that - includes control changes. The Heating view shows the twin's state - ("learning", "trusted", "drifting") and its error. -- **Learning needs variation.** Ordinary operation and owner holds provide - some. While a zone is still in shadow, the planner may use small steps - that stay inside the owner's band to learn the response. They are labelled - as learning steps. +- **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. - 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 -- **Go planner in Core first.** 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 the marginal - price from `thermal.MarginalPrice` plus a penalty for distance from the - target. The band and the `min_on_s`/`min_off_s` timings are hard - constraints. +- **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 @@ -185,24 +209,25 @@ temperature, standing loss and fill rate. 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. + owner, not hidden by favouring one room. There is no zone ranking. - **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 twin and band and keeps the Go planner as fallback. -- `thermal.DecideIntent` stays as the Go rule for a zone whose twin is not - yet trusted, or it is removed. See question 3. + 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, plus a refresh at the lease - interval. Never faster than `min_interval_s`. +- 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 @@ -214,17 +239,21 @@ temperature, standing loss and fill rate. ### 6. Safety -Each protection follows [safety.md](../safety.md#clamp-discipline): - -| Condition | Limit | Reaction and recovery | -|---|---|---| -| Value outside the declared input | declared min/max | clamp (exists today) | -| Comfort reading stale or missing | the watchdog timeout | release to device; resume on a fresh reading | -| Measured temperature outside the band | owner band | release to device; resume back inside | -| Twin not trusted, or error over limit | confidence gate | release to device, back to shadow; resume when trusted again | -| Writes too frequent | `min_interval_s` | skip; the next due slot sends | -| Short cycling | `min_on_s`, `min_off_s` | hard constraint in the plan; Core refuses a value that breaks it | -| Core stops refreshing | lease interval | driver default (see question 2) | +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 | +| 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. @@ -234,24 +263,26 @@ 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. -## Open questions for Fredrik - -1. **Order.** The roadmap lists bounded thermal control as *Later*. Should - this go ahead now behind the opt-in, beginning with the twin in shadow, - which controls nothing? -2. **Lease on process death.** A hold's timer lives in the FTW process, so - a crash or power loss leaves the last value on the device. The same holds - for today's operator hold. Should planned control require a device-side - lease (for example a hub's own timed boost), accept a host lease with the - risk stated, or limit planned values close to the device default? -3. **Untrusted twin.** Should a zone without a trusted twin get the simple - `DecideIntent` rule, or no planned control at all? -4. **Learning steps.** May the planner make small steps inside the owner's - band only to learn the response, labelled as such, or should it learn only - from ordinary operation and owner holds? -5. **Zone conflicts.** When one shared source cannot keep every zone in its - band, is releasing the source and telling the owner right, or should the - owner be able to rank zones? -6. **First case.** The roadmap names a hot-water store. Heishamon's curve - offset is the only declared heat control with hardware evidence today. - Which should prove the path first? +## 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. From 31029dcc55be679ca606b9ad86c918561039f678 Mon Sep 17 00:00:00 2001 From: hannesb90 Date: Wed, 11 Nov 2026 07:39:50 +0100 Subject: [PATCH 3/7] docs(adr): hot-water floor, proposed status and the layer-down case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review on #1994: - A dhw zone never gets a band below 55 °C. Core owns the floor: it rejects such a band in config and releases the tank to the device below it. The value is listed under Open for the owner. The example hot-water zone now targets 60 °C (band 55-65). - Status reads "proposed" until the PR merges. - The safety section says what holds when the layer is down: the lease, and the one-step limit for a control without a hold. Co-Authored-By: Claude Opus 5.5 Signed-off-by: hannesb90 --- docs/adr/0012-heat-control.md | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/docs/adr/0012-heat-control.md b/docs/adr/0012-heat-control.md index d480161a0..0a453ebd4 100644 --- a/docs/adr/0012-heat-control.md +++ b/docs/adr/0012-heat-control.md @@ -1,9 +1,10 @@ # ADR 0012: Heat control from a target temperature and a learned thermal twin -- Status: accepted; being built. 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. +- Status: proposed. The owner has answered the open questions (see + [Decisions](#decisions)); the ADR is accepted when this PR merges. 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). @@ -90,8 +91,8 @@ thermal: - id: hot_water enabled: true comfort_sensor: { driver: heishamon, metric: hp_dhw_temp_c } - target_c: 50.0 - flex_c: 5.0 + target_c: 60.0 + flex_c: 5.0 # the band 55-65 °C; a dhw zone never goes under 55 controls: - { driver: heishamon, id: set_dhw_mode } ``` @@ -105,6 +106,13 @@ thermal: - `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.** A zone whose comfort sensor is declared + `asset = "dhw"` never gets a band below 55 °C, whatever its `target_c` + and `flex_c`. Stored water that sits around 45 °C lets Legionella grow, and + Swedish building rules (BBR 6:62) ask for at least 50 °C at the tap. Core + owns the floor: it rejects a configuration whose band goes under it, and + releases the tank to the device when the measured temperature is under it. + The value 55 °C is proposed, not decided; see [Open](#open). - Without a comfort sensor, a zone cannot be planned. The roadmap does not let Core assume indoor temperature. @@ -251,12 +259,19 @@ Core does not rely on them. | 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 under its floor | 55 °C, see [Open](#open) | reject the band in config; 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), @@ -286,3 +301,9 @@ Fredrik answered on 10 November 2026: 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 at 55 °C in review, for Fredrik to + confirm or change. A periodic pass to 60 °C or more against Legionella is + a later, separate change. From d25527d8db7caeff20ede115f375e759963e69a6 Mon Sep 17 00:00:00 2001 From: hannesb90 Date: Wed, 11 Nov 2026 07:42:45 +0100 Subject: [PATCH 4/7] docs(adr): cite the current Swedish rule for the hot-water floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BBR was repealed; the 50 °C at the tap is now BFS 2024:8, chapter 8, section 6. Link Boverket's guidance and note its 60 °C recommendation for the water heater. Co-Authored-By: Claude Opus 5.5 Signed-off-by: hannesb90 --- docs/adr/0012-heat-control.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/adr/0012-heat-control.md b/docs/adr/0012-heat-control.md index 0a453ebd4..30edc66af 100644 --- a/docs/adr/0012-heat-control.md +++ b/docs/adr/0012-heat-control.md @@ -108,8 +108,10 @@ thermal: learned twin may never widen. - **A hot-water zone has a floor.** A zone whose comfort sensor is declared `asset = "dhw"` never gets a band below 55 °C, whatever its `target_c` - and `flex_c`. Stored water that sits around 45 °C lets Legionella grow, and - Swedish building rules (BBR 6:62) ask for at least 50 °C at the tap. Core + and `flex_c`. Legionella grows between about 20 and 45 °C, and 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); Boverket recommends 60 °C in the water heater. Core owns the floor: it rejects a configuration whose band goes under it, and releases the tank to the device when the measured temperature is under it. The value 55 °C is proposed, not decided; see [Open](#open). From feb9bedc7c5559e4be64ca2c8c1c7df8c449f8c8 Mon Sep 17 00:00:00 2001 From: hannesb90 Date: Wed, 11 Nov 2026 08:06:39 +0100 Subject: [PATCH 5/7] =?UTF-8?q?docs(adr):=20a=20hot-water=20floor=20with?= =?UTF-8?q?=20a=20short=20dip=20instead=20of=20a=20fixed=2055=20=C2=B0C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixed floor forbade waiting for cheap energy after a shower, though Legionella risk comes from water lukewarm for days, not hours. The dhw floor is now 50 °C at the top of the tank; the tank may sit under it for at most 2 hours and never under 45 °C, then Core releases it to the device. The numbers stay open for the owner, with the sources they rest on, and holding off a reheat needs a declared control. Co-Authored-By: Claude Opus 5.5 Signed-off-by: hannesb90 --- docs/adr/0012-heat-control.md | 46 +++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/docs/adr/0012-heat-control.md b/docs/adr/0012-heat-control.md index 30edc66af..5794449aa 100644 --- a/docs/adr/0012-heat-control.md +++ b/docs/adr/0012-heat-control.md @@ -92,7 +92,7 @@ thermal: enabled: true comfort_sensor: { driver: heishamon, metric: hp_dhw_temp_c } target_c: 60.0 - flex_c: 5.0 # the band 55-65 °C; a dhw zone never goes under 55 + flex_c: 5.0 # the band 55-65 °C; see the hot-water floor below controls: - { driver: heishamon, id: set_dhw_mode } ``` @@ -106,15 +106,28 @@ thermal: - `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.** A zone whose comfort sensor is declared - `asset = "dhw"` never gets a band below 55 °C, whatever its `target_c` - and `flex_c`. Legionella grows between about 20 and 45 °C, and the Swedish - building rules ask for at least 50 °C at the tap +- **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); Boverket recommends 60 °C in the water heater. Core - owns the floor: it rejects a configuration whose band goes under it, and - releases the tank to the device when the measured temperature is under it. - The value 55 °C is proposed, not decided; see [Open](#open). + 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. @@ -261,7 +274,8 @@ Core does not rely on them. | 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 under its floor | 55 °C, see [Open](#open) | reject the band in config; release the tank to the device; resume above the floor | +| 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 | @@ -306,6 +320,12 @@ Fredrik answered on 10 November 2026: ## Open -- **The hot-water floor.** Proposed at 55 °C in review, for Fredrik to - confirm or change. A periodic pass to 60 °C or more against Legionella is - a later, separate change. +- **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. From 5c7346f9f3e305932d088b601c4f49a7f3950243 Mon Sep 17 00:00:00 2001 From: hannesb90 <279736760+hannesb90@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:57:59 +0100 Subject: [PATCH 6/7] docs(adr): one-word status; hold a released shared source for an hour Co-Authored-By: Claude Opus 5.5 --- docs/adr/0012-heat-control.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/adr/0012-heat-control.md b/docs/adr/0012-heat-control.md index 5794449aa..d91ad6dcb 100644 --- a/docs/adr/0012-heat-control.md +++ b/docs/adr/0012-heat-control.md @@ -1,10 +1,10 @@ # ADR 0012: Heat control from a target temperature and a learned thermal twin -- Status: proposed. The owner has answered the open questions (see - [Decisions](#decisions)); the ADR is accepted when this PR merges. 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. +- 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). @@ -233,6 +233,11 @@ temperature, standing loss and fill rate. 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. - **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. @@ -329,3 +334,6 @@ Fredrik answered on 10 November 2026: 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. +- **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. From fd57fb37c7864a8e324a10d4d92910f0ba8fca4e Mon Sep 17 00:00:00 2001 From: hannesb90 <279736760+hannesb90@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:45:40 +0100 Subject: [PATCH 7/7] docs(adr): cost heat per kWh of heat, a zone's neighbours, holds seen on hardware Co-Authored-By: Claude Opus 5.5 --- docs/adr/0012-heat-control.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/adr/0012-heat-control.md b/docs/adr/0012-heat-control.md index d91ad6dcb..ddc023006 100644 --- a/docs/adr/0012-heat-control.md +++ b/docs/adr/0012-heat-control.md @@ -173,6 +173,10 @@ sensors = { 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. @@ -211,6 +215,13 @@ temperature, standing loss and fill rate. - **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. @@ -238,6 +249,16 @@ temperature, standing loss and fill rate. 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. @@ -334,6 +355,11 @@ Fredrik answered on 10 November 2026: 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.