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-one-phase-choice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Charging no longer stalls or switches off when the phase mode is empty or auto, when a battery-level PV unlock is saved but not active, or on a one-phase site. The site-meter limit is now the only fuse guard for chargers: it measures room on the phases each charger is told to use, and a three-phase offer is never below 6 A per phase.
28 changes: 2 additions & 26 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2002,7 +2002,8 @@ func main() {
lpController.SetFuseEVMax(func() (float64, bool) {
ctrlMu.Lock()
defer ctrlMu.Unlock()
return control.EVFuseBudget(tel, ctrl, lpMgr.States(), lpMgr.Configs(), ctrl.SiteFuseAmps*ctrl.SiteFuseVoltage*float64(ctrl.SiteFusePhases))
// The cap is measured for the phases this tick's commands name.
return control.EVFuseBudget(tel, ctrl, lpMgr.States(), lpController.FuseConfigs(), ctrl.SiteFuseAmps*ctrl.SiteFuseVoltage*float64(ctrl.SiteFusePhases))
})
// Every lease is preflighted and re-evaluated on each loadpoint tick.
// This closure owns site/battery health because those concepts belong
Expand Down Expand Up @@ -2130,31 +2131,6 @@ func main() {
go mpcSvc.ReplanWithReason(context.Background(), "loadpoint_battery_boost_stopped")
slog.Info("battery boost stopped — replan requested", "lp", id, "reason", reason)
})
// Wire the live per-phase site-meter current reader. The EV hard
// limit (control.EVFuseBudget) cuts a worst-phase overage; this
// reader lets the loadpoint gate an increase on the phase a
// runtime 1p or auto charger may land on. Reads the same
// meter_lN_a metrics the fuse_over_limit notifier uses.
lpController.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) {
cfgMu.RLock()
siteMeter := cfg.SiteMeterDriver()
phaseCount := cfg.Fuse.Phases
watchdogTimeout := time.Duration(cfg.Site.WatchdogTimeoutS) * time.Second
cfgMu.RUnlock()
if siteMeter == "" {
return 0, 0, 0, false
}
if watchdogTimeout <= 0 {
watchdogTimeout = 60 * time.Second
}
amps, available, fresh := sitePhaseCurrentsAt(
tel, siteMeter, phaseCount, watchdogTimeout, time.Now(),
)
if !available || !fresh {
return 0, 0, 0, false
}
return amps[0], amps[1], amps[2], true
})
// Wire the matched-vehicle reader for auto-wake. When the
// loadpoint is commanding power but the matched Tesla
// reports `Stopped` / `Disconnected` / `Complete`, the
Expand Down
9 changes: 3 additions & 6 deletions go/internal/control/ev_battery_sequence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ func TestJoinedEVWaitsForMeasuredBatteryBackoffThenResumes(t *testing.T) {
})
controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
controller.SetManualHold(config.ID, loadpoint.ManualHold{PowerW: desiredEVW, PhaseMode: "3p", Persistent: true})
controller.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return sampledPhaseA, sampledPhaseA, sampledPhaseA, true })
controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(store, st, mgr.States(), mgr.Configs(), 17250) })
controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(store, st, mgr.States(), controller.FuseConfigs(), 17250) })
emit := func(driver string, kind telemetry.DerType, watts float64) {
d := map[string]any{"control_power_available": true, "control_power_observed_at": now.Format(time.RFC3339Nano)}
if kind == telemetry.DerMeter {
Expand Down Expand Up @@ -216,11 +215,9 @@ func newEVBatterySiteAt(t *testing.T, houseW float64, start time.Time) *evBatter
})
s.ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
s.ctrl.SetManualHold(config.ID, loadpoint.ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true})
s.ctrl.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) {
a := s.gridW() / 690
return a, a, a, true
s.ctrl.SetFuseEVMax(func() (float64, bool) {
return EVFuseBudget(s.store, s.state, s.mgr.States(), s.ctrl.FuseConfigs(), 17250)
})
s.ctrl.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), 17250) })
return s
}

Expand Down
8 changes: 2 additions & 6 deletions go/internal/control/ev_deadline_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ func TestJoinedNoBatteryNoPlanGoalContinuity(t *testing.T) {
return nil
})
controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
controller.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return phaseA, phaseA, phaseA, true })
controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), mgr.Configs(), 17250) })
controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), controller.FuseConfigs(), 17250) })
controller.TickWithDispatch(ctx, now, true)
if len(commands) == 0 || commands[len(commands)-1] <= 0 {
t.Fatalf("goal stopped at integration boundary: %v", commands)
Expand Down Expand Up @@ -172,9 +171,7 @@ func TestJoinedMixedPhaseNoCreditBeforeMeasuredReduction(t *testing.T) {
return nil
})
controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
var sampledWorst float64
controller.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return sampledWorst, actualB / 690, actualB / 690, true })
controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), mgr.Configs(), 17250) })
controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), controller.FuseConfigs(), 17250) })
emit := func(driver string, kind telemetry.DerType, at time.Time, w float64, amps bool) {
d := map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano), "control_power_available": true}
if amps {
Expand All @@ -192,7 +189,6 @@ func TestJoinedMixedPhaseNoCreditBeforeMeasuredReduction(t *testing.T) {
at := wall.Add(-9*time.Second + time.Duration(step)*1500*time.Millisecond)
emit(cfgA.DriverName, telemetry.DerEV, at, actualA, false)
emit(cfgB.DriverName, telemetry.DerEV, at, actualB, false)
sampledWorst = actualA/230 + actualB/690
emit("meter", telemetry.DerMeter, at.Add(500*time.Millisecond), actualA+actualB, true)
emit(cfgA.DriverName, telemetry.DerEV, at.Add(time.Second), actualA, false)
emit(cfgB.DriverName, telemetry.DerEV, at.Add(time.Second), actualB, false)
Expand Down
115 changes: 115 additions & 0 deletions go/internal/control/ev_fuse_command_phase_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package control

import (
"context"
"encoding/json"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/telemetry"
)

// phaseSite runs one loadpoint against EVFuseBudget on a 25 A, 3 × 230 V
// site, wired as main.go wires it. Each tick the meter shows the house plus
// the last offer, spread over the phases the offer used.
type phaseSite struct {
now time.Time
store *telemetry.Store
state *State
mgr *loadpoint.Manager
ctrl *loadpoint.Controller
houseA float64
offerW float64
offer1p bool
}

func newPhaseSite(t *testing.T, cfg loadpoint.Config, houseA float64) *phaseSite {
t.Helper()
s := &phaseSite{now: time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC), store: telemetry.NewStore(), houseA: houseA}
s.state = NewState(0, 0, "meter")
s.state.SiteFuseAmps, s.state.SiteFuseVoltage, s.state.SiteFusePhases = 25, 230, 3
s.state.clock = func() time.Time { return s.now }
s.mgr = loadpoint.NewManager()
s.mgr.Load([]loadpoint.Config{cfg})
s.ctrl = loadpoint.NewController(s.mgr,
func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false },
func(string) (loadpoint.EVSample, bool) {
return loadpoint.EVSample{Connected: true, RequestActive: true, PowerW: s.offerW, PowerAt: s.now}, true
},
func(_ context.Context, _ string, payload []byte) error {
var cmd struct {
Action string `json:"action"`
PowerW float64 `json:"power_w"`
PhaseMode string `json:"phase_mode"`
}
if err := json.Unmarshal(payload, &cmd); err != nil {
return err
}
if cmd.Action == "ev_set_current" {
s.offerW = cmd.PowerW
s.offer1p = loadpoint.PhaseFor(cmd.PhaseMode, cmd.PowerW, 3680) == 1
}
return nil
})
s.ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
s.ctrl.SetFuseEVMax(func() (float64, bool) {
return EVFuseBudget(s.store, s.state, s.mgr.States(), s.ctrl.FuseConfigs(), 25*230*3)
})
return s
}

// worstA is the busiest phase: a one-phase offer may land on any of them.
func (s *phaseSite) worstA() float64 {
if s.offer1p {
return s.houseA + s.offerW/230
}
return s.houseA + s.offerW/690
}

func (s *phaseSite) tick() {
s.now = s.now.Add(5 * time.Second)
a := s.worstA()
data, _ := json.Marshal(map[string]any{"control_power_available": true,
"control_power_observed_at": s.now.Format(time.RFC3339Nano), "l1_a": a, "l2_a": a, "l3_a": a})
s.store.Update("meter", telemetry.DerMeter, a*690, nil, data)
s.store.Get("meter", telemetry.DerMeter).UpdatedAt = s.now
s.store.RecordDriverSuccess("meter")
s.ctrl.Tick(context.Background(), s.now)
}

// R2 against the real cap: a saved but unarmed battery-SoC unlock must not
// change the phases the cap is measured on.
func TestChargeNowWithUnarmedUnlockHoldsUnderEVFuseBudget(t *testing.T) {
cfg := loadpoint.Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11040,
AllowedStepsW: []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350, 11040}}
s := newPhaseSite(t, cfg, 3)
s.mgr.SetSchedule(cfg.ID, loadpoint.Schedule{SurplusUnlockBatSoC: 0.9})
s.ctrl.SetBatSoCProvider(func() (float64, bool) { return 0.5, true })
s.ctrl.SetSiteSurplusForEV(func() (float64, bool) { return -2000, true })
s.ctrl.SetManualHold(cfg.ID, loadpoint.ManualHold{PowerW: 11040, Persistent: true})
for tick := 0; tick < 20; tick++ {
s.tick()
st, _ := s.mgr.State(cfg.ID)
if s.offerW != 11040 || st.CommandedReason == "fuse_limit" {
t.Fatalf("tick %d: offer %.0f W, reason %q; want 11040 W", tick, s.offerW, st.CommandedReason)
}
}
}

// An auto offer below the phase split lands on one phase. At 20 A of house
// load the cap must be measured on that phase, not on the three the config
// leaves unnamed: 2,070 W on one phase would put 29 A on a 25 A fuse.
func TestAutoOfferGetsOnePhaseOfRoomFromEVFuseBudget(t *testing.T) {
cfg := loadpoint.Config{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 11040,
AllowedStepsW: []float64{0, 1380, 1610, 1840, 2070, 2300, 2530, 2760, 2990, 3220, 3450, 3680,
4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350, 11040}}
s := newPhaseSite(t, cfg, 20)
s.ctrl.SetManualHold(cfg.ID, loadpoint.ManualHold{PowerW: 2760, PhaseMode: "auto", Persistent: true})
for tick := 0; tick < 12; tick++ {
s.tick()
if s.worstA() > 24 {
t.Fatalf("tick %d: offer %.0f W puts %.1f A on one phase", tick, s.offerW, s.worstA())
}
}
}
18 changes: 6 additions & 12 deletions go/internal/control/sitesim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,7 @@ func (s *simSite) boot() {
s.ctrl.SetDriverOnline(func(d string) bool { h := s.store.DriverHealth(d); return h != nil && h.IsOnline() })
s.ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: s.p.Fuse.RatingA, Voltage: s.p.Voltage, PhaseCnt: 3})
s.ctrl.SetFuseEVMax(func() (float64, bool) {
return EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), s.fuseMaxW())
})
s.ctrl.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) {
if !s.meterFresh() {
return 0, 0, 0, false
}
return s.meter.PhaseA[0], s.meter.PhaseA[1], s.meter.PhaseA[2], true
return EVFuseBudget(s.store, s.state, s.mgr.States(), s.ctrl.FuseConfigs(), s.fuseMaxW())
})
s.ctrl.SetSiteSurplusForEV(func() (float64, bool) {
if !s.meterFresh() {
Expand Down Expand Up @@ -488,9 +482,9 @@ func simScenarios() []simScenario {
}
}
return []simScenario{
{name: "export start", known: "export counted as import, fuse_heat.go:120 and controller_fuse_budget.go:174", run: 5 * time.Minute,
{name: "export start", known: "export counted as import, fuse_heat.go:120", run: 5 * time.Minute,
plant: func(p *sitesim.Plant) { p.HouseW, p.PVW = houseA(2, 2, 2), -22*690 }},
{name: "export start, unsigned phase currents", known: "export counted as import, fuse_heat.go:120 and controller_fuse_budget.go:174", run: 5 * time.Minute,
{name: "export start, unsigned phase currents", known: "export counted as import, fuse_heat.go:120", run: 5 * time.Minute,
plant: func(p *sitesim.Plant) { p.HouseW, p.PVW, p.Meter.Unsigned = houseA(2, 2, 2), -22*690, true }},
{name: "charger's own load balancer", known: "a cut comes off an offer the car does not draw, ev_fuse_budget.go:172", run: 20 * time.Minute,
plant: func(p *sitesim.Plant) {
Expand Down Expand Up @@ -531,13 +525,13 @@ func simScenarios() []simScenario {
}
}},
{name: "phase mode 3p, 9 kW surplus", run: 5 * time.Minute, surplus: true, plant: surplus, lp: surplusPhase("3p")},
{name: "phase mode empty, 9 kW surplus", known: "empty phase mode gets one phase of room, controller_fuse_budget.go:181", run: 5 * time.Minute, surplus: true,
{name: "phase mode empty, 9 kW surplus", run: 5 * time.Minute, surplus: true,
plant: surplus, lp: surplusPhase("")},
{name: "phase mode auto, 9 kW surplus", known: "auto phase mode gets one phase of room, controller_fuse_budget.go:181", run: 5 * time.Minute, surplus: true,
{name: "phase mode auto, 9 kW surplus", run: 5 * time.Minute, surplus: true,
plant: surplus, lp: surplusPhase("auto")},
{name: "9 kW surplus, readback 10 s late", known: "surplus adds back the charger's late readback, surplus_reserve.go:128", run: 5 * time.Minute, surplus: true,
plant: func(p *sitesim.Plant) { surplus(p); p.EVSE.ReadbackLag = 10 * time.Second }, lp: surplusPhase("3p")},
{name: "surplus_unlock_bat_soc saved, Charge now", known: "a saved unlock that is not armed sets the fuse phase mode, controller_fuse_budget.go:184", run: 5 * time.Minute,
{name: "surplus_unlock_bat_soc saved, Charge now", run: 5 * time.Minute,
plant: func(p *sitesim.Plant) {
p.HouseW = houseA(3, 3, 3)
p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 10000, SoC: .5}
Expand Down
Loading
Loading