From 98e1068afbd344b6a3907bd269f571e6aa57963b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 19:06:17 +0200 Subject: [PATCH] feat(loadpoint): tell the driver Core's current cap per phase Each ev_set_current order now carries cap_amps_per_phase: the most current per phase Core allows this charger on the phases the order names. It is the charger's share of the EV fuse/import cap and of the busiest phase's room after the split between chargers, within the order's max_amps_per_phase and the charger's maximum, and not limited by what the plan or a hold wants. Room that no charger took goes to the first charger with a share, so two chargers are never told the same room. Omitted on a 0 W order, without a fuse budget, or without a voltage. max_amps_per_phase is the fuse rating. easee_cloud (srcfl/device-drivers#190) needs the room left to start a 6 A order at 7 A only when 7 A fits. No order changes. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-command-cap-amps.md | 5 + go/internal/loadpoint/controller.go | 13 +- .../loadpoint/controller_cap_amps_test.go | 156 ++++++++++++++++++ .../loadpoint/controller_fuse_budget.go | 60 ++++++- go/internal/loadpoint/controller_test.go | 20 ++- 5 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 .changeset/ev-command-cap-amps.md create mode 100644 go/internal/loadpoint/controller_cap_amps_test.go diff --git a/.changeset/ev-command-cap-amps.md b/.changeset/ev-command-cap-amps.md new file mode 100644 index 000000000..d0680b61d --- /dev/null +++ b/.changeset/ev-command-cap-amps.md @@ -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. diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 71e6fc882..b13b7e2d4 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -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 @@ -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 { @@ -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 diff --git a/go/internal/loadpoint/controller_cap_amps_test.go b/go/internal/loadpoint/controller_cap_amps_test.go new file mode 100644 index 000000000..de8d7baf9 --- /dev/null +++ b/go/internal/loadpoint/controller_cap_amps_test.go @@ -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) + } +} diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index 03e6c8af9..938db18e0 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -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 @@ -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 } @@ -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) @@ -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 diff --git a/go/internal/loadpoint/controller_test.go b/go/internal/loadpoint/controller_test.go index c1c74932d..574478f0e 100644 --- a/go/internal/loadpoint/controller_test.go +++ b/go/internal/loadpoint/controller_test.go @@ -25,6 +25,7 @@ type sentCommand struct { voltage float64 maxAmpsPerPhase float64 sitePhases int + capAmpsPerPhase *float64 } func lastSetCurrent(calls []sentCommand) (sentCommand, bool) { @@ -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 @@ -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 }