Skip to content

fix(control): a cut on its way covers the overage it was sent for - #1802

Merged
frahlg merged 3 commits into
masterfrom
fix/ev-fuse-cut-netting
Oct 22, 2026
Merged

frahlg merged 3 commits into
masterfrom
fix/ev-fuse-cut-netting

Conversation

@frahlg

@frahlg frahlg commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Part of v0.155.0-beta.2. Fredrik approved this fix for beta.2 and accepted its one trade-off, N1 below. Rebased on master 5ae590a, with #1799 and #1795.

Problem

stepEVMeterLimit took each fuse cut from peakW, the highest offer of the last evSettle (20 s). Easee Cloud applies a cut after 30–120 s. Once the 20 s had passed, the meter still showed the old draw and Core cut again. In the field matrix (#1800, label netting) the car went 10,400 → 6,900 W, then to 0 W 25 s later after a 6 A kettle step; 43 of 95 double cuts on master ended at 0 W. The same window missed a raise still on its way, so a cut could leave the phase over the limit once the raise landed (homebox/16A/empty/none/cloud60s/car3p/balanced/pause).

Rule

Fuse protection counts the site meter's current on each phase plus our own orders that cannot have shown yet, both ways. It reuses the per-charger, per-phase log from #1776 and #1793, with T = max(20 s, declared power_max_age_s), 180 s when the driver declares nothing.

  • A cut on its way covers the overage it was sent for until its charger can have applied it. Core cuts only what the cuts on their way do not cover.
  • A raise on its way adds to the overage the cut must remove.
  • A cut covers only within the run over the working limit it was sent in. The run ends once every phase has read under the limit for evSettle; a short dip, as battery relief gives, does not end it.
  • A cut that has not shown after T covers nothing, so the next reading over the limit cuts again. Once the run's first cut has outlasted T, cuts in that run cover for evSettle, as before.
  • Severe overage now means a phase's filtered current (τ 60 s) above 1.25 × In. Then nothing on its way holds back a cut. A reading above 1.2 × In (the instant factor) still makes a cut due at once, without waiting for the heat budget, but cuts on their way may cover it until the filtered current passes 1.25 × In. When a cut is due is unchanged: a spent heat budget or a reading above 1.2 × In.
  • While the meter shows an overage, the cap never rises above the latest orders.

Details:

  • When orders on their way go both ways, a cut and every order after it count as not shown, and of the orders before the first cut a raise counts from the lowest. A raise 6,900 → 9,660 W then a cut to 5,520 W covers 2 A (from 6,900 W), not 6 A (from 9,660 W).
  • Orders carry their phase count. A change of phases is no cut: 15 A on one phase to 6 A on three adds 6 A to two phases, so across a change the latest order counts in full as a raise.
  • A single-phase cut lands on a phase Core does not know, so with other chargers in the pool it covers no phase. Alone in the pool, it is the only lever there is.
  • Without phase currents Core cannot see the hottest phase, so there a cut covers for evSettle, as before.
  • After a restart, the offer Core finds in force at its first reading was not sent in this run. A cut takes it as shown, since the reading is all Core knows of it. Rises still count it from 0, as before.

The ambiguity, and which way this resolves it

The meter cannot tell whether a cut has landed. A reading over the limit after a cut fits two stories:

  1. The cut has not landed. The meter shows the old draw, and the cut will remove the overage.
  2. The cut has landed, and something else keeps the phase over: a new load, or a car that draws less than its order, so the cut removed little.

This PR takes story 1 while the run over the limit lasts, for at most T from the cut. Master took story 2 after 20 s, and that is the double cut on every slow charger. The meter ends story 1 early: once every phase has read under the limit for 20 s, the cut has landed or the overage has gone, and a cut sent before covers nothing more.

Why story 1 is safe when story 2 is true:

  • The overage left uncut is at most the cut Core credits, which is at most what the car was ordered on that phase before the cut. It lasts at most T from the cut, 180 s at the longest, and only within one run.
  • While every phase's filtered current (τ 60 s) stays at or below 1.25 × In, a gG fuse of 16–63 A carries that current for an hour; 180 s spends 5 % of it on the gG curve in sitesim. A miniature circuit breaker does not trip at 1.13 × In within an hour and trips within an hour at 1.45 × In.
  • Above 1.25 × In filtered, the credit ends, so the time over the limit shrinks as the overage grows: 109 s at 33 A on a 25 A fuse from 20 A, about 15 s at 2 × In from the working limit.
  • A reading above 1.2 × In still cuts at once whatever the cuts on their way do not cover.

The cost, shown by three probes against master:

  • A second load on the same phase within 20 s of a landed cut, on a fast charger that declares nothing: L1 at 29 A on 25 A (1.16 × In) for 2 min 55 s; master 28 s. With the second load 30 s after the cut: 22 s on both.
  • A car that draws 10 A of a 15 A order, L1 2 A over: 5 min 16 s over the limit; master 2 min 36 s.
  • A charger that ignores a cut at 33 A on 25 A: second cut after 109 s, when the filtered current passes 1.25 × In; master 21 s.

A driver that declares power_max_age_s shortens the first two to its own response time.

Known limit (N1), accepted for beta.2

Every charger that declares no power_max_age_s gets T = 180 s: OCPP in Core, the three CTEK drivers and tesla_wall_connector. Only easee_cloud declares. The reviewer's probe: a 5 s charger takes an instant cut that lands; 10 s later a second load takes L1 to 33 A on a 25 A fuse. Core cuts 2 of the 9 A over and leaves L1 at 31 A (1.24 × In) for 2 min 55 s. Master took 15 s, and the same charger declaring 30 s takes 25 s. L1 stays inside the heat bound above. Dropping the credit above a 1.2 × In reading is not the fix: it brings back 31 over-cut rows. The fix is a measured response time per charger, from the device chain, and until then OCPP in Core declaring one (follow-up, not in this PR).

Not changed

Rises, starts, EVFusePhaseRoomA, the split between cars, fuse_heat.go, go/internal/loadpoint, and the hour's import ceiling cut. That cut still takes the overage from the highest offer of the last 20 s, as pendingEVCutW and its battery relief expect. No new setting.

Review

An independent review of the first commit found four things; the second commit handles the first three:

  1. Without phase currents the credit had no heat bound. Now it covers for evSettle there.
  2. A change of phases, or a single-phase charger beside others, gave a false credit on every phase. Now orders carry their phase count, and a single-phase cut beside other chargers covers no phase.
  3. A car under its order retried each cut only after T: 10 min 36 s over the limit. Now cuts in a run cover for evSettle once its first cut has outlasted T: 5 min 16 s. Scoping cover to the run also brought the fast-charger probe from 2 min 35 s to 22 s when the second load comes after the run ended.
  4. Not changed: a raise the meter already shows still adds to a cut for T, as rises count it today. That over-cuts; it is never unsafe.

Open drafts on the same file

#1782, #1789 and #1792 (drafts) also change ev_fuse_budget.go. #1782 touches comments. #1792's two-phase orders will need offerPhaseA to return 2 as the phase count. #1789 has its own cut netting, per run over a limit and at most evSettle; this rule replaces that part, so #1789 needs a rebase onto it.

Field matrix (TestSiteSimFieldMatrix, 338 cases)

master 5ae590a this PR
cases with no failure 175 267
cases failing an invariant 163 71
over-cut 103 0
limit 1 0
cut, one-phase, verdict 0 0
fuse, stale, home-use 0 0
energy 40 40
idle 25 25
own-raise 6 6

FTW_FIELD_KNOWN=write removed 104 known lines and added none: 99 over-cut netting, 1 limit netting, 4 over-cut D. The netting label is gone. The 71 cases left were all listed on master: energy 39 D and 1 A, idle 13 export, 10 readback and 2 hold3p, own-raise 6 relief.

Before the rebase, on master 7202ace, this change alone took the matrix from 249 to 173 failing cases (over-cut 120 → 6). The six left there were the split's float floor (creditA × 690 = 7,599.999… W → 6,900 W step), which #1799 removed.

Probe cases (ev_fuse_cut_netting_test.go)

Probe master this PR
Slow charger (40 s/180 s declared, 120 s/180 s, 60 s undeclared): L1 step, then a kettle while the cut travels cuts 8,970 → 6,900 → 5,520 → 0 W 8,970 → 6,900 W, L1 24 A after both land
Raise to 9,660 W on its way when L1 reaches 26 A cut to 8,280 W; L1 28 A when the raise lands cut to 5,520 W; L1 24 A
Charger ignores the cut (declared 30 s, 180 s, undeclared) cuts again every 20 s, to 0 W cuts again after 30 s, 180 s, 180 s
Severe: L1 33 A on 25 A, charger ignores second cut after 21 s after 109 s, when the filtered current passes 31.25 A
Fast undeclared charger, cut lands, second step on L1 30 s / 10 s after the cut over 22 s / 28 s over 22 s / 2 min 55 s
Car draws 10 A of a 15 A order, L1 2 A over over 2 min 36 s over 5 min 16 s
No phase currents, cut travels second cut after 20 s the same
15 A on one phase to 6 A on three, L2 at 32 A cut to 0 W the same
Restart at 26 A with a 60 s charger 3 cuts while the first travels 1 cut

Tests

  • go test ./internal/control/ ./internal/loadpoint/ ./internal/sitesim/: pass; the fuse tests and TestSiteSimScenarios also pass under -race.
  • make verify: vet, tests and build clean before the rebase (a096b0b).
  • On the rebased head 6bdad95: go vet ./... and go test ./internal/... ./cmd/ftw/... pass (60 packages).

Follow-ups, not in this PR:

  • N1: a measured response time per charger, from the device chain, so a cut stops covering after the charger's real response time; until then, OCPP in Core declares one.
  • phaseRoomCapW multiplies creditA back in floats (7,600 W → 7,599.999… W), and floorChargeW then drops a step. fix(loadpoint): planned charging runs on three phases, and room never cuts a running order #1799 hides it on the same phases; a small tolerance would remove it on a change of phases.
  • The rise path has the phase-change gap this PR closes for cuts: unshown counts 15 A on one phase to 6 A on three as no rise, though two phases gain 6 A. The split's own creditA already gives a phase change no credit.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 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-22T08:48:59.091649Z 1aec356 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.

frahlg and others added 3 commits October 22, 2026 11:42
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>
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>
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>
@frahlg
frahlg force-pushed the fix/ev-fuse-cut-netting branch from a096b0b to 6bdad95 Compare October 22, 2026 09:44
@frahlg
frahlg merged commit 62ed7ea into master Oct 22, 2026
22 checks passed
frahlg added a commit that referenced this pull request Oct 22, 2026
On master with #1802, the 39 one-phase car energy rows (D) and the one
energy row labelled A pass with this branch; their lines and labels go.
No row is new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 22, 2026
…nned and ordered as one (#1801)

* fix(loadpoint): a one-phase car on a three-phase charger is seen, planned and ordered as one

A one-phase car draws a three-phase order's current per phase on one
phase. On the field table the planner got [0, 4800], the car took
1,600 W of a 4,800 W order, and the feedback said not following.

The loadpoint now tests each rise of its three-phase order on the site
meter: one phase rising by about the order's rise, the other two still,
for 30 s within the charger's response time, while the charger's own
reading shows that draw. That marks the session's car one-phase
(State.CarPhases, the field a car profile can set later); a rise on all
three phases clears it, and unplugging resets it. A house load does not
follow the orders, and a two-phase car moves two phases, so neither is
marked.

Marked, the planner gets the car's one-phase levels, 6 A up to the
charger's current per phase. The charger stays on three phases and the
order is the plan's power times three, so its current per phase is what
the car should take. The command carries expected_w, a third of the
order, and command evidence compares the car with that. The verdict is
Limited, "Charging on one phase, at most 3.7 kW". The fuse limit is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loadpoint): one-phase car tests on fast and undeclared chargers; field rows

The field matrix showed two gaps. A charger that applies in 2 s has
already taken a raise by the next reading, and a fuse ramp climbs 1 A at
a time, so a test's baseline must be the meter read before the first
raise since the order it measures from. A charger that declares no
response time is now given 3 minutes, as the fuse limit counts it, not
one.

Field matrix on #1799: 55 D rows, 28 #1795 rows and one A row turn
green. Every one-phase-car row now meets its energy, verdict and
one-phase invariants. 25 of them still cut twice when a house step on
the car's phase meets the second cut of the netting class; they are
labelled netting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loadpoint): one-phase car check compares changes, guards a clear, and survives a restart

Review findings on #1801:

M1: the check compared the charger's whole draw with the meter's rise,
so only a rise from about 0 A could mark a car. It now compares the
change in the charger's draw since the baseline.

M2: a three-phase verdict had no draw check, so a heat pump starting at
2 A a phase during a 2 A rise cleared a correct mark. It now needs the
charger's draw to have risen by about the three phases' sum.

M3: the first reading of a session had a zero baseline, and the mark
lives in memory, so after a Core restart with the car charging it was
never marked again. A reading with no baseline is no longer a test, and
an unmarked car whose charger's whole draw shows on one phase while the
other two carry less than a third of it, for 30 s, is marked. No three-
or two-phase car can show that.

Each review probe is a test, and each fails on the previous head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api): judge a one-phase car after limits, setpoint and waiting

"Charging on one phase" ran before the limit guard, so a fuse limit,
import limit, fuse cooldown, stale site meter, changed setpoint or a
reading older than the order all read as limited/one_phase. The branch
now runs after the waiting check; those verdicts come first, and a draw
beyond tolerance is judged as for any car.

The field matrix judged the car's draw against the full order and let
only one_phase excuse a third of it. It now judges the draw against the
command's expected_w, as Core does, so a marked car under a fuse limit
reads fuse_limit without failing the verdict check. The known list does
not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): rewrite the field matrix's known list on #1802

On master with #1802, the 39 one-phase car energy rows (D) and the one
energy row labelled A pass with this branch; their lines and labels go.
No row is new.

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