From 1467e1784332959c45d3bc1d9a24fc8ef0775c9c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 18:35:04 +0200 Subject: [PATCH] fix(loadpoint): a 1p order stays within the charger's own current When Core sends phase_mode 1p to a loadpoint that can run three phases, max_amps_per_phase was the site fuse. The surplus 1p lock hands back the whole step table, so a 16 A charger on a 25 A fuse got 18-25 A on one phase (simulatorprov 21 Oct 2026, case s5s). The order's per-phase limit now stops at the most the charger is known to take on one phase: max_charge_w over three phases, or the phase split, whichever is larger. It only ever lowers the limit. Co-Authored-By: Claude Opus 5.5 --- .changeset/charger-phase-current-cap.md | 5 + .../control/ev_fuse_phase_room_test.go | 5 +- go/internal/loadpoint/controller.go | 1 + .../loadpoint/controller_phase_choice_test.go | 117 ++++++++++++++++++ go/internal/loadpoint/manual_limits.go | 39 ++++++ 5 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 .changeset/charger-phase-current-cap.md diff --git a/.changeset/charger-phase-current-cap.md b/.changeset/charger-phase-current-cap.md new file mode 100644 index 00000000..1a65c1b0 --- /dev/null +++ b/.changeset/charger-phase-current-cap.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A charger is never asked for more current per phase than it supports. When FTW moves a charger that can run three phases onto one phase, the order now stops at that charger's own current per phase instead of the site fuse. Before, a 16 A charger on a 25 A fuse could be offered 18–25 A on one phase while charging from surplus or on Charge now. diff --git a/go/internal/control/ev_fuse_phase_room_test.go b/go/internal/control/ev_fuse_phase_room_test.go index 2121d7e0..c2347ac3 100644 --- a/go/internal/control/ev_fuse_phase_room_test.go +++ b/go/internal/control/ev_fuse_phase_room_test.go @@ -205,7 +205,8 @@ func TestEVFuseRaisedCarTakesOnlyFreeRoom(t *testing.T) { // A 1p hold over a 3p loadpoint: each offer counts on the phases its own // command names, never the configured ones, even when the budget is read // with the configured phase mode. Before, 1p offers counted as 3p and L1 -// reached 39 A. +// reached 39 A. The hold asks 24 A; the charger is 16 A per phase, so the +// car climbs from 9 A beside 15 A to the charger's 16 A once L1 falls. func TestEVFuseOnePhaseOffersCountOnTheirPhase(t *testing.T) { for _, delay := range []time.Duration{5 * time.Second, 90 * time.Second} { t.Run(delay.String(), func(t *testing.T) { @@ -219,7 +220,7 @@ func TestEVFuseOnePhaseOffersCountOnTheirPhase(t *testing.T) { s.houseA[0] = 5 s.run(6 * time.Minute) s.requireWithinLimit("after L1 fell") - if !c.onePhase || c.drawW < 4140 { + if !c.onePhase || c.drawW < 3680 { t.Fatalf("the car draws %.0f W (1p %v), want it to climb on L1", c.drawW, c.onePhase) } }) diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 2848e30b..07bc69f3 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1593,6 +1593,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s // A manual diagnostic may lower a site limit, but never replace the // installation's voltage, phase count or fuse with a larger offer. c.applyInstallationLimits(cmd) + applyChargerPhaseLimit(cmd, lpCfg, c.siteFuse()) if applyCurrentCeiling(cmd) { cmdReason = "charger_limit" } diff --git a/go/internal/loadpoint/controller_phase_choice_test.go b/go/internal/loadpoint/controller_phase_choice_test.go index 2c82752e..804f2403 100644 --- a/go/internal/loadpoint/controller_phase_choice_test.go +++ b/go/internal/loadpoint/controller_phase_choice_test.go @@ -175,3 +175,120 @@ func TestThreePhaseFuseCapNeverSnapsBelowSixAmps(t *testing.T) { t.Fatalf("offer %+v, reason %q; want 0 W fuse_limit", command, st.CommandedReason) } } + +// onePhaseSteps are whole amps from 6 to 16 A on one phase at 230 V. +var onePhaseSteps = []float64{0, 1380, 1610, 1840, 2070, 2300, 2530, 2760, 2990, 3220, 3450, 3680} + +// commandAmps is the current per phase a driver draws for the command, as +// the OCPP driver works it out: the watts over the phases the command +// names, never above max_amps_per_phase. +func commandAmps(cmd sentCommand) float64 { + if cmd.power <= 0 || cmd.voltage <= 0 { + return 0 + } + a := cmd.power / (cmd.voltage * float64(PhaseFor(cmd.phaseMode, cmd.power, defaultPhaseSplitW))) + if cmd.maxAmpsPerPhase > 0 { + a = math.Min(a, cmd.maxAmpsPerPhase) + } + return a +} + +// Simulatorprov 21 Oct 2026, case s5s: surplus only, 9.3 kW of surplus and +// a plan that shows no PV. The day's 1p lock hands back every step, so 1p +// went out at 5,750 W with the 25 A fuse as its only limit: 25 A on one +// phase of a 16 A charger. A one-phase charger left on an empty mode gets +// the same lock and must keep its full 16 A. +func TestSurplusOnePhaseOfferStaysWithinTheCharger(t *testing.T) { + for _, tc := range []struct { + name string + cfg Config + }{ + {"16 A three-phase charger, auto", Config{MinChargeW: 4140, MaxChargeW: 11040, PhaseMode: "auto", AllowedStepsW: threePhaseSteps}}, + {"16 A one-phase charger, empty mode", Config{MinChargeW: 1380, MaxChargeW: 3680, AllowedStepsW: onePhaseSteps}}, + } { + t.Run(tc.name, func(t *testing.T) { + now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.Local) + cfg := tc.cfg + cfg.ID, cfg.DriverName, cfg.SurplusOnly = "garage", "evse", true + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetSiteSurplusForEV(func() (float64, bool) { return 9310, true }) + c.SetNearTermPeakSurplusW(func(time.Duration) (float64, bool) { return 0, true }) + c.SetPeakRemainingSurplusW(func() (float64, bool) { return 0, true }) + for tick := 0; tick < 10; tick++ { + c.Tick(context.Background(), now) + cmd, _ := lastSetCurrent(sender.calls) + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerW: cmd.power} + now = now.Add(5 * time.Second) + } + onePhase := 0 + for _, cmd := range sender.calls { + if cmd.action != "ev_set_current" { + continue + } + if a := commandAmps(cmd); a > 16+1e-9 { + t.Fatalf("offer %+v is %.1f A per phase to a 16 A charger", cmd, a) + } + if cmd.phaseMode == "1p" { + onePhase++ + if cmd.maxAmpsPerPhase > 16+1e-9 { + t.Fatalf("1p offer %+v allows %.1f A per phase", cmd, cmd.maxAmpsPerPhase) + } + } + } + last, ok := lastSetCurrent(sender.calls) + if onePhase == 0 || !ok || last.phaseMode != "1p" || last.power != 3680 { + t.Fatalf("%d 1p offers, last %+v; want the 1p lock to charge at 3,680 W (16 A)", onePhase, last) + } + }) + } +} + +// Charge now on one phase gets the charger's own current on that phase, +// and the fuse decides the rest. A 16 A charger that can run three phases +// gets 16 A, not the 25 A fuse. A 32 A charger, whether it switches phases +// or is a one-phase charger, keeps its 32 A when the fuse allows it. +func TestChargeNowOnOnePhaseKeepsTheChargersCurrent(t *testing.T) { + for _, tc := range []struct { + name string + cfg Config + fuseA float64 + holdW float64 + wantW float64 + wantA float64 + reason string + }{ + {"16 A three-phase charger, auto", Config{MinChargeW: 4140, MaxChargeW: 11040, PhaseMode: "auto", AllowedStepsW: threePhaseSteps}, 25, 7360, 3680, 16, "charger_limit"}, + {"16 A three-phase charger, empty mode", Config{MinChargeW: 4140, MaxChargeW: 11040, AllowedStepsW: threePhaseSteps}, 25, 7360, 3680, 16, "charger_limit"}, + {"32 A three-phase charger, auto", Config{MinChargeW: 1380, MaxChargeW: 22080, PhaseMode: "auto"}, 40, 7360, 7360, 32, "manual_hold"}, + {"32 A one-phase charger", Config{MinChargeW: 1380, MaxChargeW: 7360, PhaseMode: "1p"}, 40, 7360, 7360, 32, "manual_hold"}, + {"16 A one-phase charger, empty mode", Config{MinChargeW: 1380, MaxChargeW: 3680, AllowedStepsW: onePhaseSteps}, 25, 3680, 3680, 16, "manual_hold"}, + } { + t.Run(tc.name, func(t *testing.T) { + now := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC) + cfg := tc.cfg + cfg.ID, cfg.DriverName = "garage", "evse" + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: tc.fuseA, Voltage: 230, PhaseCnt: 3}) + evW := 0.0 // 3 A of house load plus the car on one phase. + c.SetFuseEVMax(meteredEVCap(c, func() float64 { return 3 + evW/230 })) + c.SetManualHold(cfg.ID, ManualHold{PowerW: tc.holdW, PhaseMode: "1p", Persistent: true}) + for tick := 0; tick < 12; tick++ { + c.Tick(context.Background(), now) + cmd, ok := lastSetCurrent(sender.calls) + st, _ := c.manager.State(cfg.ID) + if !ok || cmd.phaseMode != "1p" || cmd.power != tc.wantW || math.Abs(commandAmps(cmd)-tc.wantA) > 1e-9 || st.CommandedReason != tc.reason { + t.Fatalf("tick %d: offer %+v (%.1f A), reason %q; want %.0f W on 1p, %.0f A, %q", + tick, cmd, commandAmps(cmd), st.CommandedReason, tc.wantW, tc.wantA, tc.reason) + } + evW = cmd.power + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerW: evW} + now = now.Add(5 * time.Second) + } + }) + } +} diff --git a/go/internal/loadpoint/manual_limits.go b/go/internal/loadpoint/manual_limits.go index bc14f74e..c0f12a08 100644 --- a/go/internal/loadpoint/manual_limits.go +++ b/go/internal/loadpoint/manual_limits.go @@ -89,6 +89,45 @@ func (c *Controller) applyInstallationLimits(cmd map[string]any) { } } +// applyChargerPhaseLimit keeps a 1p order to a charger that can run three +// phases within the current that charger takes on one phase; otherwise a +// 25 A fuse lets 25 A onto one phase of a 16 A charger. It only lowers the +// order's limit. A 1p charger's maximum already caps its one phase, and on +// a one-phase site the maximum is the one-phase rating. +func applyChargerPhaseLimit(cmd map[string]any, cfg Config, site SiteFuse) { + if mode, _ := cmd["phase_mode"].(string); mode != "1p" || cfg.PhaseMode == "1p" || site.Phases() != 3 { + return + } + a := chargerPhaseAmps(cfg, site.Voltage) + if a < 6 { + return // Not a rating any charger has; leave the order as it is. + } + limit, _ := cmd["max_amps_per_phase"].(float64) + if !finite(limit) || limit <= 0 || limit > a { + cmd["max_amps_per_phase"] = a + } +} + +// chargerPhaseAmps is the most current the charger is known to take on one +// phase: its maximum spread over three phases, or the split below which +// auto puts it on one phase, whichever is larger. The maximum alone gives a +// one-phase charger left on an empty mode a third of its current (3,680 W +// is 5.3 A, so it never charges); the split alone gives a 32 A charger 16 A. +func chargerPhaseAmps(cfg Config, voltage float64) float64 { + if !finite(voltage) || voltage <= 0 { + voltage = 230 + } + split := cfg.PhaseSplitW + if !finite(split) || split <= 0 { + split = defaultPhaseSplitW + } + maxW := cfg.MaxChargeW + if !finite(maxW) || maxW < 0 { + maxW = 0 + } + return math.Max(maxW/(3*voltage), split/voltage) +} + // The watt order must reflect the final current ceiling too. In particular, // drivers often interpret max_amps_per_phase=0 as an absent override; send an // explicit zero-power order when the fuse leaves less than the 6 A minimum.