Skip to content

docs(adr): propose heat control from a target temperature and a learned twin - #1994

Open
hannesb90 wants to merge 7 commits into
srcfl:masterfrom
hannesb90:docs/adr-0012-heat-control
Open

hannesb90 wants to merge 7 commits into
srcfl:masterfrom
hannesb90:docs/adr-0012-heat-control

Conversation

@hannesb90

@hannesb90 hannesb90 commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Problem and result

FTW can move a heat device only by hand today. Drivers declare controls,
POST /api/drivers/{name}/control holds a value for at most 24 h, and the
Heating view renders it. Core itself never sends a heat command, and
go/internal/thermal is imported by nothing. #520 asks for heat-pump control
driven by price and site state, and splits it into read-only attribution and
a separate opt-in active-heat policy. This PR proposes the shape of that
policy as ADR 0012. Status: proposed; nothing ships.

In short:

  • The owner sets a target temperature and a flex per zone. A zone is one
    comfort sensor. Each zone lists the controls that heat it. A control shared
    by several zones, such as one heat pump's curve offset, is planned once for
    all of them; local controls such as room thermostats trim each room.
  • Core learns a thermal twin per zone from measurements: heat loss
    (seeded from loadmodel), inertia, control response and dead time. It runs
    in shadow, predicting only, until a confidence gate opens.
  • A Go planner in Core chooses on/off, setpoint or offset per slot over
    the existing price, PV and weather horizon, with the band and the
    compressor timings as hard constraints. Preheating and storing PV as heat
    follow from the learned inertia. Energyplan can take over later through
    the already reserved thermal_loads/thermal_state fields behind a
    capability.
  • Drivers declare what a control does with optional asset, strategy,
    zone, min_interval_s, min_on_s/min_off_s fields and a sensors
    list. Core never learns a device by name, and existing drivers are
    unaffected.

Owner's answers (10 November) are now recorded in the ADR's Decisions
section.
The twin and the planner are a layer that proposes values, and
Core checks them, as #1772 describes. Planned control needs a device-side
timed hold (device_hold_max_s); without one, values stay within one
declared step of input.default. An untrusted twin gives no planned
control, and thermal.DecideIntent goes. There are no learning steps and no
zone ranking, and Core names no first device. VISION.md and the roadmap are
left to Fredrik, per his correction.

Implementation, as separate PRs:

Scope and safety

Docs only: one new file, docs/adr/0012-heat-control.md. Numbered 0012
because 0010 is taken by #1632. No open PR touches thermal,
catalog_controls.go or api_driver_control.go. #1859 touches
web/heating.js, which this does not change.

The proposed protections follow docs/safety.md clamp discipline: declared
bounds, stale or missing comfort reading, measurement outside the band,
untrusted or drifting twin, write rate, short cycling and lease expiry. In
each case the device is released to its own driver_default_mode. The
site-meter and watchdog paths stay ahead of all of them.

Verification

Not applicable to runtime: this is a proposal. Every claim about current
behaviour was checked against master at 7350a3e. If accepted, the
implementation would need, in separate PRs:

  1. Parsing of the new declaration fields and sensors, exposed on
    /api/drivers/{name}. No behaviour change.
  2. Moving the per-driver hold state out of api so the operator path and
    the planner share one owner. No behaviour change.
  3. The thermal twin in shadow, with its prediction error visible.
    Evidence: held-out backtests and site data, not simulation alone.
  4. The opt-in planner and dispatch, with activation and non-activation tests
    for each protection. A minor changeset, for the owner to approve.
  5. A driver declaration in srcfl/device-drivers with hardware evidence
    (model and firmware) for the first case.

Checklist

  • The change follows VISION.md and one selected scope.
  • I checked overlapping PRs and coordinated shared files/contracts.
  • Relevant checks cover the changed behaviour and failure paths. (n/a: proposal)
  • A human reviewed changed UI in a browser, or no UI changed.
  • A Changeset is included, or the change is exempt. (docs only)
  • Every commit has a DCO sign-off.

🤖 Generated with Claude Code

…ed 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 srcfl#520

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: hannesb90 <bruhn90@gmail.com>
@hannesb90
hannesb90 marked this pull request as ready for review November 10, 2026 08:26
@frahlg

frahlg commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

Owner answers (Fredrik, 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. This changes "Comfort and heat" in VISION.md and "Heat: data first, bounded control later" in docs/roadmap.md. Fredrik updates those himself; leave both files out of this PR.
  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. Remove thermal.DecideIntent and other unused thermal code in the refactor.
  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, for now. 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; Core must not be what limits which devices work. 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, as docs(vision): a hard, simple core with layers on top #1772 describes for forecasts and the Energy Planner.

@frahlg

frahlg commented Nov 10, 2026

Copy link
Copy Markdown
Member

Correction to the answers above: VISION.md is Fredrik's and takes no changes from outside PRs. Leave VISION.md and docs/roadmap.md out of this PR; Fredrik updates them himself. I have edited point 1 to say so.

Fredrik answered the open questions on srcfl#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 <noreply@anthropic.com>
Signed-off-by: hannesb90 <bruhn90@gmail.com>
hannesb90 added a commit to hannesb90/forty-two-watts that referenced this pull request Nov 10, 2026
The owner's answer on srcfl#1994: 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. A control may now
declare device_hold_max_s, and a number input its default, so Core can
own that lease rule. A zero or non-numeric hold, or a default outside
min..max or on a non-number input, drops the control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: hannesb90 <bruhn90@gmail.com>
hannesb90 added a commit to hannesb90/forty-two-watts that referenced this pull request Nov 10, 2026
Step 3 of ADR 0012 (srcfl#1994). For every room sensor a driver declares,
FTW fits dT/dt = a(Tout - Tin) + b*P + c by recursive least squares over
30-minute windows: inertia, capacity, heat loss and other gains. The
twin is judged by simulating each three-hour stretch from the parameters
it had at the start with the inputs that really happened, and reports
learning, trusted, drifting or no_heat_input.

It reads LatestMetric and the load model's outdoor temperature, persists
through modelstate, and is shown at GET /api/thermal/twins with
shadow: true. It commands nothing and nothing plans from it.

Refs srcfl#520

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: hannesb90 <bruhn90@gmail.com>

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the full ADR (docs only, 1 file). It proposes target-temperature heat control: a learned per-zone twin in shadow first, a planner that proposes values, Core checking bounds/lease/min on-off/stale release.

  • Question, DHW (hot_water example): target_c: 50 with flex_c: 5 allows the tank to sit at 45 °C. That is inside the legionella growth range. Worth either a floor on asset: dhw zones or a note that the example is illustrative only.
  • Nit, Status line: says "accepted; being built" while the PR is still open. Fine if intentional, but it reads ahead of the merge.
  • Gap, safety table: the 'measured temperature outside band' reaction is owned by the layer, so if the layer is down that case relies purely on the device-side hold. That is consistent with the lease design, just worth saying explicitly for controls with no hold (they are limited to one step from default, which covers it).

No code, no secrets, no new hosts. Safe to merge from my read as a proposal; the DHW floor is the one thing I would want a human to decide.

From review on srcfl#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 <noreply@anthropic.com>
Signed-off-by: hannesb90 <bruhn90@gmail.com>
@hannesb90

hannesb90 commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All three are in 31029dc:

  • DHW floor. A zone whose comfort sensor is declared asset = "dhw" never gets a band below 55 °C, whatever its target_c and flex_c. Core owns it: it rejects such a band in config and releases the tank to the device when the measured temperature is under the floor, so it does not depend on the layer. The example hot-water zone now targets 60 °C (band 55–65). The value 55 °C is listed under a new Open section for Fredrik to confirm or change; a periodic Legionella pass is left as a separate change.
  • Status. Now "proposed", accepted when this PR merges.
  • Layer down. Added after the safety table: with the layer down nothing reacts to a measured temperature outside the band, so that case rests on the lease alone, the device-side hold, or the one-step limit for a control without one.

Follow-up in d25527d: the rule is cited as BFS 2024:8, chapter 8, section 6 (at least 50 °C at the tap). BBR, which I first cited, has been repealed.

hannesb90 and others added 2 commits November 11, 2026 07:42
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 <noreply@anthropic.com>
Signed-off-by: hannesb90 <bruhn90@gmail.com>
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 <noreply@anthropic.com>
Signed-off-by: hannesb90 <bruhn90@gmail.com>
@hannesb90

Copy link
Copy Markdown
Contributor Author

One change to the DHW floor since my reply above, in feb9bed. A fixed 55 °C floor would forbid waiting for cheap energy after a shower, while the Legionella risk is water lukewarm for days rather than a dip of a few hours. The floor is now:

  • 50 °C at the top of the tank (the Swedish rule at the tap), which no band may go under;
  • after a draw the tank may sit under it for at most 2 hours, and never under 45 °C;
  • then Core releases the tank to the device. Core keeps the timer, so it holds with the layer down.

The three numbers are listed under Open for Fredrik, with the sources they rest on (industry figures for growth, not measurements). The ADR also notes that delaying a reheat needs a driver control that can block or lower the charge.

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ADR 0012 (docs only): the owner sets a target temperature per zone, a learned thermal twin per zone proposes control values, and Core checks them against declared bounds, a device-side lease and min on/off times.

Read as a design, it holds together. The split of who owns which protection (the safety table in section 6) is the part I liked most. Notes:

  • Hot-water floor (Open). 50 °C floor, max 2 h under it, never under 45 °C, enforced in Core. That fits the Legionella reasoning and the BFS figure you cite. Marked as proposed and awaiting Fredrik, so this needs a human call before merge, not mine.
  • Status line contradicts itself. It says "proposed" and also "accepted when this PR merges". Fine, but worth one clear word at merge time so the ADR index doesn't show it as proposed forever.
  • Shared-source infeasibility. The release-and-tell-the-owner path is right. Worth stating how often the planner re-tries after a release, otherwise a flapping conflict could release/reclaim every slot. Question, not a blocker.
  • No-lease controls are limited to one step from input.default: good, that keeps the layer-down case bounded.

No secrets, code or new network destinations in the diff. Needs a human decision on the hot-water numbers; otherwise fine to land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hannesb90

Copy link
Copy Markdown
Contributor Author

Thanks. 5c7346f:

  • Status. Now just "proposed", with the PRs on a separate Implementation line. It goes to "accepted" in the merge.
  • Release and reclaim. A released shared source stays with the device for at least 1 hour, and is taken back only when a plan keeps every zone in its band for that whole hour. The owner is told once per conflict. A flapping conflict therefore costs at most one release and one reclaim an hour. The hour is listed under Open as a starting value to tune on hardware.
  • Hot-water floor. Left for Fredrik, as you say.

… on hardware

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hannesb90

Copy link
Copy Markdown
Contributor Author

fd57fb3 adds three things learned on a site with both direct electric floor heating and a heat pump: the planner costs heat per kWh of heat, using efficiency (COP 1 against 3-4), and not per kWh of electricity; a twin bullet on zones that lose heat mostly to their neighbours; and the two device-side holds seen on hardware (Wiser timed override, CTC 1000-series). Where the COP comes from is listed under Open.

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs-only: ADR 0012 proposing heat control from a target temperature + flex per zone. Status 'proposed; nothing ships', no code or config touched.

  • Security screen: nothing to flag (docs only).
  • This is a design call, not something I can sign off: the ADR is where the owner-facing semantics (target + flex, legionella handling, what core may command) get decided, so it needs a human decision rather than a 'safe to merge'.

Read-through shows no internal contradictions with #1995/#1996 as referenced.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants