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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/ev-inferred-site-headroom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"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.

Show the projected phase current against the working limit after the safety margin, and label it as a projection without denying other charger-reported information.
2 changes: 2 additions & 0 deletions go/internal/api/api_control_feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand Down Expand Up @@ -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)
}
}
Expand Down
105 changes: 105 additions & 0 deletions go/internal/api/api_ev_headroom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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 _, obs := range s.deps.Tel.OnlineBatteryControlPowers(now, 15*time.Second) {
skew := rd.UpdatedAt.Sub(obs.Seen())
if skew >= -10*time.Second && skew <= 10*time.Second && obs.PowerW > 100 {
result.BatteryChargeW += obs.PowerW
}
}

return result
}
114 changes: 114 additions & 0 deletions go/internal/api/api_ev_headroom_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
40 changes: 40 additions & 0 deletions go/internal/api/ev_headroom_export_regression_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
31 changes: 31 additions & 0 deletions go/internal/telemetry/battery_control_powers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package telemetry

import "time"

// OnlineBatteryControlPowers returns qualified battery observations for
// diagnostic context. Callers may align them with another source, but cached
// readings alone never establish a live battery contribution.
func (s *Store) OnlineBatteryControlPowers(now time.Time, maxAge time.Duration) []ControlObservation {
s.mu.RLock()
defer s.mu.RUnlock()
if maxAge <= 0 {
return nil
}
var out []ControlObservation
for _, r := range s.readings {
if r == nil || r.DerType != DerBattery {
continue
}
h := s.health[r.Driver]
if h == nil || !h.TelemetryLive() || h.DeviceFault || r.UpdatedAt.After(now) || now.Sub(r.UpdatedAt) > maxAge {
continue
}
obs, known := ControlPowerObservation(r.RawW, r.Data, r.UpdatedAt)
age := now.Sub(obs.Seen())
if !known || age < 0 || age > maxAge || age > ControlPowerMaxAge(r.Data) {
continue
}
out = append(out, obs)
}
return out
}
54 changes: 54 additions & 0 deletions go/internal/telemetry/battery_control_powers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package telemetry

import (
"math"
"testing"
"time"
)

func TestOnlineBatteryControlPowersQualifiesDiagnosticContext(t *testing.T) {
for _, mode := range []string{"fresh", "offline", "fault", "missing health", "stale receipt", "future receipt", "stale source", "source validity", "unavailable", "nonfinite", "invalid age"} {
t.Run(mode, func(t *testing.T) {
now := time.Now().UTC()
s := NewStore()
s.Update("battery", DerBattery, 1500, nil, nil)
s.RecordDriverSuccess("battery")
r := s.Get("battery", DerBattery)
r.UpdatedAt = now
maxAge := 15 * time.Second
switch mode {
case "offline":
s.DriverHealthMut("battery").SetOffline()
case "fault":
s.DriverHealthMut("battery").SetDeviceFault(true, "test")
case "missing health":
delete(s.health, "battery")
case "stale receipt":
r.UpdatedAt = now.Add(-16 * time.Second)
case "future receipt":
r.UpdatedAt = now.Add(time.Second)
case "stale source":
r.Data = []byte(`{"power_observed_at":"` + now.Add(-16*time.Second).Format(time.RFC3339Nano) + `"}`)
case "source validity":
r.Data = []byte(`{"power_max_age_s":1,"power_observed_at":"` + now.Add(-2*time.Second).Format(time.RFC3339Nano) + `"}`)
case "unavailable":
r.Data = []byte(`{"control_power_available":false}`)
case "nonfinite":
r.RawW = math.NaN()
case "invalid age":
maxAge = 0
}
// Another DER must not enter battery context.
s.Update("car", DerEV, 8000, nil, nil)
s.RecordDriverSuccess("car")
got := s.OnlineBatteryControlPowers(now, maxAge)
if mode == "fresh" {
if len(got) != 1 || got[0].PowerW != 1500 {
t.Fatalf("observations %+v", got)
}
} else if len(got) != 0 {
t.Fatalf("unqualified context %+v", got)
}
})
}
}
8 changes: 8 additions & 0 deletions web/control-feedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ 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)}.` : '';
const current = value => `${Number(value.toFixed(1))} A`;
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 ${current(headroom.projected_max_a)}, above the ${current(headroom.fuse_a - headroom.margin_a)} working limit (${current(headroom.fuse_a)} fuse, ${current(headroom.margin_a)} safety margin).`,
proof: 'Calculated from site measurements. This is a projection, not a charger-reported cause.', 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};
Expand Down
Loading