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
5 changes: 5 additions & 0 deletions .changeset/ev-inferred-site-headroom.md
Original file line number Diff line number Diff line change
@@ -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.
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
109 changes: 109 additions & 0 deletions go/internal/api/api_ev_headroom.go
Original file line number Diff line number Diff line change
@@ -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
}
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)
}
})
}
}
7 changes: 7 additions & 0 deletions web/control-feedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
16 changes: 16 additions & 0 deletions web/control-feedback.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'}));
Expand Down