From 7156a5946631c35cb94947f84a6992d1338ad0ba Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 08:51:38 +0200 Subject: [PATCH 1/4] feat(fuse): the fuse model as an importable package 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 --- CLAUDE.md | 3 + cmd/simulator/fuse.go | 239 ++++--------------------------------- cmd/simulator/fuse_test.go | 24 ++++ fuse/fuse.go | 196 ++++++++++++++++++++++++++++++ fuse/fuse_test.go | 116 ++++++++++++++++++ 5 files changed, 362 insertions(+), 216 deletions(-) create mode 100644 cmd/simulator/fuse_test.go create mode 100644 fuse/fuse.go create mode 100644 fuse/fuse_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 98cc62d..aab2f81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,9 @@ The app supports two modes controlled by the `--desktop` flag: - `cmd/simulator/templates/index.html` - Embedded dashboard UI - `cmd/simulator/wails.json` - Wails configuration +**Public package:** +- `fuse/` - IEC 60269 fuse curves and per-phase damage; no dependencies, so an EMS's own simulation (FTW's `go/internal/sitesim`) imports the same model + **Internal packages:** - `internal/modbus/server.go` - Modbus TCP server with register storage and logging - `internal/modbus/registers.go` - Register definitions, encoding/decoding (U16, I16, U32, I32) diff --git a/cmd/simulator/fuse.go b/cmd/simulator/fuse.go index aa9f36c..feba551 100644 --- a/cmd/simulator/fuse.go +++ b/cmd/simulator/fuse.go @@ -3,38 +3,22 @@ package main import ( "math" "time" + + "github.com/srcfl/device-simulator/fuse" ) +// The fuse model lives in package fuse, which an EMS's own simulation can +// import; these names keep the site code and its JSON as they were. const ( - FuseProfileManual = "manual" - FuseProfileIecGg = "iec_gg" - FuseProfileIecGm = "iec_gm" - FuseProfileIecAm = "iec_am" - FuseProfileIecGr = "iec_gr" - FuseProfileIecGs = "iec_gs" + FuseProfileManual = fuse.Manual + FuseProfileIecGg = fuse.IecGg + FuseProfileIecGm = fuse.IecGm + FuseProfileIecAm = fuse.IecAm + FuseProfileIecGr = fuse.IecGr + FuseProfileIecGs = fuse.IecGs ) -type FuseCurvePoint struct { - Multiple float64 - Seconds float64 -} - -type FuseProfileDefinition struct { - ID string - Label string - Curve []FuseCurvePoint - CooldownSeconds float64 - MinTripSeconds float64 - TripStartRatio float64 -} - -type FusePhaseOverload struct { - Ratio float64 `json:"ratio"` - TripSeconds float64 `json:"trip_s"` - RemainingSeconds float64 `json:"remaining_s"` - Damage float64 `json:"damage"` - Active bool `json:"active"` -} +type FusePhaseOverload = fuse.Status type FuseOverload struct { L1 FusePhaseOverload `json:"l1"` @@ -42,141 +26,21 @@ type FuseOverload struct { L3 FusePhaseOverload `json:"l3"` } -var fuseProfiles = map[string]FuseProfileDefinition{ - FuseProfileManual: { - ID: FuseProfileManual, - Label: "Manual", - }, - FuseProfileIecGg: { - ID: FuseProfileIecGg, - Label: "IEC 60269 gG/gL (general purpose)", - Curve: []FuseCurvePoint{ - {Multiple: 1.25, Seconds: 3600}, - {Multiple: 1.6, Seconds: 1800}, - {Multiple: 2.0, Seconds: 600}, - {Multiple: 3.0, Seconds: 120}, - {Multiple: 5.0, Seconds: 20}, - {Multiple: 10.0, Seconds: 3}, - {Multiple: 20.0, Seconds: 0.5}, - }, - CooldownSeconds: 300, - MinTripSeconds: 0.05, - TripStartRatio: 1, - }, - FuseProfileIecGm: { - ID: FuseProfileIecGm, - Label: "IEC 60269 gM (motor, dual-rated) - UK common", - Curve: []FuseCurvePoint{ - {Multiple: 1.25, Seconds: 7200}, - {Multiple: 1.6, Seconds: 3600}, - {Multiple: 2.0, Seconds: 1200}, - {Multiple: 3.0, Seconds: 240}, - {Multiple: 5.0, Seconds: 40}, - {Multiple: 10.0, Seconds: 6}, - {Multiple: 20.0, Seconds: 1}, - }, - CooldownSeconds: 420, - MinTripSeconds: 0.05, - TripStartRatio: 1, - }, - FuseProfileIecAm: { - ID: FuseProfileIecAm, - Label: "IEC 60269 aM (motor, short-circuit)", - // Partial-range curve: trip only at higher multiples. - Curve: []FuseCurvePoint{ - {Multiple: 4.0, Seconds: 60}, - {Multiple: 6.3, Seconds: 60}, - {Multiple: 8.0, Seconds: 0.5}, - {Multiple: 10.0, Seconds: 0.2}, - {Multiple: 19.0, Seconds: 0.18}, - }, - CooldownSeconds: 600, - MinTripSeconds: 0.05, - TripStartRatio: 4, - }, - FuseProfileIecGr: { - ID: FuseProfileIecGr, - Label: "IEC 60269 gR (semiconductor, fast)", - Curve: []FuseCurvePoint{ - {Multiple: 1.25, Seconds: 1080}, - {Multiple: 1.6, Seconds: 540}, - {Multiple: 2.0, Seconds: 180}, - {Multiple: 3.0, Seconds: 36}, - {Multiple: 5.0, Seconds: 6}, - {Multiple: 10.0, Seconds: 0.9}, - {Multiple: 20.0, Seconds: 0.15}, - }, - CooldownSeconds: 180, - MinTripSeconds: 0.02, - TripStartRatio: 1, - }, - FuseProfileIecGs: { - ID: FuseProfileIecGs, - Label: "IEC 60269 gS (semiconductor, full-range)", - Curve: []FuseCurvePoint{ - {Multiple: 1.25, Seconds: 1800}, - {Multiple: 1.6, Seconds: 900}, - {Multiple: 2.0, Seconds: 300}, - {Multiple: 3.0, Seconds: 60}, - {Multiple: 5.0, Seconds: 10}, - {Multiple: 10.0, Seconds: 1.5}, - {Multiple: 20.0, Seconds: 0.25}, - }, - CooldownSeconds: 240, - MinTripSeconds: 0.03, - TripStartRatio: 1, - }, -} +var fuseProfiles = func() map[string]fuse.Profile { + m := map[string]fuse.Profile{} + for _, p := range fuse.All() { + m[p.ID] = p + } + return m +}() -func resolveFuseProfile(id string) FuseProfileDefinition { +func resolveFuseProfile(id string) fuse.Profile { if profile, ok := fuseProfiles[id]; ok { return profile } return fuseProfiles[FuseProfileManual] } -func fuseTripTimeSeconds(profile FuseProfileDefinition, multiple float64) float64 { - if multiple <= 1 { - return math.Inf(1) - } - if len(profile.Curve) == 0 { - return math.Inf(1) - } - - points := profile.Curve - - if multiple <= points[0].Multiple { - return points[0].Seconds - } - - for i := 1; i < len(points); i++ { - if multiple <= points[i].Multiple { - return logLogInterpolate(multiple, points[i-1], points[i]) - } - } - - // Extrapolate using the last segment slope for very high currents. - last := points[len(points)-1] - prev := points[len(points)-2] - return logLogInterpolate(multiple, prev, last) -} - -func logLogInterpolate(x float64, a, b FuseCurvePoint) float64 { - if x <= 0 || a.Multiple <= 0 || b.Multiple <= 0 || a.Seconds <= 0 || b.Seconds <= 0 { - return b.Seconds - } - lx := math.Log10(x) - lx1 := math.Log10(a.Multiple) - lx2 := math.Log10(b.Multiple) - if lx2 == lx1 { - return b.Seconds - } - ly1 := math.Log10(a.Seconds) - ly2 := math.Log10(b.Seconds) - t := (lx - lx1) / (lx2 - lx1) - return math.Pow(10, ly1+(ly2-ly1)*t) -} - func updateFuseState(site *Site, phasePowers PhasePowers, simDelta time.Duration) PhaseFuses { simSeconds := simDelta.Seconds() if simSeconds < 0 { @@ -211,67 +75,10 @@ func updateFuseState(site *Site, phasePowers PhasePowers, simDelta time.Duration overload := FuseOverload{} updatePhase := func(power float64, blown bool, damage *float64) (FusePhaseOverload, bool) { - if blown { - if *damage < 1 { - *damage = 1 - } - return FusePhaseOverload{Damage: *damage}, false - } - - current := math.Abs(power) / 230.0 - ratio := 0.0 - if fuseSize > 0 { - ratio = current / float64(fuseSize) - } - - startRatio := profile.TripStartRatio - if startRatio <= 0 { - startRatio = 1 - } - - if ratio <= startRatio { - if profile.CooldownSeconds > 0 && simSeconds > 0 { - *damage -= simSeconds / profile.CooldownSeconds - if *damage < 0 { - *damage = 0 - } - } - return FusePhaseOverload{Ratio: ratio, Damage: *damage, Active: false}, false - } - - tripSeconds := fuseTripTimeSeconds(profile, ratio) - if tripSeconds <= 0 || math.IsInf(tripSeconds, 1) { - return FusePhaseOverload{Ratio: ratio, Damage: *damage, Active: false}, false - } - if profile.MinTripSeconds > 0 && tripSeconds < profile.MinTripSeconds { - tripSeconds = profile.MinTripSeconds - } - - if simSeconds > 0 { - *damage += simSeconds / tripSeconds - if *damage > 1 { - *damage = 1 - } - } - - if *damage >= 1 { - return FusePhaseOverload{ - Ratio: ratio, - TripSeconds: tripSeconds, - RemainingSeconds: 0, - Damage: *damage, - Active: false, - }, true - } - - remaining := (1 - *damage) * tripSeconds - return FusePhaseOverload{ - Ratio: ratio, - TripSeconds: tripSeconds, - RemainingSeconds: remaining, - Damage: *damage, - Active: true, - }, false + ph := fuse.Phase{Damage: *damage, Blown: blown} + status := profile.Step(&ph, math.Abs(power)/230.0, float64(fuseSize), simSeconds) + *damage = ph.Damage + return status, ph.Blown && !blown } status, blew := updatePhase(phasePowers.L1, fuseBlown.L1, &damage.L1) diff --git a/cmd/simulator/fuse_test.go b/cmd/simulator/fuse_test.go new file mode 100644 index 0000000..ecd8cd2 --- /dev/null +++ b/cmd/simulator/fuse_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "testing" + "time" +) + +// The site's fuse is package fuse's iec_gg: 2 × a 25 A rating on L1 blows +// it after 600 s, and the meter's other phases stay whole. +func TestSiteFuseBlowsOnTheGGCurve(t *testing.T) { + site := &Site{FuseSize: 25, FuseProfile: FuseProfileIecGg} + at := 0 + for s := 1; s <= 700 && at == 0; s++ { + if updateFuseState(site, PhasePowers{L1: 50 * 230, L2: 1000, L3: -1000}, time.Second).L1 { + at = s + } + } + if at != 600 || site.FuseBlown.L2 || site.FuseBlown.L3 { + t.Fatalf("L1 blew after %d s (want 600), L2/L3 %v/%v", at, site.FuseBlown.L2, site.FuseBlown.L3) + } + if site.FuseOverload.L1.Damage != 1 || site.FuseOverload.L2.Active { + t.Fatalf("overload %+v", site.FuseOverload) + } +} diff --git a/fuse/fuse.go b/fuse/fuse.go new file mode 100644 index 0000000..939b193 --- /dev/null +++ b/fuse/fuse.go @@ -0,0 +1,196 @@ +// Package fuse models a fuse on each phase of a site's main supply: the time +// it takes to blow at a multiple of its rating (IEC 60269 time-current +// curves) and the damage a phase builds up and sheds as the current moves. +// +// Every second above the rating spends 1/t of the fuse's life, with t the +// curve's time to blow at that multiple; below it the damage recovers over +// the profile's cooldown. A phase blows when its damage reaches 1. The curve +// blows slowly from just above the rating, so it is stricter than a real gG +// fuse below 1.25 × its rating. +// +// It has no dependencies, so an EMS's own fast simulation can import the +// same curves the device simulator uses. +package fuse + +import ( + "math" + "sort" +) + +// Profile ids. +const ( + Manual = "manual" // never blows by itself + IecGg = "iec_gg" + IecGm = "iec_gm" + IecAm = "iec_am" + IecGr = "iec_gr" + IecGs = "iec_gs" +) + +// Point is one point of a time-current curve: at Multiple × the rating the +// fuse blows after Seconds. +type Point struct { + Multiple float64 + Seconds float64 +} + +// Profile is a fuse type. +type Profile struct { + ID string + Label string + Curve []Point + // CooldownS is how long damage 1 takes to recover at or below the start ratio. + CooldownS float64 + // MinTripS is the shortest time to blow. + MinTripS float64 + // TripStartRatio is the multiple of the rating above which damage builds. + TripStartRatio float64 +} + +var profiles = map[string]Profile{ + Manual: {ID: Manual, Label: "Manual"}, + IecGg: { + ID: IecGg, + Label: "IEC 60269 gG/gL (general purpose)", + Curve: []Point{ + {1.25, 3600}, {1.6, 1800}, {2.0, 600}, {3.0, 120}, {5.0, 20}, {10.0, 3}, {20.0, 0.5}, + }, + CooldownS: 300, MinTripS: 0.05, TripStartRatio: 1, + }, + IecGm: { + ID: IecGm, + Label: "IEC 60269 gM (motor, dual-rated) - UK common", + Curve: []Point{ + {1.25, 7200}, {1.6, 3600}, {2.0, 1200}, {3.0, 240}, {5.0, 40}, {10.0, 6}, {20.0, 1}, + }, + CooldownS: 420, MinTripS: 0.05, TripStartRatio: 1, + }, + IecAm: { + ID: IecAm, + Label: "IEC 60269 aM (motor, short-circuit)", + // Partial-range curve: trips only at higher multiples. + Curve: []Point{ + {4.0, 60}, {6.3, 60}, {8.0, 0.5}, {10.0, 0.2}, {19.0, 0.18}, + }, + CooldownS: 600, MinTripS: 0.05, TripStartRatio: 4, + }, + IecGr: { + ID: IecGr, + Label: "IEC 60269 gR (semiconductor, fast)", + Curve: []Point{ + {1.25, 1080}, {1.6, 540}, {2.0, 180}, {3.0, 36}, {5.0, 6}, {10.0, 0.9}, {20.0, 0.15}, + }, + CooldownS: 180, MinTripS: 0.02, TripStartRatio: 1, + }, + IecGs: { + ID: IecGs, + Label: "IEC 60269 gS (semiconductor, full-range)", + Curve: []Point{ + {1.25, 1800}, {1.6, 900}, {2.0, 300}, {3.0, 60}, {5.0, 10}, {10.0, 1.5}, {20.0, 0.25}, + }, + CooldownS: 240, MinTripS: 0.03, TripStartRatio: 1, + }, +} + +// Lookup returns the profile with this id. +func Lookup(id string) (Profile, bool) { + p, ok := profiles[id] + return p, ok +} + +// All returns every profile, ordered by id. +func All() []Profile { + out := make([]Profile, 0, len(profiles)) + for _, p := range profiles { + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// TripSeconds is the time to blow at multiple × the rating, interpolated on +// log-log axes, with the last segment extended above its end. It is +Inf at +// or below 1 ×, and for a profile without a curve. +func (p Profile) TripSeconds(multiple float64) float64 { + if multiple <= 1 || len(p.Curve) == 0 { + return math.Inf(1) + } + pts := p.Curve + if multiple <= pts[0].Multiple { + return pts[0].Seconds + } + for i := 1; i < len(pts); i++ { + if multiple <= pts[i].Multiple { + return logLog(multiple, pts[i-1], pts[i]) + } + } + return logLog(multiple, pts[len(pts)-2], pts[len(pts)-1]) +} + +func logLog(x float64, a, b Point) float64 { + if x <= 0 || a.Multiple <= 0 || b.Multiple <= 0 || a.Seconds <= 0 || b.Seconds <= 0 { + return b.Seconds + } + lx, lx1, lx2 := math.Log10(x), math.Log10(a.Multiple), math.Log10(b.Multiple) + if lx2 == lx1 { + return b.Seconds + } + ly1, ly2 := math.Log10(a.Seconds), math.Log10(b.Seconds) + return math.Pow(10, ly1+(ly2-ly1)*(lx-lx1)/(lx2-lx1)) +} + +// Phase is the state of one phase's fuse. +type Phase struct { + Damage float64 // 0 new, 1 blown + Blown bool +} + +// Status describes a phase after a step. +type Status struct { + Ratio float64 `json:"ratio"` // current over the rating + TripSeconds float64 `json:"trip_s"` // time to blow at this ratio from new + RemainingSeconds float64 `json:"remaining_s"` + Damage float64 `json:"damage"` + Active bool `json:"active"` // building damage +} + +// Step advances one phase by dt seconds at amps through a fuse rated +// ratingA. The sign of amps does not matter: export heats a fuse too. A +// profile without a curve leaves the phase as it is. +func (p Profile) Step(ph *Phase, amps, ratingA, dt float64) Status { + if len(p.Curve) == 0 { + return Status{Damage: ph.Damage} + } + if dt < 0 { + dt = 0 + } + if ph.Blown { + ph.Damage = math.Max(ph.Damage, 1) + return Status{Damage: ph.Damage} + } + ratio := 0.0 + if ratingA > 0 { + ratio = math.Abs(amps) / ratingA + } + start := p.TripStartRatio + if start <= 0 { + start = 1 + } + if ratio <= start { + if p.CooldownS > 0 { + ph.Damage = math.Max(0, ph.Damage-dt/p.CooldownS) + } + return Status{Ratio: ratio, Damage: ph.Damage} + } + trip := p.TripSeconds(ratio) + if trip <= 0 || math.IsInf(trip, 1) { + return Status{Ratio: ratio, Damage: ph.Damage} + } + trip = math.Max(trip, p.MinTripS) + ph.Damage = math.Min(1, ph.Damage+dt/trip) + if ph.Damage >= 1 { + ph.Blown = true + return Status{Ratio: ratio, TripSeconds: trip, Damage: ph.Damage} + } + return Status{Ratio: ratio, TripSeconds: trip, RemainingSeconds: (1 - ph.Damage) * trip, Damage: ph.Damage, Active: true} +} diff --git a/fuse/fuse_test.go b/fuse/fuse_test.go new file mode 100644 index 0000000..99b666e --- /dev/null +++ b/fuse/fuse_test.go @@ -0,0 +1,116 @@ +package fuse + +import ( + "math" + "testing" +) + +func gg(t *testing.T) Profile { + t.Helper() + p, ok := Lookup(IecGg) + if !ok { + t.Fatal("no iec_gg profile") + } + return p +} + +func TestTripSecondsFollowsTheCurve(t *testing.T) { + p := gg(t) + for _, pt := range p.Curve { + if got := p.TripSeconds(pt.Multiple); math.Abs(got-pt.Seconds) > 1e-9*pt.Seconds { + t.Errorf("%.2f×: %v s, want %v s", pt.Multiple, got, pt.Seconds) + } + } + if got := p.TripSeconds(1); !math.IsInf(got, 1) { + t.Errorf("1×: %v s, want never", got) + } + if got := p.TripSeconds(1.1); got != 3600 { + t.Errorf("1.1×: %v s, want the first point's 3600 s", got) + } + if got := p.TripSeconds(1.4); got <= 1800 || got >= 3600 { + t.Errorf("1.4×: %.0f s, want between the 1.25× and 1.6× points", got) + } + if got := p.TripSeconds(40); got >= 0.5 { + t.Errorf("40×: %v s, want under the 20× point", got) + } +} + +// A phase held at a multiple of its rating blows after the curve's time, +// in one-second steps; the other phases are untouched. Export heats a fuse +// as much as import. +func TestStepBlowsOnTheCurve(t *testing.T) { + p := gg(t) + for _, tc := range []struct{ multiple, wantS float64 }{{2, 600}, {5, 20}, {10, 3}} { + var phases [3]Phase + blewAt := 0 + for s := 1; s <= 4000 && blewAt == 0; s++ { + p.Step(&phases[0], -tc.multiple*25, 25, 1) + p.Step(&phases[1], 0, 25, 1) + if phases[0].Blown { + blewAt = s + } + } + if float64(blewAt) != tc.wantS || phases[1].Damage != 0 || phases[1].Blown { + t.Errorf("%.0f×: blew after %d s, want %v s", tc.multiple, blewAt, tc.wantS) + } + } + var ph Phase + for s := 0; s < 4000; s++ { + p.Step(&ph, 25, 25, 1) + } + if ph.Blown || ph.Damage != 0 { + t.Errorf("at the rating: %+v, want no damage", ph) + } +} + +func TestDamageRecoversBelowTheRating(t *testing.T) { + p := gg(t) + ph := Phase{Damage: 0.5} + st := p.Step(&ph, 10, 25, 75) + if math.Abs(ph.Damage-0.25) > 1e-12 || st.Active { + t.Fatalf("after 75 s at 0.4×: damage %v, want 0.25 (cooldown %v s)", ph.Damage, p.CooldownS) + } + st = p.Step(&ph, 50, 25, 1) + if !st.Active || math.Abs(st.TripSeconds-600) > 1e-9 || math.Abs(st.RemainingSeconds-(1-ph.Damage)*st.TripSeconds) > 1e-9 { + t.Fatalf("at 2×: %+v", st) + } +} + +func TestBlownStaysBlownAndManualNeverBlows(t *testing.T) { + p := gg(t) + ph := Phase{Blown: true, Damage: 0.9} + if st := p.Step(&ph, 0, 25, 1000); !ph.Blown || ph.Damage != 1 || st.Damage != 1 { + t.Fatalf("blown phase after a cool second: %+v", ph) + } + m, _ := Lookup(Manual) + ph = Phase{} + m.Step(&ph, 1000, 25, 1000) + if ph.Blown || ph.Damage != 0 { + t.Fatalf("manual: %+v, want untouched", ph) + } +} + +func TestAMBuildsOnlyFromItsStartRatio(t *testing.T) { + p, _ := Lookup(IecAm) + ph := Phase{} + p.Step(&ph, 3.9*25, 25, 100) + if ph.Damage != 0 { + t.Fatalf("aM at 3.9×: damage %v, want none", ph.Damage) + } + p.Step(&ph, 5*25, 25, 1) + if ph.Damage == 0 { + t.Fatal("aM at 5×: no damage") + } +} + +func TestAllIsSortedAndComplete(t *testing.T) { + all := All() + if len(all) != 6 { + t.Fatalf("%d profiles", len(all)) + } + for i := 1; i < len(all); i++ { + if all[i-1].ID >= all[i].ID { + t.Fatalf("not sorted: %s before %s", all[i-1].ID, all[i].ID) + } + } +} From a6baf29be5116c53b536b5fc3d9792827df55e37 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 19:52:12 +0200 Subject: [PATCH 2/4] feat(fuse): a thermal model calibrated to IEC 60269 gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/simulator/app.go | 9 +- cmd/simulator/fuse.go | 15 +- cmd/simulator/fuse_test.go | 41 ++- cmd/simulator/main.go | 15 +- cmd/simulator/templates/index.html | 4 + fuse/fuse.go | 536 ++++++++++++++++++++++------- fuse/fuse_test.go | 284 +++++++++++---- 7 files changed, 698 insertions(+), 206 deletions(-) diff --git a/cmd/simulator/app.go b/cmd/simulator/app.go index aabbeb8..62383c7 100644 --- a/cmd/simulator/app.go +++ b/cmd/simulator/app.go @@ -7,6 +7,7 @@ import ( "math" "time" + "github.com/srcfl/device-simulator/fuse" "github.com/srcfl/device-simulator/internal/modbus" "github.com/srcfl/device-simulator/internal/modbus/devices" "github.com/srcfl/device-simulator/internal/settings" @@ -909,6 +910,7 @@ func (a *App) UpdateSiteConfig(id int, config map[string]interface{}) error { if fuseSize, ok := config["fuse_size"].(float64); ok { site.FuseSize = int(fuseSize) site.FuseDamage = PhaseLoads{} + site.FuseHeat = [3]fuse.Phase{} site.FuseOverload = FuseOverload{} } if fuseProfile, ok := config["fuse_profile"].(string); ok { @@ -918,6 +920,7 @@ func (a *App) UpdateSiteConfig(id int, config map[string]interface{}) error { if site.FuseProfile != fuseProfile { site.FuseProfile = fuseProfile site.FuseDamage = PhaseLoads{} + site.FuseHeat = [3]fuse.Phase{} site.FuseOverload = FuseOverload{} } } @@ -935,19 +938,19 @@ func (a *App) UpdateSiteConfig(id int, config map[string]interface{}) error { if fb, ok := config["fuse_blown"].(map[string]interface{}); ok { if l1, ok := parseBool(fb["l1"]); ok { if site.FuseBlown.L1 && !l1 { - site.FuseDamage.L1 = 0 + site.FuseDamage.L1, site.FuseHeat[0] = 0, fuse.Phase{} } site.FuseBlown.L1 = l1 } if l2, ok := parseBool(fb["l2"]); ok { if site.FuseBlown.L2 && !l2 { - site.FuseDamage.L2 = 0 + site.FuseDamage.L2, site.FuseHeat[1] = 0, fuse.Phase{} } site.FuseBlown.L2 = l2 } if l3, ok := parseBool(fb["l3"]); ok { if site.FuseBlown.L3 && !l3 { - site.FuseDamage.L3 = 0 + site.FuseDamage.L3, site.FuseHeat[2] = 0, fuse.Phase{} } site.FuseBlown.L3 = l3 } diff --git a/cmd/simulator/fuse.go b/cmd/simulator/fuse.go index feba551..9bda46e 100644 --- a/cmd/simulator/fuse.go +++ b/cmd/simulator/fuse.go @@ -74,28 +74,31 @@ func updateFuseState(site *Site, phasePowers PhasePowers, simDelta time.Duration fuseBlown := site.FuseBlown overload := FuseOverload{} - updatePhase := func(power float64, blown bool, damage *float64) (FusePhaseOverload, bool) { - ph := fuse.Phase{Damage: *damage, Blown: blown} - status := profile.Step(&ph, math.Abs(power)/230.0, float64(fuseSize), simSeconds) + // Each phase keeps its thermal state between ticks, so a fuse that has + // been warm blows sooner. + updatePhase := func(i int, power float64, blown bool, damage *float64) (FusePhaseOverload, bool) { + ph := &site.FuseHeat[i] + ph.Blown, ph.AmbientC = blown, site.FuseAmbientC + status := profile.Step(ph, math.Abs(power)/230.0, float64(fuseSize), simSeconds) *damage = ph.Damage return status, ph.Blown && !blown } - status, blew := updatePhase(phasePowers.L1, fuseBlown.L1, &damage.L1) + status, blew := updatePhase(0, phasePowers.L1, fuseBlown.L1, &damage.L1) overload.L1 = status if blew { fuseBlown.L1 = true changed = true } - status, blew = updatePhase(phasePowers.L2, fuseBlown.L2, &damage.L2) + status, blew = updatePhase(1, phasePowers.L2, fuseBlown.L2, &damage.L2) overload.L2 = status if blew { fuseBlown.L2 = true changed = true } - status, blew = updatePhase(phasePowers.L3, fuseBlown.L3, &damage.L3) + status, blew = updatePhase(2, phasePowers.L3, fuseBlown.L3, &damage.L3) overload.L3 = status if blew { fuseBlown.L3 = true diff --git a/cmd/simulator/fuse_test.go b/cmd/simulator/fuse_test.go index ecd8cd2..9cb3784 100644 --- a/cmd/simulator/fuse_test.go +++ b/cmd/simulator/fuse_test.go @@ -1,24 +1,59 @@ package main import ( + "math" "testing" "time" + + "github.com/srcfl/device-simulator/fuse" ) // The site's fuse is package fuse's iec_gg: 2 × a 25 A rating on L1 blows -// it after 600 s, and the meter's other phases stay whole. +// it after the band's cold time in the cabinet's 25 °C, and the meter's +// other phases stay whole. func TestSiteFuseBlowsOnTheGGCurve(t *testing.T) { site := &Site{FuseSize: 25, FuseProfile: FuseProfileIecGg} + gg, _ := fuse.Lookup(fuse.IecGg) + want := gg.MeltSeconds(2, 25, fuse.DefaultAmbientC) at := 0 for s := 1; s <= 700 && at == 0; s++ { if updateFuseState(site, PhasePowers{L1: 50 * 230, L2: 1000, L3: -1000}, time.Second).L1 { at = s } } - if at != 600 || site.FuseBlown.L2 || site.FuseBlown.L3 { - t.Fatalf("L1 blew after %d s (want 600), L2/L3 %v/%v", at, site.FuseBlown.L2, site.FuseBlown.L3) + if math.Abs(float64(at)-want) > 1 || site.FuseBlown.L2 || site.FuseBlown.L3 { + t.Fatalf("L1 blew after %d s (want %.1f), L2/L3 %v/%v", at, want, site.FuseBlown.L2, site.FuseBlown.L3) } if site.FuseOverload.L1.Damage != 1 || site.FuseOverload.L2.Active { t.Fatalf("overload %+v", site.FuseOverload) } } + +// The site keeps each phase's heat between ticks: a phase that has carried +// its rating for an hour blows at 2 × sooner than a cold one, and clearing +// a blown fuse starts it cold. +func TestSiteFuseRemembersItsHeat(t *testing.T) { + blowAt := func(site *Site) int { + for s := 1; s <= 700; s++ { + if updateFuseState(site, PhasePowers{L1: 50 * 230}, time.Second).L1 { + return s + } + } + return 0 + } + cold := blowAt(&Site{FuseSize: 25, FuseProfile: FuseProfileIecGg}) + warm := &Site{FuseSize: 25, FuseProfile: FuseProfileIecGg} + for s := 0; s < 3600; s++ { + updateFuseState(warm, PhasePowers{L1: 25 * 230}, time.Second) + } + if d := warm.FuseDamage.L1; d < 0.4 || d > 0.6 { + t.Fatalf("after 1 h at the rating damage %.2f, want about half way to melting", d) + } + if hot := blowAt(warm); hot == 0 || hot >= cold*2/3 { + t.Fatalf("2× after 1 h at the rating blew after %d s, cold after %d s", hot, cold) + } + warm.FuseBlown.L1, warm.FuseDamage.L1, warm.FuseHeat[0] = false, 0, fuse.Phase{} + if again := blowAt(warm); again != cold { + t.Fatalf("a cleared fuse blew after %d s, want the cold %d s", again, cold) + } +} diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index 257552f..1fb4932 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -20,6 +20,7 @@ import ( "syscall" "time" + "github.com/srcfl/device-simulator/fuse" "github.com/srcfl/device-simulator/internal/device" simmdns "github.com/srcfl/device-simulator/internal/mdns" "github.com/srcfl/device-simulator/internal/modbus" @@ -143,6 +144,8 @@ type Site struct { FuseBlown PhaseFuses `json:"fuse_blown"` // Per-phase blown fuse state FuseDamage PhaseLoads `json:"-"` FuseOverload FuseOverload `json:"-"` + FuseHeat [3]fuse.Phase `json:"-"` // each phase's thermal state (L1, L2, L3) + FuseAmbientC float64 `json:"-"` // the fuse cabinet's air; 0 is 25 °C BoundMeter bool `json:"bound_meter"` // True if meter was auto-created with site P1Buffer *P1IntervalBuffer // P1 port interval buffer @@ -2642,14 +2645,15 @@ func handleSiteConfig(w http.ResponseWriter, r *http.Request, site *Site) { } if config.FuseSize != nil { // Validate fuse size (common values: 10, 16, 20, 25, 32 Amps) - validFuses := map[int]bool{10: true, 16: true, 20: true, 25: true, 32: true} + validFuses := map[int]bool{10: true, 16: true, 20: true, 25: true, 32: true, 35: true, 40: true, 50: true, 63: true} if !validFuses[*config.FuseSize] { site.mu.Unlock() - http.Error(w, "Fuse size must be 10, 16, 20, 25, or 32 Amps", http.StatusBadRequest) + http.Error(w, "Fuse size must be 10, 16, 20, 25, 32, 35, 40, 50 or 63 Amps", http.StatusBadRequest) return } site.FuseSize = *config.FuseSize site.FuseDamage = PhaseLoads{} + site.FuseHeat = [3]fuse.Phase{} site.FuseOverload = FuseOverload{} } if config.FuseProfile != nil { @@ -2661,6 +2665,7 @@ func handleSiteConfig(w http.ResponseWriter, r *http.Request, site *Site) { if site.FuseProfile != *config.FuseProfile { site.FuseProfile = *config.FuseProfile site.FuseDamage = PhaseLoads{} + site.FuseHeat = [3]fuse.Phase{} site.FuseOverload = FuseOverload{} } } @@ -2684,13 +2689,13 @@ func handleSiteConfig(w http.ResponseWriter, r *http.Request, site *Site) { } if config.FuseBlown != nil { if site.FuseBlown.L1 && !config.FuseBlown.L1 { - site.FuseDamage.L1 = 0 + site.FuseDamage.L1, site.FuseHeat[0] = 0, fuse.Phase{} } if site.FuseBlown.L2 && !config.FuseBlown.L2 { - site.FuseDamage.L2 = 0 + site.FuseDamage.L2, site.FuseHeat[1] = 0, fuse.Phase{} } if site.FuseBlown.L3 && !config.FuseBlown.L3 { - site.FuseDamage.L3 = 0 + site.FuseDamage.L3, site.FuseHeat[2] = 0, fuse.Phase{} } site.FuseBlown = *config.FuseBlown } diff --git a/cmd/simulator/templates/index.html b/cmd/simulator/templates/index.html index d2571e1..ae568e9 100644 --- a/cmd/simulator/templates/index.html +++ b/cmd/simulator/templates/index.html @@ -322,6 +322,10 @@

Site Settings

+ + + +