Repository navigation
test(control): closed-loop site simulation with a real fuse - #1775
Conversation
New package go/internal/sitesim: a house behind a gG main fuse, stepped one second at a time on a pinned clock. House load per phase, PV, a battery with latency, an Easee-like charger with command delay, readback lag and an optional load balancer, a car, a site meter that may drop the sign or go silent, and a timeline of restarts and outages. go/internal/control/sitesim_test.go runs the real loadpoint controller, EVFuseBudget and ComputeDispatch against it in main.go's order and checks three invariants every second: the fuse holds and Core's own limit breaks only for the response time plus 3 s; Charge now with room charges within a minute; a meter older than 60 s raises nothing. Scenarios that fail on master are skipped with the finding, so each fix deletes one line. No production code changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.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: 6cf361aeca
ℹ️ 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".
| s.store.Update(driver, kind, w, soc, data) | ||
| s.store.Get(driver, kind).UpdatedAt = s.p.Now |
There was a problem hiding this comment.
Keep SoC timestamps on the simulated clock
Store.Update stamps SoCUpdatedAt with time.Now(), but this helper rewrites only UpdatedAt while State.now() is pinned to p.Now. Whenever wall time differs from simulated time by more than five minutes—or is later than simulated time—updateBatteryChargeFull rejects the SoC as stale or future. Consequently, the 24-hour scenario does not exercise Core's full-battery latch when the simulated battery fills, and its coverage depends on when the test runs; rewrite SoCUpdatedAt onto the simulated clock as well.
Useful? React with 👍 / 👎.
| if h := s.store.DriverHealthMut(d); now.Sub(at) > time.Minute && h.IsOnline() { | ||
| h.SetOffline() // The watchdog. |
There was a problem hiding this comment.
Model the watchdog's default-mode transition
When the DriverOffline event makes the battery stale, this watchdog emulation only changes telemetry health. The production watchdog in go/cmd/ftw/main.go also invokes the driver's default mode, whereas the simulated battery retains its previous W command throughout the outage. The day scenario therefore cannot validate the required stale-driver fallback and may pass while that safety integration is broken; model or explicitly assert the default-mode transition here.
AGENTS.md reference: AGENTS.md:L67-L67
Useful? React with 👍 / 👎.
Review of #1775: - Readings carry the plant's clock for SoC times too. store.Update stamps SoCUpdatedAt with the wall clock, so the battery-full pause worked only when plant time was near wall time. TestSiteSimIgnoresWallClock runs the day across the wall clock and a day either side; every command matches. - Known rows run in CI: each must keep failing, and fails once it passes until its fix deletes the known line. - Invariant (a) is split. A phase over the fuse less its margin after a raise we sent may last 3 s; an outside change gets the fastest lever's response time plus 3 s. The 60 s charger now fails as known, and a new known row has the battery stop at its 5 % floor under Charge now. The day's battery is 20 kWh so it lasts the evening. - The #1761 replay checks it reached the field state before the car takes its offer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
Fuse protection and EV charging now compute the limit in nine places
(review of master 5e8e417, 21 Oct 2026). Each test plant in
go/internal/controlmodels a slice of the site; none has a physical fuse,a car or a charger's own load balancer. A core change cannot be checked
against a house with a real fuse.
Change
Test-only. No production code changes, so no changeset.
go/internal/sitesim(new package): a plant stepped one second at atime on a pinned clock, with no goroutines or sleeps. Signed house load per
phase, three-phase PV, a battery (1p or 3p, response latency, SoC), an
EVSE that turns
power_w/phase_modeinto a current the way the Easeedriver does (command delay, readback lag, session energy, optional load
balancer), a car (phases, maximum power), a site meter (any interval,
signed or unsigned phase currents) and a gG fuse per phase as a damage
accumulator, ported from the
iec_ggprofile insrcfl/device-simulatorcmd/simulator/fuse.go. A timeline carries plant changes, Core restarts,a driver offline and a silent meter.
go/internal/control/sitesim_test.go: an adapter that runs the realloadpoint.Controller,EVFuseBudgetandComputeDispatchinmain.go's order (site import, charger tick, battery dispatch), every 2 sas
control_interval_sdoes, with the config defaults, the 60 sfreshness gate, the watchdog and the loadpoint session store across
restarts. Readings carry the plant's clock, SoC times included.
telemetry.LiveMeasurementAtwas not needed.Invariants, every simulated second
we sent (a higher charger offer, or more than 1 A of extra battery import)
within the slowest response time may last 3 s. After an outside change
(house load, PV, the car taking more), Core's own limit may break for the
fastest lever's response time + 3 s, where Core's limit is read as
fuseHeat.overAreads it: above fuse − 1 A only while the I²-filteredcurrent (τ 60 s) is under it, never above 1.2 × the fuse. Response time is
a control tick + the device's delay + a meter reading.
phases (house and PV only; the battery should yield) and Charge now, the
car draws within 60 s + the charger's delay, and keeps drawing while that
holds. Surplus rows require that much export instead.
Battery dispatch is behind
main.go's gate and does not run.A run keeps the first broken invariant.
TestSiteSimIgnoresWallClockruns15 h of the day with the battery full as the plant passes the wall clock,
and again a day earlier and later; every command must match. It fails if
the adapter leaves
store.Update's wall-clockSoCUpdatedAtin place.Scenarios
Every row runs in CI. A
knownrow must keep failing; once its fix lands itfails with "passes now: delete its known line", so a fix PR cannot forget
it.
go test ./internal/control/ -run TestSiteSim -vprints each knownrow's failure.
fuse_heat.go:120,controller_fuse_budget.go:174; offer 0 W (fuse_limit) for 5 minev_fuse_budget.go:172; L1 holds at 25.0 A, over Core's 24 A limit for 1 min 35 sevSettle,ev_fuse_budget.go:204; a landed raise holds 25.0 A per phaseevSettlemakes every reading a gap,ev_fuse_budget.go:141; the car never restartscontroller_fuse_budget.go:181; offers 2,300 Wsurplus_reserve.go:128; the car cycles, idle 3 min 16 s of 5 minsurplus_unlock_bat_socsaved, not armed, Charge nowcontroller_fuse_budget.go:184; idle 4 min 18 s of 5 mindispatch.go:3540; L1 goes from under 24 A to 29.7 A and the car is cut only on heatThe floor row comes from the split of (a): the first day ran a 10 kWh
battery to its floor at 18:54 with the car at 16 A, and that raise is ours.
The day now has a battery that lasts the evening, and the floor is its own
row.
Later, not in this PR
s.ctrl.FuseConfigs()instead ofs.mgr.Configs(), andSetPerPhaseMeterAmpsbecomes a no-op.main.gointocontrol()should be replacedby extracting the control tick into a function both call, so the
simulation cannot test a stale copy.
Notes
than a real gG fuse below 1.25 × its rating.
Test evidence
go test ./internal/sitesim/... ./internal/control/... ./internal/loadpoint/... -p 2 -count=1: ok (control 5.0 s; the day 1.2 s; the wall-clock test 2.4 s)GOFLAGS=-p=2 make verify: "verify: vet + test + build clean"🤖 Generated with Claude Code