Repository navigation
fix(control): the EV guard trusts only the site meter and our own commands (#1674) - #1695
Conversation
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>
… 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>
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: 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".
| phase := c.recordOfferPeak(now, lpCfg.ID, w, cmd) | ||
| c.saveOffer(now, lpCfg, sample, w, phase, cmdReason) |
There was a problem hiding this comment.
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 👍 / 👎.
| for _, t := range targets { | ||
| state.batteryCommands[t.Driver] = state.batteryCommands[t.Driver].add(now, t.TargetW) |
There was a problem hiding this comment.
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 👍 / 👎.
| if !lim.set || meter.UpdatedAt.After(lim.readingAt) { | ||
| state.stepEVMeterLimit(meter, gridW, pool, fuseMaxW) | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
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>
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>
…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>
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,
applyPerPhaseFuseClampreturns 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.EVFuseBudgetkeeps its signature and acts once per new site reading:Splits use our own commands only:
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.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.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.goreads 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:
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 randomisedTestEVBatteryCommandOrdersStayWithinMeasuredHeadroom/TestEVBatteryPendingAndNewOrdersShareOnePhysicalBudget.TestJoinedEVWaitsForMeasuredBatteryBackoffThenResumesnow 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, sofuse_saturatedgoes false. Battery targets in all 551 records are unchanged.ev_fuse_async_test.goandev_pending_battery_test.gogo with the code they tested.make verifypasses on this branch.Known limits
Follow-ups, not in this PR:
FuseEVMaxWhas no reader since #1657, sorepublishFuseEVCapAfterFuseDischarge(still on charger readings) only writes a dead value;telemetry.BatteryCommandUpperWand its bookkeeping are now unused.Overlap with #1675
#1675 (segran2) also changes
ev_fuse_budget.goandev_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