diff --git a/go/go.mod b/go/go.mod index 9df9d7ae..e14af69f 100644 --- a/go/go.mod +++ b/go/go.mod @@ -13,6 +13,7 @@ require ( github.com/quic-go/quic-go v0.63.0 github.com/shirou/gopsutil/v4 v4.26.8 github.com/simonvetter/modbus v1.6.4 + github.com/srcfl/device-simulator v0.5.1-0.20261023051213-736051fbcf91 github.com/yuin/gopher-lua v1.1.2 golang.org/x/crypto v0.57.0 golang.org/x/net v0.58.0 @@ -35,7 +36,7 @@ require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.2 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/lorenzodonini/ocpp-go v0.19.0 github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect diff --git a/go/go.sum b/go/go.sum index 62dd3371..6f807ddf 100644 --- a/go/go.sum +++ b/go/go.sum @@ -20,8 +20,9 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo= github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc= github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= @@ -95,6 +96,8 @@ github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx github.com/simonvetter/modbus v1.6.4 h1:E03lBz/JftDza/+Ue+vxwkNZ/WW1xiqyFCUQ4NhqHn0= github.com/simonvetter/modbus v1.6.4/go.mod h1:hh90ZaTaPLcK2REj6/fpTbiV0J6S7GWmd8q+GVRObPw= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/srcfl/device-simulator v0.5.1-0.20261023051213-736051fbcf91 h1:86F3THGp+USxwiOIYxBqKlxLhitGZObZpzHIfPtyFHk= +github.com/srcfl/device-simulator v0.5.1-0.20261023051213-736051fbcf91/go.mod h1:KXJy4w3VMimpLm2LLm2Nr8lnKtBwUbxKAQRGKD1GnQk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -135,6 +138,7 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220804214406-8e32c043e418/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= diff --git a/go/internal/control/sitesim_field_test.go b/go/internal/control/sitesim_field_test.go index 18efe87c..e77f5a90 100644 --- a/go/internal/control/sitesim_field_test.go +++ b/go/internal/control/sitesim_field_test.go @@ -79,6 +79,7 @@ type fieldCase struct { goal bool // surplus-only with a ready-by goal exportW []float64 // surplus: export each quarter hour houseStep time.Duration // > 0: L1 +8 A for 10 min from this time into the run + warm bool // the fuse starts warm: the house has carried its load all evening } func (c fieldCase) key() string { @@ -275,7 +276,7 @@ func probeCases() []fieldCase { for _, tm := range []fieldTiming{timingFast, timingEasee, timingSlowCloud} { // A held 1p order raised to 3p beside a phase at 20 A. out = append(out, fieldCase{report: "probe", fuseA: 25, mode: "auto", steps: "mixed", timing: tm, carPhases: 3, use: usePhaseUp, - house: [3]float64{2, 20, 2}, houseName: "2-20-2"}) + house: [3]float64{2, 20, 2}, houseName: "2-20-2", warm: true}) } for _, tm := range fieldTimings { for _, use := range []fieldUse{usePlanned, useNow} { @@ -705,6 +706,11 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) c.car(p.Car) } p.HouseW = houseA(c.house[0], c.house[1], c.house[2]) + if c.warm { + if err := p.Fuse.Preheat(c.house); err != nil { + panic(err) + } + } if c.battery { p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 10000, MaxDischargeW: 10000, CapacityWh: 20000, SoC: .3, Latency: 3 * time.Second} } diff --git a/go/internal/control/sitesim_property_test.go b/go/internal/control/sitesim_property_test.go new file mode 100644 index 00000000..7fbb7fbd --- /dev/null +++ b/go/internal/control/sitesim_property_test.go @@ -0,0 +1,90 @@ +package control + +import ( + "fmt" + "maps" + "math/rand/v2" + "slices" + "testing" + "time" +) + +// randomHouseKnown names the seeds that break an invariant on master, by +// kind, and the finding. A listed kind that passes fails the test until its +// line goes. The fuse's own kind is never listed. +var randomHouseKnown = map[uint64]map[string]string{ + // 25 A, 40 s charger, no declared response time (Core assumes 180 s). L2 + // steps to 30.8 A with the car at 8 A; a cut of 5 A a phase that landed + // 94 s earlier, in the same run over the limit, still counts as on its + // way, so Core cuts to 6 A, not 0, and L2 holds 28.8 A for another 40 s. + 2: {failLimit: "netting: a landed cut still covers the next overage, ev_fuse_budget.go:436"}, +} + +// Core keeps a random house under the fuse. For each fixed seed the house +// load on each phase steps at random to anywhere up to the fuse less 2 A, +// with the fuse warm from the first load, while a car charges on Charge now +// through a charger that answers in 2, 5 or 40 s. simSite.check holds the +// fuse's invariants every plant second for an hour: the fuse never blows, +// and no phase stays over the fuse less its margin longer than the +// charger's response plus 3 s, or 3 s after a raise of Core's own. Idle +// time with room is logged, not judged: the field matrix judges it with the +// fuse's own memory. +func TestSiteSimRandomHouse(t *testing.T) { + for seed := uint64(1); seed <= 5; seed++ { + t.Run(fmt.Sprint(seed), func(t *testing.T) { + t.Parallel() + rng := rand.New(rand.NewPCG(seed, 0)) + p := simPlant(simStart) + p.Fuse.RatingA = []float64{16, 20, 25}[rng.IntN(3)] + p.EVSE.Delay = []time.Duration{2 * time.Second, 5 * time.Second, 40 * time.Second}[rng.IntN(3)] + house := func() [3]float64 { + var a [3]float64 + for i := range a { + a[i] = rng.Float64() * (p.Fuse.RatingA - 2) + } + return a + } + a := house() + p.HouseW = houseA(a[0], a[1], a[2]) + if err := p.Fuse.Preheat(a); err != nil { + t.Fatal(err) + } + s := &simSite{p: p, lp: simLoadpoint()} + s.boot() + failures, steps, damage := map[string]string{}, 0, 0.0 + next := func() time.Time { return p.Now.Add(time.Duration(15+rng.IntN(166)) * time.Second) } + for at, end := next(), simStart.Add(time.Hour); p.Now.Before(end); { + s.step() + if s.failure != "" { + // Keep each kind's first and run on, so a later blow still shows. + if k := failureKind(s.failure); failures[k] == "" { + failures[k] = s.failure + } + s.failure, s.overSince, s.limitSince, s.needSince = "", time.Time{}, time.Time{}, time.Time{} + } + damage = max(damage, p.Fuse.Damage[0], p.Fuse.Damage[1], p.Fuse.Damage[2]) + if !p.Now.Before(at) { + a, at = house(), next() + p.HouseW, s.outsideAt = houseA(a[0], a[1], a[2]), p.Now + steps++ + } + } + t.Logf("%.0f A fuse, charger %v, %d house steps: peak phase %.1f A, %v over %.0f A, fuse damage at most %.2f, car idle with room %v, %.1f kWh to the car", + p.Fuse.RatingA, p.EVSE.Delay, steps, s.peakA, s.overLimit, p.Fuse.RatingA-evPhaseMarginA, damage, s.idleWithRoom, p.EVSE.SessionWh/1000) + delete(failures, failIdle) + known := randomHouseKnown[seed] + for _, k := range slices.Sorted(maps.Keys(failures)) { + if fix, ok := known[k]; ok { + t.Logf("fails as known (%s): %s", fix, failures[k]) + } else { + t.Error(failures[k]) + } + } + for _, k := range slices.Sorted(maps.Keys(known)) { + if failures[k] == "" { + t.Errorf("%s passes now: delete its known line (%s)", k, known[k]) + } + } + }) + } +} diff --git a/go/internal/sitesim/fuse.go b/go/internal/sitesim/fuse.go index 6d436eea..6e33e75e 100644 --- a/go/internal/sitesim/fuse.go +++ b/go/internal/sitesim/fuse.go @@ -1,59 +1,46 @@ package sitesim -import "math" +import ( + "fmt" -// Fuse is a gG main fuse (IEC 60269) on each phase. Every second above its -// rating spends 1/t of its life, with t the curve's time to blow at that -// multiple of the rating; below the rating it recovers over fuseCooldownS. -// A phase blows when its damage reaches 1. Ported from the iec_gg profile in -// srcfl/device-simulator cmd/simulator/fuse.go. The curve blows slowly from -// just above the rating, so it is stricter than a real gG fuse below 1.25 × -// its rating. + "github.com/srcfl/device-simulator/fuse" +) + +// Fuse is a gG main fuse (IEC 60269) on each phase, as srcfl/device-simulator's +// fuse package models it: the thermal model the site rig judges Core by, so +// both simulators share one fuse. A phase starts cold in a 25 °C cabinet; +// Preheat starts it warm. Damage and Blown mirror each phase after a step. type Fuse struct { RatingA float64 + Phase [3]fuse.Phase Damage [3]float64 Blown [3]bool } -// ggCurve is the time to blow, in seconds, at a multiple of the rating. -var ggCurve = [...]struct{ multiple, seconds float64 }{ - {1.25, 3600}, {1.6, 1800}, {2, 600}, {3, 120}, {5, 20}, {10, 3}, {20, .5}, -} - -const ( - fuseCooldownS = 300 - fuseMinTripS = .05 -) +var gG, _ = fuse.Lookup(fuse.IecGg) // AnyBlown reports whether any phase has blown. func (f *Fuse) AnyBlown() bool { return f.Blown[0] || f.Blown[1] || f.Blown[2] } -func (f *Fuse) step(amps [3]float64, dt float64) { +// Preheat puts each phase in the steady state of amps held for hours, for a +// scenario that starts with the house near its limit. +func (f *Fuse) Preheat(amps [3]float64) error { for i, a := range amps { - if f.Blown[i] { - continue + if err := gG.Preheat(&f.Phase[i], a, f.RatingA); err != nil { + return fmt.Errorf("L%d: %w", i+1, err) } - ratio := math.Abs(a) / f.RatingA - if ratio <= 1 { - f.Damage[i] = math.Max(0, f.Damage[i]-dt/fuseCooldownS) - continue - } - f.Damage[i] = math.Min(1, f.Damage[i]+dt/math.Max(fuseMinTripS, ggTripSeconds(ratio))) - f.Blown[i] = f.Damage[i] >= 1 + f.Damage[i] = f.Phase[i].Damage } + return nil } -// ggTripSeconds interpolates the curve on log-log axes and extends its last -// segment above 20 ×. -func ggTripSeconds(multiple float64) float64 { - if multiple <= ggCurve[0].multiple { - return ggCurve[0].seconds - } - i := 1 - for i < len(ggCurve)-1 && multiple > ggCurve[i].multiple { - i++ +// step panics on a step the model refuses, such as a rating it does not +// model: a scenario that cannot be judged is a broken test. +func (f *Fuse) step(amps [3]float64, dt float64) { + for i, a := range amps { + if st := gG.Step(&f.Phase[i], a, f.RatingA, dt); st.Invalid != "" { + panic(fmt.Sprintf("sitesim: L%d fuse: %s", i+1, st.Invalid)) + } + f.Damage[i], f.Blown[i] = f.Phase[i].Damage, f.Phase[i].Blown } - a, b := ggCurve[i-1], ggCurve[i] - t := (math.Log10(multiple) - math.Log10(a.multiple)) / (math.Log10(b.multiple) - math.Log10(a.multiple)) - return math.Pow(10, math.Log10(a.seconds)+(math.Log10(b.seconds)-math.Log10(a.seconds))*t) } diff --git a/go/internal/sitesim/sitesim_test.go b/go/internal/sitesim/sitesim_test.go index 18fc6bba..b9eda630 100644 --- a/go/internal/sitesim/sitesim_test.go +++ b/go/internal/sitesim/sitesim_test.go @@ -4,27 +4,51 @@ import ( "math" "testing" "time" + + "github.com/srcfl/device-simulator/fuse" ) var t0 = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC) -func TestFuseBlowsOnTheGGCurve(t *testing.T) { - for _, tc := range []struct{ multiple, wantS float64 }{{1, math.Inf(1)}, {2, 600}, {5, 20}, {10, 3}} { - f := Fuse{RatingA: 25} - blewAt := math.Inf(1) - for s := 1; s <= 4000 && math.IsInf(blewAt, 1); s++ { - f.step([3]float64{-tc.multiple * 25, 0, 0}, 1) // Export heats it too. +// The plant's fuse is the fuse package's gG at its default 25 °C, stepped a +// second at a time: it blows in the second the model melts, export heats it +// as import does, a warm fuse blows sooner and the other phases stay whole. +func TestFuseFollowsTheFuseModel(t *testing.T) { + gG, _ := fuse.Lookup(fuse.IecGg) + blowAfter := func(f Fuse, a float64) float64 { + for s := 1; s <= 7200; s++ { + f.step([3]float64{-a, 0, 0}, 1) if f.Blown[0] { - blewAt = float64(s) + if f.Blown[1] || f.Blown[2] { + t.Fatalf("%.0f A on L1 blew another phase", a) + } + return float64(s) } } - if blewAt != tc.wantS || f.Blown[1] { - t.Errorf("%.0f× rating: blew after %v s, want %v s", tc.multiple, blewAt, tc.wantS) + return math.Inf(1) + } + for _, tc := range []struct{ ratingA, multiple float64 }{{16, 2}, {20, 2}, {25, 2}, {25, 3}, {25, 1.6}} { + want := math.Ceil(gG.MeltSeconds(tc.multiple, tc.ratingA, fuse.DefaultAmbientC)) + if got := blowAfter(Fuse{RatingA: tc.ratingA}, tc.multiple*tc.ratingA); got != want { + t.Errorf("%.1f× %.0f A: blew after %v s, want %v s", tc.multiple, tc.ratingA, got, want) } } - if got := ggTripSeconds(1.4); got <= 1800 || got >= 3600 { - t.Errorf("1.4×: %.0f s, want between the 1.25× and 1.6× points", got) + if got := blowAfter(Fuse{RatingA: 25}, 1.25*25); !math.IsInf(got, 1) { + t.Errorf("1.25× 25 A blew after %v s, want never", got) + } + warm := Fuse{RatingA: 25} + if err := warm.Preheat([3]float64{23, 23, 23}); err != nil { + t.Fatal(err) } + if cold, hot := blowAfter(Fuse{RatingA: 25}, 50), blowAfter(warm, 50); hot >= cold { + t.Errorf("2× 25 A: warm blew after %v s, cold after %v s", hot, cold) + } + defer func() { + if recover() == nil { + t.Error("a 10 A gG fuse, which the model does not cover, stepped without a panic") + } + }() + (&Fuse{RatingA: 10}).step([3]float64{5, 5, 5}, 1) } // PV export shows as negative phase currents, or as magnitudes on an