Skip to content

test(control): closed-loop site simulation with a real fuse - #1775

Merged
frahlg merged 2 commits into
masterfrom
test/sitesim-closed-loop
Oct 21, 2026
Merged

frahlg merged 2 commits into
masterfrom
test/sitesim-closed-loop

Conversation

@frahlg

@frahlg frahlg commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

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/control models 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 a
    time 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_mode into a current the way the Easee
    driver 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_gg profile in srcfl/device-simulator
    cmd/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 real
    loadpoint.Controller, EVFuseBudget and ComputeDispatch in
    main.go's order (site import, charger tick, battery dispatch), every 2 s
    as control_interval_s does, with the config defaults, the 60 s
    freshness gate, the watchdog and the loadpoint session store across
    restarts. Readings carry the plant's clock, SoC times included.
    telemetry.LiveMeasurementAt was not needed.

Invariants, every simulated second

  • (a) The fuse never blows. A phase above fuse − 1 A that follows a raise
    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.overA reads it: above fuse − 1 A only while the I²-filtered
    current (τ 60 s) is under it, never above 1.2 × the fuse. Response time is
    a control tick + the device's delay + a meter reading.
  • (b) With the meter on schedule, room for the car's minimum on its
    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.
  • (c) With a site meter older than 60 s, no charger command raises power.
    Battery dispatch is behind main.go's gate and does not run.

A run keeps the first broken invariant. TestSiteSimIgnoresWallClock runs
15 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-clock SoCUpdatedAt in place.

Scenarios

Every row runs in CI. A known row must keep failing; once its fix lands it
fails with "passes now: delete its known line", so a fix PR cannot forget
it. go test ./internal/control/ -run TestSiteSim -v prints each known
row's failure.

Scenario Master Finding / result
export start (25 A, PV exports 20 A per phase, Charge now) known export counted as import, fuse_heat.go:120, controller_fuse_budget.go:174; offer 0 W (fuse_limit) for 5 min
export start, unsigned phase currents known same; a fix must also handle a meter that drops the sign
charger's own load balancer at 25 A (added) known a cut comes off an offer the car does not draw, ev_fuse_budget.go:172; L1 holds at 25.0 A, over Core's 24 A limit for 1 min 35 s
slow cloud charger 60 s (house 15 A) known rises stack on a charger slower than evSettle, ev_fuse_budget.go:204; a landed raise holds 25.0 A per phase
slow cloud charger 120 s (house 15 A) known same; peak 28.0 A, and the car idle with room for 6 min 40 s of 15 min
#1761 replay pass checks the fault state at 9 min 50 s (offer 10,350 W, draw 4,200 W, battery 9.4 kW, 20.7 A per phase); when the car takes its offer the phases reach 29.6 A for 4 s and the battery yields
meter every 30 s after a cut to 0 known a meter slower than evSettle makes every reading a gap, ev_fuse_budget.go:141; the car never restarts
phase mode 3p, 9 kW surplus pass control for the next two: 8,970 W
phase mode empty, 9 kW surplus known empty phase mode gets one phase of room, controller_fuse_budget.go:181; offers 2,300 W
phase mode auto, 9 kW surplus known same
9 kW surplus, readback 10 s late (added) known surplus adds back the charger's late readback, surplus_reserve.go:128; the car cycles, idle 3 min 16 s of 5 min
surplus_unlock_bat_soc saved, not armed, Charge now known a saved unlock sets the fuse phase mode, controller_fuse_budget.go:184; idle 4 min 18 s of 5 min
battery reaches its floor under Charge now (added) known the battery stops at its 5 % floor with no room for that, dispatch.go:3540; L1 goes from under 24 A to 29.7 A and the car is cut only on heat
a day (24 h) pass Self mode, Charge now overnight, 9 kW PV peak, 20 kWh battery, heavy evening on L1, Core restart at 18:31, battery driver offline at 20:01, meter silent 90 s at 22:31; peak 23.2 A; runs in 1.2 s

The 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

  • After fix(loadpoint): the site-meter limit is the only fuse guard for chargers #1774 lands, the adapter's fuse budget call should take
    s.ctrl.FuseConfigs() instead of s.mgr.Configs(), and
    SetPerPhaseMeterAmps becomes a no-op.
  • The tick glue copied from main.go into control() should be replaced
    by extracting the control tick into a function both call, so the
    simulation cannot test a stale copy.

Notes

  • The fuse curve blows slowly from just above the rating, so it is stricter
    than a real gG fuse below 1.25 × its rating.
  • On a stale meter the battery's autonomous mode is modelled as 0 W.
  • About 1,050 lines with tests and comments, above the 600 aimed for.

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)
  • Each known row fails as listed; marking a passing row known fails the test; the wall-clock test fails with the SoC fix removed; the Explain EV under-delivery using site fuse headroom and concurrent battery charging #1761 check fails if the expected draw is moved by 100 W.
  • GOFLAGS=-p=2 make verify: "verify: vet + test + build clean"

🤖 Generated with Claude Code

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>
@frahlg frahlg added the no-changeset PR intentionally exempt from the changeset requirement (dev tooling / non-shipping) label Oct 21, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 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-21T09:41:36.540595Z 6cf361a 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.

@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: 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".

Comment thread go/internal/control/sitesim_test.go Outdated
Comment on lines +156 to +157
s.store.Update(driver, kind, w, soc, data)
s.store.Get(driver, kind).UpdatedAt = s.p.Now

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 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 👍 / 👎.

Comment on lines +204 to +205
if h := s.store.DriverHealthMut(d); now.Sub(at) > time.Minute && h.IsOnline() {
h.SetOffline() // The watchdog.

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 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>
@frahlg
frahlg merged commit 200da78 into master Oct 21, 2026
22 checks passed
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