Repository navigation
fix(loadpoint): the site-meter limit is the only fuse guard for chargers - #1774
Conversation
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: 6469466063
ℹ️ 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".
| choices := make(map[string]tickChoice, len(observations)) | ||
| for _, observed := range observations { | ||
| if !observed.sample.ConnectionUnknown { | ||
| choices[observed.config.ID] = c.chooseTick(now, observed.config, observed.sample, dispatchAllowed) |
There was a problem hiding this comment.
Restore offers before deriving offline phases
On the first tick after a restart, chooseTick runs before restoreOffers, so offerPeaks is still empty. If a charger is telemetry-live but control-ineligible and continues holding a persisted 1p/auto offer, this branch records an empty phase (normalized to 3p); after restoration, freezeFuseBudget therefore fails to match the saved peak and excludes that charger's watts from baseTotal. When per-phase current data is unavailable, the allocator can then grant another loadpoint the full EV cap while the first charger is still drawing, defeating the fuse clamp. Restore saved offers before computing these choices.
AGENTS.md reference: AGENTS.md:L66-L68
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This was written against 6469466, the first version of this branch. Head 6458300 removes offerPeaks, baseTotal and the branch that took a charger's phase from its last offer, so the order of restoreOffers and chooseTick no longer matters. freezeFuseBudget now only splits control.EVFuseBudget's cap, which is measured from the site meter on the phases FuseConfigs names.
A charger that Core holds out of control is counted on its configured phase mode there. Counting held offers per phase in amps is the follow-up in #1776, which stacks on this branch.
6469466 to
6458300
Compare
The loadpoint decided the phase mode twice per tick. The fuse budget passed an empty surplus dwell and treated a saved battery-SoC unlock as active; the command used the dwell and the armed state. When they differed, the budget counted the last offer as 0 and measured new room on one phase, every tick. The cap then snapped with the config minimum, so a one-phase-sized cap went out labelled 3p. chooseTick now steps the unlock, picks the surplus steps and resolves the phase mode once per loadpoint, before the budget freezes. The budget and the command read that value. The cap snaps to the smallest legal step on the commanded phases (6 A per phase). phaseModeForFuse and tickFusePhases are removed. Charge now with an unarmed unlock on a 25 A site: 4,830 / 0 / 0 W -> 11,040 W. Surplus at 9 kW with an empty or auto phase mode: 2,300-3,450 W labelled 3p -> 8,970 W on 3p. Charge now on a one-phase site: 4,830 / 0 / 0 W -> 4,830 W. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Fredrik's direction (21 Oct 2026): only measured site-meter current counts. The loadpoint kept a second fuse layer on top of control.EVFuseBudget: its own per-phase room from the meter, the last offer per phase mode as a base, and a two-tick wait after a pause. It decided phases apart from the command, which caused R1 and R2. Remove that layer: offer peaks, sharedPhaseIncreaseW, fuseResumeTicks and the fuse_cooldown reason. freezeFuseBudget now splits the control layer's cap between chargers by priority, snapped to legal steps on each command's phases. EVFuseBudget counted phases from the config and relied on the removed layer for runtime auto or 1p offers. main.go now passes FuseConfigs: the configs with the phase mode each command names this tick, so the cap is measured on those phases. Without it, an auto hold at 20 A of house load got 2,070 W and put 29 A on one phase of a 25 A fuse. Tests that only pinned the removed layer are deleted. SetPerPhaseMeterAmps is a no-op until the last test caller, in a file another branch is editing, is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
The 6 A-per-phase floor treated an empty phase mode as 3p. A one-phase charger left on empty with a 3,680 W maximum then had a 4,140 W floor and never charged. An empty mode names no phases; the charger decides, so its configured minimum stands. Only an explicit 3p needs 6 A on each phase. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
The adapter passes FuseConfigs, as main.go does, and drops the no-op per-phase reader. Three known rows now pass and lose their known line: phase mode empty and auto with 9 kW surplus, and Charge now with a saved but unarmed surplus_unlock_bat_soc. The auto row needs FuseConfigs; with the configured "auto" the cap counts one phase. The export rows still fail on control's own unsigned phase current; their known line no longer cites the deleted loadpoint layer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
64b3190 to
04d8027
Compare
Problem
Charging that used to work stopped working. The charging review of master 5e8e417 on 21 Oct 2026 (R1 and R2) traced it to the loadpoint's own fuse layer, which came in with #1657, #1672 and #1695.
That layer sat on top of
control.EVFuseBudget. It read the meter's phase currents itself, kept each car's last offer per phase mode as a base, and made a paused car wait two ticks. It decided the phase mode apart from the command (phaseModeForFuse), with an empty surplus dwell and a savedsurplus_unlock_bat_soccounted as active. When that differed from the command, it counted the last offer as 0 and measured new room on one phase, every tick.Fredrik's direction (21 Oct 2026): make fuse protection as simple as it should be; only measured site-meter current counts.
Change
Remove the loadpoint's fuse layer. Offer peaks,
sharedPhaseIncreaseW,phaseModeForFuse,fuseResumeTicksand thefuse_cooldownreason are gone.freezeFuseBudgetsplits the control layer's cap between chargers by priority, snapped to legal steps.Measure the cap on the phases the command names.
EVFuseBudgetcounted phases from the config. Its comment says the loadpoint's own gate covered runtime auto and manual phase changes, and that gate is now gone.main.gonow passeslpController.FuseConfigs(): the configs with the phase mode each command names this tick. Without this, an auto hold beside 20 A of house load got 2,070 W, which puts 29 A on one phase of a 25 A fuse (TestAutoOfferGetsOnePhaseOfRoomFromEVFuseBudgetfails that way).Decide the phase once per tick, before the cap is read.
chooseTicksteps the battery-SoC unlock, picks the surplus steps (and with them the dwell), and resolves the phase mode.FuseConfigsand the command both read it. A hold changed after the cap was read still waits one tick if it names other phases.Never offer below 6 A per phase. The cap snaps to the smallest legal step on the phases the command names: 6 A on each phase for
3p, 6 A on one phase for1porauto. The 1p/auto rule is the onemanualMinChargeWalready used. An empty mode names no phases; the charger decides, so its configured minimum stands, as before this PR.computeSurplusCmdtakes the picked steps in place of the unusedcurrentEvW.No new settings, timers or rules.
Before and after
Twelve to twenty ticks, 5 s apart; the meter follows the last offer.
surplus_unlock_bat_soc0.9 saved, battery at 50 %fuse_limit, 4×fuse_cooldownEVFuseBudgetsurplus_only, empty orautophase mode, 9 kW surplusfuse_limitfuse_limitEVFuseBudgetThe first surplus offer stops at 6,900 W because the meter shows 13 A of export on each phase and the fuse counts current either way. Control already treats it so; not changed here.
Tests
New. The before column comes from the same scenarios run on master; the new tests need
FuseConfigs, so they do not compile there.TestChargeNowHoldsWithUnarmedBatteryUnlock(loadpoint, 20 ticks) andTestChargeNowWithUnarmedUnlockHoldsUnderEVFuseBudget(control, 20 ticks against the real cap): the offer stays at the charger maximum and is neverfuse_limit.TestSurplusOnlyAutoPhaseOffersLegalThreePhaseSteps: 20 ticks, empty andauto; every offer is 3p, at least 6 A and within the surplus, and within 1 A of it from the second tick. WithFuseConfigsreporting the config instead of the command, theautocase fails.TestChargeNowHoldsOnOnePhaseSite: 20 ticks.TestThreePhaseFuseCapNeverSnapsBelowSixAmps.TestOnePhaseChargerWithEmptyModeCharges: a one-phase charger on an empty mode with a 3,680 W maximum charges at 3,680 W. With a 3p floor for empty it got 0 W.TestAutoOfferGetsOnePhaseOfRoomFromEVFuseBudget(control, against the real cap).The loadpoint tests use
meteredEVCap, a stand-in forEVFuseBudget: the cars' offers plus the metered room on the phasesFuseConfigsnames.Deleted, because they pinned only the removed layer:
TestFuseBudgetRecoversAfterTwoSafeTicks,TestNewConnectionDoesNotInheritFuseRecovery: the two-tick wait.TestSharedPhaseBudgetDoesNotIntegrateSameOverload,TestLoneZeroReadingKeepsOfferThroughPhaseGate,TestSiteFuseHotReloadAppliesToPhaseIncreaseGate: the loadpoint's own phase gate.TestEVHardLimitHoldsOfferThroughLoneZeroSamplein control covers a lone 0 W reading.Changed:
TestSharedFuseGuardUsesCommandPhase,TestAutomaticManualReleasePrecedesSharedPhaseBudget,TestUnspecifiedSurplusPhaseKeepsSinglePhaseFuseBound,TestOnePhaseCarsShareWorstPhaseBudget: the cap now comes frommeteredEVCapinstead of a fixed number plus the removed gate. Expectations are unchanged.TestManualPhaseChangeAfterSharedBudgetWaitsForNextTick: it started a 3p hold at 20 A per phase, which only ran because the old snap gave it 1,380 W (2 A per phase). It now starts at 17 A. The 1p → 3p case expects 0 W instead of 1,380 W on three phases.ev_battery_sequence_test.go,ev_deadline_runtime_test.go) passFuseConfigs()asmain.godoes.sitesim_test.go): the adapter passesFuseConfigs()and drops the per-phase reader. Three known rows now pass and lose their known line: phase mode empty andautowith 9 kW surplus, and Charge now withsurplus_unlock_bat_socsaved but not armed. Theautorow passes only withFuseConfigs(); with the configured mode the cap counts one phase. The export rows still fail as known on control's unsigned phase current; their known line no longer cites the deleted loadpoint code.go test ./internal/control/... ./internal/loadpoint/... ./internal/sitesim/... ./cmd/ftw/ -p 2andmake verifypass on master 200da78.Behaviour to review
fuse_cooldownis no longer sent. The web, API and status code still render it if an older stored reason has it; removing that is a follow-up.Not touched, on purpose
go/internal/control/ev_fuse_budget.goand its test belong to the open slow-charger branch (fix/ev-fuse-rise-stacking). Two leftovers wait for it:evPoolForstill says the loadpoint's gate covers runtime phase changes.FuseConfigsdoes that now.ev_fuse_budget_test.gostill callsSetPerPhaseMeterAmps, so that setter stays as a no-op until the call is gone.evPoolForcounts a charger on an empty mode as three phases, as before. A one-phase charger left on empty gets three phases of room. Counting each car's commands per phase in amps is the follow-up in fix(control): count EV raises per phase until the charger can apply them #1776, which stacks on this branch.No open PR touches
go/internal/loadpoint. #1763 changesgo/internal/control/ev_battery_headroom*.goonly.🤖 Generated with Claude Code