Repository navigation
fix(control): battery fuse guard reads each phase with its direction - #1891
Conversation
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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a840def5dd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| perPhase := perPhaseReliefW(store, state) | ||
| // Per phase, each way, once the targets replace the batteries' power | ||
| // in the same raw reading. | ||
| phaseImportW, phaseExportW := phaseFuseReliefW(meter, state, sumTarget-rawBat) |
There was a problem hiding this comment.
Calculate phase relief after applying the held target caps
When a planner repeatedly requests more charging than a phase permits, this now computes relief against the original request, but the hold-mode block below reduces the target before that relief is subtracted, counting the same reduction twice. Replaying the production functions with a 16 A, three-phase site, zero configured safety margin, baseline currents of 13/7/5 A and a repeated 4000 W charge request produces targets of 1380 → 0 → 1380 → 0 W even when the battery follows each command exactly. The second tick unnecessarily removes a charge already capped safely at 15 A, causing sustained dispatch oscillation. Apply the held caps before calculating target-dependent overages, or recompute relief afterward.
AGENTS.md reference: AGENTS.md:L68-L68
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 2f793d8. The guard now computes the aggregate and phase overages from the targets after the hold block. TestFuseGuardHeldChargeHoldsSteady repeats this case through applyFuseGuard and Charge mode: it holds 1380 W with L1 at 15 A. With the relief taken from the request it gives 1380/0/1380/0.
The stop on a blocked pack fell back to the whole slewed ramp whenever it would trip the fuse, and the gate checked the phases only as the meter shows them now. On an unbalanced site the stop could put one phase over the breaker. Now the blocked packs keep only the discharge that capable packs cannot cover, shared by their ramps and never more than the ramp. A projection of the stop onto each phase keeps the worst phase at its limit; the blocked packs cover that part alone, because forceFuseDischarge does not project phases until #1891 lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
…elow 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>
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>
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>
2f793d8 to
c311760
Compare
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>
…1876) * fix(control): slew no longer reopens discharge on a blocked battery The slew limiter anchors on measured power, so a pack the distributor parked at 0 W (SoC below 5 % or discharge_capable=false) was walked back toward its live discharge. Add floorBlockedDischarge, the twin of floorBlockedCharge, before forceFuseDischarge. It keeps the slewed ramp only when stopping at once would put the site over its fuse or import limit. Item A3 of the stable plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): blocked discharge keeps only the fuse need, per phase The stop on a blocked pack fell back to the whole slewed ramp whenever it would trip the fuse, and the gate checked the phases only as the meter shows them now. On an unbalanced site the stop could put one phase over the breaker. Now the blocked packs keep only the discharge that capable packs cannot cover, shared by their ramps and never more than the ramp. A projection of the stop onto each phase keeps the worst phase at its limit; the blocked packs cover that part alone, because forceFuseDischarge does not project phases until #1891 lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): blocked-discharge phase relief reads live raw power stopPhaseReliefW read RawW straight off cached meter and battery readings, which the telemetry-read ratchet rejects. It now takes the raw totals from Store.OnlineMeterW and a new Store.OnlineRawW, and gives no phase relief when either driver has gone offline or moved on to a newer reading than the rest of the decision used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…533) 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>
This branch carried a rebased copy of #1891's first two commits. The merge keeps #1891's tree (now with step 4: relief to fuseLineA, no 1 A floor) and re-applies only this PR's own change: the cut by the overage, with no half-margin buffer. Expected values move to the new line: TestPerPhaseClampHonorsSafetyMargin 3450 W (L1 on 15 A), TestPerPhaseReliefMatchesSinglePhaseSiteOnExport -3045 W, the #1868 charge floor 3862 W, and golden seeded_fuse/063 (-172.5 W) and /077 (+345 W), each by exactly the old buffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review of the step-4 commits (a11706b, c311760), which had not been reviewed.
Stack synced today by merges, with no force-push. Heads: #1910 d8737e0, #1891 8a7c2be, #1913 610e2a1. 🤖 Generated with Claude Code |
Brings in master's #1876 (7840c33) and #1906 through #1910. The merge has no text conflict, but #1876's blockedDischargeKeepW called the old six-argument fuseReliefFromSnapshot and did not build. Resolution: blockedDischargeKeepW now passes the live phase sample to fuseReliefFromSnapshot, which already reads the phases as the stop leaves them. stopPhaseReliefW duplicated that math and is gone. Its live reads move into phaseSample.moveW: each battery's raw power comes from Store.OnlineRawW and must be the reading the decision used, so an offline battery, a battery that moved on to a newer reading, or a target with no reading gives no phase relief. #1876's test now runs through phaseReliefForTargets. 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>
# Conflicts: # go/cmd/ftw/main.go
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
fix(control): a landed EV cut covers nothing later; a raise that no longer fits comes off
fix(control): battery fuse cut lands on the line, not half a margin below
…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>
…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>
…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>
Item A5 of the stable plan (wave 1, group A). It also implements step 4 (PR a) of the fuse-at-rating plan, under the owner's rule of 27 Oct: each phase's working limit is
In − safety_margin_a, with no floor under the margin. High risk: land it alone, after a green rig run.Merge after #1910. This branch now starts from
b-ev-fuse-line-at-rating(#1910, step 3), which moves the car to the same line. The battery's line must not move before the car's, or the battery holds a phase where the car still sees an overage and the car gets cut. Never ship this in a beta without #1910.Problem
The battery fuse paths read each phase current as a magnitude and took the direction from the site total. They also ignored the battery's own share of the phase.
phaseSentReliefW) held the battery at its last command. The guard cut the discharge for the exporting phase, and the bound put the cut back. Three-phase battery at -2000 W, L1 at 16.0 A, one-phase PV on L3 at -18 A: the order stayed at -2000 W and L3 stayed at 18 A on every tick. A one-phase battery on L2, once PV returned: L1 at 16.14 A pinned the order at -4882 W, and L2 exported 19.86 A (1.24 × In) on every tick.Change
perPhaseOverageW,perPhaseOverageReadingWandperPhaseReliefW, and theaccountPhaseTargetDeltaflag.phaseFuseReliefW. It readsphaseCurrentsA(which keeps fix(control): phase current takes its direction from the phase's power #1849's sum check and dead zone) and adds the batteries' move from their raw power, spread evenly over the phases. It returns the import relief and the export relief separately.Each battery's raw power comes from
Store.OnlineRawWand must be the reading the decision used (phaseSample.moveW). A battery that went offline, moved on to a newer reading or has no reading gives no phase relief, as fix(control): slew no longer reopens discharge on a blocked battery #1876's stop relief did. The earlier version read the cachedRawWwith no health gate.Battery reading lags the meter. A move from each battery's last command (the one it got in the last dispatch) may not pass the room the meter shows either (
phaseSentReliefW). This bound only tightens. A phase already over the line gives it no room, so it never asks for more than to stay at the last command, and a battery that falls short of its order does not wind it up.Mixed phases. When any phase is over the export line, the bound asks for no import relief, and when any phase is over the import line, it asks for no export relief.
phaseFuseReliefW's least-over move then decides alone, as it did before the bound.Anti-windup. The relief toward the worst phase stops once the meter cannot show the batteries' move there (
phaseUnresponsive). That needs the batteries' raw power to have changed by one ampere per phase (phaseResponseA, 690 W on 3 × 230 V) or more, and one of two signs:A live meter on which the worst phase stands still is not enough. A load on that phase can cancel the move, at once or step by step, and there the relief goes on (third review). One ampere is the step in which P1 meters report phase current, so it is the smallest move every supported meter must show. The worst phase's reference ends when that phase moves by half an ampere or more, when it is at or inside the line, or when another phase becomes the worst. Without the second, a reference kept while the relief held a phase at the line through a slow drift counted a later load change on another phase against everything the battery had moved since (fourth review). The worst phase is the one with the largest current either way, so a one-phase battery's own phase, which does show its moves, takes over once it is the worst.
A wrong off-phase verdict on a balanced battery corrects itself: going back moves the worst phase by at least one ampere (the move the verdict needs), which ends the reference on the next reading. It costs one tick over the line, not an open-ended hold.
Import is cut only for a phase that imports over the line, and export only for one that exports over it.
If no single move keeps every phase inside, it picks the move that leaves the worst phase least over. On a meter that reports phase power (
l*_w), a move never raises the current on the phase it protects.If the meter gives no direction per phase (no
l*_w) while the total exports, the phases take the total's direction, as before. See "Known limits" below.It reads the phases only while the meter still reports (
Store.OnlineMeterW, same reading as the currents). A watchdog-offline meter's cached phases drive no relief.Relief under 1 W counts as none. Without this, a raise that exactly fills the line tripped the guard on a rounding error and cut it by the buffer again and again.
fuseLineA(): the fuse lesssafety_margin_a, the same line the car uses after fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910. There is no 1 A floor. At margin 0 the battery works to the rating; at 0.5 A it works to In − 0.5 A. The owner approved one line for car and battery on 27 Oct.In − max(1 A, safety_margin_a)so the battery would not hold a phase above the car's fixed In − 1 A. fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910 removes the car's 1 A, so the floor goes too.applyFuseGuardtakes the larger of the aggregate overage and the phase overage, separately for each direction. It computes both from the targets after the 30 s fuse hold has capped them. Computed from the request, the hold's cut counted twice: a charge limited by one phase switched between its cap and 0 W on every dispatch (Codex P1).check_telemetry_reads.pyreports "none added" both againstorigin/b-ev-fuse-line-at-ratingand againstorigin/master.phaseSample.moveWreads each battery throughStore.OnlineRawW, so it adds no cached read. fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910's own failure (ev_fuse_budget.go5 → 7,dispatch.go41 → 42) is fixed on fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910 (97eb612) and merged here (a6da34d); the one conflict, infuseSaverAtLivePower, keeps this branch's phase-sample call. fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910's offline-relief fixes (8150b45, ddab122) are merged here too (2c1f827, no conflicts);go test -raceon./internal/control/ ./cmd/ftw/ ./internal/telemetry/andmake verifypass on it.#1876 is absorbed. #1876 (A3) landed on master first. Merging it gives no text conflict, but its
blockedDischargeKeepWcalled the old six-argumentfuseReliefFromSnapshotand did not build. The merge commit passes the live phase sample instead, and deletesstopPhaseReliefW:fuseReliefFromSnapshotalready reads the phases as the stop leaves them, so forceFuseDischarge and the blocked packs now share one phase rule. #1876'sslew_discharge_floortests and goldens pass unchanged; its live-reading test now runs throughphaseReliefForTargets.Still open: #1878 (A4) also changes
fuseReliefFromSnapshot. If it lands first, this branch takes it the same way; if after, it should callphaseReliefForTargetsrather than keep its own phase math.Tests
go test -race -count=1 ./internal/control/ ./cmd/ftw/ ./internal/telemetry/: ok on 8c3ae3c (170 s, 126 s, 1 s; b36e951 only tightens one test), with the reviewers' uncommitted suites added, apart from one reviewer test whose whole-run bound no balanced model can meet (see the fourth review below).check-telemetry-reads.shagainstorigin/masterand againstorigin/b-ev-fuse-line-at-rating: 133, none added.check-no-planning-docs.sh: none added. Thetest_telemetry_readsandtest_verifyunittests: OK.make verify: clean on b36e951 (vet, test, build; the Docker step skips locally).New in
fuse_battery_phase_direction_test.go(the repro tests from caa-185, caa-167 and caa-187 under normal names, plus the step 4 property test):TestBatteryFuseLineProperty: In ∈ {16, 20, 25, 35} A × {1, 3} phases × margin ∈ {0, 0.5, 1.2} A, import and export, with L = In − margin:applyFuseGuardunchanged, unclamped and with no hold.max(1 A, safety_margin_a)line.TestFuseGuardHeldChargeHoldsSteady, margin 0: a 4000 W charge on a 13/7/5 A house with a 16 A fuse holds 2070 W with L1 at 16.0 A on every repeat, throughapplyFuseGuardand through Charge mode. With the relief taken from the request it switched between the cap and 0 W.TestPerPhaseFuseReliefHoldsWhileOverloadPersists, margin 0.5: L1 holds at 15.5 A on every tick while a 20 A load persists.TestPhaseReliefSkipsOfflineMeter: an offline meter's phases drive no relief.TestFuseReliefStaysAtTheLineAfterPVDrop(C3, the 207533 shape), margin 0: the battery holds -3005 W with L1 at 14.45 A, then PV drops 1280 W and L1 reads 16.3 A. ThroughComputeDispatch, the relief brings L1 to 16.0 A and holds -3214 W on every tick while PV stays low. L1 crosses the rating once, on the PV step. On fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910 without this PR it fails: the target flips between -3214 W and -3005 W on every tick and L1 crosses 13 times.TestFuseReliefHoldsWhenTheBatteryReadingLags(the review's lag case): the 207533 shape, with the battery's reading one or two ticks behind the meter. The order holds -3214 W with L1 at 16.0 A and one crossing. Without the last-command bound it fails: with a lag of 1 the order runs -3214, -3005, -3422, -3005, … -5717 W and L1 crosses 13 times (10 with a lag of 2).TestFuseReliefSettlesWhenTheBatteryFallsShort: the battery delivers at most 3100 W of discharge and Core does not know it. The order settles at -3214 W and goes back to the -3005 W hold on the tick after PV returns. If the bound also counted the overage, it would wind up to -5940 W, and that test fails.TestFuseReliefLeavesMixedPhasesLeastOver(the reviewer's mixed case): the order moves to -1311 W and leaves L1 at 17 A and L3 at -17 A. Without the mixed-phase rule it stays at -2000 W with L3 at 18 A, and the test fails.TestFuseReliefLetsAOnePhaseBatteryOffItsOwnPhase(the reviewer's one-phase battery on L2, L1 at 18 A), checked on ticks 6–39 since the fourth review. The first relief step (-1378 W) lands on L2 alone: -17.97 A for one tick. That step is what shows the battery off L1. The battery then goes back to -3496 W, and L2 is inside the line from tick 7 until PV returns. L2 stays under 1.2 × In on every tick. After PV returns it creeps to -16.1 to -16.23 A (see Known limits). On c066892 L2 held -17.97 A on ticks 6–29 and reached -19.83 A (1.24 × In) on the PV return tick, and the test now fails there.TestFuseReliefStopsWhenTheMeterIsFrozen(frozen@5): the order stops at -3839 W from tick 8, with the true L1 at 15.09 A, and stays there. Without the anti-windup rule it runs to -10000 W.TestFuseReliefStopsWhenTheBatteryCannotReachTheWorstPhase(one-phase battery on L2, L1 at 16.3 A): the order stops at -3826 W, then goes back to the -3005 W hold from tick 10 on, and L2 stays inside the line on every tick (-9.85 A while PV is low, -11.7 A after). On c066892 it held -3826 W, with L2 at -13.42 A and -15.27 A on the PV return tick. Without the rule it winds to -4512 W with L2 at -16.4 A.TestPhaseUnresponsiveNeedsOneAmpereOfBatteryMove: pins the thresholds. With the reading repeated, 689 W is not stuck and 690 W is. With L1 0.49 A off and L2/L3 still (a load cancels the move) or one share each (a load on L1), it is not stuck; L2 at 1.99 shares is not, at 2 shares it is. A 0.5 A move on L1 sets a new reference. A meter that repeats a reading 0.1 A off the reference is stuck after 690 W.TestFuseReliefFollowsALoadAfterTheReadingCatchesUp: the battery's reading is one tick behind the meter (207537). PV falls 2760 W and L1 reads 18.45 A, one -4694 W step brings it to 16.0 A, and a 0.4 A load starts on L1 at tick 15. Before, the reading's catch-up counted as a move the meter did not show: the order stayed at -4694 W and L1 at 16.4 A for the last 45 ticks. Now the order goes to -4970 W and L1 is back at 16.0 A on the next tick.TestFuseReliefGoesOnWhenALoadMasksTheStep: a balanced 2 A load per phase starts in the reading that shows the -4694 W step, so L1 reads 18.0 A. Before, the order stayed at -4694 W with L1 at 18 A (1.125 × In) to the end. Now it goes to -6074 W and L1 is at 16.0 A from tick 7.TestFuseReliefFollowsASlowLoadRise: L1's load rises 0.15 A a tick for 30 ticks. Before, L1 climbed to 16.9 A, fell back and climbed again. Now L1 reads 16.15 A on each rise tick and 16.0 A after.TestFuseReliefStopsWhenTheMeterFreezesOffTheReference: the meter freezes on a reading 0.1 A from the worst phase's reference. The order stops after 690 W. It passes on 6a5a5f5 too; it fails if the repeat is counted from the worst phase's reference (the order runs past -6800 W).TestA2ReadLagLatchesAfterOneBigRelief,TestA2SlowLoadRampOnL1andTestA2BalancedLoadMasksTheReliefStep(not committed) pass.TestFuseReliefStopsWhenTheMeterIsFrozenfails. Without the off-phase rule, both one-phase battery tests fail. With the old rule (any live meter), the three new review tests fail. With the off-phase test at one share, the slow-rise test fails.adv_b52_test.go,adv_b52_offline_test.go,zz_review_c3_test.go) and everypvDropRuncase (frozen@5, lag 1 and 2, falls short, both one-phase cases, one-phase with lag, frozen with lag) give the same orders as on 6a5a5f5. No golden record changed.TestFuseReliefGoesOnWhenAWholeAmpereMeterRepeatsItsPhases: the meter reports whole amperes. L1 reads 18 A after a 2070 W PV drop, and a balanced 1.8 A load per phase starts in the reading that shows the -1380 W step, so every phase rounds to the same ampere. Before, that counted as a frozen meter: the order stayed at -4385 W and the true L1 at 17.8 A (1.11 × In) to the end. Now the relief goes on and L1 is at or under the line from tick 8.TestFuseReliefFollowsALoadAfterADriftAtTheLine(the reviewer's stale-reference case): the relief follows a 69 W-a-tick PV fall, a 2 kW load on L3 switches off, then a 0.4 A load starts on L1. Before, the order stayed at -3904 W and L1 at 16.4 A to the end. Now L1 is over the line only on the tick the 0.4 A load starts.TestFuseReliefRecoversFromAFalseOffPhaseVerdict: the same with a 23 W-a-tick fall that goes on through a 1 kW load change on L3, so L1 reads 16.03 A, over the line, on every tick and the reference stays. Before, the order stayed at -3697 W and L1 climbed to 16.7 A, where it stayed. Now L1 is at 16.9 A for one tick and then back at the line.TestPhaseUnresponsiveOffPhaseVerdictHoldsUntilTheReferenceEnds: the verdict and itsbackTohold after the battery is back; the reference ends at the line, on a half-ampere move, and when another phase becomes the worst; repeated phases with a moved total are not frozen.TestA2cStaleOffPhaseReference,TestA2cIntegerMeterBalancedLoadMasksStep(noise 0, 0.1, 0.2 A),TestA2cCentiAmpMeterBalancedLoadMasksStepandTestA2cMeterOfflineGapThenReliefpass.TestA2cOnePhaseBatteryDoesNotPushItsPhasePastTheExportLinestill fails: it wants L2 inside the line on every tick, which the first relief step breaks for one tick (-17.97 A) on any balanced model. The committed one-phase test checks the rest.adv_b52_test.go: only the three one-phase cases changed, all toward less export on the battery's phase (for example '1p on L2, L1 16.3' now goes back to the hold with L2 at -9.85 A instead of holding -13.42 A). Frozen, lag, export, margin and ramp cases give the same numbers as on c066892, including frozen@5 at -3839 W.pvDropLoopnow wrapspvDropRun, which adds a frozen meter, a one-phase battery and a custom load to the 207533 shape.adv_b52_test.go, not committed) passes on the new head: meter lag stays bounded (5 crossings), the export-side read lag holds, the offline second battery keeps its relief, and the sign-flip and one-phase-on-L2-at-default-load cases give the same orders as before this change.TestPhaseReliefNeedsTheBatteryReadingItUses: an offline battery, a newer battery reading and a missing reading give no phase relief. With the cachedRawWread, the first two relieve 2310 W.Changed tests, now measured at
In − safety_margin_a:fuse_saver_test.go: the bumps that only kept the 1 A results are gone. The margin tests are back at 0.5 A and 1 A, and the deadband tests are back at 16 A. Four tests that asked for a raise on top of a phase already over the fuse now start from a phase with room (L1 at 13 A, or 10 A with a 1 A margin), because the netted relief removes the whole raise. Their expected values are 2070 W, 2070 W, 2160 W (240 V) and 3105 W.ev_battery_sequence_test.go: signature change only.TestIssue1868BatteryChargeYieldsBeforeTheCar: the Charge-mode floor is now 3689 W. No phase reaches the 15.5 A line, so only the hour's limit binds: the guard cuts the 69 W overage and half the margin to 3758.5 W, and the car's headroom takes the same 69 W again (the double cut, W2-6). The 3931 W upper bound is unchanged.TestIssue1868ClosedLoopPriorityAndRecoveryandTestSiteSimFieldMatrixstay green with fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910 underneath, which is the case the old floor was added for.Golden changes (15 of 551 records)
I replayed each record from its stored inputs and replaced only the records whose dispatch changed against the parent branch. The inputs and every other record are byte-identical.
ftw_commitmoves to a11706b in the 3 files that changed.With no floor, the 22 attribution-only records and the 9 records that stopped at the 1 A line in the earlier version are back to their master values.
The phase columns are worked out from each record's own inputs, with a balanced battery; + is import.
plan_sign_floor_zeroedbecomes true. The guard can only shrink a discharge; it cannot add charge, so the phase stays over the line, but less than on master.Risk
Known limits
Meter behind the battery's reading. When the meter shows the battery's power one tick late, the relief over-relieves for a tick and then gives it back. In the 207533 shape this is a bounded 6-tick cycle between -3005 and -3422 W, with L1 between 15.7 and 16.3 A: 5 crossings in 25 ticks (13 on fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910's code). It does not grow. The opposite case, the battery's reading behind the meter, is fixed here (see Change). The rig saw a lagging battery reading in 207537 at 46–48 s.
Meters with no
l*_w. They keep the old direction rule: when the total exports, every phase counts as export. With mixed directions this is wrong for the importing phase. Example on a 16 A fuse, margin 0: L1 imports 18 A, L2/L3 export 12 A, the total exports 1380 W, and the battery discharges 2000 W. The guard reads L1 as 18 A of export and cuts the discharge to -620 W, as master does, so L1's import rises from 18 A to 20.0 A. Counting such phases as import instead would stop the guard from protecting plain PV export on these meters, which is the common case.A battery on one phase of a three-phase site is still modelled as balanced, as before. The anti-windup rule keeps it from running out for a phase it cannot reach, but that phase stays over the line (L1 at 16.3 A in the test above) until load or PV moves.
While the meter does not show the battery, the relief holds. Frozen meter: the battery moves at most one ampere per phase plus one relief step past the first relief before it stops (-3839 W in frozen@5, true L1 at 15.09 A). It holds while the meter repeats, until the frozen-meter check (C5, fix(control): a site meter that repeats one reading counts as stale #1921) stops dispatch. A battery off the worst phase goes back to its power when that phase last moved and stays there until that phase moves half an ampere, comes inside the line, or another phase becomes the worst.
The off-phase test reads the pattern on the phases, not the battery's wiring. A load change on another phase can look like a battery off the worst phase while that phase sits over the line. The battery then goes back, which moves the worst phase by at least one ampere and ends the verdict on the next reading: one tick over the line (16.9 A in the false-verdict test). Only a second load that cancels that move on the worst phase in the same reading would make the hold last; no time bound or heat budget ends it then.
Export overage while the battery charges.
applyFuseGuardcan only shrink a discharge or a charge; it never adds charge. With the battery charging and a phase exporting past the line, nothing relieves it. In the mirrored 207533 shape (battery holding +3005 W, PV pushing L1 to 16.3 A of export) L1 stays at -16.3 A for all 25 PV-high ticks, on fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910's code and on this PR alike. The heat model allows that duration at 16.3 A. This predates this PR.The half-margin buffer in
applyFuseGuardstill lands a cut below a margined line. It is 0 at margin 0. Step 5 (PR c) removes it.Stack and merge order
b-ev-fuse-line-at-rating, basemaster): the car's line.a5-battery-fuse-signed-phases, baseb-ev-fuse-line-at-rating): the battery's line.c-battery-fuse-no-buffer, base this branch): drops the half-margin buffer.d-fuse-margin-default-0, base fix(control): battery fuse cut lands on the line, not half a margin below #1913):safety_margin_adefaults to 0.On 28 Oct, 8c3ae3c and b36e951 fix the fourth review: the worst phase's reference ends at the line, a battery off the worst phase goes back instead of holding, and the frozen test includes the meter's total (see Change). On 28 Oct, c066892 fixes the third review: the anti-windup no longer stops the relief on a live meter where a load cancels the battery's move or where the battery's reading catches up (see Change). On 28 Oct, 6a5a5f5 fixes the second review's mixed-phase pin and wind-up (see Change); it does not depend on #1921. On 28 Oct I merged #1910's telemetry-read fix (97eb612) into this branch. On 27 Oct, after #1876 and #1906 landed, I merged
masterinto #1910 again (4493e94; clean,go test -race ./internal/control/ ./cmd/ftw/ok), then #1910 into this branch with the build fix above. No force-pushes. #1913 and #1914 do not have these commits yet; merge this branch into #1913, then #1913 into #1914.#1878 is not in the stack. See "Still open" above.
Lands together with #1913
Do not merge this PR without #1891 and #1913 right after it, with no beta cut in between. On this PR alone the battery beside a minimum-step car charges up to the fuse line, and
applyFuseGuardtrips at the same line and cuts by the overage plus a hidden half margin, held 30 s. The battery then hunts every 30–40 s. Rig s18-16 failed 3 of 57 runs on this stack, never on master. #1913 (3efb3ed) cuts by the overage only;TestBatteryBesideMinimumCarHoldsTheFuseLineon #1913 fails 40 of 40 seeds on #1910 and #1891 and 0 of 40 on #1913.🤖 Generated with Claude Code