Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/versioned-learned-model-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Learned PV and load state is now stored with a fingerprint of the feature vector it was fitted against, and is discarded when that fingerprint no longer matches. Coefficients only mean something against the features that produced them: change the number of time-of-day harmonics, the bucket a moment maps to, or what clear-sky irradiance refers to, and the old model keeps predicting — with a plausible-looking error — from a fit that describes a different world. Nothing in the stored numbers gave that away before. The fingerprint is derived from the feature functions themselves, so a code change to the features moves it without anyone having to remember. On a mismatch the model logs both fingerprints and cold-starts, which both models recover from; wrong coefficients they do not recover from. Existing state is carried over unchanged on upgrade.
77 changes: 68 additions & 9 deletions go/internal/loadmodel/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ package loadmodel

import (
"math"
"sync"
"time"

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

// Buckets is the number of hour-of-week buckets: 7 days × 24 hours.
Expand Down Expand Up @@ -217,6 +220,69 @@ func HourOfWeek(t time.Time) int {
return wd*24 + u.Hour()
}

// heatingGain is the load a learned slope predicts at an outdoor
// temperature: linear in the shortfall below the reference, zero above it.
// One definition, used by both Predict and Update and probed by
// featureProbe — HeatingW_per_degC means nothing except against this shape.
func heatingGain(coefWPerDegC, tempC float64) float64 {
if tempC >= HeatingReferenceC {
return 0
}
return coefWPerDegC * (HeatingReferenceC - tempC)
}

// featureSemantics declares what the numbers this model learns from mean. It
// is the half of the fingerprint a probe cannot derive: change what the
// sampler subtracts before calling Update — stop netting out the EV, say —
// and every bucket mean is a measurement of something else, while nothing in
// the model's own code has moved.
//
// CHANGE THIS STRING in the commit that changes what a caller feeds in.
// Changes to the bucket indexing or the heating shape need no edit here —
// featureProbe moves the fingerprint on its own.
const featureSemantics = "loadmodel/1 load=site_w_less_pv_bat_ev_v2x temp=outdoor_c target=house_w"

// featureProbe pins the two things whose change would invalidate stored
// coefficients: which bucket a moment maps to, and the shape the heating
// slope is measured against.
//
// The instants are given in a non-UTC zone and sit near midnight on purpose.
// Drop the UTC coercion in HourOfWeek and both the hour and the weekday move
// for those — which is precisely the defect commit 3255deba fixed, the one
// that silently misaligned every learned bucket across a DST change.
//
// Deliberately absent: typicalPrior. A bucket mean is measured watts and stays
// meaningful when the prior it started from is retuned; the prior only sets
// the fallback for buckets nobody has observed yet. Discarding months of
// learned buckets over a prior tweak would cost more than it protects.
func featureProbe() []float64 {
out := []float64{float64(Buckets), HeatingReferenceC}
zone := time.FixedZone("probe", 2*60*60)
for _, t := range []time.Time{
time.Date(2024, 1, 1, 0, 30, 0, 0, time.UTC),
time.Date(2024, 3, 31, 1, 30, 0, 0, zone),
time.Date(2024, 6, 21, 23, 45, 0, 0, zone),
time.Date(2024, 10, 27, 0, 15, 0, 0, zone),
time.Date(2024, 12, 24, 18, 0, 0, 0, time.UTC),
} {
out = append(out, float64(HourOfWeek(t)))
}
for _, tempC := range []float64{-20, -3, 0, 10, 17.5, 18, 25} {
out = append(out, heatingGain(1, tempC))
}
return out
}

var featureHash = sync.OnceValue(func() string {
return modelstate.Fingerprint(featureSemantics, featureProbe())
})

// FeatureHash fingerprints the feature space the bucket means and the heating
// slope are fitted against. Stored state is only restored when its recorded
// hash matches this one; see internal/modelstate for why, and service.go for
// what happens when it does not.
func FeatureHash() string { return featureHash() }

// Predict returns the expected load (W, non-negative) at time t with
// outdoor temperature tempC (0 if unknown). Blends per-bucket EMA with
// the typical prior by sample count, then adds the heating correction.
Expand All @@ -229,11 +295,7 @@ func (m Model) Predict(t time.Time, tempC float64) float64 {
}
prior := m.prior(idx)
base := trust*b.Mean + (1-trust)*prior
heating := 0.0
if tempC < HeatingReferenceC {
heating = m.HeatingW_per_degC * (HeatingReferenceC - tempC)
}
y := base + heating
y := base + heatingGain(m.HeatingW_per_degC, tempC)
if y < 0 {
return 0
}
Expand Down Expand Up @@ -321,10 +383,7 @@ func (m *Model) Update(t time.Time, actualLoadW, tempC float64) (updated bool) {
// though a real baseline — fridge, server, standby — always exists).
// Instead, skip the bucket update entirely for this sample and let
// existing Samples + Mean stand. Global Samples and MAE still update.
heatEst := 0.0
if tempC < HeatingReferenceC {
heatEst = m.HeatingW_per_degC * (HeatingReferenceC - tempC)
}
heatEst := heatingGain(m.HeatingW_per_degC, tempC)
if heatEst < actualLoadW {
baseSample := actualLoadW - heatEst
if b.Samples < 10 {
Expand Down
242 changes: 242 additions & 0 deletions go/internal/loadmodel/persistence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
package loadmodel

import (
"encoding/json"
"path/filepath"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/modelstate"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
)

func openTestDB(t *testing.T) *state.Store {
t.Helper()
st, err := state.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatalf("open state: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}

// trainedModel returns a model with a recognisable amount of learning in it.
func trainedModel(t *testing.T) *Model {
t.Helper()
m := newProfileModel(4000, ProfileHome)
m.HeatingW_per_degC = 275
t0 := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
for i := 0; i < 12; i++ {
m.Update(t0.AddDate(0, 0, 7*i), 2400, HeatingReferenceC)
}
if m.Samples != 12 {
t.Fatalf("fixture did not train: samples = %d", m.Samples)
}
return m
}

// requireLegacyAdoption skips a case that depends on pre-envelope state still
// being adopted. Once the features move, that state is correctly discarded and
// the case stops existing — the migration and the tests that cover it retire
// together, so a feature change never leaves a test demanding the old
// behaviour back.
func requireLegacyAdoption(t *testing.T) {
t.Helper()
if FeatureHash() != legacyFeatureHash {
t.Skip("features have moved on; unversioned state is discarded, as designed")
}
}

// TestFeatureHashPinned is a tripwire, not a rule. Changing the bucket
// indexing or the heating shape is allowed, but it makes every deployed site
// discard its learned week — and unlike the PV twin, which relearns in an
// afternoon, bucket coverage is rebuilt over weeks. That should be a
// decision, not a surprise found in production.
//
// If this test fails: confirm the change is intended, update the literal
// below, and say in the changeset that the load model cold-starts on upgrade.
// Do NOT touch legacyFeatureHash in service.go — that constant is frozen on
// purpose, and moving it would restore pre-envelope coefficients under the
// new features, which is the exact fault this guards against.
func TestFeatureHashPinned(t *testing.T) {
const want = "f79385ff0412d66b"
if got := FeatureHash(); got != want {
t.Errorf("load feature hash = %q, pinned at %q\n"+
"the feature definition changed: every deployed site will cold-start", got, want)
}
}

// The hash must move on its own when the model's definitions do, so nobody
// has to remember to bump a constant.
func TestFeatureHashTracksTheFeatureDefinition(t *testing.T) {
base := modelstate.Fingerprint(featureSemantics, featureProbe())
if p := featureProbe(); p[0] != float64(Buckets) || p[1] != HeatingReferenceC {
t.Fatalf("featureProbe layout changed; update this test: %v", p[:2])
}

// Bucket indexing moving off UTC — the 3255deba regression, which
// silently misaligned every learned bucket across a DST change.
shifted := featureProbe()
shifted[2]++ // the first HourOfWeek probe value
if modelstate.Fingerprint(featureSemantics, shifted) == base {
t.Error("a change to bucket indexing must move the hash")
}

// The heating shape the slope is measured against.
steeper := featureProbe()
steeper[1] = HeatingReferenceC + 2
if modelstate.Fingerprint(featureSemantics, steeper) == base {
t.Error("a change to the heating reference must move the hash")
}

// The semantics half: same math, different meaning for the sampled load.
if modelstate.Fingerprint("loadmodel/1 load=site_w_less_pv_bat", featureProbe()) == base {
t.Error("redeclaring what an input means must move the hash")
}
}

// Retuning typicalPrior must NOT cold-start a site. A bucket mean is measured
// watts and stays meaningful when the prior it started from changes; the
// prior only supplies the fallback for buckets nobody has observed. Weighed
// against weeks of lost coverage, that trade is deliberate — see featureProbe.
func TestPriorIsNotPartOfTheFeatureIdentity(t *testing.T) {
probe := featureProbe()
for bucket := 0; bucket < Buckets; bucket++ {
p := typicalPrior(bucket)
for i, v := range probe {
if v == p {
t.Fatalf("probe[%d] carries typicalPrior(%d) = %v: retuning the "+
"prior would cold-start every site for no safety gain", i, bucket, p)
}
}
}
}

func TestPersistedStateRoundTrips(t *testing.T) {
st := openTestDB(t)

s := NewService(st, telemetry.NewStore(), "site", 4000, 17250)
s.mu.Lock()
s.models[ProfileHome] = trainedModel(t)
s.mu.Unlock()
if err := s.persist(); err != nil {
t.Fatalf("persist: %v", err)
}

restored := NewService(st, telemetry.NewStore(), "site", 4000, 17250).Model()
if restored.Samples != 12 {
t.Fatalf("samples = %d, want 12: matching feature hash must restore", restored.Samples)
}
if restored.HeatingW_per_degC != 275 {
t.Errorf("heating slope = %v, want 275", restored.HeatingW_per_degC)
}
}

// The bug this PR exists for: bucket means and a heating slope fitted against
// one feature definition must not be restored into a build that computes
// another. They parse, they look sane, and they steer the plan.
func TestStateFittedAgainstOtherFeaturesColdStarts(t *testing.T) {
st := openTestDB(t)

js, err := modelstate.Wrap("0000badfeature00", trainedModel(t))
if err != nil {
t.Fatal(err)
}
if err := st.SaveConfig(stateKey(ProfileHome), js); err != nil {
t.Fatal(err)
}

got := NewService(st, telemetry.NewStore(), "site", 4000, 17250).Model()

if got.Samples != 0 {
t.Errorf("samples = %d, want 0: stale coefficients must not survive a feature change", got.Samples)
}
if got.HeatingW_per_degC != 0 {
t.Errorf("heating slope = %v, want 0 on a cold start", got.HeatingW_per_degC)
}
}

// State written before the envelope existed is adopted, on both the
// per-profile key and the pre-profile one. Upgrading must not cost every site
// its learned week.
func TestUnversionedStateIsAdopted(t *testing.T) {
requireLegacyAdoption(t)
for name, key := range map[string]string{
"per-profile key": stateKey(ProfileHome),
"pre-profile key": legacyStateKey,
} {
t.Run(name, func(t *testing.T) {
st := openTestDB(t)
bare, err := json.Marshal(trainedModel(t)) // pre-envelope on-disk shape
if err != nil {
t.Fatal(err)
}
if err := st.SaveConfig(key, string(bare)); err != nil {
t.Fatal(err)
}

got := NewService(st, telemetry.NewStore(), "site", 4000, 17250).Model()

if got.Samples != 12 {
t.Fatalf("samples = %d, want 12: unversioned state should be adopted", got.Samples)
}
if got.PeakW != 4000 || got.MaxPlausibleW != 17250 {
t.Errorf("config-owned fields not reapplied: peak %v, max %v", got.PeakW, got.MaxPlausibleW)
}
})
}
}

// The pre-profile key is the oldest state on any box. It now runs the same
// bucket repair the per-profile path has always run, so a model poisoned by
// the pre-guard heating-subtraction bug is repaired whichever key it sits in.
func TestLegacyKeyGetsTheBucketRepair(t *testing.T) {
requireLegacyAdoption(t)
st := openTestDB(t)

poisoned := trainedModel(t)
poisoned.Bucket[0].Mean = 15 // prior for bucket 0 is ~300 W
poisoned.Bucket[0].Samples = 400
bare, err := json.Marshal(poisoned)
if err != nil {
t.Fatal(err)
}
if err := st.SaveConfig(legacyStateKey, string(bare)); err != nil {
t.Fatal(err)
}

got := NewService(st, telemetry.NewStore(), "site", 4000, 17250).Model()

if got.Bucket[0].Mean != got.prior(0) {
t.Errorf("bucket 0 mean = %.1f, want the prior %.1f", got.Bucket[0].Mean, got.prior(0))
}
}

// A damaged blob on the boot path of a control system cold-starts. It does
// not panic, and it does not leave a half-decoded model behind.
func TestCorruptStateColdStarts(t *testing.T) {
for name, js := range map[string]string{
"truncated envelope": `{"schema_version":1,"feature_hash":"f79385ff0412d66b","model":{"samples":`,
"not json": "\x00\x01\x02 not json",
"wrong shape": `[1,2,3]`,
"no model": `{"schema_version":1,"feature_hash":"f79385ff0412d66b"}`,
"future schema": `{"schema_version":99,"feature_hash":"f79385ff0412d66b","model":{"samples":900,"alpha":0.1}}`,
"half-decoded": `{"bucket":"not-an-array","samples":900,"alpha":0.1}`,
"no alpha": `{"samples":900}`,
} {
t.Run(name, func(t *testing.T) {
st := openTestDB(t)
if err := st.SaveConfig(stateKey(ProfileHome), js); err != nil {
t.Fatal(err)
}
got := NewService(st, telemetry.NewStore(), "site", 4000, 17250).Model()
if got.Samples != 0 {
t.Errorf("samples = %d, want 0 (cold start)", got.Samples)
}
if got.Alpha != newProfileModel(4000, ProfileHome).Alpha {
t.Errorf("alpha = %v, want the cold-start value", got.Alpha)
}
})
}
}
Loading