diff --git a/CLAUDE.md b/CLAUDE.md index 98cc62d..0226a6b 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/` - each phase's fuse as a thermal model (element, body, holder; I² heating), within IEC 60269-1's gates and following ABB's gG curves for 16-63 A; 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/app.go b/cmd/simulator/app.go index aabbeb8..6b06a38 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" @@ -906,18 +907,37 @@ func (a *App) UpdateSiteConfig(id int, config map[string]interface{}) error { if baseLoad, ok := config["base_load_w"].(float64); ok { site.BaseLoadW = baseLoad } - if fuseSize, ok := config["fuse_size"].(float64); ok { + fuseSize, sizeSet := config["fuse_size"].(float64) + fuseProfile, profileSet := config["fuse_profile"].(string) + if sizeSet || profileSet { + size, profile := site.FuseSize, site.FuseProfile + if sizeSet { + if fuseSize != math.Trunc(fuseSize) { + return fmt.Errorf("fuse size %v A is not whole amperes", fuseSize) + } + size = int(fuseSize) + } + if profileSet { + profile = fuseProfile + } + if err := checkFuse(size, profile); err != nil { + return err + } + } + if sizeSet { site.FuseSize = int(fuseSize) site.FuseDamage = PhaseLoads{} + site.FuseHeat = [3]fuse.Phase{} site.FuseOverload = FuseOverload{} } - if fuseProfile, ok := config["fuse_profile"].(string); ok { + if profileSet { if _, exists := fuseProfiles[fuseProfile]; !exists { return fmt.Errorf("unknown fuse profile") } if site.FuseProfile != fuseProfile { site.FuseProfile = fuseProfile site.FuseDamage = PhaseLoads{} + site.FuseHeat = [3]fuse.Phase{} site.FuseOverload = FuseOverload{} } } @@ -935,19 +955,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 aa9f36c..d2ba5a0 100644 --- a/cmd/simulator/fuse.go +++ b/cmd/simulator/fuse.go @@ -1,40 +1,25 @@ package main import ( + "fmt" "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 +27,47 @@ 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 +}() + +// checkFuse says whether the profile models a fuse of a amperes. gG and the +// profiles scaled from it are calibrated at their bands' ratings (16-63 A; +// smaller and larger fuses have other conventional currents); aM and +// manual take any size. A size of 0 is the site's default, 16 A. +func checkFuse(a int, profileID string) error { + if a == 0 { + a = 16 + } + if a < 0 { + return fmt.Errorf("fuse size %d A", a) + } + p := resolveFuseProfile(profileID) + ratings := p.Ratings() + if p.ID == FuseProfileManual || len(ratings) == 0 && p.Supports(float64(a)) { + return nil + } + sizes := make([]int, len(ratings)) + for i, r := range ratings { + sizes[i] = int(r) + if sizes[i] == a { + return nil + } + } + return fmt.Errorf("the %s fuse is modelled at %v A, not %d A", p.ID, sizes, a) } -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 { @@ -210,85 +101,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) { - 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 + // 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 new file mode 100644 index 0000000..eba9859 --- /dev/null +++ b/cmd/simulator/fuse_test.go @@ -0,0 +1,86 @@ +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 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 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) + } +} + +// A site saved with a size the model does not cover says so on every phase +// and never blows; new sizes outside the bands are refused. +func TestUnsupportedFuseSize(t *testing.T) { + site := &Site{FuseSize: 10, FuseProfile: FuseProfileIecGg} + for s := 0; s < 60; s++ { + updateFuseState(site, PhasePowers{L1: 40 * 230}, time.Second) + } + if site.FuseBlown.L1 || site.FuseOverload.L1.Invalid == "" { + t.Fatalf("10 A at 40 A: blown %v, status %+v; want refused", site.FuseBlown.L1, site.FuseOverload.L1) + } + for _, c := range []struct { + a int + profile string + ok bool + }{ + {16, FuseProfileIecGg, true}, {35, FuseProfileIecGg, true}, {63, FuseProfileIecGg, true}, {0, FuseProfileIecGg, true}, + {10, FuseProfileIecGg, false}, {30, FuseProfileIecGg, false}, {80, FuseProfileIecGg, false}, {-1, FuseProfileIecGg, false}, + {10, FuseProfileIecGm, false}, {35, FuseProfileIecGm, true}, + {10, FuseProfileIecAm, true}, {125, FuseProfileIecAm, true}, + {10, FuseProfileManual, true}, {100, FuseProfileManual, true}, + } { + if err := checkFuse(c.a, c.profile); (err == nil) != c.ok { + t.Errorf("%d A %s: %v, want ok %v", c.a, c.profile, err, c.ok) + } + } +} diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index 257552f..91228cb 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; nil is 25 °C BoundMeter bool `json:"bound_meter"` // True if meter was auto-created with site P1Buffer *P1IntervalBuffer // P1 port interval buffer @@ -2640,16 +2643,24 @@ func handleSiteConfig(w http.ResponseWriter, r *http.Request, site *Site) { } site.BaseLoadW = *config.BaseLoadW } - 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} - if !validFuses[*config.FuseSize] { + if config.FuseSize != nil || config.FuseProfile != nil { + size, profile := site.FuseSize, site.FuseProfile + if config.FuseSize != nil { + size = *config.FuseSize + } + if config.FuseProfile != nil { + profile = *config.FuseProfile + } + if err := checkFuse(size, profile); err != nil { site.mu.Unlock() - http.Error(w, "Fuse size must be 10, 16, 20, 25, or 32 Amps", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } + } + if config.FuseSize != nil { site.FuseSize = *config.FuseSize site.FuseDamage = PhaseLoads{} + site.FuseHeat = [3]fuse.Phase{} site.FuseOverload = FuseOverload{} } if config.FuseProfile != nil { @@ -2661,6 +2672,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 +2696,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/static/css/components.css b/cmd/simulator/static/css/components.css index 367e8d9..1b8d7fb 100644 --- a/cmd/simulator/static/css/components.css +++ b/cmd/simulator/static/css/components.css @@ -1475,6 +1475,15 @@ color: var(--color-error); } +/* The fuse model's reason for refusing a phase: a sentence, not a timer */ +.phase-countdown.invalid { + text-transform: none; + letter-spacing: 0; + white-space: normal; + text-align: right; + max-width: 220px; +} + .phase-bar-scale { display: flex; align-items: center; diff --git a/cmd/simulator/static/js/components.js b/cmd/simulator/static/js/components.js index 00cb272..346114a 100644 --- a/cmd/simulator/static/js/components.js +++ b/cmd/simulator/static/js/components.js @@ -2487,6 +2487,21 @@ document.addEventListener('alpine:init', () => { return overload[phase] || null; }, + // Why the fuse model refused this phase (a size it does not model, + // for one), or '' when it judged it. + getFuseInvalid(phase) { + return this.getFuseOverload(phase)?.invalid || ''; + }, + + // The sizes the settings offer: gG's bands, plus a saved size outside + // them, so the select never shows blank. + fuseSizeOptions() { + const sizes = [16, 20, 25, 32, 35, 40, 50, 63]; + const current = Alpine.store('site').data?.fuse_size; + if (current && !sizes.includes(current)) sizes.push(current); + return sizes.sort((a, b) => a - b); + }, + hasFuseCountdown(phase) { const info = this.getFuseOverload(phase); if (!info || !info.active) return false; diff --git a/cmd/simulator/templates/index.html b/cmd/simulator/templates/index.html index d2571e1..3d90a60 100644 --- a/cmd/simulator/templates/index.html +++ b/cmd/simulator/templates/index.html @@ -234,6 +234,10 @@ :class="getFuseCountdownClass('l1')" :title="getFuseCountdownTitle('l1')" x-text="getFuseCountdownLabel('l1')"> +
@@ -254,6 +258,10 @@ :class="getFuseCountdownClass('l2')" :title="getFuseCountdownTitle('l2')" x-text="getFuseCountdownLabel('l2')">
+
@@ -274,6 +282,10 @@ :class="getFuseCountdownClass('l3')" :title="getFuseCountdownTitle('l3')" x-text="getFuseCountdownLabel('l3')">
+
@@ -317,11 +329,9 @@

Site Settings