Skip to content
Draft
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/two-phase-car-charging.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 6 additions & 3 deletions docs/cars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion drivers/BUNDLED_SOURCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions go/cmd/ftw/ev_planning_limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
}
13 changes: 13 additions & 0 deletions go/cmd/ftw/ev_planning_limits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
11 changes: 9 additions & 2 deletions go/internal/api/api_loadpoint_manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
15 changes: 15 additions & 0 deletions go/internal/api/api_loadpoint_manual_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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"}})
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion go/internal/cars/ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go/internal/cars/tick.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions go/internal/cars/two_phase_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
4 changes: 2 additions & 2 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
10 changes: 7 additions & 3 deletions go/internal/control/ev_fuse_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go/internal/control/ev_plan_priority.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion go/internal/control/fuse_phase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
31 changes: 31 additions & 0 deletions go/internal/control/two_phase_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
14 changes: 14 additions & 0 deletions go/internal/drivers/easee_cloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
9 changes: 7 additions & 2 deletions go/internal/loadpoint/car.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions go/internal/loadpoint/car_limits.go
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 7 additions & 4 deletions go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go/internal/loadpoint/controller_fuse_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion go/internal/loadpoint/controller_fuse_demand.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading