Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/thermal-control-declaration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": patch
---

A driver can now say what a control does to heat. A control may declare `asset` (space_heat, dhw, cooling or buffer), `strategy` (curve_offset, setpoint, on_off or discrete_mode), `zone`, `min_interval_s`, `device_hold_max_s` (how long the device keeps a value before it returns to its own regulation), and for on_off `min_on_s` and `min_off_s`. A number input may declare its `default`, the value the device runs at by itself. A driver may also list `sensors`: the metrics that are a heat store's temperature. `/api/drivers/{name}` shows both. Nothing plans for or sends a heat command because of them yet.

A string control may declare a closed set of `options`. The control API then refuses any other value instead of passing it to the driver. A control that declares these fields wrongly is left out, as an unrenderable control is today. Drivers that declare none of them are unchanged.
5 changes: 5 additions & 0 deletions .changeset/thermal-twin-shadow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

FTW now learns a thermal twin, in shadow, for each room sensor a driver declares (`sensors` with `asset = "space_heat"`). Every 30 minutes it fits the room's inertia, heat capacity, heat loss and other gains from the room temperature, the outdoor temperature and the heat pump's `hp_power_w`, and checks itself by predicting three hours ahead against what the sensor then measured. `GET /api/thermal/twins` shows each twin as learning, trusted or drifting, or says it has no heat input to learn from. The twin commands nothing, and nothing plans from it yet.
25 changes: 25 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import (
"github.com/srcfl/ftw/go/internal/selfupdate"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
"github.com/srcfl/ftw/go/internal/thermaltwin"
"github.com/srcfl/ftw/go/protocol/cloudwire"
)

Expand Down Expand Up @@ -1468,6 +1469,29 @@ func main() {
defer loadSvc.Stop()
slog.Info("loadmodel started", "peak_w", loadPeakW, "quality", loadSvc.Model().Quality())

// ---- Thermal twins, shadow only (docs/adr/0012) ----
// Learns each declared room sensor's inertia and heat response from the
// same outdoor temperature as the load model. It reads and stores, and
// nothing plans or dispatches from it.
twinDrivers := func() []config.Driver {
cfgMu.RLock()
defer cfgMu.RUnlock()
return append([]config.Driver(nil), cfg.Drivers...)
}
thermalTwins := thermaltwin.NewService(st, tel)
thermalTwins.Temp = loadSvc.Temp
thermalTwins.Zones = func() []thermaltwin.Zone {
catalog, err := drivers.LoadCatalogSources(drivers.CatalogSource{Dir: *userDriversDirFlag, Source: "local"}, drivers.CatalogSource{Dir: config.ManagedDriversDirOverride, Source: "managed"}, drivers.CatalogSource{Dir: resolveDriverDir(), Source: "bundled"})
if err != nil {
slog.Warn("thermal twin: driver catalog unavailable", "err", err)
return nil
}
return thermalTwinZones(twinDrivers(), catalog)
}
thermalTwins.PowerDrivers = func() []string { return thermalTwinPowerDrivers(twinDrivers()) }
thermalTwins.Start(ctx)
defer thermalTwins.Stop()

// ---- Start OCPP 1.6J Central System (optional) ----
// Chargers dial us, so there is nothing to add to cfg.Drivers and no Lua
// driver involved. A charge point whose identity (the last segment of the
Expand Down Expand Up @@ -2602,6 +2626,7 @@ func main() {
PlannerPrefs: plannerPrefs,
PVModel: pvSvc,
LoadModel: loadSvc,
ThermalTwin: thermalTwins,
ForecastLearning: forecastTrackerSvc,
Loadpoints: lpMgr,
Cars: carsRT,
Expand Down
51 changes: 51 additions & 0 deletions go/cmd/ftw/thermal_twin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package main

import (
"sort"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/drivers"
"github.com/srcfl/ftw/go/internal/thermal"
"github.com/srcfl/ftw/go/internal/thermaltwin"
)

// thermalTwinZones lists one shadow twin zone per room sensor an enabled
// driver declares. Only space heat for now: a tank or buffer has the same
// form but loses heat to the room it stands in, not to outdoors, and the
// service has no such temperature yet.
func thermalTwinZones(cfgDrivers []config.Driver, catalog []drivers.CatalogEntry) []thermaltwin.Zone {
var out []thermaltwin.Zone
for _, d := range cfgDrivers {
if d.Disabled || d.Lua == "" {
continue
}
entry := drivers.EntryForDriver(catalog, d.Lua)
if entry == nil {
continue
}
for _, s := range entry.Sensors {
if s.Asset != thermal.AssetSpaceHeat {
continue
}
out = append(out, thermaltwin.Zone{
Key: d.Name + "/" + s.Metric,
SensorDriver: d.Name,
SensorMetric: s.Metric,
})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}

// thermalTwinPowerDrivers lists the enabled drivers that may report heat
// pump power.
func thermalTwinPowerDrivers(cfgDrivers []config.Driver) []string {
var out []string
for _, d := range cfgDrivers {
if !d.Disabled {
out = append(out, d.Name)
}
}
return out
}
53 changes: 53 additions & 0 deletions go/cmd/ftw/thermal_twin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package main

import (
"os"
"path/filepath"
"reflect"
"testing"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/drivers"
"github.com/srcfl/ftw/go/internal/thermaltwin"
)

func TestThermalTwinZonesFromDeclaredSensors(t *testing.T) {
dir := t.TempDir()
write := func(name, body string) {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
write("thermo.lua", `DRIVER = {
id = "thermo",
sensors = {
{ metric = "room_living_temp_c", asset = "space_heat", zone = "living" },
{ metric = "room_bed_temp_c", asset = "space_heat" },
{ metric = "tank_temp_c", asset = "dhw" },
},
}
`)
write("meter.lua", `DRIVER = { id = "meter" }
`)
catalog, err := drivers.LoadCatalog(dir)
if err != nil {
t.Fatal(err)
}
cfg := []config.Driver{
{Name: "wiser", Lua: filepath.Join(dir, "thermo.lua")},
{Name: "spare", Lua: filepath.Join(dir, "thermo.lua"), Disabled: true},
{Name: "grid", Lua: filepath.Join(dir, "meter.lua")},
}

got := thermalTwinZones(cfg, catalog)
want := []thermaltwin.Zone{
{Key: "wiser/room_bed_temp_c", SensorDriver: "wiser", SensorMetric: "room_bed_temp_c"},
{Key: "wiser/room_living_temp_c", SensorDriver: "wiser", SensorMetric: "room_living_temp_c"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("zones = %+v, want %+v (no tank, no disabled driver)", got, want)
}
if p := thermalTwinPowerDrivers(cfg); !reflect.DeepEqual(p, []string{"wiser", "grid"}) {
t.Errorf("power drivers = %v", p)
}
}
6 changes: 5 additions & 1 deletion go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import (
"github.com/srcfl/ftw/go/internal/selfupdate"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
"github.com/srcfl/ftw/go/internal/thermaltwin"
"github.com/srcfl/ftw/go/internal/units"
v2xpolicy "github.com/srcfl/ftw/go/internal/v2x"
"github.com/srcfl/ftw/go/protocol"
Expand Down Expand Up @@ -174,7 +175,9 @@ type Deps struct {
PVModel *pvmodel.Service

// Optional: load digital-twin self-learner.
LoadModel *loadmodel.Service
LoadModel *loadmodel.Service
// Optional: shadow thermal twins of declared room sensors.
ThermalTwin *thermaltwin.Service
ForecastLearning ForecastLearning

// Optional: EV loadpoint state consumed by the API and MPC.
Expand Down Expand Up @@ -544,6 +547,7 @@ func (s *Server) routes() {
s.handle("GET /api/pvmodel", Read, s.handlePVModel)
s.handle("POST /api/pvmodel/reset", Configure, s.handlePVModelReset)
s.handle("GET /api/loadmodel", Read, s.handleLoadModel)
s.handle("GET /api/thermal/twins", Read, s.handleThermalTwins)
s.handle("POST /api/loadmodel/profile", Configure, s.handleLoadModelProfile)
s.handle("POST /api/loadmodel/reset", Configure, s.handleLoadModelReset)
s.handle("GET /api/research/load/dump", Read, s.handleLoadResearchDump)
Expand Down
1 change: 1 addition & 0 deletions go/internal/api/api_agent_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var agentAPIHints = []protocol.AgentAPIRoute{
{Path: "/api/data-sources", Description: "Available data sources and freshness"},
{Path: "/api/battery_models", Description: "Learned battery model and efficiency"},
{Path: "/api/loadmodel", Description: "Load forecast model and learning"},
{Path: "/api/thermal/twins", Description: "Learned thermal twins of room sensors; shadow only, nothing controls from them"},
{Path: "/api/pvmodel", Description: "Solar forecast model and learning"},
{Path: "/api/forecast", Description: "Load and solar forecasts", Query: map[string]string{"range": "Time ahead, such as 24h"}},
{Path: "/api/prices", Description: "Stored import and export prices", Query: map[string]string{"range": "Time ahead, such as 24h", "since_ms": "Unix milliseconds", "until_ms": "Unix milliseconds"}},
Expand Down
7 changes: 7 additions & 0 deletions go/internal/api/api_driver_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"log/slog"
"math"
"net/http"
"slices"
"sync"
"time"

Expand Down Expand Up @@ -241,6 +242,12 @@ func decodeControlValue(raw json.RawMessage, in drivers.CatalogControlInput) (an
if err := json.Unmarshal(raw, &value); err != nil {
return nil, errors.New("control requires a string value")
}
// A declared closed set is the whole allowlist, as the declared
// bounds are for a number. Unlike a number there is no nearest
// value to clamp to, so anything else is refused.
if len(in.Options) > 0 && !slices.Contains(in.Options, value) {
return nil, errors.New("control value is not one of the declared options")
}
return value, nil
default:
return nil, fmt.Errorf("control has unsupported input type %q", in.Type)
Expand Down
128 changes: 128 additions & 0 deletions go/internal/api/api_driver_thermal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package api

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/config"
)

const controlOptionsProbeLua = `DRIVER = {
id = "probe_options",
name = "Probe options",
version = "1.0.0",
controls = {
{
id = "set_mode", asset = "dhw", strategy = "discrete_mode",
input = { type = "string", options = { "eco", "boost" } },
},
},
sensors = {
{ metric = "tank_temp_c", asset = "dhw" },
},
}

local applied = 0

function driver_init(config)
host.set_make("Probe options")
host.set_poll_interval(100)
end

function driver_poll()
host.emit_metric("applied", applied, "n")
return 100
end

function driver_command(action, power_w, cmd)
if action == "set_mode" then
if cmd.value == "eco" then applied = 1 elseif cmd.value == "boost" then applied = 2 else applied = 9 end
return true
end
return false
end

function driver_default_mode()
applied = 0
end
`

// A declared closed set is the allowlist. A value outside it is a 400 and
// never reaches the driver, which would otherwise have to guess.
func TestDriverControlRefusesValueOutsideDeclaredOptions(t *testing.T) {
srv, tel := controlServerWithLua(t, controlOptionsProbeLua)

rec := post(t, srv, "/api/drivers/heat/control", `{"control":"set_mode","value":"turbo","duration_s":600}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("undeclared option = %d, want 400 (body %s)", rec.Code, rec.Body.String())
}
if hold := srv.activeControlHold("heat"); hold != nil {
t.Fatalf("hold = %+v after a refused value, want none", hold)
}
// Give the driver a few polls; a refused value must not have been sent.
time.Sleep(300 * time.Millisecond)
if v, _, ok := tel.LatestMetric("heat", "applied"); ok && v == 9 {
t.Fatalf("the refused value reached the driver")
}

rec = post(t, srv, "/api/drivers/heat/control", `{"control":"set_mode","value":"boost","duration_s":600}`)
if rec.Code != http.StatusOK {
t.Fatalf("declared option = %d, body %s", rec.Code, rec.Body.String())
}
waitMetric(t, tel, "heat", "applied", 2)
}

func TestDriverDetailSurfacesThermalDeclaration(t *testing.T) {
srv := serverWithDrivers(t,
map[string]string{"probe.lua": controlOptionsProbeLua},
&config.Config{Drivers: []config.Driver{{Name: "tank", Lua: "probe.lua"}}})

got := driverDetail(t, srv, "tank")
if len(got.Controls) != 1 || got.Controls[0].Thermal == nil {
t.Fatalf("controls = %+v, want one thermal control", got.Controls)
}
th := got.Controls[0].Thermal
if th.Asset != "dhw" || th.Strategy != "discrete_mode" {
t.Errorf("thermal = %+v", th)
}
if opts := got.Controls[0].Input.Options; len(opts) != 2 || opts[0] != "eco" || opts[1] != "boost" {
t.Errorf("options = %v", opts)
}
if len(got.Sensors) != 1 || got.Sensors[0].Metric != "tank_temp_c" || got.Sensors[0].Asset != "dhw" {
t.Errorf("sensors = %+v", got.Sensors)
}
}

// A driver that declares no heat role keeps its JSON exactly as before: no
// sensors key, and no thermal key on its controls.
func TestDriverDetailOmitsThermalKeysWhenNoneDeclared(t *testing.T) {
srv := serverWithDrivers(t,
map[string]string{"probe.lua": controlDriverLua},
&config.Config{Drivers: []config.Driver{{Name: "heat", Lua: "probe.lua"}}})

rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/drivers/heat", nil))
var raw struct {
Sensors json.RawMessage `json:"sensors"`
Controls []json.RawMessage `json:"controls"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode: %v", err)
}
if raw.Sensors != nil {
t.Errorf("sensors key present: %s", rec.Body.String())
}
if len(raw.Controls) != 1 {
t.Fatalf("controls = %d, want 1", len(raw.Controls))
}
var control map[string]any
if err := json.Unmarshal(raw.Controls[0], &control); err != nil {
t.Fatal(err)
}
if _, present := control["thermal"]; present {
t.Errorf("thermal key present on a control that declares none: %s", raw.Controls[0])
}
}
Loading
Loading