Skip to content

feat(thermaltwin): learn each declared room's thermal twin in shadow - #1996

Draft
hannesb90 wants to merge 4 commits into
srcfl:masterfrom
hannesb90:feat/thermal-twin-shadow
Draft

hannesb90 wants to merge 4 commits into
srcfl:masterfrom
hannesb90:feat/thermal-twin-shadow

Conversation

@hannesb90

@hannesb90 hannesb90 commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1995. This branch contains #1995's commits (071ccfd, f066969), which declare sensors. Review only the last commit, 5150469. It rebases cleanly once #1995 merges.

Problem and result

Step 3 of ADR 0012 (#1994), following the owner's answer there: "Go ahead, starting with the twin in shadow." Planning heat needs to know how long a room coasts and how hard the heat source moves it. FTW measures neither today. This learns both, per declared room sensor, and commands nothing.

Model. One first-order twin per zone, fitted online by recursive least squares over 30-minute windows:

dT_in/dt = a·(T_out − T_in) + b·P + c
Term Reads as
a 1/τ: inertia (tau_h)
b 1/C: K per kWh of heat input (capacity_kwh_per_k)
a/b heat loss (ua_w_per_k)
c other gains: sun, people, other heat (other_gain_k_per_h)

Inputs.

  • T_in is a sensor declared with asset = "space_heat" (feat(drivers): let a control declare what it does to heat #1995).
  • T_out comes from the same forecast cache loadmodel learns from.
  • P is hp_power_w from the sensor's own driver, or from the single other driver that reports it. With two candidates the twin does not guess: it learns without heat input and says so.

Judgement. The twin is never judged by its own fit. Each three-hour stretch is simulated from the parameters it had at the start, using the inputs that really happened, and compared with the measured temperature. Status is learning, then trusted once there is a week of windows, a day of judgements, physical parameters and a mean error ≤ 0.3 K. It becomes drifting above 0.5 K, or no_heat_input when no heat input is available.

GET /api/thermal/twins returns every twin with shadow: true. The route is also listed for analysis agents.

Scope and safety

  • Commands nothing. No new path to Registry. Per the owner's answer 3, an untrusted twin gives no planned control, and today no twin gives any.

  • Layer, not core (docs(vision): a hard, simple core with layers on top #1772). The twin runs in its own goroutine and reads only LatestMetric (no RawW/SmoothedW/ReadingsBy*) and the forecast cache. It never holds up the control tick. It fails soft: a missing catalog, sensor or outdoor temperature just skips windows.

  • Bad data is rejected, not learned:

    • a reading older than 5 min, or fewer than 25 of 30 minutes in a window;
    • more than 3 K/h of change, or an implausible temperature or power;
    • the heat input source changing mid-window.

    A changed source restarts the twin, because a fit to one source means nothing against another.

  • Persistence goes through modelstate with a feature fingerprint, so state fitted to another model form cold-starts.

  • Learning steps: none, per the owner's answer 4. The twin learns only from ordinary operation and owner holds.

  • Only space_heat zones. A tank or buffer has the same form, but it loses heat to the room it stands in, which no sensor here gives yet.

Known limits, measured in tests rather than assumed:

  • After a step change in the house (insulation, for example), the 3 h prediction recovers within a week, but τ needs about three weeks. Until then the bias term absorbs the difference. A later planner must therefore not use τ from a twin whose house recently changed. TestTwinFollowsAChangedHouse pins this.
  • b is learned against electrical input, so it includes COP, which varies with outdoor temperature. That is good enough for a shadow twin, and it is a known refinement before planning.

Verification

Raspberry Pi 4 (linux/arm64), Go 1.26.10, pinned drivers fetched:

go test ./internal/thermaltwin ./internal/api ./internal/drivers ./internal/thermal -count=1   ok
go test ./cmd/ftw -run ThermalTwin -count=1                                                 ok
go vet ./internal/thermaltwin ./internal/api ./cmd/ftw                                       clean
  • Model tests use a synthetic house with known physics (τ 40 h, 15 kWh/K):
    • it learns τ, C and UA within 10–15 %;
    • it earns trust through 0.1 K sensor quantisation;
    • it is not trusted after one day;
    • it reports drift the same day for an unexplained heat source;
    • it relearns a changed house.
  • Service tests cover persistence and restore, a cold start on a foreign fingerprint, a stale sensor, an ambiguous power source, preferring the sensor's own driver, zone removal and missing outdoor temperature.
  • Not run:
    • The race detector: ThreadSanitizer does not support this Pi kernel's 39-bit VMA, so I rely on CI for it.
    • make verify locally.
    • Real-site data: there is none yet. That is what shadow mode is for, and real-site behaviour is unverified until it has run on a real house.

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.
  • A human reviewed changed UI in a browser, or no UI changed.
  • A Changeset is included, or the change is exempt.
  • Every commit has a DCO sign-off.

🤖 Generated with Claude Code

A control may now declare asset, strategy, zone and write timings in the
thermal contract's vocabulary, and a driver may list the metrics that are
a heat store's temperature. /api/drivers/{name} exposes both. Nothing
plans for or sends a heat command because of them; this is the
declaration step of the proposal in srcfl#1994.

A string control may declare a closed set of options, which the control
API now enforces. A misdeclared thermal field drops the control, as an
unrenderable declaration already does. nestedBlock now requires the whole
field name, so temp_sensors is not read as sensors.

Refs srcfl#520

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: hannesb90 <bruhn90@gmail.com>
hannesb90 and others added 2 commits November 10, 2026 10:13
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>
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>
TestWatchedPathsCoverImports fails once drivers imports thermal, since a
change there would skip the rig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 3dc3014)
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.

1 participant