Skip to content

fix(control): a landed EV cut covers nothing later; a raise that no longer fits comes off - #1923

Merged
frahlg merged 61 commits into
c-battery-fuse-no-bufferfrom
ev-cut-landed-and-unshown
Oct 28, 2026
Merged

frahlg merged 61 commits into
c-battery-fuse-no-bufferfrom
ev-cut-landed-and-unshown

Conversation

@frahlg

@frahlg frahlg commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Stack and merge order

Stacked on #1913 (c-battery-fuse-no-buffer), which sits on #1891 and #1910. Merge after #1913. #1914 (margin default 0) names "a fix for the landed-cut problem" as its step 4. That is this PR, so merge it before #1914.

#1924 and #1925 sit on this branch. Both now contain this branch's head (8a10b27d): it is merged into #1924 (c0a52740), and #1924 into #1925 (5babe51a). #1926 sits on #1925 but does not yet contain #1925's head or this branch; it needs a merge-up of its own.

Overlap with open PRs:

Problem

Two faults in go/internal/control/ev_fuse_budget.go:

(a) Landed cut. unshownOrders counts an EV cut as on its way for the charger's whole response window: 180 s (evUnknownSettle) for a charger that declares none. It does so even after the meter shows the cut, as long as the run over the line has not ended. A house step after the landing is then covered by the old cut. Core cuts one step and never again. In #1914's trace (TestSiteSimRandomHouse seed 1, 20 A fuse, 2 s charger, margin 0):

  • 12:16:13: a heat cut takes the car from 6900 to 5520 W, and the meter shows it at 12:16:15.
  • 12:16:33: the house steps, and L2 reads 22.76 A on the 20 A line.
  • 12:16:35: Core cuts one step to 4830 W. L2 stays at 21.76 A, and nothing cuts again.

(b) C4 (27 Oct triage, section 3 item 6; seeds 207537 and 207539). stepEVMeterLimit cuts only on a measured overage. FTW's own raise on its way sizes a cut but never triggers one. When an outside step makes a raise on its way no longer fit, Core waits for the meter to show the overage.

(c) Review of the first commit. It kept one shown level for all phases, and a move on one phase alone counted for every phase:

  • A house load that drops on one phase while a three-phase cut travels read as the cut landing. The cover came off the phase that was still over, and Core stacked a second cut. Fuse 25 A, 40 s charger: a 3 A drop on L2 gave cuts to 8970 and then 7590 W, and L1 sat 2 A under the line for 2 m 26 s. A 1.5 A load cycling on L2 gave three cuts and two rises; fix(control): battery fuse cut lands on the line, not half a margin below #1913 gave one cut.
  • C4 missed the triage case as written, with only L1 stepping. The loadpoint never reported a car seen on three phases, so a one-phase house step looked like a one-phase car taking the raise.

(d) Third review (at 281f9f63).

  • C4 cut a raise that had landed under the line. aheadA shrank only when a reading-to-reading move had a charger's shape. A landing in another shape left the whole raise counted as on its way, on top of a reading that already held it. Two cases: a house load on another phase that moved 1 A or more in the landing reading, and a car that ramps less than 0.5 A a reading. 16 A fuse, 40 s charger, raise 6→14 A with 8 A of room: it landed at 14/14/14 A and Core cut to 5520 W (house drop on L2) or 6900 W (slow ramp). fix(control): battery fuse cut lands on the line, not half a margin below #1913 did not cut.
  • A one-phase load cycling on the phase over the line made Core cut and rise over and over, for a car not yet seen on three phases. Each drop read as the cut landing on that phase and took its cover off. The load's return could not give it back, since the shown level moves only toward the latest offer. 25 A fuse, 40 s charger, 1.5 A on L2 every 3 s: 21 cuts and 29 rises in 20 min. fix(control): battery fuse cut lands on the line, not half a margin below #1913: one cut.

Change

The site meter now tells Core how much of a charger's offers it already shows (seeOffers, chargerOffers.shownA). This holds with one charger in the pool whose log has no change of phases:

  • A reading-to-reading move shaped like an order landing moves that level toward the latest offer, never past it. Every phase alike counts: mean at least 0.5 A and no more than 1 A apart. One phase alone also counts: at least 1 A, with the others within 0.5 A.
  • The level is kept per phase, and a move counts only in a shape the offer can take. A one-phase car the loadpoint has seen, or a one-phase order, is one level: every phase dropping alike moves it, and so does a move on the car's phase alone where that phase is known (since the final review; before, a move on any phase counted while the phase was not known). A three-phase order moves only on every phase alike. A phase that moves alone is a house load, or a car not yet seen that draws on one phase, and Core cannot tell which, so it moves nothing (since the third review). A house load on one phase therefore never moves any level.
  • The loadpoint now reports a car it has seen draw on all three phases as CarPhases = 3. Before, it reported 0 for both "three phases" and "unknown". Every reader checks CarPhases == 1, so nothing else changes. For such a car a move on one phase is never the car.
  • While one of our battery orders moves the phases by 0.5 A or more, an alike move may be the battery's. Then nothing counts, and the level is unsure until only one offer can show.

With that:

  • (a) A cut covers at most what the meter has still to show (unshownOrders). A cut the meter shows covers nothing that comes after it. When a landing and a house step fall into one reading, the meter cannot tell them apart. In that case a cut covers a phase that has risen since by more than 0.5 A only up to the overage that phase had when the cut went out (cutForA). This was seed 68's case.
  • (b) aheadA is the rise the meter has still to show on each phase (riseAheadA, since the third review). It no longer uses the shape tracker. When a raise goes out with no other raise still to show, Core keeps the meter's phase currents of that moment and the most of the offers that reading may have shown. The rise still to come is the raise less the meter's own rise since then, within the offers the reading may show. A three-phase order takes the smallest rise across the phases, so a house step on some phases does not hide a raise the others have not shown. A one-phase car takes its own phase's rise. Any car the loadpoint has not seen on three phases takes the largest rise (since the final review; the fourth review's aloneA is gone). The record ends once the meter shows the whole raise, or once only the latest offer can show. On each phase it counts as the reading it will make. Above 1.2 × In, or over the line on a phase whose heat budget is spent, it comes off on this reading, down to the line, in whole amps. Under that, the heat budget judges it once it lands, as any reading. Overages between In and 1.2 × In stay with the I²t model (owner rule). The heat cut's own rise is now at least aheadA, so a landed cut no longer nets a later raise out of it.

No margin, floor, dead zone or timer moves. Where the meter cannot tell (several chargers, a change of phases, our battery moving, no phase currents), cover works as before, and C4 stays off.

Final review fix (dcf49824)

Two regressions against #1913, both fixed:

  • Unseen car cut under the line. aloneA missed a one-phase landing when another phase moved 1 A or more in the same reading, so C4 cut the car with every phase under the line (field matrix with 1.2 A toggled on L2: 38 cuts with a 10 s meter, 0 on fix(control): battery fuse cut lands on the line, not half a margin below #1913). riseAheadA now takes the largest rise until the loadpoint has seen the car on three phases.
  • One-phase order lost its cut cover. A house load moving alone on any phase read as the cut landing, because a one-phase order never knows the car's phase. A one-phase move now counts only on a known car phase.

Tests, each failing before dcf49824:

  • TestEVFuseUnseenOnePhaseCarLandsBesideDrift, new cases with 1.2 A and -1.2 A of house on L2 in the landing reading: before, cut to 4830 W one second after the landing.
  • New TestEVFuseOnePhaseOrderHouseMoveIsNotTheCutLanding: 16 A on one phase, heat cut to 2990 W, then a 3 A load on L2 switches off. Before, a second cut to 2300 W.
  • TestEVFuseRaiseOnItsWayThatNoLongerFitsCutsAtOnce now sets a car seen on three phases: for an unseen car, L1's 9.7 A rise counts as the landing.

go test -race ./internal/control/ ./internal/loadpoint/ ./cmd/ftw/ and the hygiene scripts pass. The notes below describe earlier rounds.

Tests

Each new or tightened test fails on #1913 (610e2a1b) and passes here:

  • TestEVFuseLandedCutCoversOnlyItsRun, now pinning the fix: L1 over for 2 m 55 s before, 28 s after, in the same-run case.
  • New TestEVFuseCutCoversOnlyThePhaseOverageItWasSentFor (seed 68's shape): L3 over for 3 m 1 s before, 1 m 6 s after, which is its own heat budget.
  • New TestEVFuseRaiseOnItsWayThatNoLongerFitsCutsAtOnce (the triage's C4 case): 16 A fuse, a 6→16 A raise sent 30 s earlier to a charger that declares 40 s, L1 at 15.7 A. Before: no cut. After: 4140 W on that reading.

Added after review. Each fails on the first commit (bb9703e8) and passes here:

  • TestEVFuseOnePhaseHouseMoveIsNotTheCutLanding/drop_on_L2: before, cuts 8970 then 7590 W. After, one cut to 8970 W, as on fix(control): battery fuse cut lands on the line, not half a margin below #1913.
  • TestEVFuseOnePhaseHouseMoveIsNotTheCutLanding/cycling_on_L2: before, offers 10350, 9660, 8970, 8280, 8970, 9660. After, one cut to 9660 W and no rise, as on fix(control): battery fuse cut lands on the line, not half a margin below #1913.
  • TestEVFuseRaiseOnItsWayCutsForACarSeenOnThreePhases: the triage's C4 case word for word (15.7/6/6 A) for a car seen on three phases. Before, the offer stays at 11040 W. After, 4140 W or less on that reading.
  • TestOnePhaseCarMarkedFromTheMeter (loadpoint) now requires CarPhases = 3 after a three-phase rise; before, it read 0. carPhasesUnknown in the site sim now checks for a one-phase mark only (== 1).

Added at the second check (test only):

  • TestEVFuseBatteryOrderMoveIsNotTheCutLanding: a 3 A cut travels to a 40 s charger while our battery order of 1 A a phase lands and goes back. With the battery-order guard in seeOffers removed, the drop reads as the cut landing and Core cuts again to 8280 W; with it, the one cut to 8970 W holds. Before this test, removing the guard left every test green.

Added at the third review. Each fails on 281f9f63 and passes here (53b8aa34):

  • New TestEVFuseRaiseThatLandedIsNotCountedTwice: a 6→14 A raise lands at 14/14/14 A on a 16 A fuse. Before: cut to 5520 W one second after the landing when L2's house load dropped 1.2 A or 2 A in that reading (car unseen or seen on three phases), and cut to 6900 W at 11.2 A when the car ramped 0.4 A a reading (car unseen or seen on three phases). After: no cut. The clean landing passes on both.
  • New TestEVFuseLoadCyclingOnTheOverPhaseDoesNotHunt: closed loop, house 18→10 A a phase, 1.5 A on L2 toggling every 3 s or 11 s, 40 s charger, 20 min. Before: 21 and 9 cuts. After: at most one.
  • Mutation checks: with the old one-phase move kept in seeOffers, the cycling test fails again (21 and 9 cuts) and the C4 fix alone does not help. With the largest rise in place of the smallest for a three-phase order, TestEVFuseRaiseOnItsWayThatNoLongerFitsCutsAtOnce and TestEVFuseRaiseOnItsWayCutsForACarSeenOnThreePhases fail.
  • A first version took the smallest rise for a car not yet seen as well. The field matrix (sitesim_field_matrix_test.go, built only without -race) caught it: a one-phase car not yet seen was cut 11040 → 0 W with every phase under 24.5 A. The second commit (53b8aa34) adds the one-phase-alone case above, and the matrix passes.
  • The reviewer's tests from this round pass: TestZZA3LandingWithConcurrentHouseMove, TestZZA3SlowRampLanding, TestZZA3PlugFlapWhileDrawing and TestZZA3RiseBesideOnePhaseCycler (one cut and 8 rises in all six variants). The earlier reviewers' UnrelatedOnePhaseDropDuringCut, DoubleCutRecovery and HuntingOnePhaseCycler still pass. TestZZReviewC4OnePhaseHouseStep still fails, as before (car not yet seen; see Risk).

Added at the fourth review (at 53b8aa34). Fails on 53b8aa34, passes here (8a10b27d):

  • New TestEVFuseUnseenOnePhaseCarLandsBesideDrift: a one-phase car not yet seen takes a 6→16 A raise on L1 and lands at 24 A on a 25 A fuse. At 53b8aa34, house drift on L2 of 0.6 A, 1 A or −0.6 A while the raise travelled, or 0.7 A in the landing reading, made Core cut to 4830 W one second after the landing: "held" was measured against the reading when the raise went out, over the whole response time. At 281f9f63 the two landing-reading cases fail; fix(control): battery fuse cut lands on the line, not half a margin below #1913 passes all.
  • Mutation: counting every move as alone (no check on the other phases) fails TestEVFuseRaiseOnItsWayThatNoLongerFitsCutsAtOnce (L1 +9.7 A, L2 +3 A in one reading), so that test guards the other side.
  • Reviewer's closed-loop probe: the field matrix with 0.7 A of house on L2 toggled every 17 s (patch not committed). At 53b8aa34, four car1p chargenow cases cut 11100 → 0 W with every phase under the line. Here none do, on this branch and on the top of the stack (fix(control): the car comes before battery charge #1925 merged). The three timing/25A/.../surplus idle cases fail the same on fix(control): battery fuse cut lands on the line, not half a margin below #1913, 281f9f63, 53b8aa34 and here; they are not about the cut. With the other phases held within 0.5 A instead of 1 A, easee120s/car1p still failed: the 5 s meter caught the toggle in the landing reading.
  • The earlier reviewers' tests (TestZZA3*, TestZZReviewUnrelatedOnePhaseDropDuringCut, DoubleCutRecovery, HuntingOnePhaseCycler) still pass. TestZZReviewC4OnePhaseHouseStep still fails (see Risk).
  • The seed-count table above was not rerun at 8a10b27d.

Second check of the review fix, on the stack as merged on 28 Oct: the reviewer's tests TestZZReviewUnrelatedOnePhaseDropDuringCut (one cut, 0 s under the line), TestZZReviewDoubleCutRecovery and TestZZReviewHuntingOnePhaseCycler (one cut, no rise, with 5 s and 40 s chargers) pass. TestZZReviewC4OnePhaseHouseStep still fails, as the Risk section says: the car there has not been seen on three phases.

Seed-count harness: TestSiteSimRandomHouse's random house up to In, 200 seeds, 1 h each. The harness is d-zz_seeds_test.go from the task scratchpad, plus a second judge that restarts the allowance at each outside step:

margin 0 margin 0.5 margin 0, judge restarts at each step over-line seconds car cuts car kWh
#1913 12 9 7 28 667 1 754 343.5
first commit 7 5 0 26 564 1 781 339.9
with the review fix 7 5 0 26 564 1 781 339.9
with the third-review fix (53b8aa34) 6 5 0 26 153 1 817 334.3

The second review fix changes no seed. The random house steps every phase on its own, so a move on one phase alone, with the others within 0.5 A, is rare there. The third-review fix drops seed 172 at margin 0 and cuts time over the line by 1.5 %, at 2 % more car cuts and 1.6 % less car energy (see Risk).

The 7 left at margin 0 before the third review were seeds 70, 99, 138, 158, 172, 179 and 189 (now without 172). All have a 40 s charger and fail "over Core's limit for more than 46 s". I traced each one. Core cuts within 1–2 s of the heat crossing, sized right. A second house step lands inside the same run, and Core cuts again within 1–2 s, to 0 W. The 40 s charger applies both cuts after the judge's allowance, which runs from the first crossing and does not restart at the second step (triage R2). None remains under the step-aware judge. The heat model explains every one: each phase over the line is either inside its heat budget or waiting on the charger's 40 s.

Other checks (at 8a10b27d, and go test -race again at 5babe51a, the top of the stack):

  • go test -race ./internal/control/ ./internal/loadpoint/ ./cmd/ftw/: pass (209 s for control).
  • go test -tags=netgo,osusergo ./internal/control/ ./internal/loadpoint/, field matrix included: pass.
  • make verify: clean. Docker was unavailable, so the avahi run was skipped. On a first run two timing tests (TestHABridgeOwnerBackoffIsCapped, TestGorillaWSOpenGivesUpOnASilentServer) failed while a race run loaded the machine; both pass alone, and the second make verify was clean.
  • Repo hygiene against this PR's base and against origin/master: check-telemetry-reads.sh reports 133, none added; check-no-planning-docs.sh passes; the test_telemetry_reads.py unittest is OK.
  • The existing anti-hunting tests pass unchanged: TestSiteSimNoisyLineCyclesSlowly, TestSiteSimCarEndsAtTheLine, TestIssue1868ClosedLoopPriorityAndRecovery and the field matrix.
  • TestSite/siterig not run here; the runner does that.

Risk

Medium. This changes when Core's fuse cut fires.

  • Double counting. C4 now reads the landing from the meter's rise since the raise went out, so a landing counts in any shape: a slow ramp, or beside a house load that moves in the same reading. A house load that drops on one phase while a three-phase raise travels still reads as raise not shown yet, by as much as it dropped. C4 then cuts only if that phase's reading plus the drop passes 1.2 × In, or the line on a phase whose heat budget is spent. Example: a 2 A drop on L2 next to a landing at 14 A on a 16 A fuse does not cut; a drop of more than 3.2 A in the same window would. Measured against fix(control): battery fuse cut lands on the line, not half a margin below #1913 across 200 h: 3.6 % more car cuts and 2.7 % less car energy, with 8.8 % less time over the line.
  • Hunting. The noisy-line test still holds at most 5 cuts an hour with 0.2 A of noise.
  • Known gap, narrowed. Triage item 6 is closed for a car the loadpoint has seen on three phases. It stays open for a car not yet seen: the largest rise counts as the landing, so a house step on any phase while a raise is on its way reads as the car taking it. C4 then leaves it to the meter and the heat model, as on fix(control): battery fuse cut lands on the line, not half a margin below #1913. Taking the smallest rise there broke the field matrix (a one-phase car not yet seen was cut 11040 → 0 W). The loadpoint tests the car on each rise of 2 A a phase or more, a session's start included, and holds the verdict for the session. So the gap covers the time before the first such rise has run its test, about the charger's response time plus 30 s.
  • One-phase car not yet seen, cut cover. For a three-phase order, a move on one phase alone no longer moves the shown level. A one-phase car not yet seen whose cut lands on its phase therefore keeps its cover until the charger's window ends, as on fix(control): battery fuse cut lands on the line, not half a margin below #1913. A house step on that phase in that window is covered by the old cut, up to the overage the phase had when the cut went out (cutForA). Once the loadpoint marks the car one-phase, its phase moves the level again.
  • One-phase car on its own phase. A known one-phase car is still one level that a move on its own phase moves. A load cycling on that same phase while a cut travels could read as the cut landing. A rough open-loop probe (one-phase order, 1.5 A cycling on the car's phase, 40 s charger) gave two cuts against one without the load, and no repeated cutting. No committed test covers it.
  • Battery sites. While our battery order moves by 0.5 A or more a phase, the meter evidence is off. A battery that moves on its own, outside FTW's orders, can look like a charger landing. That can take cover off a cut and give an extra cut, or leave a raise to the heat budget as before. It must move every phase alike to do so; a one-phase move no longer counts for the other phases.

🤖 Generated with Claude Code

frahlg and others added 3 commits October 27, 2026 19:05
Core now works to the fuse rating unless a site sets a margin. Every
explicit safety_margin_a is kept. A stored value of exactly 0.5 A, the
old default, adds one load-time notice and changes nothing.

The peak-limit error names the margin only when it is above 0, and the
settings field shows 0 with help that says the margin is opt-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The changeset said FTW works to the fuse rating unless a site sets a
margin. On this PR's base every per-phase line still sits 1 A under the
rating; that holds only once #1910 and the step-4 amendment of #1891
land. The changeset now names what this PR changes: the default, and
the whole-site import and export limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onger fits comes off

The site meter now tells Core how much of a charger's offers it already
shows. A move shaped like an order landing (every phase alike, or one
phase alone for a one-phase car) moves that level toward the latest
offer. A cut the meter shows no longer covers an overage that an outside
step causes after it, and no longer nets against a raise sent after it.
Where a landing and a house step fall in one reading, a cut covers a
phase that has risen since only up to the overage it had then.

C4: our own rise on its way, as the meter tells it, is judged as the
reading it will make. Above 1.2 x In, or over the line on a phase whose
heat budget is spent, it comes off on this reading, down to the line.

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

chatgpt-codex-connector Bot commented Oct 27, 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-27T20:59:42.024797Z bb9703e 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 2 commits October 27, 2026 22:23
seeOffers kept one shown level for every phase, so a house load moving on
one phase alone read as a three-phase cut landing and took the cut's cover
off the phase it was sent for. With a 40 s charger Core then stacked a
second cut and left L1 2 A under the line for over 2 min. The shown level
is now kept per phase: a three-phase order moves every phase alike, and a
phase that moves alone moves only its own level.

The loadpoint now reports a car it has seen draw on all three phases as
CarPhases 3. For such a car a one-phase move is never the car, so a house
step on one phase while a raise travels no longer hides the raise, and C4
cuts on that reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The battery fuse guard judged a rise from the battery's own reading and
the meter's phases, so it took a rise the meter did not show yet as
already shown. With the meter a poll behind the battery, a PV surplus that
grew each tick stacked rises past the line, then the relief cut them back:
16.4 A on a 16 A fuse and a charge that rose and fell (C2, seeds 207530,
207535).

fitBatteryRisesToFuse caps charge above the battery's latest order to
(fuseLineA - worst phase - own unshown rises) x V x phases. The unshown
rises are the battery's orders of the last evSettle and the chargers'
raises, counted as evShownW counts them, so the car comes first. Holding
an order is never a rise and the check never cuts; an overage stays with
the relief. No margin and no floor: the order ends at the line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 2 commits October 27, 2026 23:09
…less phases

A battery order now stays unshown until evSettle after the battery's own
reading reaches it, so a battery that ramps slower than evSettle no longer
gets a second rise on top of an order the meter cannot show yet.

On a meter with phase currents but no phase power while the total
exports, a phase counts as export only when the other phases cannot
carry the export alone; any other phase counts as import. Before, every
phase flipped to export, so a three-phase charge could rise into a phase
that imports beside two exporting ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With battery_covers_ev off, dispatch took the car out of the meter
reading before the battery decided. The battery then saw the PV the
car was using as export and charged from it while the site imported
for the car (rig seeds 207535, 207539, 207530: about 10 kW import).

The battery now charges only from the surplus the meter shows after
the car. Where the house alone has surplus, the battery sees the meter
with the car included, and any discharge stops at once, since it would
feed the car. A planned PV charge on the energy path takes only the
surplus the car leaves; a planned grid purchase keeps its charge.
Cover off still never discharges the battery for the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 2 commits October 27, 2026 23:52
Manual peak_shaving gave zero error for any import under the peak
limit, so a battery charge that was running when the car started held
while the site imported for the car, with battery_covers_ev on or off.
When the site imports while the car and the battery both charge, the
charge now comes down by the import, to 0 at most. Under the peak limit
the battery still never discharges.

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

After a restart, the 0 A orders FTW sent while the meter was stale counted
as the EV base the meter shows. With the car still drawing 8.2 kW from
before, a new 6.2 kW order then counted as a 9 A rise to come, and the
battery was held in discharge for the car (seed 207532).

Until a charger's first order of this run can have landed, the battery
side now counts the charger's live measured draw as in the reading, never
above the latest order and never above the consumption the site meter
shows. The car's own limit does not read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 4 commits October 28, 2026 00:33
Review of #1926 found two ways the restart credit let the battery end a
discharge into room the car's pending rise still needs:

- The charger reading counted with no check that it was current. A held
  reading after a failed poll, or a sample older than the site reading,
  could show a car that a fast charger had already stopped. The draw now
  counts only when the last poll succeeded and the charger measured it
  within one control interval of the site reading.
- The consumption bound subtracted the smoothed battery, PV and V2X
  values, which lag an output that just fell. It now takes the higher of
  raw and smoothed for each, so the bound stays at or below what the
  meter shows.

New tests pin both, plus the shown-offer subtractions and a window that
outlasts evSettle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebases #1914 onto the top of the fuse stack. The one conflict was the
margin in the site sim's boot; it now reads simFuseSafetyA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simFuseSafetyA follows the config default, now 0. fitScenario takes the
room under the line and sets the house from simFuseSafetyA, so its exact
fit stays twelve whole steps at any margin. "charger's own load
balancer" passes at margin 0, so its known line goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the stack under it, the default moves the car's and the battery's
per-phase line as well as the site totals. 0.5 A stops being the
default, so #1910's changeset no longer calls it that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 12 commits October 28, 2026 01:18
Brings in #1891's tip (fd07863, b19d30d and master through 7840c33)
by way of #1913 and #1923.

Both sides pulled the signed phase currents out of phaseFuseReliefW:
#1891 as signedA, which flips every phase when the total exports and no
phase shows a direction, and this branch as signedPhasesA, which flips
only a phase the total forces to export. Resolution: keep signedPhasesA
and drop signedA, so the relief, #1891's new phaseSentReliefW and
fitBatteryRisesToFuse read the phases one way. phaseMoveW is gone;
#1891's phaseSample.moveW, which reads each battery live and also returns
the move from the last command, replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At the 0 A default the scenario passes only because the charger's own
balancer sits at 25 A, the rating and so the line. The bug is still in
Core: a cut comes off an offer the car does not draw. Two known variants
keep it tracked and turn red once it is fixed: the stored 0.5 A margin
(over 24.5 A for more than 11 s) and a balancer at 25.5 A at margin 0
(over 25 A for more than 11 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phase anti-windup from a5 read phases through signedA, which this
branch had folded into signedPhasesA. It now uses signedPhasesA, so it
reads phase direction the same way as the relief and the rise check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard in seeOffers that stops reading the meter while our battery
order moves had no test; removing it left every test green. A battery
order that lands and goes back while a cut travels to a 40 s charger
now must not lead to a second cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removing the PI path's immediate discharge stop left every named test
green; only the golden replay caught it. A peak_shaving loop with
battery_covers_ev off, starting from a 2.9 kW discharge while the house
alone has surplus, now requires that no order discharges into the car.

The comment on the plan back-off said the plan's grid leaves out the car.
It counts the car it planned; the comment now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 20 commits October 28, 2026 06:45
The battery side's restart credit (evFoundDrawW) had two gaps.

A charger sample measured after the site reading counted as part of
it. A car that rose in that gap was credited for draw the meter did
not show yet, and the battery ended its discharge into room the rise
had taken. With a 3 s meter, L2 reached 19.30 A. A sample now counts
only when measured at or before the site reading.

The rise reserve took max_charge_w as the most the unknown offer in
force can be. A charger still on an offer from before a restart that
lowered max_charge_w, or on its own higher default, could rise past
it: 19.00 A per phase on the unit fixture, 19.30 A on the plant. The
reserve now runs to the larger of max_charge_w and the site fuse's
rating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The restart credit compared a charger's sample time, stamped by the
charger's own clock (OCPP MeterValues), with the time the site reading
arrived on Core's clock. A charger clock 1 s or more behind Core's let
a rise sampled after the reading count as already in it, and L2 reached
19.30 A on the third review's plant case.

A sample now counts only when stamped evClockAllowance (5 s) before the
reading arrived, and the draw is the lowest of that sample and every
sample since. noteDraw keeps the charger's samples while the restart
window is open.

Also tests the max_charge_w half of the rise cap: a 22 kW charger on a
3 x 25 A fuse keeps the reserve up to max_charge_w.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the current tips of #1910, #1891, #1913, #1923, #1924,
#1925 and #1926.

The only conflict was #1910's changeset: it now carries #1910's new
sentence on an offline battery's relief, and keeps this branch's
"a 0.5 A margin" wording, since 0.5 A is no longer the default here.

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

The SiteFuseSafetyA comment read as if the margin were needed by
default; it now says zero is the default and when to set one. The
changeset named "the site's total import and export limits", which
reads as the hourly import_limit; that limit does not move. It now
names the fuse's import and export ceilings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mutation that restored a hidden 0.5 A margin at startup passed every
suite. This test pins the value newControlStateFromConfig hands Core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At margin 0 Core holds the phases at the 16 A line. Rig s18-16's 0.1 A
house step at small_step then lands over the line until Core cuts: on
fdfbfec the phase read 16.101 A for 3 s and Core cut the battery
4851 -> 4719 W within 2 s. The criterion "own commands stay inside the
rated fuse" counted that stretch as Core's and failed. The owner's rule
judges an overage between In and 1.2 x In by the I2t model.

The criterion now skips the 10 s after the step, the same gap the minor
window already leaves. On the fdfbfec record Core's own commands peak
at 16.008 A before the step and 16.005 A after it, inside 16.01 A.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two regressions against #1913 from the final review.

A car the loadpoint has not seen on three phases may draw on one phase
Core does not know. The alone rise (aloneA) missed its landing when any
other phase moved a step in the same reading, so the whole raise counted
as still to come and C4 cut a car with every phase under the line, at
most session starts with a 10 s meter. riseAheadA now takes the largest
rise for such a car, as for a one-phase car whose phase is not known,
and the smallest only once the car is seen on three phases.

A one-phase order never knows the car's phase, so a house load moving
alone on any phase read as its cut landing and took the cover off a cut
still on its way. A one-phase move now counts only on a known car phase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 5 s clock allowance let the restart credit count a charger sample
taken 5-7 s before the site reading. A car that stopped after that
sample (a fast charger applying the 0 A sent while the meter was stale,
or the car pausing) still counted, the battery ended the discharge the
car's return needed, and L2 reached 21 A on a 16 A fuse when it resumed.

Core now judges a sample by when it first arrived on Core's clock. It
counts as in the reading only when it arrived evMeterDelay (1 s) before
the reading, and no more than one control interval before that. A sample
already there when Core began to watch the charger arrived at a time
Core does not know, so it never counts. The charger's stamp only tells
samples apart, so a charger clock behind Core's no longer matters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At margin 0 the car in s8-60-25 settles at the rating, as the owner's
rule says, and the rig's old 1 A hidden margin failed it: 271 s above
24 A against 67 s allowed, with no second over 25 A. siterig/full has the
same threshold in overage() (s9, s10, s11), the random invariants and
s15bat.

Port #1907's judge hunks: s3, s8, s9-s11, s15bat and the invariants now
count time over the fuse rating. TestS8JudgesAtTheFuse and #1907's
TestInvariantsJudgeAtTheFuse fail on the old judges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(config): fuse safety_margin_a defaults to 0
fix(control): after a restart the battery credits the car the meter shows
fix(control): the car comes before battery charge
fix(control): a battery charge rise fits each phase before it is sent
@frahlg
frahlg merged commit 49f9699 into c-battery-fuse-no-buffer Oct 28, 2026
frahlg added a commit that referenced this pull request Oct 28, 2026
…1913, #1923, #1924, #1925, #1926, #1914)

* fix(control): one log of FTW's own EV offers

The battery side read a 20 s pool log of EV offers (State.evOffers), while
the EV fuse limit keeps each charger's offers for its declared response
time, or 180 s when it declares none. After 20 s the battery counted a slow
charger's unshown raise as already in the meter and charged into that room;
when the car started, a 25 A site read 32.6 A per phase for 3 s.

Delete State.evOffers and its two writers. evInMeterW and
clampEVBatteryHeadroom read evShownW, each charger's latest offer less
what it may not show yet. The hourly peak in stepEVMeterLimit takes each
charger's highest offer of the last evSettle from the same log.

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

* fix(control): battery fuse guard reads each phase with its direction

Delete perPhaseOverageW, perPhaseOverageReadingW and perPhaseReliefW.
The battery fuse guard, the fuse saver and the forced discharge read the
signed phase currents (phaseCurrentsA, with #1849's sum check and dead
zone), count the batteries' own move on each phase, and relieve to the
car's working limit, the fuse less max(1 A, safety_margin_a).

Item A5 of the stable plan.

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

* fix(control): battery waits only for a declared charger response time

The first A6 change gave the battery side the EV limit's 180 s guess
for every charger that declares no response time. Only Easee Cloud
declares one, so on every other charger the battery reserved the car's
whole demand on top of a reading that already showed the car, and
stopped charging for 3 minutes after each car start or raise. On the
#1868 shape (rig s18) battery charge stayed at 0 W until 180 s.

evShownW now uses the charger's declared power_max_age_s, and evSettle
when it declares none, as master did. Easee Cloud declares 180 s, so the
slow-charger case stays fixed.

Tests: the slow-charger test now declares 180 s, and a new plant test
runs the s18 shape with a fast charger that declares nothing.

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

* fix(control): fuse guard reads phases after the hold caps the targets

The guard took the phase relief from the original request, then the 30 s
fuse hold capped that request and the relief came off again. A charge
limited by one phase switched between 1380 W and 0 W on every dispatch.
Predict the aggregate and the phases from the held targets instead.

The phase reader now takes the meter's raw total from OnlineMeterW, the
same reading as its currents, so an offline meter's cached phases drive
no relief. The batteries' raw power is read in one helper. dispatch.go
keeps its 41 cached telemetry reads.

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

* fix(control): car and battery-beside-car work to the fuse rating less safety_margin_a

The EV phase limit was In - 1 A, fixed in code. It is now fuseLineA():
In - safety_margin_a, with no floor. A rise moves one step once it fits
under the line, a start takes all the room, and battery charge beside a
car uses the same line. The hour's import limit keeps its own margins.
Starts and per-phase cuts round down to whole charger amps, since
chargers round offers to the nearest amp.

Step 3 (PR b) of the fuse-at-rating plan (owner rule of 27 Oct).

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

* fix(control): keep covered offers whole and leave battery relief out of EV room

Two review findings on the fuse-line change:

- A cut rounds to whole charger amps only when a new cut is needed. When
  cuts on their way cover the overage, the offer stays as it is. Rounding
  a kept step-table offer took the car 6200 -> 5500 -> 4800 -> 0 W in 3 s
  on a 0.6 A overage while a 180 s cut travelled.
- Dispatch records the discharge the fuse guard adds to each battery, and
  an EV rise or start does not count it as room. Before, the car rose into
  the relief and the battery fed it 650 W at the line for as long as the
  house load stayed.

The noisy-line sim test now pins the slow one-step cycle at an exact fit
(at most 5 cuts an hour, never a stop) and no moves with part of a step
left, instead of allowing 45 moves an hour.

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

* fix(control): battery fuse guard reads each phase with its direction

Delete perPhaseOverageW, perPhaseOverageReadingW and perPhaseReliefW.
The battery fuse guard, the fuse saver and the forced discharge read the
signed phase currents (phaseCurrentsA, with #1849's sum check and dead
zone), count the batteries' own move on each phase, and relieve to the
car's working limit, the fuse less max(1 A, safety_margin_a).

Item A5 of the stable plan.

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

* fix(control): fuse guard reads phases after the hold caps the targets

The guard took the phase relief from the original request, then the 30 s
fuse hold capped that request and the relief came off again. A charge
limited by one phase switched between 1380 W and 0 W on every dispatch.
Predict the aggregate and the phases from the held targets instead.

The phase reader now takes the meter's raw total from OnlineMeterW, the
same reading as its currents, so an offline meter's cached phases drive
no relief. The batteries' raw power is read in one helper. dispatch.go
keeps its 41 cached telemetry reads.

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

* fix(control): battery relieves to the fuse less safety_margin_a

phaseFuseReliefW now reads fuseLineA(), the line the car uses since the
EV change, instead of max(1 A, safety_margin_a). There is no floor under
the margin: at margin 0 the battery works to the fuse rating.

Tests return to the rating where they had moved to the 1 A limit, and a
new property test covers In x phases x margin in both directions.

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

* test(control): re-record golden records at the fuse less safety_margin_a

Replayed from stored inputs; only records whose dispatch changed against
the parent branch were replaced.

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

* fix(control): battery fuse cut lands on the line, not half a margin below

applyFuseGuard cut a battery charge or discharge by the overage plus
half of safety_margin_a. The guard now predicts from the held targets
and keeps its 30 s hold, so the extra cut only parked the phase below
the line the user set. Cut by the overage only.

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

* docs(changeset): name the limit the battery cut lands on

The changeset said the phase lands on the fuse less safety_margin_a.
On #1891 as it stands, the phase line is the fuse less max(1 A,
safety_margin_a), so at the 0.5 A default a 16 A phase settles at
15 A, not 15.5 A. Say what this PR changes, cutting only by the
overage, without naming a line that depends on step 4.

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

* fix(config): fuse safety_margin_a defaults to 0

Core now works to the fuse rating unless a site sets a margin. Every
explicit safety_margin_a is kept. A stored value of exactly 0.5 A, the
old default, adds one load-time notice and changes nothing.

The peak-limit error names the margin only when it is above 0, and the
settings field shows 0 with help that says the margin is opt-in.

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

* docs(changeset): say what the default change moves on its own

The changeset said FTW works to the fuse rating unless a site sets a
margin. On this PR's base every per-phase line still sits 1 A under the
rating; that holds only once #1910 and the step-4 amendment of #1891
land. The changeset now names what this PR changes: the default, and
the whole-site import and export limits.

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

* test(control): relief stays at the line after a PV drop (C3, seed 207533)

On a 16 A fuse with no margin the battery holds 3 kW of discharge with L1
at 14.4 A; PV drops 1280 W and L1 reads 16.3 A. The relief must bring L1
to 16 A and keep it there while the reading sits at the line with the
battery at its relief power. Without phaseFuseReliefW's own-move count
the relief flips between -3214 W and -3005 W on every tick and L1 crosses
the rating 13 times; with it, L1 crosses once.

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

* test(control): phase relief reads each battery live, in the decision's reading

The merge of #1876 moved stopPhaseReliefW's live reads into
phaseSample.moveW. Pin it: a battery that went offline, moved on to a
newer reading or has no reading gives no phase relief. With the cached
RawW read #1891 had, the offline and newer-reading cases relieve 2310 W
and the no-reading case counts a 2000 W charge from 0 W.

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

* fix(control): battery relief holds when the battery's reading lags the meter

The phase relief counts the battery's move from its own reading. When
that reading lags the meter by a tick, the meter already shows the relief
while the reading does not, so the relief came off; on the next tick it
went back on top of the reading's catch-up. In the 207533 shape (16 A,
margin 0) with a one-tick lag the order ran -3214, -3005, -3422, -3005,
... -5717 W and L1 went back to 16.3 A on every other tick: 13 crossings.

A move from the battery's last command (the one it got in the last
dispatch) may now not pass the room the meter shows either. That bound
only tightens, and a phase already over the line gives it no room, so it
never asks for more than to stay at the last command: a battery that
falls short of its order does not wind it up. With lag 1 or 2 the order
holds -3214 W with L1 at 16.0 A and one crossing, on the PV step.

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

* fix(control): a landed EV cut covers nothing later; a raise that no longer fits comes off

The site meter now tells Core how much of a charger's offers it already
shows. A move shaped like an order landing (every phase alike, or one
phase alone for a one-phase car) moves that level toward the latest
offer. A cut the meter shows no longer covers an overage that an outside
step causes after it, and no longer nets against a raise sent after it.
Where a landing and a house step fall in one reading, a cut covers a
phase that has risen since only up to the overage it had then.

C4: our own rise on its way, as the meter tells it, is judged as the
reading it will make. Above 1.2 x In, or over the line on a phase whose
heat budget is spent, it comes off on this reading, down to the line.

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

* fix(control): a one-phase house move is not the car's cut landing

seeOffers kept one shown level for every phase, so a house load moving on
one phase alone read as a three-phase cut landing and took the cut's cover
off the phase it was sent for. With a 40 s charger Core then stacked a
second cut and left L1 2 A under the line for over 2 min. The shown level
is now kept per phase: a three-phase order moves every phase alike, and a
phase that moves alone moves only its own level.

The loadpoint now reports a car it has seen draw on all three phases as
CarPhases 3. For such a car a one-phase move is never the car, so a house
step on one phase while a raise travels no longer hides the raise, and C4
cuts on that reading.

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

* fix(control): a battery charge rise fits each phase before it is sent

The battery fuse guard judged a rise from the battery's own reading and
the meter's phases, so it took a rise the meter did not show yet as
already shown. With the meter a poll behind the battery, a PV surplus that
grew each tick stacked rises past the line, then the relief cut them back:
16.4 A on a 16 A fuse and a charge that rose and fell (C2, seeds 207530,
207535).

fitBatteryRisesToFuse caps charge above the battery's latest order to
(fuseLineA - worst phase - own unshown rises) x V x phases. The unshown
rises are the battery's orders of the last evSettle and the chargers'
raises, counted as evShownW counts them, so the car comes first. Holding
an order is never a rise and the check never cuts; an overage stays with
the relief. No margin and no floor: the order ends at the line.

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

* fix(control): battery rise check counts slow batteries and direction-less phases

A battery order now stays unshown until evSettle after the battery's own
reading reaches it, so a battery that ramps slower than evSettle no longer
gets a second rise on top of an order the meter cannot show yet.

On a meter with phase currents but no phase power while the total
exports, a phase counts as export only when the other phases cannot
carry the export alone; any other phase counts as import. Before, every
phase flipped to export, so a three-phase charge could rise into a phase
that imports beside two exporting ones.

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

* fix(control): the car comes before battery charge

With battery_covers_ev off, dispatch took the car out of the meter
reading before the battery decided. The battery then saw the PV the
car was using as export and charged from it while the site imported
for the car (rig seeds 207535, 207539, 207530: about 10 kW import).

The battery now charges only from the surplus the meter shows after
the car. Where the house alone has surplus, the battery sees the meter
with the car included, and any discharge stops at once, since it would
feed the car. A planned PV charge on the energy path takes only the
surplus the car leaves; a planned grid purchase keeps its charge.
Cover off still never discharges the battery for the car.

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

* fix(control): peak shaving drops a running charge for the car

Manual peak_shaving gave zero error for any import under the peak
limit, so a battery charge that was running when the car started held
while the site imported for the car, with battery_covers_ev on or off.
When the site imports while the car and the battery both charge, the
charge now comes down by the import, to 0 at most. Under the peak limit
the battery still never discharges.

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

* fix(control): after a restart the battery credits the car the meter shows

After a restart, the 0 A orders FTW sent while the meter was stale counted
as the EV base the meter shows. With the car still drawing 8.2 kW from
before, a new 6.2 kW order then counted as a 9 A rise to come, and the
battery was held in discharge for the car (seed 207532).

Until a charger's first order of this run can have landed, the battery
side now counts the charger's live measured draw as in the reading, never
above the latest order and never above the consumption the site meter
shows. The car's own limit does not read it.

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

* fix(control): credit a found car only from a current charger sample

Review of #1926 found two ways the restart credit let the battery end a
discharge into room the car's pending rise still needs:

- The charger reading counted with no check that it was current. A held
  reading after a failed poll, or a sample older than the site reading,
  could show a car that a fast charger had already stopped. The draw now
  counts only when the last poll succeeded and the charger measured it
  within one control interval of the site reading.
- The consumption bound subtracted the smoothed battery, PV and V2X
  values, which lag an output that just fell. It now takes the higher of
  raw and smoothed for each, so the bound stays at or below what the
  meter shows.

New tests pin both, plus the shown-offer subtractions and a window that
outlasts evSettle.

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

* test(control): run the site sim at the new 0 A default margin

simFuseSafetyA follows the config default, now 0. fitScenario takes the
room under the line and sets the house from simFuseSafetyA, so its exact
fit stays twelve whole steps at any margin. "charger's own load
balancer" passes at margin 0, so its known line goes.

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

* docs(changeset): say what margin 0 moves once the fuse stack lands

With the stack under it, the default moves the car's and the battery's
per-phase line as well as the site totals. 0.5 A stops being the
default, so #1910's changeset no longer calls it that.

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

* test(control): keep the load-balancer bug as a known line above the line

At the 0 A default the scenario passes only because the charger's own
balancer sits at 25 A, the rating and so the line. The bug is still in
Core: a cut comes off an offer the car does not draw. Two known variants
keep it tracked and turn red once it is fixed: the stored 0.5 A margin
(over 24.5 A for more than 11 s) and a balancer at 25.5 A at margin 0
(over 25 A for more than 11 s).

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

* fix(control): EV room reads battery relief through live telemetry

evFuseReliefW read each battery's cached RawW, which survives a driver
going offline. It now reads Store.OnlineRawW. A battery with no live
reading counts its whole recorded relief, so the car never builds on
discharge Core can no longer hold.

fuseSaverEarlyExit read each battery's SmoothedW a second time to record
relief. It now records against the live snapshot the fuse-saver decision
already took (fleetAtLivePower), so it adds no read of its own.

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

* fix(control): an offline battery's fuse relief ends after evSettle

With no battery online, ComputeDispatch returns before it records relief,
so batteryFuseRelief kept its last value and evFuseReliefW counted all of
it for as long as the battery stayed offline. The car was held up to the
whole relief below the line. The relief now counts in full until the
default mode Core sent at the transition can have reached the meter
(evSettle), then ends.

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

* fix(control): each offline battery's fuse relief ends after evSettle

The expiry in 8150b45 ran only when no battery was online. With a
second battery online, an offline battery's relief stayed until a
recorded dispatch rewrote the map: never while the legacy modes sat in
the deadband, and on the first tick when one did record, so the car
could rise onto discharge Core ordered a second earlier. Each offline
battery now has its own clock: its relief counts whole for evSettle
and then ends, on every dispatch path. A battery back online leaves the
clock, so its next offline period counts whole again; a test now covers
that reset.

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

* fix(control): battery phase relief neither pins mixed phases nor winds up

The last-command bound held the battery at its last command when one
phase sat at the import line and another was over the export line: the
guard cut the discharge for the exporting phase and the bound put it
back. Now the bound gives way on one side while any phase is over the
other side's line, and phaseFuseReliefW's least-over move decides.

When the meter does not show the battery's move (a frozen meter, or a
worst phase a one-phase battery cannot reach), each tick added the same
overage again and the order ran out to the discharge limit. Now the
relief toward the worst phase stops at the last command once the
batteries' raw power has moved one ampere per phase since the worst
phase last moved by half an ampere. One ampere is the step in which P1
meters report phase current.

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

* fix(control): battery phase anti-windup stops only when the meter cannot show the move

phaseUnresponsive stopped the relief whenever the batteries had moved
690 W and the worst phase had not moved half an ampere. On a live meter
that also happens when a load cancels the move (a balanced load starting
with the relief step, or a slow rise on the worst phase), and when the
battery's reading catches up with an order the meter already showed.
The relief then held over the line with no end.

Now it stops only when the meter repeats its phases exactly while the
batteries moved one ampere per phase, or when another phase moved by
twice the balanced share of that move (a battery off the worst phase).
The repeat is measured from the reading the meter last changed in, so a
meter that freezes on any reading is still caught.

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

* fix(control): battery off the worst phase goes back, and a stale reference ends

Fourth review of the phase anti-windup (c066892):

- The worst phase's reference had no end while that phase sat over the
  line. A relief that follows a slow drift keeps it there, so the
  reference stayed while the battery moved hundreds of watts, and a load
  that later switched off on another phase read as a battery off the
  worst phase. The relief then held L1 up to 0.49 A over the line with no
  time limit. The reference now also ends when the worst phase is at or
  inside the line, or when another phase becomes the worst.
- An off-phase verdict no longer holds the last command. The batteries go
  back to their power when the worst phase last moved: what they moved
  since did not reach it. A one-phase battery no longer holds its own
  phase at -17.97 A for the whole overage (and -19.83 A, past 1.2 x In,
  when PV returns). A false verdict on a balanced battery moves the
  worst phase by at least one ampere on the next reading, which ends the
  reference, so it costs one tick instead of an open-ended hold. The
  verdict holds until the reference ends, so the relief does not go out
  again once the battery is back.
- The frozen-meter test now also compares the meter's total. A P1 meter
  reports whole amperes, so a live meter can repeat its phases while its
  total moves; that latched the relief at 1.11 x In.

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

* test(control): the drift test also wants no verdict on the load change

With L1 at the line ending the reference, no off-phase verdict comes when
the load on L3 switches off. Without it, the step back still ends the
hold, but with L1 at 16.8 and 17.7 A for two ticks; the test now sees
that.

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

* test(control): our battery order's move is not the car's cut landing

The guard in seeOffers that stops reading the meter while our battery
order moves had no test; removing it left every test green. A battery
order that lands and goes back while a cut travels to a 40 s charger
now must not lead to a second cut.

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

* test(control): the car-first discharge stop has its own test

Removing the PI path's immediate discharge stop left every named test
green; only the golden replay caught it. A peak_shaving loop with
battery_covers_ev off, starting from a 2.9 kW discharge while the house
alone has surplus, now requires that no order discharges into the car.

The comment on the plan back-off said the plan's grid leaves out the car.
It counts the car it planned; the comment now says so.

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

* fix(control): the battery rise check reads only live battery power

fitBatteryRisesToFuse, unshownBatteryW and recordBatteryReadings read
the battery's cached RawW, which survives the driver going offline, and
added three direct telemetry reads that the repo-hygiene check refuses.
They now use Store.OnlineRawW, as phaseSample.moveW does: an offline
battery's last value no longer sets the rise base or the shown level.

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

* fix(control): judge a raise's landing from the meter's rise

C4 counted a raise as landed only when a reading-to-reading move had a
charger's shape. A slow ramp, or a house load on another phase that moved
in the landing reading, left the whole raise counted as on its way on top
of a reading that already held it, and Core cut a raise that fit under
the line. The rise still to come now reads the meter's own rise since the
raise went out: the smallest across the phases for a three-phase order,
the car's phase for a one-phase car.

For a three-phase order, a move on one phase alone no longer moves the
shown level of a car not yet seen. A load cycling on the phase over the
line took the cut's cover off on each drop and never gave it back, so
Core cut again and again.

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

* fix(control): a car not yet seen may take a raise on one phase

The smallest rise across the phases read a one-phase car not yet seen
as a raise that had not landed: its phase rose, the other two did not.
Core then cut the car from 11040 W to 0 with every phase under the line
(field matrix, car1p cases). One phase that has risen alone since the
raise went out, the others within half a step, now counts as that car
taking the raise, as the shape tracker read it before. The changeset
again says what holds: the one-phase house step case is closed for a
car seen on three phases.

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

* fix(control): judge a one-phase landing reading by reading

For a car not yet seen on three phases, a raise counted as landed on one
phase only while the other phases stayed within 0.5 A of their reading
when the raise went out. That window spans the charger's whole response
time, so 0.6 A of house drift on another phase made the full raise count
as still to come on top of a reading that already showed it, and Core
cut the car with every phase under the line (field matrix with 0.7 A on
L2: car1p cut 11100 -> 0 W).

Each phase now keeps its rise over the readings in which the other
phases moved less than a step (aloneA). A rise of a step or more there
may be the car on that phase. Drift between readings, or under a step
in the landing reading, no longer hides it; a house load that moves a
step or more on another phase in the same reading still does.

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

* fix(control): restart credit keeps room for the car's rise to its max

After a restart Core does not know the offer in force on the charger, so
a car drawing below it may still rise before Core's order lands. The
credit now counts only the draw less that possible rise past the latest
offer, with the rise measured to the loadpoint's max_charge_w; a charger
with no max_charge_w gets no credit.

The charger and output reads move into health-gated telemetry accessors
(OnlineLowerW, SumOnlineHigherW). Tests cover the raw and smoothed
guards, a 10 s OCPP sample cadence on the plant, and the review's car
rising under a 16 A offer in force.

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

* test(control): Charge now landing shown before Core logs its offer

Site rig s1-16, s1-25 and s15-25 failed on c9b52c3: the charger answered
within a second, and the meter reading that showed the car at 15.9 A came
in 7 ms before the tick that first logged the 16 A offer. The reading
could not show an offer logged after it, so the landing counted as a
raise still to come, 15.9 + 16 A passed 1.2 x In, and Core cut 11,040 W
to 5,520 W with 9 A of room on every phase. The same run of the base at
281f9f6, without this branch's commits, fails the same way; #1923's
ab02710, merged here in c0a5274, reads the meter's own rise and fixes
it. This test pins that rig shape.

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

* fix(control): restart credit counts no sample newer than the meter

The battery side's restart credit (evFoundDrawW) had two gaps.

A charger sample measured after the site reading counted as part of
it. A car that rose in that gap was credited for draw the meter did
not show yet, and the battery ended its discharge into room the rise
had taken. With a 3 s meter, L2 reached 19.30 A. A sample now counts
only when measured at or before the site reading.

The rise reserve took max_charge_w as the most the unknown offer in
force can be. A charger still on an offer from before a restart that
lowered max_charge_w, or on its own higher default, could rise past
it: 19.00 A per phase on the unit fixture, 19.30 A on the plant. The
reserve now runs to the larger of max_charge_w and the site fuse's
rating.

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

* fix(control): restart credit holds against a charger clock behind Core's

The restart credit compared a charger's sample time, stamped by the
charger's own clock (OCPP MeterValues), with the time the site reading
arrived on Core's clock. A charger clock 1 s or more behind Core's let
a rise sampled after the reading count as already in it, and L2 reached
19.30 A on the third review's plant case.

A sample now counts only when stamped evClockAllowance (5 s) before the
reading arrived, and the draw is the lowest of that sample and every
sample since. noteDraw keeps the charger's samples while the restart
window is open.

Also tests the max_charge_w half of the rise cap: a 22 kW charger on a
3 x 25 A fuse keeps the reserve up to max_charge_w.

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

* docs(control): the fuse margin is opt-in, and only the fuse ceilings move

The SiteFuseSafetyA comment read as if the margin were needed by
default; it now says zero is the default and when to set one. The
changeset named "the site's total import and export limits", which
reads as the hourly import_limit; that limit does not move. It now
names the fuse's import and export ceilings.

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

* test(control): battery beside a minimum-step car holds the fuse line (rig s18-16)

On #1910 and #1891 the battery rises beside the car to the working limit,
the fuse guard cuts it by the overage plus half of safety_margin_a and holds
30 s, and the battery rises again: it hunts between 15.0 and 15.5 A on a
16 A site. Where it stands when the house moves is chance, which is why
rig s18-16 failed 3 of 57 runs on those heads. #1913's cut by the overage
only keeps it on the line.

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

* test(config): an unset fuse margin reaches Core as 0

A mutation that restored a hidden 0.5 A margin at startup passed every
suite. This test pins the value newControlStateFromConfig hands Core.

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

* test(site): s18 judges own commands apart from the house's step

At margin 0 Core holds the phases at the 16 A line. Rig s18-16's 0.1 A
house step at small_step then lands over the line until Core cuts: on
fdfbfec the phase read 16.101 A for 3 s and Core cut the battery
4851 -> 4719 W within 2 s. The criterion "own commands stay inside the
rated fuse" counted that stretch as Core's and failed. The owner's rule
judges an overage between In and 1.2 x In by the I2t model.

The criterion now skips the 10 s after the step, the same gap the minor
window already leaves. On the fdfbfec record Core's own commands peak
at 16.008 A before the step and 16.005 A after it, inside 16.01 A.

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

* fix(control): no cut of an unseen car, no landing from a 1p house move

Two regressions against #1913 from the final review.

A car the loadpoint has not seen on three phases may draw on one phase
Core does not know. The alone rise (aloneA) missed its landing when any
other phase moved a step in the same reading, so the whole raise counted
as still to come and C4 cut a car with every phase under the line, at
most session starts with a 10 s meter. riseAheadA now takes the largest
rise for such a car, as for a one-phase car whose phase is not known,
and the smallest only once the car is seen on three phases.

A one-phase order never knows the car's phase, so a house load moving
alone on any phase read as its cut landing and took the cover off a cut
still on its way. A one-phase move now counts only on a known car phase.

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

* fix(control): judge a restart sample by its arrival, not its stamp

The 5 s clock allowance let the restart credit count a charger sample
taken 5-7 s before the site reading. A car that stopped after that
sample (a fast charger applying the 0 A sent while the meter was stale,
or the car pausing) still counted, the battery ended the discharge the
car's return needed, and L2 reached 21 A on a 16 A fuse when it resumed.

Core now judges a sample by when it first arrived on Core's clock. It
counts as in the reading only when it arrived evMeterDelay (1 s) before
the reading, and no more than one control interval before that. A sample
already there when Core began to watch the charger arrived at a time
Core does not know, so it never counts. The charger's stamp only tells
samples apart, so a charger clock behind Core's no longer matters.

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

* test(site): judge overages at the fuse rating, not fuse - 1 A

At margin 0 the car in s8-60-25 settles at the rating, as the owner's
rule says, and the rig's old 1 A hidden margin failed it: 271 s above
24 A against 67 s allowed, with no second over 25 A. siterig/full has the
same threshold in overage() (s9, s10, s11), the random invariants and
s15bat.

Port #1907's judge hunks: s3, s8, s9-s11, s15bat and the invariants now
count time over the fuse rating. TestS8JudgesAtTheFuse and #1907's
TestInvariantsJudgeAtTheFuse fail on the old judges.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 28, 2026
…mit (#1919)

* fix(control): one log of FTW's own EV offers

The battery side read a 20 s pool log of EV offers (State.evOffers), while
the EV fuse limit keeps each charger's offers for its declared response
time, or 180 s when it declares none. After 20 s the battery counted a slow
charger's unshown raise as already in the meter and charged into that room;
when the car started, a 25 A site read 32.6 A per phase for 3 s.

Delete State.evOffers and its two writers. evInMeterW and
clampEVBatteryHeadroom read evShownW, each charger's latest offer less
what it may not show yet. The hourly peak in stepEVMeterLimit takes each
charger's highest offer of the last evSettle from the same log.

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

* fix(control): battery fuse guard reads each phase with its direction

Delete perPhaseOverageW, perPhaseOverageReadingW and perPhaseReliefW.
The battery fuse guard, the fuse saver and the forced discharge read the
signed phase currents (phaseCurrentsA, with #1849's sum check and dead
zone), count the batteries' own move on each phase, and relieve to the
car's working limit, the fuse less max(1 A, safety_margin_a).

Item A5 of the stable plan.

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

* fix(control): battery waits only for a declared charger response time

The first A6 change gave the battery side the EV limit's 180 s guess
for every charger that declares no response time. Only Easee Cloud
declares one, so on every other charger the battery reserved the car's
whole demand on top of a reading that already showed the car, and
stopped charging for 3 minutes after each car start or raise. On the
#1868 shape (rig s18) battery charge stayed at 0 W until 180 s.

evShownW now uses the charger's declared power_max_age_s, and evSettle
when it declares none, as master did. Easee Cloud declares 180 s, so the
slow-charger case stays fixed.

Tests: the slow-charger test now declares 180 s, and a new plant test
runs the s18 shape with a fast charger that declares nothing.

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

* fix(control): fuse guard reads phases after the hold caps the targets

The guard took the phase relief from the original request, then the 30 s
fuse hold capped that request and the relief came off again. A charge
limited by one phase switched between 1380 W and 0 W on every dispatch.
Predict the aggregate and the phases from the held targets instead.

The phase reader now takes the meter's raw total from OnlineMeterW, the
same reading as its currents, so an offline meter's cached phases drive
no relief. The batteries' raw power is read in one helper. dispatch.go
keeps its 41 cached telemetry reads.

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

* fix(control): car and battery-beside-car work to the fuse rating less safety_margin_a

The EV phase limit was In - 1 A, fixed in code. It is now fuseLineA():
In - safety_margin_a, with no floor. A rise moves one step once it fits
under the line, a start takes all the room, and battery charge beside a
car uses the same line. The hour's import limit keeps its own margins.
Starts and per-phase cuts round down to whole charger amps, since
chargers round offers to the nearest amp.

Step 3 (PR b) of the fuse-at-rating plan (owner rule of 27 Oct).

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

* fix(control): keep covered offers whole and leave battery relief out of EV room

Two review findings on the fuse-line change:

- A cut rounds to whole charger amps only when a new cut is needed. When
  cuts on their way cover the overage, the offer stays as it is. Rounding
  a kept step-table offer took the car 6200 -> 5500 -> 4800 -> 0 W in 3 s
  on a 0.6 A overage while a 180 s cut travelled.
- Dispatch records the discharge the fuse guard adds to each battery, and
  an EV rise or start does not count it as room. Before, the car rose into
  the relief and the battery fed it 650 W at the line for as long as the
  house load stayed.

The noisy-line sim test now pins the slow one-step cycle at an exact fit
(at most 5 cuts an hour, never a stop) and no moves with part of a step
left, instead of allowing 45 moves an hour.

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

* fix(control): battery fuse guard reads each phase with its direction

Delete perPhaseOverageW, perPhaseOverageReadingW and perPhaseReliefW.
The battery fuse guard, the fuse saver and the forced discharge read the
signed phase currents (phaseCurrentsA, with #1849's sum check and dead
zone), count the batteries' own move on each phase, and relieve to the
car's working limit, the fuse less max(1 A, safety_margin_a).

Item A5 of the stable plan.

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

* fix(control): fuse guard reads phases after the hold caps the targets

The guard took the phase relief from the original request, then the 30 s
fuse hold capped that request and the relief came off again. A charge
limited by one phase switched between 1380 W and 0 W on every dispatch.
Predict the aggregate and the phases from the held targets instead.

The phase reader now takes the meter's raw total from OnlineMeterW, the
same reading as its currents, so an offline meter's cached phases drive
no relief. The batteries' raw power is read in one helper. dispatch.go
keeps its 41 cached telemetry reads.

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

* fix(control): battery relieves to the fuse less safety_margin_a

phaseFuseReliefW now reads fuseLineA(), the line the car uses since the
EV change, instead of max(1 A, safety_margin_a). There is no floor under
the margin: at margin 0 the battery works to the fuse rating.

Tests return to the rating where they had moved to the 1 A limit, and a
new property test covers In x phases x margin in both directions.

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

* test(control): re-record golden records at the fuse less safety_margin_a

Replayed from stored inputs; only records whose dispatch changed against
the parent branch were replaced.

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

* fix(control): battery fuse cut lands on the line, not half a margin below

applyFuseGuard cut a battery charge or discharge by the overage plus
half of safety_margin_a. The guard now predicts from the held targets
and keeps its 30 s hold, so the extra cut only parked the phase below
the line the user set. Cut by the overage only.

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

* docs(changeset): name the limit the battery cut lands on

The changeset said the phase lands on the fuse less safety_margin_a.
On #1891 as it stands, the phase line is the fuse less max(1 A,
safety_margin_a), so at the 0.5 A default a 16 A phase settles at
15 A, not 15.5 A. Say what this PR changes, cutting only by the
overage, without naming a line that depends on step 4.

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

* fix(config): fuse safety_margin_a defaults to 0

Core now works to the fuse rating unless a site sets a margin. Every
explicit safety_margin_a is kept. A stored value of exactly 0.5 A, the
old default, adds one load-time notice and changes nothing.

The peak-limit error names the margin only when it is above 0, and the
settings field shows 0 with help that says the margin is opt-in.

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

* docs(changeset): say what the default change moves on its own

The changeset said FTW works to the fuse rating unless a site sets a
margin. On this PR's base every per-phase line still sits 1 A under the
rating; that holds only once #1910 and the step-4 amendment of #1891
land. The changeset now names what this PR changes: the default, and
the whole-site import and export limits.

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

* fix(control): a restart seeds the fuse heat at most at the working limit

After a Core restart the I² heat model started at the first reading's
current, as if that overload had lasted. In seed 207539 one 27.94 A
reading on a 20 A fuse held an idle car back about 43 s while the model
cooled. Seed each phase at its first reading, at most the working
limit. A sustained overload after a restart is still cut on the next
reading; a cold start would have given it a fresh budget of over a
minute. The instant cut above 1.2 x In is unchanged.

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

* test(control): relief stays at the line after a PV drop (C3, seed 207533)

On a 16 A fuse with no margin the battery holds 3 kW of discharge with L1
at 14.4 A; PV drops 1280 W and L1 reads 16.3 A. The relief must bring L1
to 16 A and keep it there while the reading sits at the line with the
battery at its relief power. Without phaseFuseReliefW's own-move count
the relief flips between -3214 W and -3005 W on every tick and L1 crosses
the rating 13 times; with it, L1 crosses once.

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

* test(control): phase relief reads each battery live, in the decision's reading

The merge of #1876 moved stopPhaseReliefW's live reads into
phaseSample.moveW. Pin it: a battery that went offline, moved on to a
newer reading or has no reading gives no phase relief. With the cached
RawW read #1891 had, the offline and newer-reading cases relieve 2310 W
and the no-reading case counts a 2000 W charge from 0 W.

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

* fix(control): battery relief holds when the battery's reading lags the meter

The phase relief counts the battery's move from its own reading. When
that reading lags the meter by a tick, the meter already shows the relief
while the reading does not, so the relief came off; on the next tick it
went back on top of the reading's catch-up. In the 207533 shape (16 A,
margin 0) with a one-tick lag the order ran -3214, -3005, -3422, -3005,
... -5717 W and L1 went back to 16.3 A on every other tick: 13 crossings.

A move from the battery's last command (the one it got in the last
dispatch) may now not pass the room the meter shows either. That bound
only tightens, and a phase already over the line gives it no room, so it
never asks for more than to stay at the last command: a battery that
falls short of its order does not wind it up. With lag 1 or 2 the order
holds -3214 W with L1 at 16.0 A and one crossing, on the PV step.

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

* fix(control): a landed EV cut covers nothing later; a raise that no longer fits comes off

The site meter now tells Core how much of a charger's offers it already
shows. A move shaped like an order landing (every phase alike, or one
phase alone for a one-phase car) moves that level toward the latest
offer. A cut the meter shows no longer covers an overage that an outside
step causes after it, and no longer nets against a raise sent after it.
Where a landing and a house step fall in one reading, a cut covers a
phase that has risen since only up to the overage it had then.

C4: our own rise on its way, as the meter tells it, is judged as the
reading it will make. Above 1.2 x In, or over the line on a phase whose
heat budget is spent, it comes off on this reading, down to the line.

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

* fix(control): a one-phase house move is not the car's cut landing

seeOffers kept one shown level for every phase, so a house load moving on
one phase alone read as a three-phase cut landing and took the cut's cover
off the phase it was sent for. With a 40 s charger Core then stacked a
second cut and left L1 2 A under the line for over 2 min. The shown level
is now kept per phase: a three-phase order moves every phase alike, and a
phase that moves alone moves only its own level.

The loadpoint now reports a car it has seen draw on all three phases as
CarPhases 3. For such a car a one-phase move is never the car, so a house
step on one phase while a raise travels no longer hides the raise, and C4
cuts on that reading.

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

* fix(control): a battery charge rise fits each phase before it is sent

The battery fuse guard judged a rise from the battery's own reading and
the meter's phases, so it took a rise the meter did not show yet as
already shown. With the meter a poll behind the battery, a PV surplus that
grew each tick stacked rises past the line, then the relief cut them back:
16.4 A on a 16 A fuse and a charge that rose and fell (C2, seeds 207530,
207535).

fitBatteryRisesToFuse caps charge above the battery's latest order to
(fuseLineA - worst phase - own unshown rises) x V x phases. The unshown
rises are the battery's orders of the last evSettle and the chargers'
raises, counted as evShownW counts them, so the car comes first. Holding
an order is never a rise and the check never cuts; an overage stays with
the relief. No margin and no floor: the order ends at the line.

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

* fix(control): battery rise check counts slow batteries and direction-less phases

A battery order now stays unshown until evSettle after the battery's own
reading reaches it, so a battery that ramps slower than evSettle no longer
gets a second rise on top of an order the meter cannot show yet.

On a meter with phase currents but no phase power while the total
exports, a phase counts as export only when the other phases cannot
carry the export alone; any other phase counts as import. Before, every
phase flipped to export, so a three-phase charge could rise into a phase
that imports beside two exporting ones.

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

* fix(control): the car comes before battery charge

With battery_covers_ev off, dispatch took the car out of the meter
reading before the battery decided. The battery then saw the PV the
car was using as export and charged from it while the site imported
for the car (rig seeds 207535, 207539, 207530: about 10 kW import).

The battery now charges only from the surplus the meter shows after
the car. Where the house alone has surplus, the battery sees the meter
with the car included, and any discharge stops at once, since it would
feed the car. A planned PV charge on the energy path takes only the
surplus the car leaves; a planned grid purchase keeps its charge.
Cover off still never discharges the battery for the car.

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

* fix(control): peak shaving drops a running charge for the car

Manual peak_shaving gave zero error for any import under the peak
limit, so a battery charge that was running when the car started held
while the site imported for the car, with battery_covers_ev on or off.
When the site imports while the car and the battery both charge, the
charge now comes down by the import, to 0 at most. Under the peak limit
the battery still never discharges.

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

* fix(control): after a restart the battery credits the car the meter shows

After a restart, the 0 A orders FTW sent while the meter was stale counted
as the EV base the meter shows. With the car still drawing 8.2 kW from
before, a new 6.2 kW order then counted as a 9 A rise to come, and the
battery was held in discharge for the car (seed 207532).

Until a charger's first order of this run can have landed, the battery
side now counts the charger's live measured draw as in the reading, never
above the latest order and never above the consumption the site meter
shows. The car's own limit does not read it.

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

* fix(control): credit a found car only from a current charger sample

Review of #1926 found two ways the restart credit let the battery end a
discharge into room the car's pending rise still needs:

- The charger reading counted with no check that it was current. A held
  reading after a failed poll, or a sample older than the site reading,
  could show a car that a fast charger had already stopped. The draw now
  counts only when the last poll succeeded and the charger measured it
  within one control interval of the site reading.
- The consumption bound subtracted the smoothed battery, PV and V2X
  values, which lag an output that just fell. It now takes the higher of
  raw and smoothed for each, so the bound stays at or below what the
  meter shows.

New tests pin both, plus the shown-offer subtractions and a window that
outlasts evSettle.

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

* test(control): run the site sim at the new 0 A default margin

simFuseSafetyA follows the config default, now 0. fitScenario takes the
room under the line and sets the house from simFuseSafetyA, so its exact
fit stays twelve whole steps at any margin. "charger's own load
balancer" passes at margin 0, so its known line goes.

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

* docs(changeset): say what margin 0 moves once the fuse stack lands

With the stack under it, the default moves the car's and the battery's
per-phase line as well as the site totals. 0.5 A stops being the
default, so #1910's changeset no longer calls it that.

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

* test(control): keep the load-balancer bug as a known line above the line

At the 0 A default the scenario passes only because the charger's own
balancer sits at 25 A, the rating and so the line. The bug is still in
Core: a cut comes off an offer the car does not draw. Two known variants
keep it tracked and turn red once it is fixed: the stored 0.5 A margin
(over 24.5 A for more than 11 s) and a balancer at 25.5 A at margin 0
(over 25 A for more than 11 s).

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

* fix(control): EV room reads battery relief through live telemetry

evFuseReliefW read each battery's cached RawW, which survives a driver
going offline. It now reads Store.OnlineRawW. A battery with no live
reading counts its whole recorded relief, so the car never builds on
discharge Core can no longer hold.

fuseSaverEarlyExit read each battery's SmoothedW a second time to record
relief. It now records against the live snapshot the fuse-saver decision
already took (fleetAtLivePower), so it adds no read of its own.

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

* fix(control): an offline battery's fuse relief ends after evSettle

With no battery online, ComputeDispatch returns before it records relief,
so batteryFuseRelief kept its last value and evFuseReliefW counted all of
it for as long as the battery stayed offline. The car was held up to the
whole relief below the line. The relief now counts in full until the
default mode Core sent at the transition can have reached the meter
(evSettle), then ends.

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

* fix(control): each offline battery's fuse relief ends after evSettle

The expiry in 8150b45 ran only when no battery was online. With a
second battery online, an offline battery's relief stayed until a
recorded dispatch rewrote the map: never while the legacy modes sat in
the deadband, and on the first tick when one did record, so the car
could rise onto discharge Core ordered a second earlier. Each offline
battery now has its own clock: its relief counts whole for evSettle
and then ends, on every dispatch path. A battery back online leaves the
clock, so its next offline period counts whole again; a test now covers
that reset.

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

* fix(control): battery phase relief neither pins mixed phases nor winds up

The last-command bound held the battery at its last command when one
phase sat at the import line and another was over the export line: the
guard cut the discharge for the exporting phase and the bound put it
back. Now the bound gives way on one side while any phase is over the
other side's line, and phaseFuseReliefW's least-over move decides.

When the meter does not show the battery's move (a frozen meter, or a
worst phase a one-phase battery cannot reach), each tick added the same
overage again and the order ran out to the discharge limit. Now the
relief toward the worst phase stops at the last command once the
batteries' raw power has moved one ampere per phase since the worst
phase last moved by half an ampere. One ampere is the step in which P1
meters report phase current.

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

* fix(control): battery phase anti-windup stops only when the meter cannot show the move

phaseUnresponsive stopped the relief whenever the batteries had moved
690 W and the worst phase had not moved half an ampere. On a live meter
that also happens when a load cancels the move (a balanced load starting
with the relief step, or a slow rise on the worst phase), and when the
battery's reading catches up with an order the meter already showed.
The relief then held over the line with no end.

Now it stops only when the meter repeats its phases exactly while the
batteries moved one ampere per phase, or when another phase moved by
twice the balanced share of that move (a battery off the worst phase).
The repeat is measured from the reading the meter last changed in, so a
meter that freezes on any reading is still caught.

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

* fix(control): battery off the worst phase goes back, and a stale reference ends

Fourth review of the phase anti-windup (c066892):

- The worst phase's reference had no end while that phase sat over the
  line. A relief that follows a slow drift keeps it there, so the
  reference stayed while the battery moved hundreds of watts, and a load
  that later switched off on another phase read as a battery off the
  worst phase. The relief then held L1 up to 0.49 A over the line with no
  time limit. The reference now also ends when the worst phase is at or
  inside the line, or when another phase becomes the worst.
- An off-phase verdict no longer holds the last command. The batteries go
  back to their power when the worst phase last moved: what they moved
  since did not reach it. A one-phase battery no longer holds its own
  phase at -17.97 A for the whole overage (and -19.83 A, past 1.2 x In,
  when PV returns). A false verdict on a balanced battery moves the
  worst phase by at least one ampere on the next reading, which ends the
  reference, so it costs one tick instead of an open-ended hold. The
  verdict holds until the reference ends, so the relief does not go out
  again once the battery is back.
- The frozen-meter test now also compares the meter's total. A P1 meter
  reports whole amperes, so a live meter can repeat its phases while its
  total moves; that latched the relief at 1.11 x In.

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

* test(control): the drift test also wants no verdict on the load change

With L1 at the line ending the reference, no off-phase verdict comes when
the load on L3 switches off. Without it, the step back still ends the
hold, but with L1 at 16.8 and 17.7 A for two ticks; the test now sees
that.

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

* test(control): our battery order's move is not the car's cut landing

The guard in seeOffers that stops reading the meter while our battery
order moves had no test; removing it left every test green. A battery
order that lands and goes back while a cut travels to a 40 s charger
now must not lead to a second cut.

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

* test(control): the car-first discharge stop has its own test

Removing the PI path's immediate discharge stop left every named test
green; only the golden replay caught it. A peak_shaving loop with
battery_covers_ev off, starting from a 2.9 kW discharge while the house
alone has surplus, now requires that no order discharges into the car.

The comment on the plan back-off said the plan's grid leaves out the car.
It counts the car it planned; the comment now says so.

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

* fix(control): the battery rise check reads only live battery power

fitBatteryRisesToFuse, unshownBatteryW and recordBatteryReadings read
the battery's cached RawW, which survives the driver going offline, and
added three direct telemetry reads that the repo-hygiene check refuses.
They now use Store.OnlineRawW, as phaseSample.moveW does: an offline
battery's last value no longer sets the rise base or the shown level.

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

* fix(control): judge a raise's landing from the meter's rise

C4 counted a raise as landed only when a reading-to-reading move had a
charger's shape. A slow ramp, or a house load on another phase that moved
in the landing reading, left the whole raise counted as on its way on top
of a reading that already held it, and Core cut a raise that fit under
the line. The rise still to come now reads the meter's own rise since the
raise went out: the smallest across the phases for a three-phase order,
the car's phase for a one-phase car.

For a three-phase order, a move on one phase alone no longer moves the
shown level of a car not yet seen. A load cycling on the phase over the
line took the cut's cover off on each drop and never gave it back, so
Core cut again and again.

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

* fix(control): a car not yet seen may take a raise on one phase

The smallest rise across the phases read a one-phase car not yet seen
as a raise that had not landed: its phase rose, the other two did not.
Core then cut the car from 11040 W to 0 with every phase under the line
(field matrix, car1p cases). One phase that has risen alone since the
raise went out, the others within half a step, now counts as that car
taking the raise, as the shape tracker read it before. The changeset
again says what holds: the one-phase house step case is closed for a
car seen on three phases.

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

* fix(control): judge a one-phase landing reading by reading

For a car not yet seen on three phases, a raise counted as landed on one
phase only while the other phases stayed within 0.5 A of their reading
when the raise went out. That window spans the charger's whole response
time, so 0.6 A of house drift on another phase made the full raise count
as still to come on top of a reading that already showed it, and Core
cut the car with every phase under the line (field matrix with 0.7 A on
L2: car1p cut 11100 -> 0 W).

Each phase now keeps its rise over the readings in which the other
phases moved less than a step (aloneA). A rise of a step or more there
may be the car on that phase. Drift between readings, or under a step
in the landing reading, no longer hides it; a house load that moves a
step or more on another phase in the same reading still does.

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

* fix(control): restart credit keeps room for the car's rise to its max

After a restart Core does not know the offer in force on the charger, so
a car drawing below it may still rise before Core's order lands. The
credit now counts only the draw less that possible rise past the latest
offer, with the rise measured to the loadpoint's max_charge_w; a charger
with no max_charge_w gets no credit.

The charger and output reads move into health-gated telemetry accessors
(OnlineLowerW, SumOnlineHigherW). Tests cover the raw and smoothed
guards, a 10 s OCPP sample cadence on the plant, and the review's car
rising under a 16 A offer in force.

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

* test(control): Charge now landing shown before Core logs its offer

Site rig s1-16, s1-25 and s15-25 failed on c9b52c3: the charger answered
within a second, and the meter reading that showed the car at 15.9 A came
in 7 ms before the tick that first logged the 16 A offer. The reading
could not show an offer logged after it, so the landing counted as a
raise still to come, 15.9 + 16 A passed 1.2 x In, and Core cut 11,040 W
to 5,520 W with 9 A of room on every phase. The same run of the base at
281f9f6, without this branch's commits, fails the same way; #1923's
ab02710, merged here in c0a5274, reads the meter's own rise and fixes
it. This test pins that rig shape.

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

* fix(control): restart credit counts no sample newer than the meter

The battery side's restart credit (evFoundDrawW) had two gaps.

A charger sample measured after the site reading counted as part of
it. A car that rose in that gap was credited for draw the meter did
not show yet, and the battery ended its discharge into room the rise
had taken. With a 3 s meter, L2 reached 19.30 A. A sample now counts
only when measured at or before the site reading.

The rise reserve took max_charge_w as the most the unknown offer in
force can be. A charger still on an offer from before a restart that
lowered max_charge_w, or on its own higher default, could rise past
it: 19.00 A per phase on the unit fixture, 19.30 A on the plant. The
reserve now runs to the larger of max_charge_w and the site fuse's
rating.

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

* fix(control): restart credit holds against a charger clock behind Core's

The restart credit compared a charger's sample time, stamped by the
charger's own clock (OCPP MeterValues), with the time the site reading
arrived on Core's clock. A charger clock 1 s or more behind Core's let
a rise sampled after the reading count as already in it, and L2 reached
19.30 A on the third review's plant case.

A sample now counts only when stamped evClockAllowance (5 s) before the
reading arrived, and the draw is the lowest of that sample and every
sample since. noteDraw keeps the charger's samples while the restart
window is open.

Also tests the max_charge_w half of the rise cap: a 22 kW charger on a
3 x 25 A fuse keeps the reserve up to max_charge_w.

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

* docs(control): the fuse margin is opt-in, and only the fuse ceilings move

The SiteFuseSafetyA comment read as if the margin were needed by
default; it now says zero is the default and when to set one. The
changeset named "the site's total import and export limits", which
reads as the hourly import_limit; that limit does not move. It now
names the fuse's import and export ceilings.

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

* test(control): battery beside a minimum-step car holds the fuse line (rig s18-16)

On #1910 and #1891 the battery rises beside the car to the working limit,
the fuse guard cuts it by the overage plus half of safety_margin_a and holds
30 s, and the battery rises again: it hunts between 15.0 and 15.5 A on a
16 A site. Where it stands when the house moves is chance, which is why
rig s18-16 failed 3 of 57 runs on those heads. #1913's cut by the overage
only keeps it on the line.

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

* test(config): an unset fuse margin reaches Core as 0

A mutation that restored a hidden 0.5 A margin at startup passed every
suite. This test pins the value newControlStateFromConfig hands Core.

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

* test(site): s18 judges own commands apart from the house's step

At margin 0 Core holds the phases at the 16 A line. Rig s18-16's 0.1 A
house step at small_step then lands over the line until Core cuts: on
fdfbfec the phase read 16.101 A for 3 s and Core cut the battery
4851 -> 4719 W within 2 s. The criterion "own commands stay inside the
rated fuse" counted that stretch as Core's and failed. The owner's rule
judges an overage between In and 1.2 x In by the I2t model.

The criterion now skips the 10 s after the step, the same gap the minor
window already leaves. On the fdfbfec record Core's own commands peak
at 16.008 A before the step and 16.005 A after it, inside 16.01 A.

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

* fix(control): no cut of an unseen car, no landing from a 1p house move

Two regressions against #1913 from the final review.

A car the loadpoint has not seen on three phases may draw on one phase
Core does not know. The alone rise (aloneA) missed its landing when any
other phase moved a step in the same reading, so the whole raise counted
as still to come and C4 cut a car with every phase under the line, at
most session starts with a 10 s meter. riseAheadA now takes the largest
rise for such a car, as for a one-phase car whose phase is not known,
and the smallest only once the car is seen on three phases.

A one-phase order never knows the car's phase, so a house load moving
alone on any phase read as its cut landing and took the cover off a cut
still on its way. A one-phase move now counts only on a known car phase.

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

* fix(control): judge a restart sample by its arrival, not its stamp

The 5 s clock allowance let the restart credit count a charger sample
taken 5-7 s before the site reading. A car that stopped after that
sample (a fast charger applying the 0 A sent while the meter was stale,
or the car pausing) still counted, the battery ended the discharge the
car's return needed, and L2 reached 21 A on a 16 A fuse when it resumed.

Core now judges a sample by when it first arrived on Core's clock. It
counts as in the reading only when it arrived evMeterDelay (1 s) before
the reading, and no more than one control interval before that. A sample
already there when Core began to watch the charger arrived at a time
Core does not know, so it never counts. The charger's stamp only tells
samples apart, so a charger clock behind Core's no longer matters.

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

* test(site): judge overages at the fuse rating, not fuse - 1 A

At margin 0 the car in s8-60-25 settles at the rating, as the owner's
rule says, and the rig's old 1 A hidden margin failed it: 271 s above
24 A against 67 s allowed, with no second over 25 A. siterig/full has the
same threshold in overage() (s9, s10, s11), the random invariants and
s15bat.

Port #1907's judge hunks: s3, s8, s9-s11, s15bat and the invariants now
count time over the fuse rating. TestS8JudgesAtTheFuse and #1907's
TestInvariantsJudgeAtTheFuse fail on the old judges.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 28, 2026
…ack #1888, #1907, #1908, #1920, #1922) (#1888)

* fix(control): one log of FTW's own EV offers

The battery side read a 20 s pool log of EV offers (State.evOffers), while
the EV fuse limit keeps each charger's offers for its declared response
time, or 180 s when it declares none. After 20 s the battery counted a slow
charger's unshown raise as already in the meter and charged into that room;
when the car started, a 25 A site read 32.6 A per phase for 3 s.

Delete State.evOffers and its two writers. evInMeterW and
clampEVBatteryHeadroom read evShownW, each charger's latest offer less
what it may not show yet. The hourly peak in stepEVMeterLimit takes each
charger's highest offer of the last evSettle from the same log.

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

* test(site): add P0 rig scenarios R1-R5

Stale meter, battery dropout, the planner floor, a SIGKILL restart and
a start into a running site had no judged rig scenario. Add r1-25 to
r5c-25 with their judges and known lines naming the Wave 1 items (A1,
A3/A4, E1, D6). The rig gains stopFTW(sig), a planner soc_min field, a
charger plugged at start and a delayed Core start. r1-25 is skipped
until device-simulator can silence the site meter.

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

* fix(control): battery fuse guard reads each phase with its direction

Delete perPhaseOverageW, perPhaseOverageReadingW and perPhaseReliefW.
The battery fuse guard, the fuse saver and the forced discharge read the
signed phase currents (phaseCurrentsA, with #1849's sum check and dead
zone), count the batteries' own move on each phase, and relieve to the
car's working limit, the fuse less max(1 A, safety_margin_a).

Item A5 of the stable plan.

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

* fix(control): battery waits only for a declared charger response time

The first A6 change gave the battery side the EV limit's 180 s guess
for every charger that declares no response time. Only Easee Cloud
declares one, so on every other charger the battery reserved the car's
whole demand on top of a reading that already showed the car, and
stopped charging for 3 minutes after each car start or raise. On the
#1868 shape (rig s18) battery charge stayed at 0 W until 180 s.

evShownW now uses the charger's declared power_max_age_s, and evSettle
when it declares none, as master did. Easee Cloud declares 180 s, so the
slow-charger case stays fixed.

Tests: the slow-charger test now declares 180 s, and a new plant test
runs the s18 shape with a fast charger that declares nothing.

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

* fix(control): fuse guard reads phases after the hold caps the targets

The guard took the phase relief from the original request, then the 30 s
fuse hold capped that request and the relief came off again. A charge
limited by one phase switched between 1380 W and 0 W on every dispatch.
Predict the aggregate and the phases from the held targets instead.

The phase reader now takes the meter's raw total from OnlineMeterW, the
same reading as its currents, so an offline meter's cached phases drive
no relief. The batteries' raw power is read in one helper. dispatch.go
keeps its 41 cached telemetry reads.

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

* test(site): make R2, R4, R5a and R5b judge what Core does on master

Review fixes for W0-4:

- r4: Core refuses a battery hold over 1800 s, so the 3600 s hold made
  the run error before any kill. Post a 1800 s hold 300 s before the
  first kill; it outlasts both.
- r2: the site stays under 1.2 x the fuse, where Core cuts the car on
  its documented 60 s heat budget (control/fuse_heat.go), not at once.
  Judge that contract: over 1.2 x the fuse at most the charger's delay
  + 7 s, and the 60 s heat under 1.13 x the fuse. Record the time over
  fuse - 1 A and when Core sends the cut.
- r5a: start the battery at its 5 % floor so the site runs 30 A before
  Core starts, check that as setup, and judge against fuse - 0.5 A,
  Core's own line (as r3 does).
- r5b: drop the D6 known line. The saved plan is younger than
  MaxPlanAge and a fresh plan lands within seconds, so the deadline
  reserve never acts and the run does not reach D6.
- r1, r4: Charge now counts as back only once it runs unbroken to the
  next event, so a stop that starts after the restart is seen.
- README: move the r rows off the s17 line #1875 edits.

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

* test(site): judge R2-R5a at the fuse rating, not fuse - 0.5 A or - 1 A

Core's working limit is the fuse rating less safety_margin_a, which is
opt-in (owner rule of 27 Oct). R3 and R5a now judge the overage above
the fuse, R2 and R4 record it there, and the fixtures put Core's own
line at 25.0 A on a 25 A fuse. New cases hold exactly 25.0 A (passes)
and 25.1 A beyond the time budget (fails) for R3 and R5a.

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

* test(site): pin R5a's 3 s clock to the first sample at the fuse

R5a's second criterion now starts its clock at the first sample at or
under the fuse, not under fuse - 0.5 A. So a site that dips to 24.8 A
and then holds 26 A for 10 s fails, where the old judge passed. Add that
case so the clock start cannot drift back.

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

* test(site): the rig's "over the limit" is above the fuse, not fuse - 1 A

Core's working limit per phase is the fuse rating less safety_margin_a,
and the margin is opt-in (owner rule of 27 Oct). The invariants judge,
the overage helper behind S9-S11, S3, S8 and S15bat now count a phase as
over the limit only above the fuse rating. The fuse - 1 A and
fuse - 0.5 A metrics go; the I2 and back-under metrics read at the fuse.
Time budgets stay as they were.

This only loosens the judges, so Core that still holds In - 1 A passes.
Room expectations (S1, S12, the random roomA) stay at fuse - 1 A for now.

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

* test(site): the rig expects the room the fuse rating gives

S1 targets floor(fuse - house) in 1 A steps (8 970 W on 16 A with a 3 A
house, was 8 280 W). S12 counts a cut on room alone while every phase is
under fuse - 0.1 A and records fuse_limit with 6 A of room under the fuse.
The random judge measures Charge now room against the fuse, with the
car's 6 A minimum as the threshold. The stack pins safety_margin_a to 0
so the rig does not depend on Core's default.

This tightens the rig: it passes only once Core works to the rating.

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

* test(site): say S1-16's target is a floor while the battery covers the house

A run on a Core that stops at fuse - 1 A gave the car 9 590 W with the
battery at -5 000 W, above the 8 970 W target. S1-16 does not catch the
old line.

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

* fix(control): car and battery-beside-car work to the fuse rating less safety_margin_a

The EV phase limit was In - 1 A, fixed in code. It is now fuseLineA():
In - safety_margin_a, with no floor. A rise moves one step once it fits
under the line, a start takes all the room, and battery charge beside a
car uses the same line. The hour's import limit keeps its own margins.
Starts and per-phase cuts round down to whole charger amps, since
chargers round offers to the nearest amp.

Step 3 (PR b) of the fuse-at-rating plan (owner rule of 27 Oct).

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

* fix(control): keep covered offers whole and leave battery relief out of EV room

Two review findings on the fuse-line change:

- A cut rounds to whole charger amps only when a new cut is needed. When
  cuts on their way cover the overage, the offer stays as it is. Rounding
  a kept step-table offer took the car 6200 -> 5500 -> 4800 -> 0 W in 3 s
  on a 0.6 A overage while a 180 s cut travelled.
- Dispatch records the discharge the fuse guard adds to each battery, and
  an EV rise or start does not count it as room. Before, the car rose into
  the relief and the battery fed it 650 W at the line for as long as the
  house load stayed.

The noisy-line sim test now pins the slow one-step cycle at an exact fit
(at most 5 cuts an hour, never a stop) and no moves with part of a step
left, instead of allowing 45 moves an hour.

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

* fix(control): battery fuse guard reads each phase with its direction

Delete perPhaseOverageW, perPhaseOverageReadingW and perPhaseReliefW.
The battery fuse guard, the fuse saver and the forced discharge read the
signed phase currents (phaseCurrentsA, with #1849's sum check and dead
zone), count the batteries' own move on each phase, and relieve to the
car's working limit, the fuse less max(1 A, safety_margin_a).

Item A5 of the stable plan.

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

* fix(control): fuse guard reads phases after the hold caps the targets

The guard took the phase relief from the original request, then the 30 s
fuse hold capped that request and the relief came off again. A charge
limited by one phase switched between 1380 W and 0 W on every dispatch.
Predict the aggregate and the phases from the held targets instead.

The phase reader now takes the meter's raw total from OnlineMeterW, the
same reading as its currents, so an offline meter's cached phases drive
no relief. The batteries' raw power is read in one helper. dispatch.go
keeps its 41 cached telemetry reads.

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

* fix(control): battery relieves to the fuse less safety_margin_a

phaseFuseReliefW now reads fuseLineA(), the line the car uses since the
EV change, instead of max(1 A, safety_margin_a). There is no floor under
the margin: at margin 0 the battery works to the fuse rating.

Tests return to the rating where they had moved to the 1 A limit, and a
new property test covers In x phases x margin in both directions.

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

* test(control): re-record golden records at the fuse less safety_margin_a

Replayed from stored inputs; only records whose dispatch changed against
the parent branch were replaced.

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

* fix(control): battery fuse cut lands on the line, not half a margin below

applyFuseGuard cut a battery charge or discharge by the overage plus
half of safety_margin_a. The guard now predicts from the held targets
and keeps its 30 s hold, so the extra cut only parked the phase below
the line the user set. Cut by the overage only.

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

* docs(changeset): name the limit the battery cut lands on

The changeset said the phase lands on the fuse less safety_margin_a.
On #1891 as it stands, the phase line is the fuse less max(1 A,
safety_margin_a), so at the 0.5 A default a 16 A phase settles at
15 A, not 15.5 A. Say what this PR changes, cutting only by the
overage, without naming a line that depends on step 4.

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

* fix(config): fuse safety_margin_a defaults to 0

Core now works to the fuse rating unless a site sets a margin. Every
explicit safety_margin_a is kept. A stored value of exactly 0.5 A, the
old default, adds one load-time notice and changes nothing.

The peak-limit error names the margin only when it is above 0, and the
settings field shows 0 with help that says the margin is opt-in.

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

* docs(changeset): say what the default change moves on its own

The changeset said FTW works to the fuse rating unless a site sets a
margin. On this PR's base every per-phase line still sits 1 A under the
rating; that holds only once #1910 and the step-4 amendment of #1891
land. The changeset now names what this PR changes: the default, and
the whole-site import and export limits.

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

* test(control): relief stays at the line after a PV drop (C3, seed 207533)

On a 16 A fuse with no margin the battery holds 3 kW of discharge with L1
at 14.4 A; PV drops 1280 W and L1 reads 16.3 A. The relief must bring L1
to 16 A and keep it there while the reading sits at the line with the
battery at its relief power. Without phaseFuseReliefW's own-move count
the relief flips between -3214 W and -3005 W on every tick and L1 crosses
the rating 13 times; with it, L1 crosses once.

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

* test(site): judge random runs by rig items R2-R7 of the 27 Oct triage

The random judge called eight nightly seeds on master 83c2b24 wrong in
ways the triage traced to the judge, not to Core:

- R2: a stretch over the fuse took only the windows open at its first
  sample. Every window that opens inside it while it is still allowed
  now extends it, a Core restart included (207530, 207539).
- R3: the battery counted as the 2 s lever while its inverter was
  offline or had no spare power. It is the lever only while it runs,
  is above its floor and its spare power covers the overage; otherwise
  the charger, at max(2, command_response_s) (207530, 207534, 207539).
- R4: a car that starts on the charger's own default, or on an order
  still on its way, is a step FTW did not take, answered by the charger
  from draw start + max(2, command_response_s) + 5 s. A stretch is
  FTW's own only after FTW's last change to the car's order was a rise,
  or its battery order moved toward import (207532, 207534, 207537).
- R5 (owner rule): between the fuse and 1.2 x the fuse a phase counts
  only once Core's 60 s I2 heat is over the fuse too; above 1.2 x at
  once (207536). r3 and r5a take the same heat budget (#1888 review).
- R6: idle seconds with room under Charge now are counted from when the
  room appears and may last evRestartDwell + command + startup + a tick
  (+ 2 s meter and tick + 3 s). The count starts again on a Charge now
  that changes the request, on each FTW cut and when Core answers after
  a restart. Only meter and charger faults excuse the car.
- R7: the floor judges FTW's own orders (status.dispatch), not the
  inverter register, against max(inverter floor, planner soc_min) with
  no +0.5 %.

TestJudge now also judges the random seeds a results directory holds,
from each run's plan.json, so a nightly run can be judged again.

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

* test(site): tighten the random judge after review of #1920

- A stretch over the fuse that no outside change explains is FTW's own
  again (3 s after the heat clock), in either direction. The guess that
  it was a step let export raises and warm fuses split by short dips
  pass. ownRise is gone: with that rule it decided nothing.
- A window that opens inside FTW's own raise no longer turns it into a
  step; only a step's stretch grows.
- Charge now: a cut no longer restarts the idle count. A cut FTW had
  to make stops the car, and the count starts when the car stops; a cut
  with room is FTW's doing. The restart on Core's return was dead (the
  down mask already resets the count).
- The battery lever uses the floor the floor check uses (max of the
  inverter floor and the planner soc_min). At the floor it can only
  charge less.
- Only a meter fault adds its length to a step's time. An inverter
  outage takes the battery lever away; a charger outage delays the
  charger lever by what is left of it.
- New check (R8, C1): a forced battery mode FTW does not dispatch lasts
  at most 10 s once the inverter and Core are back.

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

* test(control): phase relief reads each battery live, in the decision's reading

The merge of #1876 moved stopPhaseReliefW's live reads into
phaseSample.moveW. Pin it: a battery that went offline, moved on to a
newer reading or has no reading gives no phase relief. With the cached
RawW read #1891 had, the offline and newer-reading cases relieve 2310 W
and the no-reading case counts a 2000 W charge from 0 W.

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

* fix(control): battery relief holds when the battery's reading lags the meter

The phase relief counts the battery's move from its own reading. When
that reading lags the meter by a tick, the meter already shows the relief
while the reading does not, so the relief came off; on the next tick it
went back on top of the reading's catch-up. In the 207533 shape (16 A,
margin 0) with a one-tick lag the order ran -3214, -3005, -3422, -3005,
... -5717 W and L1 went back to 16.3 A on every other tick: 13 crossings.

A move from the battery's last command (the one it got in the last
dispatch) may now not pass the room the meter shows either. That bound
only tightens, and a phase already over the line gives it no room, so it
never asks for more than to stay at the last command: a battery that
falls short of its order does not wind it up. With lag 1 or 2 the order
holds -3214 W with L1 at 16.0 A and one crossing, on the PV step.

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

* test(site): R8 counts a forced battery from when Core has the inverter back

The random judge's R8 check counted 10 s from the inverter's return. The
Modbus reconnect backoff (up to 60 s) holds Core's first good read back,
so the check could not pass even with C1 fixed (207539: 55.7 s).

The count now starts at Core's first good read of the inverter after an
outage or a restart, at the latest 60 s after the return. After an outage
it allows the registry's 30 s default retry (defaultRetryMax) until #1915
(C11) lands. While Core is stopped with SIGTERM, a running inverter must
leave its forced mode within 10 s of the stop: the driver cleanup writes
the default (triage item 10).

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

* test(site): R8 records the seconds Core's timers excuse

Review of #1922. The judge forgives the Modbus reconnect backoff (up
to 60 s) and the registry's default retry (up to 30 s) after a return,
while the triage line is 10 s from the return. result.json now records
those seconds as battery_forced_in_core_timers_s, so a pass such as
207539's (123 s) shows what was forgiven.

TestForcedAllowancesMatchCore reads both timers from Core's source and
fails once the registry retries the default on the first good poll
(#1915, C11) while defaultRetryMaxS is still 30.

Two R8 cases pin the judge's clock: an inverter that is back while Core
is down counts from Core's first read, and a second outage counts from
its own return.

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

* fix(control): a landed EV cut covers nothing later; a raise that no longer fits comes off

The site meter now tells Core how much of a charger's offers it already
shows. A move shaped like an order landing (every phase alike, or one
phase alone for a one-phase car) moves that level toward the latest
offer. A cut the meter shows no longer covers an overage that an outside
step causes after it, and no longer nets against a raise sent after it.
Where a landing and a house step fall in one reading, a cut covers a
phase that has risen since only up to the overage it had then.

C4: our own rise on its way, as the meter tells it, is judged as the
reading it will make. Above 1.2 x In, or over the line on a phase whose
heat budget is spent, it comes off on this reading, down to the line.

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

* fix(control): a one-phase house move is not the car's cut landing

seeOffers kept one shown level for every phase, so a house load moving on
one phase alone read as a three-phase cut landing and took the cut's cover
off the phase it was sent for. With a 40 s charger Core then stacked a
second cut and left L1 2 A under the line for over 2 min. The shown level
is now kept per phase: a three-phase order moves every phase alike, and a
phase that moves alone moves only its own level.

The loadpoint now reports a car it has seen draw on all three phases as
CarPhases 3. For such a car a one-phase move is never the car, so a house
step on one phase while a raise travels no longer hides the raise, and C4
cuts on that reading.

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

* fix(control): a battery charge rise fits each phase before it is sent

The battery fuse guard judged a rise from the battery's own reading and
the meter's phases, so it took a rise the meter did not show yet as
already shown. With the meter a poll behind the battery, a PV surplus that
grew each tick stacked rises past the line, then the relief cut them back:
16.4 A on a 16 A fuse and a charge that rose and fell (C2, seeds 207530,
207535).

fitBatteryRisesToFuse caps charge above the battery's latest order to
(fuseLineA - worst phase - own unshown rises) x V x phases. The unshown
rises are the battery's orders of the last evSettle and the chargers'
raises, counted as evShownW counts them, so the car comes first. Holding
an order is never a rise and the check never cuts; an overage stays with
the relief. No margin and no floor: the order ends at the line.

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

* fix(control): battery rise check counts slow batteries and direction-less phases

A battery order now stays unshown until evSettle after the battery's own
reading reaches it, so a battery that ramps slower than evSettle no longer
gets a second rise on top of an order the meter cannot show yet.

On a meter with phase currents but no phase power while the total
exports, a phase counts as export only when the other phases cannot
carry the export alone; any other phase counts as import. Before, every
phase flipped to export, so a three-phase charge could rise into a phase
that imports beside two exporting ones.

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

* fix(control): the car comes before battery charge

With battery_covers_ev off, dispatch took the car out of the meter
reading before the battery decided. The battery then saw the PV the
car was using as export and charged from it while the site imported
for the car (rig seeds 207535, 207539, 207530: about 10 kW import).

The battery now charges only from the surplus the meter shows after
the car. Where the house alone has surplus, the battery sees the meter
with the car included, and any discharge stops at once, since it would
feed the car. A planned PV charge on the energy path takes only the
surplus the car leaves; a planned grid purchase keeps its charge.
Cover off still never discharges the battery for the car.

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

* fix(control): peak shaving drops a running charge for the car

Manual peak_shaving gave zero error for any import under the peak
limit, so a battery charge that was running when the car started held
while the site imported for the car, with battery_covers_ev on or off.
When the site imports while the car and the battery both charge, the
charge now comes down by the import, to 0 at most. Under the peak limit
the battery still never discharges.

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

* fix(control): after a restart the battery credits the car the meter shows

After a restart, the 0 A orders FTW sent while the meter was stale counted
as the EV base the meter shows. With the car still drawing 8.2 kW from
before, a new 6.2 kW order then counted as a 9 A rise to come, and the
battery was held in discharge for the car (seed 207532).

Until a charger's first order of this run can have landed, the battery
side now counts the charger's live measured draw as in the reading, never
above the latest order and never above the consumption the site meter
shows. The car's own limit does not read it.

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

* fix(control): credit a found car only from a current charger sample

Review of #1926 found two ways the restart credit let the battery end a
discharge into room the car's pending rise still needs:

- The charger reading counted with no check that it was current. A held
  reading after a failed poll, or a sample older than the site reading,
  could show a car that a fast charger had already stopped. The draw now
  counts only when the last poll succeeded and the charger measured it
  within one control interval of the site reading.
- The consumption bound subtracted the smoothed battery, PV and V2X
  values, which lag an output that just fell. It now takes the higher of
  raw and smoothed for each, so the bound stays at or below what the
  meter shows.

New tests pin both, plus the shown-offer subtractions and a window that
outlasts evSettle.

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

* test(control): run the site sim at the new 0 A default margin

simFuseSafetyA follows the config default, now 0. fitScenario takes the
room under the line and sets the house from simFuseSafetyA, so its exact
fit stays twelve whole steps at any margin. "charger's own load
balancer" passes at margin 0, so its known line goes.

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

* docs(changeset): say what margin 0 moves once the fuse stack lands

With the stack under it, the default moves the car's and the battery's
per-phase line as well as the site totals. 0.5 A stops being the
default, so #1910's changeset no longer calls it that.

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

* test(control): keep the load-balancer bug as a known line above the line

At the 0 A default the scenario passes only because the charger's own
balancer sits at 25 A, the rating and so the line. The bug is still in
Core: a cut comes off an offer the car does not draw. Two known variants
keep it tracked and turn red once it is fixed: the stored 0.5 A margin
(over 24.5 A for more than 11 s) and a balancer at 25.5 A at margin 0
(over 25 A for more than 11 s).

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

* fix(control): EV room reads battery relief through live telemetry

evFuseReliefW read each battery's cached RawW, which survives a driver
going offline. It now reads Store.OnlineRawW. A battery with no live
reading counts its whole recorded relief, so the car never builds on
discharge Core can no longer hold.

fuseSaverEarlyExit read each battery's SmoothedW a second time to record
relief. It now records against the live snapshot the fuse-saver decision
already took (fleetAtLivePower), so it adds no read of its own.

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

* test(site): only a rise extends a stretch; raises after a step are FTW's

Second review of #1920:

- A house event that is no fault extends a step's stretch only if the
  worst phase rose 0.5 A or more within 3 s. Load events that change
  nothing and the inverter's own return no longer chain the allowance.
- The inverter is back at the end of its outage, so at its return the
  battery is the 2 s lever, not the charger.
- A window does not explain a stretch if FTW raised the car after the
  window's meter delay and before the stretch: FTW's raise after a step
  or after a Core restart is its own.
- A stretch stays open across dips while a phase's heat is over the
  fuse, so a warm fuse held 3 s over and 1 s under fails. A window that
  opens after the allowance, with no over second in between, still gives
  a new step its own time.
- Tests pin the export lever, the heat clock for steps and the 60 s
  restart window.

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

* test(site): a raise over the fuse on import is FTW's own

The random judge credited FTW's own raises to a step in three shapes
from the third review of #1920:

- a car raise sent while a step's import stretch was over the fuse
  passed as part of the step;
- a house event that changed nothing, opening as that raise landed,
  took the rise and gave the stretch a fresh lever time;
- a battery charge raise after a Core restart or a step's meter delay
  was never counted (triage C2), so the restart window excused it.

For import, a window no longer explains a stretch when FTW raised the
battery's charge after the window's meter delay and before the stretch,
or raised the car or the battery while the stretch was over the fuse.
A raise in a dip under the fuse still counts as allowed, a battery
order moved while the inverter is off reaches no battery, and raising
the car into export stays the right answer.

The 10 recorded seeds keep every verdict and allowance.

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

* fix(control): an offline battery's fuse relief ends after evSettle

With no battery online, ComputeDispatch returns before it records relief,
so batteryFuseRelief kept its last value and evFuseReliefW counted all of
it for as long as the battery stayed offline. The car was held up to the
whole relief below the line. The relief now counts in full until the
default mode Core sent at the transition can have reached the meter
(evSettle), then ends.

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

* test(site): judge FTW's battery orders as the inverter sees them

Fourth review of #1920 (4985523):

- A missing status.dispatch (Core down, or before its first tick) keeps
  the last order, as the inverter does, instead of reading as 0 W. A
  charge order restored after a restart is no raise.
- A battery move counts from 0.5 A a phase (345 W), as raisedAt does,
  so a one-sample closed-loop wobble is no raise.
- A car raise before a stretch makes it FTW's own only on import.
- On export, a battery order moved toward export after a window's
  meter delay, or while the stretch is over the fuse, makes the
  stretch FTW's own, so a Core restart no longer excuses it.

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

* fix(control): each offline battery's fuse relief ends after evSettle

The expiry in 8150b45 ran only when no battery was online. With a
second battery online, an offline battery's relief stayed until a
recorded dispatch rewrote the map: never while the legacy modes sat in
the deadband, and on the first tick when one did record, so the car
could rise onto discharge Core ordered a second earlier. Each offline
battery now has its own clock: its relief counts whole for evSettle
and then ends, on every dispatch path. A battery back online leaves the
clock, so its next offline period counts whole again; a test now covers
that reset.

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

* fix(control): battery phase relief neither pins mixed phases nor winds up

The last-command bound held the battery at its last command when one
phase sat at the import line and another was over the export line: the
guard cut the discharge for the exporting phase and the bound put it
back. Now the bound gives way on one side while any phase is over the
other side's line, and phaseFuseReliefW's least-over move decides.

When the meter does not show the battery's move (a frozen meter, or a
worst phase a one-phase battery cannot reach), each tick added the same
overage again and the order ran out to the discharge limit. Now the
relief toward the worst phase stops at the last command once the
batteries' raw power has moved one ampere per phase since the worst
phase last moved by half an ampere. One ampere is the step in which P1
meters report phase current.

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

* fix(control): battery phase anti-windup stops only when the meter cannot show the move

phaseUnresponsive stopped the relief whenever the batteries had moved
690 W and the worst phase had not moved half an ampere. On a live meter
that also happens when a load cancels the move (a balanced load starting
with the relief step, or a slow rise on the worst phase), and when the
battery's reading catches up with an order the meter already showed.
The relief then held over the line with no end.

Now it stops only when the meter repeats its phases exactly while the
batteries moved one ampere per phase, or when another phase moved by
twice the balanced share of that move (a battery off the worst phase).
The repeat is measured from the reading the meter last changed in, so a
meter that freezes on any reading is still caught.

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

* fix(control): battery off the worst phase goes back, and a stale reference ends

Fourth review of the phase anti-windup (c066892):

- The worst phase's reference had no end while that phase sat over the
  line. A relief that follows a slow drift keeps it there, so the
  reference stayed while the battery moved hundreds of watts, and a load
  that later switched off on another phase read as a battery off the
  worst phase. The relief then held L1 up to 0.49 A over the line with no
  time limit. The reference now also ends when the worst phase is at or
  inside the line, or when another phase becomes the worst.
- An off-phase verdict no longer holds the last command. The batteries go
  back to their power when the worst phase last moved: what they moved
  since did not reach it. A one-phase battery no longer holds its own
  phase at -17.97 A for the whole overage (and -19.83 A, past 1.2 x In,
  when PV returns). A false verdict on a balanced battery moves the
  worst phase by at least one ampere on the next reading, which ends the
  reference, so it costs one tick instead of an open-ended hold. The
  verdict holds until the reference ends, so the relief does not go out
  again once the battery is back.
- The frozen-meter test now also compares the meter's total. A P1 meter
  reports whole amperes, so a live meter can repeat its phases while its
  total moves; that latched the relief at 1.11 x In.

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

* test(control): the drift test also wants no verdict on the load change

With L1 at the line ending the reference, no off-phase verdict comes when
the load on L3 switches off. Without it, the step back still ends the
hold, but with L1 at 16.8 and 17.7 A for two ticks; the test now sees
that.

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

* test(control): our battery order's move is not the car's cut landing

The guard in seeOffers that stops reading the meter while our battery
order moves had no test; removing it left every test green. A battery
order that lands and goes back while a cut travels to a 40 s charger
now must not lead to a second cut.

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

* test(control): the car-first discharge stop has its own test

Removing the PI path's immediate discharge stop left every named test
green; only the golden replay caught it. A peak_shaving loop with
battery_covers_ev off, starting from a 2.9 kW discharge while the house
alone has surplus, now requires that no order discharges into the car.

The comment on the plan back-off said the plan's grid leaves out the car.
It counts the car it planned; the comment now says so.

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

* fix(control): the battery rise check reads only live battery power

fitBatteryRisesToFuse, unshownBatteryW and recordBatteryReadings read
the battery's cached RawW, which survives the driver going offline, and
added three direct telemetry reads that the repo-hygiene check refuses.
They now use Store.OnlineRawW, as phaseSample.moveW does: an offline
battery's last value no longer sets the rise base or the shown level.

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

* fix(control): judge a raise's landing from the meter's rise

C4 counted a raise as landed only when a reading-to-reading move had a
charger's shape. A slow ramp, or a house load on another phase that moved
in the landing reading, left the whole raise counted as on its way on top
of a reading that already held it, and Core cut a raise that fit under
the line. The rise still to come now reads the meter's own rise since the
raise went out: the smallest across the phases for a three-phase order,
the car's phase for a one-phase car.

For a three-phase order, a move on one phase alone no longer moves the
shown level of a car not yet seen. A load cycling on the phase over the
line took the cut's cover off on each drop and never gave it back, so
Core cut again and again.

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

* fix(control): a car not yet seen may take a raise on one phase

The smallest rise across the phases read a one-phase car not yet seen
as a raise that had not landed: its phase rose, the other two did not.
Core then cut the car from 11040 W to 0 with every phase under the line
(field matrix, car1p cases). One phase that has risen alone since the
raise went out, the others within half a step, now counts as that car
taking the raise, as the shape tracker read it before. The changeset
again says what holds: the one-phase house step case is closed for a
car seen on three phases.

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

* fix(control): judge a one-phase landing reading by reading

For a car not yet seen on three phases, a raise counted as landed on one
phase only while the other phases stayed within 0.5 A of their reading
when the raise went out. That window spans the charger's whole response
time, so 0.6 A of house drift on another phase made the full raise count
as still to come on top of a reading that already showed it, and Core
cut the car with every phase under the line (field matrix with 0.7 A on
L2: car1p cut 11100 -> 0 W).

Each phase now keeps its rise over the readings in which the other
phases moved less than a step (aloneA). A rise of a step or more there
may be the car on that phase. Drift between readings, or under a step
in the landing reading, no longer hides it; a house load that moves a
step or more on another phase in the same reading still does.

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

* fix(control): restart credit keeps room for the car's rise to its max

After a restart Core does not know the offer in force on the charger, so
a car drawing below it may still rise before Core's order lands. The
credit now counts only the draw less that possible rise past the latest
offer, with the rise measured to the loadpoint's max_charge_w; a charger
with no max_charge_w gets no credit.

The charger and output reads move into health-gated telemetry accessors
(OnlineLowerW, SumOnlineHigherW). Tests cover the raw and smoothed
guards, a 10 s OCPP sample cadence on the plant, and the review's car
rising under a 16 A offer in force.

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

* test(control): Charge now landing shown before Core logs its offer

Site rig s1-16, s1-25 and s15-25 failed on c9b52c3: the charger answered
within a second, and the meter reading that showed the car at 15.9 A came
in 7 ms before the tick that first logged the 16 A offer. The reading
could not show an offer logged after it, so the landing counted as a
raise still to come, 15.9 + 16 A passed 1.2 x In, and Core cut 11,040 W
to 5,520 W with 9 A of room on every phase. The same run of the base at
281f9f6, without this branch's commits, fails the same way; #1923's
ab02710, merged here in c0a5274, reads the meter's own rise and fixes
it. This test pins that rig shape.

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

* fix(control): restart credit counts no sample newer than the meter

The battery side's restart credit (evFoundDrawW) had two gaps.

A charger sample measured after the site reading counted as part of
it. A car that rose in that gap was credited for draw the meter did
not show yet, and the battery ended its discharge into room the rise
had taken. With a 3 s meter, L2 reached 19.30 A. A sample now counts
only when measured at or before the site reading.

The rise reserve took max_charge_w as the most the unknown offer in
force can be. A charger still on an offer from before a restart that
lowered max_charge_w, or on its own higher default, could rise past
it: 19.00 A per phase on the unit fixture, 19.30 A on the plant. The
reserve now runs to the larger of max_charge_w and the site fuse's
rating.

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

* fix(control): restart credit holds against a charger clock behind Core's

The restart credit compared a charger's sample time, stamped by the
charger's own clock (OCPP MeterValues), with the time the site reading
arrived on Core's clock. A charger clock 1 s or more behind Core's let
a rise sampled after the reading count as already in it, and L2 reached
19.30 A on the third review's plant case.

A sample now counts only when stamped evClockAllowance (5 s) before the
reading arrived, and the draw is the lowest of that sample and every
sample since. noteDraw keeps the charger's samples while the restart
window is open.

Also tests the max_charge_w half of the rise cap: a 22 kW charger on a
3 x 25 A fuse keeps the reserve up to max_charge_w.

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

* docs(control): the fuse margin is opt-in, and only the fuse ceilings move

The SiteFuseSafetyA comment read as if the margin were needed by
default; it now says zero is the default and when to set one. The
changeset named "the site's total import and export limits", which
reads as the hourly import_limit; that limit does not move. It now
names the fuse's import and export ceilings.

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

* test(control): battery beside a minimum-step car holds the fuse line (rig s18-16)

On #1910 and #1891 the battery rises beside the car to the working limit,
the fuse guard cuts it by the overage plus half of safety_margin_a and holds
30 s, and the battery rises again: it hunts between 15.0 and 15.5 A on a
16 A site. Where it stands when the house moves is chance, which is why
rig s18-16 failed 3 of 57 runs on those heads. #1913's cut by the overage
only keeps it on the line.

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

* test(config): an unset fuse margin reaches Core as 0

A mutation that restored a hidden 0.5 A margin at startup passed every
suite. This test pins the value newControlStateFromConfig hands Core.

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

* test(site): s18 judges own commands apart from the house's step

At margin 0 Core holds the phases at the 16 A line. Rig s18-16's 0.1 A
house step at small_step then lands over the line until Core cuts: on
fdfbfec the phase read 16.101 A for 3 s and Core cut the battery
4851 -> 4719 W within 2 s. The criterion "own commands stay inside the
rated fuse" counted that stretch as Core's and failed. The owner's rule
judges an overage between In and 1.2 x In by the I2t model.

The criterion now skips the 10 s after the step, the same gap the minor
window already leaves. On the fdfbfec record Core's own commands peak
at 16.008 A before the step and 16.005 A after it, inside 16.01 A.

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

* test(site): R8 reads Core's reconnect backoff from source

The judge's reconnectBackoffMaxS was a fixed 60 s, checked against
modbus/client.go. #1930 cuts Core's cap to 10 s, so the check would
fail whichever of the two lands second. The judge now reads the cap
from the source the rig builds Core from, and the 207539 shapes are
set relative to it. The unit tests pass with 60 s and with #1930's
10 s applied.

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

* fix(control): no cut of an unseen car, no landing from a 1p house move

Two regressions against #1913 from the final review.

A car the loadpoint has not seen on three phases may draw on one phase
Core does not know. The alone rise (aloneA) missed its landing when any
other phase moved a step in the same reading, so the whole raise counted
as still to come and C4 cut a car with every phase under the line, at
most session starts with a 10 s meter. riseAheadA now takes the largest
rise for such a car, as for a one-phase car whose phase is not known,
and the smallest only once the car is seen on three phases.

A one-phase order never knows the car's phase, so a house load moving
alone on any phase read as its cut landing and took the cover off a cut
still on its way. A one-phase move now counts only on a known car phase.

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

* fix(control): judge a restart sample by its arrival, not its stamp

The 5 s clock allowance let the restart credit count a charger sample
taken 5-7 s before the site reading. A car that stopped after that
sample (a fast charger applying the 0 A sent while the meter was stale,
or the car pausing) still counted, the battery ended the discharge the
car's return needed, and L2 reached 21 A on a 16 A fuse when it resumed.

Core now judges a sample by when it first arrived on Core's clock. It
counts as in the reading only when it arrived evMeterDelay (1 s) before
the reading, and no more than one control interval before that. A sample
already there when Core began to watch the charger arrived at a time
Core does not know, so it never counts. The charger's stamp only tells
samples apart, so a charger clock behind Core's no longer matters.

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

* test(site): judge overages at the fuse rating, not fuse - 1 A

At margin 0 the car in s8-60-25 settles at the rating, as the owner's
rule says, and the rig's old 1 A hidden margin failed it: 271 s above
24 A against 67 s allowed, with no second over 25 A. siterig/full has the
same threshold in overage() (s9, s10, s11), the random invariants and
s15bat.

Port #1907's judge hunks: s3, s8, s9-s11, s15bat and the invariants now
count time over the fuse rating. TestS8JudgesAtTheFuse and #1907's
TestInvariantsJudgeAtTheFuse fail on the old judges.

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

* test(site): cap a stretch's events by the fuse heat; a car cut into export is FTW's own

Owner decisions of 28 Oct 2026 for the random judge:

- A car cut FTW sends that pushes export over the fuse is FTW's own, as
  a battery move toward export already was: after a step's meter delay
  and before the stretch, or while the stretch is over the fuse.
- What events add to a stretch (R2 growth) stops at the fuse's heat
  budget: once a phase's 60 s heat passes the fuse, Core's cut gets its
  lever's time and no further event extends the stretch. A window that
  opens after the allowance ran out, with the current under the fuse
  since, is a new step and its cap runs from it. This closes E3 (small
  real rises chaining a stretch's allowance).

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