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/ev-charger-not-following.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

A charger that keeps charging after FTW tells it to stop or slow down now shows as Not following, not Limited. In 0.155.0 an Easee Cloud charger that went on drawing 3.1 kW after FTW cut it to 0 W for the main fuse showed "Limited by the main fuse" for as long as it drew. A limit explains why FTW ordered less; it does not explain a charger that takes more. FTW now warns once the charger has drawn more than its order for four minutes, and raises an alarm ten minutes later. A car that takes less than a limited order is judged as it is under a plan order. Sending the same order on every tick no longer pushes the earlier order out of the two-minute response window, so a slow cloud charger is not judged on an order it could not yet have applied.
28 changes: 25 additions & 3 deletions go/internal/api/api_control_feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type ControlFeedback struct {
State string `json:"-"`
VerificationTier *int `json:"-"`
VerificationLost bool `json:"-"`
Escalated bool `json:"-"` // not following long enough to be an alarm
Response string `json:"-"`
SiteBeforeW *float64 `json:"-"`
SiteAfterW *float64 `json:"-"`
Expand Down Expand Up @@ -360,6 +361,9 @@ func setControlStatus(f *ControlFeedback) {
if status == "following" && (f.VerificationTier == nil || *f.VerificationTier < 1) {
status = "waiting"
}
if status == "not_following" && f.Escalated {
severity = "alarm"
}
f.Status, f.Severity = status, severity
f.Evidence = "none"
if f.VerificationTier != nil {
Expand Down Expand Up @@ -390,6 +394,15 @@ func controlVerificationLost(f ControlFeedback, cmd telemetry.CommandEvidence, n
return !cmd.Since.IsZero() && now.Sub(cmd.Since) >= grace && now.Sub(cmd.LastMeasuredAt) >= grace
}

// evMismatchWait is how long a charger's fresh readings must disagree with
// the orders in force before it is not following. evOverdrawAlarm is how
// much longer one that draws more than ordered may go on before the warning
// becomes an alarm.
const (
evMismatchWait = 2 * time.Minute
evOverdrawAlarm = 10 * time.Minute
)

func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, commanded, fresh bool, coreReason string, connected *bool, now time.Time) {
set := func(state, reason, severity string) { f.State, f.Reason, f.Severity = state, reason, severity }
if !fresh {
Expand Down Expand Up @@ -419,8 +432,8 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence,
}
// A positive clamp explains the lower offer, not prolonged zero delivery.
// Keep known safety pauses and unconfirmed/stale commands authoritative.
evMismatchObserved := !cmd.PowerMismatchSince.IsZero() && cmd.LastObservation.Sub(cmd.PowerMismatchSince) >= 2*time.Minute
noPowerObserved := evMismatchObserved || !cmd.NoPowerSince.IsZero() && cmd.NoPowerAt.Sub(cmd.NoPowerSince) >= 2*time.Minute
evMismatchObserved := !cmd.PowerMismatchSince.IsZero() && cmd.LastObservation.Sub(cmd.PowerMismatchSince) >= evMismatchWait
noPowerObserved := evMismatchObserved || !cmd.NoPowerSince.IsZero() && cmd.NoPowerAt.Sub(cmd.NoPowerSince) >= evMismatchWait
if f.Kind == "ev" && commanded && cmd.Result == "accepted" &&
coreReason != "site_meter_stale" && coreReason != "site_phase_currents_stale" && coreReason != "fuse_cooldown" &&
f.SentW != nil && *f.SentW > 100 && f.ActualW != nil && math.Abs(*f.ActualW) < 100 &&
Expand All @@ -432,7 +445,11 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence,
set("limited", "device_limit", "warning")
return
}
if coreReason == "site_meter_stale" || coreReason == "site_phase_currents_stale" || coreReason == "fuse_cooldown" || coreReason == "fuse_limit" || coreReason == "import_limit" || coreReason == "charger_limit" {
// A limit explains a lower order, not a draw that differs from it. A
// charger whose fresh readings have disagreed with the order past its
// response time is judged below, as under any other order.
limit := coreReason == "site_meter_stale" || coreReason == "site_phase_currents_stale" || coreReason == "fuse_cooldown" || coreReason == "fuse_limit" || coreReason == "import_limit" || coreReason == "charger_limit"
if limit && !(f.Kind == "ev" && evMismatchObserved) {
set("limited", coreReason, "warning")
return
}
Expand Down Expand Up @@ -501,6 +518,11 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence,
reason = "power_below_target"
}
set("warning", reason, "warning")
// FTW cannot stop a charger that draws more than it is told. A car
// that takes less only charges slower.
if f.Kind == "ev" && (reason == "power_while_idle" || reason == "power_above_target") {
f.Escalated = !cmd.PowerOverSince.IsZero() && cmd.LastObservation.Sub(cmd.PowerOverSince) >= evMismatchWait+evOverdrawAlarm
}
return
}
if coreReason == "core_limit" {
Expand Down
172 changes: 172 additions & 0 deletions go/internal/api/api_ev_not_following_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package api

import (
"fmt"
"testing"
"testing/synctest"
"time"

"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/telemetry"
)

// evTrace is an Easee Cloud charger on a loadpoint: a command and a poll
// every five seconds, as Core and the driver send them. The cloud stamps a
// power value when it changes and each poll confirms it (easee_cloud.lua).
type evTrace struct {
tel *telemetry.Store
mgr *loadpoint.Manager
srv *Server
changedAt time.Time
drawW float64
}

func newEVTrace(drawW float64) *evTrace {
tel := telemetry.NewStore()
tel.DriverHealthMut("easee")
mgr := loadpoint.NewManager()
mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11040}})
return &evTrace{tel: tel, mgr: mgr, srv: New(&Deps{Tel: tel, Loadpoints: mgr}), changedAt: time.Now(), drawW: drawW}
}

// tick orders w for reason, polls the charger and waits five seconds.
func (e *evTrace) tick(w float64, reason string) {
c := e.tel.BeginCommand("easee", []byte(fmt.Sprintf(`{"action":"ev_set_current","power_w":%g}`, w)), time.Now())
e.tel.CompleteCommand(c, "accepted")
e.mgr.SetCommanded("garage", w, reason)
e.tel.Update("easee", telemetry.DerEV, e.drawW, nil, []byte(fmt.Sprintf(
`{"connected":true,"control_power_observed_at":%q,"control_power_confirmed":true,"power_max_age_s":180}`,
e.changedAt.Format(time.RFC3339Nano))))
e.mgr.Observe("garage", true, e.drawW, 0, true)
time.Sleep(5 * time.Second)
}

func (e *evTrace) verdict(t *testing.T) ControlFeedback {
t.Helper()
for _, f := range e.srv.controlFeedback(time.Now()) {
if f.Kind == "ev" {
return f
}
}
t.Fatal("no EV verdict")
return ControlFeedback{}
}

// Field report, 22 Oct 2026, v0.155.0-beta.1, Easee Cloud: the charger drew
// 3.13 kW, Core cut its order to 0 W for the main fuse at 06:32 and the
// charger kept drawing. The verdict said "Limited by the main fuse" for as
// long as it drew. A limit explains a lower order, never a draw above it.
func TestChargerDrawingAgainstAFuseStopIsNotFollowing(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
e := newEVTrace(3130)
for range 60 * 60 / 5 {
e.tick(7590, "plan")
}
cut := time.Now()
var first, alarm time.Duration
for range 20 * 60 / 5 {
e.tick(0, "fuse_limit")
f, since := e.verdict(t), time.Since(cut)
if f.Status == "not_following" && first == 0 {
first = since
if f.Reason != "power_while_idle" || f.Severity != "warning" || f.Evidence != "measured" {
t.Fatalf("first not following at %v: %s/%s/%s", since, f.Reason, f.Severity, f.Evidence)
}
}
if f.Severity == "alarm" && alarm == 0 {
alarm = since
if f.Status != "not_following" {
t.Fatalf("alarm at %v is %s/%s", since, f.Status, f.Reason)
}
}
if first != 0 && f.Status != "not_following" {
t.Fatalf("at %v the verdict went back to %s/%s while the charger still draws", since, f.Status, f.Reason)
}
}
t.Logf("not following from %v after the cut, alarm from %v", first, alarm)
// Easee declares 180 s for its power to show; Core waits two minutes
// for an order to take hold and two more of measured disagreement.
if first < 3*time.Minute || first > 5*time.Minute {
t.Fatalf("not following after %v of drawing against a stop, want within 3-5 min", first)
}
if alarm < first+10*time.Minute || alarm > first+11*time.Minute {
t.Fatalf("alarm after %v (not following from %v), want ten minutes later", alarm, first)
}
})
}

// A charger that obeys the cut within its response time stays Limited,
// including Easee Cloud at the end of its declared 180 s.
func TestChargerFollowingAFuseStopStaysLimited(t *testing.T) {
for _, after := range []time.Duration{100 * time.Second, 175 * time.Second} {
synctest.Test(t, func(t *testing.T) {
e := newEVTrace(7500)
for range 10 * 60 / 5 {
e.tick(7590, "plan")
}
cut := time.Now()
for range 15 * 60 / 5 {
if time.Since(cut) >= after && e.drawW > 0 {
e.drawW, e.changedAt = 0, time.Now()
}
e.tick(0, "fuse_limit")
if f := e.verdict(t); f.Status != "limited" || f.Reason != "fuse_limit" || f.Severity == "alarm" {
t.Fatalf("stopped after %v: at %v the verdict is %s/%s/%s", after, time.Since(cut), f.Status, f.Reason, f.Severity)
}
}
})
}
}

// Time spent drawing below the order is not time drawing above it. After
// 13 minutes at 3 kW against a 7 kW order, a car that rises to 9 kW is
// drawing more than it is told: a warning now, an alarm only ten minutes
// after that wait has passed for the overdraw itself.
func TestOverdrawAfterAShortfallAlarmsOnItsOwnTime(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
e := newEVTrace(3000)
for range 13 * 60 / 5 {
e.tick(7000, "plan")
}
if f := e.verdict(t); f.Reason != "power_below_target" || f.Severity != "warning" {
t.Fatalf("3 kW against 7 kW reads %s/%s", f.Reason, f.Severity)
}
e.drawW, e.changedAt = 9000, time.Now()
over := time.Now()
var alarm time.Duration
for range 15 * 60 / 5 {
e.tick(7000, "plan")
f := e.verdict(t)
if f.Reason != "power_above_target" {
t.Fatalf("at %v 9 kW against 7 kW reads %s", time.Since(over), f.Reason)
}
if f.Severity == "alarm" && alarm == 0 {
alarm = time.Since(over)
}
}
if alarm < 12*time.Minute || alarm > 13*time.Minute {
t.Fatalf("overdraw alarmed after %v, want two minutes plus ten", alarm)
}
})
}

// A car that takes less than a fuse-limited order is not the fuse's doing.
// It reads as it would under a plan order: not following, a warning.
func TestCarBelowAFuseLimitedOrderIsNotFollowing(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
e := newEVTrace(3130)
for range 10 * 60 / 5 {
e.tick(8280, "fuse_limit")
}
f := e.verdict(t)
if f.Status != "not_following" || f.Reason != "power_below_target" || f.Severity != "warning" {
t.Fatalf("3.1 kW against an 8.3 kW fuse-limited order reads %s/%s/%s", f.Status, f.Reason, f.Severity)
}
for range 30 * 60 / 5 {
e.tick(8280, "fuse_limit")
}
if f := e.verdict(t); f.Severity != "warning" {
t.Fatalf("a car drawing less than offered escalated to %s", f.Severity)
}
})
}
Loading
Loading