diff --git a/.changeset/easee-load-balancer-limit.md b/.changeset/easee-load-balancer-limit.md new file mode 100644 index 00000000..7f48993d --- /dev/null +++ b/.changeset/easee-load-balancer-limit.md @@ -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. diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index 1ed31dd9..eb5abc40 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -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"` @@ -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"` } @@ -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 } @@ -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" @@ -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 } @@ -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. diff --git a/go/internal/api/api_control_scenarios_test.go b/go/internal/api/api_control_scenarios_test.go index 36595786..4470550b 100644 --- a/go/internal/api/api_control_scenarios_test.go +++ b/go/internal/api/api_control_scenarios_test.go @@ -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"}, diff --git a/go/internal/api/api_ev_load_balancer_test.go b/go/internal/api/api_ev_load_balancer_test.go new file mode 100644 index 00000000..fd8901b9 --- /dev/null +++ b/go/internal/api/api_ev_load_balancer_test.go @@ -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) + } +} diff --git a/go/internal/api/api_ev_not_following_test.go b/go/internal/api/api_ev_not_following_test.go index 2592f610..cfd9dfdd 100644 --- a/go/internal/api/api_ev_not_following_test.go +++ b/go/internal/api/api_ev_not_following_test.go @@ -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 { @@ -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) } diff --git a/go/internal/control/sitesim_field_test.go b/go/internal/control/sitesim_field_test.go index a907b6ca..5b0c8d30 100644 --- a/go/internal/control/sitesim_field_test.go +++ b/go/internal/control/sitesim_field_test.go @@ -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 { @@ -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 @@ -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, @@ -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) @@ -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 @@ -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 } @@ -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) } @@ -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 } @@ -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 } diff --git a/web/control-feedback.js b/web/control-feedback.js index e8164403..9fda1e31 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -66,6 +66,7 @@ const REASON_TITLES = { core_limit: 'Limited by FTW', fuse_limit: 'Limited by the main fuse', fuse_cooldown: 'Waiting after a site power limit', import_limit: 'Limited by the hourly import limit', charger_limit: 'Limited by FTW’s charger setting', device_limit: 'Charger limit', offered_current_lower: 'Charger offers less current', + load_balancer_limit: 'Limited by the charger’s load balancing', one_phase: 'Charging on one phase', device_fault: 'Device fault', telemetry_stale: 'No fresh readings', readings_lost: 'Lost control', command_failed: 'Commands failing', command_unconfirmed: 'Command not confirmed', default_failed: 'Cannot hand back control', @@ -99,7 +100,11 @@ function sentence(row) { case 'fuse_cooldown': return 'FTW waits a moment after a site power limit before it raises the rate.'; case 'charger_limit': return 'The charger limit in FTW’s settings caps the rate.'; case 'device_limit': return `The charger’s own limit is ${amps(row.device_limit_a)}, below the ${amps(row.requested_a)} FTW asked for.`; - case 'offered_current_lower': return `The charger offers ${amps(row.offered_a)}, below the ${amps(row.requested_a)} FTW asked for.`; + case 'offered_current_lower': return num(row.limit_a) && !(num(row.offered_a) && num(row.requested_a) && row.offered_a + 0.5 < row.requested_a) + ? `The charger lets the car take ${amps(row.limit_a)}, below the ${amps(row.requested_a)} FTW asked for.` + : `The charger offers ${amps(row.offered_a)}, below the ${amps(row.requested_a)} FTW asked for.`; + case 'load_balancer_limit': return `${row.make ? `${row.make} limits the charger to ${amps(row.limit_a)} (its load balancing)` + : `The charger’s load balancing limits it to ${amps(row.limit_a)}`}. FTW asked for ${amps(row.requested_a)}; the car takes ${controlPower(actual)}.`; case 'one_phase': return `The car charges on ${row.charging_phase || 'one phase'} only${num(row.charging_phase_max_w) ? `, at most ${controlPower(row.charging_phase_max_w)}` : ''}. FTW plans with what it takes.`; case 'power_below_target': case 'power_above_target': return `Asked for ${target(row)}, delivering ${controlPower(actual)}.`; case 'power_wrong_direction': return `Asked for ${target(row)}, but it is ${doing(row, actual)} ${controlPower(actual)}.`; @@ -137,6 +142,7 @@ function nextStep(row) { case 'site_meter_stale': case 'site_phase_currents_stale': return 'Check the grid meter’s connection.'; case 'device_limit': return 'Raise the limit in the charger’s app if you want faster charging.'; case 'offered_current_lower': return 'Check the charger’s load balancing and limits.'; + case 'load_balancer_limit': return 'If this happens often, compare the charger’s load balancing settings with FTW’s main fuse setting.'; case 'observe_only': return 'Turn on control in the device settings if you want FTW to steer it.'; default: return ''; } @@ -227,6 +233,7 @@ export function controlNumbers(row, live = true) { if (num(row.requested_a)) values.push(['Requested current', amps(row.requested_a)]); if (num(row.offered_a)) values.push(['Charger offer', amps(row.offered_a)]); if (num(row.device_limit_a)) values.push(['Charger limit', amps(row.device_limit_a)]); + if (num(row.limit_a)) values.push([row.limited_by === 'load_balancer' ? 'Load balancing allows' : 'Charger lets the car take', amps(row.limit_a)]); const e = row.site_evidence; if (e && typeof e === 'object') { for (const [label, key] of [['Grid before', 'grid_before_w'], ['Grid after', 'grid_after_w']]) { diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index 9250313c..16cb4db5 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -59,6 +59,27 @@ test('expected limits stay calm and explain themselves', () => { assert.equal(car.title, 'Car is full'); const limit = controlStatus(row({kind: 'ev', status: 'limited', reason: 'device_limit', severity: 'warning', device_limit_a: 8, requested_a: 16})); assert.equal(limit.text, 'The charger’s own limit is 8 A, below the 16 A FTW asked for.'); + // Field trace, 23 Oct 2026: 15 A ordered, Easee's load balancing allowed 13 A. + const lb = {kind: 'ev', mode: 'plan', status: 'limited', reason: 'load_balancer_limit', severity: 'info', + sent_w: 10350, actual_w: 8850, requested_a: 15, offered_a: 15, device_limit_a: 16, limit_a: 13, limited_by: 'load_balancer'}; + const balanced = controlStatus(row({...lb, make: 'Easee'})); + assert.equal(balanced.title, 'Limited by the charger’s load balancing'); + assert.equal(balanced.text, 'Easee limits the charger to 13 A (its load balancing). FTW asked for 15 A; the car takes 8.8 kW.'); + assert.equal(balanced.next, 'If this happens often, compare the charger’s load balancing settings with FTW’s main fuse setting.'); + assert.equal(balanced.tone, 'neutral'); + assert.equal(controlStatus(row(lb)).text, 'The charger’s load balancing limits it to 13 A. FTW asked for 15 A; the car takes 8.8 kW.'); + assert.deepEqual(controlNumbers(row(lb)).filter(([label]) => /current|offer|limit|allows/i.test(label)), [ + ['Requested current', '15 A'], ['Charger offer', '15 A'], ['Charger limit', '16 A'], ['Load balancing allows', '13 A']]); + // A lower current with no cause shown says what the charger does, no more. + const unexplained = {...lb, reason: 'offered_current_lower', severity: 'warning', limited_by: undefined}; + assert.equal(controlStatus(row(unexplained)).text, 'The charger lets the car take 13 A, below the 15 A FTW asked for.'); + assert.deepEqual(controlNumbers(row(unexplained)).at(-1), ['Charger lets the car take', '13 A']); + // A charger holding a lower order than FTW sent. + const lower = controlStatus(row({kind: 'ev', status: 'limited', reason: 'offered_current_lower', severity: 'warning', offered_a: 12, requested_a: 15})); + assert.equal(lower.text, 'The charger offers 12 A, below the 15 A FTW asked for.'); + // A lower held order is said first, even when the driver reports less. + const both = controlStatus(row({kind: 'ev', status: 'limited', reason: 'offered_current_lower', severity: 'warning', offered_a: 12, limit_a: 10, requested_a: 15})); + assert.equal(both.text, 'The charger offers 12 A, below the 15 A FTW asked for.'); const onePhase = controlStatus(row({kind: 'ev', mode: 'plan', status: 'limited', reason: 'one_phase', severity: 'info', sent_w: 3680, actual_w: 3650, charging_phase: 'L1', charging_phase_max_w: 3680})); assert.equal(onePhase.title, 'Charging on one phase');