Skip to content

fix(loadpoint): the site-meter limit is the only fuse guard for chargers - #1774

Merged
frahlg merged 4 commits into
masterfrom
fix/loadpoint-one-phase-decision
Oct 21, 2026
Merged

frahlg merged 4 commits into
masterfrom
fix/loadpoint-one-phase-decision

Conversation

@frahlg

@frahlg frahlg commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

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 saved surplus_unlock_bat_soc counted 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, fuseResumeTicks and the fuse_cooldown reason are gone. freezeFuseBudget splits the control layer's cap between chargers by priority, snapped to legal steps.

Measure the cap on the phases the command names. EVFuseBudget counted 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.go now passes lpController.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 (TestAutoOfferGetsOnePhaseOfRoomFromEVFuseBudget fails that way).

Decide the phase once per tick, before the cap is read. chooseTick steps the battery-SoC unlock, picks the surplus steps (and with them the dwell), and resolves the phase mode. FuseConfigs and 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 for 1p or auto. The 1p/auto rule is the one manualMinChargeW already used. An empty mode names no phases; the charger decides, so its configured minimum stands, as before this PR.

computeSurplusCmd takes the picked steps in place of the unused currentEvW.

No new settings, timers or rules.

Before and after

Twelve to twenty ticks, 5 s apart; the meter follows the last offer.

Case master this branch
Charge now, 25 A fuse, 3 A house load, empty phase mode, surplus_unlock_bat_soc 0.9 saved, battery at 50 % 4,830 / 0 / 0 W, repeating; 8× fuse_limit, 4× fuse_cooldown 11,040 W every tick, also under the real EVFuseBudget
surplus_only, empty or auto phase mode, 9 kW surplus 2,300, 3,220, then 3,450 W labelled 3p (3.3–5 A per phase, the car does not charge); 12× fuse_limit 6,900, then 8,970 W on 3p
Charge now 4,830 W on a one-phase 25 A site, 3 A house load 4,830 / 0 / 0 W, repeating 4,830 W every tick
3p hold, 20 A on every phase 2,760 W labelled 3p (4 A per phase) 0 W, fuse_limit
Auto hold, 20 A on every phase, real EVFuseBudget 0 W (loadpoint gate) 0 W (cap measured on one phase)

The 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) and TestChargeNowWithUnarmedUnlockHoldsUnderEVFuseBudget (control, 20 ticks against the real cap): the offer stays at the charger maximum and is never fuse_limit.
  • TestSurplusOnlyAutoPhaseOffersLegalThreePhaseSteps: 20 ticks, empty and auto; every offer is 3p, at least 6 A and within the surplus, and within 1 A of it from the second tick. With FuseConfigs reporting the config instead of the command, the auto case 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 for EVFuseBudget: the cars' offers plus the metered room on the phases FuseConfigs names.

Deleted, because they pinned only the removed layer:

  • TestFuseBudgetRecoversAfterTwoSafeTicks, TestNewConnectionDoesNotInheritFuseRecovery: the two-tick wait.
  • TestSharedPhaseBudgetDoesNotIntegrateSameOverload, TestLoneZeroReadingKeepsOfferThroughPhaseGate, TestSiteFuseHotReloadAppliesToPhaseIncreaseGate: the loadpoint's own phase gate. TestEVHardLimitHoldsOfferThroughLoneZeroSample in control covers a lone 0 W reading.

Changed:

  • TestSharedFuseGuardUsesCommandPhase, TestAutomaticManualReleasePrecedesSharedPhaseBudget, TestUnspecifiedSurplusPhaseKeepsSinglePhaseFuseBound, TestOnePhaseCarsShareWorstPhaseBudget: the cap now comes from meteredEVCap instead 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.
  • Control integration tests (ev_battery_sequence_test.go, ev_deadline_runtime_test.go) pass FuseConfigs() as main.go does.
  • Site simulation (test(control): closed-loop site simulation with a real fuse #1775, sitesim_test.go): the adapter passes FuseConfigs() and drops the 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 surplus_unlock_bat_soc saved but not armed. The auto row passes only with FuseConfigs(); 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 2 and make verify pass on master 200da78.

Behaviour to review

  • A paused car resumes as soon as the control cap has room for its minimum. Control's own start rule (35 s of room for the minimum plus a step) and one-step rises still apply.
  • fuse_cooldown is 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.
  • The fuse budget reads this tick's battery-SoC unlock state, not the last tick's.
  • The surplus steps are picked before the surplus reading is checked, so on a missing reading the dwell may be set from the forecast. A stale site meter already stops dispatch before this point.

Not touched, on purpose

go/internal/control/ev_fuse_budget.go and its test belong to the open slow-charger branch (fix/ev-fuse-rise-stacking). Two leftovers wait for it:

  • The comment in evPoolFor still says the loadpoint's gate covers runtime phase changes. FuseConfigs does that now.
  • ev_fuse_budget_test.go still calls SetPerPhaseMeterAmps, so that setter stays as a no-op until the call is gone.
  • evPoolFor counts 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 changes go/internal/control/ev_battery_headroom*.go only.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 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-21T09:31:12.795220Z 6469466 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@frahlg
frahlg force-pushed the fix/loadpoint-one-phase-decision branch from 6469466 to 6458300 Compare October 21, 2026 09:42
@frahlg frahlg changed the title fix(loadpoint): one phase choice for the fuse budget and the command fix(loadpoint): the site-meter limit is the only fuse guard for chargers Oct 21, 2026
frahlg and others added 4 commits October 21, 2026 13:40
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>
@frahlg
frahlg force-pushed the fix/loadpoint-one-phase-decision branch from 64b3190 to 04d8027 Compare October 21, 2026 11:41
@frahlg
frahlg merged commit fc621be into master Oct 21, 2026
23 checks passed
@frahlg
frahlg deleted the fix/loadpoint-one-phase-decision branch October 21, 2026 12:19
@frahlg
frahlg restored the fix/loadpoint-one-phase-decision branch October 21, 2026 12:19
@frahlg
frahlg deleted the fix/loadpoint-one-phase-decision branch October 21, 2026 12:20
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