Skip to content

fix(control): a restart seeds the fuse heat at most at the working limit - #1919

Merged
frahlg merged 103 commits into
masterfrom
heat-filter-cold-start
Oct 28, 2026
Merged

frahlg merged 103 commits into
masterfrom
heat-filter-cold-start

Conversation

@frahlg

@frahlg frahlg commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

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, the limitA that evMeterRoom uses). 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:

  • FTW cannot know the fuse's heat across a restart. A fuse cools over minutes and a restart takes seconds, so a cold model (0 A²) is wrong whenever the fuse was warm. It would also give a fresh budget to an overload that was already running. From cold, 1.15 × In takes about 85 s to reach In (72 s to reach a line 1 A under it). The test below shows a cold seed lets 1.15 × In run 72 s after the restart.
  • Seeding at the line treats the fuse as being at the most heat FTW's own control allows. Any reading over the line then spends the budget at once, so a sustained overload is cut on the next reading.
  • One reading cannot show that an overage has lasted, so the model no longer charges it as if it had. The instant cut above 1.2 × In has not changed and still applies to that first reading. In 207539, 27.94 A is 1.4 × In, so it falls under the instant cut anyway.

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 s evRestartDwell plus 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.
  • TestEVHardLimitRestartStartsHeatAtFirstReading and TestEVFuseRestartTakesFoundOfferAsShown: the restart cut lands on the second reading (1 s), not the first.
  • issue1868Site and TestEVHardLimitTrimsPhaseOverageFromOffer: 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 and make verify is clean (the Docker avahi step was skipped). One earlier make verify run failed once in internal/state TestLiveBackupPastItsLimitNamesTheLimitAndTable, 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:

🤖 Generated with Claude Code

frahlg and others added 16 commits October 27, 2026 14:11
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>
@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T18:42:57.423123Z 0e48dc5 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

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

frahlg and others added 8 commits October 27, 2026 19:42
Brings #1910, now up to date with master, under #1891. Clean merge.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(config): fuse safety_margin_a defaults to 0
fix(control): after a restart the battery credits the car the meter shows
fix(control): the car comes before battery charge
fix(control): a battery charge rise fits each phase before it is sent
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
@frahlg
frahlg merged commit 22d0bd3 into master Oct 28, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant