Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
8 changes: 7 additions & 1 deletion go/internal/control/sitesim_field_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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} {
Expand Down Expand Up @@ -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}
}
Expand Down
90 changes: 90 additions & 0 deletions go/internal/control/sitesim_property_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
})
}
}
65 changes: 26 additions & 39 deletions go/internal/sitesim/fuse.go
Original file line number Diff line number Diff line change
@@ -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)
}
46 changes: 35 additions & 11 deletions go/internal/sitesim/sitesim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading