Skip to content

fix(control): count EV raises per phase until the charger can apply them - #1776

Merged
frahlg merged 1 commit into
masterfrom
fix/ev-fuse-rise-stacking
Oct 21, 2026
Merged

frahlg merged 1 commit into
masterfrom
fix/ev-fuse-rise-stacking

Conversation

@frahlg

@frahlg frahlg commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Stacked on #1774 (fix/loadpoint-one-phase-decision at 04d80273, which carries the #1775 site simulation). Merge #1774 first; GitHub then moves this PR to master.

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:

  1. Slow cloud chargers. These are from fuse review finding 3 on master 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.
  2. Phase switch at the same watts. A hold moved from 3p to 1p. The next tick offered nearly the same watts on one phase, and L1 read 36 A.
  3. Two cars. The first car was raised and took the second car's share in the same tick, while the second (a 90 s charger) still drew it: 27 A for 60 s.

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.

  • Per-command current. Each loadpoint logs its commands with the current per phase each command names. That is the watts divided by the phases from the command's own phase_mode and split, never the configured mode. loadpoint.State.CommandedPhases carries it. A 1p command counts its whole current on every phase, since it may land on any of them.
  • Response time. A charger can have applied a command after the longer of 20 s and its driver's declared power_max_age_s, or after 180 s when it declares none.
  • Pending. Against a reading taken at time t, a charger's pending current is the highest offer it was sent after t − its response time, less the lowest offer the reading may show.
    • An offer set before that window is already in the reading.
    • A cut needs no reservation: the reading shows the real draw until the cut lands, so no other car can take that room early.
    • Pending is measured against the reading, not the clock, so a silent meter never frees room.
  • Room. Room on a phase is the 24 A working limit, less the reading's current or heat, less every charger's pending current. It is converted to watts only for one charger, on the phases its command names.
  • Split between cars. fix(loadpoint): the site-meter limit is the only fuse guard for chargers #1774's freezeFuseBudget goes 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 new control.EVFusePhaseRoomA (wired with Controller.SetFusePhaseRoomA, read once per tick with the cap) gives this room to the split.
  • Cuts read the meter alone and stay immediate. Pending never cuts: a car always keeps the current it is credited with.
  • Wait after a cut. Rises still wait 20 s after a cut, as on master. Right after a cut, battery relief may hold import down. A rise in that window spends the relief: without the wait, TestFuseOverageGetsBatteryReliefUntilEVCutShows fails with a 6,210 W offer while the battery still discharges 649 W.

pendingLoad stays one per-phase input, so battery orders can join it later. loadpoint/manual_limits.go and the sign handling in fuse_heat.go are untouched.

Site simulation

  • The sitesim adapter wires SetFusePhaseRoomA as main.go does.
  • Three known-failure lines now pass and are deleted: slow cloud charger 60 s, slow cloud charger 120 s, and battery reaches its floor under Charge now.
  • a day passes with the simulated charger declaring nothing.

Before and after

Worst phase on the 25 A site (24 A working limit):

Scenario #1774 this PR
Easee-like charger, house 15 A per phase, 60 s delay, declared 180 s or nothing 25.0 A, 156 s above 24 A 23.0 A
the same, 120 s delay 28.0 A, 180 s above 24 A 23.0 A
Hold 8,280 W 3p → 7,360 W 1p, L1 at 12 A, 5 s or 90 s charger 36 A for 50 s or 90 s 24.0 A
Two cars at 8 A, the first raised; second charger 90 s, or both slow 27 A for 60 s 24.0 A

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:

Case master 599511e this PR
Restart after a 30 s user stop, nothing declared 25 s 3 min 20 s 20 s
Back to full after a 2-minute house spike, nothing declared 55 s 4 min 25 s 3 min 35 s
the same, declares 5 s 55 s 1 min 5 s

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.

Charger delay, declared energy, #1774 energy, PR time above 24 A, #1774 time above 24 A, PR
5 s, none (counts as 180 s) 3.59 kWh 3.45 kWh 15 s 15 s
5 s, declares 5 s (counts as 20 s) 3.59 kWh 3.58 kWh 15 s 15 s
60 s, 180 s or none 3.11 kWh 3.15 kWh 215 s 70 s
120 s, 180 s or none 2.97 kWh 3.09 kWh 495 s 120 s

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 as main.go wires 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.

  • TestEVHardLimitRiseWaitsForMeterOrDwell is now TestEVHardLimitRisesOnceCutApplied. 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=1 and make verify pass.

Related pull requests

Follow-ups (not in this PR)

  • Hardware evidence is still needed. It needs a run on a real Easee Cloud site: Charge now near the fuse, a phase switch, and two cars if possible, with meter phase currents and command times.
  • Local charger drivers in srcfl/device-drivers (ctek, ctek_hybrid, ctek_v2, tesla_wall_connector) should declare a measured power_max_age_s or response time, so they climb fast again.
  • zaptec_cloud declares nothing and needs a measured value.
  • Battery orders can move into the same pending input; they still use pendingBatteryCommandsW in watts.
  • A 1p command's phase is unknown, so a switch from 1p to 3p near the limit gets no credit for the 1p current and may stop the car rather than switch.
  • An offer the car does not take, for example while it is paused, counts as free room once its response time has passed. A car that resumes takes it all at once and is cut on the next reading over the limit.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T09:43:27.303292Z 602c5a8 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread go/internal/control/ev_fuse_budget.go Outdated
Comment on lines +146 to +147
if mode := phaseModes[point.ID]; mode == "" || mode == "3p" {
c.phases = max(1, min(3, s.SiteFusePhases))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@frahlg frahlg changed the title fix(control): count an EV rise as flowing until the charger can apply it fix(control): count EV offers per phase until the charger can apply them Oct 21, 2026
@frahlg
frahlg force-pushed the fix/ev-fuse-rise-stacking branch from 602c5a8 to 599511e Compare October 21, 2026 11:35
@frahlg
frahlg changed the base branch from master to fix/loadpoint-one-phase-decision October 21, 2026 11:35
@frahlg
frahlg force-pushed the fix/loadpoint-one-phase-decision branch from 64b3190 to 04d8027 Compare October 21, 2026 11:41
@frahlg
frahlg force-pushed the fix/ev-fuse-rise-stacking branch from 599511e to 4f8314b Compare October 21, 2026 11:53
@frahlg frahlg changed the title fix(control): count EV offers per phase until the charger can apply them fix(control): count EV raises per phase until the charger can apply them Oct 21, 2026
@frahlg
frahlg deleted the branch fix/loadpoint-one-phase-decision October 21, 2026 12:19
@frahlg frahlg closed this Oct 21, 2026
@frahlg frahlg reopened this Oct 21, 2026
@frahlg
frahlg changed the base branch from fix/loadpoint-one-phase-decision to master October 21, 2026 12:19
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>
@frahlg
frahlg force-pushed the fix/ev-fuse-rise-stacking branch from 4f8314b to 805f0ac Compare October 21, 2026 12:20
@frahlg
frahlg merged commit 6aa082f into master Oct 21, 2026
23 checks passed
frahlg added a commit that referenced this pull request Oct 22, 2026
…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>
frahlg added a commit that referenced this pull request Oct 22, 2026
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>
frahlg added a commit that referenced this pull request Oct 22, 2026
)

* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant