diff --git a/.changeset/ev-planned-charging-three-phase.md b/.changeset/ev-planned-charging-three-phase.md new file mode 100644 index 00000000..362d8c1a --- /dev/null +++ b/.changeset/ev-planned-charging-three-phase.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Planned charging and Charge now run on three phases. In 0.155.0 a loadpoint set to auto could charge a three-phase car on one phase all night: with an Easee step table that also lists one-phase levels, the planner spread the charge at 3.1–3.5 kW, which only one phase can deliver, and FTW switched the charger to one phase to follow it. On a 3×20 A site the car got about 3.3 kW instead of about 10 kW. One phase never helps the fuse: it puts all the current on one phase, caps the car near 3.7 kW, and an Easee has to pause to switch back. Auto, and an unset phase mode, now mean three phases on a charger that can run 6 A on each, and a planned order is raised to that minimum (4.14 kW at 230 V). Only charging from spare solar below it uses one phase, with the same hold against flapping as before, also when that solar-only loadpoint has a goal. The planner gets only steps the charger can run on three phases. A charger whose step table, or maximum, stays below 6 A on three phases is a one-phase charger and keeps its one-phase steps and orders; so does a loadpoint set to 1p. A one-phase charger above 16 A with a longer table still needs phase mode 1p. diff --git a/.changeset/ev-split-keeps-order.md b/.changeset/ev-split-keeps-order.md new file mode 100644 index 00000000..dc2519b6 --- /dev/null +++ b/.changeset/ev-split-keeps-order.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +FTW no longer cuts a charging car when every phase has room. In 0.155.0 a car charging on one phase under auto was cut from 3.5 kW to 1.6 kW and then to 0 W, again and again, while the busiest phase read about 16 A on a 20 A fuse: dividing the fuse room between cars read the order as three-phase, gave the car's own current no credit, and cut it to what was left on that phase. Keeping a car's current order on the same phases is now never a cut there; only a measured overage on the site meter cuts. An order that moves the same watts onto other phases is still held to the room on those phases. diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 8f470691..731455fd 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -452,7 +452,9 @@ type Loadpoint struct { // PhaseMode selects how the controller picks between 1Φ and 3Φ // delivery: "3p" (default) | "1p" | "auto". Empty == "3p" for - // backward compat with pre-switching configs. See loadpoint.Config. + // backward compat with pre-switching configs. "auto" charges on three + // phases and uses one only for surplus charging below the 3Φ minimum. + // 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"` MinPhaseHoldS int `yaml:"min_phase_hold_s,omitempty" json:"min_phase_hold_s,omitempty"` diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 66976ea7..21b9d557 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -62,6 +62,8 @@ type simSite struct { peakA float64 overLimit time.Duration // time with a phase over the fuse less its margin idleWithRoom time.Duration // time the car was idle with room for it + overAt time.Time // last second a phase was over the fuse less its margin + cutsWithRoom int // car orders lowered with no phase over that limit within the response time failure string } @@ -151,6 +153,7 @@ func (s *simSite) send(_ context.Context, driver string, payload []byte) error { PowerW float64 `json:"power_w"` PhaseMode string `json:"phase_mode"` SplitW float64 `json:"phase_split_w"` + MaxA float64 `json:"max_amps_per_phase"` } if err := json.Unmarshal(payload, &cmd); err != nil { return err @@ -168,8 +171,16 @@ func (s *simSite) send(_ context.Context, driver string, payload []byte) error { if cmd.PowerW > s.offerW { s.raisedAt = s.p.Now } + if cmd.PowerW < s.offerW && (s.overAt.IsZero() || s.p.Now.Sub(s.overAt) > s.responseTime(true)) { + s.cutsWithRoom++ + } s.offerW = cmd.PowerW s.commands = append(s.commands, simCommand{s.p.Now, "evse", cmd.PowerW}) + // Without a split the Easee driver splits auto at the fuse's current + // on one phase (easee_cloud.lua pick_phases). + if cmd.SplitW <= 0 && cmd.MaxA > 0 { + cmd.SplitW = s.p.Voltage * cmd.MaxA + } s.p.EVSE.Command(s.p.Now, cmd.PowerW, cmd.PhaseMode, cmd.SplitW, s.p.Voltage) return nil } @@ -328,6 +339,7 @@ func (s *simSite) check() { } s.heatSet = true if overLimit { + s.overAt = now s.overLimit += sitesim.Step if s.limitSince.IsZero() { s.limitSince, s.ownRaise = now, !s.raisedAt.IsZero() && now.Sub(s.raisedAt) <= s.responseTime(true) && !s.outsideAt.After(s.raisedAt) @@ -421,6 +433,7 @@ type simScenario struct { start time.Time // zero: simStart run time.Duration surplus bool + plan loadpoint.PlanFunc plant func(*sitesim.Plant) lp func(*loadpoint.Config) setup func(*simSite) @@ -455,6 +468,16 @@ func simLoadpoint() loadpoint.Config { func houseA(l1, l2, l3 float64) [3]float64 { return [3]float64{l1 * 230, l2 * 230, l3 * 230} } +// planEV is a saved goal and a plan that gives the car w in every quarter +// hour: the path Core takes overnight. +func planEV(w float64) loadpoint.PlanFunc { + return func(now time.Time) (loadpoint.Directive, bool) { + slot := now.Truncate(15 * time.Minute) + return loadpoint.Directive{SlotStart: slot, SlotEnd: slot.Add(15 * time.Minute), + LoadpointEnergyWh: map[string]float64{"garage": w / 4}}, true + } +} + // planBatteryCharge has the plan charge the battery at w for the hour. func planBatteryCharge(w float64) func(*simSite) { return func(s *simSite) { @@ -596,6 +619,35 @@ func simScenarios() []simScenario { } return nil }}}}, + // Field report 22 Oct 2026, v0.155.0-beta.1: a 3x20 A site, phase_mode + // auto, an Easee step table with one-phase levels, a plan for 11.1 kW. + // The car ran on one phase at 3.3 kW and FTW cut it with room on every + // phase. Planned charging now runs on three phases and only measured + // overage cuts. + {name: "night plan, auto, one-phase steps", start: time.Date(2026, 10, 22, 3, 0, 0, 0, time.Local), run: 40 * time.Minute, + plant: func(p *sitesim.Plant) { + p.Fuse.RatingA, p.HouseW, p.EVSE.Delay = 20, houseA(1.6, 1.7, 2.3), 40*time.Second + }, + lp: func(c *loadpoint.Config) { + c.MinChargeW, c.MaxChargeW, c.PhaseMode = 1600, 11100, "auto" + c.AllowedStepsW = []float64{0, 1600, 1900, 2100, 2400, 2600, 2800, 3100, 3300, 3500, 3800, + 4800, 5500, 6200, 6900, 7600, 8300, 9000, 9700, 10400, 11100} + }, + plan: planEV(11100), + expect: []simExpect{ + {2 * time.Minute, func(s *simSite) error { + if s.p.EVSE.DrawPhases != 3 || s.p.EVSE.DrawW < 4140 { + return fmt.Errorf("car draws %.0f W on %d phases, want three", s.p.EVSE.DrawW, s.p.EVSE.DrawPhases) + } + return nil + }}, + {40*time.Minute - time.Second, func(s *simSite) error { + if s.cutsWithRoom > 0 || s.p.EVSE.SessionWh < 6000 { + return fmt.Errorf("%d cuts with room on every phase, %.2f kWh in 40 min", s.cutsWithRoom, s.p.EVSE.SessionWh/1000) + } + return nil + }}, + }}, {name: "a day", start: time.Date(2026, 10, 21, 0, 0, 0, 0, time.Local), run: 24 * time.Hour, plant: simDay}, } } @@ -611,7 +663,16 @@ func runSimScenario(sc simScenario, start time.Time) *simSite { if sc.lp != nil { sc.lp(&lp) } - s := &simSite{p: p, lp: lp, surplus: sc.surplus, setup: sc.setup} + s := &simSite{p: p, lp: lp, surplus: sc.surplus, plan: sc.plan, setup: sc.setup} + if sc.plan != nil { + setup := sc.setup + s.setup = func(s *simSite) { + s.mgr.SetSchedule(s.lp.ID, loadpoint.Schedule{SoC: .8}) + if setup != nil { + setup(s) + } + } + } s.boot() for _, e := range sc.expect { s.run(start.Add(e.at).Sub(p.Now)) diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index a1ac6b39..c71ce0c7 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -26,72 +26,29 @@ # Core floors the order to 0 W (fuse_limit). help1022/20A/auto/own/cloud60s/car1p/2A/chargenow over-cut netting help1022/20A/auto/own/cloud60s/car1p/2A/chargenow verdict #1795 -help1022/20A/auto/own/cloud60s/car1p/2A/planned one-phase B -help1022/20A/auto/own/cloud60s/car1p/2A/planned over-cut B +help1022/20A/auto/own/cloud60s/car1p/2A/planned energy D +help1022/20A/auto/own/cloud60s/car1p/2A/planned verdict D help1022/20A/auto/own/cloud60s/car3p/2A/chargenow over-cut netting help1022/20A/auto/own/cloud60s/car3p/2A/pause over-cut netting -help1022/20A/auto/own/cloud60s/car3p/2A/planned cut A -help1022/20A/auto/own/cloud60s/car3p/2A/planned one-phase B -help1022/20A/auto/own/cloud60s/car3p/2A/planned over-cut B -help1022/20A/auto/own/cloud60s/car3p/2A/restart cut A -help1022/20A/auto/own/cloud60s/car3p/2A/restart energy A -help1022/20A/auto/own/cloud60s/car3p/2A/restart one-phase B -help1022/20A/auto/own/cloud60s/car3p/2A/restart over-cut B help1022/20A/auto/own/easee120s/car1p/2A/chargenow over-cut netting help1022/20A/auto/own/easee120s/car1p/2A/chargenow verdict #1795 -help1022/20A/auto/own/easee120s/car1p/2A/planned cut A help1022/20A/auto/own/easee120s/car1p/2A/planned energy A -help1022/20A/auto/own/easee120s/car1p/2A/planned one-phase B -help1022/20A/auto/own/easee120s/car1p/2A/planned over-cut B +help1022/20A/auto/own/easee120s/car1p/2A/planned verdict D help1022/20A/auto/own/easee120s/car3p/2A/chargenow over-cut netting help1022/20A/auto/own/easee120s/car3p/2A/pause over-cut netting -help1022/20A/auto/own/easee120s/car3p/2A/planned cut A -help1022/20A/auto/own/easee120s/car3p/2A/planned energy A -help1022/20A/auto/own/easee120s/car3p/2A/planned one-phase B -help1022/20A/auto/own/easee120s/car3p/2A/planned over-cut B -help1022/20A/auto/own/easee120s/car3p/2A/restart cut A -help1022/20A/auto/own/easee120s/car3p/2A/restart energy A -help1022/20A/auto/own/easee120s/car3p/2A/restart one-phase B -help1022/20A/auto/own/easee120s/car3p/2A/restart over-cut B help1022/20A/auto/own/easee40s/car1p/2A/chargenow over-cut netting help1022/20A/auto/own/easee40s/car1p/2A/chargenow verdict #1795 -help1022/20A/auto/own/easee40s/car1p/2A/planned one-phase B -help1022/20A/auto/own/easee40s/car1p/2A/planned over-cut B +help1022/20A/auto/own/easee40s/car1p/2A/planned energy D +help1022/20A/auto/own/easee40s/car1p/2A/planned verdict D help1022/20A/auto/own/easee40s/car3p/2A/chargenow over-cut netting help1022/20A/auto/own/easee40s/car3p/2A/pause over-cut netting -help1022/20A/auto/own/easee40s/car3p/2A/planned one-phase B -help1022/20A/auto/own/easee40s/car3p/2A/planned over-cut B -help1022/20A/auto/own/easee40s/car3p/2A/planned+L1step cut A -help1022/20A/auto/own/easee40s/car3p/2A/planned+L1step energy A -help1022/20A/auto/own/easee40s/car3p/2A/planned+L1step one-phase B -help1022/20A/auto/own/easee40s/car3p/2A/planned+L1step over-cut B -help1022/20A/auto/own/easee40s/car3p/2A/restart cut A -help1022/20A/auto/own/easee40s/car3p/2A/restart one-phase B -help1022/20A/auto/own/easee40s/car3p/2A/restart over-cut B help1022/20A/auto/own/fast2s/car1p/2A/chargenow verdict #1795 -help1022/20A/auto/own/fast2s/car1p/2A/planned one-phase B -help1022/20A/auto/own/fast2s/car3p/2A/planned one-phase B -help1022/20A/auto/own/fast2s/car3p/2A/restart one-phase B +help1022/20A/auto/own/fast2s/car1p/2A/planned energy D +help1022/20A/auto/own/fast2s/car1p/2A/planned verdict D homebox/16A/empty/none/cloud60s/car3p/balanced/chargenow over-cut netting homebox/16A/empty/none/cloud60s/car3p/balanced/pause limit netting -homebox/16A/empty/none/cloud60s/car3p/balanced/planned energy B -homebox/16A/empty/none/cloud60s/car3p/balanced/planned idle B -homebox/16A/empty/none/cloud60s/car3p/balanced/restart energy B -homebox/16A/empty/none/cloud60s/car3p/balanced/restart idle B homebox/16A/empty/none/easee120s/car3p/balanced/chargenow over-cut netting -homebox/16A/empty/none/easee120s/car3p/balanced/planned energy B -homebox/16A/empty/none/easee120s/car3p/balanced/planned idle B -homebox/16A/empty/none/easee120s/car3p/balanced/restart energy B -homebox/16A/empty/none/easee120s/car3p/balanced/restart idle B homebox/16A/empty/none/easee40s/car3p/balanced/chargenow over-cut netting -homebox/16A/empty/none/easee40s/car3p/balanced/planned energy B -homebox/16A/empty/none/easee40s/car3p/balanced/planned idle B -homebox/16A/empty/none/easee40s/car3p/balanced/restart energy B -homebox/16A/empty/none/easee40s/car3p/balanced/restart idle B -homebox/16A/empty/none/fast2s/car3p/balanced/planned energy B -homebox/16A/empty/none/fast2s/car3p/balanced/planned idle B -homebox/16A/empty/none/fast2s/car3p/balanced/restart energy B -homebox/16A/empty/none/fast2s/car3p/balanced/restart idle B pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/chargenow+battery over-cut netting 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 @@ -101,19 +58,10 @@ pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery over-cut netting 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/16A/empty/none/easee40s/car3p/balanced/planned+plan2300W energy B -probe/16A/empty/none/easee40s/car3p/balanced/planned+plan2300W idle B -probe/16A/empty/none/fast2s/car3p/balanced/planned+plan2300W energy B -probe/16A/empty/none/fast2s/car3p/balanced/planned+plan2300W idle B probe/25A/auto/1p-3/easee40s/car3p/balanced/chargenow+charger1p32A idle hold3p -probe/25A/auto/1p-3/easee40s/car3p/balanced/planned+charger1p32A cut A +probe/25A/auto/1p-3/easee40s/car3p/balanced/planned+charger1p32A over-cut netting probe/25A/auto/1p-3/fast2s/car3p/balanced/chargenow+charger1p32A idle hold3p -probe/25A/auto/1p-3/fast2s/car3p/balanced/planned+charger1p32A cut A -probe/25A/auto/1p-3/fast2s/car3p/balanced/planned+charger1p32A energy A probe/25A/auto/mixed/easee40s/car3p/balanced/surplus+goal+export2500W idle readback -probe/25A/empty/none/easee40s/car3p/balanced/planned+plan2300W idle B -probe/25A/empty/none/easee40s/car3p/balanced/planned+plan2300W limit B -probe/25A/empty/none/easee40s/car3p/balanced/planned+plan2300W one-phase B sweep/16A/1p/1p/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/1p/1p/easee40s/car3p/balanced/planned over-cut netting sweep/16A/1p/none/easee40s/car3p/balanced/chargenow over-cut netting @@ -128,18 +76,12 @@ sweep/16A/3p/3p/easee40s/car1p/balanced/planned verdict #1795 sweep/16A/3p/3p/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/3p/mixed/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/3p/mixed/easee40s/car1p/L1/planned energy D -sweep/16A/3p/mixed/easee40s/car1p/L1/planned idle B -sweep/16A/3p/mixed/easee40s/car1p/L1/planned verdict #1795 sweep/16A/3p/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/3p/mixed/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/3p/mixed/easee40s/car1p/balanced/planned energy D -sweep/16A/3p/mixed/easee40s/car1p/balanced/planned idle B sweep/16A/3p/mixed/easee40s/car1p/balanced/planned over-cut netting sweep/16A/3p/mixed/easee40s/car1p/balanced/planned verdict #1795 -sweep/16A/3p/mixed/easee40s/car3p/L1/planned idle B sweep/16A/3p/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/3p/mixed/easee40s/car3p/balanced/planned cut B -sweep/16A/3p/mixed/easee40s/car3p/balanced/planned idle B sweep/16A/3p/none/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/3p/none/easee40s/car1p/L1/planned energy D sweep/16A/3p/none/easee40s/car1p/balanced/chargenow over-cut netting @@ -150,73 +92,51 @@ sweep/16A/3p/none/easee40s/car1p/balanced/planned verdict #1795 sweep/16A/3p/none/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/auto/3p/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/auto/3p/easee40s/car1p/L1/planned energy D -sweep/16A/auto/3p/easee40s/car1p/L1/planned idle B sweep/16A/auto/3p/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/auto/3p/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/auto/3p/easee40s/car1p/balanced/planned energy D -sweep/16A/auto/3p/easee40s/car1p/balanced/planned idle B -sweep/16A/auto/3p/easee40s/car3p/L1/planned energy B -sweep/16A/auto/3p/easee40s/car3p/L1/planned idle B +sweep/16A/auto/3p/easee40s/car1p/balanced/planned over-cut D +sweep/16A/auto/3p/easee40s/car1p/balanced/planned verdict D sweep/16A/auto/3p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/auto/3p/easee40s/car3p/balanced/planned energy B -sweep/16A/auto/3p/easee40s/car3p/balanced/planned idle B sweep/16A/auto/mixed/easee40s/car1p/L1/chargenow verdict #1795 -sweep/16A/auto/mixed/easee40s/car1p/L1/planned one-phase B +sweep/16A/auto/mixed/easee40s/car1p/L1/planned energy D sweep/16A/auto/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/auto/mixed/easee40s/car1p/balanced/chargenow verdict #1795 -sweep/16A/auto/mixed/easee40s/car1p/balanced/planned one-phase B +sweep/16A/auto/mixed/easee40s/car1p/balanced/planned energy D sweep/16A/auto/mixed/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/auto/mixed/easee40s/car3p/L1/planned energy B -sweep/16A/auto/mixed/easee40s/car3p/L1/planned one-phase B +sweep/16A/auto/mixed/easee40s/car1p/balanced/planned verdict D sweep/16A/auto/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/auto/mixed/easee40s/car3p/balanced/planned energy B -sweep/16A/auto/mixed/easee40s/car3p/balanced/planned one-phase B -sweep/16A/auto/mixed/easee40s/car3p/balanced/planned over-cut B sweep/16A/auto/none/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/auto/none/easee40s/car1p/L1/planned energy D -sweep/16A/auto/none/easee40s/car1p/L1/planned idle B sweep/16A/auto/none/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/auto/none/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/auto/none/easee40s/car1p/balanced/planned energy D -sweep/16A/auto/none/easee40s/car1p/balanced/planned idle B -sweep/16A/auto/none/easee40s/car3p/L1/planned energy B -sweep/16A/auto/none/easee40s/car3p/L1/planned idle B +sweep/16A/auto/none/easee40s/car1p/balanced/planned over-cut D +sweep/16A/auto/none/easee40s/car1p/balanced/planned verdict D sweep/16A/auto/none/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/auto/none/easee40s/car3p/balanced/planned energy B -sweep/16A/auto/none/easee40s/car3p/balanced/planned idle B sweep/16A/empty/3p/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/empty/3p/easee40s/car1p/L1/planned energy D -sweep/16A/empty/3p/easee40s/car1p/L1/planned idle B sweep/16A/empty/3p/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/empty/3p/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/empty/3p/easee40s/car1p/balanced/planned energy D -sweep/16A/empty/3p/easee40s/car1p/balanced/planned idle B -sweep/16A/empty/3p/easee40s/car3p/L1/planned energy B -sweep/16A/empty/3p/easee40s/car3p/L1/planned idle B +sweep/16A/empty/3p/easee40s/car1p/balanced/planned over-cut D +sweep/16A/empty/3p/easee40s/car1p/balanced/planned verdict D sweep/16A/empty/3p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/empty/3p/easee40s/car3p/balanced/planned energy B -sweep/16A/empty/3p/easee40s/car3p/balanced/planned idle B sweep/16A/empty/mixed/easee40s/car1p/L1/chargenow verdict #1795 -sweep/16A/empty/mixed/easee40s/car1p/L1/planned one-phase B +sweep/16A/empty/mixed/easee40s/car1p/L1/planned energy D sweep/16A/empty/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/empty/mixed/easee40s/car1p/balanced/chargenow verdict #1795 -sweep/16A/empty/mixed/easee40s/car1p/balanced/planned one-phase B +sweep/16A/empty/mixed/easee40s/car1p/balanced/planned energy D sweep/16A/empty/mixed/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/empty/mixed/easee40s/car3p/L1/planned energy B -sweep/16A/empty/mixed/easee40s/car3p/L1/planned one-phase B +sweep/16A/empty/mixed/easee40s/car1p/balanced/planned verdict D sweep/16A/empty/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/empty/mixed/easee40s/car3p/balanced/planned energy B -sweep/16A/empty/mixed/easee40s/car3p/balanced/planned one-phase B -sweep/16A/empty/mixed/easee40s/car3p/balanced/planned over-cut B sweep/16A/empty/none/easee40s/car1p/L1/chargenow verdict #1795 sweep/16A/empty/none/easee40s/car1p/L1/planned energy D -sweep/16A/empty/none/easee40s/car1p/L1/planned idle B sweep/16A/empty/none/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/empty/none/easee40s/car1p/balanced/chargenow verdict #1795 sweep/16A/empty/none/easee40s/car1p/balanced/planned energy D -sweep/16A/empty/none/easee40s/car1p/balanced/planned idle B -sweep/16A/empty/none/easee40s/car3p/L1/planned energy B -sweep/16A/empty/none/easee40s/car3p/L1/planned idle B +sweep/16A/empty/none/easee40s/car1p/balanced/planned over-cut D +sweep/16A/empty/none/easee40s/car1p/balanced/planned verdict D sweep/25A/1p/1p/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/1p/1p/easee40s/car3p/L1/planned over-cut netting sweep/25A/1p/1p/easee40s/car3p/balanced/chargenow over-cut netting @@ -232,18 +152,11 @@ sweep/25A/3p/3p/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/3p/3p/easee40s/car3p/balanced/chargenow over-cut netting sweep/25A/3p/mixed/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/3p/mixed/easee40s/car1p/L1/planned energy D -sweep/25A/3p/mixed/easee40s/car1p/L1/planned idle B -sweep/25A/3p/mixed/easee40s/car1p/L1/planned over-cut B sweep/25A/3p/mixed/easee40s/car1p/L1/planned verdict #1795 sweep/25A/3p/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/3p/mixed/easee40s/car1p/balanced/planned energy D -sweep/25A/3p/mixed/easee40s/car1p/balanced/planned idle B sweep/25A/3p/mixed/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/3p/mixed/easee40s/car3p/L1/planned cut B -sweep/25A/3p/mixed/easee40s/car3p/L1/planned idle B sweep/25A/3p/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/3p/mixed/easee40s/car3p/balanced/planned cut B -sweep/25A/3p/mixed/easee40s/car3p/balanced/planned idle B sweep/25A/3p/none/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/3p/none/easee40s/car1p/L1/planned energy D sweep/25A/3p/none/easee40s/car1p/L1/planned verdict #1795 @@ -253,117 +166,57 @@ sweep/25A/3p/none/easee40s/car3p/L1/chargenow over-cut netting sweep/25A/3p/none/easee40s/car3p/balanced/chargenow over-cut netting sweep/25A/auto/3p/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/auto/3p/easee40s/car1p/L1/planned energy D -sweep/25A/auto/3p/easee40s/car1p/L1/planned idle B +sweep/25A/auto/3p/easee40s/car1p/L1/planned verdict D sweep/25A/auto/3p/easee40s/car1p/balanced/chargenow over-cut netting -sweep/25A/auto/3p/easee40s/car1p/balanced/planned idle B -sweep/25A/auto/3p/easee40s/car1p/balanced/planned one-phase B +sweep/25A/auto/3p/easee40s/car1p/balanced/planned energy D sweep/25A/auto/3p/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/auto/3p/easee40s/car3p/L1/planned energy B -sweep/25A/auto/3p/easee40s/car3p/L1/planned idle B sweep/25A/auto/3p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/auto/3p/easee40s/car3p/balanced/planned idle B -sweep/25A/auto/3p/easee40s/car3p/balanced/planned one-phase B sweep/25A/auto/mixed/easee40s/car1p/L1/chargenow over-cut netting -sweep/25A/auto/mixed/easee40s/car1p/L1/planned one-phase B -sweep/25A/auto/mixed/easee40s/car1p/L1/planned over-cut B +sweep/25A/auto/mixed/easee40s/car1p/L1/planned energy D +sweep/25A/auto/mixed/easee40s/car1p/L1/planned verdict D sweep/25A/auto/mixed/easee40s/car1p/balanced/chargenow over-cut netting -sweep/25A/auto/mixed/easee40s/car1p/balanced/planned one-phase B +sweep/25A/auto/mixed/easee40s/car1p/balanced/planned energy D sweep/25A/auto/mixed/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/auto/mixed/easee40s/car3p/L1/planned cut A -sweep/25A/auto/mixed/easee40s/car3p/L1/planned one-phase B -sweep/25A/auto/mixed/easee40s/car3p/L1/planned over-cut B sweep/25A/auto/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/auto/mixed/easee40s/car3p/balanced/planned one-phase B sweep/25A/auto/none/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/auto/none/easee40s/car1p/L1/planned energy D -sweep/25A/auto/none/easee40s/car1p/L1/planned idle B +sweep/25A/auto/none/easee40s/car1p/L1/planned verdict D sweep/25A/auto/none/easee40s/car1p/balanced/chargenow over-cut netting -sweep/25A/auto/none/easee40s/car1p/balanced/planned idle B -sweep/25A/auto/none/easee40s/car1p/balanced/planned limit B -sweep/25A/auto/none/easee40s/car1p/balanced/planned one-phase B +sweep/25A/auto/none/easee40s/car1p/balanced/planned energy D sweep/25A/auto/none/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/auto/none/easee40s/car3p/L1/planned energy B -sweep/25A/auto/none/easee40s/car3p/L1/planned idle B sweep/25A/auto/none/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/auto/none/easee40s/car3p/balanced/planned idle B -sweep/25A/auto/none/easee40s/car3p/balanced/planned limit B -sweep/25A/auto/none/easee40s/car3p/balanced/planned one-phase B sweep/25A/empty/3p/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/empty/3p/easee40s/car1p/L1/planned energy D -sweep/25A/empty/3p/easee40s/car1p/L1/planned idle B +sweep/25A/empty/3p/easee40s/car1p/L1/planned verdict D sweep/25A/empty/3p/easee40s/car1p/balanced/chargenow over-cut netting -sweep/25A/empty/3p/easee40s/car1p/balanced/planned idle B -sweep/25A/empty/3p/easee40s/car1p/balanced/planned one-phase B +sweep/25A/empty/3p/easee40s/car1p/balanced/planned energy D sweep/25A/empty/3p/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/empty/3p/easee40s/car3p/L1/planned energy B -sweep/25A/empty/3p/easee40s/car3p/L1/planned idle B sweep/25A/empty/3p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/empty/3p/easee40s/car3p/balanced/planned idle B -sweep/25A/empty/3p/easee40s/car3p/balanced/planned one-phase B sweep/25A/empty/mixed/easee40s/car1p/L1/chargenow over-cut netting -sweep/25A/empty/mixed/easee40s/car1p/L1/planned one-phase B -sweep/25A/empty/mixed/easee40s/car1p/L1/planned over-cut B +sweep/25A/empty/mixed/easee40s/car1p/L1/planned energy D +sweep/25A/empty/mixed/easee40s/car1p/L1/planned verdict D sweep/25A/empty/mixed/easee40s/car1p/balanced/chargenow over-cut netting -sweep/25A/empty/mixed/easee40s/car1p/balanced/planned one-phase B +sweep/25A/empty/mixed/easee40s/car1p/balanced/planned energy D sweep/25A/empty/mixed/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/empty/mixed/easee40s/car3p/L1/planned cut A -sweep/25A/empty/mixed/easee40s/car3p/L1/planned one-phase B -sweep/25A/empty/mixed/easee40s/car3p/L1/planned over-cut B sweep/25A/empty/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/empty/mixed/easee40s/car3p/balanced/planned one-phase B sweep/25A/empty/none/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/empty/none/easee40s/car1p/L1/planned energy D -sweep/25A/empty/none/easee40s/car1p/L1/planned idle B +sweep/25A/empty/none/easee40s/car1p/L1/planned verdict D sweep/25A/empty/none/easee40s/car1p/balanced/chargenow over-cut netting -sweep/25A/empty/none/easee40s/car1p/balanced/planned idle B -sweep/25A/empty/none/easee40s/car1p/balanced/planned limit B -sweep/25A/empty/none/easee40s/car1p/balanced/planned one-phase B +sweep/25A/empty/none/easee40s/car1p/balanced/planned energy D sweep/25A/empty/none/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/empty/none/easee40s/car3p/L1/planned energy B -sweep/25A/empty/none/easee40s/car3p/L1/planned idle B sweep/25A/empty/none/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/empty/none/easee40s/car3p/balanced/planned idle B -sweep/25A/empty/none/easee40s/car3p/balanced/planned limit B -sweep/25A/empty/none/easee40s/car3p/balanced/planned one-phase B timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle export timing/16A/3p/3p/easee120s/car3p/L1/surplus idle export timing/16A/3p/3p/easee40s/car3p/L1/surplus idle export timing/16A/3p/3p/fast2s/car3p/L1/surplus idle export -timing/16A/auto/mixed/cloud60s/car3p/L1/planned energy B -timing/16A/auto/mixed/cloud60s/car3p/L1/planned one-phase B -timing/16A/auto/mixed/cloud60s/car3p/L1/restart energy B -timing/16A/auto/mixed/cloud60s/car3p/L1/restart one-phase B timing/16A/auto/mixed/cloud60s/car3p/L1/surplus idle export -timing/16A/auto/mixed/easee120s/car3p/L1/planned energy B -timing/16A/auto/mixed/easee120s/car3p/L1/planned one-phase B -timing/16A/auto/mixed/easee120s/car3p/L1/restart energy B -timing/16A/auto/mixed/easee120s/car3p/L1/restart one-phase B timing/16A/auto/mixed/easee120s/car3p/L1/surplus idle export -timing/16A/auto/mixed/easee40s/car3p/L1/restart energy B -timing/16A/auto/mixed/easee40s/car3p/L1/restart one-phase B timing/16A/auto/mixed/easee40s/car3p/L1/surplus idle export -timing/16A/auto/mixed/fast2s/car3p/L1/planned energy B -timing/16A/auto/mixed/fast2s/car3p/L1/planned one-phase B -timing/16A/auto/mixed/fast2s/car3p/L1/restart energy B -timing/16A/auto/mixed/fast2s/car3p/L1/restart one-phase B timing/16A/auto/mixed/fast2s/car3p/L1/surplus idle export -timing/16A/empty/none/cloud60s/car3p/L1/planned energy B -timing/16A/empty/none/cloud60s/car3p/L1/planned idle B -timing/16A/empty/none/cloud60s/car3p/L1/restart energy B -timing/16A/empty/none/cloud60s/car3p/L1/restart idle B timing/16A/empty/none/cloud60s/car3p/L1/surplus idle export -timing/16A/empty/none/easee120s/car3p/L1/planned energy B -timing/16A/empty/none/easee120s/car3p/L1/planned idle B -timing/16A/empty/none/easee120s/car3p/L1/restart energy B -timing/16A/empty/none/easee120s/car3p/L1/restart idle B timing/16A/empty/none/easee120s/car3p/L1/surplus idle export -timing/16A/empty/none/easee40s/car3p/L1/restart energy B -timing/16A/empty/none/easee40s/car3p/L1/restart idle B timing/16A/empty/none/easee40s/car3p/L1/surplus idle export -timing/16A/empty/none/fast2s/car3p/L1/planned energy B -timing/16A/empty/none/fast2s/car3p/L1/planned idle B -timing/16A/empty/none/fast2s/car3p/L1/restart energy B -timing/16A/empty/none/fast2s/car3p/L1/restart idle B timing/16A/empty/none/fast2s/car3p/L1/surplus idle export timing/25A/3p/3p/cloud60s/car3p/L1/chargenow over-cut netting timing/25A/3p/3p/cloud60s/car3p/L1/pause over-cut netting @@ -375,48 +228,18 @@ timing/25A/3p/3p/easee40s/car3p/L1/pause over-cut netting timing/25A/3p/3p/easee40s/car3p/L1/surplus idle readback timing/25A/auto/mixed/cloud60s/car3p/L1/chargenow over-cut netting timing/25A/auto/mixed/cloud60s/car3p/L1/pause over-cut netting -timing/25A/auto/mixed/cloud60s/car3p/L1/planned cut A -timing/25A/auto/mixed/cloud60s/car3p/L1/planned one-phase B -timing/25A/auto/mixed/cloud60s/car3p/L1/planned over-cut B -timing/25A/auto/mixed/cloud60s/car3p/L1/restart cut A -timing/25A/auto/mixed/cloud60s/car3p/L1/restart one-phase B -timing/25A/auto/mixed/cloud60s/car3p/L1/restart over-cut B timing/25A/auto/mixed/cloud60s/car3p/L1/surplus idle readback timing/25A/auto/mixed/easee120s/car3p/L1/chargenow over-cut netting timing/25A/auto/mixed/easee120s/car3p/L1/pause over-cut netting -timing/25A/auto/mixed/easee120s/car3p/L1/planned cut A -timing/25A/auto/mixed/easee120s/car3p/L1/planned one-phase B -timing/25A/auto/mixed/easee120s/car3p/L1/planned over-cut B -timing/25A/auto/mixed/easee120s/car3p/L1/restart cut A -timing/25A/auto/mixed/easee120s/car3p/L1/restart one-phase B -timing/25A/auto/mixed/easee120s/car3p/L1/restart over-cut B timing/25A/auto/mixed/easee120s/car3p/L1/surplus idle readback timing/25A/auto/mixed/easee40s/car3p/L1/pause over-cut netting -timing/25A/auto/mixed/easee40s/car3p/L1/restart one-phase B -timing/25A/auto/mixed/easee40s/car3p/L1/restart over-cut B timing/25A/auto/mixed/easee40s/car3p/L1/surplus idle readback -timing/25A/auto/mixed/fast2s/car3p/L1/planned one-phase B -timing/25A/auto/mixed/fast2s/car3p/L1/restart one-phase B timing/25A/empty/none/cloud60s/car3p/L1/chargenow over-cut netting timing/25A/empty/none/cloud60s/car3p/L1/pause over-cut netting -timing/25A/empty/none/cloud60s/car3p/L1/planned energy B -timing/25A/empty/none/cloud60s/car3p/L1/planned idle B -timing/25A/empty/none/cloud60s/car3p/L1/restart energy B -timing/25A/empty/none/cloud60s/car3p/L1/restart idle B timing/25A/empty/none/cloud60s/car3p/L1/surplus idle readback timing/25A/empty/none/easee120s/car3p/L1/chargenow over-cut netting timing/25A/empty/none/easee120s/car3p/L1/pause over-cut netting -timing/25A/empty/none/easee120s/car3p/L1/planned energy B -timing/25A/empty/none/easee120s/car3p/L1/planned idle B -timing/25A/empty/none/easee120s/car3p/L1/restart energy B -timing/25A/empty/none/easee120s/car3p/L1/restart idle B timing/25A/empty/none/easee120s/car3p/L1/surplus idle readback timing/25A/empty/none/easee40s/car3p/L1/pause over-cut netting -timing/25A/empty/none/easee40s/car3p/L1/restart energy B -timing/25A/empty/none/easee40s/car3p/L1/restart idle B timing/25A/empty/none/easee40s/car3p/L1/surplus idle readback -timing/25A/empty/none/fast2s/car3p/L1/planned energy B -timing/25A/empty/none/fast2s/car3p/L1/planned idle B -timing/25A/empty/none/fast2s/car3p/L1/restart energy B -timing/25A/empty/none/fast2s/car3p/L1/restart idle B timing/25A/empty/none/fast2s/car3p/L1/surplus idle export diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 07bc69f3..bd61fe67 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1269,7 +1269,7 @@ func (c *Controller) chooseTick(now time.Time, cfg Config, sample EVSample, disp return choice // No command this tick. } if hold, held := c.GetManualHold(cfg.ID, now); held { - choice.phaseMode = holdPhaseMode(hold, cfg, choice.surplusOn) + choice.phaseMode = holdPhaseMode(hold, cfg, c.siteFuse()) } else { if choice.surplusOn || choice.surplusAdds { choice.steps = c.pickSurplusSteps(now, cfg) @@ -1277,7 +1277,9 @@ func (c *Controller) chooseTick(now time.Time, cfg Config, sample EVSample, disp // Easee treats an unset phase_mode as 3p and then ignores a 1Φ step. // A schedule still overrides the surplus 1Φ lock, or a deadline // charge stays near 3.7 kW on a cloudy day. - choice.phaseMode = resolvePhaseMode(cfg.PhaseMode, sched.HasTarget(), + // A solar-only loadpoint charges from surplus whatever its goal, so + // its schedule does not make the charge a planned one. + choice.phaseMode = resolvePhaseMode(cfg.PhaseMode, threePhaseCapable(cfg, c.siteFuse()), sched.HasTarget() && !cfg.SurplusOnly, c.surplusLockedTo1P(cfg.ID), choice.surplusOn, c.dwellSelectedPhaseMode(cfg.ID)) } if c.siteFuse().Phases() == 1 { @@ -1286,19 +1288,24 @@ func (c *Controller) chooseTick(now time.Time, cfg Config, sample EVSample, disp return choice } -// holdPhaseMode: explicit hold > explicit loadpoint config > surplus default -// ("auto") > driver default. A sticky Start hold on a 1380 W surplus slot -// must not die at the Easee driver's unset → "3p" interpretation. -func holdPhaseMode(hold ManualHold, cfg Config, surplusOn bool) string { +// 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. +func holdPhaseMode(hold ManualHold, cfg Config, site SiteFuse) string { + mode := hold.PhaseMode + if mode == "" { + mode = cfg.PhaseMode + } switch { - case hold.PhaseMode != "": - return hold.PhaseMode - case cfg.PhaseMode != "": - return cfg.PhaseMode - case surplusOn: + case mode != "" && mode != "auto": + return mode + case !threePhaseCapable(cfg, site): + return "1p" + case hold.PowerW < phaseMinChargeW(cfg, "3p", site): return "auto" } - return "" + return "3p" } func (c *Controller) observeTick(now time.Time, cfg Config, sample EVSample, dispatchAllowed bool) { @@ -1467,7 +1474,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s cmd["power_w"] = holdW // A hold changed after the budget froze names its own phases; the // phase check below makes it wait for the next tick's budget. - phaseMode = holdPhaseMode(hold, lpCfg, surplusOn) + phaseMode = holdPhaseMode(hold, lpCfg, c.siteFuse()) if phaseMode != "" { cmd["phase_mode"] = phaseMode } @@ -1503,7 +1510,8 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s cmd["site_phases"] = site.Phases() } } else { - cmdW, planReady, _ := c.computeCommandForBudget(now, lpCfg, sample.PowerW, false) + // Snap to steps legal on the phases this command names. + cmdW, planReady, _ := c.computeCommandForBudget(now, c.fuseConfig(lpCfg, phaseMode), sample.PowerW, false) cmdReason = "plan" if c.plan != nil { if d, ok := c.currentDirective(now); ok && d.LoadpointFallback[lpCfg.ID] { @@ -2224,37 +2232,28 @@ afterNearTerm: // surplusLockedTo1P reports whether the surplus_only 1Φ lock is // currently active for the given loadpoint. Read-only accessor. -// resolvePhaseMode picks the phase_mode sent to the driver. -// A schedule wins, or a deadline charge is stuck on the 1Φ lock. -// Then the day lock, then the 30 min dwell, then the operator pin. -func resolvePhaseMode(operatorMode string, scheduleActive, surplusLocked1P, surplusOn bool, dwell string) string { +// resolvePhaseMode picks the phase_mode sent to the driver. An operator pin +// wins; a 3Φ-only charger never gets the surplus 1Φ lock. Unset and "auto" +// mean the same. A charger that cannot reach 6 A on three phases runs on +// one. Otherwise planned charging runs on three: one phase never helps the +// fuse, it puts all the current on one, caps the car near 3.7 kW, and an +// Easee pauses to switch and may stay there. Only surplus charging without +// a schedule may use one phase: the day lock, then the 30 min dwell, then +// auto. +func resolvePhaseMode(operatorMode string, threePhase, scheduleActive, surplusLocked1P, surplusOn bool, dwell string) string { switch { - case scheduleActive: - if operatorMode == "" { - return "auto" - } + case operatorMode != "" && operatorMode != "auto": return operatorMode + case !threePhase: + return "1p" + case scheduleActive || !surplusOn: + return "3p" case surplusLocked1P: - // A 3Φ-only charger (operator pinned "3p") physically cannot - // trickle at 1Φ, so the surplus 1Φ lock must never override it — - // 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" - } return "1p" - case surplusOn && (operatorMode == "" || operatorMode == "auto"): - if dwell != "" { - return dwell - } - if operatorMode == "" { - return "auto" - } - return operatorMode - default: - return operatorMode + case dwell != "": + return dwell } + return "auto" } func (c *Controller) surplusLockedTo1P(id string) bool { diff --git a/go/internal/loadpoint/controller_bat_soc_unlock_test.go b/go/internal/loadpoint/controller_bat_soc_unlock_test.go index 6c929098..92702659 100644 --- a/go/internal/loadpoint/controller_bat_soc_unlock_test.go +++ b/go/internal/loadpoint/controller_bat_soc_unlock_test.go @@ -289,10 +289,10 @@ func TestTickScheduleUnlockAddsSurplusOverEmptyPlan(t *testing.T) { if sent.power != 4140 || w != 4140 || r != "pv_surplus" { t.Fatalf("want 4140 W / pv_surplus; sent %.0f W, recorded (%.0f W, %q)", sent.power, w, r) } - // A scheduled charge keeps the schedule's phase behaviour: "auto" for - // an unset operator mode, never the surplus 1Φ lock. - if sent.phaseMode != "auto" { - t.Errorf("phase_mode = %q, want auto while a schedule is active", sent.phaseMode) + // A scheduled charge keeps the schedule's phase behaviour: three phases + // for an unset operator mode, never the surplus 1Φ lock. + if sent.phaseMode != "3p" { + t.Errorf("phase_mode = %q, want 3p while a schedule is active", sent.phaseMode) } } diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index e81c3df0..03e6c8af 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -41,7 +41,7 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample if hold, held := c.GetManualHold(cfg.ID, now); held { request.wantW = clampManualPower(cfg, hold, c.siteFuse()) } else { - request.wantW, _, _ = c.computeCommandForBudget(now, cfg, sample.PowerW, false) + request.wantW, _, _ = c.computeCommandForBudget(now, request.cfg, sample.PowerW, false) } request.goal = request.wantW > 0 && !cfg.SurplusOnly if _, held := c.GetManualHold(cfg.ID, now); !held { @@ -138,8 +138,12 @@ func (c *Controller) creditA(id string, phases int) float64 { } // phaseRoomCapW is the most a command may offer when it can add at most -// roomA to the busiest phase. It always keeps the last command's current. -// An auto command below the split lands on one phase. +// roomA to the busiest phase. An auto command below the split lands on one +// phase. Room limits rises only: keeping the last command is never a cut, +// since the meter or the pending count already holds it, and only a +// measured overage cuts (EVFuseBudget). The same watts on other phases are +// a rise on some of them, so the last command is kept only when the order +// it allows lands on the same phases. func (c *Controller) phaseRoomCapW(id, mode string, wantW, split, roomA float64) float64 { site := c.siteFuse() if site.Voltage <= 0 { @@ -151,6 +155,10 @@ func (c *Controller) phaseRoomCapW(id, mode string, wantW, split, roomA float64) if phases == 3 && c.commandPhases(mode, capW, split) == 1 { capW = math.Min(capW, (c.creditA(id, 1)+roomA)*site.Voltage) } + if st, ok := c.manager.State(id); ok && st.CommandedKnown && st.CommandedPhases > 0 && st.CommandedW > capW && + c.commandPhases(mode, math.Min(wantW, st.CommandedW), split) == st.CommandedPhases { + capW = st.CommandedW + } return capW } diff --git a/go/internal/loadpoint/controller_fuse_phase_test.go b/go/internal/loadpoint/controller_fuse_phase_test.go index f9232a78..f5ad4fd5 100644 --- a/go/internal/loadpoint/controller_fuse_phase_test.go +++ b/go/internal/loadpoint/controller_fuse_phase_test.go @@ -13,10 +13,15 @@ func TestSharedFuseGuardUsesCommandPhase(t *testing.T) { manual, schedule, surplus bool wantW, worstA, commandW float64 }{ - {name: "legacy_plan", wantW: 13110, worstA: 5, commandW: 13110, demandMode: "3p"}, - {name: "legacy_manual", manual: true, wantW: 13110, worstA: 5, commandW: 13110, demandMode: "3p"}, - {name: "legacy_manual_over_schedule", manual: true, schedule: true, wantW: 13110, worstA: 5, commandW: 13110, demandMode: "3p"}, - {name: "scheduled_auto", schedule: true, wantW: 2760, worstA: 20, commandMode: "auto", demandMode: "auto"}, + // An unset mode is auto: three phases, named in the order. + {name: "legacy_plan", wantW: 13110, worstA: 5, commandW: 13110, commandMode: "3p", demandMode: "3p"}, + {name: "legacy_manual", manual: true, wantW: 13110, worstA: 5, commandW: 13110, commandMode: "3p", demandMode: "3p"}, + {name: "legacy_manual_over_schedule", manual: true, schedule: true, wantW: 13110, worstA: 5, commandW: 13110, commandMode: "3p", demandMode: "3p"}, + {name: "scheduled_1p", configMode: "1p", schedule: true, wantW: 2760, worstA: 20, commandMode: "1p", demandMode: "1p"}, + // Planned charging on auto runs on three phases: the 3Φ minimum does + // not fit 4 A of room, and no one-phase order takes its place. + {name: "scheduled_auto_runs_3p", configMode: "auto", schedule: true, wantW: 4140, worstA: 20, commandMode: "3p", demandMode: "3p"}, + {name: "scheduled_auto_room", configMode: "auto", schedule: true, wantW: 13110, worstA: 5, commandW: 13110, commandMode: "3p", demandMode: "3p"}, {name: "surplus_manual_auto", manual: true, surplus: true, wantW: 2760, worstA: 20, commandMode: "auto", demandMode: "auto"}, {name: "manual_1p_over_3p", configMode: "3p", manual: true, holdMode: "1p", wantW: 2760, worstA: 20, commandMode: "1p", demandMode: "1p"}, {name: "manual_3p_over_auto", configMode: "auto", manual: true, holdMode: "3p", wantW: 13110, worstA: 5, commandW: 13110, commandMode: "3p", demandMode: "3p"}, @@ -59,7 +64,7 @@ func TestAutomaticManualReleasePrecedesSharedPhaseBudget(t *testing.T) { t.Run(release, func(t *testing.T) { now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) cfg := Config{ID: "garage", DriverName: "charger", MinChargeW: 1380, MaxChargeW: 13110, - AllowedStepsW: []float64{0, 1380, 2760, 4140, 13110}, VehicleCapacityWh: 60000, PluginSoC: .8} + AllowedStepsW: []float64{0, 1380, 2760, 4140, 13110}, VehicleCapacityWh: 60000, PluginSoC: .8, PhaseMode: "1p"} d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{cfg.ID: 690}, LoadpointMaxPowerW: map[string]float64{cfg.ID: 2760}} samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: release != "idle"}} @@ -84,7 +89,7 @@ func TestAutomaticManualReleasePrecedesSharedPhaseBudget(t *testing.T) { t.Fatal("completed manual hold was not released") } command, known := lastSetCurrent(sender.calls) - if !known || command.phaseMode != "auto" || command.power != 0 { + if !known || command.phaseMode != "1p" || command.power != 0 { t.Fatalf("automatic order reused the earlier manual 3p budget: %+v", command) } }) diff --git a/go/internal/loadpoint/controller_manual_hold_test.go b/go/internal/loadpoint/controller_manual_hold_test.go index c706e04f..bc57adfc 100644 --- a/go/internal/loadpoint/controller_manual_hold_test.go +++ b/go/internal/loadpoint/controller_manual_hold_test.go @@ -110,12 +110,13 @@ func TestManualHoldExpiresFallsBackToPlan(t *testing.T) { cmd := sender.calls[0] // Should have followed the plan, not the hold. Plan allocates // 6 kWh / 1 h = 6000 W, snapped to nearest step 6210 W (3Φ-flavored - // budget). And phase_mode comes from lpCfg ("auto"), not hold ("1p"). + // budget). And phase_mode comes from lpCfg ("auto", which planned + // charging sends as "3p"), not hold ("1p"). if cmd.power != 6210 { t.Errorf("power = %.0f, want 6210 (plan path), hold should have expired", cmd.power) } - if cmd.phaseMode != "auto" { - t.Errorf("phase_mode = %q, want \"auto\" (lpCfg path), hold should have expired", cmd.phaseMode) + if cmd.phaseMode != "3p" { + t.Errorf("phase_mode = %q, want \"3p\" (lpCfg path), hold should have expired", cmd.phaseMode) } // Expired hold should have been lazily evicted on the read. if _, active := c.GetManualHold(cfg.ID, now); active { diff --git a/go/internal/loadpoint/controller_phase_handoff_test.go b/go/internal/loadpoint/controller_phase_handoff_test.go index 94714db0..249d74c7 100644 --- a/go/internal/loadpoint/controller_phase_handoff_test.go +++ b/go/internal/loadpoint/controller_phase_handoff_test.go @@ -54,22 +54,30 @@ func runPhaseTick(t *testing.T, cfg Config, site SiteFuse, wantWh float64) sentC return sender.calls[0] } +// Planned charging on an auto loadpoint runs on three phases. A plan of +// 4,000 W, below 6 A a phase, goes out as the 3Φ minimum, not as one phase. func TestCommandCarriesPhaseModePreference(t *testing.T) { cfg := phaseLoadpoint("auto", 0, 0) cmd := runPhaseTick(t, cfg, SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}, 4000) - if cmd.phaseMode != "auto" { - t.Errorf("phase_mode in cmd = %q, want \"auto\"", cmd.phaseMode) + if cmd.phaseMode != "3p" || cmd.power != 4140 { + t.Errorf("cmd = %.0f W phase_mode %q, want 4140 W \"3p\"", cmd.power, cmd.phaseMode) + } + cfg = phaseLoadpoint("1p", 0, 0) + if cmd := runPhaseTick(t, cfg, SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}, 3000); cmd.phaseMode != "1p" { + t.Errorf("a 1p pin sent phase_mode %q", cmd.phaseMode) } } -func TestEmptyPhaseModeOmittedFromCommand(t *testing.T) { - // Empty PhaseMode (legacy default) MUST NOT land in the cmd — - // drivers that don't read this field should see exactly the - // pre-phase-switching shape and behave as locked-3Φ. +// An unset PhaseMode means auto: a charger that can reach 6 A on three +// phases runs on three, and says so, so the order is raised to the 3Φ +// minimum instead of going out as a 4,000 W "unset" that Easee runs as +// 5.8 A on three phases and stops. Drivers that read no phase_mode (OCPP +// takes "3p" as the site's phases) see the same order as before. +func TestEmptyPhaseModeIsThreePhase(t *testing.T) { cfg := phaseLoadpoint("", 0, 0) cmd := runPhaseTick(t, cfg, SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}, 4000) - if cmd.phaseMode != "" { - t.Errorf("phase_mode = %q, want empty (omitted from cmd)", cmd.phaseMode) + if cmd.phaseMode != "3p" || cmd.power != 4140 { + t.Errorf("cmd = %.0f W phase_mode %q, want 4140 W \"3p\"", cmd.power, cmd.phaseMode) } } diff --git a/go/internal/loadpoint/controller_phase_mode_test.go b/go/internal/loadpoint/controller_phase_mode_test.go index 577c8dea..e75a499e 100644 --- a/go/internal/loadpoint/controller_phase_mode_test.go +++ b/go/internal/loadpoint/controller_phase_mode_test.go @@ -2,46 +2,58 @@ package loadpoint import "testing" -// TestResolvePhaseMode locks the phase_mode precedence — in particular the -// operator directive (2026-05-30) that an active charge schedule overrides the -// surplus 1Φ forecast lock so a deadline-driven charge can pull 3Φ grid power. +// TestResolvePhaseMode locks the phase_mode precedence: an operator pin wins, +// unset means auto, a charger that cannot reach 6 A on three phases runs on +// one, planned charging runs on three (Fredrik, 22 Oct 2026: one phase never +// helps the fuse and caps the car near 3.7 kW), and only surplus charging +// without a schedule may use one phase. The 2026-05-30 directive stands: a +// schedule overrides the surplus 1Φ forecast lock. func TestResolvePhaseMode(t *testing.T) { cases := []struct { name string operatorMode string + oneOnly bool // the charger cannot reach 6 A on three phases scheduleActive bool locked1P bool surplusOn bool dwell string want string }{ - // The fix: a schedule beats the 1Φ forecast lock. - {"schedule overrides 1p lock (auto)", "auto", true, true, true, "1p", "auto"}, - {"schedule overrides 1p lock (unset)", "", true, true, true, "1p", "auto"}, + // A schedule beats the 1Φ forecast lock: auto and unset are three + // phases. + {"schedule overrides 1p lock (auto)", "auto", false, true, true, true, "1p", "3p"}, + {"schedule overrides 1p lock (unset)", "", false, true, true, true, "1p", "3p"}, // An explicit operator pin is still honoured under a schedule. - {"schedule honours explicit 3p", "3p", true, true, false, "", "3p"}, - {"schedule honours explicit 1p", "1p", true, true, false, "", "1p"}, + {"schedule honours explicit 3p", "3p", false, true, true, false, "", "3p"}, + {"schedule honours explicit 1p", "1p", false, true, true, false, "", "1p"}, // No schedule: the surplus 1Φ lock applies as before. - {"1p lock applies without schedule", "auto", false, true, true, "3p", "1p"}, + {"1p lock applies without schedule", "auto", false, false, true, true, "3p", "1p"}, // A 3Φ-only charger must never be forced to 1Φ by the surplus // lock — it physically cannot trickle single-phase (e.g. CTEK). - {"3p charger overrides 1p lock", "3p", false, true, true, "1p", "3p"}, + {"3p charger overrides 1p lock", "3p", false, false, true, true, "1p", "3p"}, // No schedule, surplus-active, auto → dwell verdict. - {"dwell verdict when surplus auto", "auto", false, false, true, "3p", "3p"}, - {"auto default when no dwell", "auto", false, false, true, "", "auto"}, - {"unset → auto under surplus", "", false, false, true, "", "auto"}, - // No schedule, no surplus → operator's mode verbatim. - {"operator verbatim, no surplus", "auto", false, false, false, "", "auto"}, - {"operator explicit 1p verbatim", "1p", false, false, false, "", "1p"}, - {"operator unset verbatim", "", false, false, false, "", ""}, + {"dwell verdict when surplus auto", "auto", false, false, false, true, "3p", "3p"}, + {"auto default when no dwell", "auto", false, false, false, true, "", "auto"}, + {"unset → auto under surplus", "", false, false, false, true, "", "auto"}, + // No schedule, no surplus: auto and unset are three phases, a pin is + // verbatim. + {"auto is 3p without surplus", "auto", false, false, false, false, "", "3p"}, + {"unset is 3p without surplus", "", false, false, false, false, "", "3p"}, + {"operator explicit 1p verbatim", "1p", false, false, false, false, "", "1p"}, + // The day lock and the dwell belong to surplus charging alone. + {"1p lock ignored without surplus", "auto", false, false, true, false, "1p", "3p"}, + // A one-phase-only charger runs on one phase, whatever drives it. + {"one-phase charger, schedule", "auto", true, true, false, false, "", "1p"}, + {"one-phase charger, unset", "", true, false, false, false, "", "1p"}, + {"one-phase charger pinned 3p", "3p", true, false, false, false, "", "3p"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := resolvePhaseMode(tc.operatorMode, tc.scheduleActive, tc.locked1P, tc.surplusOn, tc.dwell) + got := resolvePhaseMode(tc.operatorMode, !tc.oneOnly, tc.scheduleActive, tc.locked1P, tc.surplusOn, tc.dwell) if got != tc.want { - t.Errorf("resolvePhaseMode(%q, sched=%v, locked1P=%v, surplus=%v, dwell=%q) = %q, want %q", - tc.operatorMode, tc.scheduleActive, tc.locked1P, tc.surplusOn, tc.dwell, got, tc.want) + t.Errorf("resolvePhaseMode(%q, one-phase=%v, sched=%v, locked1P=%v, surplus=%v, dwell=%q) = %q, want %q", + tc.operatorMode, tc.oneOnly, tc.scheduleActive, tc.locked1P, tc.surplusOn, tc.dwell, got, tc.want) } }) } diff --git a/go/internal/loadpoint/deadline_need.go b/go/internal/loadpoint/deadline_need.go index 73f88e0e..03428709 100644 --- a/go/internal/loadpoint/deadline_need.go +++ b/go/internal/loadpoint/deadline_need.go @@ -43,22 +43,30 @@ func DeadlineNeed(st State) (energyWh, powerW float64, ok bool) { // lower of the charger's and the car's maximum, and the charger's steps up to // it. When the charger's lowest step is above the car's maximum it keeps that // one step: the car takes what it can, and a plan with no step would never -// charge. +// charge. The minimum is never below the lowest step: PlanningSteps leaves +// out levels the charger's phases cannot run. func LimitPower(st State, steps []float64) (minW, maxW float64, out []float64) { minW, maxW = st.MinChargeW, st.MaxChargeW + lowest := 0.0 + for _, w := range steps { + if w > 0 && (lowest == 0 || w < lowest) { + lowest = w + } + } + minW = math.Max(minW, lowest) if st.CarMaxW <= 0 || (maxW > 0 && st.CarMaxW >= maxW) { + if maxW > 0 && minW > maxW { + minW = maxW + } return minW, maxW, steps } maxW = st.CarMaxW - lowest, kept := 0.0, false + kept := false for _, w := range steps { if w <= maxW { out = append(out, w) kept = kept || w > 0 } - if w > 0 && (lowest == 0 || w < lowest) { - lowest = w - } } if !kept && lowest > 0 { out = append(out, lowest) diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 765197a6..0c84bb12 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -57,10 +57,10 @@ type Config struct { // PhaseMode selects how the controller picks between 1Φ and 3Φ // delivery each tick. "3p" (default) and "1p" lock the install 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 - // backward compatibility with pre-switching configs. + // one mode and filter AllowedStepsW accordingly. "auto" charges on + // three phases; only surplus charging below the 3Φ minimum switches + // to one, with a hysteresis timer (MinPhaseHoldS) preventing flap. + // Empty == "3p" for backward compatibility with pre-switching configs. PhaseMode string `yaml:"phase_mode,omitempty" json:"phase_mode,omitempty"` // PhaseSplitW is the wantW threshold below which "auto" picks 1Φ. diff --git a/go/internal/loadpoint/planned_three_phase_test.go b/go/internal/loadpoint/planned_three_phase_test.go new file mode 100644 index 00000000..69bbc9cb --- /dev/null +++ b/go/internal/loadpoint/planned_three_phase_test.go @@ -0,0 +1,164 @@ +package loadpoint + +import ( + "context" + "slices" + "testing" + "time" +) + +// The Easee step table from the 22 Oct 2026 field report: one-phase levels +// up to 3,800 W, three-phase levels from 4,800 W. +var easeeFieldSteps = []float64{0, 1600, 1900, 2100, 2400, 2600, 2800, 3100, 3300, 3500, 3800, + 4800, 5500, 6200, 6900, 7600, 8300, 9000, 9700, 10400, 11100} + +// The planner spread that night at 3.1-3.5 kW, which only one phase can +// deliver. It now gets three-phase levels only, unless the loadpoint is +// pinned to one phase or its charger cannot reach 6 A on three. +func TestPlanningStepsKeepThreePhaseLevels(t *testing.T) { + site := SiteFuse{MaxAmps: 20, Voltage: 230, PhaseCnt: 3} + for _, mode := range []string{"auto", "", "3p"} { + got := PlanningSteps(Config{MinChargeW: 1600, MaxChargeW: 11100, PhaseMode: mode, AllowedStepsW: easeeFieldSteps}, site) + if !slices.Equal(got, []float64{0, 4800, 5500, 6200, 6900, 7600, 8300, 9000, 9700, 10400, 11100}) { + t.Fatalf("%q: planner steps %v", mode, got) + } + _, maxW, _ := LimitPower(State{MinChargeW: 1600, MaxChargeW: 11100}, got) + if minW, _, _ := LimitPower(State{MinChargeW: 1600, MaxChargeW: 11100}, got); minW != 4800 || maxW != 11100 { + t.Fatalf("%q: planner minimum %.0f W, want the lowest step", mode, minW) + } + } + if got := PlanningSteps(Config{MinChargeW: 1600, MaxChargeW: 11100, PhaseMode: "1p", AllowedStepsW: easeeFieldSteps}, site); !slices.Equal(got, easeeFieldSteps) { + t.Fatalf("a 1p pin lost its one-phase steps: %v", got) + } + if got := PlanningSteps(Config{MinChargeW: 1380, MaxChargeW: 3680, AllowedStepsW: []float64{0, 1380, 3680}}, site); !slices.Equal(got, []float64{0, 1380, 3680}) { + t.Fatalf("a charger that cannot reach 6 A on three phases lost its steps: %v", got) + } + // A table with nothing at 6 A on three phases is a one-phase charger. + oneOnly := []float64{0, 1380, 2300, 3680} + if got := PlanningSteps(Config{MinChargeW: 1380, MaxChargeW: 5520, PhaseMode: "auto", AllowedStepsW: oneOnly}, site); !slices.Equal(got, oneOnly) { + t.Fatalf("one-phase-only table: %v, want it kept", got) + } + // So is a maximum below 6 A on three phases, with no table. + if got := PlanningSteps(Config{MinChargeW: 1380, MaxChargeW: 3680}, site); !slices.Equal(got, []float64{0, 1380, 1610, 1840, 2070, 2300, 2530, 2760, 2990, 3220, 3450, 3680}) { + t.Fatalf("16 A one-phase charger without a table: %v, want its one-phase ladder", got) + } +} + +// What goes out for a plan slot, by the charger's table and phase mode. +// A one-phase-only table runs on one phase (master sent auto and charged); +// unset is auto, raised to the three-phase minimum on a charger that can. +func TestPlannedOrderFollowsTheChargersPhases(t *testing.T) { + site := SiteFuse{MaxAmps: 20, Voltage: 230, PhaseCnt: 3} + for _, c := range []struct { + name, mode string + steps []float64 + maxW, wh float64 + wantW float64 + wantMode string + }{ + {"one-phase-only table, auto", "auto", []float64{0, 1380, 2300, 3680}, 5520, 4140, 3680, "1p"}, + {"one-phase-only table, unset", "", []float64{0, 1380, 2300, 3680}, 5520, 2300, 2300, "1p"}, + {"unset, low slot", "", []float64{0, 1380, 2300, 3680, 4140, 6900, 11000}, 11000, 2300, 4140, "3p"}, + {"auto, low slot", "auto", []float64{0, 1380, 2300, 3680, 4140, 6900, 11000}, 11000, 2300, 4140, "3p"}, + } { + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: c.maxW, PhaseMode: c.mode, AllowedStepsW: c.steps} + cmd := runPhaseTick(t, cfg, site, c.wh) + if cmd.power != c.wantW || cmd.phaseMode != c.wantMode { + t.Errorf("%s: plan %.0f Wh/h sent %.0f W %q, want %.0f W %q", c.name, c.wh, cmd.power, cmd.phaseMode, c.wantW, c.wantMode) + } + } +} + +// "Only spare solar" with a goal stays a solar charge: 2.5 kW of surplus on +// a low-sun day goes out on one phase, not as a three-phase order an Easee +// turns into 3.3 A and stops. +func TestSolarOnlyWithGoalKeepsOnePhaseSurplus(t *testing.T) { + base := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC) + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 11000, PhaseMode: "auto", SurplusOnly: true, + AllowedStepsW: []float64{0, 1380, 1840, 2300, 2760, 3220, 3680, 4140, 6900, 11000}} + sender := &fakeSender{} + dir := &Directive{SlotStart: base.Add(-time.Second), SlotEnd: base.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{cfg.ID: 0}} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + c := newTestController(t, []Config{cfg}, dir, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 20, Voltage: 230, PhaseCnt: 3}) + c.manager.SetSchedule(cfg.ID, Schedule{SoC: 0.8, TimeOfDayMinUTC: 7 * 60, Recurring: true}) + c.SetSiteSurplusForEV(func() (float64, bool) { return 2500, true }) + c.SetPeakRemainingSurplusW(func() (float64, bool) { return 3000, true }) + for i := range 3 { + c.Tick(context.Background(), base.Add(time.Duration(i)*5*time.Second)) + } + cmd, _ := lastSetCurrent(sender.calls) + if cmd.power <= 0 || cmd.power > 2500 || (cmd.phaseMode != "1p" && cmd.phaseMode != "auto") { + t.Fatalf("2.5 kW of surplus sent %.0f W %q, want a one-phase order", cmd.power, cmd.phaseMode) + } +} + +func TestChargeNowRunsOnThreePhases(t *testing.T) { + site := SiteFuse{MaxAmps: 20, Voltage: 230, PhaseCnt: 3} + for _, c := range []struct { + name, cfgMode, holdMode string + powerW, maxW float64 + want string + }{ + {"auto, full power", "auto", "", 11100, 11100, "3p"}, + {"auto, below the 3p minimum", "auto", "", 1380, 11100, "auto"}, + {"unset, low hold", "", "", 1380, 11100, "auto"}, + {"unset, full power", "", "", 11100, 11100, "3p"}, + {"hold pinned to 1p", "auto", "1p", 3680, 11100, "1p"}, + {"loadpoint pinned to 1p", "1p", "", 3680, 11100, "1p"}, + {"hold auto, full power", "", "auto", 11100, 11100, "3p"}, + {"one-phase charger, unset", "", "", 3680, 3680, "1p"}, + } { + got := holdPhaseMode(ManualHold{PowerW: c.powerW, PhaseMode: c.holdMode}, Config{MinChargeW: 1380, MaxChargeW: c.maxW, PhaseMode: c.cfgMode}, site) + if got != c.want { + t.Errorf("%s: phase_mode %q, want %q", c.name, got, c.want) + } + } +} + +// Field report 22 Oct 2026: a one-phase order of 3,500 W under auto, with +// 2.4 A of room left on the car's own phase. The site cap rose one step, +// past the 3,680 W split, so the split read the order as three-phase, +// credited it nothing, and cut it to the room alone: 1,600 W, then less, +// then 0, with every phase under its limit. Keeping the current order is +// never a cut; only a measured overage cuts. +func TestSplitKeepsTheCurrentOrder(t *testing.T) { + now := time.Date(2026, 10, 22, 3, 47, 45, 0, time.UTC) + cfg := Config{ID: "garage", DriverName: "charger", MinChargeW: 1600, MaxChargeW: 11100, + AllowedStepsW: easeeFieldSteps, PhaseMode: "auto", SurplusOnly: true} + for _, c := range []struct { + name string + mode string + orderW float64 + orderPhases int + siteCapW float64 + roomA float64 + wantMin, wantMax float64 + }{ + {"one-phase order, cap past the split", "auto", 3500, 1, 3730, 2.4, 3500, 3500}, + {"one-phase order, no room", "auto", 3500, 1, 3730, 0, 3500, 3500}, + // The site cap is the measured limit; keeping never beats it. + {"one-phase order after a measured cut", "auto", 3500, 1, 2000, -3, 0, 2000}, + {"three-phase order after a measured cut", "3p", 9700, 3, 2000, -3, 0, 2000}, + // The same watts on other phases are a rise on some of them: a + // three-phase order that now resolves to one phase gets the room + // alone, and 5,000 W moved from one phase to three adds 7.2 A to + // two phases that carried none of it. + {"three-phase order moving to one phase", "auto", 4140, 3, 3000, 2.4, 0, (6 + 2.4) * 230}, + {"one-phase order moving to three phases", "3p", 5000, 1, 20000, 2, 0, 2 * 690}, + } { + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + ctrl := newTestController(t, []Config{cfg}, nil, samples, &fakeSender{}) + ctrl.SetSiteFuse(SiteFuse{MaxAmps: 20, Voltage: 230, PhaseCnt: 3}) + ctrl.SetFuseEVMax(func() (float64, bool) { return c.siteCapW, true }) + ctrl.SetFusePhaseRoomA(func() (float64, bool) { return c.roomA, true }) + ctrl.manager.Observe(cfg.ID, true, c.orderW, 0, true) + ctrl.manager.SetCommanded(cfg.ID, c.orderW, "fuse_limit") + ctrl.manager.setCommandedPhases(cfg.ID, c.orderPhases) + ctrl.tickChoices = map[string]tickChoice{cfg.ID: {surplusOn: true, phaseMode: c.mode}} + ctrl.freezeFuseBudget(now, samples) + if got := ctrl.tickFuseCaps[cfg.ID]; got < c.wantMin || got > c.wantMax { + t.Errorf("%s: cap %.0f W, want %.0f-%.0f W", c.name, got, c.wantMin, c.wantMax) + } + } +} diff --git a/go/internal/loadpoint/planning_steps.go b/go/internal/loadpoint/planning_steps.go index b6271b18..2250766f 100644 --- a/go/internal/loadpoint/planning_steps.go +++ b/go/internal/loadpoint/planning_steps.go @@ -2,15 +2,15 @@ package loadpoint import "math" -// PlanningSteps exposes legal whole-amp offers for a scheduled charge when -// no explicit device steps were configured. Scheduled auto mode uses three -// phases, as resolvePhaseMode does. Explicit device steps keep their meaning. +// PlanningSteps exposes legal whole-amp offers for a scheduled charge. +// Scheduled charging runs on the car's phases, as resolvePhaseMode sends it: +// three on a charger that can reach 6 A on each, else one. A three-phase +// charger's explicit steps keep those at or above 6 A on its phases; one- +// phase levels in a shared table would let the plan spread a night at 3 kW +// that only one phase can deliver. Other explicit steps keep their meaning. func PlanningSteps(cfg Config, site SiteFuse) []float64 { - if len(cfg.AllowedStepsW) > 0 { - return append([]float64(nil), cfg.AllowedStepsW...) - } phases := site.Phases() - if cfg.PhaseMode == "1p" { + if !threePhaseCapable(cfg, site) { phases = 1 } voltage := site.Voltage @@ -18,6 +18,18 @@ func PlanningSteps(cfg Config, site SiteFuse) []float64 { voltage = 230 } step := voltage * float64(phases) + if len(cfg.AllowedStepsW) > 0 { + if phases == 1 { + return append([]float64(nil), cfg.AllowedStepsW...) + } + steps := []float64{0} + for _, w := range cfg.AllowedStepsW { + if w >= 6*step && (cfg.MaxChargeW <= 0 || w <= cfg.MaxChargeW) { + steps = append(steps, w) + } + } + return steps + } minimum := math.Max(6, math.Ceil(cfg.MinChargeW/step)) maximum := math.Floor(cfg.MaxChargeW / step) if site.MaxAmps > 0 { @@ -33,3 +45,31 @@ func PlanningSteps(cfg Config, site SiteFuse) []float64 { } return steps } + +// 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 +// one-phase charger whatever its mode says. A one-phase charger above 16 A +// with such a table needs phase_mode 1p. +func threePhaseCapable(cfg Config, site SiteFuse) bool { + if site.Phases() != 3 || cfg.PhaseMode == "1p" { + return false + } + voltage := site.Voltage + if !finite(voltage) || voltage <= 0 { + voltage = 230 + } + minimum := 18 * voltage + if finite(cfg.MaxChargeW) && cfg.MaxChargeW > 0 && cfg.MaxChargeW < minimum { + return false + } + if len(cfg.AllowedStepsW) == 0 { + return true + } + for _, w := range cfg.AllowedStepsW { + if w >= minimum { + return true + } + } + return false +}