Skip to content

fix(control): the battery leaves the hour's overage to the EV cut Core sent (#1674) - #1706

Merged
frahlg merged 2 commits into
masterfrom
1674-battery-counts-ev-cut
Oct 17, 2026
Merged

frahlg merged 2 commits into
masterfrom
1674-battery-counts-ev-cut

Conversation

@frahlg

@frahlg frahlg commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Follows #1699 and #1701 (both merged). Closes the gap that #1699 left open for #1674.

Problem

On a large overage of the import ceiling, the EV guard (stepEVMeterLimit) cuts the car by the measured overage. In the same control tick the battery's fuse guard (forceFuseDischarge through fuseReliefFromSnapshot) discharges for the same watts. The car's cut reaches the meter 5–15 s later. The battery is still discharging, so the site swings into export.

Field evidence, Göran (segran2: Easee Cloud, Pixii, 25 A), v0.147.2-beta.1, 17 Oct 2026 UTC:

  • 14:26:25: the battery went to −4.8 kW after a 4,161 W overage.
  • 14:22:38: it reached −9.35 kW, and the site exported about 4 kW for ~10 s as the car stopped.

Once the cut lands, the meter reads the import limit less the cut. So the site exports when the cut is larger than the limit: a car above the ceiling, which PV or battery support allows. Under the fuse alone (16.5 kW at 25 A) one car's cut never gets there. The fuse case over-corrects for a moment but does not export.

Change

fuseReliefFromSnapshot keeps the fuse apart from the hour's import limit:

  • The fuse less its margin, and the per-phase relief: unchanged, on every reading.
  • The hour's import limit, where it is tighter than the fuse: its overage less pendingEVCutW.

pendingEVCutW is the EV cut sent during the current overage. The EV guard already marks the first reading of a run over the hour's limit (overSince); it now also notes the pool's offer then (overOfferW). The cut is that offer less this tick's offers (EVCommandedW). It reads only our own commands, never a charger or battery reading. It counts while the run is younger than 20 s (loadpoint.CommandSettle). So:

  • a cut that reaches the meter ends the run, and a later overage gets relief at once;
  • a car that draws less than its offer cannot hold an overage through a second cut;
  • if the car has not followed in 20 s, the battery covers the overage until the cut shows.

applyFuseGuard (charge scaling) is unchanged. A charging battery still yields to the car on a ceiling overage (EV first). That can over-correct, but it cannot discharge.

Results (simulated, 1 s steps, evBatterySite)

Case Before (#1699) This PR
7.5 kW ceiling, PV covers the house and 3 kW of the car (9,660 W), house +7 kW, Easee 10 s, battery follows in 3 s battery −6,053 W; meter 7,608 W until the stop lands, then −2,053 W for 3 s battery 0 W; meter 13,660 W for 10 s (under the 16,560 W fuse limit), then 4,000 W; no export
Same, charger takes a minute no credit, so no wait battery −6,053 W from 20 s after the stop went out; meter held at the hour's limit (7,607 W) until the stop lands
Stop has landed, then house +4.5 kW (car off) −569 W at 2 s same: −569 W at 2 s, meter at the limit by 6 s
Car takes 2 kW of a 10,350 W offer, house +12.6 kW no credit, so no wait battery −3,420 W from 20 s; meter held at the hour's limit
25 A fuse, no ceiling, car 10,350 W, house +8 kW (21,350 W, 31 A: past the heat budget's instant limit) battery −4,790 W from the first reading same: relief from the first reading, meter under the 25 A breaker from the second reading, car cut to 5,520 W, battery idle once the cut shows

TestCeilingOverageLeftToCommandedEVCutDoesNotExport fails without this change (site exported 2,053 W) and passes here. TestFuseReliefLeavesOnlyCeilingWattsToCommandedEVCut is a table for the rule: the fuse part (total and per phase) never waits, only watts the cut removes wait, and no cut, a cut sent before this overage, or an overage older than 20 s waits for nothing.

A first version counted every offer of the last 20 s. An independent review found two ways it waited too long: after a cut had already landed, and when the car drew less than its offer (two cuts, 41 of 60 s over the limit). The run-based rule above fixes both; TestCeilingOverageAfterTheCutShowsGetsBatteryRelief and TestCeilingOverageWaitsOnceWhenTheCarDrawsLessThanItsOffer fail on that first version and pass here.

Limits

  • setCommandedForManual records a cut before it is sent, so a failed send still counts, for at most 20 s and only against the hour's budget.
  • On holdoff and deadband ticks fuseSaverFromLivePower now sees the smaller relief too. A charging battery then keeps charging until the next full tick, where applyFuseGuard cuts it.
  • No hardware run.

Overlap

Test plan

  • make verify
  • bash .github/scripts/check-telemetry-reads.sh origin/master: none added
  • New export test fails without this change and passes here
  • Hardware: Göran's site after a beta

🤖 Generated with Claude Code

@frahlg
frahlg force-pushed the 1674-battery-counts-ev-cut branch from 3ac062b to 88e0284 Compare October 17, 2026 15:30
@frahlg
frahlg changed the base branch from 1674-hourly-import-budget to master October 17, 2026 15:30
@frahlg
frahlg force-pushed the 1674-battery-counts-ev-cut branch from 88e0284 to 9f0116e Compare October 17, 2026 15:32
…e sent (#1674)

Göran, 17 October 14:22:38 UTC: an overage of the import ceiling stopped
the car, and in the same tick the battery's fuse guard discharged for the
same watts. When the stop reached the meter the battery was still
discharging, and the site exported about 4 kW for ~10 s.

fuseReliefFromSnapshot now keeps the fuse apart from the hour's import
limit. The fuse and the per-phase relief act on every reading, as before.
Where the hour's limit is tighter, its overage less the EV cut sent during
that overage asks for relief. The EV guard notes the pool's offer on the
first reading of a run over the hour's limit; the cut is that offer less
this tick's offers. Our own commands only, no charger or battery reading.
The cut counts while the run is younger than 20 s. A cut that reaches the
meter ends the run, so a later overage gets relief at once, and a car that
draws less than its offer cannot hold an overage through a second cut.
The charge guard is unchanged, so a charging battery still yields to the
car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
@frahlg
frahlg force-pushed the 1674-battery-counts-ev-cut branch from 9f0116e to f81845f Compare October 17, 2026 16:00
@frahlg
frahlg marked this pull request as ready for review October 17, 2026 16:00
@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-17T16:02:19.146592Z f81845f 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: f81845f1ad

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

if lim.overSince.IsZero() || s.now().Sub(lim.overSince) >= evSettle || !finiteFuseValue(s.EVCommandedW) {
return 0
}
return math.Max(0, lim.overOfferW-math.Max(0, s.EVCommandedW))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Track sent cuts instead of aggregate plugged offers

When multiple managed EVs are charging and one unplugs during an hourly-ceiling overage, loadpoint.CommandedEVPowerW immediately excludes that loadpoint while evLimit remains active as long as another EV is still plugged in. This subtraction therefore treats the unplugged car's entire old offer as a cut Core sent, even though the meter may already reflect the unplug, and can suppress necessary battery relief for the remainder of evSettle. Record the actual reductions commanded by the fuse guard, or reset the baseline when pool membership changes, rather than inferring commands from the aggregate offer.

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

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.

Fixed in #1713. The EV guard now restarts the run's baseline when a car leaves or joins the pool, so only cuts sent to cars still plugged count. TestUnpluggedCarIsNoCutTheBatteryWaitsFor covers two cars with one unplugged during the overage.

@frahlg
frahlg merged commit 61e2563 into master Oct 17, 2026
22 checks passed
frahlg added a commit that referenced this pull request Oct 17, 2026
)

Codex on #1706: with several managed chargers, unplugging one car during
an overage of the hour's import limit dropped its offer from the pool.
pendingEVCutW counted that drop as a cut Core sent, although the meter may
already show the unplug, and held back battery relief for up to 20 s.

The EV guard now notes which loadpoints are plugged. When a car leaves or
joins, the run's baseline restarts at the pool's offer then, so only cuts
sent to cars still plugged count. Our own commands only.

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
) (#1713)

* fix(control): an unplugged car is no EV cut the battery waits for (#1674)

Codex on #1706: with several managed chargers, unplugging one car during
an overage of the hour's import limit dropped its offer from the pool.
pendingEVCutW counted that drop as a cut Core sent, although the meter may
already show the unplug, and held back battery relief for up to 20 s.

The EV guard now notes which loadpoints are plugged. When a car leaves or
joins, the run's baseline restarts at the pool's offer then, so only cuts
sent to cars still plugged count. Our own commands only.

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

* fix(control): keep a pending EV cut per charger when another car leaves (#1674)

Codex on #1713: restarting the whole pool's baseline when a car left also
dropped a cut already sent to a car that stays. The battery then covered
that cut too, and the site could export when it landed.

The EV guard now notes each charger's offer when a run over the hour's
limit begins. pendingEVCutW sums, over chargers still plugged, the start
offer less this tick's offer. A car that leaves drops out; a car that
joins has no start offer. main.go hands Core this tick's offers per
driver through SetEVCommanded, from loadpoint.CommandedEVPowerByDriver,
which keeps a stopped car at 0 W. Our own commands only; the 20 s cap
stays.

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