Skip to content

fix(control): fuse protection as one function - #1789

Draft
frahlg wants to merge 5 commits into
masterfrom
fix/fuse-one-function
Draft

frahlg wants to merge 5 commits into
masterfrom
fix/fuse-one-function

Conversation

@frahlg

@frahlg frahlg commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Step 6 of the fuse rule in Fredrik's review of 21 October 2026: fuse protection is one function. The third of three PRs. Stacked on #1782 (direction of phase current) and #1783 (import_limit): this branch merges both onto master. Review the last commit, fix(control): fuse protection as one function; I rebase it when those land. Like #1782, it goes in the next beta together with the fix for the charger's late readback in surplus charging (surplus_reserve.go:128): signed phase currents remove the slow ramp under export that hid that bug.

The function

go/internal/control/fuse_phase.go holds fusePhases, the one reading of the main fuse that EV allocation and battery dispatch both use:

  • the site meter's current on each configured phase, with direction (from fix(control): phase current takes its direction from the phase's power #1782); without phase currents, the meter's total shared evenly over the phases;
  • one margin: safety_margin_a, at least 1 A, on every phase (was 1 A for the car, and 0.5 A plus half a margin of buffer for the battery);
  • room for a rise: limit − (level + our pending raises). The car reads each phase's heat as well as the reading (a slow charger must not spend heat the last minute used); the battery reads the reading (it answers within a tick, and the heat's lag made it hunt);
  • overage for a cut: a phase over the limit once its I²-filtered heat is spent, or above 1.2 × In; import and export apart.

Who uses it:

  • EVFuseBudget (car): rise and start room, and the cut. The meter total no longer stands next to the phases.
  • fitBatteryRisesToFuse (battery, replaces clampEVBatteryHeadroom): a charge rise fits the phases' room after what the cars still need, EV first; a discharge that would end keeps what the room needs, down to the battery's floor; the export side the same with signs turned. It now applies with or without a car, and sets FuseSaturated.
  • forceFuseDischarge / fuseSaverFromLivePower (battery cut): the import overage net of EV cuts already sent during the run over the limit (at most evSettle), plus what the targets add. The hour's import limit nets the same cuts.

Deleted

  • applyFuseGuard's per-phase part, its 30 s latch (FuseHoldMaxChargeW/DischargeW/Until) and its half-margin buffer. What is left is applySiteLimits: the hour's import limit and max_export_w, both totals.
  • The aggregate fuse checks in applyFuseGuard and fuseReliefFromSnapshot (the total judged the fuse beside the phases; a Pixii total 5 kW above its phases cut the battery).
  • perPhaseOverageW, perPhaseOverageReadingW, perPhaseReliefW (a second phase-current reader, magnitudes, direction from the total).
  • clampEVBatteryHeadroom, evBatteryPhaseSpareW, worstPhaseA (ev_battery_headroom.go, a third reader with its own margin).
  • The joint EV/battery allocator in ComputeDispatch, phaseImportCeilingW and phaseImbalanceA (the fix(control): give a planned EV its fuse share on the worst phase #1536 imbalance proxy), republishFuseEVCapAfterFuseDischarge, and the dead FuseEVMaxW output.
  • pendingEVCutW and overOffers (replaced by evCutsSent, which reads our command log).
  • The cadence special case: a reading after more than evSettle was a "gap" that restarted the idle start and the hour's run. Only a reading older than the meter's max age (stale) does now.
  • cmd/ftw's phase-current gate read the meter_l*_a metrics, a separate copy; it now reads the meter reading through control.SitePhaseCurrents. PhaseImbalanceW (planner) reads the same.

Net: production code −209 lines (+680 −889), tests −158, golden corpus −1,647. No settings added; safety_margin_a keeps its meaning as the one margin, with a 1 A floor.

Also in this PR (from the end-to-end run and Efret's site)

  • Battery floor. Fuse relief no longer discharges below the battery's soc_min, else the planner's (10 % by default); PowerLimits.SoCMin carries it and hot-reloads with the other limits. Below the floor the lever is the car.
  • Start when the minimum fits. An idle car needed its minimum plus a step; on a 16 A fuse with 8.5 A on L1 it never started and said fuse_limit. It starts a step below the room, or at its minimum when only that fits. A three-phase car's minimum counts 6 A on each phase. Under an unmeasured hour's ceiling the step stays.
  • A second car plugging in has no offer of ours yet; it no longer marks the pool idle and stops the first car.
  • A timed-out command counts as sent. The loadpoint already records the offer before it sends; SetEVCommanded now also logs it at send time, so pending and cut netting see it. Pinned by TestTimedOutCommandStillCountsAsSent.

Site simulation, before → after

Base: master with #1782 and #1783.

Scenario base this PR
Pixii total disagrees with its phases (new) fails: a total 5 kW above the phases cut the battery from 3,000 W to 175 W passes: car 11,040 W, battery 3,000 W, peak 21.3 A
car and battery relieve one overage (new) fails: the battery discharged for an overage the car's cut covered, then its stop held a phase over; 2 min 23 s over 24 A passes: the car's cut takes it, the battery stays at 0 W; 48 s over 24 A inside the heat budget, peak 28.0 A
idle start on a 16 A fuse (new) fails: idle 1 min 7 s with room passes: starts at 6 A, peak 14.5 A
#1761 replay passes: peak 29.6 A, 4 s over 24 A passes: same
meter every 30 s after a cut fails as known: the car never restarts in 10 min still fails as known, new cause: the heat model holds the 39 A spike seen before the 30 s silence; the car draws again after 2 min 35 s
charger's own load balancer fails as known fails as known (unchanged)
all other rows pass pass, same numbers (a day: idle with room 1 min 0 s → 52 s)

The simulation's over-the-limit counter now has 0.01 A of tolerance: a phase that settles exactly at the 24 A working limit is not over it.

Golden corpus

Re-recorded. 107 of 551 records moved their targets (48 to more import, 59 to less); the rest moved only attributions (fuse_ev_max_w, the latch and per_phase_overage_w are gone; phase_over_a and phase_export_over_a are new). Records that name only fuse_max_w now describe it as a balanced 230 V three-phase fuse, as config does, and the phases' power now carries the current's sign, as drivers emit it. I checked every moved record: none ends with a phase further over its limit than before, on import or export. The largest moves:

  • seeded_fuse/050: discharge −9,956 → −4,787 W. The total said over; the phases had room.
  • seeded_fuse/031, slew/seeded_103_planner_arbitrage: a battery under 32 A of export now keeps charging (+5,550 W, +8,745 W) until the phase is at the limit, the lever the rule names for export.
  • seeded_fuse/030: idle mode with a battery discharging 2,369 W and L1 at 23.4 A: the battery keeps 1,955 W of discharge instead of stopping into 26.8 A.

Conflicts and open points

Evidence

  • go test -p 2 ./internal/control ./internal/sitesim ./internal/loadpoint ./internal/api ./internal/mpc ./cmd/ftw: pass
  • npm test: 868 pass
  • make verify (with GOFLAGS=-p=2): pass on c60da23

Real-hardware evidence is still needed: a Pixii site (total and phases disagree), an Easee Cloud site with a battery, and a site under export with a battery.

🤖 Generated with Claude Code

frahlg and others added 5 commits October 21, 2026 16:28
Fuse protection read every phase current as a magnitude. Under 20 A of
solar export a phase counted as 20 A of import, so on a 25 A fuse a car
never started, and on a 16 A fuse 8 A of export was enough to block it.

phaseCurrentsA now signs each phase by the same phase's power in the
reading (l1_w, l2_w, l3_w, which P1, Pixii, SolarEdge and SDM630 emit).
Without that power the current counts as import, the safe side. Rises
and starts read the level with direction: an exporting phase counts at
its reading, so new load there has more room. An importing phase still
counts at the higher of its reading and its heat. The heat filter keeps
I², and only an importing phase can be over for the EV cut.

The site simulation's meter now emits signed phase power, as P1 does,
also when its currents lose their sign. Both export rows pass and lose
their known line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e_limit

Core folded the clock hour's import ceiling into the EV cap and the
battery allocator under the fuse's name. A car held back by the hour's
budget said fuse_limit, a boost it stopped said "Fuse protection", and
the web page and apps blamed the main fuse while the phases were low.

EVFuseBudget now records which limit left less room at each site
reading, and EVLimitReason names it. The loadpoint reads it once per
tick with the cap (SetFuseLimitReason) and labels a capped command
import_limit or fuse_limit. The allocator marks FuseSaturatedByImport
when the hour's limit is the ceiling it hit, and the boost stops with
import_limit_block. fuse_limit now means the main fuse alone; the web
page says so and gives the hour's limit its own words.

The site simulation's new row is a night with a 6 kW hourly ceiling, a
heavy first 50 minutes, a Core restart at 02:09 and phases at 1.5 A
near the end of the hour. On master the idle car says fuse_limit; now
it says import_limit and starts within 16 s of the new hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imit

Review of #1783 (Codex): after a quiet part of the hour, the hour's limits
can sit above what the fuse allows, so the label compared them and said
fuse_limit. An idle car is then kept off by the ceiling's own start test,
which caps the start level at the ceiling itself. Under a measured hour
the idle start now names the limit by that test: the start level against
this reading, and import_limit when the dwell's mean import fails it.

TestImportCeilingStartTestSaysImportLimit: 40 quiet minutes under a
7.5 kW ceiling, then an 8 kW house and a car plugged in. It said
fuse_limit; it says import_limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fredrik's rule of 21 October 2026: fuse protection counts the site
meter's current per phase, with direction, and our own raises that
cannot show yet. Nothing else. Core read the fuse in nine places with
five margins and two phase-current sources.

fusePhases (fuse_phase.go) is now the one reading for every charger and
battery decision: the meter's signed current per phase (its total shared
evenly when it reports none), one margin (safety_margin_a, at least
1 A), room for a rise (limit less level and our pending raises) and an
overage for a cut (once a phase's heat budget is spent, or above
1.2 x In), import and export apart.

- EVFuseBudget reads it for rises, starts and cuts; the meter total no
  longer stands beside the phases.
- fitBatteryRisesToFuse replaces clampEVBatteryHeadroom: a battery's
  rise fits the phases' room after what the cars still need, EV first,
  with or without a car; a discharge that would end keeps what the room
  needs, down to the battery's floor; the export side the same with the
  signs turned. It sets FuseSaturated.
- forceFuseDischarge relieves the import overage net of the EV cuts we
  sent during the run over the limit, at most evSettle. The hour's limit
  nets the same cuts.

Deleted: applyFuseGuard's per-phase part, latch and half-margin buffer
(what is left is applySiteLimits: the hour's limit and max_export_w);
the aggregate fuse checks beside the phases; perPhaseOverageW and
perPhaseReliefW; clampEVBatteryHeadroom and its own phase reader; the
joint allocator, phaseImportCeilingW and the #1536 imbalance proxy;
republishFuseEVCapAfterFuseDischarge and the dead FuseEVMaxW;
pendingEVCutW; the meter-cadence gap rule (only a stale reading starts
afresh); and cmd/ftw's second phase-current source, the meter_l*_a
metrics.

Also: fuse relief stops at the battery's soc_min, else the planner's
(PowerLimits.SoCMin); an idle car starts when its minimum fits; a second
car plugging in no longer stops the first; a timed-out command counts
as sent.

Site simulation: Pixii total/phase disagreement, both sides relieving
one overage and the 16 A idle start pass; the #1761 replay is
unchanged. Golden corpus re-recorded: 107 of 551 records moved, none
ends with a phase further over its limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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