Skip to content

minor: the fuse as a thermal model, within IEC 60269 and following ABB's gG curves, as an importable package (fuse/) - #9

Merged
frahlg merged 4 commits into
mainfrom
feat/fuse-package
Oct 23, 2026
Merged

frahlg merged 4 commits into
mainfrom
feat/fuse-package

Conversation

@frahlg

@frahlg frahlg commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

The house lives in this repo, and FTW's fast closed-loop simulation (go/internal/sitesim in srcfl/ftw) should judge its fuse by the same model. This moves the fuse model into an importable package, fuse/ at the module root, with no dependencies. It also replaces the model itself (Fredrik, 22 October): the simulator is the judge of Core's fuse protection, so its fuse must follow physics and IEC 60269, and must not copy Core. The model is a central estimate, one maker's mean curve within the standard's tube, not a worst case.

Why the old model could not judge

The old fuse was a damage counter: each second above the rating spent 1/t of the fuse's life, and the damage recovered linearly over a fixed cooldown.

  • No preload. A fuse that had carried 0.9–1.0 × In for an hour blew at an overload no sooner than a cold one.
  • Wrong long-time limit. 1.13 × blew within the hour, where IEC 60269-1 lets a gG carry 1.25 × In for it.
  • Too slow at high multiples. 5 × took 20 s at every rating, against the 5 s gate.
  • Recovery did not depend on the current.

The model

Each phase has three thermal bodies in a ladder:

heat I²  ->  element  ->  fuse body and sand  ->  holder and cabinet  ->  ambient
  • Units. Heat into the element is u² (u = I/In). The three resistance shares Re, Rb and Rh add up to 1, so at a steady current the element sits u² above ambient.
  • Melting. The element melts at UMin², UMin being the steady multiple that just melts it at 20 °C. That rise maps to tin's melting point, 232 °C: the M-effect solder spot. A warmer cabinet leaves less margin.
  • Time constants. Each body's τ is its heat capacity times its own outgoing resistance. The system's modes come out of the coupled ladder: at 25 A they are 0.12 s, 9.4 s and 1,308 s.
  • Exact steps. Step solves the ladder exactly for a current held over the step, from an eigendecomposition done once per band. Steps of 0.5, 1 and 2 s reach the same state to 1e-9. dt = 0 changes nothing and returns the time left at that current, which makes it a "time to melt now" probe.
  • Export heats as import (I²). Cooling follows the modes; after an hour it is the slowest mode's exponential.
  • API:
    • Lookup, All, TripSeconds(multiple) at the reference band, and Step(*Phase, amps, ratingA, dt) Status;
    • MeltSeconds(multiple, ratingA, ambientC), Band, Ratings, Supports;
    • Preheat(*Phase, amps, ratingA), which sets the steady state of a held current, so a scenario that starts mid-evening starts warm;
    • Phase.Theta (the state) and Phase.AmbientC *float64 (nil means 25 °C; 0 is 0 °C).
  • Status.
    • ratio is current over rating.
    • trip_s is the time to melt from cold at this ratio.
    • remaining_s is the time to melt from now.
    • damage is the element's way from ambient to melting, 0 to 1.
    • active means it will melt if the current stays.
    • invalid says why a step was refused.
  • Refused input. A NaN current, a negative, NaN or infinite step, an ambient that is not finite or not below 232 °C, and a rating the profile does not model are refused. The state stays as it was and invalid says why. An infinite current, or one whose square overflows, blows the fuse; the reported ratio stays finite for JSON.
  • Preheat on a blown fuse is refused: a judge does not replace a fuse by itself.
  • Independent of Core. Nothing is shared with Core's heat filter (τ 60 s, 1.25 × In).

Calibration

What the standard fixes. IEC 60269-1 fixes, per rating:

  • the conventional currents: Inf = 1.25 In must not melt and If = 1.6 In must melt within 1 h, for 16–63 A;
  • four gates at 10 s, 5 s and 0.1 s;
  • the pre-arcing I²t window at 0.01 s.

These come from the standard's tables as given in:

  • Schneider Electric, Complementary technical information, CA908036E v3.2 (2018);
  • Enel GSCL004 rev. 0 (2016), Tables 6, 7 and 14, for 25–63 A;
  • Mersen EduPack TM-104 (2012), slide 6, for 25 A.

35 A is not in those tables; its gates and I²t are interpolated on log axes between 32 A and 40 A. The reference ambient of 20 °C, with the curves approximately valid at 30 °C, is from Enel GSCL004 §6.1.

What the standard leaves open. The gates leave a wide tube. No gate constrains 1.4–2 ×, where home overloads land, and 2 × from cold is constrained by nothing. The review's sensitivity run on the a6baf29 structure shows how wide the tube is for 25 A: varying the holder's share (0.2–0.6), the holder's τ (600–2,400 s) and UMin (1.30–1.55), with every gate kept, puts a cold 1.6 × anywhere from 82 s to 2,293 s, and 2 × from 24 s to 351 s.

Fitted to a manufacturer. To pin that region, each band follows ABB's pre-arcing curves for OFAF gG fuse-links, size 000, 500 V (ABB Fusegear DIN-type HRC-fuse links, 1SCC317001C0201, page 14). The curves were digitized from the PDF's vector paths, so the read-off error is under 2 % in time (48 vertices a curve, linear on log axes between them).

All six parameters (UMin, Re, τe, Rh, τb, τh) are fitted per band, by least squares on log time from cold at 20 °C, to eleven ABB points:

  • the curve's start at 7,990 s (the exact vertex, so UMin stays below it);
  • 1.5, 1.6, 2, 2.5, 3, 4, 5, 8 and 10 × In;
  • the current that melts in 0.01 s.

The fit keeps every gate.

R(T). An element resistance rising with temperature (silver, 0.0038 per K) was tried and made the fit worse: rms 0.11–0.14 decades instead of 0.08–0.095, UMin at its bound, and the 32 A I²t outside its window. It is left out.

Ferraz Shawmut. Their NH gG chart (gGNH, F 24) was also tried. It is a raster image with label leader lines across the curves, and it could not be traced reliably, so it is not used. Read by eye it looks slower than ABB at 2–3 ×, which shows how far makers sit apart inside the tube.

Validation against ABB (from cold, 20 °C)

Each cell is ABB / model, with the model's error in decades in brackets. The last two columns give the error at the eleven fitted points and at all 48 vertices of the ABB curve.

In 1.5 × 1.6 × 2 × 3 × 5 × rms, max at 11 points rms, max at 48 vertices
16 3,547 s / 2,760 s (−0.11) 1,165 s / 1,483 s (+0.10) 53.1 s / 53.2 s (+0.00) 3.29 s / 3.48 s (+0.02) 0.333 s / 0.308 s (−0.03) 0.092, 0.14 0.199, 0.46
20 2,593 s / 1,941 s (−0.13) 820 s / 1,094 s (+0.13) 57.8 s / 57.9 s (+0.00) 4.58 s / 4.73 s (+0.01) 0.450 s / 0.425 s (−0.02) 0.095, 0.14 0.170, 0.39
25 2,341 s / 1,816 s (−0.11) 822 s / 1,058 s (+0.11) 58.7 s / 58.9 s (+0.00) 4.22 s / 4.48 s (+0.03) 0.445 s / 0.404 s (−0.04) 0.088, 0.13 0.159, 0.36
32 2,581 s / 2,060 s (−0.10) 970 s / 1,211 s (+0.10) 72.9 s / 73.2 s (+0.00) 5.06 s / 5.38 s (+0.03) 0.527 s / 0.470 s (−0.05) 0.080, 0.12 0.147, 0.33
35 2,680 s / 2,180 s (−0.09) 1,050 s / 1,284 s (+0.09) 76.5 s / 76.9 s (+0.00) 5.21 s / 5.53 s (+0.03) 0.539 s / 0.480 s (−0.05) 0.080, 0.13 0.145, 0.33
40 2,778 s / 2,246 s (−0.09) 1,067 s / 1,312 s (+0.09) 80.2 s / 80.6 s (+0.00) 5.71 s / 6.04 s (+0.02) 0.598 s / 0.544 s (−0.04) 0.081, 0.13 0.143, 0.32
50 3,566 s / 3,005 s (−0.07) 1,471 s / 1,721 s (+0.07) 122 s / 123 s (+0.00) 8.24 s / 8.65 s (+0.02) 0.829 s / 0.776 s (−0.03) 0.087, 0.15 0.139, 0.28
63 1,798 s / 1,562 s (−0.06) 910 s / 1,026 s (+0.05) 135 s / 139 s (+0.01) 9.99 s / 10.1 s (+0.01) 0.767 s / 0.710 s (−0.03) 0.092, 0.17 0.129, 0.22

The fitted points hide the shape between them. Against all 48 vertices the model is rms 0.13–0.20 decades off, at most 0.22–0.46:

  • too slow at 1.6–1.95 ×: 16 A at 1.82 × melts after 447 s in the model and 150 s in ABB's curve;
  • too fast at 2.0–2.5 ×: down to −0.27 decades.

The cause is a step in the model's curve. Where the element's quasi-steady rise over the body reaches the melting rise, at u = UMin/√(Re + Rb) = 2.02–2.09 × In (16–63 A), the body stage takes over from the holder. Between 1.9 and 2.3 × the model's log-log slope reaches −18 to −52; ABB's reaches −7 to −11. The review refitted all 48 vertices and reached only rms 0.12–0.15, so this is the three-body ladder's shape, not a poor fit. A test holds every vertex within 0.5 decades, as a regression check.

For sitesim and the site test: overloads at 1.9–2.1 × In sit on that step, so a small change of current there moves the melting time a lot. A finding there deserves a second look against the fitted points before it is called a defect.

Calibration against IEC 60269-1 (from cold, 20 °C)

In UMin Imin(10 s), ≥ 10 s Imax(5 s), ≤ 5 s Imin(0.1 s), ≥ 0.1 s Imax(0.1 s), ≤ 0.1 s I²t at 0.01 s (window) 1.25 × 1.6 × (< 1 h) 2 × 2 × after 1 h at In step at
16 1.430 33 A: 22.3 s 65 A: 1.09 s 85 A: 0.197 s 150 A: 0.028 s 566 A²s (300–1,000) never 1,483 s 53.2 s 9.0 s 2.02 ×
20 1.413 42 A: 24.7 s 85 A: 1.20 s 110 A: 0.210 s 200 A: 0.031 s 1,097 A²s (500–1,800) never 1,094 s 57.9 s 11.0 s 2.03 ×
25 1.396 52 A: 26.3 s 110 A: 0.919 s 150 A: 0.164 s 260 A: 0.036 s 2,205 A²s (1,000–3,000) never 1,058 s 58.9 s 10.0 s 2.03 ×
32 1.392 75 A: 14.3 s 150 A: 0.716 s 200 A: 0.174 s 350 A: 0.040 s 4,532 A²s (1,800–5,000) never 1,211 s 73.2 s 12.2 s 2.04 ×
35 * 1.390 82 A: 14.4 s 165 A: 0.709 s 222 A: 0.168 s 387 A: 0.040 s 5,499 A²s (2,210–6,331) never 1,284 s 76.9 s 13.0 s 2.04 ×
40 1.396 95 A: 15.1 s 190 A: 0.767 s 260 A: 0.170 s 450 A: 0.041 s 7,682 A²s (3,000–9,000) never 1,312 s 80.6 s 14.2 s 2.04 ×
50 1.410 125 A: 17.2 s 250 A: 0.776 s 350 A: 0.163 s 610 A: 0.041 s 14,012 A²s (5,000–16,000) never 1,721 s 123 s 22.5 s 2.05 ×
63 1.314 160 A: 19.4 s 320 A: 0.612 s 450 A: 0.125 s 820 A: 0.029 s 18,388 A²s (9,000–27,000) never 1,026 s 139 s 19.0 s 2.09 ×

* Interpolated gates.

Preload, 1 h at In, then:

In Damage after 1 h 1.5 × after 1 h 1.5 × cold
16 0.46 1,945 s 2,760 s
25 0.50 1,112 s 1,816 s
63 0.54 754 s 1,562 s

At 2 × the preloaded fuse takes 9–22.5 s, against 53–139 s cold.

Short overloads, 25 A. 30 s at 1.5 × every 2 min (0.5 × between) raises the damage after each burst from 0.53 to 0.64 over ten cycles. A held 1.5 × then blows in 1,532 s instead of 1,816 s cold.

Ambient, 1.5 × on 25 A. 3,053 s at 0 °C, 1,816 s at 20 °C, 1,630 s at 25 °C and 1,191 s at 40 °C.

aM, any rating. Gates from Mersen TM-104 slide 7, and all six hold. Fitted with UMin = 4 (an assumption) and the holder's share at 0.2.

Free choices and limits

  • The three-body shape. Between the fitted points the model misses ABB's curve by up to 0.46 decades, most around its step at 2.02–2.09 × In (see the validation).
  • The fit makes it ABB-like. UMin (1.31–1.43), the holder's share (0.50–0.60) and τ (1,250–1,950 s), the body's τ (7–19 s) and the element (Re 0.063–0.076, τ 0.07–0.20 s) are fitted to one maker's curves, inside the IEC tube. They are not fixed by physics or by the standard. Another maker's fuse-links sit elsewhere in the tube: the review's range above, and Ferraz looking slower at 2–3 ×.
  • R(T) is omitted (see above).
  • One melting temperature. Tin's 232 °C is used for all times; the ambient acts through it.
  • gG covers 16–63 A only. IEC's 4 < In < 16 A band has other conventional currents (1.5 / 1.9 In), and ABB gives no 10 A curve here to fit. 10 A is therefore refused for gG and the profiles scaled from it: by Step (invalid) and by the simulator's API and Wails binding. Above 63 A is refused too. aM and manual model any size and take any.
  • Pre-arcing only. No arcing time, current limiting or ageing.
  • Not calibrated: gM, gR and gS are gG with scaled times (× 2, × 0.3, × 0.5).
  • Phase current. The simulator takes it as P / 230 V.

Also here

  • cmd/simulator/fuse.go keeps its names as aliases. The site keeps each phase's thermal state (Site.FuseHeat) between ticks, clears it with the fuse, its size or its profile, and takes the cabinet's air from Site.FuseAmbientC (nil is 25 °C).
  • Fuse size. A size is checked together with its profile, in the API and the Wails binding. gG, gM, gR and gS take 16, 20, 25, 32, 35, 40, 50 and 63 A; aM and manual take any size. Changing the profile alone is checked against the saved size.
  • Dashboard. A phase the model refuses shows the reason under its bar, for example "iec_gg is modelled for 16-63 A, not 10 A" for a site saved with 10 A before this change. The size list keeps a saved size outside the bands, so it is never blank. Reviewed in a browser: a saved 10 A gG site shows the reason on all three phases and "10 A" in the settings; picking 16 A clears the reasons and the model judges the phases again.
  • Tests:
    • every gate and I²t window, ABB within 0.2 decades at the eleven fitted points and within 0.5 at all 48 vertices;
    • 1.25 × holds and 1.6 × blows through Step;
    • preload, Preheat against a 6 h soak, accumulating short overloads, cooling along the slowest mode;
    • export as import, exactness to 1e-9;
    • the status fields, 0 °C as a temperature, every refused input, infinite and overflowing currents (blown, finite ratio, valid JSON), Preheat on a blown fuse;
    • the warm cabinet, the aM gates;
    • the site loop's heat memory, sizes checked with their profiles.
  • go test -race ./... passes.

What this changes for FTW

Part of the work described in #8. Independent of #8; either can land first.

🤖 Generated with Claude Code

The IEC 60269 curves and the per-phase damage rule lived in package main,
so FTW's fast closed-loop simulation (go/internal/sitesim) kept its own
port of the gG curve. They move to package fuse at the module root, with
no dependencies: Lookup, All, Profile.TripSeconds and Profile.Step on a
Phase. The site loop now calls it; its JSON and behaviour are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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:52:45.513781Z 7156a59 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 fuse was a damage counter: 1/t of its life per second above the
rating, recovered linearly over a fixed cooldown. A fuse that had carried
its rating for an hour blew no sooner than a cold one, and 1.13 × blew
within the hour where IEC 60269-1 lets a gG carry 1.25 × for it.

Now each phase is three thermal bodies in a ladder (element, fuse body
and sand, holder and cabinet) heated by I² and melting at tin's 232 °C,
with the ambient as a parameter. Each gG band (16, 20, 25, 32, 35, 40, 50,
63 A) is fitted to IEC 60269-1's gates and pre-arcing I²t at 0.01 s and
keeps the conventional currents; aM to its gates. gM, gR and gS are gG
with scaled times and say they are not calibrated. Steps are exact, so
the result does not depend on the step size. The site keeps each phase's
heat between ticks and clears it with the fuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg frahlg changed the title minor: the fuse model as an importable package (fuse/) minor: the fuse as a thermal model calibrated to IEC 60269, as an importable package (fuse/) Oct 22, 2026
…; refuse bad input

The review of a6baf29 (#9):

- The 1.4-2 × region, where home overloads land, rested on three free
  choices (UMin, the holder's share and time constant) that no source
  pinned. All six thermal parameters are now fitted per band to ABB's
  pre-arcing curves for OFAF gG size 000 (1SCC317001C0201 p. 14,
  digitized from the PDF's vector paths), keeping every IEC 60269-1 gate,
  I²t window and conventional current: rms 0.08-0.095 decades, at most
  0.17. A temperature-dependent element resistance made the fit worse and
  is left out.
- Ambient 0 meant unset, so a 0 °C cabinet became 25 °C. Phase.AmbientC
  is now a pointer; nil is the default.
- A NaN current, step or ambient, an infinite step, an ambient at the
  melting point or a rating without a band left NaN in the state or blew
  a fuse that never melts. Step now refuses them, leaves the state and
  says why in Status.Invalid; an infinite current blows the fuse.
- 10 A has other conventional currents (1.5/1.9 In) than the 16-63 A
  band it borrowed. gG now models 16-63 A only; the simulator refuses
  other sizes, and a saved 10 A site reports the refusal.
- Preheat puts a phase at the steady state of a held current, for
  scenarios that start mid-evening.
- dt = 0 is documented as a time-to-melt probe; the step-size test checks
  the exactness to 1e-9; the Tau wording and the modes are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg frahlg changed the title minor: the fuse as a thermal model calibrated to IEC 60269, as an importable package (fuse/) minor: the fuse as a thermal model, within IEC 60269 and following ABB's gG curves, as an importable package (fuse/) Oct 23, 2026
…ays blown

The re-review of 5710bba (#9):

- F1: the 0.08-0.17 decades held only at the eleven fitted points.
  Against all 48 vertices of each ABB curve the model is rms 0.13-0.20
  decades off, at most 0.22-0.46: too slow at 1.6-1.95 ×, too fast at
  2.0-2.5 ×, where the body stage takes over at UMin/√(Re + Rb) =
  2.02-2.09 × In. That is the three-body ladder's shape; the comment says
  so and a test holds every vertex within 0.5 decades. The fit now uses
  the curves' exact start points, so 20 A's start no longer falls below
  its asymptote.
- F2: an infinite current reported an infinite ratio, which JSON cannot
  carry, and a current whose square overflows left NaN in the state. Both
  now blow the fuse with a finite ratio.
- F3: a size is checked with its profile: gG and the profiles scaled from
  it at their bands, aM and manual at any size. The dashboard shows a
  phase's refusal and keeps a saved size in the size list.
- F4: Preheat refuses a blown fuse instead of reviving it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit 736051f into main Oct 23, 2026
6 checks passed
@frahlg
frahlg deleted the feat/fuse-package branch October 23, 2026 05:12
frahlg added a commit that referenced this pull request Oct 23, 2026
…10)

* fix(ocpp): one heartbeat and meter loop per connection, DropConnection

Each connection to a Central System started another heartbeat, MeterValues
and simulation loop, and nothing ended them but Stop. After one reconnect
a charger sent every heartbeat and MeterValues twice and integrated its
energy twice a second. Heartbeats and MeterValues now end with their
connection; the simulation runs once for the charger's life, connected
or not, since a car charges through a network outage.

Two hooks for scripted runs: DropConnection(d) closes the connection as a
lost network does and keeps the charger away for d, with the session going
on; SetFrameLog reports every OCPP-J frame the charger reads or writes.

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

* feat(ocpp): the car's own limits (SetCar)

A car takes at most its own current per phase and charges on its own
phases: a one-phase car under a three-phase order draws on L1 only, and a
car that takes 10 A draws 10 A of a 16 A offer. The charger's offer
(effectivePowerW) is unchanged; its draw follows the car.

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

* feat(run): headless runs from a house spec, with a timeline and a record

simulator run -spec house.json -port-base N -out DIR builds one house:
devices by the device-drivers driver they emulate (a hybrid whose battery
follows its EMS writes, OCPP chargers with their car), the site meter
behind a fuse per phase, and the house load. It serves them on ports from
the base, applies a timeline of events (loads, pv, soc, plug, car,
offline, online, reconnect, mark, stop), and takes the same events over
POST /api/run/event while it runs. It records a sample a second of what
flows through each fuse, the fuse's damage and each device's state to
plant.jsonl, every OCPP frame per charger, ports.json and result.json.
Self-update endpoints are off in a run.

The site loop read Running without the simulator's lock while a stop
wrote it; it now takes the lock.

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

* fix(run): an outage asked for during a retry keeps its length; "meter" is reserved

Codex's review of #10:
- A reconnect event while the charger was already waiting to retry only
  moved a deadline nobody read; the retry dialled after the usual delay.
  Each dial now waits out DropConnection's deadline first.
- A device called "meter" lost its ports.json entry to the site meter.
  The spec now refuses that id.

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

* test(run): draw port bases below the ephemeral range

freeBase took a base from the kernel's next ephemeral port and skipped
those over 65000. macOS hands ephemeral ports out in turn, so once its
cursor sat near the top every try was skipped and every run test failed
with "no free port range".

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

* feat(run): the fuse cabinet's air and a warm start

With the thermal fuse on main (#9), a run's spec can set the cabinet's
air (site.fuse_ambient_c, default 25 °C) and start the fuses warm
(site.fuse_preheat_a, a current per phase they have carried long enough
to settle), as a house is in the evening. A fuse size the profile does
not model is refused before the run touches the simulator's settings.

The site loop now reads its tick before it starts: a run sets the tick
before it builds its site, and the next run could set it again while the
last loop was still reading it (the race detector caught it once runs
followed each other within milliseconds).

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

* minor: faults and seeded random timelines for headless runs (#11)

* feat(run): faults and seeded random timelines

Faults for an EMS under test, as events in a headless run and over
/api/run/event: a Modbus device that keeps its connections and answers
nothing (silence), a site meter whose readings stand still (meter_late),
one reading whose total disagrees with its phases (meter_glitch), phase
currents that carry a sign (signed_currents), and a charger's own cap
per phase (load_balancer). New model parameters, overridable per device:
startup_delay_s (the car draws that long after an offer rises from zero)
and load_balancer_a for an OCPP charger, signed_phase_currents for a
meter.

A spec's random block draws a timeline from a seed within the house's
bounds (house load per phase under the fuse less 2 A), picks the open
timing of each device, and writes timeline.json, a spec that replays it.

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

* fix(run): each session waits out the startup delay; faults keep the latest; a late meter keeps its energy

Codex's review of #11:
- offerSince now resets when a connector leaves its session, so every
  session waits out startup_delay_s, not only the first.
- An earlier silence's or outage's timer no longer ends a later one: each
  device counts its faults and a timer acts only for the latest.
- Energy that flows during meter_late shows on the counters when the
  readings move again; before, it was lost.

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

* feat(ocpp): a charger wired on one phase

wired_phases: 1 in a run spec (SetWiredPhases) wires a charger on L1. It
gives an order's current there whatever numberPhases says, and max_kw is
its rating as wired, so a 16 A order on a 3.68 kW charger draws 16 A on
L1. Before, the order became 16 A x 3 x 230 W, was capped to 3.68 kW and
spread over three phases, and a one-phase car took 5.3 A of it.

FTW's site test needs it for a charger wired on one phase, under Charge
now and planned charging.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 23, 2026
* fix(ocpp): send Basic auth from user:password in the OCPP URL

A Central System that requires OCPP 1.6 security profile 1 (FTW does)
refused the simulated charger: it dialled without credentials, and the
WebSocket dialer rejects user:password inside the URL. Move them to an
Authorization header and keep the password out of the logs.

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

* fix(ocpp): deliver StartTransaction once the charger is online

A car plugged in before the charger had reached its Central System
started a local session whose StartTransaction was lost: sendRequest
failed and nothing kept the message. Queue StartTransaction and
StopTransaction while there is no connection and send them, in order,
after the next BootNotification is accepted.

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

* feat(ocpp): profile_delay_s answers Accepted at once and applies later

A cloud charger such as Easee accepts a charging profile at once and
changes its output a minute or two later. The simulated charger applied
every profile at once, so nothing could test how a Central System
copes with that. POST /api/simulators/{id}/config {"profile_delay_s": 60}
now holds accepted SetChargingProfile and ClearChargingProfile changes
for that long, in order. Invalid profiles are still refused at once.

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

* fix(ocpp): a 1-phase profile loads L1, not a third of each phase

The simulated charger always spread its draw evenly over three phases,
also under a profile with numberPhases 1, so a site meter never saw the
one-phase load a real car puts on L1. A charger that can switch phases
(ConnectorSwitch3to1PhaseSupported) now draws a 1-phase profile on L1
only, at most its rated current per phase, and reports it per phase:
phasePowers and per-phase currents in its state and MeterValues. The
site meter takes a charger's draw per phase instead of through the
site's phase factors.

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

* fix(sungrow): write register defaults in engineering units

Register defaults are written through each register's scale, but the
Sungrow ones were raw register values: an SH10RT came up rated 100 kW
(5000, 0.1 kW) with 500 kW charge and discharge limits (33046/33047,
0.01 kW). Use 10 kW and 5 kW, in the register set, the global defaults
and the sungrow-hybrid and residential-5kw profiles.

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

* feat(sungrow): ems_control makes the battery follow the EMS registers

The Sungrow model stored an EMS's writes to 13049-13051 and never
applied them, so an EMS under test commanded a battery that did not
move. With POST /api/simulators/{id}/config {"ems_control": true} the
battery does what a real SH does: in forced mode (13049 = 2) it follows
13050 (170 charge, 187 discharge, 204 stop) and 13051 watts; in any
other mode it runs self-consumption against the site meter. It answers
after ems_response_ms (default 2000), stays within 33046/33047 and the
SoC limits 13057/13058, and writes the power-flow flags (13000) the Lua
driver signs the power by. Off by default: the dashboard sets the
battery by writing the registers directly.

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

* feat(profiles): each model names its driver and carries its timing

A profile now says which srcfl/device-drivers driver it emulates
(driver_id, the file stem: sungrow, sdm630, pixii, ocpp...) and carries
the model's timing in a model block, so field evidence for a driver can
later set it. command_response_s replaces the two settings this branch
added (ems_response_ms, profile_delay_s): it is how long the device
takes to act on an accepted command, the battery after an EMS write or
the charger after a charging profile. sungrow-hybrid says 2 s; applying
a profile sets it, and POST /api/simulators/{id}/config overrides it.

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

* fix: zero battery limits, phases in composite schedules, stop ids after a queue

Codex's review of #8:
- A Sungrow limit register holding 0 now allows no power that way; before,
  0 turned the clamp off.
- GetCompositeSchedule reports the phases the charger draws on: 16 A on one
  phase, not 5.3 A on three. A change of phases starts a new period.
- A session that starts and ends offline is stopped under the transaction id
  the Central System gives its queued start.

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

* minor: headless runs from a house spec, with a timeline and a record (#10)

* fix(ocpp): one heartbeat and meter loop per connection, DropConnection

Each connection to a Central System started another heartbeat, MeterValues
and simulation loop, and nothing ended them but Stop. After one reconnect
a charger sent every heartbeat and MeterValues twice and integrated its
energy twice a second. Heartbeats and MeterValues now end with their
connection; the simulation runs once for the charger's life, connected
or not, since a car charges through a network outage.

Two hooks for scripted runs: DropConnection(d) closes the connection as a
lost network does and keeps the charger away for d, with the session going
on; SetFrameLog reports every OCPP-J frame the charger reads or writes.

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

* feat(ocpp): the car's own limits (SetCar)

A car takes at most its own current per phase and charges on its own
phases: a one-phase car under a three-phase order draws on L1 only, and a
car that takes 10 A draws 10 A of a 16 A offer. The charger's offer
(effectivePowerW) is unchanged; its draw follows the car.

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

* feat(run): headless runs from a house spec, with a timeline and a record

simulator run -spec house.json -port-base N -out DIR builds one house:
devices by the device-drivers driver they emulate (a hybrid whose battery
follows its EMS writes, OCPP chargers with their car), the site meter
behind a fuse per phase, and the house load. It serves them on ports from
the base, applies a timeline of events (loads, pv, soc, plug, car,
offline, online, reconnect, mark, stop), and takes the same events over
POST /api/run/event while it runs. It records a sample a second of what
flows through each fuse, the fuse's damage and each device's state to
plant.jsonl, every OCPP frame per charger, ports.json and result.json.
Self-update endpoints are off in a run.

The site loop read Running without the simulator's lock while a stop
wrote it; it now takes the lock.

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

* fix(run): an outage asked for during a retry keeps its length; "meter" is reserved

Codex's review of #10:
- A reconnect event while the charger was already waiting to retry only
  moved a deadline nobody read; the retry dialled after the usual delay.
  Each dial now waits out DropConnection's deadline first.
- A device called "meter" lost its ports.json entry to the site meter.
  The spec now refuses that id.

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

* test(run): draw port bases below the ephemeral range

freeBase took a base from the kernel's next ephemeral port and skipped
those over 65000. macOS hands ephemeral ports out in turn, so once its
cursor sat near the top every try was skipped and every run test failed
with "no free port range".

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

* feat(run): the fuse cabinet's air and a warm start

With the thermal fuse on main (#9), a run's spec can set the cabinet's
air (site.fuse_ambient_c, default 25 °C) and start the fuses warm
(site.fuse_preheat_a, a current per phase they have carried long enough
to settle), as a house is in the evening. A fuse size the profile does
not model is refused before the run touches the simulator's settings.

The site loop now reads its tick before it starts: a run sets the tick
before it builds its site, and the next run could set it again while the
last loop was still reading it (the race detector caught it once runs
followed each other within milliseconds).

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

* minor: faults and seeded random timelines for headless runs (#11)

* feat(run): faults and seeded random timelines

Faults for an EMS under test, as events in a headless run and over
/api/run/event: a Modbus device that keeps its connections and answers
nothing (silence), a site meter whose readings stand still (meter_late),
one reading whose total disagrees with its phases (meter_glitch), phase
currents that carry a sign (signed_currents), and a charger's own cap
per phase (load_balancer). New model parameters, overridable per device:
startup_delay_s (the car draws that long after an offer rises from zero)
and load_balancer_a for an OCPP charger, signed_phase_currents for a
meter.

A spec's random block draws a timeline from a seed within the house's
bounds (house load per phase under the fuse less 2 A), picks the open
timing of each device, and writes timeline.json, a spec that replays it.

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

* fix(run): each session waits out the startup delay; faults keep the latest; a late meter keeps its energy

Codex's review of #11:
- offerSince now resets when a connector leaves its session, so every
  session waits out startup_delay_s, not only the first.
- An earlier silence's or outage's timer no longer ends a later one: each
  device counts its faults and a timer acts only for the latest.
- Energy that flows during meter_late shows on the counters when the
  readings move again; before, it was lost.

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

* feat(ocpp): a charger wired on one phase

wired_phases: 1 in a run spec (SetWiredPhases) wires a charger on L1. It
gives an order's current there whatever numberPhases says, and max_kw is
its rating as wired, so a 16 A order on a 3.68 kW charger draws 16 A on
L1. Before, the order became 16 A x 3 x 230 W, was capped to 3.68 kW and
spread over three phases, and a one-phase car took 5.3 A of it.

FTW's site test needs it for a charger wired on one phase, under Charge
now and planned charging.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.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