Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ev-charge-now-one-phase-charger.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 13 additions & 7 deletions go/internal/api/api_loadpoint_manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -266,22 +268,26 @@ 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
}
}

now := time.Now()
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
Expand Down
53 changes: 53 additions & 0 deletions go/internal/api/api_loadpoint_phases_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
5 changes: 0 additions & 5 deletions go/internal/control/testdata/sitesim_field_known.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions go/internal/loadpoint/planned_three_phase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
4 changes: 4 additions & 0 deletions go/internal/loadpoint/planning_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading