From 602aa0f7b657eb88e4eb751f20c8d011c4d34e15 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 8 Oct 2026 07:01:16 +0200 Subject: [PATCH 1/7] fix(control): show a charger's load balancer as the limit An Easee charger's load balancer can cut the car below FTW's request. The EV sheet then said "Not following" and that the charger had not said why, with requested current, charger offer and charger limit all equal. Read the driver's vendor-neutral current_limited_by. When it names the load balancer and the car draws less than asked, report load_balancer_limit as limited, with words that name the load balancer. More power than asked still warns. Move the bundled driver pin to srcfl/device-drivers#172, which keeps the load-balancing reason while the car charges. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/easee-load-balancer-limit.md | 5 +++ drivers/BUNDLED_SOURCE.json | 2 +- go/internal/api/api_control_feedback.go | 12 ++++- go/internal/api/api_control_scenarios_test.go | 44 +++++++++++++++++++ web/control-feedback.js | 5 +++ web/control-feedback.test.mjs | 8 ++++ 6 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 .changeset/easee-load-balancer-limit.md diff --git a/.changeset/easee-load-balancer-limit.md b/.changeset/easee-load-balancer-limit.md new file mode 100644 index 00000000..753c3ae0 --- /dev/null +++ b/.changeset/easee-load-balancer-limit.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +When an Easee charger's load balancer cuts the car below FTW's request, the EV sheet now says "Limited by the charger's load balancer" instead of "Not following" with no reason. Bundles easee_cloud 1.3.7, which keeps that reason while the car charges and matches its reason labels to Easee's published table. diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 32ba7bbc..2b2235cf 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -16,7 +16,7 @@ "from the signed channel. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "c9de25106b07071fde55c9f8f5e95b69c58e6fc9", + "commit": "3c0bc2acb730f7f921eb7457f1122491f7d7fd7f", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index 0e7d1ced..1b8b00d4 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -38,6 +38,7 @@ type ControlFeedback struct { VerificationTier *int `json:"-"` VerificationLost bool `json:"-"` Response string `json:"-"` + LimitedBy string `json:"-"` SiteBeforeW *float64 `json:"-"` SiteAfterW *float64 `json:"-"` SiteBeforeAtMs int64 `json:"-"` @@ -66,6 +67,7 @@ type feedbackReading struct { DeviceLimitA *float64 `json:"device_limit_a"` DeviceLimitAgeS *float64 `json:"device_limit_age_s"` Reason string `json:"reason_no_current_label"` + LimitedBy string `json:"current_limited_by"` Connected *bool `json:"connected"` Online *bool `json:"is_online"` } @@ -139,7 +141,7 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback { f := ControlFeedback{Driver: rd.Driver, Kind: kind.String(), Mode: mode, State: "waiting", Reason: "no_command", Severity: "info", Response: "unconfirmed"} var d feedbackReading _ = json.Unmarshal(rd.Data, &d) - f.ReadbackW, f.OfferedA, f.DeviceReason = d.SetpointW, d.MaxA, d.Reason + f.ReadbackW, f.OfferedA, f.DeviceReason, f.LimitedBy = d.SetpointW, d.MaxA, d.Reason, d.LimitedBy if d.DeviceLimitAgeS != nil && *d.DeviceLimitAgeS >= 0 && *d.DeviceLimitAgeS <= 120 { f.DeviceLimitA = d.DeviceLimitA } @@ -337,7 +339,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": + "fuse_limit", "fuse_cooldown", "charger_limit", "load_balancer_limit": status = "limited" case "device_limit", "offered_current_lower": status, severity = "limited", "warning" @@ -472,6 +474,12 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("limited", reason, "info") return } + // The charger names its load balancer only while it holds the car + // back. More power than asked still warns. + if f.Kind == "ev" && f.LimitedBy == "load_balancer" && *f.SentW > 100 && *f.ActualW > -100 && *f.ActualW < *f.SentW { + set("limited", "load_balancer_limit", "info") + return + } reason := "power_above_target" switch { case math.Abs(*f.SentW) < 100: diff --git a/go/internal/api/api_control_scenarios_test.go b/go/internal/api/api_control_scenarios_test.go index 36595786..ba050afc 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"}, @@ -366,3 +367,46 @@ func TestChangeOnlyChargerIgnoringNewTargetWarns(t *testing.T) { } }) } + +// A tester's Easee load balancer cut an 11 kW charge to 8.3 kW while the +// battery charged. The charger named its load balancer, so the shortfall is a +// known limit, not an unexplained warning. Without that name it still warns, +// and more power than asked is never explained by a limit. +func TestLoadBalancedChargerReadsAsLimited(t *testing.T) { + for _, tc := range []struct { + name, extra string + actualW float64 + status, reason, severity string + }{ + {"named", `,"current_limited_by":"load_balancer"`, 8300, "limited", "load_balancer_limit", "info"}, + {"held at zero", `,"current_limited_by":"load_balancer"`, 0, "limited", "load_balancer_limit", "info"}, + {"not named", ``, 8300, "not_following", "power_below_target", "warning"}, + {"above target", `,"current_limited_by":"load_balancer"`, 13000, "not_following", "power_above_target", "warning"}, + } { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + tel := telemetry.NewStore() + tel.DriverHealthMut("easee") + srv := New(&Deps{Tel: tel}) + changedAt := time.Now() + var last ControlFeedback + for sec := range 6 * 60 { + if sec%60 == 0 { + c := tel.BeginCommand("easee", []byte(`{"action":"ev_set_current","power_w":11000}`), time.Now()) + tel.CompleteCommand(c, "accepted") + } + time.Sleep(time.Second) + if sec%10 == 0 { + data := fmt.Sprintf(`{"connected":true,"max_a":16,"control_power_observed_at":%q,"control_power_confirmed":true,"power_max_age_s":180%s}`, + changedAt.Format(time.RFC3339Nano), tc.extra) + tel.Update("easee", telemetry.DerEV, tc.actualW, nil, []byte(data)) + } + last = srv.controlFeedback(time.Now())[0] + } + if last.Status != tc.status || last.Reason != tc.reason || last.Severity != tc.severity { + t.Fatalf("got %s/%s/%s, want %s/%s/%s", last.Status, last.Reason, last.Severity, tc.status, tc.reason, tc.severity) + } + }) + }) + } +} diff --git a/web/control-feedback.js b/web/control-feedback.js index 95fda5fa..d41aca63 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -65,6 +65,7 @@ const REASON_TITLES = { battery_full: 'Battery full', battery_nearly_full: 'Battery nearly full', battery_nearly_empty: 'Battery nearly empty', core_limit: 'Limited by FTW', fuse_limit: 'Limited by the main fuse', fuse_cooldown: 'Waiting after a fuse 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 balancer', 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', response_unknown: 'No power reading', observe_only: 'Monitoring only', disabled: 'Disabled', device_control: 'Own control', @@ -97,6 +98,9 @@ function sentence(row) { 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 'load_balancer_limit': return num(actual) && actual >= 100 + ? `Asked for ${target(row)}, delivering ${controlPower(actual)}. The charger’s load balancer is limiting the car.` + : `Asked for ${target(row)}, but the charger’s load balancer gives the car no current.`; 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)}.`; case 'no_power_response': return `Asked for ${target(row)}, but it delivers no power.`; @@ -133,6 +137,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 'The load balancer raises the current again when the house uses less. If this happens often, check that FTW’s main fuse setting matches the load balancer’s.'; case 'observe_only': return 'Turn on control in the device settings if you want FTW to steer it.'; default: return ''; } diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index 00654f00..8e1c807b 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -59,6 +59,14 @@ 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.'); + const balanced = controlStatus(row({kind: 'ev', mode: 'plan', status: 'limited', reason: 'load_balancer_limit', sent_w: 11000, actual_w: 8300})); + assert.equal(balanced.title, 'Limited by the charger’s load balancer'); + assert.equal(balanced.text, 'Asked for 11.0 kW charge, delivering 8.3 kW. The charger’s load balancer is limiting the car.'); + assert.match(balanced.next, /main fuse setting matches the load balancer/); + assert.doesNotMatch(balanced.next, /has not said why/); + assert.equal(balanced.tone, 'neutral'); + const held = controlStatus(row({kind: 'ev', status: 'limited', reason: 'load_balancer_limit', sent_w: 11000, actual_w: 0})); + assert.equal(held.text, 'Asked for 11.0 kW charge, but the charger’s load balancer gives the car no current.'); }); test('the receipt walks from sent to confirmed without inventing proof', () => { From e33726bc145870fde5fb74bd31f977c073bd825b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 20 Oct 2026 19:49:10 +0200 Subject: [PATCH 2/7] fix(feedback): retain an explicit load-balancer reason at zero power Signed-off-by: Fredrik Ahlgren --- go/internal/api/api_control_feedback.go | 4 +++- go/internal/api/api_control_scenarios_test.go | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index 3700008d..03265872 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -418,10 +418,12 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, return } // A positive clamp explains the lower offer, not prolonged zero delivery. - // Keep known safety pauses and unconfirmed/stale commands authoritative. + // An explicit load-balancer reason can explain 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 if f.Kind == "ev" && commanded && cmd.Result == "accepted" && + f.LimitedBy != "load_balancer" && 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 && f.OfferedA != nil && *f.OfferedA > 0 && noPowerObserved { diff --git a/go/internal/api/api_control_scenarios_test.go b/go/internal/api/api_control_scenarios_test.go index ba050afc..46d7d4e2 100644 --- a/go/internal/api/api_control_scenarios_test.go +++ b/go/internal/api/api_control_scenarios_test.go @@ -381,6 +381,7 @@ func TestLoadBalancedChargerReadsAsLimited(t *testing.T) { {"named", `,"current_limited_by":"load_balancer"`, 8300, "limited", "load_balancer_limit", "info"}, {"held at zero", `,"current_limited_by":"load_balancer"`, 0, "limited", "load_balancer_limit", "info"}, {"not named", ``, 8300, "not_following", "power_below_target", "warning"}, + {"zero without a named limiter", ``, 0, "not_following", "no_power_response", "warning"}, {"above target", `,"current_limited_by":"load_balancer"`, 13000, "not_following", "power_above_target", "warning"}, } { t.Run(tc.name, func(t *testing.T) { From b6d93cdf92e617cd0e3fd687cc143eec4030655e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 20 Oct 2026 20:02:14 +0200 Subject: [PATCH 3/7] build(drivers): pin the updated Easee load-balancer tree Signed-off-by: Fredrik Ahlgren --- drivers/BUNDLED_SOURCE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 350fe876..cf4aefca 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -16,7 +16,7 @@ "from the signed channel. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "5cc7bb1a65fe41c4f2cb28a7f546875313b2df85", + "commit": "aa0ee46ac071ae8400657b463845735316263cdc", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", From e06b728f083e0f2e67789076e5f56a9b8501ee70 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 19:12:55 +0100 Subject: [PATCH 4/7] fix(control): show a charger's load balancer as the limit #1537 (602aa0f7, e33726bc) on current master, as Segran integrated it in #1823 (e225339b): the feedback code keeps master's one-phase and hourly-import paths beside the load-balancer reason. The driver pin from both PRs is left out; it moves only to a merged driver commit. Co-authored-by: Segran Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/easee-load-balancer-limit.md | 5 +++ go/internal/api/api_control_feedback.go | 17 +++++-- go/internal/api/api_control_scenarios_test.go | 45 +++++++++++++++++++ web/control-feedback.js | 7 +++ web/control-feedback.test.mjs | 9 ++++ 5 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 .changeset/easee-load-balancer-limit.md diff --git a/.changeset/easee-load-balancer-limit.md b/.changeset/easee-load-balancer-limit.md new file mode 100644 index 00000000..753c3ae0 --- /dev/null +++ b/.changeset/easee-load-balancer-limit.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +When an Easee charger's load balancer cuts the car below FTW's request, the EV sheet now says "Limited by the charger's load balancer" instead of "Not following" with no reason. Bundles easee_cloud 1.3.7, which keeps that reason while the car charges and matches its reason labels to Easee's published table. diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index 1ed31dd9..ca9824cd 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -40,6 +40,7 @@ type ControlFeedback struct { VerificationLost bool `json:"-"` Escalated bool `json:"-"` // not following long enough to be an alarm Response string `json:"-"` + LimitedBy string `json:"-"` SiteBeforeW *float64 `json:"-"` SiteAfterW *float64 `json:"-"` SiteBeforeAtMs int64 `json:"-"` @@ -72,6 +73,7 @@ type feedbackReading struct { DeviceLimitA *float64 `json:"device_limit_a"` DeviceLimitAgeS *float64 `json:"device_limit_age_s"` Reason string `json:"reason_no_current_label"` + LimitedBy string `json:"current_limited_by"` Connected *bool `json:"connected"` Online *bool `json:"is_online"` } @@ -147,7 +149,7 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback { f := ControlFeedback{Driver: rd.Driver, Kind: kind.String(), Mode: mode, State: "waiting", Reason: "no_command", Severity: "info", Response: "unconfirmed"} var d feedbackReading _ = json.Unmarshal(rd.Data, &d) - f.ReadbackW, f.OfferedA, f.DeviceReason = d.SetpointW, d.MaxA, d.Reason + f.ReadbackW, f.OfferedA, f.DeviceReason, f.LimitedBy = d.SetpointW, d.MaxA, d.Reason, d.LimitedBy if d.DeviceLimitAgeS != nil && *d.DeviceLimitAgeS >= 0 && *d.DeviceLimitAgeS <= 120 { f.DeviceLimitA = d.DeviceLimitA } @@ -357,7 +359,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" @@ -450,10 +452,13 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, return } // A positive clamp explains the lower offer, not prolonged zero delivery. - // Keep known safety pauses and unconfirmed/stale commands authoritative. + // An explicit load-balancer reason can explain zero delivery. Keep known + // safety pauses and unconfirmed/stale commands authoritative. 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" && + f.LimitedBy != "load_balancer" && 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 && f.OfferedA != nil && *f.OfferedA > 0 && noPowerObserved { @@ -533,6 +538,12 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("limited", reason, "info") return } + // The charger names its load balancer only while it holds the car + // back. More power than asked still warns. + if f.Kind == "ev" && f.LimitedBy == "load_balancer" && *f.SentW > 100 && *f.ActualW > -100 && *f.ActualW < *f.SentW { + set("limited", "load_balancer_limit", "info") + return + } reason := "power_above_target" switch { case math.Abs(*f.SentW) < 100: diff --git a/go/internal/api/api_control_scenarios_test.go b/go/internal/api/api_control_scenarios_test.go index 36595786..46d7d4e2 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"}, @@ -366,3 +367,47 @@ func TestChangeOnlyChargerIgnoringNewTargetWarns(t *testing.T) { } }) } + +// A tester's Easee load balancer cut an 11 kW charge to 8.3 kW while the +// battery charged. The charger named its load balancer, so the shortfall is a +// known limit, not an unexplained warning. Without that name it still warns, +// and more power than asked is never explained by a limit. +func TestLoadBalancedChargerReadsAsLimited(t *testing.T) { + for _, tc := range []struct { + name, extra string + actualW float64 + status, reason, severity string + }{ + {"named", `,"current_limited_by":"load_balancer"`, 8300, "limited", "load_balancer_limit", "info"}, + {"held at zero", `,"current_limited_by":"load_balancer"`, 0, "limited", "load_balancer_limit", "info"}, + {"not named", ``, 8300, "not_following", "power_below_target", "warning"}, + {"zero without a named limiter", ``, 0, "not_following", "no_power_response", "warning"}, + {"above target", `,"current_limited_by":"load_balancer"`, 13000, "not_following", "power_above_target", "warning"}, + } { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + tel := telemetry.NewStore() + tel.DriverHealthMut("easee") + srv := New(&Deps{Tel: tel}) + changedAt := time.Now() + var last ControlFeedback + for sec := range 6 * 60 { + if sec%60 == 0 { + c := tel.BeginCommand("easee", []byte(`{"action":"ev_set_current","power_w":11000}`), time.Now()) + tel.CompleteCommand(c, "accepted") + } + time.Sleep(time.Second) + if sec%10 == 0 { + data := fmt.Sprintf(`{"connected":true,"max_a":16,"control_power_observed_at":%q,"control_power_confirmed":true,"power_max_age_s":180%s}`, + changedAt.Format(time.RFC3339Nano), tc.extra) + tel.Update("easee", telemetry.DerEV, tc.actualW, nil, []byte(data)) + } + last = srv.controlFeedback(time.Now())[0] + } + if last.Status != tc.status || last.Reason != tc.reason || last.Severity != tc.severity { + t.Fatalf("got %s/%s/%s, want %s/%s/%s", last.Status, last.Reason, last.Severity, tc.status, tc.reason, tc.severity) + } + }) + }) + } +} diff --git a/web/control-feedback.js b/web/control-feedback.js index e8164403..188f1616 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -66,7 +66,9 @@ 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 balancer', 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', response_unknown: 'No power reading', observe_only: 'Monitoring only', disabled: 'Disabled', device_control: 'Own control', @@ -100,7 +102,11 @@ function sentence(row) { 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 'load_balancer_limit': return num(actual) && actual >= 100 + ? `Asked for ${target(row)}, delivering ${controlPower(actual)}. The charger’s load balancer is limiting the car.` + : `Asked for ${target(row)}, but the charger’s load balancer gives the car no current.`; 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)}.`; case 'no_power_response': return `Asked for ${target(row)}, but it delivers no power.`; @@ -137,6 +143,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 'The load balancer raises the current again when the house uses less. If this happens often, check that FTW’s main fuse setting matches the load balancer’s.'; case 'observe_only': return 'Turn on control in the device settings if you want FTW to steer it.'; default: return ''; } diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index 9250313c..3984da20 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -59,12 +59,21 @@ 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.'); + const balanced = controlStatus(row({kind: 'ev', mode: 'plan', status: 'limited', reason: 'load_balancer_limit', sent_w: 11000, actual_w: 8300})); + assert.equal(balanced.title, 'Limited by the charger’s load balancer'); + assert.equal(balanced.text, 'Asked for 11.0 kW charge, delivering 8.3 kW. The charger’s load balancer is limiting the car.'); + assert.match(balanced.next, /main fuse setting matches the load balancer/); + assert.doesNotMatch(balanced.next, /has not said why/); + assert.equal(balanced.tone, 'neutral'); + const held = controlStatus(row({kind: 'ev', status: 'limited', reason: 'load_balancer_limit', sent_w: 11000, actual_w: 0})); + assert.equal(held.text, 'Asked for 11.0 kW charge, but the charger’s load balancer gives the car no current.'); 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'); assert.equal(onePhase.text, 'The car charges on L1 only, at most 3.7 kW. FTW plans with what it takes.'); assert.equal(onePhase.next, ''); assert.equal(onePhase.tone, 'neutral'); + }); test('the receipt walks from sent to confirmed without inventing proof', () => { From 5c2f2ecc19d9162115d9440e11ae9602a6e00369 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 19:24:43 +0100 Subject: [PATCH 5/7] fix(control): say Easee limits the charger to N A, not "Not following" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Göran's trace (#1763, 23 Oct 2026): FTW ordered 15 A throughout, the charger read back 15 A, while Easee's own load balancing signalled 12, 14 and 13 A to the car. FTW said "Not following". easee_cloud 1.4.1 reports current_limit_a while the charger's offer to the car is below the order, and current_limited_by = "load_balancer" when Easee's allocation shows the cause. Core reads both. Once the order has stood its response time and the car takes about that limit, the verdict is limited/load_balancer_limit (info), or offered_current_lower (warning) when no cause is shown. A car taking far less than the limit, nothing, or more than asked keeps its not-following verdict. The row carries limit_a, limited_by and the charger's make, so the sheet says "Easee limits the charger to 13 A (its load balancing). FTW asked for 15 A; the car takes 8.8 kW." and the numbers list the allocated current beside requested and offered. This replaces #1537's reason-only branch, which also called a car at zero limited. The field matrix gains Göran's charger as two probe rows; its Charge now row fails on master with not_following. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/easee-load-balancer-limit.md | 2 +- go/internal/api/api_control_feedback.go | 50 ++++++--- go/internal/api/api_control_scenarios_test.go | 44 -------- go/internal/api/api_ev_load_balancer_test.go | 106 ++++++++++++++++++ go/internal/api/api_ev_not_following_test.go | 5 +- go/internal/control/sitesim_field_test.go | 55 ++++++++- web/control-feedback.js | 14 +-- web/control-feedback.test.mjs | 22 ++-- 8 files changed, 217 insertions(+), 81 deletions(-) create mode 100644 go/internal/api/api_ev_load_balancer_test.go diff --git a/.changeset/easee-load-balancer-limit.md b/.changeset/easee-load-balancer-limit.md index 753c3ae0..769e4dbe 100644 --- a/.changeset/easee-load-balancer-limit.md +++ b/.changeset/easee-load-balancer-limit.md @@ -2,4 +2,4 @@ "ftw": patch --- -When an Easee charger's load balancer cuts the car below FTW's request, the EV sheet now says "Limited by the charger's load balancer" instead of "Not following" with no reason. Bundles easee_cloud 1.3.7, which keeps that reason while the car charges and matches its reason labels to Easee's published table. +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 Easee's allocated and signalled current only while they are below the order and answer it. A lower offer whose cause the charger does not show reads "The charger offers 12 A, below the 15 A FTW asked for." A car taking far less than the limit allows is still not following, 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 ca9824cd..486541c3 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -40,7 +40,6 @@ type ControlFeedback struct { VerificationLost bool `json:"-"` Escalated bool `json:"-"` // not following long enough to be an alarm Response string `json:"-"` - LimitedBy string `json:"-"` SiteBeforeW *float64 `json:"-"` SiteAfterW *float64 `json:"-"` SiteBeforeAtMs int64 `json:"-"` @@ -57,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"` @@ -73,6 +78,7 @@ 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"` @@ -149,7 +155,15 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback { f := ControlFeedback{Driver: rd.Driver, Kind: kind.String(), Mode: mode, State: "waiting", Reason: "no_command", Severity: "info", Response: "unconfirmed"} var d feedbackReading _ = json.Unmarshal(rd.Data, &d) - f.ReadbackW, f.OfferedA, f.DeviceReason, f.LimitedBy = d.SetpointW, d.MaxA, d.Reason, d.LimitedBy + 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 } @@ -427,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 = nil, nil, nil, nil, nil, "" set("unknown", "telemetry_stale", "warning") return } @@ -452,13 +466,10 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, return } // A positive clamp explains the lower offer, not prolonged zero delivery. - // An explicit load-balancer reason can explain zero delivery. Keep known - // safety pauses and unconfirmed/stale commands authoritative. + // Keep known safety pauses and unconfirmed/stale commands authoritative. 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" && - f.LimitedBy != "load_balancer" && 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 && f.OfferedA != nil && *f.OfferedA > 0 && noPowerObserved { @@ -492,6 +503,23 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("warning", "setpoint_changed", "warning") return } + // The charger's driver reports the current it lets the car take while + // that is below the order, and names its load balancing when the + // charger's own allocation shows it (easee_cloud 1.4.1). That explains a + // car taking about that much, not one taking far less or more than + // asked. A lower offer 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.5 < *f.RequestedA && *f.ActualW < *f.SentW && settled { + if limitW := *f.RequestedW * *f.LimitA / *f.RequestedA; *f.ActualW >= limitW-telemetry.ControlToleranceW(limitW) { + if f.LimitedBy == "load_balancer" { + set("limited", "load_balancer_limit", "info") + } else { + set("limited", "offered_current_lower", "warning") + } + return + } + } if f.Kind == "ev" && f.SentW != nil && f.RequestedA != nil && f.OfferedA != nil && *f.SentW > 100 && *f.OfferedA+0.5 < *f.RequestedA && settled { set("limited", "offered_current_lower", "warning") return @@ -538,12 +566,6 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("limited", reason, "info") return } - // The charger names its load balancer only while it holds the car - // back. More power than asked still warns. - if f.Kind == "ev" && f.LimitedBy == "load_balancer" && *f.SentW > 100 && *f.ActualW > -100 && *f.ActualW < *f.SentW { - set("limited", "load_balancer_limit", "info") - return - } reason := "power_above_target" switch { case math.Abs(*f.SentW) < 100: diff --git a/go/internal/api/api_control_scenarios_test.go b/go/internal/api/api_control_scenarios_test.go index 46d7d4e2..4470550b 100644 --- a/go/internal/api/api_control_scenarios_test.go +++ b/go/internal/api/api_control_scenarios_test.go @@ -367,47 +367,3 @@ func TestChangeOnlyChargerIgnoringNewTargetWarns(t *testing.T) { } }) } - -// A tester's Easee load balancer cut an 11 kW charge to 8.3 kW while the -// battery charged. The charger named its load balancer, so the shortfall is a -// known limit, not an unexplained warning. Without that name it still warns, -// and more power than asked is never explained by a limit. -func TestLoadBalancedChargerReadsAsLimited(t *testing.T) { - for _, tc := range []struct { - name, extra string - actualW float64 - status, reason, severity string - }{ - {"named", `,"current_limited_by":"load_balancer"`, 8300, "limited", "load_balancer_limit", "info"}, - {"held at zero", `,"current_limited_by":"load_balancer"`, 0, "limited", "load_balancer_limit", "info"}, - {"not named", ``, 8300, "not_following", "power_below_target", "warning"}, - {"zero without a named limiter", ``, 0, "not_following", "no_power_response", "warning"}, - {"above target", `,"current_limited_by":"load_balancer"`, 13000, "not_following", "power_above_target", "warning"}, - } { - t.Run(tc.name, func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - tel := telemetry.NewStore() - tel.DriverHealthMut("easee") - srv := New(&Deps{Tel: tel}) - changedAt := time.Now() - var last ControlFeedback - for sec := range 6 * 60 { - if sec%60 == 0 { - c := tel.BeginCommand("easee", []byte(`{"action":"ev_set_current","power_w":11000}`), time.Now()) - tel.CompleteCommand(c, "accepted") - } - time.Sleep(time.Second) - if sec%10 == 0 { - data := fmt.Sprintf(`{"connected":true,"max_a":16,"control_power_observed_at":%q,"control_power_confirmed":true,"power_max_age_s":180%s}`, - changedAt.Format(time.RFC3339Nano), tc.extra) - tel.Update("easee", telemetry.DerEV, tc.actualW, nil, []byte(data)) - } - last = srv.controlFeedback(time.Now())[0] - } - if last.Status != tc.status || last.Reason != tc.reason || last.Severity != tc.severity { - t.Fatalf("got %s/%s/%s, want %s/%s/%s", last.Status, last.Reason, last.Severity, tc.status, tc.reason, tc.severity) - } - }) - }) - } -} 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..6738fff0 --- /dev/null +++ b/go/internal/api/api_ev_load_balancer_test.go @@ -0,0 +1,106 @@ +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) { + const named = `,"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", `,"current_limit_a":13`, 8850, "limited", "offered_current_lower", "warning"}, + {"no limit reported", ``, 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"}, + {"limit at the order", `,"current_limit_a":15,"current_limited_by":"load_balancer"`, 8850, "not_following", "power_below_target", "warning"}, + } { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + e := newEVTrace(10370) + 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.LimitA == nil || *f.LimitA != 13 || + f.LimitedBy != "load_balancer" || f.ActualW == nil || *f.ActualW != 8850 { + t.Fatalf("numbers: requested %v, limit %v by %q, actual %v", f.RequestedA, f.LimitA, f.LimitedBy, f.ActualW) + } + }) + }) + } +} + +// 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) + } + } +} 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..30569f94 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,23 @@ 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 { + 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 +691,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 +722,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 +750,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 188f1616..20b6841e 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -66,9 +66,8 @@ 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 balancer', + 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', response_unknown: 'No power reading', observe_only: 'Monitoring only', disabled: 'Disabled', device_control: 'Own control', @@ -101,12 +100,10 @@ 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 'load_balancer_limit': return num(actual) && actual >= 100 - ? `Asked for ${target(row)}, delivering ${controlPower(actual)}. The charger’s load balancer is limiting the car.` - : `Asked for ${target(row)}, but the charger’s load balancer gives the car no current.`; + case 'offered_current_lower': return `The charger offers ${amps(num(row.limit_a) ? row.limit_a : 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)}.`; case 'no_power_response': return `Asked for ${target(row)}, but it delivers no power.`; @@ -143,7 +140,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 'The load balancer raises the current again when the house uses less. If this happens often, check that FTW’s main fuse setting matches the load balancer’s.'; + 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 ''; } @@ -234,6 +231,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' ? 'Allocated by load balancing' : 'Offered to the car', 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 3984da20..c122078a 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -59,21 +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.'); - const balanced = controlStatus(row({kind: 'ev', mode: 'plan', status: 'limited', reason: 'load_balancer_limit', sent_w: 11000, actual_w: 8300})); - assert.equal(balanced.title, 'Limited by the charger’s load balancer'); - assert.equal(balanced.text, 'Asked for 11.0 kW charge, delivering 8.3 kW. The charger’s load balancer is limiting the car.'); - assert.match(balanced.next, /main fuse setting matches the load balancer/); - assert.doesNotMatch(balanced.next, /has not said why/); + // 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'); - const held = controlStatus(row({kind: 'ev', status: 'limited', reason: 'load_balancer_limit', sent_w: 11000, actual_w: 0})); - assert.equal(held.text, 'Asked for 11.0 kW charge, but the charger’s load balancer gives the car no current.'); + 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|Allocated/.test(label)), [ + ['Requested current', '15 A'], ['Charger offer', '15 A'], ['Charger limit', '16 A'], ['Allocated by load balancing', '13 A']]); + // A lower offer with no cause shown says what the charger offers, no more. + const unexplained = {...lb, reason: 'offered_current_lower', severity: 'warning', limited_by: undefined}; + assert.equal(controlStatus(row(unexplained)).text, 'The charger offers 13 A, below the 15 A FTW asked for.'); + assert.deepEqual(controlNumbers(row(unexplained)).at(-1), ['Offered to the car', '13 A']); 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'); assert.equal(onePhase.text, 'The car charges on L1 only, at most 3.7 kW. FTW plans with what it takes.'); assert.equal(onePhase.next, ''); assert.equal(onePhase.tone, 'neutral'); - }); test('the receipt walks from sent to confirmed without inventing proof', () => { From 22ee4668e8ccbc60984b50e83cded419aefdee34 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 19:38:37 +0100 Subject: [PATCH 6/7] fix(control): a charger holding a lower order says so first Review fixes to the load-balancer verdict: - Check the order the charger holds (max_a) before the driver's lower current, so a charger holding 12 A of a 15 A order still reads offered_current_lower. - A car taking more than the limit allows is not limited by it. - Allow 2 A below the limit as well as 10 %, so a balancer stepping up from 12 to 14 A does not flash "Not following" while the cloud still reports the car's old draw. - Require a positive limit; clear make with the other stale fields. - Words: "Load balancing allows 13 A" and "The charger lets the car take 13 A" for the driver's current, so a card never says the charger offers both 13 A and 15 A. - The field matrix's balancer rows send max_a like the driver. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/easee-load-balancer-limit.md | 2 +- go/internal/api/api_control_feedback.go | 29 ++++++------ go/internal/api/api_ev_load_balancer_test.go | 49 +++++++++++++++++--- go/internal/control/sitesim_field_test.go | 1 + web/control-feedback.js | 6 ++- web/control-feedback.test.mjs | 13 ++++-- 6 files changed, 72 insertions(+), 28 deletions(-) diff --git a/.changeset/easee-load-balancer-limit.md b/.changeset/easee-load-balancer-limit.md index 769e4dbe..7f48993d 100644 --- a/.changeset/easee-load-balancer-limit.md +++ b/.changeset/easee-load-balancer-limit.md @@ -2,4 +2,4 @@ "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 Easee's allocated and signalled current only while they are below the order and answer it. A lower offer whose cause the charger does not show reads "The charger offers 12 A, below the 15 A FTW asked for." A car taking far less than the limit allows is still not following, and an old charger reason is never shown as the cause. +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 486541c3..001b0e7e 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -441,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, f.LimitA, f.LimitedBy = nil, 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 } @@ -503,15 +503,22 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("warning", "setpoint_changed", "warning") return } - // The charger's driver reports the current it lets the car take while - // that is below the order, and names its load balancing when the - // charger's own allocation shows it (easee_cloud 1.4.1). That explains a - // car taking about that much, not one taking far less or more than - // asked. A lower offer whose cause the driver does not show still asks - // for a look. + if f.Kind == "ev" && f.SentW != nil && f.RequestedA != nil && f.OfferedA != nil && *f.SentW > 100 && *f.OfferedA+0.5 < *f.RequestedA && settled { + 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: within 10 % or 2 A, as the car follows a step, never more. 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.5 < *f.RequestedA && *f.ActualW < *f.SentW && settled { - if limitW := *f.RequestedW * *f.LimitA / *f.RequestedA; *f.ActualW >= limitW-telemetry.ControlToleranceW(limitW) { + *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, 2*perA) && *f.ActualW <= limitW+tolerance && *f.ActualW < *f.SentW { if f.LimitedBy == "load_balancer" { set("limited", "load_balancer_limit", "info") } else { @@ -520,10 +527,6 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, return } } - if f.Kind == "ev" && f.SentW != nil && f.RequestedA != nil && f.OfferedA != nil && *f.SentW > 100 && *f.OfferedA+0.5 < *f.RequestedA && settled { - 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_ev_load_balancer_test.go b/go/internal/api/api_ev_load_balancer_test.go index 6738fff0..faea21fa 100644 --- a/go/internal/api/api_ev_load_balancer_test.go +++ b/go/internal/api/api_ev_load_balancer_test.go @@ -20,22 +20,29 @@ import ( // "Not following". The driver now reports the limit and, when its // allocation shows it, names the load balancing. func TestEaseeLoadBalancingReadsAsLimited(t *testing.T) { - const named = `,"current_limit_a":13,"current_limited_by":"load_balancer"` + // 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", `,"current_limit_a":13`, 8850, "limited", "offered_current_lower", "warning"}, - {"no limit reported", ``, 8850, "not_following", "power_below_target", "warning"}, + {"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"}, - {"limit at the order", `,"current_limit_a":15,"current_limited_by":"load_balancer"`, 8850, "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 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") } @@ -59,15 +66,43 @@ func TestEaseeLoadBalancingReadsAsLimited(t *testing.T) { return } // Requested, allocated and delivered stay apart. - if f.RequestedA == nil || math.Abs(*f.RequestedA-15) > 0.01 || f.LimitA == nil || *f.LimitA != 13 || - f.LimitedBy != "load_balancer" || f.ActualW == nil || *f.ActualW != 8850 { - t.Fatalf("numbers: requested %v, limit %v by %q, actual %v", f.RequestedA, f.LimitA, f.LimitedBy, f.ActualW) + 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) { diff --git a/go/internal/control/sitesim_field_test.go b/go/internal/control/sitesim_field_test.go index 30569f94..5b0c8d30 100644 --- a/go/internal/control/sitesim_field_test.go +++ b/go/internal/control/sitesim_field_test.go @@ -478,6 +478,7 @@ func (s *simSite) evData() map[string]any { // 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) diff --git a/web/control-feedback.js b/web/control-feedback.js index 20b6841e..555b41ee 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -100,7 +100,9 @@ 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(num(row.limit_a) ? row.limit_a : row.offered_a)}, below the ${amps(row.requested_a)} FTW asked for.`; + case 'offered_current_lower': return num(row.limit_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.`; @@ -231,7 +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' ? 'Allocated by load balancing' : 'Offered to the car', amps(row.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 c122078a..633fe42d 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -68,12 +68,15 @@ test('expected limits stay calm and explain themselves', () => { 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|Allocated/.test(label)), [ - ['Requested current', '15 A'], ['Charger offer', '15 A'], ['Charger limit', '16 A'], ['Allocated by load balancing', '13 A']]); - // A lower offer with no cause shown says what the charger offers, no more. + 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 offers 13 A, below the 15 A FTW asked for.'); - assert.deepEqual(controlNumbers(row(unexplained)).at(-1), ['Offered to the car', '13 A']); + 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.'); 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'); From 84a3aada1c46a38b8a067c50a6577a64d4a57a73 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 19:55:02 +0100 Subject: [PATCH 7/7] fix(control): second review pass on the limit margin and words - The margin below the limit is 10 %, or as the car follows a step up to 2 A but at most 15 % of the limit, so a car with its own cap well under a low limit stays not following. - When the charger holds a lower order and the driver also reports a lower current, the sentence names the held order. - Tests for the positive-limit guard without a readback, for stale rows dropping limit, cause and make, and for both margin edges. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- go/internal/api/api_control_feedback.go | 9 +++++---- go/internal/api/api_ev_load_balancer_test.go | 9 +++++++++ web/control-feedback.js | 2 +- web/control-feedback.test.mjs | 3 +++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index 001b0e7e..eb5abc40 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -510,15 +510,16 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, // 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: within 10 % or 2 A, as the car follows a step, never more. 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. + // 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, 2*perA) && *f.ActualW <= limitW+tolerance && *f.ActualW < *f.SentW { + 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 { diff --git a/go/internal/api/api_ev_load_balancer_test.go b/go/internal/api/api_ev_load_balancer_test.go index faea21fa..fd8901b9 100644 --- a/go/internal/api/api_ev_load_balancer_test.go +++ b/go/internal/api/api_ev_load_balancer_test.go @@ -33,9 +33,12 @@ func TestEaseeLoadBalancingReadsAsLimited(t *testing.T) { {"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"}, } { @@ -138,4 +141,10 @@ function driver_default_mode() end 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/web/control-feedback.js b/web/control-feedback.js index 555b41ee..9fda1e31 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -100,7 +100,7 @@ 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 num(row.limit_a) + 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)` diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index 633fe42d..16cb4db5 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -77,6 +77,9 @@ test('expected limits stay calm and explain themselves', () => { // 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');