diff --git a/.changeset/ev-charger-not-following.md b/.changeset/ev-charger-not-following.md new file mode 100644 index 00000000..11913ecf --- /dev/null +++ b/.changeset/ev-charger-not-following.md @@ -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. diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index ca84538e..f63a24a1 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -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:"-"` @@ -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 { @@ -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 { @@ -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 && @@ -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 } @@ -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" { diff --git a/go/internal/api/api_ev_not_following_test.go b/go/internal/api/api_ev_not_following_test.go new file mode 100644 index 00000000..2592f610 --- /dev/null +++ b/go/internal/api/api_ev_not_following_test.go @@ -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) + } + }) +} diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index c71ce0c7..409d68ae 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -25,26 +25,18 @@ # loadpoint, and on a charger whose table stops below 4,140 W # Core floors the order to 0 W (fuse_limit). help1022/20A/auto/own/cloud60s/car1p/2A/chargenow over-cut netting -help1022/20A/auto/own/cloud60s/car1p/2A/chargenow verdict #1795 help1022/20A/auto/own/cloud60s/car1p/2A/planned energy D -help1022/20A/auto/own/cloud60s/car1p/2A/planned verdict D help1022/20A/auto/own/cloud60s/car3p/2A/chargenow over-cut netting help1022/20A/auto/own/cloud60s/car3p/2A/pause over-cut netting help1022/20A/auto/own/easee120s/car1p/2A/chargenow over-cut netting -help1022/20A/auto/own/easee120s/car1p/2A/chargenow verdict #1795 help1022/20A/auto/own/easee120s/car1p/2A/planned energy A -help1022/20A/auto/own/easee120s/car1p/2A/planned verdict D help1022/20A/auto/own/easee120s/car3p/2A/chargenow over-cut netting help1022/20A/auto/own/easee120s/car3p/2A/pause over-cut netting help1022/20A/auto/own/easee40s/car1p/2A/chargenow over-cut netting -help1022/20A/auto/own/easee40s/car1p/2A/chargenow verdict #1795 help1022/20A/auto/own/easee40s/car1p/2A/planned energy D -help1022/20A/auto/own/easee40s/car1p/2A/planned verdict D help1022/20A/auto/own/easee40s/car3p/2A/chargenow over-cut netting help1022/20A/auto/own/easee40s/car3p/2A/pause over-cut netting -help1022/20A/auto/own/fast2s/car1p/2A/chargenow verdict #1795 help1022/20A/auto/own/fast2s/car1p/2A/planned energy D -help1022/20A/auto/own/fast2s/car1p/2A/planned verdict D homebox/16A/empty/none/cloud60s/car3p/balanced/chargenow over-cut netting homebox/16A/empty/none/cloud60s/car3p/balanced/pause limit netting homebox/16A/empty/none/easee120s/car3p/balanced/chargenow over-cut netting @@ -66,77 +58,50 @@ sweep/16A/1p/1p/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/1p/1p/easee40s/car3p/balanced/planned over-cut netting sweep/16A/1p/none/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/1p/none/easee40s/car3p/balanced/planned over-cut netting -sweep/16A/3p/3p/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/3p/3p/easee40s/car1p/L1/planned energy D sweep/16A/3p/3p/easee40s/car1p/balanced/chargenow over-cut netting -sweep/16A/3p/3p/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/3p/3p/easee40s/car1p/balanced/planned energy D sweep/16A/3p/3p/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/3p/3p/easee40s/car1p/balanced/planned verdict #1795 sweep/16A/3p/3p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/3p/mixed/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/3p/mixed/easee40s/car1p/L1/planned energy D sweep/16A/3p/mixed/easee40s/car1p/balanced/chargenow over-cut netting -sweep/16A/3p/mixed/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/3p/mixed/easee40s/car1p/balanced/planned energy D sweep/16A/3p/mixed/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/3p/mixed/easee40s/car1p/balanced/planned verdict #1795 sweep/16A/3p/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/3p/none/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/3p/none/easee40s/car1p/L1/planned energy D sweep/16A/3p/none/easee40s/car1p/balanced/chargenow over-cut netting -sweep/16A/3p/none/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/3p/none/easee40s/car1p/balanced/planned energy D sweep/16A/3p/none/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/3p/none/easee40s/car1p/balanced/planned verdict #1795 sweep/16A/3p/none/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/auto/3p/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/auto/3p/easee40s/car1p/L1/planned energy D sweep/16A/auto/3p/easee40s/car1p/balanced/chargenow over-cut netting -sweep/16A/auto/3p/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/auto/3p/easee40s/car1p/balanced/planned energy D sweep/16A/auto/3p/easee40s/car1p/balanced/planned over-cut D -sweep/16A/auto/3p/easee40s/car1p/balanced/planned verdict D sweep/16A/auto/3p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/auto/mixed/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/auto/mixed/easee40s/car1p/L1/planned energy D sweep/16A/auto/mixed/easee40s/car1p/balanced/chargenow over-cut netting -sweep/16A/auto/mixed/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/auto/mixed/easee40s/car1p/balanced/planned energy D sweep/16A/auto/mixed/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/auto/mixed/easee40s/car1p/balanced/planned verdict D sweep/16A/auto/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/auto/none/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/auto/none/easee40s/car1p/L1/planned energy D sweep/16A/auto/none/easee40s/car1p/balanced/chargenow over-cut netting -sweep/16A/auto/none/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/auto/none/easee40s/car1p/balanced/planned energy D sweep/16A/auto/none/easee40s/car1p/balanced/planned over-cut D -sweep/16A/auto/none/easee40s/car1p/balanced/planned verdict D sweep/16A/auto/none/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/empty/3p/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/empty/3p/easee40s/car1p/L1/planned energy D sweep/16A/empty/3p/easee40s/car1p/balanced/chargenow over-cut netting -sweep/16A/empty/3p/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/empty/3p/easee40s/car1p/balanced/planned energy D sweep/16A/empty/3p/easee40s/car1p/balanced/planned over-cut D -sweep/16A/empty/3p/easee40s/car1p/balanced/planned verdict D sweep/16A/empty/3p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/empty/mixed/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/empty/mixed/easee40s/car1p/L1/planned energy D sweep/16A/empty/mixed/easee40s/car1p/balanced/chargenow over-cut netting -sweep/16A/empty/mixed/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/empty/mixed/easee40s/car1p/balanced/planned energy D sweep/16A/empty/mixed/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/empty/mixed/easee40s/car1p/balanced/planned verdict D sweep/16A/empty/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/empty/none/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/empty/none/easee40s/car1p/L1/planned energy D sweep/16A/empty/none/easee40s/car1p/balanced/chargenow over-cut netting -sweep/16A/empty/none/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/empty/none/easee40s/car1p/balanced/planned energy D sweep/16A/empty/none/easee40s/car1p/balanced/planned over-cut D -sweep/16A/empty/none/easee40s/car1p/balanced/planned verdict D sweep/25A/1p/1p/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/1p/1p/easee40s/car3p/L1/planned over-cut netting sweep/25A/1p/1p/easee40s/car3p/balanced/chargenow over-cut netting @@ -145,63 +110,54 @@ sweep/25A/1p/none/easee40s/car3p/L1/planned over-cut netting sweep/25A/1p/none/easee40s/car3p/balanced/chargenow over-cut netting sweep/25A/3p/3p/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/3p/3p/easee40s/car1p/L1/planned energy D -sweep/25A/3p/3p/easee40s/car1p/L1/planned verdict #1795 sweep/25A/3p/3p/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/3p/3p/easee40s/car1p/balanced/planned energy D sweep/25A/3p/3p/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/3p/3p/easee40s/car3p/balanced/chargenow over-cut netting sweep/25A/3p/mixed/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/3p/mixed/easee40s/car1p/L1/planned energy D -sweep/25A/3p/mixed/easee40s/car1p/L1/planned verdict #1795 sweep/25A/3p/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/3p/mixed/easee40s/car1p/balanced/planned energy D sweep/25A/3p/mixed/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/3p/mixed/easee40s/car3p/balanced/chargenow over-cut netting sweep/25A/3p/none/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/3p/none/easee40s/car1p/L1/planned energy D -sweep/25A/3p/none/easee40s/car1p/L1/planned verdict #1795 sweep/25A/3p/none/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/3p/none/easee40s/car1p/balanced/planned energy D sweep/25A/3p/none/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/3p/none/easee40s/car3p/balanced/chargenow over-cut netting sweep/25A/auto/3p/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/auto/3p/easee40s/car1p/L1/planned energy D -sweep/25A/auto/3p/easee40s/car1p/L1/planned verdict D sweep/25A/auto/3p/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/auto/3p/easee40s/car1p/balanced/planned energy D sweep/25A/auto/3p/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/auto/3p/easee40s/car3p/balanced/chargenow over-cut netting sweep/25A/auto/mixed/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/auto/mixed/easee40s/car1p/L1/planned energy D -sweep/25A/auto/mixed/easee40s/car1p/L1/planned verdict D sweep/25A/auto/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/auto/mixed/easee40s/car1p/balanced/planned energy D sweep/25A/auto/mixed/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/auto/mixed/easee40s/car3p/balanced/chargenow over-cut netting sweep/25A/auto/none/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/auto/none/easee40s/car1p/L1/planned energy D -sweep/25A/auto/none/easee40s/car1p/L1/planned verdict D sweep/25A/auto/none/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/auto/none/easee40s/car1p/balanced/planned energy D sweep/25A/auto/none/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/auto/none/easee40s/car3p/balanced/chargenow over-cut netting sweep/25A/empty/3p/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/empty/3p/easee40s/car1p/L1/planned energy D -sweep/25A/empty/3p/easee40s/car1p/L1/planned verdict D sweep/25A/empty/3p/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/empty/3p/easee40s/car1p/balanced/planned energy D sweep/25A/empty/3p/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/empty/3p/easee40s/car3p/balanced/chargenow over-cut netting sweep/25A/empty/mixed/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/empty/mixed/easee40s/car1p/L1/planned energy D -sweep/25A/empty/mixed/easee40s/car1p/L1/planned verdict D sweep/25A/empty/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/empty/mixed/easee40s/car1p/balanced/planned energy D sweep/25A/empty/mixed/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/empty/mixed/easee40s/car3p/balanced/chargenow over-cut netting sweep/25A/empty/none/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/empty/none/easee40s/car1p/L1/planned energy D -sweep/25A/empty/none/easee40s/car1p/L1/planned verdict D sweep/25A/empty/none/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/empty/none/easee40s/car1p/balanced/planned energy D sweep/25A/empty/none/easee40s/car3p/L1/chargenow over-cut netting diff --git a/go/internal/telemetry/command_evidence.go b/go/internal/telemetry/command_evidence.go index 1216e685..52820535 100644 --- a/go/internal/telemetry/command_evidence.go +++ b/go/internal/telemetry/command_evidence.go @@ -26,10 +26,13 @@ type CommandEvidence struct { Result string ReadbackMismatchSince time.Time PowerMismatchSince time.Time - PowerMatchSince time.Time - PowerObservedSince time.Time - LastObservation time.Time - LastMeasuredAt time.Time + // PowerOverSince is the start of the current run of readings above + // every command in force, a part of PowerMismatchSince's run. + PowerOverSince time.Time + PowerMatchSince time.Time + PowerObservedSince time.Time + LastObservation time.Time + LastMeasuredAt time.Time // NoPower is an observed EV stop during uninterrupted positive orders. // Retuning the current cannot restart this clock; a pause, failed command, // missing measurement or observed power ends it. @@ -152,10 +155,13 @@ func (s *Store) BeginCommand(driver string, payload []byte, now time.Time) Comma if !materialStep(old.PowerW, c.PowerW) { c.Since, c.Baseline, c.StepAfter, c.StepAfterAt = old.Since, old.Baseline, old.StepAfter, old.StepAfterAt c.ReadbackMismatchSince, c.PowerMismatchSince, c.PowerMatchSince = old.ReadbackMismatchSince, old.PowerMismatchSince, old.PowerMatchSince + c.PowerOverSince = old.PowerOverSince c.PowerObservedSince, c.LastObservation, c.LastGapW = old.PowerObservedSince, old.LastObservation, old.LastGapW } } - if c.PowerW != nil { + // A repeat of the last order is the same order: keeping it would push + // the earlier ones out of Recent long before their response time ends. + if n := len(c.Recent); c.PowerW != nil && (n == 0 || c.Recent[n-1].PowerW != *c.PowerW) { c.Recent = append(c.Recent, CommandPoint{PowerW: *c.PowerW, At: now}) } if len(c.Recent) > 8 { @@ -292,6 +298,7 @@ func (s *Store) endCommandControlAt(driver string, failed bool, now time.Time) { c.Result = "released" } c.ReadbackMismatchSince, c.PowerMismatchSince, c.PowerMatchSince = time.Time{}, time.Time{}, time.Time{} + c.PowerOverSince = time.Time{} c.PowerObservedSince, c.LastObservation, c.LastGapW = time.Time{}, time.Time{}, nil c.Recent, c.StepAfter, c.StepAfterAt = nil, nil, time.Time{} if !failed { @@ -375,6 +382,7 @@ func (s *Store) observeCommand(driver string, kind DerType, power float64, data if json.Unmarshal(data, &d) != nil { c.NoPowerSince, c.NoPowerAt = time.Time{}, time.Time{} c.ReadbackMismatchSince, c.PowerMismatchSince, c.PowerMatchSince = time.Time{}, time.Time{}, time.Time{} + c.PowerOverSince = time.Time{} c.PowerObservedSince, c.LastObservation = time.Time{}, time.Time{} s.commands[k] = c return @@ -382,7 +390,7 @@ func (s *Store) observeCommand(driver string, kind DerType, power float64, data c.MaxAge = DeclaredPowerMaxAge(data) if now.Sub(c.LastObservation) > max(time.Minute, c.MaxAge) { c.ReadbackMismatchSince, c.PowerMismatchSince, c.PowerMatchSince = time.Time{}, time.Time{}, time.Time{} - c.PowerObservedSince = time.Time{} + c.PowerOverSince, c.PowerObservedSince = time.Time{}, time.Time{} } mark := func(since *time.Time, mismatch bool, at time.Time) { if !mismatch { @@ -423,7 +431,7 @@ func (s *Store) observeCommand(driver string, kind DerType, power float64, data // delivery of one source sample also cannot extend a measured time window. if !fresh || !finite(power) || observedAt.Before(c.Since) { c.PowerMismatchSince, c.PowerMatchSince, c.LastObservation = time.Time{}, time.Time{}, time.Time{} - c.PowerObservedSince, c.LastGapW = time.Time{}, nil + c.PowerOverSince, c.PowerObservedSince, c.LastGapW = time.Time{}, time.Time{}, nil s.commands[k] = c return } @@ -441,6 +449,8 @@ func (s *Store) observeCommand(driver string, kind DerType, power float64, data tolerance := ControlToleranceW(*c.PowerW) mismatch := !finite(gap) || gap > tolerance mark(&c.PowerMismatchSince, mismatch, observedAt) + // Above the latest command and off every one in force is above them all. + mark(&c.PowerOverSince, mismatch && kind != DerPV && power > *c.PowerW, observedAt) follows := !mismatch if kind == DerPV { // Weak sun alone cannot prove that a ceiling took effect. diff --git a/go/internal/telemetry/command_evidence_test.go b/go/internal/telemetry/command_evidence_test.go index 1e2c7410..d45adce7 100644 --- a/go/internal/telemetry/command_evidence_test.go +++ b/go/internal/telemetry/command_evidence_test.go @@ -178,3 +178,27 @@ func TestLastMeasuredResponseSurvivesNewCommandButNotRelease(t *testing.T) { t.Fatal("intentional release kept loss alarm armed") } } + +// Core sends a charger its order on every tick. A repeated order must not +// push the one before it out of the response window: a cloud charger may +// still show it for two minutes. +func TestRepeatedOrderKeepsEarlierOrderInForce(t *testing.T) { + s := NewStore() + start := time.Now() + send := func(w string, at time.Time) { + c := s.BeginCommand("easee", []byte(`{"action":"ev_set_current","power_w":`+w+`}`), at) + s.CompleteCommand(c, "accepted") + } + send("7590", start) + for i := range 30 { + send("0", start.Add(time.Duration(10+2*i)*time.Second)) + } + at := start.Add(70 * time.Second) + s.mu.Lock() + s.observeCommand("easee", DerEV, 3130, json.RawMessage(`{}`), at) + s.mu.Unlock() + got, _ := s.CommandEvidence("easee", "ev") + if got.LastGapW == nil || *got.LastGapW != 0 || !got.PowerMismatchSince.IsZero() { + t.Fatalf("1 min after a cut the earlier 7590 W order was out of force: gap %v, mismatch since %v", got.LastGapW, got.PowerMismatchSince) + } +}