Repository navigation
fix(control): count EV raises per phase until the charger can apply them - #1776
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 602c5a85bf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if mode := phaseModes[point.ID]; mode == "" || mode == "3p" { | ||
| c.phases = max(1, min(3, s.SiteFusePhases)) |
There was a problem hiding this comment.
Use the commanded phase mode for pending rises
When a manual hold selects 1p over a loadpoint configured as 3p—an override explicitly supported by phaseModeForFuse—this branch still sets c.phases to three. pendingLoad consequently divides each unapplied rise by three, so with a slow charger subsequent meter ticks can grant more headroom before the earlier one-phase current arrives, stacking rises on one phase and potentially exceeding its fuse limit. Preserve the resolved phase mode that was actually sent with each offer rather than reconstructing it from configuration.
AGENTS.md reference: AGENTS.md:L68-L68
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Resolved in 599511e. Each offer is now logged with the current per phase that its own command names: loadpoint.State.CommandedPhases, from the command's phase_mode and split. The configured mode and this tick's mode no longer count. A 1p command counts its whole current on every phase, since it may land on any of them. The old log is never reinterpreted when the phase changes: each entry keeps its own current. TestEVFuseOnePhaseOffersCountOnTheirPhase reads the budget with the configured 3p mode under a 1p hold while the L1 house load falls. It stays at 23 A with a 5 s and a 90 s charger; the previous commit reached 39 A.
602c5a8 to
599511e
Compare
64b3190 to
04d8027
Compare
599511e to
4f8314b
Compare
The EV budget counted watts and trusted the meter before a charger could have applied a command. A slow cloud charger stacked rises (25-28 A on a 25 A fuse at a 60-120 s delay), a 3p to 1p switch at the same watts put 36 A on one phase, and a raised car took a second car's share before its cut landed (27 A). Fuse protection now reads the measured current per phase plus our own commands that cannot have shown yet. Each loadpoint logs its offers with the current per phase its command names (State.CommandedPhases). Against a reading, a charger's pending current is the highest offer sent since its response time before the reading, less the lowest the reading may show: the longer of 20 s and its declared power_max_age_s, or 180 s when it declares none. A level set before that window is in the reading, so a cut needs no reservation. EVFusePhaseRoomA gives the split between cars the room left on the busiest phase; a car keeps its credited current and takes a share of that room in priority order. Cuts read the meter alone and stay immediate. The sitesim adapter wires the room as main.go does; slow cloud charger 60 s and 120 s and the battery floor case now pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4f8314b to
805f0ac
Compare
…it (#1793) #1776 counts an EV order as flowing until the charger can have applied it, using the highest order sent within the charger's response time. That included orders a later cut had already replaced. With Easee Cloud's declared 180 s, a charge at 10,350 W, a 25 s pause and a 4,140 W request kept 10,350 W reserved: the restart waited 2 min 47 s with fuse_limit while the phases read about 1.3 A. Reported on a real Easee/Pixii site by segran2 in #1763, reproduced in evMeterSite. The charger follows its latest order, and the meter shows its draw until a cut lands, so a cut ends the reservation of the higher orders before it. A run of rises still counts its highest, which is its latest, so stacked rises stay covered. The restart now comes after 12 s. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Easee Cloud applies a cut after 30-120 s, but the EV fuse cap took each cut from the highest offer of the last 20 s. Once that window passed, the meter still showed the old draw and Core cut again: in the field matrix 10,400 -> 6,900 W, then 0 W after a 6 A kettle step. The same window missed a raise still on its way, so a cut could leave the phase over the limit once the raise landed. The cut now counts our own orders that cannot have shown yet, both ways, from the per-charger, per-phase log of #1776/#1793. A cut on its way covers the overage it was sent for until its charger can have applied it (max(20 s, declared power_max_age_s), 180 s undeclared); Core cuts only what it does not cover. A raise on its way adds to the cut. A cut and every order after it count as not shown; of the orders before it, a raise counts from the lowest. An offer found in force at a restart, which Core did not send in this run, counts as shown. Once a phase's filtered current passes 1.25 x In, nothing on its way holds back a cut. Over the fuse the cap never rises above the latest offers. Rises, starts and the hour's import ceiling are unchanged. Field matrix: 93 of 99 netting rows pass. The other six are cut by the split, which floors 7,600 W to 6,900 W (7600/690*690 = 7599.999...); Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
) * fix(control): a cut on its way covers the overage it was sent for Easee Cloud applies a cut after 30-120 s, but the EV fuse cap took each cut from the highest offer of the last 20 s. Once that window passed, the meter still showed the old draw and Core cut again: in the field matrix 10,400 -> 6,900 W, then 0 W after a 6 A kettle step. The same window missed a raise still on its way, so a cut could leave the phase over the limit once the raise landed. The cut now counts our own orders that cannot have shown yet, both ways, from the per-charger, per-phase log of #1776/#1793. A cut on its way covers the overage it was sent for until its charger can have applied it (max(20 s, declared power_max_age_s), 180 s undeclared); Core cuts only what it does not cover. A raise on its way adds to the cut. A cut and every order after it count as not shown; of the orders before it, a raise counts from the lowest. An offer found in force at a restart, which Core did not send in this run, counts as shown. Once a phase's filtered current passes 1.25 x In, nothing on its way holds back a cut. Over the fuse the cap never rises above the latest offers. Rises, starts and the hour's import ceiling are unchanged. Field matrix: 93 of 99 netting rows pass. The other six are cut by the split, which floors 7,600 W to 6,900 W (7600/690*690 = 7599.999...); Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): a cut covers only its own run, on known phases Review of the first commit found three ways a cut on its way could cover more than it removes: - A cut that landed covered a later step on the same phase for the charger's whole response time: 180 s for a fast charger that declares nothing, such as OCPP. A cut now covers only within the run over the limit it was sent in. The run ends once every phase has read under the working limit for evSettle; a short dip, as battery relief gives, does not end it. Probe: 2 min 35 s over the limit becomes 22 s, as master. - A car that draws less than its order retried each cut after 180 s (10 min 36 s over the limit in the review's case). Once the run's first cut has outlasted its charger's response time, cuts cover for evSettle, as before: 5 min 16 s. - A change of phases read as a cut in amps per phase: 15 A on one phase to 6 A on three covered 9 A on every phase. Orders now carry their phase count; across a change the latest order counts in full as a raise. A single-phase cut covers no phase when other chargers share the pool. Without phase currents Core cannot see the hottest phase, so there a cut covers for evSettle, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(control): field rows after the cut netting on master 5ae590a FTW_FIELD_KNOWN=write removes 99 over-cut netting, 1 limit netting and 4 over-cut D rows and adds none. 71 of 338 cases still fail, all listed on master: energy 40 (39 D, 1 A), idle 25 (export, readback, hold3p), own-raise 6 (relief). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #1774 (
fix/loadpoint-one-phase-decisionat04d80273, which carries the #1775 site simulation). Merge #1774 first; GitHub then moves this PR tomaster.Problem
The EV budget counted watts, and it trusted the meter before a charger could have applied a command. Three ways this let a phase pass the fuse:
5e8e4171, 21 October 2026. A rise counted as seen after 20 s, but Easee Cloud takes one to three minutes. Rises stacked: beside 15 A of house load on a 25 A fuse, a 60 s delay reached 25.0 A and a 120 s delay 28.0 A.An earlier version of this PR also took the phase count from the configuration rather than from the command (Codex P1 below).
Rule
Fuse protection reads the measured site-meter current per phase plus our own commands that cannot have shown yet, and nothing else. When unsure, it assumes the worse case.
phase_modeand split, never the configured mode.loadpoint.State.CommandedPhasescarries it. A 1p command counts its whole current on every phase, since it may land on any of them.power_max_age_s, or after 180 s when it declares none.freezeFuseBudgetgoes through the cars in priority order. Each car may keep its last command's current and take a share of that room. Its own last current counts only when the new command uses the same phases, or when the last one used all three. A decrease frees nothing within the tick. The newcontrol.EVFusePhaseRoomA(wired withController.SetFusePhaseRoomA, read once per tick with the cap) gives this room to the split.TestFuseOverageGetsBatteryReliefUntilEVCutShowsfails with a 6,210 W offer while the battery still discharges 649 W.pendingLoadstays one per-phase input, so battery orders can join it later.loadpoint/manual_limits.goand the sign handling infuse_heat.goare untouched.Site simulation
SetFusePhaseRoomAasmain.godoes.a daypasses with the simulated charger declaring nothing.Before and after
Worst phase on the 25 A site (24 A working limit):
For Codex P1, the budget reads the configured 3p mode under a 1p hold while the L1 house load falls. An earlier commit reached 39 A; this PR stays at 23.0 A.
Times for a charger that applies a current in 5 s, from the reviewer's scenarios:
Cost
A charger whose command lands quickly is still counted for the rest of its response time, once in the meter and once as pending. After a house spike, the fuse's heat model gives room back a step at a time. Each rise during that climb then holds its room until the response time has passed. A charger that declares nothing therefore climbs back in steps of 3 minutes. Declaring a response time removes most of this.
Probe: house at 10 A per phase with the car steady at 8,970 W, a 2-minute spike to 16 A, then 25 minutes in all.
The time above 24 A that remains is the spike itself: the cut goes out at once, and the charger needs its delay to apply it. A driver that declares a shorter time than its charger needs is not protected. A 60 s charger that declares 5 s still stacks to 30 A, so declarations must come from measurement.
Tests
New
ev_fuse_phase_room_test.go, wired asmain.gowires the controller:TestEVFusePhaseSwitchNeedsRoomOnThePhase: 3p→1p.TestEVFuseRaisedCarTakesOnlyFreeRoom: two cars.TestEVFuseOnePhaseOffersCountOnTheirPhase: Codex P1.TestEVFusePhaseRoomHoldsOnSilentMeter.Each of the first three asserts that no phase passes 24 A and that the car still charges. They are red before this change (36 A, 27 A, 39 A).
TestEVHardLimitSlowChargerRisesDoNotStack: 60 s and 120 s delay, charger declares 180 s or nothing.TestEVHardLimitUndeclaredFastChargerTakesRoomAtOnce: whole room in one move; pending never cuts.TestEVHardLimitRiseWaitsForMeterOrDwellis nowTestEVHardLimitRisesOnceCutApplied. The old test asserted a 20 s pace for a car that does not follow, which relied on asking the meter whether a rise landed. The new test asserts the 20 s wait after a cut, one step per reading, and a step of room left free.TestJoinedEVWaitsForMeasuredBatteryBackoffThenResumes: its charger applies a current at once and declares 5 s.The control test harnesses and the sitesim adapter wire
SetFusePhaseRoomA.go test ./internal/control/... ./internal/loadpoint/... ./internal/sitesim/... -p 2 -count=1andmake verifypass.Related pull requests
Follow-ups (not in this PR)
srcfl/device-drivers(ctek,ctek_hybrid,ctek_v2,tesla_wall_connector) should declare a measuredpower_max_age_sor response time, so they climb fast again.zaptec_clouddeclares nothing and needs a measured value.pendingBatteryCommandsWin watts.🤖 Generated with Claude Code