Repository navigation
fix(control): a restart seeds the fuse heat at most at the working limit - #1919
Merged
Merged
Conversation
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>
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 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>
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>
… 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>
…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>
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>
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>
#1881 (the old base) squash-merged. Conflicts kept the branch's fuseLineA() log line and sitesim meterNoise field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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. |
…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>
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>
…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>
…e 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>
…onger fits comes off The site meter now tells Core how much of a charger's offers it already shows. A move shaped like an order landing (every phase alike, or one phase alone for a one-phase car) moves that level toward the latest offer. A cut the meter shows no longer covers an overage that an outside step causes after it, and no longer nets against a raise sent after it. Where a landing and a house step fall in one reading, a cut covers a phase that has risen since only up to the overage it had then. C4: our own rise on its way, as the meter tells it, is judged as the reading it will make. Above 1.2 x In, or over the line on a phase whose heat budget is spent, it comes off on this reading, down to the line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
seeOffers kept one shown level for every phase, so a house load moving on one phase alone read as a three-phase cut landing and took the cut's cover off the phase it was sent for. With a 40 s charger Core then stacked a second cut and left L1 2 A under the line for over 2 min. The shown level is now kept per phase: a three-phase order moves every phase alike, and a phase that moves alone moves only its own level. The loadpoint now reports a car it has seen draw on all three phases as CarPhases 3. For such a car a one-phase move is never the car, so a house step on one phase while a raise travels no longer hides the raise, and C4 cuts on that reading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The battery fuse guard judged a rise from the battery's own reading and the meter's phases, so it took a rise the meter did not show yet as already shown. With the meter a poll behind the battery, a PV surplus that grew each tick stacked rises past the line, then the relief cut them back: 16.4 A on a 16 A fuse and a charge that rose and fell (C2, seeds 207530, 207535). fitBatteryRisesToFuse caps charge above the battery's latest order to (fuseLineA - worst phase - own unshown rises) x V x phases. The unshown rises are the battery's orders of the last evSettle and the chargers' raises, counted as evShownW counts them, so the car comes first. Holding an order is never a rise and the check never cuts; an overage stays with the relief. No margin and no floor: the order ends at the line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…less phases A battery order now stays unshown until evSettle after the battery's own reading reaches it, so a battery that ramps slower than evSettle no longer gets a second rise on top of an order the meter cannot show yet. On a meter with phase currents but no phase power while the total exports, a phase counts as export only when the other phases cannot carry the export alone; any other phase counts as import. Before, every phase flipped to export, so a three-phase charge could rise into a phase that imports beside two exporting ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With battery_covers_ev off, dispatch took the car out of the meter reading before the battery decided. The battery then saw the PV the car was using as export and charged from it while the site imported for the car (rig seeds 207535, 207539, 207530: about 10 kW import). The battery now charges only from the surplus the meter shows after the car. Where the house alone has surplus, the battery sees the meter with the car included, and any discharge stops at once, since it would feed the car. A planned PV charge on the energy path takes only the surplus the car leaves; a planned grid purchase keeps its charge. Cover off still never discharges the battery for the car. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
fix(control): battery fuse guard reads each phase with its direction
Joins #1878's split of fuse relief (fuse part, import-limit-only part) with the stack's per-phase relief: fuseReliefPartsFromSnapshot takes the phase sample and returns both parts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The four edits #1919's body lists: the heat seeds at fuseLineA(), the restart test expects the 25 A line, the over-the-line subtest says the overage has lasted, and the trim comment states the I2t budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # go/internal/control/ev_battery_priority_test.go # go/internal/control/ev_fuse_budget_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Item C8 from the random-rig triage of 27 October, seen in seed 207539.
After a Core restart, the I² heat model (
fuse_heat.go) set each phase's heat to the first reading's current, as if that overload had lasted. In 207539 the first reading after the restart showed 27.94 A on a 20 A fuse. The model then needed about 43 s to cool under the working limit before an idle car could start.Change
The first reading after a start now seeds each phase's heat at its current, at most the working limit (
SiteFuseAmps - evPhaseMarginA, thelimitAthatevMeterRoomuses). The reading's own current still counts for the time until the next reading. Every later reading works as before.Why this choice and not a cold start, in physics terms:
Cost: a first reading between the line and 1.2 × In now cuts one reading (1 s) later than before.
Tests
New:
TestEVStartAfterRestartDoesNotWaitOutOneHighReading: Core starts on a 35 A (1.4 × In) reading on L1, then the site reads 10 A per phase. Before the fix the car started after 1m27s. After the fix it starts within the 35 sevRestartDwellplus at most 3 s. A reading without room always restarts the dwell, an anti-hunting timer that stays.TestEVHardLimitSustainedOverageAfterRestartCuts: a 25 A fuse, Core restarts, then a sustained 28.75 A (1.15 × In) on L1. The cut lands on the second reading. With a cold seed this test fails, cutting after 1m12s.Updated:
TestFuseHeatStartsAtFirstReadingAfterRestart: 26 A on a 24 A line seeds 576 A², not 676. The held current is still 26 A.TestEVHardLimitRestartStartsHeatAtFirstReadingandTestEVFuseRestartTakesFoundOfferAsShown: the restart cut lands on the second reading (1 s), not the first.issue1868SiteandTestEVHardLimitTrimsPhaseOverageFromOffer: these test cut and yield logic on an overage the fixture treats as lasting. The old seed supplied that heat by accident. A new helper,lastingFuseHeat, now sets it on purpose.Runs:
go test -race ./internal/control/passes andmake verifyis clean (the Docker avahi step was skipped). One earliermake verifyrun failed once ininternal/stateTestLiveBackupPastItsLimitNamesTheLimitAndTable, a time-limit test that does not touch this code, while a second suite was running. It passed alone (3 runs) and on the full rerun. I did not run TestSite/siterig; the runner does.Risk
Low. Only the first reading after a start changes. A sustained overload after a restart is still cut within a second, and the instant cut is unchanged. The change is on the safe side compared with a cold start, which the task offered as the other option.
Stack and merge order
Based on
master, stands alone, merge in any order. Overlaps with open PRs:fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910 (
b-ev-fuse-line-at-rating) removesevPhaseMarginAand makes the lineIn - SiteFuseSafetyA(default 0). Whichever of the two PRs lands second needs these four edits. I checked them with a trial merge of fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910 atd8737e0binto this PR's head; with all four,go test -race ./internal/control/passes.fuse_heat.go,stepFuseHeat: seed withs.fuseLineA()in place ofmath.Max(0, s.SiteFuseAmps-evPhaseMarginA). Without it the build fails.fuse_heat_test.go,TestFuseHeatStartsAtFirstReadingAfterRestart: the fixture has a 25 A fuse and no margin, so the line is 25 A. Expect[625 9 0], not[576 9 0], and say "25 A working limit" in the message. Without it the test reportsfirst reading set [625 9 0].ev_battery_priority_test.go, fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910's new subtestTestEVPriorityFallsBackWhenTheBatteryCannotYield/over_the_working_limit: after its 15.6 A reading, addlastingFuseHeat(tel, st)(and takestin the closure).issue1868Sitenow sets lasting heat at 15.1 A, so one 15.6 A reading on the 15.5 A line stays inside the heat budget and the car is not cut. Under the I²t rule that is correct; the subtest must state that the overage has lasted. Without it the test reportsunsafe battery priority: cap=4140.ev_fuse_budget_test.go: the only text conflict, in the comment aboveTestEVHardLimitTrimsPhaseOverageFromOffer. Keep this PR'slastingFuseHeatline and fix(control): car and battery work to the fuse rating (fuse stack #1910, #1891, #1913, #1923, #1924, #1925, #1926, #1914) #1910's 25 A line and 9,660 W result. Word the comment as "26 A on the 25 A working limit, long enough to spend the heat budget", not "with no earlier reading the fuse heat starts at 26 A".fuse_heat_test.go,ev_fuse_cut_netting_test.goand thefuse_heat.godoc comment merge with no text conflict; edits 1 to 3 are still needed.fix(control): battery fuse cut lands on the line, not half a margin below #1913 / fix(control): battery fuse guard reads each phase with its direction #1891 edit
ev_battery_priority_test.go. I added one line near the end ofissue1868Site.fix(control): fuse protection as one function #1789 (fuse as one function) and fix(control): phase current takes its direction from the phase's power #1782 rewrite
stepFuseHeatfrom an older base. They conflict withmasteralready, and this PR adds one more spot to resolve there.🤖 Generated with Claude Code