Skip to content

fix(control): one log of FTW's own EV offers - #1881

Merged
frahlg merged 2 commits into
masterfrom
a6-one-ev-offer-log
Oct 27, 2026
Merged

frahlg merged 2 commits into
masterfrom
a6-one-ev-offer-log

Conversation

@frahlg

@frahlg frahlg commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Item A6 of the stable plan.

Problem

Core kept two logs of its own EV offers:

  • State.evOffers held 20 s. It was written at ev_fuse_budget.go:372 and dispatch.go:2478.
  • Each charger's log (evLimit.offers) holds the declared response time, or 180 s when the charger declares none.

The EV fuse limit reads the per-charger log. The battery side read the 20 s log, through evInMeterW (dispatch.go:4649) and clampEVBatteryHeadroom (ev_battery_headroom.go:20). After 20 s, the battery counted a slow charger's raise as already in the meter and charged into the car's room.

Scenario: a 25 A site with 3 A of house load per phase, a planned 9.4 kW battery charge, and an 11 kW Charge-now car on a charger that starts after 2 min. The battery ramped to 9.4 kW while the car's offer was unshown. When the car started, all three phases read 32.6 A for 3 s, above the 30 A instant limit (1.2 × In). The car then sat idle with room for 3 min. A 30 s or 40 s start gave 32.0 A.

Change

  • Delete State.evOffers and both of its writers.
  • New evShownW: each plugged charger's latest offer less what it may not show yet (commandLog.unshown). evInMeterW and clampEVBatteryHeadroom read it.
    • A charger whose driver declares power_max_age_s keeps a raise unshown for that time, as the EV limit does. Easee Cloud, the slow charger we know, declares 180 s, so the battery now waits for its raise.
    • A charger that declares none keeps evSettle (20 s) on the battery side, as on master. The EV limit still gives it 180 s.
  • peakW in stepEVMeterLimit sums each charger's highest offer of the last evSettle from the same per-charger log. I kept the 20 s window here, not each charger's response time. The hourly import cut needs it: with the longer window, TestEVHardLimitCutsAgainAfterSettleWhenChargerIgnores and TestEVHardLimitCarUnderItsOfferDelaysCut fail, as the audit found.
  • Test fixtures: sent(st, "ev", …) now seeds the per-charger log. issue1868Site seeds the car's 4140 W offer under its loadpoint ID and older than 180 s: the steady state Fuse guard sheds EV fully (min step 4140 W) while home battery grid-charges, and EV never restarts #1868 is about. The young offer has its own test below.
  • The control site simulator can now declare a charger's power_max_age_s outside the field matrix (simSite.evMaxAge).

Why undeclared chargers keep 20 s on the battery side

The first version of this PR gave the battery side 180 s for every charger that declares none. Only Easee Cloud declares a response time, so every other charger (CTEK, Zap/OCPP, Ambibox, Ferroamp, Tesla Wall Connector) got 180 s. The battery clamp reads only the site meter and our own commands. For 180 s after each car start or raise it then reserved the car's whole demand on top of a reading that already showed the car. On the #1868 shape (rig s18: 16 A, 3 A house, car steps [0, 4140] W, 6000 W battery charge, a charger that draws within 2 s) battery charge stayed at 0 W from the car's start until 3 min, where master charges at 4140 W from about 60 s. The s18 judge needs the battery above 1000 W at 100-120 s. A review caught this.

The cost of the 20 s rule is the case this PR started from, but only for a slow charger that declares nothing. The one slow controllable charger in the bundled set declares its time, and Zaptec Cloud is read-only. A slow charger added later should declare power_max_age_s.

Other fixes the review listed, not taken here:

  • Ending the reservation once the site meter shows the rise. PV, house load and the battery's own changes all move the same reading, so a false "shown" would give the battery the car's room. That needs more design than this PR.
  • Accepting the 3 min of zero battery charge and changing the s18 judge. That is the owner's call, and this PR does not need it.

Tests

  • TestSlowChargerUnshownOfferKeepsBatteryOut (from the audit repros TestReproSlowCharger* in caa-165 and TestReproEVBatteryStagesSlowCharger in caa-173): a charger that declares 180 s, with start delays of 5 s, 30 s, 40 s and 2 min.
    • Every delay peaks at 24.0 A with no failure.
    • With the same charger declaring nothing, it fails as on master: 32.0 A at 30 s and 40 s, 32.6 A at 2 min, and the car idle with room. This is the trade-off above.
  • New TestFastChargerYoungOfferLetsBatteryShare (the review's s18 plant test), with a manual battery hold and with a planned charge: car at 4140 W and battery at 3000 W or more at 60, 100, 120 and 170 s. With the first version's 180 s rule it fails: battery 0 W at 60 s.
  • cd go && go test -race -count=1 ./internal/control/... ./internal/loadpoint/... ./internal/sitesim/...: ok (control 205 s).
  • go vet ./internal/control/ and go build ./...: ok.
  • I did not run TestSite or make verify. The siterig runner and CI cover them.

Risk

Medium. This changes how much EV load the battery and the joint allocator treat as already in the meter.

🤖 Generated with Claude Code

The battery side read a 20 s pool log of EV offers (State.evOffers), while
the EV fuse limit keeps each charger's offers for its declared response
time, or 180 s when it declares none. After 20 s the battery counted a slow
charger's unshown raise as already in the meter and charged into that room;
when the car started, a 25 A site read 32.6 A per phase for 3 s.

Delete State.evOffers and its two writers. evInMeterW and
clampEVBatteryHeadroom read evShownW, each charger's latest offer less
what it may not show yet. The hourly peak in stepEVMeterLimit takes each
charger's highest offer of the last evSettle from the same log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 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-27T13:13:28.111053Z 8ad6aaa 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.

The first A6 change gave the battery side the EV limit's 180 s guess
for every charger that declares no response time. Only Easee Cloud
declares one, so on every other charger the battery reserved the car's
whole demand on top of a reading that already showed the car, and
stopped charging for 3 minutes after each car start or raise. On the
#1868 shape (rig s18) battery charge stayed at 0 W until 180 s.

evShownW now uses the charger's declared power_max_age_s, and evSettle
when it declares none, as master did. Easee Cloud declares 180 s, so the
slow-charger case stays fixed.

Tests: the slow-charger test now declares 180 s, and a new plant test
runs the s18 shape with a fast charger that declares nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit ed66473 into master Oct 27, 2026
23 checks passed
frahlg added a commit that referenced this pull request Oct 27, 2026
#1881 (the old base) squash-merged. Conflicts kept the branch's
fuseLineA() log line and sitesim meterNoise field.

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