Skip to content

fix(control): the EV guard trusts only the site meter and our own commands (#1674) - #1695

Merged
frahlg merged 3 commits into
masterfrom
1674-meter-hard-limit
Oct 17, 2026
Merged

frahlg merged 3 commits into
masterfrom
1674-meter-hard-limit

Conversation

@frahlg

@frahlg frahlg commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Fredrik approved this for the next beta on 17 October 2026. Hardware validation happens on that beta, starting with Göran's site (#1674).

Problem

On v0.145.2-beta.1, Göran's Easee Cloud charger stopped and restarted again and again (#1674). Since #1657 the EV hard cap runs every tick as limit − grid + EV credit, and a car earns credit only when its own readings bracket a site reading. Easee Cloud reports power late and only on change. When proof is missing, or one 0 W sample arrives, the car's own draw counts as house load, the offer drops below the minimum step and the charger stops. Since #1672, applyPerPhaseFuseClamp returns early whenever the shared budget exists, so the reactive phase clamp never runs.

At 05:49:15 the car drew 10,242 W, one 0 W sample arrived, and Pixii read 20.45/17.05/15.87 A on a 25 A fuse. The cap came out at (25 − 1 − 20.45) × 230 × 3 = 2,449.5 W, below 4,140 W, and the car stopped. No phase was near its limit.

Rule

Fredrik, 17 October: never trust slow current values from an Easee or any other device. The safety path now reads only the site meter and FTW's own commands. No charger, car or battery reading takes part in a cut, a rise, a start or a split of room.

FTW keeps its own commands for 20 s (loadpoint.CommandSettle, Easee Cloud needs 5–15 s). Within that window the meter may show any of them, so a device's lowest recent command is what the meter surely contains and its highest is what it may still contain. A log younger than 20 s counts the time before its first command as 0.

control.EVFuseBudget keeps its signature and acts once per new site reading:

  • Cut by the measured overage at once: worst phase against fuse − 1 A, total import against the effective ceiling (fuse less its configured margin, or the tariff ceiling). The overage comes off the highest offer of the last 20 s, so charger lag never takes the same watts twice. A charger that ignores a cut gets the next one after 20 s.
  • Hold while inside the limits. Headroom above the offer stays within what the reading measured.
  • Rise one step (1 A on the pool's phases) only on a fresh reading with room for two steps, once the meter shows the last change: import rose by at least half a step since the last rise, or the reading was taken 20 s after it.
  • Gap: a cut starts below 0 W of room, a rise needs two steps, and every increase leaves one step free, so a steady load does not make the offer hunt.
  • Stop only when the overage exceeds offer − minimum step; the loadpoint floors a cap below the minimum to 0.
  • Restart an idle pool only after the meter has shown room for minimum + one step for 35 s (above Easee's ~30 s contactor minimum). A meter gap over 20 s starts that count again.
  • Stale meter: as today, a site meter a minute old closes the dispatch gate and the loadpoint sends 0 W. Before that, a silent meter repeats its last reading, which never acts twice, so it cannot raise or restart a charge. Increases also need a qualified (fresh) reading.
  • Core restart: FTW saves its own last order per charger (power, phase mode, reason) in state.db, keyed by the charger's hardware identity, on every change and once a minute. On the first tick after a restart it restores that order as the hold base, but only while the same plug-in session is connected: a matching session id, or, for chargers without one such as Easee Cloud, a session energy counter that has not gone back, and only within 10 minutes. An observed unplug clears it. Otherwise the pool starts idle as before. The meter still cuts any overage at once.

Splits use our own commands only:

  • Between cars (freezeFuseBudget): a car's base is its highest offer of the last 20 s on the same phases. The car's reading never counts. A switch from 3p to 1p at the same watts is still an increase.
  • EV and battery (clampEVBatteryHeadroom): the EV still needs its demand less its lowest logged offer. Keeping a battery's latest order is never an increase, as for the car; a shortfall of measured room (an overage, or room the EV needs) takes positive charge back, and new charge needs room left after our own increases the meter may not show yet. A lost discharge always counts, and a battery with no recent command may be discharging, so its configured discharge stays reserved. EV first (fix(control): give a planned EV its fuse share on the worst phase #1536, fix(control): let battery charging yield to the EV #1672) is unchanged in effect. Without the hold rule a commands-only battery counted its own repeated order twice and hunted 0 ↔ 3,210 W every tick.
  • EV limit and battery: battery increases we ordered that the meter may not show yet shrink the room for an EV rise or start, never the current offer.
  • fix(control): give a planned EV its fuse share on the worst phase #1536 allocator: house load is inferred from our lowest logged EV offer (plus any operator-declared EV load), not from charger readings.

Removed: coherentEVBalance, evPhaseIncreaseW, the bracketed EV and battery credit (bracketedEVPower, bracketedBatteryPower, evPowerAtLatestSite, batteryPowerAtLatestSite, coherentEVBatterySite, evBatteryBaseline), capBatteryOffersToHeadroom, ev_pending_battery.go, and the dead reactive clamp (nextFusePhaseCapA, applyPerPhaseFuseClamp). Configured fuse, margins and drivers do not change. ev_fuse_budget.go reads the meter once, so its direct telemetry reads go from 8 to 5. Production code against master: +483 −697 lines.

Tests

The control tests run a simulated site: an Easee-like 3p charger that takes 5 s to apply a new current, a cloud poll every 5 s with the draw 5–15 s old, a meter reading every second, and the one-minute dispatch gate from main.go.

Field cases from #1674:

Case master this branch
(a) 05:49: lone 0 W sample at 20.45/17.05/15.87 A cap 2,449.5 W, car stops offer holds at 10,350 W
(b) 23:45: 7.5 kW ceiling, 0.9 kW house, telemetry 5, 10 and 15 s late 6,210 ↔ 0 W about every 11 s starts once at 5,520 W (a step free), grid stays ≤ 7,500 W
(c) house step to 8.15 kW – one step down to 4,830 W, no stop
(d) car pauses, house fills its room, car resumes – cut on the first reading that shows the overage

Charger readings (none may change the offer):

  • TestEVHardLimitOffersIgnoreChargerReadings: missing for the whole run, 15 s late during a start, 2 kW above the offer, a lone 0 W. The offer sequence matches the normal run exactly.
  • TestEVHardLimitHoldsThroughMissingChargerPower: no reading for 60 s mid-charge.
  • TestEVHardLimitIgnoresChargerReadings: lone 0 W and unavailable power at the field currents; before our first command a charging car's reading earns no credit.
  • TestEVBatterySplitIgnoresDeviceReadings: EV and battery readings do not move a battery target.
  • TestLoneZeroReadingKeepsOfferThroughPhaseGate (loadpoint): master sends 0 W on the lone reading; this branch keeps 10,350 W.

Restart and shared overage:

  • TestEVHardLimitRestartKeepsChargingCarAtFieldCurrents: Core restarts mid-charge at the 05:49 currents; every offer after the restart stays 10,350 W. Without the restore the car got 0 W for 41 s.
  • TestEVHardLimitRestartAfterReplugStartsIdle: a replug while Core was down does not inherit the old order.
  • TestEVFirstHouseStepWhileBatteryAndEVCharge: a 2 kW house step while the battery charges 3,210 W and the car 10,350 W. Both answer the overage; the car dips to 8,280 W and never stops, the battery yields the full 2 kW (to 1,210 W), and the car is back at 10,350 W after 29 s. The battery holds steady before the step.
  • TestEVHardLimitCarUnderItsOfferDelaysCut: see Known limits.

Meter gaps and stability:

  • TestEVHardLimitNeverRisesOnSilentMeter: 50 s of silence with room the meter never shows; nothing moves.
  • TestEVHardLimitRestartsAfterMeterGapOnlyOnSustainedRoom: 70 s gap → 0 W; restart only 35 s or more after fresh readings return.
  • TestEVHardLimitRiseWaitsForMeterOrDwell: one step at a time; a rise the meter confirms follows in under 20 s, an unconfirmed one (car paused) waits 20 s; recovery ends with a step free.
  • TestEVHardLimitCutsAgainAfterSettleWhenChargerIgnores, TestEVHardLimitBatteryOrderBlocksRiseNeverCuts, TestEVHardLimitTrimsPhaseOverageFromOffer (2 A over: 10,350 → 8,970 W), TestEVHardLimitStopsOnlyWhenOverageExceedsOfferLessMinimum, TestEVHardLimitIdleStartNeedsRoomOnDeclaredPhases.

EV and battery split: TestEVBattery* (EV need first, an unseen EV rise is still need, a reduction never funds a sibling, a stopped discharge reserves its supply, our pending rise counts once, 1p need on the worst phase, a battery without commands reserves its discharge, unknown meter, export relief) and the randomised TestEVBatteryCommandOrdersStayWithinMeasuredHeadroom / TestEVBatteryPendingAndNewOrdersShareOnePhysicalBudget. TestJoinedEVWaitsForMeasuredBatteryBackoffThenResumes now checks the 35 s restart dwell after the battery backs off.

Changed expectations: two loadpoint tests now establish our own offer before the overload, since a car's reading no longer counts as an offer. Two golden records (seeded_ev_boost/031, /046) had EV load known only from a charger reading; the allocator now counts it as house load, so fuse_saturated goes false. Battery targets in all 551 records are unchanged. ev_fuse_async_test.go and ev_pending_battery_test.go go with the code they tested.

make verify passes on this branch.

Known limits

  • A car that pauses and resumes into house load that has filled its room overloads until the next meter reading plus the charger's response, a few seconds. This is case (d): the meter decides.
  • A car that does not follow its offer lets the cap rise one step per 20 s up to its demand; the meter cuts when it starts drawing.
  • After a Core restart without a matching saved order (new session, a charger with neither session id nor energy counter, or more than 10 minutes down), the pool counts as idle: if the meter then shows less than minimum + one step of room, a car that was charging stops once and restarts after the dwell.
  • A car that draws well below its offer delays a cut by 20 s per cut, since the cut comes off the offer, not the draw. With a 2 kW overage and the car 4 kW under its offer, the first two cuts change nothing and the third takes effect after the charger's delay: 48 s measured with a 5 s charger delay, about 58 s at Easee's 15 s.
  • When a house step hits the battery and the car together, both are cut and the car climbs back step by step: 29 s for a 2 kW step.
  • Each increase leaves a step free, so under a tight ceiling the car runs one step lower than before (5,520 instead of 6,210 W at 23:45).
  • After a cut, recovery takes one step per confirmed rise, or per 20 s.
  • The import ceiling stays a hard total-import bound, as before. Treating it as a slow average is a separate decision.
  • Not run on hardware.

Follow-ups, not in this PR: FuseEVMaxW has no reader since #1657, so republishFuseEVCapAfterFuseDischarge (still on charger readings) only writes a dead value; telemetry.BatteryCommandUpperW and its bookkeeping are now unused.

Overlap with #1675

#1675 (segran2) also changes ev_fuse_budget.go and ev_battery_headroom.go, and has right of way; this PR does not touch its branch. Its budget-evidence changes (bracket alignment, fallback credit) have nothing left to act on here, but its resume check (controller_resume.go), its not-following status and its budget diagnostics should land too. The resume check is not a safety path, but it could confirm recovery through the meter rise instead of Easee power. Whichever PR merges second needs a rebase; the diagnostics would then log the meter verdict instead of bracket credit.

🤖 Generated with Claude Code

frahlg and others added 2 commits October 17, 2026 14:55
The EV hard cap credited a car only when its own readings bracketed a
site reading. Easee Cloud reports late and on change only, so missing
proof or one 0 W sample counted the car as house load and stopped the
charge (05:49: 2,449.5 W of credit at 20.45 A on a 25 A fuse), and an
import ceiling made the charger start and stop every half minute.

EVFuseBudget now cuts by the overage the meter measures on the worst
phase or in total import, taken from the highest offer the meter may
still contain. It holds inside the limits, rises one 1 A step when the
meter shows room and the cars have taken the last step, and restarts an
idle pool only with room for its minimum plus a step. EV readings only
gate a rise. In the loadpoint split, keeping an offer on the same
phases is no longer an increase, and the phase gate no longer cuts.

The bracket credit for the EV cap, the common battery reserve and the
dead reactive phase clamp are removed. Overlaps #1675.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Fredrik's rule (17 Oct): never trust slow current values from an Easee
or any other device. The EV hard limit, the split between cars, the
EV/battery split and the #1536 allocator now read only the site meter
and FTW's own recent commands, kept for the 20 s a command takes to
reach the meter.

A rise needs a fresh reading with room for two steps and waits until
the meter shows the last change (import rose with it, or 20 s passed).
Every increase leaves a step free. An idle pool restarts only after 35 s
of measured room; a meter gap starts the count again, and a silent
meter's repeated reading never acts twice. Battery commands, not
readings, set the battery's baseline and pending range.

Removes the bracketed EV and battery credit and ev_pending_battery.go.
Overlaps #1675.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
@frahlg frahlg changed the title fix(control): the EV hard limit follows the site meter (#1674) fix(control): the EV guard trusts only the site meter and our own commands (#1674) Oct 17, 2026
… order

Core restarts on every update. The pool counted a charging car as idle
on the first tick and, at 05:49 currents (2,449.5 W of room), stopped it.
FTW now saves its own last order per charger, keyed by hardware
identity, and restores it as the hold base while the same plug-in
session is connected: a matching session id, or a session energy
counter that has not gone back, within 10 minutes.

A commands-only battery counted its own repeated order twice and
hunted. Keeping a battery's latest order is now never an increase;
a measured shortfall takes charge back and new charge needs room after
our unseen increases. A command log younger than the settle time
counts the time before it as 0, so a new EV offer is not taken as
already in the meter.

The EV limit reads the meter once (telemetry-read check 8 -> 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
@frahlg
frahlg marked this pull request as ready for review October 17, 2026 13:42
@frahlg
frahlg merged commit 47799bd into master Oct 17, 2026
23 checks passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 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-17T13:45:59.777395Z 37e682a Draft marked ready
ℹ️ 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: 37e682a11a

ℹ️ 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".

Comment on lines +1573 to +1574
phase := c.recordOfferPeak(now, lpCfg.ID, w, cmd)
c.saveOffer(now, lpCfg, sample, w, phase, cmdReason)

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 Record EV offers only after successful dispatch

When ev_set_current is refused or times out, these calls still record and persist the requested watts before sendOutcomeWithDeadline reports the failure. If Core then restarts within ten minutes, restoreOffers treats that unaccepted value as an offer already represented in the site meter; for example, a rejected 10 kW offer with only 2 kW of measured room can be restored and sent again because the guard adds that room to the fictitious 10 kW baseline. Update the command history and persisted offer only after the driver accepts the command.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

Comment on lines +2448 to +2449
for _, t := range targets {
state.batteryCommands[t.Driver] = state.batteryCommands[t.Driver].add(now, t.TargetW)

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 Log battery commands after the driver accepts them

These entries are created while computing targets, before the dispatch loop calls the driver, so a refused target becomes the battery's assumed latest command. If a 5 kW charge target is refused and the next meter sample has only 2 kW of room, capBatteryCharge treats the repeated 5 kW target as an established hold rather than an increase and can send the full target if the next retry succeeds, exceeding the measured ceiling. Feed accepted outcomes back into this log instead of recording every computed target.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

Comment on lines +127 to +129
if !lim.set || meter.UpdatedAt.After(lim.readingAt) {
state.stepEVMeterLimit(meter, gridW, pool, fuseMaxW)
}

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 Clear the EV cap when an offer stops on a repeated reading

When the meter remains silent but is still inside the dispatch freshness timeout, a transition from a positive offer to 0 does not call stepEVMeterLimit, so lim.capW retains the previous positive cap. If a plan or manual action resumes charging before another meter update, the controller can immediately reuse that cap without the documented 35-second measured-room dwell; any house load that appeared during the silent interval can then overload the fuse until a new reading arrives. Process the pool's transition to idle even when meter.UpdatedAt has not advanced, while continuing to require fresh readings for a restart.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

frahlg added a commit that referenced this pull request Oct 17, 2026
Since #1695 the EV hard limit cut on every one-second reading over the
fuse less 1 A on a phase, and on any reading where total import passed
the fuse less its margin. A heat pump start or an oven switching on cut
the car, which then climbed back 1 A per confirmed step.

Each phase of the main fuse is now a first-order low-pass on I² from the
site meter, tau 60 s. A phase cuts once its filtered current passes the
fuse less 1 A, or at once above 1.2 x the fuse. The cut takes off what
the meter shows over the limit, so one cut stops the heat rising. Rises
and restarts still read the instantaneous worst phase. Where the meter
reports phase currents, they replace total import as the fuse check; a
tariff ceiling still cuts. The heat holds through a meter gap and steps
over the real time when the meter returns. After a Core restart it starts
at the first reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
frahlg added a commit that referenced this pull request Oct 17, 2026
Since #1695 the EV hard limit cut on every one-second reading over the
fuse less 1 A on a phase, and on any reading where total import passed
the fuse less its margin. A heat pump start or an oven switching on cut
the car, which then climbed back 1 A per confirmed step.

Each phase of the main fuse is now a first-order low-pass on I² from the
site meter, tau 60 s. A phase cuts once its filtered current passes the
fuse less 1 A, or at once above 1.2 x the fuse. The cut takes off what
the meter shows over the limit, so one cut stops the heat rising. Rises
and restarts still read the instantaneous worst phase. Where the meter
reports phase currents, they replace total import as the fuse check; a
tariff ceiling still cuts. The heat holds through a meter gap and steps
over the real time when the meter returns. After a Core restart it starts
at the first reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
frahlg added a commit that referenced this pull request Oct 17, 2026
…1701)

* fix(control): the EV guard gives the main fuse a heat budget (#1674)

Since #1695 the EV hard limit cut on every one-second reading over the
fuse less 1 A on a phase, and on any reading where total import passed
the fuse less its margin. A heat pump start or an oven switching on cut
the car, which then climbed back 1 A per confirmed step.

Each phase of the main fuse is now a first-order low-pass on I² from the
site meter, tau 60 s. A phase cuts once its filtered current passes the
fuse less 1 A, or at once above 1.2 x the fuse. The cut takes off what
the meter shows over the limit, so one cut stops the heat rising. Rises
and restarts still read the instantaneous worst phase. Where the meter
reports phase currents, they replace total import as the fuse check; a
tariff ceiling still cuts. The heat holds through a meter gap and steps
over the real time when the meter returns. After a Core restart it starts
at the first reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

* fix(control): an EV rise waits for the fuse heat as well (#1674)

Repeated house peaks on a 16 A fuse (17 A for 10 s, 14 A between) cut
the car, which climbed back in the quiet between peaks and was cut again:
4, 7 and 10 cuts in 30 peaks. Rises and starts now read each phase at the
higher of the reading and its heat, so each pattern cuts once and settles
where the peaks fit. Adds Fredrik's 16 A case as tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

* test(control): a total import the phases contradict does not cut the EV (#1674)

Göran's site, 14:20:34Z on v0.147.2-beta.1: one Pixii reading gave
19,174 W while its worst phase was 5.7 A, and the total against the fuse
cut the car from 10,350 W to 8,081 W. With phase currents the phases
judge the fuse and the offer holds; without them the same total still
cuts to 8,081 W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

* fix(control): the fuse heat counts a meter gap at the last current seen (#1674)

The heat model applied each new reading's current to the whole time since
the last one. After a meter gap, one high reading counted as the whole gap
at that current and could cut the car at once, and a low one erased an
overload from before the gap. A reading now stands for the time until the
next: the model integrates the last current seen over that time, then
holds the new one. Cut times, counted from the first reading at the new
current, are unchanged. The 30 s pattern on a 16 A fuse peaks just over
15 A only as a peak ends, when the meter shows 14 A, so it is not cut.

Raised by the Codex review on #1701.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

---------

Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
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