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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ev-planned-charging-three-phase.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/ev-split-keeps-order.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
63 changes: 62 additions & 1 deletion go/internal/control/sitesim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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},
}
}
Expand All @@ -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))
Expand Down
Loading
Loading