From 0156fb7963f516afec6b1c7cc5e0ac2efb982134 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 11:55:49 +0200 Subject: [PATCH] fix(loadpoint): Charge now runs on one phase on a charger that cannot run three An app sends Charge now with the phases the API reports, and the API reported three for an auto loadpoint. On a charger whose table stops below 6 A on three phases, a 3p order has no step and went out as 0 W: the field matrix's two hold3p rows, car idle with room. A client's 3p on such a charger is not a pin: the loadpoint's own mode decides, so auto and unset run on one phase. A loadpoint pinned to 3p keeps it. The API reports one phase for such a charger unless it is pinned to 3p, as Core runs it. Both hold3p rows pass; their lines and label go. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-charge-now-one-phase-charger.md | 5 ++ go/internal/api/api_loadpoint_manual.go | 20 ++++--- go/internal/api/api_loadpoint_phases_test.go | 53 +++++++++++++++++++ .../control/testdata/sitesim_field_known.txt | 5 -- go/internal/loadpoint/controller.go | 7 ++- .../loadpoint/planned_three_phase_test.go | 25 +++++++++ go/internal/loadpoint/planning_steps.go | 4 ++ 7 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 .changeset/ev-charge-now-one-phase-charger.md create mode 100644 go/internal/api/api_loadpoint_phases_test.go diff --git a/.changeset/ev-charge-now-one-phase-charger.md b/.changeset/ev-charge-now-one-phase-charger.md new file mode 100644 index 00000000..304a4b80 --- /dev/null +++ b/.changeset/ev-charge-now-one-phase-charger.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Charge now works on a one-phase charger again when the app asks for three phases. On a charger whose levels stop below 6 A on three phases, such as a 32 A one-phase charger with the levels 1.4, 2.3 and 3.7 kW, the app sent Charge now as three-phase, because FTW reported three phases for an auto charger. A three-phase order there has no level, so the car got 0 W. FTW now runs it on one phase unless the charger is set to 3p, and reports one phase for such a charger, so the app sends what it can run. diff --git a/go/internal/api/api_loadpoint_manual.go b/go/internal/api/api_loadpoint_manual.go index 22380cac..a63134d5 100644 --- a/go/internal/api/api_loadpoint_manual.go +++ b/go/internal/api/api_loadpoint_manual.go @@ -249,14 +249,16 @@ func (s *Server) handleLoadpointManualHoldGet(w http.ResponseWriter, r *http.Req // slider (range from min/max charge W ÷ phases ÷ voltage) and reflect // whether a manual hold is currently pinned. Phases come from the // loadpoint's phase_mode (1p/3p) or the site fuse; voltage from the -// site fuse. Mutates states in place. +// site fuse. A charger that cannot run three phases reports one unless +// pinned to 3p, as Core runs it. Mutates states in place. func (s *Server) decorateLoadpointsWithManual(states []loadpoint.State) { var fusePhases int - var voltage float64 + var voltage, maxAmps float64 if s.deps.CfgMu != nil && s.deps.Cfg != nil { s.deps.CfgMu.RLock() fusePhases = s.deps.Cfg.Fuse.Phases voltage = s.deps.Cfg.Fuse.Voltage + maxAmps = s.deps.Cfg.Fuse.MaxAmps s.deps.CfgMu.RUnlock() } if fusePhases <= 0 { @@ -266,10 +268,11 @@ func (s *Server) decorateLoadpointsWithManual(states []loadpoint.State) { voltage = 230 } - phaseModeByID := map[string]string{} + site := loadpoint.SiteFuse{MaxAmps: maxAmps, Voltage: voltage, PhaseCnt: fusePhases} + cfgByID := map[string]loadpoint.Config{} if s.deps.Loadpoints != nil { for _, c := range s.deps.Loadpoints.Configs() { - phaseModeByID[c.ID] = c.PhaseMode + cfgByID[c.ID] = c } } @@ -277,11 +280,14 @@ func (s *Server) decorateLoadpointsWithManual(states []loadpoint.State) { chargers := s.chargerReadings() for i := range states { phases := fusePhases - switch phaseModeByID[states[i].ID] { - case "1p": + cfg, known := cfgByID[states[i].ID] + switch { + case cfg.PhaseMode == "1p": phases = 1 - case "3p": + case cfg.PhaseMode == "3p": phases = 3 + case known && !loadpoint.ThreePhaseCapable(cfg, site): + phases = 1 } states[i].Phases = phases states[i].VoltageV = voltage diff --git a/go/internal/api/api_loadpoint_phases_test.go b/go/internal/api/api_loadpoint_phases_test.go new file mode 100644 index 00000000..722fe592 --- /dev/null +++ b/go/internal/api/api_loadpoint_phases_test.go @@ -0,0 +1,53 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +// The app sends Charge now with the phases the API reports. A charger whose +// table stops below 6 A on three phases runs on one, so it reports one +// unless pinned to 3p; a loadpoint that can run three reports three. +func TestLoadpointsReportTheChargersPhases(t *testing.T) { + onePhase := []float64{0, 1380, 2300, 3680} + mixed := []float64{0, 1380, 2300, 3680, 4140, 6900, 11040} + for _, tc := range []struct { + name string + mode string + steps []float64 + maxW float64 + want int + }{ + {"32 A one-phase table, auto", "auto", onePhase, 7360, 1}, + {"one-phase table, unset", "", onePhase, 7360, 1}, + {"one-phase table pinned 3p", "3p", onePhase, 7360, 3}, + {"mixed table, auto", "auto", mixed, 11040, 3}, + {"no table, unset", "", nil, 11000, 3}, + {"pinned 1p", "1p", mixed, 11040, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", PhaseMode: tc.mode, AllowedStepsW: tc.steps, + MinChargeW: 1380, MaxChargeW: tc.maxW}}) + cfg := &config.Config{Fuse: config.Fuse{MaxAmps: 25, Phases: 3, Voltage: 230}} + srv := New(&Deps{Loadpoints: mgr, Cfg: cfg, CfgMu: &sync.RWMutex{}}) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil)) + var body struct { + Loadpoints []loadpoint.State `json:"loadpoints"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil || len(body.Loadpoints) != 1 { + t.Fatalf("GET /api/loadpoints = %d: %s", rr.Code, rr.Body.String()) + } + if got := body.Loadpoints[0].Phases; got != tc.want { + t.Fatalf("phases %d, want %d", got, tc.want) + } + }) + } +} diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index f3a4a200..71f1a29a 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -13,17 +13,12 @@ # last raise, then pauses when both land (surplus_reserve.go:128). # relief srcfl/ftw#1789, draft: the battery ends its relief before the # car's cut shows. -# hold3p no fix yet: the app sends Charge now as 3p for an auto -# loadpoint, and on a charger whose table stops below 4,140 W -# Core floors the order to 0 W (fuse_limit). pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/chargenow+battery own-raise relief pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/pause+battery own-raise relief pr1763/25A/3p/3p-15A/easee120s/car3p/6-2-2/chargenow+battery own-raise relief pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery own-raise relief pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/pause+battery own-raise relief pr1763/25A/3p/3p-15A/fast2s/car3p/6-2-2/pause+battery own-raise relief -probe/25A/auto/1p-3/easee40s/car3p/balanced/chargenow+charger1p32A idle hold3p -probe/25A/auto/1p-3/fast2s/car3p/balanced/chargenow+charger1p32A idle hold3p probe/25A/auto/mixed/easee40s/car3p/balanced/surplus+goal+export2500W idle readback timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle export timing/16A/3p/3p/easee120s/car3p/L1/surplus idle export diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 89bdca6b..71e6fc88 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1293,10 +1293,13 @@ func (c *Controller) chooseTick(now time.Time, cfg Config, sample EVSample, disp // holdPhaseMode: a pinned hold or loadpoint mode wins. Otherwise Charge now // runs as planned charging does: three phases on a charger that can, one on // a charger that cannot. A hold below the 3Φ minimum goes out as auto, so a -// sticky Start on a 1380 W surplus slot does not die at 3Φ's 6 A. +// sticky Start on a 1380 W surplus slot does not die at 3Φ's 6 A. A +// client's 3p on a charger that cannot run three phases is not a pin: the +// app sends the phases the API reported, and a 3p order there is 0 W. The +// loadpoint's own mode decides. func holdPhaseMode(hold ManualHold, cfg Config, site SiteFuse) string { mode := hold.PhaseMode - if mode == "" { + if mode == "" || (mode == "3p" && !threePhaseCapable(cfg, site)) { mode = cfg.PhaseMode } switch { diff --git a/go/internal/loadpoint/planned_three_phase_test.go b/go/internal/loadpoint/planned_three_phase_test.go index 69bbc9cb..83efdbc5 100644 --- a/go/internal/loadpoint/planned_three_phase_test.go +++ b/go/internal/loadpoint/planned_three_phase_test.go @@ -108,6 +108,10 @@ func TestChargeNowRunsOnThreePhases(t *testing.T) { {"loadpoint pinned to 1p", "1p", "", 3680, 11100, "1p"}, {"hold auto, full power", "", "auto", 11100, 11100, "3p"}, {"one-phase charger, unset", "", "", 3680, 3680, "1p"}, + // A client's 3p is not a pin on a charger that cannot run it. + {"one-phase charger, client sends 3p", "", "3p", 3680, 3680, "1p"}, + {"one-phase charger on auto, client sends 3p", "auto", "3p", 3680, 3680, "1p"}, + {"one-phase charger pinned 3p", "3p", "3p", 3680, 3680, "3p"}, } { got := holdPhaseMode(ManualHold{PowerW: c.powerW, PhaseMode: c.holdMode}, Config{MinChargeW: 1380, MaxChargeW: c.maxW, PhaseMode: c.cfgMode}, site) if got != c.want { @@ -162,3 +166,24 @@ func TestSplitKeepsTheCurrentOrder(t *testing.T) { } } } + +// Field matrix, hold3p: an app sends Charge now as 3p for an auto +// loadpoint. On a 32 A one-phase charger whose table stops at 3,680 W, a +// 3p order has no step and went out as 0 W. Core runs it on one phase. +func TestChargeNowOnAOnePhaseChargerIgnoresAClients3p(t *testing.T) { + base := time.Date(2026, 10, 22, 20, 0, 0, 0, time.UTC) + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 7360, PhaseMode: "auto", + AllowedStepsW: []float64{0, 1380, 2300, 3680}} + sender := &fakeSender{} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetManualHold(cfg.ID, ManualHold{PowerW: cfg.MaxChargeW, PhaseMode: "3p", Persistent: true, StartedAt: base, UpdatedAt: base}) + for i := range 3 { + c.Tick(context.Background(), base.Add(time.Duration(i)*5*time.Second)) + } + cmd, ok := lastSetCurrent(sender.calls) + if !ok || cmd.power != 3680 || cmd.phaseMode != "1p" { + t.Fatalf("Charge now sent %.0f W %q, want 3680 W 1p", cmd.power, cmd.phaseMode) + } +} diff --git a/go/internal/loadpoint/planning_steps.go b/go/internal/loadpoint/planning_steps.go index 2250766f..80c00eb2 100644 --- a/go/internal/loadpoint/planning_steps.go +++ b/go/internal/loadpoint/planning_steps.go @@ -46,6 +46,10 @@ func PlanningSteps(cfg Config, site SiteFuse) []float64 { return steps } +// ThreePhaseCapable reports whether a charger can run three phases at 6 A +// each; see threePhaseCapable. +func ThreePhaseCapable(cfg Config, site SiteFuse) bool { return threePhaseCapable(cfg, site) } + // threePhaseCapable reports whether a charger can run three phases at 6 A // each: a three-phase site, no 1p pin, and a step table, or else a maximum, // that reaches 6 A on every phase. A table with nothing that high is a