Skip to content
Draft
5 changes: 5 additions & 0 deletions .changeset/easee-load-balancer-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

When an Easee charger's own load balancing holds the car below FTW's order, the EV sheet now says "Easee limits the charger to 12 A (its load balancing). FTW asked for 15 A; the car takes 8.3 kW." instead of "Not following". It needs easee_cloud 1.4.1, which reports the current Easee lets the car take while it is below the order, and names its load balancing when Easee's allocation shows it. Without a named cause the sheet reads "The charger lets the car take 12 A, below the 15 A FTW asked for." A car taking far less or more than the limit allows is still judged as before, and an old charger reason is never shown as the cause.
43 changes: 40 additions & 3 deletions go/internal/api/api_control_feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ type ControlFeedback struct {
RequestedA *float64 `json:"requested_a"`
OfferedA *float64 `json:"offered_a"`
DeviceLimitA *float64 `json:"device_limit_a"`
DeviceReason string `json:"device_reason,omitempty"`
// LimitA is the current a charger lets the car take now, when its driver
// reports it below the order; LimitedBy names the cause when the driver
// shows it, and Make the charger for the sentence.
LimitA *float64 `json:"limit_a,omitempty"`
LimitedBy string `json:"limited_by,omitempty"`
Make string `json:"make,omitempty"`
DeviceReason string `json:"device_reason,omitempty"`
// ChargingPhase names the one phase (L1-L3) a one-phase car charges on
// under a three-phase order, and ChargingPhaseMaxW the most it takes.
ChargingPhase string `json:"charging_phase,omitempty"`
Expand All @@ -72,6 +78,8 @@ type feedbackReading struct {
DeviceLimitA *float64 `json:"device_limit_a"`
DeviceLimitAgeS *float64 `json:"device_limit_age_s"`
Reason string `json:"reason_no_current_label"`
LimitA *float64 `json:"current_limit_a"`
LimitedBy string `json:"current_limited_by"`
Connected *bool `json:"connected"`
Online *bool `json:"is_online"`
}
Expand Down Expand Up @@ -148,6 +156,14 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback {
var d feedbackReading
_ = json.Unmarshal(rd.Data, &d)
f.ReadbackW, f.OfferedA, f.DeviceReason = d.SetpointW, d.MaxA, d.Reason
if d.LimitA != nil {
f.LimitA, f.LimitedBy = d.LimitA, d.LimitedBy
if s.deps.Registry != nil {
if env := s.deps.Registry.Env(rd.Driver); env != nil {
f.Make, _ = env.Identity()
}
}
}
if d.DeviceLimitAgeS != nil && *d.DeviceLimitAgeS >= 0 && *d.DeviceLimitAgeS <= 120 {
f.DeviceLimitA = d.DeviceLimitA
}
Expand Down Expand Up @@ -357,7 +373,7 @@ func setControlStatus(f *ControlFeedback) {
"vehicle_complete", "vehicle_limit_completion", "vehicle_not_requesting":
status = "following"
case "battery_full", "battery_nearly_full", "battery_nearly_empty", "core_limit",
"fuse_limit", "fuse_cooldown", "charger_limit", "one_phase":
"fuse_limit", "fuse_cooldown", "charger_limit", "load_balancer_limit", "one_phase":
status = "limited"
case "import_limit":
status = "limited"
Expand Down Expand Up @@ -425,7 +441,7 @@ const (
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 {
f.ActualW, f.ReadbackW, f.OfferedA, f.DeviceLimitA = nil, nil, nil, nil
f.ActualW, f.ReadbackW, f.OfferedA, f.DeviceLimitA, f.LimitA, f.LimitedBy, f.Make = nil, nil, nil, nil, nil, "", ""
set("unknown", "telemetry_stale", "warning")
return
}
Expand Down Expand Up @@ -491,6 +507,27 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence,
set("limited", "offered_current_lower", "warning")
return
}
// The charger holds the order but its driver reports that it lets the car
// take less, and names its load balancing when the charger's allocation
// shows it (easee_cloud 1.4.1). That explains a car taking about that
// much, never more: within 10 %, or as the car follows a step, within
// 2 A but at most 15 % of the limit. A car taking far less, nothing, or
// more keeps its verdict. A lower current whose cause the driver does
// not show still asks for a look.
if f.Kind == "ev" && f.SentW != nil && f.RequestedW != nil && f.RequestedA != nil && f.LimitA != nil && f.ActualW != nil &&
*f.SentW > 100 && *f.RequestedA > 0 && *f.LimitA > 0 && *f.LimitA+0.5 < *f.RequestedA && settled {
perA := *f.RequestedW / *f.RequestedA
limitW := *f.LimitA * perA
tolerance := telemetry.ControlToleranceW(limitW)
if *f.ActualW >= limitW-math.Max(tolerance, math.Min(2*perA, 0.15*limitW)) && *f.ActualW <= limitW+tolerance && *f.ActualW < *f.SentW {
if f.LimitedBy == "load_balancer" {
set("limited", "load_balancer_limit", "info")
} else {
set("limited", "offered_current_lower", "warning")
}
return
}
}
// This is a known Core stop, not a BMS diagnosis or new measurement proof.
// Continued power, changed setpoints, stale readings and faults keep their
// own verdicts. A full battery is still allowed to discharge.
Expand Down
1 change: 1 addition & 0 deletions go/internal/api/api_control_scenarios_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ func TestControlStatusAnswersEveryReason(t *testing.T) {
"battery_full": {"limited", "info"},
"battery_nearly_full": {"limited", "info"},
"fuse_limit": {"limited", "info"},
"load_balancer_limit": {"limited", "info"},
"device_limit": {"limited", "warning"},
"power_below_target": {"not_following", "warning"},
"setpoint_changed": {"not_following", "warning"},
Expand Down
150 changes: 150 additions & 0 deletions go/internal/api/api_ev_load_balancer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package api

import (
"context"
"math"
"os"
"path/filepath"
"testing"
"testing/synctest"
"time"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/drivers"
"github.com/srcfl/ftw/go/internal/telemetry"
)

// Field trace, 23 Oct 2026 (srcfl/ftw#1763), Easee Cloud: FTW ordered 15 A
// (10.35 kW) throughout and the charger read back 15 A, while Easee's own
// load balancing signalled 13 A to the car, which took 8.85 kW. FTW said
// "Not following". The driver now reports the limit and, when its
// allocation shows it, names the load balancing.
func TestEaseeLoadBalancingReadsAsLimited(t *testing.T) {
// The driver always reads back the order it holds (max_a, observation 48).
const held = `,"max_a":15`
const named = held + `,"current_limit_a":13,"current_limited_by":"load_balancer"`
for _, tc := range []struct {
name, extra string
drawW float64
status, reason, severity string
}{
{"named", named, 8850, "limited", "load_balancer_limit", "info"},
{"cause not shown", held + `,"current_limit_a":13`, 8850, "limited", "offered_current_lower", "warning"},
{"no limit reported", held, 8850, "not_following", "power_below_target", "warning"},
{"a 13 A limit does not explain zero", named, 0, "not_following", "no_power_response", "warning"},
{"nor a car taking far less", named, 4000, "not_following", "power_below_target", "warning"},
{"nor a car 2 A under it", named, 7500, "not_following", "power_below_target", "warning"},
{"nor a car 1.5 A under a 7 A limit", held + `,"current_limit_a":7,"current_limited_by":"load_balancer"`, 3800, "not_following", "power_below_target", "warning"},
{"nor a car at the order", named, 10300, "following", "power_observed", "info"},
{"limit at the order", held + `,"current_limit_a":15,"current_limited_by":"load_balancer"`, 8850, "not_following", "power_below_target", "warning"},
{"a zero limit", held + `,"current_limit_a":0,"current_limited_by":"load_balancer"`, 0, "not_following", "no_power_response", "warning"},
{"a zero limit, no readback", `,"current_limit_a":0,"current_limited_by":"load_balancer"`, 0, "not_following", "no_power_response", "warning"},
// A charger that holds a lower order than FTW sent says so first.
{"charger holds 12 A", `,"max_a":12,"current_limit_a":10,"current_limited_by":"load_balancer"`, 6900, "limited", "offered_current_lower", "warning"},
} {
t.Run(tc.name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
e := newEVTrace(10370)
e.extra = held
for range 5 * 60 / 5 {
e.tick(10350, "plan")
}
if f := e.verdict(t); f.Status != "following" {
t.Fatalf("before the cut: %s/%s", f.Status, f.Reason)
}
e.drawW, e.changedAt, e.extra = tc.drawW, time.Now(), tc.extra
cut := time.Now()
var f ControlFeedback
for range 6 * 60 / 5 {
e.tick(10350, "plan")
f = e.verdict(t)
if tc.status == "limited" && f.Status == "not_following" {
t.Fatalf("at %v: not following (%s)", time.Since(cut), f.Reason)
}
}
if f.Status != tc.status || f.Reason != tc.reason || f.Severity != tc.severity {
t.Fatalf("got %s/%s/%s, want %s/%s/%s", f.Status, f.Reason, f.Severity, tc.status, tc.reason, tc.severity)
}
if tc.reason != "load_balancer_limit" {
return
}
// Requested, allocated and delivered stay apart.
if f.RequestedA == nil || math.Abs(*f.RequestedA-15) > 0.01 || f.OfferedA == nil || *f.OfferedA != 15 ||
f.LimitA == nil || *f.LimitA != 13 || f.LimitedBy != "load_balancer" || f.ActualW == nil || *f.ActualW != 8850 {
t.Fatalf("numbers: requested %v, offer %v, limit %v by %q, actual %v", f.RequestedA, f.OfferedA, f.LimitA, f.LimitedBy, f.ActualW)
}
})
})
}
}

// The trace's balancer stepped between 12, 14 and 13 A. When it lets the
// car take more, the cloud reports the car's old draw for a while; that is
// the balancer's limit, not a charger that stopped following.
func TestEaseeLoadBalancingStepsUp(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
e := newEVTrace(8280)
e.extra = `,"max_a":15,"current_limit_a":12,"current_limited_by":"load_balancer"`
for range 5 * 60 / 5 {
e.tick(10350, "plan")
}
if f := e.verdict(t); f.Reason != "load_balancer_limit" {
t.Fatalf("at 12 A: %s/%s", f.Status, f.Reason)
}
e.extra = `,"max_a":15,"current_limit_a":14,"current_limited_by":"load_balancer"`
for range 4 {
e.tick(10350, "plan")
if f := e.verdict(t); f.Reason != "load_balancer_limit" {
t.Fatalf("after the step to 14 A, car still at 8,280 W: %s/%s", f.Status, f.Reason)
}
}
e.drawW, e.changedAt = 9660, time.Now()
e.tick(10350, "plan")
if f := e.verdict(t); f.Reason != "load_balancer_limit" || *f.LimitA != 14 {
t.Fatalf("at 14 A: %s/%s", f.Status, f.Reason)
}
})
}

// The sentence names the charger's make with its limit, from the identity
// its driver reported. Rows without a limit do not carry it.
func TestChargerLimitCarriesTheMake(t *testing.T) {
dir := t.TempDir()
lua := filepath.Join(dir, "easee_like.lua")
src := `
function driver_init(config) host.set_make("Easee") end
function driver_poll() return 60000 end
function driver_command(action, power_w, cmd) return true end
function driver_default_mode() end
`
if err := os.WriteFile(lua, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
tel := telemetry.NewStore()
reg := drivers.NewRegistry(tel)
t.Cleanup(reg.ShutdownAll)
if err := reg.Add(context.Background(), config.Driver{Name: "easee", Lua: lua}); err != nil {
t.Fatal(err)
}
srv := New(&Deps{Tel: tel, Registry: reg})
for _, tc := range []struct {
extra, make string
}{
{`,"current_limit_a":13,"current_limited_by":"load_balancer"`, "Easee"},
{``, ""},
} {
c := tel.BeginCommand("easee", []byte(`{"action":"ev_set_current","power_w":10350}`), time.Now())
tel.CompleteCommand(c, "accepted")
tel.Update("easee", telemetry.DerEV, 8850, nil, []byte(`{"connected":true`+tc.extra+`}`))
rows := srv.controlFeedback(time.Now())
if len(rows) != 1 || rows[0].Make != tc.make {
t.Fatalf("extra %q: rows %+v, want make %q", tc.extra, rows, tc.make)
}
}
// Stale readings drop the limit, its cause and the make together.
tel.Update("easee", telemetry.DerEV, 8850, nil, []byte(`{"connected":true,"current_limit_a":13,"current_limited_by":"load_balancer"}`))
rows := srv.controlFeedback(time.Now().Add(2 * time.Minute))
if len(rows) != 1 || rows[0].Reason != "telemetry_stale" || rows[0].LimitA != nil || rows[0].LimitedBy != "" || rows[0].Make != "" {
t.Fatalf("stale row keeps its limit: %+v", rows)
}
}
5 changes: 3 additions & 2 deletions go/internal/api/api_ev_not_following_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type evTrace struct {
srv *Server
changedAt time.Time
drawW float64
extra string // more reading fields, each with a leading comma
}

func newEVTrace(drawW float64) *evTrace {
Expand All @@ -35,8 +36,8 @@ func (e *evTrace) tick(w float64, reason string) {
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))))
`{"connected":true,"control_power_observed_at":%q,"control_power_confirmed":true,"power_max_age_s":180%s}`,
e.changedAt.Format(time.RFC3339Nano), e.extra)))
e.mgr.Observe("garage", true, e.drawW, 0, true)
time.Sleep(5 * time.Second)
}
Expand Down
56 changes: 52 additions & 4 deletions go/internal/control/sitesim_field_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type fieldCase struct {
exportW []float64 // surplus: export each quarter hour
houseStep time.Duration // > 0: L1 +8 A for 10 min from this time into the run
warm bool // the fuse starts warm: the house has carried its load all evening
balancerA float64 // > 0: the charger's own load balancing keeps each phase under this
}

func (c fieldCase) key() string {
Expand All @@ -99,7 +100,7 @@ func (c fieldCase) key() string {
text string
}{
{c.battery, "battery"}, {c.charger != "", "charger" + c.charger}, {c.planW > 0, fmt.Sprintf("plan%gW", c.planW)}, {c.needW > 0, fmt.Sprintf("need%gW", c.needW)},
{c.goal, "goal"}, {export != "", export}, {c.houseStep > 0, "L1step"},
{c.goal, "goal"}, {export != "", export}, {c.houseStep > 0, "L1step"}, {c.balancerA > 0, fmt.Sprintf("balancer%gA", c.balancerA)},
} {
if v.on {
name += "+" + v.text
Expand Down Expand Up @@ -172,7 +173,9 @@ func helpReport(use fieldUse, tm fieldTiming) fieldCase {

// pr1763Site is the site in srcfl/ftw#1763's comments: 3 × 25 A, Easee Cloud
// and Pixii, the battery charging from the plan while the car charges,
// offers to 15 A of which the car takes about 12 A, pause and restart.
// offers to 15 A of which the car takes about 12 A, pause and restart. Here
// the car holds itself at 8.3 kW. The trace of 23 Oct 2026 showed that on
// this site Easee's own load balancing did it; probeCases has that charger.
func pr1763Site(use fieldUse, tm fieldTiming) fieldCase {
return fieldCase{report: "pr1763", fuseA: 25, mode: "3p", steps: "3p-15A", timing: tm, carPhases: 3, use: use,
house: [3]float64{6, 2, 2}, houseName: "6-2-2", battery: true,
Expand Down Expand Up @@ -302,6 +305,14 @@ func probeCases() []fieldCase {
c.carPhases = 1
out = append(out, c)
}
for _, use := range []fieldUse{usePlanned, useNow} {
// The charger in srcfl/ftw#1763's trace: Easee's own load balancing
// keeps each phase under 18 A after the house, so the car gets 12 A
// of a 15 A order, and the driver reports it (easee_cloud 1.4.1).
c := pr1763Site(use, timingEasee)
c.battery, c.balancerA, c.car = false, 18, nil
out = append(out, c)
}
c := helpReport(usePlanned, timingEasee)
c.houseStep = 30 * time.Minute
return append(out, c)
Expand Down Expand Up @@ -430,6 +441,8 @@ type fieldRun struct {
trace func(format string, args ...any) // FTW_FIELD_TRACE names the case
tracedReason string
exportPeak func(window time.Duration) (float64, bool) // surplus: the forecast from now
balancedA float64 // what the charger's own load balancing lets the car take, 0 for none
balancedSince time.Time
}

// FieldVerdict returns a reader of the EV verdict the API shows for a store
Expand Down Expand Up @@ -460,6 +473,24 @@ func (s *simSite) evData() map[string]any {
if age := s.field.c.timing.declared; age > 0 {
d["power_max_age_s"] = age.Seconds()
}
// The charger's own load balancing, reported as easee_cloud 1.4.1 does
// from Easee's allocation and pilot: the whole amps it lets the car take
// while that is below the order in force.
f, e, limitA := s.field, s.p.EVSE, 0.0
if e.LoadBalancerA > 0 && s.ev.Plugged && e.OfferA > 0 {
d["max_a"] = e.OfferA // the order the charger holds, Easee's observation 48
a := e.OfferA
for i := range min(max(e.OfferPhases, 1), e.Phases, s.p.Car.Phases) {
a = math.Min(a, e.LoadBalancerA-s.p.HouseW[i]/s.p.Voltage)
}
if a = math.Floor(a); a >= e.MinA && a < e.OfferA-0.5 {
limitA = a
d["current_limit_a"], d["current_limited_by"] = a, "load_balancer"
}
}
if limitA != f.balancedA {
f.balancedA, f.balancedSince = limitA, s.p.Now
}
return d
}

Expand Down Expand Up @@ -661,9 +692,22 @@ func (f *fieldRun) check(s *simSite) {
// Core waits its response time for an order to take hold, then two
// minutes of measured disagreement (srcfl/ftw#1795). A car marked
// one-phase is judged against a third of a three-phase order, the
// expected_w Core sends with it (srcfl/ftw#1801).
// expected_w Core sends with it (srcfl/ftw#1801). A car held at what the
// charger's own load balancing allows, as its driver reports, is
// limited and must say so (srcfl/ftw#1763); in the first 30 s after the
// balancer moves, Core's reading may still show the old draw.
if f.verdict != nil && !f.mismatchSince.IsZero() && now.Sub(f.mismatchSince) >= 2*time.Minute+30*time.Second {
if status, reason := f.verdict(); status != "not_following" && reason != "one_phase" {
status, reason := f.verdict()
ok := status == "not_following" || reason == "one_phase"
if limitW := f.balancedA * p.Voltage * float64(p.EVSE.DrawPhases); f.balancedA > 0 &&
math.Abs(p.EVSE.DrawW-limitW) <= telemetry.ControlToleranceW(limitW) {
if now.Sub(f.balancedSince) >= 30*time.Second {
ok = reason == "load_balancer_limit"
} else {
ok = ok || reason == "load_balancer_limit"
}
}
if !ok {
f.fail(s, failVerdict, "the car draws %.0f W against an expected %.0f W (%v), the verdict says %s/%s",
p.EVSE.DrawW, f.expectW, now.Sub(f.orderAt).Round(time.Second), status, reason)
}
Expand All @@ -679,6 +723,9 @@ func fieldRoomW(c fieldCase, e *sitesim.EVSE, car *sitesim.Car) float64 {
worst = math.Max(worst, c.house[i])
}
roomA := math.Min(c.fuseA-evPhaseMarginA-worst, e.MaxA)
if e.LoadBalancerA > 0 {
roomA = math.Min(roomA, e.LoadBalancerA-worst)
}
if roomA < e.MinA {
return 0
}
Expand All @@ -704,6 +751,7 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun)
if c.charger == "1p32A" {
p.EVSE.Phases, p.EVSE.MaxA = 1, 32
}
p.EVSE.LoadBalancerA = c.balancerA
if c.timing.cloud {
p.EVSE.ReadbackLag, p.EVSE.SplitAtA, p.EVSE.PhaseHold = 10*time.Second, c.fuseA, 90*time.Second
}
Expand Down
Loading
Loading