diff --git a/.changeset/thermal-control-declaration.md b/.changeset/thermal-control-declaration.md new file mode 100644 index 000000000..28c838a6b --- /dev/null +++ b/.changeset/thermal-control-declaration.md @@ -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. diff --git a/.changeset/thermal-twin-shadow.md b/.changeset/thermal-twin-shadow.md new file mode 100644 index 000000000..10ef87023 --- /dev/null +++ b/.changeset/thermal-twin-shadow.md @@ -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. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 8456f3ac4..9d5087336 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -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" ) @@ -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 @@ -2602,6 +2626,7 @@ func main() { PlannerPrefs: plannerPrefs, PVModel: pvSvc, LoadModel: loadSvc, + ThermalTwin: thermalTwins, ForecastLearning: forecastTrackerSvc, Loadpoints: lpMgr, Cars: carsRT, diff --git a/go/cmd/ftw/thermal_twin.go b/go/cmd/ftw/thermal_twin.go new file mode 100644 index 000000000..cb0254f0c --- /dev/null +++ b/go/cmd/ftw/thermal_twin.go @@ -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 +} diff --git a/go/cmd/ftw/thermal_twin_test.go b/go/cmd/ftw/thermal_twin_test.go new file mode 100644 index 000000000..a0c351d0c --- /dev/null +++ b/go/cmd/ftw/thermal_twin_test.go @@ -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) + } +} diff --git a/go/internal/api/api.go b/go/internal/api/api.go index d6de34afb..eac762af2 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -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" @@ -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. @@ -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) diff --git a/go/internal/api/api_agent_catalog.go b/go/internal/api/api_agent_catalog.go index bda1909f0..427fe8502 100644 --- a/go/internal/api/api_agent_catalog.go +++ b/go/internal/api/api_agent_catalog.go @@ -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"}}, diff --git a/go/internal/api/api_driver_control.go b/go/internal/api/api_driver_control.go index 592e1f840..c6db6952e 100644 --- a/go/internal/api/api_driver_control.go +++ b/go/internal/api/api_driver_control.go @@ -23,6 +23,7 @@ import ( "log/slog" "math" "net/http" + "slices" "sync" "time" @@ -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) diff --git a/go/internal/api/api_driver_thermal_test.go b/go/internal/api/api_driver_thermal_test.go new file mode 100644 index 000000000..d9e9b2707 --- /dev/null +++ b/go/internal/api/api_driver_thermal_test.go @@ -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]) + } +} diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index 0747bc92e..5f5608bc0 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -42,6 +42,9 @@ type driverDetailResp struct { // Controls are what this driver says an operator may command. Absent // for every driver that only reports. Controls []drivers.CatalogControl `json:"controls,omitempty"` + // Sensors are the metrics this driver says are a heat store's + // temperature. Absent for every driver that declares none. + Sensors []drivers.CatalogSensor `json:"sensors,omitempty"` // Hold is the operator setting in force, if any, and when it ends. Hold *controlHold `json:"hold,omitempty"` // ControlState says whether Core has confirmed autonomous default mode. @@ -121,7 +124,10 @@ func (s *Server) handleDriverDetail(w http.ResponseWriter, r *http.Request) { resp.Identity = driverIdentityDTO{Make: make, SN: sn, MAC: mac, Endpoint: ep} } } - resp.Controls = s.driverControls(name) + if entry := s.driverCatalogEntry(name); entry != nil { + resp.Controls = entry.Controls + resp.Sensors = entry.Sensors + } resp.Hold = s.activeControlHold(name) resp.ControlState = s.driverControlState(name, resp.Hold) writeJSON(w, 200, resp) @@ -160,6 +166,15 @@ func (s *Server) driverControlState(name string, hold *controlHold) driverContro // failure belongs in the catalog endpoint, where an operator is looking at // driver files, not here. func (s *Server) driverControls(name string) []drivers.CatalogControl { + if entry := s.driverCatalogEntry(name); entry != nil { + return entry.Controls + } + return nil +} + +// driverCatalogEntry is the catalog entry for the configured driver `name`, +// or nil. See driverControls for why a parse failure is nil too. +func (s *Server) driverCatalogEntry(name string) *drivers.CatalogEntry { cfg, ok := s.configuredDriver(name) if !ok || cfg.Lua == "" { return nil @@ -173,7 +188,7 @@ func (s *Server) driverControls(name string) []drivers.CatalogControl { if err != nil { return nil } - return entry.Controls + return &entry } dir := s.deps.DriverDir @@ -184,7 +199,7 @@ func (s *Server) driverControls(name string) []drivers.CatalogControl { if err != nil { return nil } - return drivers.ControlsForDriver(entries, lua) + return drivers.EntryForDriver(entries, lua) } func (s *Server) configuredDriver(name string) (config.Driver, bool) { diff --git a/go/internal/api/api_thermal_twin.go b/go/internal/api/api_thermal_twin.go new file mode 100644 index 000000000..b524bc2fd --- /dev/null +++ b/go/internal/api/api_thermal_twin.go @@ -0,0 +1,23 @@ +package api + +import ( + "net/http" + + "github.com/srcfl/ftw/go/internal/thermaltwin" +) + +// GET /api/thermal/twins — each declared room sensor's learned twin, in +// shadow. `shadow: true` is part of the answer, not decoration: nothing in +// Core plans or commands from these, and a reader must not infer that a +// trusted twin is steering anything. +func (s *Server) handleThermalTwins(w http.ResponseWriter, r *http.Request) { + twins := []thermaltwin.ZoneStatus{} + if s.deps.ThermalTwin != nil { + twins = s.deps.ThermalTwin.Snapshot() + } + writeJSON(w, http.StatusOK, map[string]any{ + "shadow": true, + "enabled": s.deps.ThermalTwin != nil, + "twins": twins, + }) +} diff --git a/go/internal/api/api_thermal_twin_test.go b/go/internal/api/api_thermal_twin_test.go new file mode 100644 index 000000000..11fdafd69 --- /dev/null +++ b/go/internal/api/api_thermal_twin_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/thermaltwin" +) + +type twinTele struct{} + +func (twinTele) LatestMetric(string, string) (float64, time.Time, bool) { return 0, time.Time{}, false } + +func getThermalTwins(t *testing.T, srv *Server) map[string]json.RawMessage { + t.Helper() + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/thermal/twins", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/thermal/twins = %d, body %s", rec.Code, rec.Body.String()) + } + var got map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + return got +} + +// Every answer says shadow, whether or not twins exist, so no reader can +// take a trusted twin for one that steers anything. +func TestThermalTwinsAreAlwaysMarkedShadow(t *testing.T) { + got := getThermalTwins(t, New(&Deps{})) + if string(got["shadow"]) != "true" || string(got["enabled"]) != "false" || string(got["twins"]) != "[]" { + t.Errorf("without a service = %v", got) + } + + svc := thermaltwin.NewService(nil, twinTele{}) + svc.Zones = func() []thermaltwin.Zone { + return []thermaltwin.Zone{{Key: "wiser/room_temp_c", SensorDriver: "wiser", SensorMetric: "room_temp_c"}} + } + svc.Tick(time.Now()) + got = getThermalTwins(t, New(&Deps{ThermalTwin: svc})) + if string(got["shadow"]) != "true" || string(got["enabled"]) != "true" { + t.Errorf("with a service = %v", got) + } + var twins []thermaltwin.ZoneStatus + if err := json.Unmarshal(got["twins"], &twins); err != nil { + t.Fatal(err) + } + if len(twins) != 1 || twins[0].Key != "wiser/room_temp_c" || twins[0].Status != thermaltwin.StatusNoHeatInput { + t.Errorf("twins = %+v", twins) + } +} diff --git a/go/internal/drivers/catalog.go b/go/internal/drivers/catalog.go index b8e1c48c7..7e714ac37 100644 --- a/go/internal/drivers/catalog.go +++ b/go/internal/drivers/catalog.go @@ -77,6 +77,10 @@ type CatalogEntry struct { // Controls are the operator-facing commands the driver declares. Empty // for every driver that only reports. See catalog_controls.go. Controls []CatalogControl `json:"controls,omitempty"` + // Sensors are the metrics the driver says describe a heat store's + // temperature. Empty for every driver that declares none. See + // catalog_thermal.go. + Sensors []CatalogSensor `json:"sensors,omitempty"` // ConfigSecrets lists driver-specific config keys that the Settings // UI / setup wizard should render as password inputs and store @@ -240,6 +244,7 @@ func parseCatalogEntry(path string) (CatalogEntry, error) { e.AuthPostPath = pickString(block, "auth_post_path") e.AuthPostPaths = pickList(block, "auth_post_paths") e.Controls = pickControls(block) + e.Sensors = pickSensors(block) return e, nil } diff --git a/go/internal/drivers/catalog_controls.go b/go/internal/drivers/catalog_controls.go index 5247c72c9..ddfbcafc0 100644 --- a/go/internal/drivers/catalog_controls.go +++ b/go/internal/drivers/catalog_controls.go @@ -23,6 +23,9 @@ type CatalogControl struct { Label string `json:"label,omitempty"` Evidence string `json:"evidence,omitempty"` Input CatalogControlInput `json:"input"` + // Thermal says what this control does to heat, when the driver declares + // it. Nil for every other control. See catalog_thermal.go. + Thermal *CatalogThermalControl `json:"thermal,omitempty"` } // CatalogControlInput describes the single value a control carries. Min, max @@ -34,6 +37,12 @@ type CatalogControlInput struct { Max *float64 `json:"max,omitempty"` Step *float64 `json:"step,omitempty"` Unit string `json:"unit,omitempty"` + // Options is the closed set a string input accepts. Empty means any + // string, as before. + Options []string `json:"options,omitempty"` + // Default is the value a number control's device runs at by itself, + // inside min..max. Nil means the driver does not say. + Default *float64 `json:"default,omitempty"` } var controlEvidence = map[string]bool{"readback": true, "write_ack": true} @@ -51,6 +60,15 @@ func validControlToken(value string) bool { // — and a control that appears for one spelling and not the other would look // like the driver, not the lookup. func ControlsForDriver(catalog []CatalogEntry, luaPath string) []CatalogControl { + if e := EntryForDriver(catalog, luaPath); e != nil { + return e.Controls + } + return nil +} + +// EntryForDriver is the catalog entry for luaPath, found as ControlsForDriver +// describes, or nil. +func EntryForDriver(catalog []CatalogEntry, luaPath string) *CatalogEntry { if luaPath == "" { return nil } @@ -58,14 +76,14 @@ func ControlsForDriver(catalog []CatalogEntry, luaPath string) []CatalogControl wantFilename := filepath.Base(wantPath) // A filename is only a fallback. When two sources contain the same // filename, the configured path is the only safe identity we have. - for _, e := range catalog { - if strings.EqualFold(filepath.ToSlash(filepath.Clean(e.Path)), wantPath) { - return e.Controls + for i := range catalog { + if strings.EqualFold(filepath.ToSlash(filepath.Clean(catalog[i].Path)), wantPath) { + return &catalog[i] } } - for _, e := range catalog { - if strings.EqualFold(e.Filename, wantFilename) { - return e.Controls + for i := range catalog { + if strings.EqualFold(catalog[i].Filename, wantFilename) { + return &catalog[i] } } return nil @@ -110,16 +128,47 @@ func pickControls(block string) []CatalogControl { Max: fieldNumber(input, "max"), Step: fieldNumber(input, "step"), } + options, ok := pickOptions(input) + if !ok { + continue + } + control.Input.Options = options + hasDefault := fieldPresent(withoutBlock(input, "options"), "default") + control.Input.Default = fieldNumber(input, "default") switch control.Input.Type { case "number": if control.Input.Min == nil || control.Input.Max == nil || *control.Input.Min >= *control.Input.Max { continue } - case "boolean", "string": + if options != nil { + continue + } + // A default outside the range, or not a number, is no + // default: the device cannot run at it, and a planner + // measuring small steps from it would start in the wrong + // place. + if hasDefault && (control.Input.Default == nil || + *control.Input.Default < *control.Input.Min || + *control.Input.Default > *control.Input.Max) { + continue + } + case "boolean": + if options != nil || hasDefault { + continue + } + case "string": + if hasDefault { + continue + } default: continue } + thermal, ok := pickThermalControl(entry, control.Input) + if !ok { + continue + } + control.Thermal = thermal out = append(out, control) } return out @@ -128,11 +177,14 @@ func pickControls(block string) []CatalogControl { // nestedBlock returns the body of `name = { … }`, matching braces so an inner // table does not end the outer one. Braces inside string literals are skipped; // a topic or a label is allowed to contain one. +// +// The name must stand alone: `temp_sensors = {` is not `sensors = {`. func nestedBlock(block, name string) string { - loc := regexp.MustCompile(regexp.QuoteMeta(name) + `\s*=\s*\{`).FindStringIndex(block) - if loc == nil { + m := regexp.MustCompile(`(?:^|[^A-Za-z0-9_])(` + regexp.QuoteMeta(name) + `\s*=\s*\{)`).FindStringSubmatchIndex(block) + if m == nil { return "" } + loc := []int{m[2], m[3]} depth := 0 inString := false for i := loc[1] - 1; i < len(block); i++ { diff --git a/go/internal/drivers/catalog_thermal.go b/go/internal/drivers/catalog_thermal.go new file mode 100644 index 000000000..815afaff6 --- /dev/null +++ b/go/internal/drivers/catalog_thermal.go @@ -0,0 +1,207 @@ +package drivers + +import ( + "math" + "regexp" + "strings" + + "github.com/srcfl/ftw/go/internal/thermal" +) + +// What a heat control does, said by the driver in the thermal contract's own +// vocabulary, so Core can reason about a heat pump, a thermostat and a tank +// without knowing any of them by name. Proposed in docs/adr/0012. +// +// This only describes. Nothing in Core plans for or sends a thermal control +// because of it; the operator path in api_driver_control.go is unchanged. +type CatalogThermalControl struct { + Asset thermal.AssetKind `json:"asset"` + Strategy thermal.InfluenceStrategy `json:"strategy"` + // Zone links the control to the sensors that judge it. Empty means the + // driver does not say. + Zone string `json:"zone,omitempty"` + // MinIntervalS is the shortest time between two writes the device or its + // vendor accepts: a cloud rate limit, or flash wear. + MinIntervalS *int `json:"min_interval_s,omitempty"` + // MinOnS and MinOffS protect a compressor from short cycling. They are + // declared only on an on_off control. + MinOnS *int `json:"min_on_s,omitempty"` + MinOffS *int `json:"min_off_s,omitempty"` + // DeviceHoldMaxS says the device itself keeps a commanded value for at + // most this long and then returns to its own regulation: a hub's timed + // boost or override. It is the lease that survives FTW stopping. Nil + // means the device holds a value until told otherwise. + DeviceHoldMaxS *int `json:"device_hold_max_s,omitempty"` +} + +// CatalogSensor names one metric the driver already emits as the temperature +// of a heat store: a room, a hot-water tank, a buffer. +type CatalogSensor struct { + Metric string `json:"metric"` + Asset thermal.AssetKind `json:"asset"` + Zone string `json:"zone,omitempty"` +} + +// The strategies a control may declare. synthetic_price is a signal Core +// would publish, not a lever a driver offers, so it is not one of them. +var controlStrategyInput = map[thermal.InfluenceStrategy]string{ + thermal.InfluenceCurveOffset: "number", + thermal.InfluenceSetpoint: "number", + thermal.InfluenceOnOff: "boolean", + thermal.InfluenceDiscreteMode: "string", +} + +var thermalControlKeys = []string{"asset", "strategy", "zone", "min_interval_s", "min_on_s", "min_off_s", "device_hold_max_s"} + +var metricNameRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) + +// pickThermalControl reads the thermal fields of one control entry. ok is +// false when the entry tries to declare them and gets them wrong, and the +// caller then drops the whole control, as it does for a bad evidence kind. +// A planner later trusting a misdeclared strategy, or silently missing a +// compressor's minimum off time because it was written as a string, is the +// failure this prevents. +func pickThermalControl(entry string, input CatalogControlInput) (*CatalogThermalControl, bool) { + entry = withoutBlock(entry, "input") + declared := false + for _, key := range thermalControlKeys { + if fieldPresent(entry, key) { + declared = true + break + } + } + if !declared { + return nil, true + } + + out := &CatalogThermalControl{ + Asset: thermal.AssetKind(fieldString(entry, "asset")), + Strategy: thermal.InfluenceStrategy(fieldString(entry, "strategy")), + Zone: fieldString(entry, "zone"), + } + if !thermal.ValidAssetKind(out.Asset) { + return nil, false + } + want, known := controlStrategyInput[out.Strategy] + if !known || input.Type != want { + return nil, false + } + if out.Strategy == thermal.InfluenceDiscreteMode && len(input.Options) < 2 { + return nil, false + } + if fieldPresent(entry, "zone") && !validControlToken(out.Zone) { + return nil, false + } + + var ok bool + if out.MinIntervalS, ok = pickSeconds(entry, "min_interval_s"); !ok { + return nil, false + } + if out.MinOnS, ok = pickSeconds(entry, "min_on_s"); !ok { + return nil, false + } + if out.MinOffS, ok = pickSeconds(entry, "min_off_s"); !ok { + return nil, false + } + if (out.MinOnS != nil || out.MinOffS != nil) && out.Strategy != thermal.InfluenceOnOff { + return nil, false + } + if out.DeviceHoldMaxS, ok = pickSeconds(entry, "device_hold_max_s"); !ok { + return nil, false + } + // A hold of zero seconds is no hold: the device would revert before + // the value took effect, and calling it a lease would be false. + if out.DeviceHoldMaxS != nil && *out.DeviceHoldMaxS == 0 { + return nil, false + } + return out, true +} + +// pickSeconds reads a whole, non-negative number of seconds. Absent is nil and +// fine; present and anything else is not. +func pickSeconds(entry, name string) (*int, bool) { + if !fieldPresent(entry, name) { + return nil, true + } + v := fieldNumber(entry, name) + if v == nil || *v < 0 || *v != math.Trunc(*v) || *v > 7*24*3600 { + return nil, false + } + s := int(*v) + return &s, true +} + +// pickOptions reads an input's `options = { "a", "b" }`. Absent is nil and +// fine. A list that holds anything but distinct control tokens is refused: +// an option the API would accept and the driver would not is the gap a +// closed set exists to remove. +func pickOptions(input string) ([]string, bool) { + if !fieldPresent(input, "options") { + return nil, true + } + body := nestedBlock(input, "options") + if strings.TrimSpace(body) == "" { + return nil, false + } + quoted := regexp.MustCompile(`"([^"]*)"`) + if strings.Trim(quoted.ReplaceAllString(body, ""), ", \t\r\n") != "" { + return nil, false + } + var out []string + seen := map[string]bool{} + for _, m := range quoted.FindAllStringSubmatch(body, -1) { + if !validControlToken(m[1]) || seen[m[1]] { + return nil, false + } + seen[m[1]] = true + out = append(out, m[1]) + } + return out, true +} + +// pickSensors reads the DRIVER block's `sensors` list. Each entry stands on +// its own, so one malformed sensor is dropped without hiding the others. +func pickSensors(block string) []CatalogSensor { + body := nestedBlock(stripLuaComments(block), "sensors") + if body == "" { + return nil + } + var out []CatalogSensor + seen := map[string]bool{} + for _, entry := range topLevelTables(body) { + s := CatalogSensor{ + Metric: fieldString(entry, "metric"), + Asset: thermal.AssetKind(fieldString(entry, "asset")), + Zone: fieldString(entry, "zone"), + } + if len(s.Metric) > 128 || !metricNameRE.MatchString(s.Metric) || seen[s.Metric] { + continue + } + if !thermal.ValidAssetKind(s.Asset) { + continue + } + if fieldPresent(entry, "zone") && !validControlToken(s.Zone) { + continue + } + seen[s.Metric] = true + out = append(out, s) + } + return out +} + +// fieldPresent reports whether `name =` appears as a whole field name, +// whatever its value. It is how a malformed value is told apart from an +// absent one. +func fieldPresent(block, name string) bool { + return regexp.MustCompile(`(?:^|[\s,{])` + regexp.QuoteMeta(name) + `\s*=`).MatchString(block) +} + +// withoutBlock blanks `name = { … }` so fields inside it are not read as the +// entry's own: a control's thermal `zone` is not something inside its input. +func withoutBlock(block, name string) string { + inner := nestedBlock(block, name) + if inner == "" { + return block + } + return strings.Replace(block, inner, strings.Repeat(" ", len(inner)), 1) +} diff --git a/go/internal/drivers/catalog_thermal_test.go b/go/internal/drivers/catalog_thermal_test.go new file mode 100644 index 000000000..942648599 --- /dev/null +++ b/go/internal/drivers/catalog_thermal_test.go @@ -0,0 +1,223 @@ +package drivers + +import ( + "reflect" + "testing" + + "github.com/srcfl/ftw/go/internal/thermal" +) + +func intp(v int) *int { return &v } + +const thermalDriver = `DRIVER = { + id = "probe", + name = "Probe", + version = "1.0.0", + controls = { + { + id = "set_heat_curve_offset", label = "Heat curve offset", + evidence = "write_ack", + asset = "space_heat", strategy = "curve_offset", zone = "z1", + min_interval_s = 600, device_hold_max_s = 3600, + input = { type = "number", min = -3, max = 3, step = 1, unit = "°C", default = 0 }, + }, + { + id = "set_power", asset = "space_heat", strategy = "on_off", + min_on_s = 1200, min_off_s = 900, + input = { type = "boolean" }, + }, + { + id = "set_dhw_mode", asset = "dhw", strategy = "discrete_mode", + input = { type = "string", options = { "eco", "normal", "boost" } }, + }, + }, + sensors = { + { metric = "hp_indoor_temp_c", asset = "space_heat", zone = "z1" }, + { metric = "hp_dhw_temp_c", asset = "dhw" }, + }, +} +` + +func TestThermalDeclarationParses(t *testing.T) { + entry := writeDriver(t, thermalDriver) + if entry.ID != "probe" || entry.Version != "1.0.0" { + t.Fatalf("thermal fields broke the surrounding block: %+v", entry) + } + if len(entry.Controls) != 3 { + t.Fatalf("controls = %d, want 3: %+v", len(entry.Controls), entry.Controls) + } + want := []*CatalogThermalControl{ + {Asset: thermal.AssetSpaceHeat, Strategy: thermal.InfluenceCurveOffset, Zone: "z1", MinIntervalS: intp(600), DeviceHoldMaxS: intp(3600)}, + {Asset: thermal.AssetSpaceHeat, Strategy: thermal.InfluenceOnOff, MinOnS: intp(1200), MinOffS: intp(900)}, + {Asset: thermal.AssetDHW, Strategy: thermal.InfluenceDiscreteMode}, + } + for i, w := range want { + if got := entry.Controls[i].Thermal; !reflect.DeepEqual(got, w) { + t.Errorf("control %s thermal = %+v, want %+v", entry.Controls[i].ID, got, w) + } + } + if got := entry.Controls[2].Input.Options; !reflect.DeepEqual(got, []string{"eco", "normal", "boost"}) { + t.Errorf("options = %v", got) + } + // The offset control's own input still parses as before. + if in := entry.Controls[0].Input; in.Min == nil || *in.Min != -3 || in.Max == nil || *in.Max != 3 { + t.Errorf("input = %+v", in) + } + if d := entry.Controls[0].Input.Default; d == nil || *d != 0 { + t.Errorf("default = %v, want 0", d) + } + if entry.Controls[1].Input.Default != nil || entry.Controls[1].Thermal.DeviceHoldMaxS != nil { + t.Errorf("on_off control = %+v, want no default and no device hold", entry.Controls[1]) + } + + wantSensors := []CatalogSensor{ + {Metric: "hp_indoor_temp_c", Asset: thermal.AssetSpaceHeat, Zone: "z1"}, + {Metric: "hp_dhw_temp_c", Asset: thermal.AssetDHW}, + } + if !reflect.DeepEqual(entry.Sensors, wantSensors) { + t.Errorf("sensors = %+v, want %+v", entry.Sensors, wantSensors) + } +} + +// A control that says nothing about heat keeps exactly the shape it had, so +// no existing driver changes for a field it never declared. +func TestControlWithoutThermalFieldsIsUnchanged(t *testing.T) { + entry := writeDriver(t, offsetControl) + if len(entry.Controls) != 1 || entry.Controls[0].Thermal != nil { + t.Fatalf("controls = %+v, want one with no thermal", entry.Controls) + } + if entry.Sensors != nil { + t.Errorf("sensors = %+v, want nil", entry.Sensors) + } +} + +// Each of these tries to declare a heat role and gets it wrong. The control +// is dropped rather than kept without its thermal fields: a planner trusting +// a misdeclared strategy, or missing a compressor's minimum off time because +// it was written as a string, is worse than the control not appearing. +func TestThermalControlDropsMisdeclarations(t *testing.T) { + num := `input = { type = "number", min = -3, max = 3 }` + cases := map[string]string{ + "asset only": `{ id = "c", asset = "space_heat", ` + num + ` }`, + "strategy only": `{ id = "c", strategy = "curve_offset", ` + num + ` }`, + "zone only": `{ id = "c", zone = "z1", ` + num + ` }`, + "unknown asset": `{ id = "c", asset = "sauna", strategy = "curve_offset", ` + num + ` }`, + "unknown strategy": `{ id = "c", asset = "space_heat", strategy = "vibes", ` + num + ` }`, + "synthetic price": `{ id = "c", asset = "space_heat", strategy = "synthetic_price", ` + num + ` }`, + "offset on boolean": `{ id = "c", asset = "space_heat", strategy = "curve_offset", input = { type = "boolean" } }`, + "on_off on number": `{ id = "c", asset = "space_heat", strategy = "on_off", ` + num + ` }`, + "mode without options": `{ id = "c", asset = "dhw", strategy = "discrete_mode", input = { type = "string" } }`, + "mode with one option": `{ id = "c", asset = "dhw", strategy = "discrete_mode", input = { type = "string", options = { "eco" } } }`, + "bad zone": `{ id = "c", asset = "space_heat", strategy = "curve_offset", zone = "Living Room", ` + num + ` }`, + "interval as string": `{ id = "c", asset = "space_heat", strategy = "curve_offset", min_interval_s = "600", ` + num + ` }`, + "negative interval": `{ id = "c", asset = "space_heat", strategy = "curve_offset", min_interval_s = -1, ` + num + ` }`, + "fractional interval": `{ id = "c", asset = "space_heat", strategy = "curve_offset", min_interval_s = 1.5, ` + num + ` }`, + "min_off as string": `{ id = "c", asset = "space_heat", strategy = "on_off", min_off_s = "900", input = { type = "boolean" } }`, + "min_on on a setpoint": `{ id = "c", asset = "space_heat", strategy = "setpoint", min_on_s = 60, ` + num + ` }`, + "interval over a week": `{ id = "c", asset = "space_heat", strategy = "curve_offset", min_interval_s = 700000, ` + num + ` }`, + "zero device hold": `{ id = "c", asset = "space_heat", strategy = "curve_offset", device_hold_max_s = 0, ` + num + ` }`, + "device hold string": `{ id = "c", asset = "space_heat", strategy = "curve_offset", device_hold_max_s = "3600", ` + num + ` }`, + "device hold alone": `{ id = "c", device_hold_max_s = 3600, ` + num + ` }`, + } + for name, entry := range cases { + t.Run(name, func(t *testing.T) { + parsed := writeDriver(t, "DRIVER = {\n id = \"probe\",\n controls = {\n "+entry+",\n },\n}\n") + if len(parsed.Controls) != 0 { + t.Errorf("controls = %+v, want none", parsed.Controls) + } + }) + } +} + +// A zone written inside the input belongs to the input, not to the control, +// and must not make an ordinary control look like a half-declared heat one. +func TestThermalFieldsInsideInputAreNotTheControls(t *testing.T) { + entry := writeDriver(t, `DRIVER = { + id = "probe", + controls = { + { id = "c", input = { type = "number", min = 0, max = 1, zone = "z1" } }, + }, +} +`) + if len(entry.Controls) != 1 || entry.Controls[0].Thermal != nil { + t.Fatalf("controls = %+v, want one with no thermal", entry.Controls) + } +} + +func TestControlOptionsValidation(t *testing.T) { + cases := map[string]struct { + entry string + keep bool + }{ + "plain string options": {`{ id = "c", input = { type = "string", options = { "a", "b" } } }`, true}, + "empty options": {`{ id = "c", input = { type = "string", options = { } } }`, false}, + "duplicate option": {`{ id = "c", input = { type = "string", options = { "a", "a" } } }`, false}, + "bad option token": {`{ id = "c", input = { type = "string", options = { "Eco Mode" } } }`, false}, + "non-string option": {`{ id = "c", input = { type = "string", options = { "a", 2 } } }`, false}, + "options on a number": {`{ id = "c", input = { type = "number", min = 0, max = 1, options = { "a" } } }`, false}, + "options on a boolean": {`{ id = "c", input = { type = "boolean", options = { "a" } } }`, false}, + "options not a table": {`{ id = "c", input = { type = "string", options = "a" } }`, false}, + "default in range": {`{ id = "c", input = { type = "number", min = -3, max = 3, default = -1.5 } }`, true}, + "default at a bound": {`{ id = "c", input = { type = "number", min = 0, max = 1, default = 0 } }`, true}, + "default out of range": {`{ id = "c", input = { type = "number", min = -3, max = 3, default = 5 } }`, false}, + "default as string": {`{ id = "c", input = { type = "number", min = -3, max = 3, default = "0" } }`, false}, + "default on boolean": {`{ id = "c", input = { type = "boolean", default = 0 } }`, false}, + "default on string": {`{ id = "c", input = { type = "string", default = 0 } }`, false}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + parsed := writeDriver(t, "DRIVER = {\n id = \"probe\",\n controls = {\n "+tc.entry+",\n },\n}\n") + if got := len(parsed.Controls) == 1; got != tc.keep { + t.Errorf("kept = %v, want %v: %+v", got, tc.keep, parsed.Controls) + } + }) + } +} + +// One bad sensor is dropped on its own; it does not hide the others. +func TestSensorsDropEachMalformedEntry(t *testing.T) { + entry := writeDriver(t, `DRIVER = { + id = "probe", + sensors = { + { metric = "room_temp_c", asset = "space_heat" }, + { metric = "Room Temp", asset = "space_heat" }, + { metric = "tank_temp_c", asset = "sauna" }, + { asset = "dhw" }, + { metric = "room_temp_c", asset = "cooling" }, + { metric = "zone_temp_c", asset = "space_heat", zone = "Bad Zone" }, + { metric = "tank_temp_c", asset = "dhw" }, + }, +} +`) + want := []CatalogSensor{ + {Metric: "room_temp_c", Asset: thermal.AssetSpaceHeat}, + {Metric: "tank_temp_c", Asset: thermal.AssetDHW}, + } + if !reflect.DeepEqual(entry.Sensors, want) { + t.Errorf("sensors = %+v, want %+v", entry.Sensors, want) + } +} + +// A field whose name only ends in "sensors" or "controls" is not that list. +func TestNestedBlockNeedsTheWholeName(t *testing.T) { + entry := writeDriver(t, `DRIVER = { + id = "probe", + temp_sensors = { { metric = "room_temp_c", asset = "space_heat" } }, + my_controls = { { id = "c", input = { type = "boolean" } } }, +} +`) + if entry.Sensors != nil || entry.Controls != nil { + t.Errorf("sensors = %+v, controls = %+v, want neither", entry.Sensors, entry.Controls) + } +} + +func TestSensorsIgnoreCommentedDeclarations(t *testing.T) { + entry := writeDriver(t, `DRIVER = { + id = "probe", + -- sensors = { { metric = "room_temp_c", asset = "space_heat" } }, +} +`) + if entry.Sensors != nil { + t.Errorf("sensors = %+v, want nil", entry.Sensors) + } +} diff --git a/go/internal/thermal/model.go b/go/internal/thermal/model.go index 8642b9158..b5eb71cde 100644 --- a/go/internal/thermal/model.go +++ b/go/internal/thermal/model.go @@ -22,8 +22,19 @@ const ( InfluenceCurveOffset InfluenceStrategy = "curve_offset" InfluenceSetpoint InfluenceStrategy = "setpoint" InfluenceDiscreteMode InfluenceStrategy = "discrete_mode" + // InfluenceOnOff is a device whose only lever is start and stop. + InfluenceOnOff InfluenceStrategy = "on_off" ) +// ValidAssetKind reports whether k is one of the declared asset kinds. +func ValidAssetKind(k AssetKind) bool { + switch k { + case AssetSpaceHeat, AssetCooling, AssetDHW, AssetBuffer: + return true + } + return false +} + // IntentKind is planner intent before the driver maps it to vendor commands. type IntentKind string diff --git a/go/internal/thermaltwin/model.go b/go/internal/thermaltwin/model.go new file mode 100644 index 000000000..b701aa277 --- /dev/null +++ b/go/internal/thermaltwin/model.go @@ -0,0 +1,337 @@ +// Package thermaltwin learns how a heated zone behaves, in shadow. +// +// One first-order model per zone, proposed in docs/adr/0012: +// +// dT_in/dt = a·(T_out − T_in) + b·P + c +// +// a is 1/τ, the zone's inertia; b is how many kelvin one kWh of heat input +// moves it; c is what neither explains (sun, people, other heat). The heat +// loss UA follows as a/b. Learned online by recursive least squares over +// fixed windows. +// +// The twin only predicts. Nothing in Core plans or dispatches from it. It is +// judged the only way a model may be: against measured temperature, by +// simulating each three-hour stretch from the parameters it had at the +// start, fed the inputs that really happened. A prediction never verifies +// itself. +package thermaltwin + +import ( + "math" + "time" + + "github.com/srcfl/ftw/go/internal/modelstate" +) + +const ( + // Window is the length of one learning sample. Room sensors report in + // 0.1 K steps and a zone moves a few tenths of a kelvin an hour, so a + // shorter window would learn mostly quantisation. + Window = 30 * time.Minute + // EvalHorizonWindows is how far ahead a twin is judged: three hours, + // the scale of a price peak the planner would heat ahead of. + EvalHorizonWindows = 6 + + windowH = 0.5 + + // Forgetting factor. A half-life of about 350 windows, a week, follows + // the season without losing a cold snap within days. + lambda = 0.998 +) + +// Proposed thresholds, ADR 0012. Not used for control by anything yet. +const ( + minSamplesForTrust = 7 * 48 // a week of windows + minEvaluationsForTrust = 48 // a day of three-hour judgements + trustMAEK = 0.3 + driftMAEK = 0.5 + maeMemory = 24 // evaluations, one per window +) + +// Status is what a twin can honestly say about itself. +type Status string + +const ( + StatusNoHeatInput Status = "no_heat_input" + StatusLearning Status = "learning" + StatusTrusted Status = "trusted" + StatusDrifting Status = "drifting" +) + +// Sample is one closed window, as the service measured it. +type Sample struct { + TInStartC float64 // zone temperature at the window's start + TInEndC float64 // zone temperature at the window's end + TOutC float64 // outdoor temperature over the window + PowerKW float64 // mean heat input over the window; 0 without a source + // DurationH is the time between the two temperatures. Zero means a + // whole window. + DurationH float64 + // Contiguous is false when the previous window was not observed. A gap + // breaks the three-hour simulations that span it. + Contiguous bool +} + +func (s Sample) durationH() float64 { + if s.DurationH > 0 { + return s.DurationH + } + return windowH +} + +type pending struct { + TInStartC float64 `json:"t_in_start_c"` + TInEndC float64 `json:"t_in_end_c"` + TOutC float64 `json:"t_out_c"` + PowerKW float64 `json:"power_kw"` + DurationH float64 `json:"duration_h"` + Theta [3]float64 `json:"theta"` +} + +// Twin is one zone's learned model. Its JSON is the persisted state. +type Twin struct { + Theta [3]float64 `json:"theta"` + P [3][3]float64 `json:"p"` + + Samples int64 `json:"samples"` + PowerSamples int64 `json:"power_samples"` + Rejected int64 `json:"rejected"` + + MAE3hK float64 `json:"mae_3h_k"` + Evaluations int64 `json:"evaluations"` + WasTrusted bool `json:"was_trusted"` + + // PowerSource names where heat input came from. A twin fitted to one + // source means nothing against another, so a change starts it over. + PowerSource string `json:"power_source,omitempty"` + + Pending []pending `json:"pending,omitempty"` +} + +// New returns a twin at its prior: τ = 50 h, 20 kWh/K, no unexplained gain. +// The covariance is wide, so a week of data outweighs the prior. +func New() *Twin { + t := &Twin{Theta: [3]float64{1.0 / 50, 1.0 / 20, 0}} + t.P[0][0], t.P[1][1], t.P[2][2] = 0.01, 0.01, 0.25 + return t +} + +// Observe learns from one window. It returns false, and learns nothing, for +// a window no physical zone produces: a sensor glitch, an open window, a +// missing input. +func (t *Twin) Observe(s Sample) bool { + if !plausible(s) { + t.Rejected++ + t.Pending = nil + return false + } + if !s.Contiguous { + t.Pending = nil + } + t.Pending = append(t.Pending, pending{ + TInStartC: s.TInStartC, TInEndC: s.TInEndC, TOutC: s.TOutC, + PowerKW: s.PowerKW, DurationH: s.durationH(), Theta: t.Theta, + }) + if len(t.Pending) >= EvalHorizonWindows { + t.evaluate() + t.Pending = t.Pending[1:] + } + + x := regressor(s.TInStartC, s.TOutC, s.PowerKW) + y := (s.TInEndC - s.TInStartC) / s.durationH() + t.update(x, y) + t.Samples++ + if s.PowerKW > 0 { + t.PowerSamples++ + } + // Records a first trust here, not only when someone reads the status: + // a twin nobody looked at must still be able to drift. + _ = t.Status() + return true +} + +func plausible(s Sample) bool { + for _, v := range []float64{s.TInStartC, s.TInEndC, s.TOutC, s.PowerKW} { + if math.IsNaN(v) || math.IsInf(v, 0) { + return false + } + } + switch { + case s.TInStartC < -10 || s.TInStartC > 45 || s.TInEndC < -10 || s.TInEndC > 45: + return false + case s.TOutC < -50 || s.TOutC > 50: + return false + case s.PowerKW < 0 || s.PowerKW > 50: + return false + case s.DurationH < 0 || s.DurationH > 2*windowH: + return false + case math.Abs(s.TInEndC-s.TInStartC)/s.durationH() > 3: + // Faster than any heated building: a window, a sensor in the sun, a + // sensor moved. + return false + } + return true +} + +func regressor(tInC, tOutC, powerKW float64) [3]float64 { + return [3]float64{tOutC - tInC, powerKW, 1} +} + +// update is one recursive-least-squares step with forgetting. +func (t *Twin) update(x [3]float64, y float64) { + var px [3]float64 + for i := 0; i < 3; i++ { + for j := 0; j < 3; j++ { + px[i] += t.P[i][j] * x[j] + } + } + denom := lambda + for i := 0; i < 3; i++ { + denom += x[i] * px[i] + } + if denom <= 0 || math.IsNaN(denom) || math.IsInf(denom, 0) { + return + } + var k [3]float64 + for i := 0; i < 3; i++ { + k[i] = px[i] / denom + } + errY := y - dot(t.Theta, x) + next := t.Theta + for i := 0; i < 3; i++ { + next[i] += k[i] * errY + } + var nextP [3][3]float64 + for i := 0; i < 3; i++ { + for j := 0; j < 3; j++ { + nextP[i][j] = (t.P[i][j] - k[i]*px[j]) / lambda + } + } + // Keep P symmetric and bounded; an unbounded covariance is how RLS with + // forgetting blows up during a long stretch of identical inputs. + for i := 0; i < 3; i++ { + for j := i + 1; j < 3; j++ { + m := (nextP[i][j] + nextP[j][i]) / 2 + nextP[i][j], nextP[j][i] = m, m + } + if nextP[i][i] > 1e3 { + return + } + } + if !finiteVec(next) { + return + } + t.Theta, t.P = next, nextP +} + +// evaluate simulates the oldest pending window forward to the newest one's +// end, from the parameters the twin had when the stretch began. +func (t *Twin) evaluate() { + first := t.Pending[0] + temp := first.TInStartC + for i, w := range t.Pending { + if i > 0 { + // The gap between one window's end and the next one's start + // is the zone drifting with the inputs of the window before. + prev := t.Pending[i-1] + if gapH := windowH - prev.DurationH; gapH > 0 { + temp += gapH * dot(first.Theta, regressor(temp, prev.TOutC, prev.PowerKW)) + } + } + temp += w.DurationH * dot(first.Theta, regressor(temp, w.TOutC, w.PowerKW)) + } + errK := math.Abs(temp - t.Pending[len(t.Pending)-1].TInEndC) + if math.IsNaN(errK) || math.IsInf(errK, 0) { + return + } + t.Evaluations++ + // A running mean at first, then about half a day of memory: long + // enough that one odd evening does not cost trust, short enough that a + // house that changed shows as drifting the same day. + n := float64(t.Evaluations) + if n > maeMemory { + n = maeMemory + } + t.MAE3hK += (errK - t.MAE3hK) / n +} + +// Params is the twin read as physics. +type Params struct { + TauH *float64 `json:"tau_h,omitempty"` + CapacityKWhPerK *float64 `json:"capacity_kwh_per_k,omitempty"` + UAWPerK *float64 `json:"ua_w_per_k,omitempty"` + OtherGainKPerH float64 `json:"other_gain_k_per_h"` +} + +// Params derives physical quantities. A non-positive coefficient has no +// physical reading and is left out rather than shown as nonsense. +func (t *Twin) Params() Params { + a, b, c := t.Theta[0], t.Theta[1], t.Theta[2] + p := Params{OtherGainKPerH: c} + if a > 0 { + tau := 1 / a + p.TauH = &tau + } + if b > 0 && t.PowerSamples > 0 { + cap := 1 / b + p.CapacityKWhPerK = &cap + if a > 0 { + ua := 1000 * a / b + p.UAWPerK = &ua + } + } + return p +} + +func (t *Twin) physical() bool { + p := t.Params() + return p.TauH != nil && *p.TauH >= 1 && *p.TauH <= 2000 && + p.CapacityKWhPerK != nil && *p.CapacityKWhPerK >= 1 && *p.CapacityKWhPerK <= 500 +} + +// Status reports the twin's standing and records a first trust, so later +// error above the drift limit reads as drifting rather than as learning. +func (t *Twin) Status() Status { + if t.PowerSamples == 0 { + return StatusNoHeatInput + } + if t.Samples < minSamplesForTrust || t.Evaluations < minEvaluationsForTrust || !t.physical() { + if t.WasTrusted && t.Evaluations >= minEvaluationsForTrust { + return StatusDrifting + } + return StatusLearning + } + switch { + case t.MAE3hK <= trustMAEK: + t.WasTrusted = true + return StatusTrusted + case t.WasTrusted && t.MAE3hK <= driftMAEK: + return StatusTrusted + case t.WasTrusted: + return StatusDrifting + } + return StatusLearning +} + +// FeatureHash fingerprints the model's form, so stored state from a +// different form cold starts instead of restoring meaningless coefficients. +func FeatureHash() string { + var probe []float64 + for _, v := range [][3]float64{{20, -5, 2}, {21.5, 10, 0}, {18, 0, 4.5}} { + x := regressor(v[0], v[1], v[2]) + probe = append(probe, x[:]...) + } + probe = append(probe, windowH, lambda, EvalHorizonWindows) + return modelstate.Fingerprint("thermaltwin: dT/dt K/h = a(Tout-Tin) + b*P_kW + c; heat input hp_power_w", probe) +} + +func dot(a, b [3]float64) float64 { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2] } + +func finiteVec(v [3]float64) bool { + for _, x := range v { + if math.IsNaN(x) || math.IsInf(x, 0) { + return false + } + } + return true +} diff --git a/go/internal/thermaltwin/model_test.go b/go/internal/thermaltwin/model_test.go new file mode 100644 index 000000000..a3ce97434 --- /dev/null +++ b/go/internal/thermaltwin/model_test.go @@ -0,0 +1,217 @@ +package thermaltwin + +import ( + "math" + "testing" +) + +// house is a zone with known physics, integrated finely so the twin is +// judged against a continuous system rather than its own discretisation. +type house struct { + a, b, c float64 // 1/h, K/kWh, K/h + tIn float64 +} + +func (h *house) step(tOut, powerKW, hours float64) { + const sub = 30 + dt := hours / sub + for i := 0; i < sub; i++ { + h.tIn += dt * (h.a*(tOut-h.tIn) + h.b*powerKW + h.c) + } +} + +// drive runs the house for n windows with a daily outdoor swing and a heat +// pump that cycles, and feeds every window to the twin. +func drive(tw *Twin, h *house, n int, quantize bool) { + q := func(v float64) float64 { + if !quantize { + return v + } + return math.Round(v*10) / 10 + } + for i := 0; i < n; i++ { + hour := float64(i) * windowH + tOut := -2 + 6*math.Sin(2*math.Pi*hour/24) + power := 0.0 + // On for 2 h, off for 1 h, with a stronger burst every evening: + // the variation a twin needs to separate inertia from input. + if int(hour)%3 != 2 { + power = 1.6 + } + if int(hour)%24 >= 17 && int(hour)%24 < 19 { + power = 3.0 + } + start := q(h.tIn) + h.step(tOut, power, windowH) + tw.Observe(Sample{TInStartC: start, TInEndC: q(h.tIn), TOutC: tOut, PowerKW: power, Contiguous: true}) + } +} + +func TestTwinLearnsKnownPhysics(t *testing.T) { + h := &house{a: 1.0 / 40, b: 1.0 / 15, c: 0.05, tIn: 20} + tw := New() + drive(tw, h, 14*48, false) + + p := tw.Params() + if p.TauH == nil || math.Abs(*p.TauH-40)/40 > 0.1 { + t.Errorf("tau = %v h, want 40 ±10%%", deref(p.TauH)) + } + if p.CapacityKWhPerK == nil || math.Abs(*p.CapacityKWhPerK-15)/15 > 0.1 { + t.Errorf("capacity = %v kWh/K, want 15 ±10%%", deref(p.CapacityKWhPerK)) + } + wantUA := 1000 * (1.0 / 40) / (1.0 / 15) + if p.UAWPerK == nil || math.Abs(*p.UAWPerK-wantUA)/wantUA > 0.15 { + t.Errorf("UA = %v W/K, want %.0f ±15%%", deref(p.UAWPerK), wantUA) + } + if tw.MAE3hK > 0.05 { + t.Errorf("3 h error = %.3f K on noise-free data", tw.MAE3hK) + } + if got := tw.Status(); got != StatusTrusted { + t.Errorf("status = %s, want trusted", got) + } +} + +// Real sensors step in 0.1 K. The twin must still predict three hours ahead +// well enough to earn trust. +func TestTwinEarnsTrustThroughSensorQuantisation(t *testing.T) { + h := &house{a: 1.0 / 40, b: 1.0 / 15, c: 0.05, tIn: 20} + tw := New() + drive(tw, h, 14*48, true) + if tw.MAE3hK > trustMAEK { + t.Errorf("3 h error = %.3f K, want ≤ %.1f", tw.MAE3hK, trustMAEK) + } + if got := tw.Status(); got != StatusTrusted { + t.Errorf("status = %s, want trusted (params %+v)", got, tw.Params()) + } +} + +// A week is the minimum. Good data for a day earns nothing yet. +func TestTwinDoesNotTrustEarly(t *testing.T) { + h := &house{a: 1.0 / 40, b: 1.0 / 15, c: 0.05, tIn: 20} + tw := New() + drive(tw, h, 48, false) + if got := tw.Status(); got != StatusLearning { + t.Errorf("status after a day = %s, want learning", got) + } +} + +func TestTwinWithoutHeatInputSaysSo(t *testing.T) { + tw := New() + for i := 0; i < 400; i++ { + tw.Observe(Sample{TInStartC: 20, TInEndC: 19.95, TOutC: 0, Contiguous: true}) + } + if got := tw.Status(); got != StatusNoHeatInput { + t.Errorf("status = %s, want no_heat_input", got) + } + if p := tw.Params(); p.CapacityKWhPerK != nil || p.UAWPerK != nil { + t.Errorf("params = %+v; capacity and UA need heat input", p) + } +} + +// A house that changes in a way the model can follow (new insulation, a +// colder season) is relearned and stays trusted: trust is about prediction, +// not about the parameters staying put. +func TestTwinFollowsAChangedHouse(t *testing.T) { + h := &house{a: 1.0 / 40, b: 1.0 / 15, c: 0.05, tIn: 20} + tw := New() + drive(tw, h, 14*48, false) + h.a = 2.0 / 40 + // A week: the forgetting half-life, chosen to follow seasons, not + // to chase a single cold night. + drive(tw, h, 7*48, false) + if got := tw.Status(); got != StatusTrusted || tw.MAE3hK > trustMAEK { + t.Errorf("status = %s with 3 h error %.2f K, want trusted within %.1f", got, tw.MAE3hK, trustMAEK) + } + // The inertia itself follows more slowly than the prediction recovers: + // for a while the unexplained gain c absorbs the difference. That is + // enough for three hours ahead and not for how long a house can coast, + // which is why a planner must not use τ from a twin whose house just + // changed. Measured here so the lag is a known number, not a surprise. + p := tw.Params() + if p.TauH == nil || *p.TauH >= 40 { + t.Fatalf("tau = %v h after a week, want moving from 40 toward 20", deref(p.TauH)) + } + drive(tw, h, 14*48, false) + if p := tw.Params(); p.TauH == nil || math.Abs(*p.TauH-20)/20 > 0.2 { + t.Errorf("tau = %v h after three weeks, want 20 ±20%%", deref(p.TauH)) + } +} + +// A trusted twin that stops predicting, here a sensor moved next to a heat +// source that comes and goes with no pattern, must say drifting the same +// day, not quietly keep its trust. +func TestTwinReportsDrift(t *testing.T) { + h := &house{a: 1.0 / 40, b: 1.0 / 15, c: 0.05, tIn: 20} + tw := New() + drive(tw, h, 14*48, false) + if tw.Status() != StatusTrusted { + t.Fatalf("precondition: twin not trusted") + } + seed := uint32(1) + gain := func() float64 { + seed = seed*1664525 + 1013904223 + return 2.5 * (float64(seed>>8)/float64(1<<24) - 0.5) // ±1.25 K/h + } + sawDrift := false + for i := 0; i < 48 && !sawDrift; i++ { + h.c = 0.05 + gain() + drive(tw, h, 1, false) + sawDrift = tw.Status() == StatusDrifting + } + if !sawDrift { + t.Errorf("status stayed %s with 3 h error %.2f K", tw.Status(), tw.MAE3hK) + } +} + +func TestTwinRejectsImplausibleWindows(t *testing.T) { + cases := map[string]Sample{ + "NaN temperature": {TInStartC: math.NaN(), TInEndC: 20, TOutC: 0}, + "jump of 5 K": {TInStartC: 20, TInEndC: 25, TOutC: 0}, + "freezing sensor": {TInStartC: -30, TInEndC: -30, TOutC: 0}, + "negative power": {TInStartC: 20, TInEndC: 20, TOutC: 0, PowerKW: -1}, + "absurd outdoor": {TInStartC: 20, TInEndC: 20, TOutC: 80}, + "window too long": {TInStartC: 20, TInEndC: 20, TOutC: 0, DurationH: 3}, + "infinite power": {TInStartC: 20, TInEndC: 20, TOutC: 0, PowerKW: math.Inf(1)}, + } + for name, s := range cases { + t.Run(name, func(t *testing.T) { + tw := New() + before := tw.Theta + if tw.Observe(s) { + t.Fatal("accepted") + } + if tw.Theta != before || tw.Samples != 0 || tw.Rejected != 1 { + t.Errorf("rejected window changed the twin: %+v", tw) + } + }) + } +} + +// A gap breaks the three-hour stretches that span it. Judging across one +// would score the twin on a simulation fed inputs that never arrived. +func TestTwinGapRestartsEvaluation(t *testing.T) { + tw := New() + for i := 0; i < EvalHorizonWindows-1; i++ { + tw.Observe(Sample{TInStartC: 20, TInEndC: 20, TOutC: 0, PowerKW: 1, Contiguous: true}) + } + tw.Observe(Sample{TInStartC: 20, TInEndC: 20, TOutC: 0, PowerKW: 1, Contiguous: false}) + if tw.Evaluations != 0 { + t.Errorf("evaluations = %d across a gap, want 0", tw.Evaluations) + } + if len(tw.Pending) != 1 { + t.Errorf("pending = %d after a gap, want 1", len(tw.Pending)) + } +} + +func TestFeatureHashIsStable(t *testing.T) { + if a, b := FeatureHash(), FeatureHash(); a != b || len(a) != 16 { + t.Errorf("hash = %q, %q", a, b) + } +} + +func deref(v *float64) any { + if v == nil { + return nil + } + return *v +} diff --git a/go/internal/thermaltwin/service.go b/go/internal/thermaltwin/service.go new file mode 100644 index 000000000..7fc04475c --- /dev/null +++ b/go/internal/thermaltwin/service.go @@ -0,0 +1,344 @@ +package thermaltwin + +import ( + "context" + "log/slog" + "sort" + "sync" + "time" + + "github.com/srcfl/ftw/go/internal/modelstate" +) + +// HeatPowerMetric is the canonical electrical input of a heat pump, the same +// metric the Heating view discovers pumps by (docs/nibe-local.md). +const HeatPowerMetric = "hp_power_w" + +const ( + tick = time.Minute + // A reading older than this is not the zone now. + maxReadingAge = 5 * time.Minute + // A window needs most of its minutes; a sensor that reported for ten of + // thirty is not a measurement of the window. + minWindowSamples = 25 + // Temperatures at each end of a window are means of this many readings, + // which is what keeps 0.1 K sensor steps from dominating. + edgeSamples = 3 + stateKeyRoot = "thermaltwin/" +) + +// Zone is one declared temperature sensor the twin learns. +type Zone struct { + // Key identifies the zone across restarts: driver name and metric. + Key string `json:"key"` + SensorDriver string `json:"sensor_driver"` + SensorMetric string `json:"sensor_metric"` +} + +// MetricReader is the part of the telemetry store the service reads. +type MetricReader interface { + LatestMetric(driver, name string) (float64, time.Time, bool) +} + +// ConfigStore is the part of the state store the service persists to. +type ConfigStore interface { + LoadConfig(key string) (string, bool) + SaveConfig(key, value string) error +} + +// Service samples every declared zone once a minute and feeds closed windows +// to its twin. It reads and stores; it commands nothing. +type Service struct { + Store ConfigStore + Tele MetricReader + // Temp is outdoor temperature at a time, the same source the load model + // learns its heating coefficient from. + Temp func(time.Time) (float64, bool) + // Zones lists the declared sensors to learn. Called at start and at each + // window boundary, so a config change is picked up within a window. + Zones func() []Zone + // PowerDrivers lists configured drivers that may report HeatPowerMetric. + PowerDrivers func() []string + + mu sync.Mutex + zones map[string]*zoneState + refreshed time.Time + + stop chan struct{} + done chan struct{} +} + +type reading struct { + at time.Time + v float64 +} + +type zoneState struct { + zone Zone + twin *Twin + start time.Time + tIn []reading + power []float64 + src string + gap bool + // lastEnd is the end of the last observed window, for contiguity. + lastEnd time.Time +} + +// NewService returns a stopped service. +func NewService(store ConfigStore, tele MetricReader) *Service { + return &Service{Store: store, Tele: tele, zones: map[string]*zoneState{}} +} + +// Start runs the sampling loop until Stop or ctx ends. +func (s *Service) Start(ctx context.Context) { + s.stop = make(chan struct{}) + s.done = make(chan struct{}) + go func() { + defer close(s.done) + t := time.NewTicker(tick) + defer t.Stop() + s.Tick(time.Now()) + for { + select { + case <-ctx.Done(): + return + case <-s.stop: + return + case now := <-t.C: + s.Tick(now) + } + } + }() +} + +// Stop ends the loop and waits for it. +func (s *Service) Stop() { + if s.stop == nil { + return + } + close(s.stop) + <-s.done + s.stop = nil +} + +// Tick takes one minute's readings. Exported for tests and for a caller +// that drives time itself. +func (s *Service) Tick(now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + windowStart := now.Truncate(Window) + if !s.refreshed.Equal(windowStart) { + s.refreshZonesLocked() + s.refreshed = windowStart + } + for _, zs := range s.zones { + if !zs.start.Equal(windowStart) { + if !zs.start.IsZero() { + s.closeWindowLocked(zs) + } + zs.start, zs.tIn, zs.power, zs.src, zs.gap = windowStart, nil, nil, "", false + } + s.sampleLocked(zs, now) + } +} + +func (s *Service) refreshZonesLocked() { + if s.Zones == nil { + return + } + want := map[string]Zone{} + for _, z := range s.Zones() { + if z.Key != "" { + want[z.Key] = z + } + } + for key := range s.zones { + if _, ok := want[key]; !ok { + delete(s.zones, key) + } + } + for key, z := range want { + if zs, ok := s.zones[key]; ok { + zs.zone = z + continue + } + s.zones[key] = &zoneState{zone: z, twin: s.restore(key)} + } +} + +func (s *Service) sampleLocked(zs *zoneState, now time.Time) { + v, at, ok := s.read(zs.zone.SensorDriver, zs.zone.SensorMetric, now) + if !ok { + zs.gap = true + return + } + src := s.powerSourceLocked(zs.zone, now) + if len(zs.tIn) > 0 && src != zs.src { + // Heat input appeared, vanished or moved mid-window; the window's + // mean would mix two meanings. + zs.gap = true + } + zs.src = src + if src != "" { + p, _, ok := s.read(src, HeatPowerMetric, now) + if !ok { + zs.gap = true + return + } + zs.power = append(zs.power, p/1000) + } + zs.tIn = append(zs.tIn, reading{at: at, v: v}) +} + +// powerSourceLocked prefers the sensor's own driver, then the single other +// driver reporting heat power. Two candidates is no answer: the twin cannot +// tell which one heats this zone. +func (s *Service) powerSourceLocked(z Zone, now time.Time) string { + if _, _, ok := s.read(z.SensorDriver, HeatPowerMetric, now); ok { + return z.SensorDriver + } + if s.PowerDrivers == nil { + return "" + } + var found []string + for _, d := range s.PowerDrivers() { + if _, _, ok := s.read(d, HeatPowerMetric, now); ok { + found = append(found, d) + } + } + if len(found) == 1 { + return found[0] + } + return "" +} + +func (s *Service) read(driver, metric string, now time.Time) (float64, time.Time, bool) { + if s.Tele == nil || driver == "" { + return 0, time.Time{}, false + } + v, at, ok := s.Tele.LatestMetric(driver, metric) + if !ok || now.Sub(at) > maxReadingAge || at.After(now.Add(time.Minute)) { + return 0, time.Time{}, false + } + return v, at, true +} + +func (s *Service) closeWindowLocked(zs *zoneState) { + end := zs.start.Add(Window) + if zs.gap || len(zs.tIn) < minWindowSamples || (zs.src != "" && len(zs.power) != len(zs.tIn)) { + return + } + if s.Temp == nil { + return + } + tOut, ok := s.Temp(zs.start.Add(Window / 2)) + if !ok { + return + } + startC, startAt := edgeMean(zs.tIn[:edgeSamples]) + endC, endAt := edgeMean(zs.tIn[len(zs.tIn)-edgeSamples:]) + var powerKW float64 + for _, p := range zs.power { + powerKW += p + } + if len(zs.power) > 0 { + powerKW /= float64(len(zs.power)) + } + + if zs.src != "" && zs.twin.PowerSource != zs.src { + if zs.twin.Samples > 0 { + slog.Info("thermal twin restarts: heat input source changed", + "zone", zs.zone.Key, "from", zs.twin.PowerSource, "to", zs.src) + zs.twin = New() + } + zs.twin.PowerSource = zs.src + } + learned := zs.twin.Observe(Sample{ + TInStartC: startC, + TInEndC: endC, + TOutC: tOut, + PowerKW: powerKW, + DurationH: endAt.Sub(startAt).Hours(), + Contiguous: zs.lastEnd.Equal(zs.start), + }) + if learned { + zs.lastEnd = end + } + s.persist(zs) +} + +func edgeMean(rs []reading) (float64, time.Time) { + var sum float64 + var at int64 + for _, r := range rs { + sum += r.v + at += r.at.UnixNano() + } + n := int64(len(rs)) + return sum / float64(n), time.Unix(0, at/n) +} + +func (s *Service) restore(key string) *Twin { + t := New() + if s.Store == nil { + return t + } + js, ok := s.Store.LoadConfig(stateKeyRoot + key) + if !ok { + return t + } + var stored Twin + res := modelstate.Unwrap(js, FeatureHash(), "", &stored) + if !res.OK() { + slog.Info("thermal twin cold start", "zone", key, "reason", res.Reason) + return t + } + return &stored +} + +func (s *Service) persist(zs *zoneState) { + if s.Store == nil { + return + } + js, err := modelstate.Wrap(FeatureHash(), zs.twin) + if err == nil { + err = s.Store.SaveConfig(stateKeyRoot+zs.zone.Key, js) + } + if err != nil { + slog.Warn("thermal twin persist", "zone", zs.zone.Key, "err", err) + } +} + +// ZoneStatus is one zone's twin, as the API shows it. +type ZoneStatus struct { + Zone + PowerSource string `json:"power_source,omitempty"` + Status Status `json:"status"` + Params Params `json:"params"` + Samples int64 `json:"samples"` + Rejected int64 `json:"rejected"` + Evaluations int64 `json:"evaluations"` + MAE3hK float64 `json:"mae_3h_k"` +} + +// Snapshot returns every zone's twin, sorted by key. +func (s *Service) Snapshot() []ZoneStatus { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]ZoneStatus, 0, len(s.zones)) + for _, zs := range s.zones { + out = append(out, ZoneStatus{ + Zone: zs.zone, + PowerSource: zs.twin.PowerSource, + Status: zs.twin.Status(), + Params: zs.twin.Params(), + Samples: zs.twin.Samples, + Rejected: zs.twin.Rejected, + Evaluations: zs.twin.Evaluations, + MAE3hK: zs.twin.MAE3hK, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} diff --git a/go/internal/thermaltwin/service_test.go b/go/internal/thermaltwin/service_test.go new file mode 100644 index 000000000..d2e8c81dc --- /dev/null +++ b/go/internal/thermaltwin/service_test.go @@ -0,0 +1,186 @@ +package thermaltwin + +import ( + "strings" + "testing" + "time" +) + +type metric struct { + v float64 + at time.Time +} + +type fakeTele map[string]metric + +func (f fakeTele) LatestMetric(driver, name string) (float64, time.Time, bool) { + m, ok := f[driver+"/"+name] + return m.v, m.at, ok +} + +type fakeStore map[string]string + +func (f fakeStore) LoadConfig(key string) (string, bool) { v, ok := f[key]; return v, ok } +func (f fakeStore) SaveConfig(key, value string) error { f[key] = value; return nil } + +var t0 = time.Date(2026, 11, 10, 6, 0, 0, 0, time.UTC) + +var livingRoom = Zone{Key: "wiser/room_temp_c", SensorDriver: "wiser", SensorMetric: "room_temp_c"} + +func newTestService(tele fakeTele, store fakeStore, zones ...Zone) *Service { + s := NewService(store, tele) + s.Temp = func(time.Time) (float64, bool) { return 0, true } + s.Zones = func() []Zone { return zones } + s.PowerDrivers = func() []string { return []string{"wiser", "heishamon", "nibe"} } + return s +} + +// run ticks once a minute for the given minutes, letting update set this +// minute's readings first. +func run(s *Service, from time.Time, minutes int, update func(now time.Time)) time.Time { + now := from + for i := 0; i < minutes; i++ { + update(now) + s.Tick(now) + now = now.Add(time.Minute) + } + return now +} + +func zoneStatus(t *testing.T, s *Service, key string) ZoneStatus { + t.Helper() + for _, z := range s.Snapshot() { + if z.Key == key { + return z + } + } + t.Fatalf("zone %s not in snapshot %+v", key, s.Snapshot()) + return ZoneStatus{} +} + +func TestServiceLearnsAndPersistsAZone(t *testing.T) { + tele, store := fakeTele{}, fakeStore{} + s := newTestService(tele, store, livingRoom) + h := &house{a: 1.0 / 40, b: 1.0 / 15, c: 0.05, tIn: 20} + + // Three whole windows plus the minute that closes the third. + run(s, t0, 3*30+1, func(now time.Time) { + h.step(0, 2, 1.0/60) + tele["wiser/room_temp_c"] = metric{h.tIn, now} + tele["heishamon/hp_power_w"] = metric{2000, now} + }) + + z := zoneStatus(t, s, livingRoom.Key) + if z.Samples != 3 || z.Rejected != 0 { + t.Fatalf("samples = %d, rejected = %d, want 3 and 0", z.Samples, z.Rejected) + } + if z.PowerSource != "heishamon" { + t.Errorf("power source = %q, want the single driver reporting %s", z.PowerSource, HeatPowerMetric) + } + if z.Status != StatusLearning { + t.Errorf("status = %s, want learning", z.Status) + } + + js, ok := store[stateKeyRoot+livingRoom.Key] + if !ok || !strings.Contains(js, FeatureHash()) { + t.Fatalf("persisted state = %q", js) + } + restored := newTestService(fakeTele{}, store, livingRoom) + restored.Tick(t0.Add(10 * time.Hour)) + if got := zoneStatus(t, restored, livingRoom.Key); got.Samples != 3 || got.PowerSource != "heishamon" { + t.Errorf("restored = %+v, want the persisted twin", got) + } +} + +// State fitted to another form of the model cold starts instead of +// restoring coefficients that mean something else. +func TestServiceColdStartsOnForeignState(t *testing.T) { + store := fakeStore{stateKeyRoot + livingRoom.Key: `{"schema_version":1,"feature_hash":"0000000000000000","model":{"samples":999}}`} + s := newTestService(fakeTele{}, store, livingRoom) + s.Tick(t0) + if got := zoneStatus(t, s, livingRoom.Key); got.Samples != 0 { + t.Errorf("samples = %d, want a cold start", got.Samples) + } +} + +// A sensor that stops reporting for part of a window yields no sample: an +// old reading is not the zone now. +func TestServiceSkipsAWindowWithAStaleSensor(t *testing.T) { + tele := fakeTele{} + s := newTestService(tele, fakeStore{}, livingRoom) + run(s, t0, 31, func(now time.Time) { + at := now + if now.Sub(t0) >= 10*time.Minute { + at = t0.Add(10 * time.Minute) // frozen from minute ten + } + tele["wiser/room_temp_c"] = metric{20, at} + tele["heishamon/hp_power_w"] = metric{1500, now} + }) + if got := zoneStatus(t, s, livingRoom.Key); got.Samples != 0 { + t.Errorf("samples = %d, want 0 for a window with a stale sensor", got.Samples) + } +} + +// Two drivers reporting heat power and neither the sensor's own: the twin +// cannot tell which heats this zone, so it learns without heat input and +// says so, rather than guess. +func TestServiceRefusesAnAmbiguousPowerSource(t *testing.T) { + tele := fakeTele{} + s := newTestService(tele, fakeStore{}, livingRoom) + run(s, t0, 31, func(now time.Time) { + tele["wiser/room_temp_c"] = metric{20, now} + tele["heishamon/hp_power_w"] = metric{1500, now} + tele["nibe/hp_power_w"] = metric{900, now} + }) + z := zoneStatus(t, s, livingRoom.Key) + if z.PowerSource != "" || z.Status != StatusNoHeatInput { + t.Errorf("zone = %+v, want no power source and no_heat_input", z) + } +} + +// The sensor's own driver reporting heat power wins over any other. +func TestServicePrefersTheSensorsOwnPower(t *testing.T) { + tele := fakeTele{} + pump := Zone{Key: "nibe/hp_indoor_temp_c", SensorDriver: "nibe", SensorMetric: "hp_indoor_temp_c"} + s := newTestService(tele, fakeStore{}, pump) + run(s, t0, 31, func(now time.Time) { + tele["nibe/hp_indoor_temp_c"] = metric{20, now} + tele["nibe/hp_power_w"] = metric{900, now} + tele["heishamon/hp_power_w"] = metric{1500, now} + }) + if z := zoneStatus(t, s, pump.Key); z.PowerSource != "nibe" { + t.Errorf("power source = %q, want nibe", z.PowerSource) + } +} + +func TestServiceDropsARemovedZone(t *testing.T) { + tele := fakeTele{} + zones := []Zone{livingRoom} + s := NewService(fakeStore{}, tele) + s.Temp = func(time.Time) (float64, bool) { return 0, true } + s.Zones = func() []Zone { return zones } + s.Tick(t0) + if len(s.Snapshot()) != 1 { + t.Fatalf("snapshot = %+v", s.Snapshot()) + } + zones = nil + s.Tick(t0.Add(Window)) + if got := s.Snapshot(); len(got) != 0 { + t.Errorf("snapshot = %+v after the zone was removed", got) + } +} + +// Without an outdoor temperature a window teaches nothing true, so it is +// skipped rather than learned against a guessed zero. +func TestServiceNeedsOutdoorTemperature(t *testing.T) { + tele := fakeTele{} + s := newTestService(tele, fakeStore{}, livingRoom) + s.Temp = func(time.Time) (float64, bool) { return 0, false } + run(s, t0, 31, func(now time.Time) { + tele["wiser/room_temp_c"] = metric{20, now} + tele["heishamon/hp_power_w"] = metric{1500, now} + }) + if got := zoneStatus(t, s, livingRoom.Key); got.Samples != 0 { + t.Errorf("samples = %d without outdoor temperature, want 0", got.Samples) + } +} diff --git a/go/test/site/runner/runner.go b/go/test/site/runner/runner.go index 955fab08b..821a8f3fc 100644 --- a/go/test/site/runner/runner.go +++ b/go/test/site/runner/runner.go @@ -28,6 +28,7 @@ var watchedPaths = []string{ "go/internal/units/", // charge steps "go/internal/localzone/", // household clock: goal deadlines, import hour "go/internal/cars/", "go/internal/sitepower/", + "go/internal/thermal/", // control heat effects, imported by drivers "go/internal/events/", "go/internal/state/", "drivers/", "optimizer/native/bundle/", // bundled drivers and solver "go/test/site/",