From 677ba887808210088e2b443c31556b1b3c49cf77 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 19:35:29 +0200 Subject: [PATCH] fix(charging): carry two-phase cars through plans and current limits Signed-off-by: Fredrik Ahlgren --- .changeset/two-phase-car-charging.md | 5 ++ docs/cars.md | 9 ++- drivers/BUNDLED_SOURCE.json | 2 +- go/cmd/ftw/ev_planning_limits.go | 3 + go/cmd/ftw/ev_planning_limits_test.go | 13 ++++ go/internal/api/api_loadpoint_manual.go | 11 ++- go/internal/api/api_loadpoint_manual_test.go | 15 ++++ go/internal/cars/ops.go | 2 +- go/internal/cars/tick.go | 2 +- go/internal/cars/two_phase_test.go | 41 ++++++++++ go/internal/config/config.go | 4 +- go/internal/control/ev_fuse_budget.go | 10 ++- go/internal/control/ev_plan_priority.go | 2 +- go/internal/control/fuse_phase.go | 4 +- go/internal/control/two_phase_test.go | 31 ++++++++ go/internal/drivers/easee_cloud_test.go | 14 ++++ go/internal/loadpoint/car.go | 9 ++- go/internal/loadpoint/car_limits.go | 25 ++++++ go/internal/loadpoint/controller.go | 11 ++- .../loadpoint/controller_fuse_budget.go | 2 +- .../loadpoint/controller_fuse_demand.go | 2 +- go/internal/loadpoint/loadpoint.go | 28 ++++--- go/internal/loadpoint/manual_limits.go | 29 +++++-- go/internal/loadpoint/planning_steps.go | 8 +- go/internal/loadpoint/snap.go | 4 +- go/internal/loadpoint/two_phase_test.go | 78 +++++++++++++++++++ web/app.js | 5 +- web/ev-two-phase.test.mjs | 38 +++++++++ 28 files changed, 366 insertions(+), 41 deletions(-) create mode 100644 .changeset/two-phase-car-charging.md create mode 100644 go/internal/cars/two_phase_test.go create mode 100644 go/internal/control/two_phase_test.go create mode 100644 go/internal/loadpoint/car_limits.go create mode 100644 go/internal/loadpoint/two_phase_test.go create mode 100644 web/ev-two-phase.test.mjs diff --git a/.changeset/two-phase-car-charging.md b/.changeset/two-phase-car-charging.md new file mode 100644 index 000000000..5d1cf0cbd --- /dev/null +++ b/.changeset/two-phase-car-charging.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Support two-phase cars in charging plans, current limits and Easee commands. Keep the car's phase count separate from the site's wiring and Easee's Auto phase mode. diff --git a/docs/cars.md b/docs/cars.md index aaa49c50a..9ab3a489a 100644 --- a/docs/cars.md +++ b/docs/cars.md @@ -175,9 +175,12 @@ lower of the charger's maximum and the car's own (`maxW`), keeping the charger's lowest step when that is above the car's maximum. The charge limit the car reports caps the plan only while the level is the car's own and still exact. The reserve that keeps a goal charging while no plan is -accepted holds to the same power and limit. `phases` is the owner's note: -`maxW` already counts the car's phases, and the controller follows the -charger's phase mode. A change of car or goal rolls the charger's deadline +accepted holds to the same power and limit. `phases` accepts 0 (unknown), +1, 2 or 3. A two-phase car on a three-phase charger uses two-phase steps +and current limits; an explicit one-phase choice still wins. The car's +limits apply only to its session. Easee uses Auto for a two-phase car: +its API value `phaseMode=2` means Auto, not a measured phase count. +A change of car or goal rolls the charger's deadline before the replan, so that plan has the car's goal in it. - **One goal per car**, used wherever the car is plugged in. `readyByMin` is diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 350fe876b..8d8b1d6a5 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": "75aeab7d6e92dc41098c627ad666cad539f73b34", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/go/cmd/ftw/ev_planning_limits.go b/go/cmd/ftw/ev_planning_limits.go index da187e397..3a84719f5 100644 --- a/go/cmd/ftw/ev_planning_limits.go +++ b/go/cmd/ftw/ev_planning_limits.go @@ -13,5 +13,8 @@ func planningStepsForLoadpoint(st loadpoint.State, cfg *config.Config) []float64 break } } + if st.CarPhases == 2 && lp.PhaseMode != "1p" { + lp.PhaseMode = "2p" + } return loadpoint.PlanningSteps(lp, loadpoint.SiteFuse{MaxAmps: cfg.Fuse.MaxAmps, Voltage: cfg.Fuse.Voltage, PhaseCnt: cfg.Fuse.Phases}) } diff --git a/go/cmd/ftw/ev_planning_limits_test.go b/go/cmd/ftw/ev_planning_limits_test.go index 36795b906..29b66f906 100644 --- a/go/cmd/ftw/ev_planning_limits_test.go +++ b/go/cmd/ftw/ev_planning_limits_test.go @@ -21,3 +21,16 @@ func TestConfiguredEVLimitsReachPlanner(t *testing.T) { t.Fatalf("3-phase limits lost: %v", steps) } } + +func TestTwoPhaseCarLimitsReachPlanner(t *testing.T) { + m := loadpoint.NewManager() + m.Load([]loadpoint.Config{{ID: "garage", MinChargeW: 4140, MaxChargeW: 11000, PhaseMode: "3p", AllowedStepsW: []float64{0, 4140, 6900, 11000}}}) + m.Observe("garage", true, 0, 0, true) + m.SetCarBinding("garage", loadpoint.CarBinding{ID: "volvo", Phases: 2, MaxW: 6400}) + st, _ := m.State("garage") + cfg := &config.Config{Fuse: config.Fuse{MaxAmps: 16, Phases: 3, Voltage: 230}, Loadpoints: []config.Loadpoint{{ID: "garage", PhaseMode: "3p"}}} + steps := planningStepsForLoadpoint(st, cfg) + if len(steps) != 9 || steps[1] != 2760 || steps[len(steps)-1] != 5980 || st.MinChargeW != 2760 || st.MaxChargeW != 6400 { + t.Fatalf("two-phase car lost at the planner: min=%v max=%v steps=%v", st.MinChargeW, st.MaxChargeW, steps) + } +} diff --git a/go/internal/api/api_loadpoint_manual.go b/go/internal/api/api_loadpoint_manual.go index 22380cacc..afc0bbad0 100644 --- a/go/internal/api/api_loadpoint_manual.go +++ b/go/internal/api/api_loadpoint_manual.go @@ -120,10 +120,10 @@ func (s *Server) handleLoadpointManualHold(w http.ResponseWriter, r *http.Reques return } switch req.PhaseMode { - case "", "auto", "1p", "3p": + case "", "auto", "1p", "2p", "3p": default: writeJSON(w, 400, map[string]string{ - "error": "phase_mode must be omitted/empty or one of: auto, 1p, 3p", + "error": "phase_mode must be omitted/empty or one of: auto, 1p, 2p, 3p", }) return } @@ -280,6 +280,13 @@ func (s *Server) decorateLoadpointsWithManual(states []loadpoint.State) { switch phaseModeByID[states[i].ID] { case "1p": phases = 1 + case "2p": + phases = min(2, fusePhases) + minimum := states[i].MinChargeW + if phases == 1 { + minimum /= 2 + } + states[i].MinChargeW = math.Max(minimum, 6*voltage*float64(phases)) case "3p": phases = 3 } diff --git a/go/internal/api/api_loadpoint_manual_test.go b/go/internal/api/api_loadpoint_manual_test.go index c04a60c2e..baf73fd1c 100644 --- a/go/internal/api/api_loadpoint_manual_test.go +++ b/go/internal/api/api_loadpoint_manual_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/telemetry" ) @@ -29,6 +30,19 @@ func newManualHoldServer(t *testing.T) (*Server, *loadpoint.Controller) { return New(&Deps{Loadpoints: mgr, LoadpointCtrl: ctrl}), ctrl } +func TestTwoPhaseCarReachesTheAmpSlider(t *testing.T) { + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", PhaseMode: "3p", MinChargeW: 4140, MaxChargeW: 11000}}) + mgr.Observe("garage", true, 0, 0, true) + mgr.SetCarBinding("garage", loadpoint.CarBinding{ID: "volvo", Phases: 2, MaxW: 6400}) + srv := New(&Deps{Loadpoints: mgr, Cfg: &config.Config{Fuse: config.Fuse{Phases: 3, Voltage: 230}}}) + states := mgr.States() + srv.decorateLoadpointsWithManual(states) + if len(states) != 1 || states[0].Phases != 2 || states[0].VoltageV != 230 || states[0].MinChargeW != 2760 || states[0].MaxChargeW != 6400 { + t.Fatalf("two-phase slider uses the site's three phases: %+v", states) + } +} + func TestManualHoldUnavailableWithoutController(t *testing.T) { mgr := loadpoint.NewManager() mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee"}}) @@ -70,6 +84,7 @@ func TestManualHoldValidatesBody(t *testing.T) { {"hold_s too large", `{"power_w":1380,"hold_s":99999}`, http.StatusBadRequest}, {"negative power", `{"power_w":-1,"hold_s":30}`, http.StatusBadRequest}, {"bad phase_mode", `{"power_w":1380,"hold_s":30,"phase_mode":"5p"}`, http.StatusBadRequest}, + {"two-phase car", `{"power_w":4600,"hold_s":30,"phase_mode":"2p"}`, http.StatusOK}, {"malformed json", `{`, http.StatusBadRequest}, } for _, tc := range cases { diff --git a/go/internal/cars/ops.go b/go/internal/cars/ops.go index c464f1ccf..d4f858f26 100644 --- a/go/internal/cars/ops.go +++ b/go/internal/cars/ops.go @@ -687,7 +687,7 @@ func applyUpdate(car *Car, u Update) error { car.MaxW = v } if u.Phases != nil { - if p := *u.Phases; p != 0 && p != 1 && p != 3 { + if p := *u.Phases; p < 0 || p > 3 { return fmt.Errorf("%w: phases", ErrInvalid) } else { car.Phases = p diff --git a/go/internal/cars/tick.go b/go/internal/cars/tick.go index 8c9abe825..286f9506e 100644 --- a/go/internal/cars/tick.go +++ b/go/internal/cars/tick.go @@ -811,7 +811,7 @@ func (r *Runtime) syncLocked(now time.Time) []func() { } func bindingFor(car *Car) loadpoint.CarBinding { - return loadpoint.CarBinding{ID: car.ID, Name: car.Name, CapacityWh: car.CapacityWh, MaxW: car.MaxW} + return loadpoint.CarBinding{ID: car.ID, Name: car.Name, CapacityWh: car.CapacityWh, MaxW: car.MaxW, Phases: car.Phases} } // planningLimitLocked is the car's charge limit as the plan and the reserve diff --git a/go/internal/cars/two_phase_test.go b/go/internal/cars/two_phase_test.go new file mode 100644 index 000000000..44bbfb3c2 --- /dev/null +++ b/go/internal/cars/two_phase_test.go @@ -0,0 +1,41 @@ +package cars + +import ( + "testing" + + "github.com/srcfl/ftw/go/protocol" +) + +func TestCarAcceptsTwoPhases(t *testing.T) { + for _, phases := range []int{0, 1, 2, 3} { + var car Car + if err := applyUpdate(&car, Update{Phases: &phases}); err != nil || car.Phases != phases { + t.Fatalf("phases %d: car=%+v, err=%v", phases, car, err) + } + if got := bindingFor(&car); got.Phases != phases { + t.Fatalf("phases %d lost at the charger: %+v", phases, got) + } + } + for _, phases := range []int{-1, 4} { + if err := applyUpdate(&Car{}, Update{Phases: &phases}); err == nil { + t.Fatalf("accepted %d phases", phases) + } + } +} + +func TestTwoPhaseCarSurvivesRestart(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + car := h.add(protocol.Car{Name: "V60", Phases: 2, MaxW: 6400}) + again := newHarness(t, homeConfig(), nil, h.kv) + again.start() + for _, stored := range again.rt.Cars() { + if stored.ID == car.ID { + if stored.Phases != 2 || stored.MaxW != 6400 { + t.Fatalf("car lost its AC limits after restart: %+v", stored) + } + return + } + } + t.Fatal("car missing after restart") +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index ba75296c3..ff87ba07c 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -450,8 +450,8 @@ type Loadpoint struct { PluginSoC float64 `yaml:"plugin_soc,omitempty" json:"plugin_soc,omitempty"` PluginSoCPct float64 `yaml:"plugin_soc_pct,omitempty" json:"plugin_soc_pct,omitempty"` - // PhaseMode selects how the controller picks between 1Φ and 3Φ - // delivery: "3p" (default) | "1p" | "auto". Empty == "3p" for + // PhaseMode selects the charging count: "3p" (default), "2p", "1p" + // or "auto". The car's two-phase limit also selects "2p". Empty == "3p" for // backward compat with pre-switching configs. See loadpoint.Config. PhaseMode string `yaml:"phase_mode,omitempty" json:"phase_mode,omitempty"` PhaseSplitW float64 `yaml:"phase_split_w,omitempty" json:"phase_split_w,omitempty"` diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index ec669dff7..e5c08ebf5 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -221,8 +221,8 @@ func (s *State) offerPhaseA(w float64, phases int) float64 { return 0 } n := 1 - if phases == 3 && s.SiteFusePhases > 1 { - n = s.SiteFusePhases + if (phases == 2 || phases == 3) && s.SiteFusePhases > 1 { + n = min(phases, s.SiteFusePhases) } return w / (s.SiteFuseVoltage * float64(n)) } @@ -537,12 +537,16 @@ func evPoolFor(state *State, points []loadpoint.State, configs []loadpoint.Confi cfg := byID[point.ID] // Empty is the declared three-phase default. The controller's own // gate covers runtime auto/manual phase changes. - if cfg.PhaseMode != "" && cfg.PhaseMode != "3p" { + if cfg.PhaseMode == "2p" { + pool.phases = math.Min(pool.phases, 2) + } else if cfg.PhaseMode != "" && cfg.PhaseMode != "3p" { pool.phases = 1 } minW := cfg.MinChargeW if cfg.PhaseMode == "3p" && state.SiteFuseVoltage > 0 { minW = math.Max(minW, evMinPhaseA*state.SiteFuseVoltage*3) + } else if cfg.PhaseMode == "2p" && state.SiteFuseVoltage > 0 { + minW = math.Max(minW, evMinPhaseA*state.SiteFuseVoltage*min(2, float64(state.SiteFusePhases))) } if minW > 0 && (pool.minW == 0 || minW < pool.minW) { pool.minW = minW diff --git a/go/internal/control/ev_plan_priority.go b/go/internal/control/ev_plan_priority.go index 549ab81ae..bd6995015 100644 --- a/go/internal/control/ev_plan_priority.go +++ b/go/internal/control/ev_plan_priority.go @@ -23,7 +23,7 @@ func (state *State) SetEVFuseDemands(demands []loadpoint.FuseDemand) { continue } phase := demand.PhaseMode - if phase != "1p" && phase != "3p" { + if phase != "1p" && phase != "2p" && phase != "3p" { phase = "auto" } if i, ok := byDriver[demand.DriverName]; ok { diff --git a/go/internal/control/fuse_phase.go b/go/internal/control/fuse_phase.go index 2adf893af..f2511b182 100644 --- a/go/internal/control/fuse_phase.go +++ b/go/internal/control/fuse_phase.go @@ -190,7 +190,9 @@ func (s *State) evCutsSent(since, at time.Time) (w float64, phaseA [3]float64) { func (s *State) evNeedW(at time.Time) (w, phases float64) { w, phases = scheduledEVPowerW(s), 3 for _, demand := range s.evFuseDemands { - if demand.phaseMode != "3p" { + if demand.phaseMode == "2p" { + phases = math.Min(phases, 2) + } else if demand.phaseMode != "3p" { phases = 1 } } diff --git a/go/internal/control/two_phase_test.go b/go/internal/control/two_phase_test.go new file mode 100644 index 000000000..55edc94d4 --- /dev/null +++ b/go/internal/control/two_phase_test.go @@ -0,0 +1,31 @@ +package control + +import ( + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func TestTwoPhaseFuseCurrentAndDemand(t *testing.T) { + s := &State{SiteFuseVoltage: 230, SiteFusePhases: 3} + if got := s.offerPhaseA(4600, 2); got != 10 { + t.Fatalf("two-phase 4600 W offer = %v A, want 10 A", got) + } + s.SetEVFuseDemands([]loadpoint.FuseDemand{{DriverName: "easee", PowerW: 4600, PhaseMode: "2p"}}) + if _, phases := s.evNeedW(time.Now()); phases != 2 { + t.Fatalf("two-phase demand became %v phases", phases) + } + pool, plugged := evPoolFor(s, []loadpoint.State{{ID: "garage", PluggedIn: true}}, []loadpoint.Config{{ID: "garage", PhaseMode: "2p", MinChargeW: 2760}}) + if !plugged || pool.phases != 2 || pool.minW != 2760 { + t.Fatalf("two-phase pool = %+v", pool) + } + // The pair of phases is unknown. A two-phase cut must not claim + // relief on all three phases, which could spend room on the third. + s.SetEVCommanded([]loadpoint.State{{ID: "garage", DriverName: "easee", PluggedIn: true, CommandedKnown: true, CommandedW: 4600, CommandedPhases: 2}}) + for _, sent := range s.evLimit.offers["garage"].log { + if sent.three || sent.a != 10 { + t.Fatalf("two-phase offer credited a third phase: %+v", sent) + } + } +} diff --git a/go/internal/drivers/easee_cloud_test.go b/go/internal/drivers/easee_cloud_test.go index ac4199b1f..9f0961f58 100644 --- a/go/internal/drivers/easee_cloud_test.go +++ b/go/internal/drivers/easee_cloud_test.go @@ -108,3 +108,17 @@ func TestEaseeLockedThreePhaseStillHonorsOperatorChoice(t *testing.T) { t.Errorf("amps = %d, want 0 below Easee minimum in explicitly locked 3p mode", amps) } } + +func TestBundledEaseeTwoPhaseCurrent(t *testing.T) { + L := loadEaseePhaseFunctions(t) + phases := callEaseePickPhases(t, L, "2p", 4600, 230, 16, 0) + if phases != 2 { + t.Fatalf("the pinned Easee driver selected %d phases for a two-phase car", phases) + } + if got := callEaseePerPhaseAmps(t, L, 4600, 230, phases, 16); got != 10 { + t.Fatalf("two-phase current = %d A, want 10 A", got) + } + if got := callEaseePerPhaseAmps(t, L, 6400, 230, phases, 16); got != 13 { + t.Fatalf("two-phase watt ceiling rounded up: %d A", got) + } +} diff --git a/go/internal/loadpoint/car.go b/go/internal/loadpoint/car.go index e8e0c5fc8..f96b2b791 100644 --- a/go/internal/loadpoint/car.go +++ b/go/internal/loadpoint/car.go @@ -29,9 +29,10 @@ type CarBinding struct { // holds. Zero is config.yaml's vehicle_capacity_wh, or the default when // that is unset: a visitor's capacity is not known either. CapacityWh float64 - // MaxW is the most the car's own charger takes, zero when unknown. Its - // phases are in the rating already, so they bound nothing further. + // MaxW is the most the car's own charger takes, zero when unknown. MaxW float64 + // Phases is the car's AC phase count, independently of the site wiring. + Phases int // ChargeLimit is the car's own charge limit as the plan and the reserve // read it: only while the level is the car's own exact reading. Zero is // none. @@ -65,6 +66,10 @@ func (m *Manager) SetCarBinding(id string, b CarBinding) bool { m.mu.Unlock() return false } + if lp.car.Phases != b.Phases { + // The old count cannot fund a rise on the newly declared phases. + lp.commandedPhases = 0 + } lp.car = b capacity := b.CapacityWh if capacity <= 0 { diff --git a/go/internal/loadpoint/car_limits.go b/go/internal/loadpoint/car_limits.go new file mode 100644 index 000000000..996723447 --- /dev/null +++ b/go/internal/loadpoint/car_limits.go @@ -0,0 +1,25 @@ +package loadpoint + +// WithCarLimits keeps the charger's wiring separate from the car's AC +// charger. A two-phase car uses two phases even on a three-phase site. +// An explicit one-phase choice remains available. +func WithCarLimits(cfg Config, phases int, maxW float64) Config { + if phases == 2 && cfg.PhaseMode != "1p" && cfg.PhaseMode != "2p" { + cfg.PhaseMode = "2p" + cfg.MinChargeW *= 2.0 / 3 + // These steps describe the configured charger before the car's + // phase count was known. Rebuild whole-amp steps for this car. + cfg.AllowedStepsW = nil + } + if phases == 2 && finite(maxW) && maxW > 0 && (cfg.MaxChargeW <= 0 || maxW < cfg.MaxChargeW) { + cfg.MaxChargeW = maxW + } + return cfg +} + +func limitPhaseMode(mode, configured string) string { + if configured == "2p" && mode != "1p" { + return "2p" + } + return mode +} diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 49efeed80..d96a0170d 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1292,7 +1292,7 @@ func (c *Controller) chooseTick(now time.Time, cfg Config, sample EVSample, disp func holdPhaseMode(hold ManualHold, cfg Config, surplusOn bool) string { switch { case hold.PhaseMode != "": - return hold.PhaseMode + return limitPhaseMode(hold.PhaseMode, cfg.PhaseMode) case cfg.PhaseMode != "": return cfg.PhaseMode case surplusOn: @@ -2015,6 +2015,9 @@ func (c *Controller) computeSurplusCmd(now time.Time, lpCfg Config, wantW float6 // timestamps share the same clock as the rest of the cycle and tests // can drive it deterministically. func (c *Controller) pickSurplusSteps(now time.Time, lpCfg Config) []float64 { + if c != nil && lpCfg.PhaseMode == "2p" { + return PlanningSteps(lpCfg, c.siteFuse()) + } if c == nil { return surplus3PhaseSteps(lpCfg) } @@ -2240,8 +2243,8 @@ func resolvePhaseMode(operatorMode string, scheduleActive, surplusLocked1P, surp // defends against a stale lock left from a previous "auto"/"1p" // config. pickSurplusSteps also refuses to set the lock for a // "3p" loadpoint, so this is belt-and-braces. - if operatorMode == "3p" { - return "3p" + if operatorMode == "3p" || operatorMode == "2p" { + return operatorMode } return "1p" case surplusOn && (operatorMode == "" || operatorMode == "auto"): @@ -2310,7 +2313,7 @@ func sameLocalDay(a, b time.Time) bool { // locked the install to 1Φ, so a 3Φ-only set would just wedge the // loadpoint at 0. func surplus3PhaseSteps(lpCfg Config) []float64 { - if lpCfg.PhaseMode == "1p" { + if lpCfg.PhaseMode == "1p" || lpCfg.PhaseMode == "2p" { return lpCfg.AllowedStepsW } split := lpCfg.PhaseSplitW diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index e81c3df03..317ac9687 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -95,7 +95,7 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample } } -// commandPhases is where a command of w lands: 1 or 3 phases, as the +// commandPhases is where a command of w lands: 1, 2 or 3 phases, as the // driver resolves its phase_mode and split. A 1-phase site has one. func (c *Controller) commandPhases(mode string, w, split float64) int { if c.siteFuse().Phases() == 1 { diff --git a/go/internal/loadpoint/controller_fuse_demand.go b/go/internal/loadpoint/controller_fuse_demand.go index 23e7310ad..fe785d8bd 100644 --- a/go/internal/loadpoint/controller_fuse_demand.go +++ b/go/internal/loadpoint/controller_fuse_demand.go @@ -31,7 +31,7 @@ func (c *Controller) recordFuseDemand(driver string, powerW float64, phaseMode s } if phaseMode == "" { phaseMode = "3p" // The completed command leaves the driver's default in force. - } else if phaseMode != "1p" && phaseMode != "3p" { + } else if phaseMode != "1p" && phaseMode != "2p" && phaseMode != "3p" { phaseMode = "auto" } c.fuseDemandMu.Lock() diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 765197a6c..3fe913ffc 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -55,8 +55,8 @@ type Config struct { // override per-loadpoint or pre-plug-in. PluginSoC float64 `yaml:"plugin_soc,omitempty" json:"plugin_soc,omitempty"` - // PhaseMode selects how the controller picks between 1Φ and 3Φ - // delivery each tick. "3p" (default) and "1p" lock the install to + // PhaseMode selects the charging phases each tick. "3p" (default), + // "2p" and "1p" lock the charging count to // one mode and filter AllowedStepsW accordingly. "auto" lets the // controller switch based on the MPC budget, with a hysteresis // timer (MinPhaseHoldS) preventing flap. Empty == "3p" for @@ -202,6 +202,7 @@ type State struct { // when unknown. The plan and the reserve hold to them (LimitPower, // PlanningTarget). CarMaxW float64 `json:"-"` + CarPhases int `json:"-"` CarChargeLimit float64 `json:"-"` // MinChargeW / MaxChargeW / AllowedStepsW are repeated here so the @@ -260,7 +261,7 @@ type State struct { CommandedSinceMs int64 `json:"commanded_since_ms,omitempty"` // Internal identity of the manual choice used to compute the order. ManualCommandUpdatedAt time.Time `json:"-"` - // CommandedPhases is 1 or 3: the phases the last command lands on, from + // CommandedPhases is 1, 2 or 3: the phases the last command lands on, from // its phase_mode and split. Zero before the first command. CommandedPhases int `json:"-"` @@ -510,7 +511,7 @@ type loadpointRuntime struct { commandedW float64 commandedKnown bool commandedReason string - commandedPhases int // 1 or 3: where the last command lands; 0 before it + commandedPhases int // 1, 2 or 3: where the last command lands; 0 before it // commandedSince is when the current (commandedW, commandedReason) pair // was first ordered. The manual status counts elapsed time from it. commandedSince time.Time @@ -781,7 +782,11 @@ func (m *Manager) Configs() []Config { out := make([]Config, 0, len(m.order)) for _, id := range m.order { if lp, ok := m.byID[id]; ok { - out = append(out, lp.Config) + cfg := lp.Config + if lp.pluggedIn { + cfg = WithCarLimits(cfg, lp.car.Phases, lp.car.MaxW) + } + out = append(out, cfg) } } return out @@ -1334,8 +1339,12 @@ func vehicleSupersedesLocked(lp *loadpointRuntime, observedAt time.Time) bool { } func (lp *loadpointRuntime) snapshot() State { - steps := make([]float64, len(lp.AllowedStepsW)) - copy(steps, lp.AllowedStepsW) + cfg := lp.Config + if lp.pluggedIn { + cfg = WithCarLimits(cfg, lp.car.Phases, lp.car.MaxW) + } + steps := make([]float64, len(cfg.AllowedStepsW)) + copy(steps, cfg.AllowedStepsW) sort.Float64s(steps) st := State{ ManualRestoreUnconfirmed: lp.manualRestoreUnconfirmed, @@ -1355,14 +1364,15 @@ func (lp *loadpointRuntime) snapshot() State { GoalComplete: lp.finishGoalCompleted, TargetTime: lp.targetTime, UpdatedAtMs: lp.updatedAtMs, - MinChargeW: lp.MinChargeW, - MaxChargeW: lp.MaxChargeW, + MinChargeW: cfg.MinChargeW, + MaxChargeW: cfg.MaxChargeW, AllowedStepsW: steps, SurplusOnly: lp.Config.SurplusOnly, Schedule: lp.schedule, ChargingDeclined: lp.chargingDeclined, SoCRetention: lp.socRetention, CarMaxW: lp.car.MaxW, + CarPhases: lp.car.Phases, CarChargeLimit: lp.car.ChargeLimit, CommandedW: lp.commandedW, CommandedReason: lp.commandedReason, diff --git a/go/internal/loadpoint/manual_limits.go b/go/internal/loadpoint/manual_limits.go index c0f12a08b..dfeb418a5 100644 --- a/go/internal/loadpoint/manual_limits.go +++ b/go/internal/loadpoint/manual_limits.go @@ -18,6 +18,7 @@ func clampManualPower(cfg Config, hold ManualHold, site SiteFuse) float64 { if mode == "" { mode = cfg.PhaseMode } + mode = limitPhaseMode(mode, cfg.PhaseMode) if site.Phases() == 1 { mode = "1p" } else if mode == "" && cfg.SurplusOnly { @@ -44,7 +45,8 @@ func manualMinChargeW(cfg Config, hold ManualHold, site SiteFuse) float64 { if mode == "" { mode = cfg.PhaseMode } - if mode == "1p" || mode == "auto" { + mode = limitPhaseMode(mode, cfg.PhaseMode) + if mode == "1p" || mode == "2p" || mode == "auto" { return phaseMinChargeW(cfg, mode, site) } return cfg.MinChargeW @@ -54,13 +56,19 @@ func manualMinChargeW(cfg Config, hold ManualHold, site SiteFuse) float64 { // 6 A on one phase for 1p or auto, 6 A on each phase for 3p. An empty mode // names no phases; the charger decides, and its configured minimum stands. func phaseMinChargeW(cfg Config, mode string, site SiteFuse) float64 { - if site.Phases() != 3 { - return cfg.MinChargeW - } voltage := site.Voltage if !finite(voltage) || voltage <= 0 { voltage = 230 } + if cfg.PhaseMode == "2p" && (mode == "1p" || site.Phases() == 1) { + return math.Max(cfg.MinChargeW/2, 6*voltage) + } + if mode == "2p" { + return math.Max(cfg.MinChargeW, 12*voltage) + } + if site.Phases() != 3 { + return cfg.MinChargeW + } switch mode { case "1p", "auto": return math.Max(cfg.MinChargeW/3, 6*voltage) @@ -125,7 +133,11 @@ func chargerPhaseAmps(cfg Config, voltage float64) float64 { if !finite(maxW) || maxW < 0 { maxW = 0 } - return math.Max(maxW/(3*voltage), split/voltage) + phases := PhaseFor(cfg.PhaseMode, maxW, split) + if cfg.PhaseMode == "auto" || cfg.PhaseMode == "" { + phases = 3 + } + return math.Max(maxW/(float64(phases)*voltage), split/voltage) } // The watt order must reflect the final current ceiling too. In particular, @@ -156,6 +168,13 @@ func applyCurrentCeiling(cmd map[string]any) bool { } phases := PhaseFor(mode, w, split) ceiling := v * float64(phases) * math.Floor(a) + if mode == "2p" { + amps := math.Floor(math.Min(w, ceiling)/(2*v) + 1e-9) + if amps < 6 { + amps = 0 + } + ceiling = amps * 2 * v + } if w > ceiling { cmd["power_w"] = ceiling return true diff --git a/go/internal/loadpoint/planning_steps.go b/go/internal/loadpoint/planning_steps.go index b6271b18b..a0ebdc74e 100644 --- a/go/internal/loadpoint/planning_steps.go +++ b/go/internal/loadpoint/planning_steps.go @@ -12,13 +12,19 @@ func PlanningSteps(cfg Config, site SiteFuse) []float64 { phases := site.Phases() if cfg.PhaseMode == "1p" { phases = 1 + } else if cfg.PhaseMode == "2p" { + phases = min(2, phases) } voltage := site.Voltage if voltage <= 0 { voltage = 230 } step := voltage * float64(phases) - minimum := math.Max(6, math.Ceil(cfg.MinChargeW/step)) + minW := cfg.MinChargeW + if cfg.PhaseMode == "2p" && phases == 1 { + minW = phaseMinChargeW(cfg, "1p", site) + } + minimum := math.Max(6, math.Ceil(minW/step)) maximum := math.Floor(cfg.MaxChargeW / step) if site.MaxAmps > 0 { maximum = math.Min(maximum, math.Floor(site.MaxAmps)) diff --git a/go/internal/loadpoint/snap.go b/go/internal/loadpoint/snap.go index de544f79b..db559bc7a 100644 --- a/go/internal/loadpoint/snap.go +++ b/go/internal/loadpoint/snap.go @@ -58,13 +58,15 @@ func floorChargeW(want, min, max float64, steps []float64) float64 { return best } -// PhaseFor maps mode and splitW to 1 or 3. Unknown modes stay on 3. +// PhaseFor maps mode and splitW to the charging phase count. // splitW must come from the site, not a hard-coded 230 V. Drivers own // the decision this function documents. func PhaseFor(mode string, wantW, splitW float64) int { switch mode { case "1p": return 1 + case "2p": + return 2 case "auto": if splitW > 0 && wantW < splitW { return 1 diff --git a/go/internal/loadpoint/two_phase_test.go b/go/internal/loadpoint/two_phase_test.go new file mode 100644 index 000000000..d31ce0d5b --- /dev/null +++ b/go/internal/loadpoint/two_phase_test.go @@ -0,0 +1,78 @@ +package loadpoint + +import ( + "context" + "math" + "slices" + "testing" + "time" +) + +func TestTwoPhasePlanningAndFuseRoom(t *testing.T) { + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 2760, MaxChargeW: 6400, PhaseMode: "2p"} + site := SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3} + steps := PlanningSteps(cfg, site) + if !slices.Equal(steps, []float64{0, 2760, 3220, 3680, 4140, 4600, 5060, 5520, 5980}) { + t.Fatalf("two-phase steps = %v", steps) + } + c := newTestController(t, []Config{cfg}, nil, nil, &fakeSender{}) + c.SetSiteFuse(site) + if got := c.phaseRoomCapW(cfg.ID, "2p", 6400, 0, 7); got != 3220 { + t.Fatalf("7 A of room allowed %.0f W; want 3220 W on two phases", got) + } + if got := clampManualPower(cfg, ManualHold{PowerW: 6400, PhaseMode: "2p", MaxAmpsPerPhase: 10}, site); got != 4600 { + t.Fatalf("10 A manual ceiling = %.0f W, want 4600 W", got) + } + steps = PlanningSteps(cfg, SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 1}) + if steps[1] != 1380 { + t.Fatalf("two-phase car's one-phase minimum = %v, want 1380 W", steps) + } + if got := phaseMinChargeW(cfg, "1p", site); got != 1380 { + t.Fatalf("explicit one-phase minimum = %.0f W", got) + } +} + +func TestTwoPhaseCarControlsTheOfferAndClearsForTheNextCar(t *testing.T) { + now := time.Now() + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11040, PhaseMode: "3p"} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, map[string]EVSample{"easee": {Connected: true}}, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + c.manager.Observe("garage", true, 0, 0, true) + c.manager.setCommandedPhases("garage", 3) + c.manager.SetCarBinding("garage", CarBinding{ID: "volvo", Phases: 2, MaxW: 6400}) + if st, _ := c.manager.State("garage"); st.CommandedPhases != 0 { + t.Fatal("the previous phase count survived a changed car limit") + } + c.SetManualHold("garage", ManualHold{PowerW: 6400, PhaseMode: "3p", Persistent: true}) + c.Tick(context.Background(), now) + cmd, ok := lastSetCurrent(sender.calls) + if !ok || cmd.phaseMode != "2p" || cmd.power > 6400 || cmd.sitePhases != 3 { + t.Fatalf("two-phase car on three-phase site: %+v", cmd) + } + st, _ := c.manager.State("garage") + if st.CommandedPhases != 2 || st.CommandedW != 5980 || st.MinChargeW != 2760 || st.MaxChargeW != 6400 { + t.Fatalf("two-phase state = %+v", st) + } + effective := c.manager.Configs()[0] + if effective.PhaseMode != "2p" || effective.MinChargeW != 2760 || effective.MaxChargeW != 6400 { + t.Fatalf("car limits = %+v", effective) + } + c.manager.SetCarBinding("garage", CarBinding{}) + if got := c.manager.Configs()[0]; got.PhaseMode != "3p" || got.MaxChargeW != cfg.MaxChargeW || got.MinChargeW != cfg.MinChargeW { + t.Fatalf("the next car inherited the previous car's limits: %+v", got) + } +} + +func TestTwoPhaseCurrentCeilingAndSinglePhaseSite(t *testing.T) { + cmd := map[string]any{"power_w": 6400.0, "phase_mode": "2p", "voltage": 240.0, "max_amps_per_phase": 10.0} + applyCurrentCeiling(cmd) + if got := cmd["power_w"].(float64); math.Abs(got-4800) > 1e-6 { + t.Fatalf("two-phase ceiling = %v", got) + } + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 6400, PhaseMode: "2p"} + got := runPhaseTick(t, cfg, SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 1}, 6000) + if got.phaseMode != "1p" || got.power > 3680 { + t.Fatalf("single-phase installation = %+v", got) + } +} diff --git a/web/app.js b/web/app.js index 9a795e1ee..1b96dcce2 100644 --- a/web/app.js +++ b/web/app.js @@ -3576,7 +3576,8 @@ function aToW(a) { return Math.round(a * perA); } var minA = Math.max(1, Math.round(wToA((lp && lp.min_charge_w) || 0)) || 6); - var maxA = Math.round(wToA((lp && lp.max_charge_w) || 0)) || 16; + if (phases === 2) minA = Math.max(6, Math.ceil(wToA((lp && lp.min_charge_w) || 0))); + var maxA = (phases === 2 ? Math.floor : Math.round)(wToA((lp && lp.max_charge_w) || 0)) || 16; if (maxA <= minA) { maxA = minA + 1; } var active = !!(lp && lp.manual_active); @@ -3765,7 +3766,7 @@ body: JSON.stringify({ power_w: pause ? 0 : aToW(a), hold_s: 0, - phase_mode: phases === 1 ? "1p" : "3p", + phase_mode: phases === 1 ? "1p" : phases === 2 ? "2p" : "3p", }), }).then(failOn).then(function () { busy = false; diff --git a/web/ev-two-phase.test.mjs b/web/ev-two-phase.test.mjs new file mode 100644 index 000000000..3830c5b6d --- /dev/null +++ b/web/ev-two-phase.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); +const manual = source.slice(source.indexOf('function buildManualChargeSection'), source.indexOf('function sliderHeader')); + +test('two-phase manual charging sends the displayed amps on two phases', async () => { + const nodes = []; + const document = { createElement(tag) { + const node = { tag, style: {}, children: [], listeners: {}, + appendChild(child) { this.children.push(child); }, + cloneNode() { return document.createElement(tag); }, + setAttribute() {}, + addEventListener(name, fn) { this.listeners[name] = fn; }, + }; + nodes.push(node); + return node; + } }; + const writes = []; + const build = new Function('document', 'evIsPaused', 'manualStatusText', 'evWrite', 'refreshEvModalAfterWrite', + manual + '; return buildManualChargeSection;')( + document, () => false, () => '', async (path, options) => { + writes.push({ path, body: JSON.parse(options.body) }); + return { ok: true }; + }, () => {}, + ); + build({ id: 'garage', phases: 2, voltage_v: 230, min_charge_w: 1380, max_charge_w: 6400 }); + const slider = nodes.find(n => n.tag === 'input'); + assert.equal(slider.min, '6'); + assert.equal(slider.max, '13'); + slider.value = '10'; + slider.listeners.input(); + assert.ok(nodes.some(n => n.textContent === '10 A · 4.6 kW')); + nodes.find(n => n.textContent === 'Charge now').listeners.click(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(writes, [{ path: '/api/loadpoints/garage/manual_hold', body: { power_w: 4600, hold_s: 0, phase_mode: '2p' } }]); +});