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

Each charging order now tells the charger's driver how much current per phase FTW allows that charger right now, in a new `cap_amps_per_phase` field. It is the charger's share of the room below the fuse and the import limit, after the split between chargers, within the order's `max_amps_per_phase` and the charger's maximum. Until now a driver saw only the fuse rating. The Easee Cloud driver needs this to start a 6 A charge at 7 A only when 7 A fits, so a car on a tight phase is not stopped and restarted. The field adds no new limit, and FTW sends the same orders as before.
13 changes: 12 additions & 1 deletion go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type Controller struct {

// tickFuseCaps belongs to the serial tick, after one shared budget read.
tickFuseCaps map[string]float64
tickFuseRooms map[string]fuseRoom // serial tick, beside tickFuseCaps
tickFuseReason string
tickChoices map[string]tickChoice // serial tick
tickDirectiveActive bool
Expand Down Expand Up @@ -1235,7 +1236,11 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat
if dispatchAllowed {
c.tickDirective, c.tickDirectiveOK = c.currentDirective(now)
c.tickDirectiveActive = true
defer func() { c.tickFuseCaps, c.tickFuseReason = nil, ""; c.tickChoices = nil; c.tickDirectiveActive = false }()
defer func() {
c.tickFuseCaps, c.tickFuseRooms, c.tickFuseReason = nil, nil, ""
c.tickChoices = nil
c.tickDirectiveActive = false
}()
}
choices := make(map[string]tickChoice, len(observations))
for _, observed := range observations {
Expand Down Expand Up @@ -1637,6 +1642,12 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s
if applyCurrentCeiling(cmd) {
cmdReason = "fuse_limit"
}
// cap_amps_per_phase is how far this order may rise on its phases. A
// driver that briefly offers more than the order, as easee_cloud does to
// start a car at 6 A, must stay within it. Absent: Core knows no cap.
if capA, known := c.capAmpsPerPhase(lpCfg, cmd); known {
cmd["cap_amps_per_phase"] = capA
}
// Tell the manager what was ordered, after every clamp has spoken.
// The interruption latch reads this to keep a pause the box chose —
// plan slot, Stop hold, surplus clamp — from ever reading as a
Expand Down
156 changes: 156 additions & 0 deletions go/internal/loadpoint/controller_cap_amps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package loadpoint

import (
"context"
"math"
"testing"
"time"
)

// cap_amps_per_phase tells a driver how far an order may rise on its own
// phases. easee_cloud starts a 6 A order at 7 A only when it says 7 A fits;
// max_amps_per_phase is the fuse rating and says nothing about the room.
func TestCommandCarriesCapAmpsPerPhase(t *testing.T) {
for _, tc := range []struct {
name string
mode string
planW float64
totalW float64 // control.EVFuseBudget
roomA float64 // control.EVFusePhaseRoomA; NaN: no phase currents
hold *ManualHold
lastW float64 // last tick's 3p order, which roomA already counts
wantW float64
wantMaxA float64
wantCapA float64
wantNoCap bool
}{
// The room on the busiest phase takes a 16 A plan to 6 A.
{name: "room limits the order to 6 A", mode: "3p", planW: 11040, totalW: 17250, roomA: 6.4,
wantW: 4140, wantMaxA: 25, wantCapA: 6.4},
{name: "room for exactly 7 A", mode: "3p", planW: 4140, totalW: 17250, roomA: 7,
wantW: 4140, wantMaxA: 25, wantCapA: 7},
// A 6 A order on its way is in the room already; it counts once.
{name: "last 6 A order not shown yet", mode: "3p", planW: 4140, totalW: 4416, roomA: .4, lastW: 4140,
wantW: 4140, wantMaxA: 25, wantCapA: 6.4},
// The plan wants 6 A; the cap is the room, not the plan.
{name: "room ample", mode: "3p", planW: 4140, totalW: 17250, roomA: 15,
wantW: 4140, wantMaxA: 25, wantCapA: 15},
{name: "room past the charger's maximum", mode: "3p", planW: 4140, totalW: 17250, roomA: 20,
wantW: 4140, wantMaxA: 25, wantCapA: 16},
// The hour's import limit or the fuse in total import, in watts.
{name: "total budget limits", mode: "3p", planW: 4140, totalW: 4500, roomA: 15,
wantW: 4140, wantMaxA: 25, wantCapA: 6.52},
{name: "no phase currents", mode: "3p", planW: 4140, totalW: 5175, roomA: math.NaN(),
wantW: 4140, wantMaxA: 25, wantCapA: 7.5},
// A hold's current limit is the order's max_amps_per_phase; its
// power is what the owner wants, as a plan's is.
{name: "manual hold with a current limit", mode: "3p", totalW: 17250, roomA: 15,
hold: &ManualHold{PowerW: 4140, PhaseMode: "3p", MaxAmpsPerPhase: 6.5, Persistent: true},
wantW: 4140, wantMaxA: 6.5, wantCapA: 6.5},
{name: "manual hold", mode: "3p", totalW: 17250, roomA: 15,
hold: &ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true},
wantW: 4140, wantMaxA: 25, wantCapA: 15},
// One phase: watts over one phase's voltage.
{name: "one phase, total budget", mode: "1p", planW: 3680, totalW: 1500, roomA: 20,
wantW: 1380, wantMaxA: 25, wantCapA: 6.52},
{name: "one phase, room", mode: "1p", planW: 1380, totalW: 17250, roomA: 7.5,
wantW: 1380, wantMaxA: 25, wantCapA: 7.5},
// A 0 W order names no current to raise.
{name: "no room", mode: "3p", planW: 4140, totalW: 17250, roomA: 5,
wantW: 0, wantMaxA: 25, wantNoCap: true},
} {
t.Run(tc.name, func(t *testing.T) {
now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 11040,
AllowedStepsW: threePhaseSteps, PhaseMode: tc.mode}
d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute),
LoadpointEnergyWh: map[string]float64{cfg.ID: tc.planW / 4}, LoadpointMaxPowerW: map[string]float64{cfg.ID: tc.planW}}
samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}}
sender := &fakeSender{}
c := newTestController(t, []Config{cfg}, d, samples, sender)
c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
c.SetFuseEVMax(func() (float64, bool) { return tc.totalW, true })
c.SetFusePhaseRoomA(func() (float64, bool) { return tc.roomA, !math.IsNaN(tc.roomA) })
if tc.hold != nil {
c.SetManualHold(cfg.ID, *tc.hold)
}
if tc.lastW > 0 {
c.manager.SetCommanded(cfg.ID, tc.lastW, "plan")
c.manager.setCommandedPhases(cfg.ID, 3)
}
c.Tick(context.Background(), now)
got, ok := lastSetCurrent(sender.calls)
if !ok || math.Abs(got.power-tc.wantW) > .01 || got.maxAmpsPerPhase != tc.wantMaxA {
t.Fatalf("order %+v, want %.0f W with max_amps_per_phase %.1f", got, tc.wantW, tc.wantMaxA)
}
if tc.wantNoCap {
if got.capAmpsPerPhase != nil {
t.Fatalf("cap_amps_per_phase = %.2f, want none", *got.capAmpsPerPhase)
}
return
}
if got.capAmpsPerPhase == nil {
t.Fatal("cap_amps_per_phase missing")
}
capA := *got.capAmpsPerPhase
if math.Abs(capA-tc.wantCapA) > 1e-9 {
t.Fatalf("cap_amps_per_phase = %v, want %v", capA, tc.wantCapA)
}
if capA > got.maxAmpsPerPhase {
t.Fatalf("cap_amps_per_phase %.2f above max_amps_per_phase %.2f", capA, got.maxAmpsPerPhase)
}
})
}
}

// Without a fuse budget Core knows no cap, and an older Core sends none:
// a driver must read a missing field as "no room above the order".
func TestCommandOmitsCapAmpsWithoutFuseBudget(t *testing.T) {
now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 11040,
AllowedStepsW: threePhaseSteps, PhaseMode: "3p"}
d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute),
LoadpointEnergyWh: map[string]float64{cfg.ID: 4140 / 4}, LoadpointMaxPowerW: map[string]float64{cfg.ID: 4140}}
sender := &fakeSender{}
c := newTestController(t, []Config{cfg}, d, map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}}, sender)
c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
c.Tick(context.Background(), now)
got, ok := lastSetCurrent(sender.calls)
if !ok || got.power != 4140 {
t.Fatalf("order %+v, want 4140 W", got)
}
if got.capAmpsPerPhase != nil {
t.Fatalf("cap_amps_per_phase = %.2f without a fuse budget", *got.capAmpsPerPhase)
}
}

// Two chargers never get the same room. Each keeps its own share, and the
// room neither took goes to the first in the split's order only.
func TestCapAmpsPerPhaseSplitsRoomBetweenChargers(t *testing.T) {
now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
a := Config{ID: "a", DriverName: "easee-a", MinChargeW: 1380, MaxChargeW: 11040,
AllowedStepsW: threePhaseSteps, PhaseMode: "3p"}
b := a
b.ID, b.DriverName = "b", "easee-b"
d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute),
LoadpointEnergyWh: map[string]float64{a.ID: 4140 / 4, b.ID: 4140 / 4},
LoadpointMaxPowerW: map[string]float64{a.ID: 4140, b.ID: 4140}}
samples := map[string]EVSample{a.DriverName: {Connected: true, RequestActive: true}, b.DriverName: {Connected: true, RequestActive: true}}
sender := &fakeSender{}
c := newTestController(t, []Config{a, b}, d, samples, sender)
c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
c.SetFuseEVMax(func() (float64, bool) { return 17250, true })
c.SetFusePhaseRoomA(func() (float64, bool) { return 13, true })
c.Tick(context.Background(), now)
caps := map[string]float64{}
for _, sent := range sender.calls {
if sent.action != "ev_set_current" || sent.power != 4140 || sent.capAmpsPerPhase == nil {
t.Fatalf("order %+v, want 4140 W with a cap", sent)
}
caps[sent.driver] = *sent.capAmpsPerPhase
}
// 13 A of room: 6 A each, and the 1 A left goes to a.
if caps[a.DriverName] != 7 || caps[b.DriverName] != 6 {
t.Fatalf("caps %v, want a 7 A and b 6 A", caps)
}
}
60 changes: 58 additions & 2 deletions go/internal/loadpoint/controller_fuse_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import (
"time"
)

// fuseRoom is what one charger may use this tick, whatever it wants. What no
// charger took goes to the first charger with a share, so two chargers are
// never given the same room. capAmpsPerPhase reports it to the driver.
type fuseRoom struct {
w float64 // share of the EV cap, in watts
phaseA float64 // room on the busiest phase above its last command; with phaseKnown
phaseKnown bool // the site meter reports phase currents
}

type fuseRequest struct {
cfg Config
deadline time.Time
Expand All @@ -19,7 +28,7 @@ type fuseRequest struct {
// limit here: control.EVFuseBudget reads the site meter for the phases that
// FuseConfigs names.
func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample) {
c.tickFuseCaps, c.tickFuseReason = nil, ""
c.tickFuseCaps, c.tickFuseRooms, c.tickFuseReason = nil, nil, ""
if c.fuseEVMax == nil {
return
}
Expand Down Expand Up @@ -77,6 +86,7 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample
perPhase = perPhase && finite(roomA)
}
c.tickFuseCaps = make(map[string]float64, len(requests))
c.tickFuseRooms = make(map[string]fuseRoom, len(requests))
remaining := total
for _, request := range requests {
want := math.Min(request.wantW, remaining)
Expand All @@ -88,11 +98,57 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample
cap := floorChargeW(want, request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW)
c.tickFuseCaps[request.cfg.ID] = cap
remaining -= cap
room := fuseRoom{w: cap, phaseKnown: perPhase}
if perPhase {
// A cut frees room only once the meter shows it.
roomA -= math.Max(0, c.phaseRiseA(request.cfg.ID, mode, cap, split))
room.phaseA = math.Max(0, c.phaseRiseA(request.cfg.ID, mode, cap, split))
roomA -= room.phaseA
}
c.tickFuseRooms[request.cfg.ID] = room
}
for _, request := range requests {
room := c.tickFuseRooms[request.cfg.ID]
if room.w <= 0 {
continue
}
room.w += math.Max(0, remaining)
room.phaseA += math.Max(0, roomA)
c.tickFuseRooms[request.cfg.ID] = room
break
}
}

// capAmpsPerPhase is the most current per phase Core allows this charger on
// the phases its positive order names: its fuse room after the split between
// chargers, the import limit, max_amps_per_phase and the charger's maximum.
// It is not limited by what the plan or a hold wants, so it may exceed the
// order. Unknown without a frozen budget or a voltage.
func (c *Controller) capAmpsPerPhase(cfg Config, cmd map[string]any) (float64, bool) {
room, ok := c.tickFuseRooms[cfg.ID]
w, _ := cmd["power_w"].(float64)
v, _ := cmd["voltage"].(float64)
if !ok || !finite(room.w) || !finite(w) || w <= 0 || !finite(v) || v <= 0 {
return 0, false
}
mode, _ := cmd["phase_mode"].(string)
split, _ := cmd["phase_split_w"].(float64)
phases := c.commandPhases(mode, w, split)
capA := room.w / (v * float64(phases))
if room.phaseKnown {
capA = math.Min(capA, c.creditA(cfg.ID, phases)+room.phaseA)
}
if cfg.MaxChargeW > 0 {
capA = math.Min(capA, cfg.MaxChargeW/(v*float64(phases)))
}
if !finite(capA) {
return 0, false
}
// Two decimals, rounded down; the nudge absorbs float error at a step.
capA = math.Floor(math.Max(0, capA)*100+1e-6) / 100
if limit, known := cmd["max_amps_per_phase"].(float64); known && finite(limit) && limit > 0 {
capA = math.Min(capA, limit)
}
return capA, true
}

// commandPhases is where a command of w lands: 1 or 3 phases, as the
Expand Down
20 changes: 11 additions & 9 deletions go/internal/loadpoint/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type sentCommand struct {
voltage float64
maxAmpsPerPhase float64
sitePhases int
capAmpsPerPhase *float64
}

func lastSetCurrent(calls []sentCommand) (sentCommand, bool) {
Expand All @@ -48,14 +49,15 @@ func countAction(calls []sentCommand, action string) int {

func (f *fakeSender) Send(ctx context.Context, driver string, payload []byte) error {
var d struct {
Action string `json:"action"`
PowerW float64 `json:"power_w"`
PhaseMode string `json:"phase_mode"`
PhaseSplitW float64 `json:"phase_split_w"`
MinPhaseHoldS int `json:"min_phase_hold_s"`
Voltage float64 `json:"voltage"`
MaxAmpsPerPhase float64 `json:"max_amps_per_phase"`
SitePhases int `json:"site_phases"`
Action string `json:"action"`
PowerW float64 `json:"power_w"`
PhaseMode string `json:"phase_mode"`
PhaseSplitW float64 `json:"phase_split_w"`
MinPhaseHoldS int `json:"min_phase_hold_s"`
Voltage float64 `json:"voltage"`
MaxAmpsPerPhase float64 `json:"max_amps_per_phase"`
SitePhases int `json:"site_phases"`
CapAmpsPerPhase *float64 `json:"cap_amps_per_phase"`
}
if err := json.Unmarshal(payload, &d); err != nil {
return err
Expand All @@ -65,7 +67,7 @@ func (f *fakeSender) Send(ctx context.Context, driver string, payload []byte) er
phaseMode: d.PhaseMode, phaseSplitW: d.PhaseSplitW,
minPhaseHoldS: d.MinPhaseHoldS,
voltage: d.Voltage, maxAmpsPerPhase: d.MaxAmpsPerPhase,
sitePhases: d.SitePhases,
sitePhases: d.SitePhases, capAmpsPerPhase: d.CapAmpsPerPhase,
})
return f.err
}
Expand Down
Loading