Skip to content

test(control): field matrix for EV charging and fuse protection - #1800

Merged
frahlg merged 3 commits into
masterfrom
test/ev-field-matrix
Oct 22, 2026
Merged

frahlg merged 3 commits into
masterfrom
test/ev-field-matrix

Conversation

@frahlg

@frahlg frahlg commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Problem

v0.155.0-beta.1 passed review, the 17-case device rig and the Go closed-loop sim, and field sites hit bugs the same night. The tests ran our defaults: fixed 3p, no step table, planned charging stubbed in sitesim, and chargers that applied an order at once or exactly when they declared. Easee Cloud applies in 30-60 s and declares 180 s.

This PR adds a gate for v0.155.0-beta.2: the fast Go sim runs Core against the settings sites run. Test code only; no control logic changes.

What runs

TestSiteSimFieldMatrix (go/internal/control/sitesim_field*_test.go), 338 cases, each in a synctest bubble so the telemetry store, command evidence, the API's verdict and live Home use run on the plant's clock.

  • Planned charging runs through the real controller. The adapter's stub directive is replaced by a minimal planner: each 15 min slot asks for the remaining energy at the smallest planning step that reaches the goal by the deadline, as the slot's Wh and on-power. Its steps are what main.go gives the planner: LimitPower(st, PlanningSteps(...)). It replans from what the car took.
  • Chargers apply in between. Timing is (apply delay, declared power_max_age_s): (2 s, none), (40 s, 180 s), (120 s, 180 s), (60 s, none). Cloud timings also get the Easee driver's own auto split (voltage × the fuse's current) and its 90 s phase hold (easee_cloud.lua pick_phases), and report 10 s late.
  • Surplus cases get the planner's PV forecast as pickSurplusSteps reads it (here, the export the case will have), so one phase is judged as Core decides it: only when the next 30 min stay below three phases at 6 A.
  • Requests: planned charging to a deadline (3 h, a kettle step on L1 twice), Charge now as the app sends it (3p, or 1p on a 1p charger, as web/app.js does), surplus-only while export steps 2.5 / 6 / 9 / 3 kW (never past the fuse with the car off), Pause for 25 s then Start, and a planned charge across a 20 s Core restart.

Configurations

Settings only: no names, hosts, credentials or addresses. Help-report data stays local; the matrix holds only the charging settings below, with the house rounded to 2 A a phase.

Case prefix Source Settings
help1022/ Help report, 22 Oct 2026, v0.155.0-beta.1 3 × 20 A, Easee Cloud, phase_mode: auto, steps 1,600-3,800 W (one phase) and 4,800-11,100 W (three), min 1,600, max 11,100, 84 kWh, deadline goal, control_interval_s: 5, house about 2 A a phase. Pixii is the site meter; its battery sat at its floor, so it is left out.
pr1763/ Site in #1763's comments 3 × 25 A, Easee Cloud, 3p, steps 6-15 A on three phases (max 10,350), car takes about 8.3 kW, Pixii charging 6 kW from the plan at the same time, house heavier on L1.
homebox/ The home box, read-only /api/loadpoints 3 × 16 A, Easee Cloud, phase_mode unset, no steps, 4,140-11,000 W, 76 kWh.
sweep/ Settings page and axes Fuse 16 / 25 A × phase mode unset / auto / 3p / 1p × steps none (what loadpoints.js writes for a new charger: 4,140-11,000 W) / Easee whole amps with one-phase levels / 3p only (1p only for a 1p charger) × car 3p / 1p × house balanced (3 A) / mostly on L1 (7-1-1 A), planned and Charge now, Easee 40 s / 180 s.
timing/ Device-rig defaults (reports/simulatorprov-20261021) and axes Fuse 16 / 25 A × (unset, none) as the rig's charger, (auto, Easee steps) as the rig's s5s, (3p, 3p steps), every request, every timing, house on L1.
probe/, help1022/.../car1p, +L1step #1799's review and fix D A charger whose table holds only one-phase levels (0, 1,380, 2,300, 3,680 W, max 5,520, auto); unset mode with a goal and a 2,300 W plan slot; surplus-only with a goal and 2.5 kW export; a held 1p order raised to 3p beside L2 at 20 A on 25 A; the help report's site with a one-phase car; a 10 min, 8 A step on L1 under a three-phase car.

Pruned: a 1p phase mode is a one-phase charger, so car phases and a 3p table do not apply to it; 20 A runs as the help report's site; duplicate cases run once (two homebox/ rows replace their sweep/ twins).

Invariants, every second

  1. The simulated fuse never blows.
  2. FTW's limit as before: after an outside step, over it no longer than the fastest lever; after our own raise, no more than 3 s. An outside step a case marks no longer counts as our raise.
  3. cut: no fuse cut while every phase reads under fuse − 1 A. Pending orders may hold a raise back; they never cut.
  4. over-cut: no second fuse cut while a cut the charger has not applied yet covers the measured overage.
  5. energy: a planned charge delivers 90 % of the energy the fuse allows by its deadline.
  6. one-phase: planned charging and Charge now never put a three-phase charger on one phase. Surplus may.
  7. verdict: a car drawing against an order older than the charger's response time reads not_following (or limited/one_phase) after two minutes and a half, read from GET /api/loadpoints as the app reads it.
  8. home-use: Home use stays measured while every device reads (not while the sim's PV, which has no driver, produces).
  9. idle: the car charges within a minute plus the charger's delay once it has room, judged by the importing phases' heat as Core's start rule judges it.

Known rows

go/internal/control/testdata/sitesim_field_known.txt lists each row and invariant that fails on master with the fix that turns it green. A listed row that passes fails the test ("passes now: delete its known line"), and so does an unlisted failure. FTW_FIELD_KNOWN=write rewrites the file from a run, keeps each label and marks new failures new. FTW_FIELD_TRACE=<case> logs each order with the phases, the offer and the car.

Results

On master: 89 of 338 cases pass, 249 fail; all 249 are listed, so the test passes.

Report Cases Fail on master Fail with #1795 + #1799
help1022 25 23 13
pr1763 16 6 6
homebox 16 12 4
sweep 158 135 99
timing 108 65 37
probe 15 8 4
all 338 249 163

Known rows by fix (row = case and invariant), checked by running this matrix on each branch:

Label Fix Rows Turns green on that branch
A #1799: room limits rises only, the split never cuts a running order 21 21 of 21 (A applied alone)
B #1799: planned charging and Charge now on three phases, planner steps a 3p order can deliver 192 192 of 192
#1795 #1795: not following 29 29 of 29
netting no fix yet 99 0
D fix D, on #1799's branch 24 0
export #1782, draft 13 0
readback no fix yet 10 0
relief #1789, draft 6 0
hold3p no fix yet 2 0

With #1795 and #1799 together, 241 lines go and 86 cases turn fully green. 20 rows appear: one-phase cars on planned charging move from one-phase to energy (D: the charger now stays on 3p, as it should, and the plan counts 3p watts) or to over-cut (netting). #1799 alone also adds verdict rows that #1795 turns green.

What the field reports reproduce

  • help1022 reproduces the night: cut the car 3500 -> 0 W for the fuse with every phase under 19 A (meter [15.2 1.7 1.5] A) while planned at a one-phase step under auto. Fixed by A (cut) and B (three phases).
  • homebox and every unset or auto planned case with min 4,140 W: under a goal Core sends auto, evPoolFor then grants one phase of room, and an idle start needs room for min_charge_w on that one phase (18 A). A planned charge never starts on 16 A. Fixed by B.
  • Unset or auto with no steps, 25 A: Easee splits auto at 25 A × 230 V = 5,750 W, so a 4,140 W order runs on one phase at 16 A. Core cuts 4,140 → 3,910 → 3,680 W and the current does not move; the phase stays at 25 A past the limit window. Fixed by B.
  • pr1763: pause and restart pass on master since fix(control): a cut ends the reservation of the higher orders before it #1793; with the battery charging it shows relief and netting.

Findings no beta.2 fix covers

  • netting (96 rows, every cloud timing). A cut takes the overage from the highest order of the last evSettle (20 s) (ev_fuse_budget.go stepEVMeterLimit, peakW). Easee shows a cut after 30-120 s, so Core cuts again before the first cut lands: 10,400 → 6,900 W, then 0 W 25 s later after a 6 A kettle. Of the 95 double cuts on master, 43 take the car to 0 W, 25 cost one extra step and 27 more. The same window misses a raise still on its way, so a cut is too small and the phase stays over the limit (homebox/.../cloud60s/.../pause). The rule "count our own orders that cannot have shown yet" covers cuts too; the owner decides.
  • D (36 rows after the fixes). A one-phase car on a three-phase charger takes a third of the order. With B the charger rightly stays on 3p, but the plan counts 3p watts and delivers under 90 %. fix(loadpoint): a charger on one phase under a three-phase order is seen, and planned at its rate #1796 plans at the delivered rate, but the rate under a 6 A order is 1,380 W, not the car's 3,680 W, so on that branch energy got worse (78 rows).
  • export (13 rows). An exporting phase counts as import, so surplus never finds room to start on 16 A (fuse_heat.go abs; fix(control): phase current takes its direction from the phase's power #1782).
  • readback (9 rows). Surplus raises again before a cloud charger applied the last raise, then both land and it pauses; it cycles 40 s on, 40 s off with 6 kW export. Same finding as sitesim's known row at surplus_reserve.go:128.
  • hold3p (2 rows). On a charger whose table stops at 3,680 W with phase mode auto, Charge now never starts: the on-box app sends phase_mode: 3p because /api/loadpoints reports 3 phases for auto, and Core floors a 3p order to the table, below 4,140 W, so 0 W with reason fuse_limit. Planned charging on the same charger works with fix(loadpoint): planned charging runs on three phases, and room never cuts a running order #1799.
  • relief (6 rows). With the battery charging, a kettle makes Core discharge the battery for relief, then end the discharge (a raise) before the car's cut lands; L1 stays over the limit more than 3 s.

Overlap with open PRs

#1799 and #1796 also change sitesim_test.go and sitesim.go. The plan field, the boot lines and the Charge now condition here are byte-identical to #1799's, and the sim's Easee split lives in sitesim.EVSE instead of send, so this commit cherry-picks cleanly onto #1795, #1796 and #1799. Whichever lands second deletes its known lines with FTW_FIELD_KNOWN=write and reviews the diff.

Runtime

53 s of CPU, 10 s wall on 10 cores; 15 s wall with GOMAXPROCS=4 go test -p 2. Deterministic over three runs. The matrix skips -race builds (//go:build !race): it runs one goroutine per case and took 826 s of CPU under the race detector. TestSiteSimScenarios still runs under -race.

Evidence

Not in this PR

🤖 Generated with Claude Code

v0.155.0-beta.1 passed our tests and failed on field sites the same
night. We tested our defaults: fixed 3p, no step table, planned charging
stubbed, and chargers that applied an order at once or exactly when they
declared. Easee Cloud applies in 30-60 s and declares 180 s.

TestSiteSimFieldMatrix runs Core's EV charging and fuse protection in the
closed loop against 314 cases: the field reports (the help report of
22 Oct, the site in #1763, the home box) with every charger timing,
a sweep over fuse, phase mode, step table, car phases and house load, and
a timing sweep over every request. Planned charging now runs through the
real controller: a minimal planner sends the slot's Wh and on-power, from
the steps main.go gives the planner.

Each case runs in a synctest bubble, so command evidence, the API's
verdict and live Home use run on the plant's clock. Invariants, every
second: the fuse holds; FTW's limit as before; no fuse cut while every
phase is under the limit; no second cut while an unapplied cut covers the
overage; a planned charge delivers 90 % of what the fuse allows; planned
charging and Charge now never put a three-phase charger on one phase; a
charger drawing against its order reads not following; Home use stays
measured while every device reads.

testdata/sitesim_field_known.txt lists the rows that fail on master and
the fix that turns each green. The sim gains the Easee driver's auto
split and phase hold, and outside steps no longer count as our raise.
The matrix skips -race builds: it finds nothing there and runs 15 times
slower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg frahlg added the no-changeset PR intentionally exempt from the changeset requirement (dev tooling / non-shipping) label Oct 22, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 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-22T06:54:46.534026Z 558592c 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.

frahlg and others added 2 commits October 22, 2026 08:55
Twenty-four rows from #1799's review and fix D: a charger whose
table holds only one-phase levels (max 5,520 W, auto), phase mode unset
with a goal and a 2,300 W plan slot, surplus-only with a goal and 2.5 kW
of export, a held 1p order raised to 3p beside a phase at 20 A, the help
report's site with a one-phase car, and a long L1 step under a
three-phase car.

Surplus cases get the planner's PV forecast as pickSurplusSteps reads it,
so the one-phase rule is judged as Core makes it: one phase only when the
next 30 min stay below three phases at 6 A. Surplus cuts follow the
export and are no longer judged as fuse cuts. The trace also logs a
change of reason.

New label hold3p: the app sends Charge now as 3p for an auto loadpoint,
and a charger whose table stops below 4,140 W gets 0 W.

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

no-changeset PR intentionally exempt from the changeset requirement (dev tooling / non-shipping)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant