diff --git a/.changeset/ev-inferred-site-headroom.md b/.changeset/ev-inferred-site-headroom.md new file mode 100644 index 000000000..7908aacd7 --- /dev/null +++ b/.changeset/ev-inferred-site-headroom.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Explain EV under-delivery with a conditional site-headroom projection from fresh three-phase measurements with confirmed import on every phase, keeping device-reported reasons distinct. diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index 03265872c..a56b525e2 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -32,6 +32,7 @@ type ControlFeedback struct { SiteConfirmation string `json:"site_confirmation"` SiteMeter string `json:"site_meter,omitempty"` SiteEvidence *ControlComparison `json:"site_evidence,omitempty"` + SiteHeadroom *EVSiteHeadroom `json:"site_headroom,omitempty"` ToleranceW *float64 `json:"tolerance_w"` // Internal steps of the verdict; the API exposes their result above. State string `json:"-"` @@ -280,6 +281,7 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback { f.Reason = "readings_lost" } f.ReadingsFresh = fresh + f.SiteHeadroom = s.evSiteHeadroom(f, lps[rd.Driver], meter, now) out = append(out, f) } } diff --git a/go/internal/api/api_ev_headroom.go b/go/internal/api/api_ev_headroom.go new file mode 100644 index 000000000..2976f6eef --- /dev/null +++ b/go/internal/api/api_ev_headroom.go @@ -0,0 +1,109 @@ +package api + +import ( + "encoding/json" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" + "math" + "time" +) + +// EVSiteHeadroom is a conditional projection, never a charger diagnosis or +// dispatch input. Current site loads are held constant in the calculation. +type EVSiteHeadroom struct { + Evidence string `json:"evidence"` + FuseA float64 `json:"fuse_a"` + MarginA float64 `json:"margin_a"` + ProjectedMaxA float64 `json:"projected_max_a"` + BatteryChargeW float64 `json:"battery_charge_w"` +} + +func projectEVHeadroom(f ControlFeedback, phases int, voltage float64, fuse config.Fuse, currents, powers []float64, meterAt, now time.Time) *EVSiteHeadroom { + // Only balanced, explicitly three-phase charging is supported. Single-phase + // and automatic switching need a verified phase mapping, not an assumed L1. + if f.Kind != "ev" || f.Reason != "power_below_target" || !f.ReadingsFresh || + f.SentW == nil || f.ActualW == nil || *f.ActualW < 100 || phases != 3 || fuse.Phases != 3 || + len(currents) != 3 || len(powers) != 3 || voltage <= 0 || fuse.MaxAmps <= 0 || math.IsNaN(voltage) || math.IsInf(voltage, 0) { + return nil + } + age := now.Sub(meterAt) + skew := meterAt.Sub(time.UnixMilli(f.ObservedAtMs)) + if age < 0 || age > 15*time.Second || skew < -10*time.Second || skew > 10*time.Second { + return nil + } + gap := *f.SentW - *f.ActualW + if math.IsNaN(gap) || math.IsInf(gap, 0) || gap <= telemetry.ControlToleranceW(*f.SentW) { + return nil + } + margin := fuse.EffectiveSafetyMarginA() + if math.IsNaN(margin) || math.IsInf(margin, 0) || margin < 0 || margin >= fuse.MaxAmps { + return nil + } + worst := 0.0 + for i, a := range currents { + // Meters may report current magnitudes during export. Signed power must + // establish import on every phase before adding the missing EV load. + if math.IsNaN(a) || math.IsInf(a, 0) || a < 0 || + math.IsNaN(powers[i]) || math.IsInf(powers[i], 0) || powers[i] <= 0 { + return nil + } + worst = math.Max(worst, a+gap/(3*voltage)) + } + if worst <= fuse.MaxAmps-margin+0.5 { + return nil + } + return &EVSiteHeadroom{Evidence: "calculated", FuseA: fuse.MaxAmps, MarginA: margin, ProjectedMaxA: worst} +} + +func (s *Server) evSiteHeadroom(f ControlFeedback, lp loadpoint.State, meter string, now time.Time) *EVSiteHeadroom { + if s.deps.Cfg == nil || s.deps.CfgMu == nil || s.deps.Tel == nil || meter == "" { + return nil + } + s.deps.CfgMu.RLock() + fuse := s.deps.Cfg.Fuse + explicit3P := false + for _, c := range s.deps.Cfg.Loadpoints { + if c.DriverName == f.Driver && c.PhaseMode == "3p" { + explicit3P = true + } + } + s.deps.CfgMu.RUnlock() + if !explicit3P { + return nil + } + rd := s.deps.Tel.Get(meter, telemetry.DerMeter) + h := s.deps.Tel.DriverHealth(meter) + if rd == nil || h == nil || !h.TelemetryLive() || h.DeviceFault { + return nil + } + var phase struct { + L1 *float64 `json:"l1_a"` + L2 *float64 `json:"l2_a"` + L3 *float64 `json:"l3_a"` + W1 *float64 `json:"l1_w"` + W2 *float64 `json:"l2_w"` + W3 *float64 `json:"l3_w"` + } + if json.Unmarshal(rd.Data, &phase) != nil || phase.L1 == nil || phase.L2 == nil || phase.L3 == nil || + phase.W1 == nil || phase.W2 == nil || phase.W3 == nil { + return nil + } + result := projectEVHeadroom(f, lp.Phases, lp.VoltageV, fuse, + []float64{*phase.L1, *phase.L2, *phase.L3}, []float64{*phase.W1, *phase.W2, *phase.W3}, rd.UpdatedAt, now) + if result == nil { + return nil + } + for _, b := range s.deps.Tel.ReadingsByType(telemetry.DerBattery) { + bh := s.deps.Tel.DriverHealth(b.Driver) + if bh == nil || !bh.TelemetryLive() || bh.DeviceFault { + continue + } + obs, known := telemetry.ControlPowerObservation(b.RawW, b.Data, b.UpdatedAt) + age, skew := now.Sub(obs.Seen()), rd.UpdatedAt.Sub(obs.Seen()) + if known && age >= 0 && age <= 15*time.Second && skew >= -10*time.Second && skew <= 10*time.Second && obs.PowerW > 100 { + result.BatteryChargeW += obs.PowerW + } + } + return result +} diff --git a/go/internal/api/api_ev_headroom_test.go b/go/internal/api/api_ev_headroom_test.go new file mode 100644 index 000000000..44fe58731 --- /dev/null +++ b/go/internal/api/api_ev_headroom_test.go @@ -0,0 +1,114 @@ +package api + +import ( + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" + "math" + "sync" + "testing" + "time" +) + +func TestEVSiteHeadroomProjection(t *testing.T) { + now := time.Now() + for _, tc := range []struct { + name string + modify func(*ControlFeedback, *config.Fuse, *[]float64, *int, *time.Time) + want bool + }{ + {"actual household case", nil, true}, + {"ample headroom", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { *a = []float64{8, 9, 8} }, false}, + {"asymmetric busiest phase", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { + *a = []float64{10, 21, 10} + }, true}, + {"stale meter", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { + *at = now.Add(-16 * time.Second) + }, false}, + {"future meter", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { + *at = now.Add(time.Second) + }, false}, + {"misaligned EV", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { + f.ObservedAtMs = now.Add(-11 * time.Second).UnixMilli() + }, false}, + {"stale EV", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { f.ReadingsFresh = false }, false}, + {"single phase mapping unknown", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { *p = 1 }, false}, + {"missing phase", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { *a = []float64{21, 20} }, false}, + {"nonfinite phase", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { (*a)[0] = math.NaN() }, false}, + {"mixed export", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { (*a)[0] = -3 }, false}, + {"failed command precedence", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { + f.Reason = "command_failed" + }, false}, + {"device reason precedence", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { + f.Reason = "load_balancer_limit" + }, false}, + {"zero delivery not explained", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { f.ActualW = watts(0) }, false}, + {"invalid safety margin", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { + c.SafetyMarginA = watts(26) + }, false}, + {"safety margin binds", func(f *ControlFeedback, c *config.Fuse, a *[]float64, p *int, at *time.Time) { + c.SafetyMarginA = watts(2) + *a = []float64{15, 15, 15} + }, true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := ControlFeedback{Kind: "ev", Reason: "power_below_target", ReadingsFresh: true, SentW: watts(10350), ActualW: watts(4155), ObservedAtMs: now.UnixMilli()} + c := config.Fuse{MaxAmps: 25, Phases: 3, Voltage: 230} + a := []float64{19.65, 20.52, 19.46} + p := 3 + at := now + if tc.modify != nil { + tc.modify(&f, &c, &a, &p, &at) + } + got := projectEVHeadroom(f, p, 230, c, a, []float64{4519.5, 4719.6, 4475.8}, at, now) + if (got != nil) != tc.want { + t.Fatalf("projection=%+v want present=%v", got, tc.want) + } + if got != nil && tc.name == "actual household case" && math.Abs(got.ProjectedMaxA-29.498) > 0.01 { + t.Fatalf("unexpected projection %+v", got) + } + }) + } +} + +func TestEVSiteHeadroomServerContext(t *testing.T) { + tel := telemetry.NewStore() + tel.Update("meter", telemetry.DerMeter, 13843, nil, []byte(`{"l1_a":19.65,"l2_a":20.52,"l3_a":19.46,"l1_w":4519.5,"l2_w":4719.6,"l3_w":4475.8}`)) + tel.Update("battery", telemetry.DerBattery, 9383, nil, nil) + tel.DriverHealthMut("meter").RecordSuccess() + tel.DriverHealthMut("battery").RecordSuccess() + cfg := &config.Config{Fuse: config.Fuse{MaxAmps: 25, Phases: 3, Voltage: 230}, Loadpoints: []config.Loadpoint{{DriverName: "ev", PhaseMode: "3p"}}} + srv := New(&Deps{Tel: tel, Cfg: cfg, CfgMu: &sync.RWMutex{}}) + now := time.Now() + f := ControlFeedback{Driver: "ev", Kind: "ev", Reason: "power_below_target", ReadingsFresh: true, SentW: watts(10350), ActualW: watts(4155), ObservedAtMs: now.UnixMilli()} + lp := loadpoint.State{Phases: 3, VoltageV: 230} + got := srv.evSiteHeadroom(f, lp, "meter", now) + if got == nil || got.BatteryChargeW != 9383 { + t.Fatalf("missing site/battery context %+v", got) + } + tel.DriverHealthMut("battery").SetDeviceFault(true, "test") + got = srv.evSiteHeadroom(f, lp, "meter", now) + if got == nil || got.BatteryChargeW != 0 { + t.Fatalf("faulted battery contributed %+v", got) + } + cfg.Loadpoints[0].PhaseMode = "auto" + if got = srv.evSiteHeadroom(f, lp, "meter", now); got != nil { + t.Fatalf("unknown phase mapping %+v", got) + } + cfg.Loadpoints[0].PhaseMode = "3p" + tel.DriverHealthMut("meter").SetDeviceFault(true, "test") + if got = srv.evSiteHeadroom(f, lp, "meter", now); got != nil { + t.Fatalf("faulted meter contributed %+v", got) + } +} + +func TestEVSiteHeadroomRejectsInvalidPhasePower(t *testing.T) { + now := time.Now() + f := ControlFeedback{Kind: "ev", Reason: "power_below_target", ReadingsFresh: true, SentW: watts(10350), ActualW: watts(4155), ObservedAtMs: now.UnixMilli()} + fuse := config.Fuse{MaxAmps: 25, Phases: 3, Voltage: 230} + for _, powers := range [][]float64{nil, {4600, 4600}, {math.NaN(), 4600, 4600}, {4600, math.Inf(1), 4600}, {4600, 4600, math.Inf(-1)}} { + if got := projectEVHeadroom(f, 3, 230, fuse, []float64{20, 20, 20}, powers, now, now); got != nil { + t.Fatalf("invalid phase power produced explanation: %+v", got) + } + } +} diff --git a/go/internal/api/ev_headroom_export_regression_test.go b/go/internal/api/ev_headroom_export_regression_test.go new file mode 100644 index 000000000..f5de0722c --- /dev/null +++ b/go/internal/api/ev_headroom_export_regression_test.go @@ -0,0 +1,40 @@ +package api + +import ( + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestEVHeadroomRequiresPhaseImportDirection(t *testing.T) { + for _, tc := range []struct { + name string + grid float64 + data string + }{ + {"missing phase power", 13800, `{"l1_a":20,"l2_a":20,"l3_a":20}`}, + {"missing one phase power", 13800, `{"l1_a":20,"l2_a":20,"l3_a":20,"l1_w":4600,"l2_w":4600}`}, + {"null phase power", 13800, `{"l1_a":20,"l2_a":20,"l3_a":20,"l1_w":null,"l2_w":4600,"l3_w":4600}`}, + {"zero phase power leaves direction unknown", 9200, `{"l1_a":20,"l2_a":20,"l3_a":20,"l1_w":0,"l2_w":4600,"l3_w":4600}`}, + {"all phases export with magnitude currents", -13800, `{"l1_a":20,"l2_a":20,"l3_a":20,"l1_w":-4600,"l2_w":-4600,"l3_w":-4600}`}, + {"mixed flow with net import and magnitude currents", 920, `{"l1_a":20,"l2_a":12,"l3_a":12,"l1_w":-4600,"l2_w":2760,"l3_w":2760}`}, + } { + t.Run(tc.name, func(t *testing.T) { + tel := telemetry.NewStore() + tel.Update("meter", telemetry.DerMeter, tc.grid, nil, []byte(tc.data)) + tel.DriverHealthMut("meter").RecordSuccess() + cfg := &config.Config{Fuse: config.Fuse{MaxAmps: 25, Phases: 3, Voltage: 230}, Loadpoints: []config.Loadpoint{{DriverName: "ev", PhaseMode: "3p"}}} + srv := New(&Deps{Tel: tel, Cfg: cfg, CfgMu: &sync.RWMutex{}}) + now := time.Now() + f := ControlFeedback{Driver: "ev", Kind: "ev", Reason: "power_below_target", ReadingsFresh: true, SentW: watts(10350), ActualW: watts(4155), ObservedAtMs: now.UnixMilli()} + got := srv.evSiteHeadroom(f, loadpoint.State{Phases: 3, VoltageV: 230}, "meter", now) + if got != nil { + t.Fatalf("unqualified phase direction produced an import-headroom explanation: %+v", got) + } + }) + } +} diff --git a/web/control-feedback.js b/web/control-feedback.js index dcdbbfed0..d66659ebe 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -153,6 +153,13 @@ function proofLine(row) { // can act, the next step. Tone follows Core's severity and status. export function controlStatus(row, live = true) { if (!live) return {title: 'Status not current', text: 'FTW has not reported for a while.', proof: '', next: 'Waiting for a fresh report.', tone: 'stale'}; + const headroom = row.kind === 'ev' && row.reason === 'power_below_target' && row.readings_fresh && row.site_headroom?.evidence === 'calculated' ? row.site_headroom : null; + if (headroom && [headroom.fuse_a, headroom.margin_a, headroom.projected_max_a, headroom.battery_charge_w].every(num)) { + const battery = headroom.battery_charge_w > 100 ? ` The home battery is charging at ${controlPower(headroom.battery_charge_w)}.` : ''; + return {title: 'Insufficient site headroom', text: sentence(row) + battery, + next: `With other loads unchanged, the requested EV power would raise the busiest phase to about ${Math.round(headroom.projected_max_a)} A, above the ${headroom.fuse_a} A fuse budget (${headroom.margin_a} A safety margin).`, + proof: 'Calculated from site measurements. The charger has not reported a limiting reason.', tone: 'warning'}; + } const title = REASON_TITLES[row.reason] || STATUS_TITLES[row.status] || 'Checking'; const tone = row.severity === 'alarm' ? 'alarm' : row.severity === 'warning' ? 'warning' : row.status === 'following' ? 'ok' : 'neutral'; return {title, text: sentence(row), proof: proofLine(row), next: nextStep(row), tone}; diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index 317df0962..92ae56fb0 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -149,6 +149,22 @@ test('the summary answers the question for the whole site', () => { assert.equal(controlSummary([row()], false).tone, 'stale'); }); +test('calculated site headroom explains an EV shortfall without claiming a vendor cause', () => { + const ev = row({kind: 'ev', status: 'not_following', reason: 'power_below_target', severity: 'warning', sent_w: 10350, actual_w: 4155, + site_headroom: {evidence: 'calculated', fuse_a: 25, margin_a: 0.5, projected_max_a: 29.5, battery_charge_w: 9383}}); + const words = controlStatus(ev); + assert.equal(words.title, 'Insufficient site headroom'); + assert.match(words.text, /home battery is charging at 9.4 kW/); + assert.match(words.next, /other loads unchanged/); + assert.match(words.next, /25 A/); + assert.match(words.proof, /Calculated.*has not reported/); + assert.equal(controlStatus(ev, false).title, 'Status not current'); + assert.equal(controlStatus({...ev, reason: 'command_failed'}).title, 'Commands failing'); + assert.equal(controlStatus({...ev, readings_fresh: false}).title, 'Not following'); + assert.doesNotMatch(controlStatus({...ev, site_headroom: {...ev.site_headroom, battery_charge_w: 0}}).text, /battery/); + assert.equal(controlStatus({...ev, site_headroom: {...ev.site_headroom, projected_max_a: NaN}}).title, 'Not following'); +}); + test('limited positive EV offer does not explain prolonged zero delivery', () => { const missing=controlStatus(row({kind:'ev',status:'not_following',reason:'no_power_response',severity:'warning',requested_w:10350,sent_w:6900,actual_w:0,requested_a:15,offered_a:10,device_reason:'limited by dynamic charger current'}));