diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index fd9fa78..c14cbfb 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -150,6 +150,12 @@ type Site struct { 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 lastPhaseW PhasePowers // what flowed through each phase's fuse on the last tick + + // What the site meter reports, beside the truth (headless runs). + meterFrozenUntil time.Time // a late meter: its registers stand still until then + meterHeldKWh [2]float64 // import, export that flowed while they stood still + meterGlitch bool // the next reading's total disagrees with its phases + signedCurrents bool // phase currents carry the sign of their power BoundMeter bool `json:"bound_meter"` // True if meter was auto-created with site P1Buffer *P1IntervalBuffer // P1 port interval buffer @@ -4757,19 +4763,44 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { } } + // A late meter keeps its last reading; a glitch shows once. + site.mu.Lock() + frozen := time.Now().Before(site.meterFrozenUntil) + q := meterQuirks{signedCurrents: site.signedCurrents, glitch: site.meterGlitch && !frozen} + if q.glitch { + site.meterGlitch = false + } + // The energy goes on while the readings stand still, and shows when + // they move again. + var held [2]float64 + if frozen { + if totalPower > 0 { + site.meterHeldKWh[0] += energyDeltaKWh + } else if totalPower < 0 { + site.meterHeldKWh[1] += energyDeltaKWh + } + } else { + held, site.meterHeldKWh = site.meterHeldKWh, [2]float64{} + } + site.mu.Unlock() + // Handle P1 interval mode - if p1Buffer == nil || p1Buffer.Mode == P1ModeDirect { + if frozen { + // The meter stands still; the physics above went on. + } else if p1Buffer == nil || p1Buffer.Mode == P1ModeDirect { // Direct mode: write immediately to all meters for _, mID := range meterIDs { mSim := getSimulator(mID) if mSim != nil && mSim.Protocol == "modbus" && mSim.Server != nil && isRunning(mSim) { - writeMeterRegisters(mSim, totalPower, phasePowers, fuseBlown) + writeMeterRegisters(mSim, totalPower, phasePowers, fuseBlown, q) + accumulateMeterEnergy(mSim, held[0], held[1]) updateMeterEnergy(mSim, totalPower, energyDeltaKWh, energyDeltaWh) } } // Also write to primary meter if not in meterIDs (backward compat) if len(meterIDs) == 0 { - writeMeterRegisters(meterSim, totalPower, phasePowers, fuseBlown) + writeMeterRegisters(meterSim, totalPower, phasePowers, fuseBlown, q) + accumulateMeterEnergy(meterSim, held[0], held[1]) updateMeterEnergy(meterSim, totalPower, energyDeltaKWh, energyDeltaWh) } // Propagate meter values to inverter sims that have built-in meter registers @@ -4779,7 +4810,7 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { continue } if sim.Category == "inverter" && sim.RegisterSet != nil && sim.RegisterSet.Semantic["meter_power"] != nil { - writeMeterRegisters(sim, totalPower, phasePowers, fuseBlown) + writeMeterRegisters(sim, totalPower, phasePowers, fuseBlown, q) } } } else { @@ -4796,6 +4827,8 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { } else if totalPower < 0 { p1Buffer.ExportEnergyDelta += energyDeltaKWh } + p1Buffer.ImportEnergyDelta += held[0] + p1Buffer.ExportEnergyDelta += held[1] // Check if interval has elapsed intervalDuration := getP1IntervalDuration(p1Buffer.Mode) @@ -4844,7 +4877,17 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { // writeMeterRegisters writes power values to meter registers. // Writes to semantic names on the meter's RegisterSet, and also to global-lookup // addresses for dual-format support (SDM630 + Sungrow on same meter). -func writeMeterRegisters(meterSim *Simulator, totalPower float64, phasePowers PhasePowers, fuseBlown PhaseFuses) { +// meterQuirks are what a meter reports beside the truth. +type meterQuirks struct { + signedCurrents bool // currents carry the sign of their phase's power + glitch bool // the total reads 0 W while the phases are right +} + +func writeMeterRegisters(meterSim *Simulator, totalPower float64, phasePowers PhasePowers, fuseBlown PhaseFuses, quirks ...meterQuirks) { + var q meterQuirks + if len(quirks) > 0 { + q = quirks[0] + } if fuseBlown.L1 { phasePowers.L1 = 0 } @@ -4855,6 +4898,9 @@ func writeMeterRegisters(meterSim *Simulator, totalPower float64, phasePowers Ph phasePowers.L3 = 0 } totalPower = phasePowers.L1 + phasePowers.L2 + phasePowers.L3 + if q.glitch { + totalPower = 0 + } // Write total power via semantic name (hits the correct address for any device) setSemanticValue(meterSim, "meter_power", totalPower) @@ -4868,6 +4914,11 @@ func writeMeterRegisters(meterSim *Simulator, totalPower float64, phasePowers Ph l1Current := math.Abs(phasePowers.L1) / 230.0 l2Current := math.Abs(phasePowers.L2) / 230.0 l3Current := math.Abs(phasePowers.L3) / 230.0 + if q.signedCurrents { + l1Current = math.Copysign(l1Current, phasePowers.L1) + l2Current = math.Copysign(l2Current, phasePowers.L2) + l3Current = math.Copysign(l3Current, phasePowers.L3) + } setSemanticValue(meterSim, "meter_l1_current", l1Current) setSemanticValue(meterSim, "meter_l2_current", l2Current) diff --git a/cmd/simulator/run.go b/cmd/simulator/run.go index 91fdf02..da5e51c 100644 --- a/cmd/simulator/run.go +++ b/cmd/simulator/run.go @@ -47,6 +47,9 @@ type runSpec struct { Site runSiteSpec `json:"site"` Devices []runDeviceSpec `json:"devices"` Timeline []runEvent `json:"timeline"` + // Random adds a timeline drawn from a seed (run_random.go). The run + // writes the result to timeline.json, a spec that replays it. + Random *runRandom `json:"random,omitempty"` } type runSiteSpec struct { @@ -59,6 +62,9 @@ type runSiteSpec struct { // enough to settle, so the run starts warm, as a house does in the // evening; omitted starts cold. FusePreheatA *[3]float64 `json:"fuse_preheat_a,omitempty"` + // SignedPhaseCurrents overrides the SDM630 model's: the meter's phase + // currents carry the sign of their power. + SignedPhaseCurrents *bool `json:"signed_phase_currents,omitempty"` } // runDeviceSpec is one device, by the device-drivers driver it emulates. @@ -69,6 +75,8 @@ type runDeviceSpec struct { // Overrides of the model's parameters. CommandResponseS *float64 `json:"command_response_s,omitempty"` + StartupDelayS *float64 `json:"startup_delay_s,omitempty"` + LoadBalancerA *float64 `json:"load_balancer_a,omitempty"` // A hybrid inverter. EMSControl *bool `json:"ems_control,omitempty"` // default on @@ -80,11 +88,12 @@ type runDeviceSpec struct { // An OCPP 1.6 charger in client mode, and the car on it. OCPPURL string `json:"ocpp_url,omitempty"` // user:pass@ is sent as Basic auth - MaxKW float64 `json:"max_kw,omitempty"` // on three phases; default 11.04 + MaxKW float64 `json:"max_kw,omitempty"` // as wired; default 11.04 on three phases Vendor string `json:"vendor,omitempty"` Model string `json:"model,omitempty"` PhaseSwitch bool `json:"phase_switch,omitempty"` - OCPPConfig map[string]string `json:"ocpp_config,omitempty"` // configuration keys over the defaults + WiredPhases int `json:"wired_phases,omitempty"` // 1: on L1 only, and max_kw is its rating there + OCPPConfig map[string]string `json:"ocpp_config,omitempty"` // configuration keys over the defaults CarMaxA float64 `json:"car_max_a,omitempty"` CarPhases int `json:"car_phases,omitempty"` Plugged bool `json:"plugged,omitempty"` // a car plugged in at the start @@ -104,6 +113,13 @@ type runDeviceSpec struct { // online device back again // reconnect device, for_s a charger loses its connection for for_s; // the car keeps charging +// silence device, for_s a Modbus device keeps its connections but +// answers nothing for for_s (0: until online) +// meter_late for_s the site meter's readings stand still +// meter_glitch the next reading's total reads 0 W while +// its phases are right +// signed_currents on the meter's phase currents carry a sign +// load_balancer device, max_a the charger's own cap per phase; 0 none // stop end the run type runEvent struct { AtS float64 `json:"at_s,omitempty"` @@ -140,6 +156,28 @@ type headlessRun struct { stop chan struct{} stopOnce sync.Once + + // faultGen counts the outages and silences applied to each device, so + // the timer of an earlier one cannot end a later one. + faultMu sync.Mutex + faultGen map[string]int +} + +// fault starts an outage or silence of a device and returns a check that +// holds while it is still the latest one. +func (r *headlessRun) fault(id string) func() bool { + r.faultMu.Lock() + defer r.faultMu.Unlock() + if r.faultGen == nil { + r.faultGen = map[string]int{} + } + r.faultGen[id]++ + gen := r.faultGen[id] + return func() bool { + r.faultMu.Lock() + defer r.faultMu.Unlock() + return r.faultGen[id] == gen + } } // runHeadless is `simulator run`. It returns the process exit code. @@ -186,6 +224,18 @@ func startRun(spec runSpec, base int, out string) (*headlessRun, error) { if err := os.MkdirAll(out, 0o755); err != nil { return nil, err } + if spec.Random != nil { + evs := randomize(&spec) + log.Printf("run: %s", describeRandom(spec.Random, len(evs))) + spec.Timeline = append(spec.Timeline, evs...) + sort.SliceStable(spec.Timeline, func(i, j int) bool { return spec.Timeline[i].AtS < spec.Timeline[j].AtS }) + replay := spec + replay.Random = nil + b, _ := json.MarshalIndent(replay, "", " ") + if err := os.WriteFile(filepath.Join(out, "timeline.json"), b, 0o644); err != nil { + return nil, err + } + } r := &headlessRun{spec: spec, out: out, base: base, devs: map[string]*Simulator{}, frames: map[string]*os.File{}, stop: make(chan struct{})} // A run is self-contained: its own ports and state file, none of the @@ -287,6 +337,12 @@ func (r *headlessRun) build() error { } site.PhaseFactors = PhaseFactors{L1: 1.0 / 3, L2: 1.0 / 3, L3: 1.0 / 3} site.LoadScenario = "static" + if r.meter != nil { + site.signedCurrents = r.meter.ProfilesManager.Get("sdm630").Params().SignedPhaseCurrents + } + if v := r.spec.Site.SignedPhaseCurrents; v != nil { + site.signedCurrents = *v + } site.mu.Unlock() r.setLoads(r.spec.Site.HouseW) for _, d := range r.spec.Devices { @@ -361,12 +417,23 @@ func (r *headlessRun) buildCharger(d runDeviceSpec) (*Simulator, error) { } srv.ApplyConfiguration(cfg) srv.SetOCPPURL(d.OCPPURL) - delay := commandResponse(sim.ProfilesManager.ForDriver("ocpp")) + params := sim.ProfilesManager.ForDriver("ocpp").Params() + delay := seconds(params.CommandResponseS) if d.CommandResponseS != nil { delay = seconds(*d.CommandResponseS) } srv.SetProfileDelay(delay) + startup, lb := params.StartupDelayS, params.LoadBalancerA + if d.StartupDelayS != nil { + startup = *d.StartupDelayS + } + if d.LoadBalancerA != nil { + lb = *d.LoadBalancerA + } + srv.SetStartupDelay(seconds(startup)) + srv.SetLoadBalancer(lb) srv.SetCar(d.CarMaxA, d.CarPhases) + srv.SetWiredPhases(d.WiredPhases) frames, err := os.Create(filepath.Join(r.out, "ocpp-"+d.ID+".jsonl")) if err != nil { return nil, err @@ -514,14 +581,23 @@ func (r *headlessRun) apply(ev runEvent) error { var sim *Simulator if sim, err = r.device(ev.Device, false); err == nil { stopSimulatorServer(sim) + latest := r.fault(ev.Device) if ev.ForS > 0 { id := ev.Device - time.AfterFunc(time.Duration(ev.ForS*float64(time.Second)), func() { r.apply(runEvent{Do: "online", Device: id}) }) + time.AfterFunc(time.Duration(ev.ForS*float64(time.Second)), func() { + if latest() { + r.apply(runEvent{Do: "online", Device: id}) + } + }) } } case "online": var sim *Simulator if sim, err = r.device(ev.Device, false); err == nil { + r.fault(ev.Device) + if sim.Server != nil { + sim.Server.SetSilent(false) + } err = startSimulatorServer(sim) } case "reconnect": @@ -529,6 +605,43 @@ func (r *headlessRun) apply(ev runEvent) error { if sim, err = r.device(ev.Device, true); err == nil { sim.OCPPServer.DropConnection(time.Duration(ev.ForS * float64(time.Second))) } + case "silence": + var sim *Simulator + if sim, err = r.device(ev.Device, false); err == nil && sim.Server == nil { + err = fmt.Errorf("%s is not a Modbus device", ev.Device) + } + if err == nil { + sim.Server.SetSilent(true) + latest := r.fault(ev.Device) + if ev.ForS > 0 { + time.AfterFunc(time.Duration(ev.ForS*float64(time.Second)), func() { + if latest() { + sim.Server.SetSilent(false) + } + }) + } + } + case "meter_late": + r.site.mu.Lock() + r.site.meterFrozenUntil = time.Now().Add(time.Duration(ev.ForS * float64(time.Second))) + r.site.mu.Unlock() + case "meter_glitch": + r.site.mu.Lock() + r.site.meterGlitch = true + r.site.mu.Unlock() + case "signed_currents": + r.site.mu.Lock() + r.site.signedCurrents = ev.On == nil || *ev.On + r.site.mu.Unlock() + case "load_balancer": + var sim *Simulator + if sim, err = r.device(ev.Device, true); err == nil { + a := 0.0 + if ev.MaxA != nil { + a = *ev.MaxA + } + sim.OCPPServer.SetLoadBalancer(a) + } case "stop": defer r.finish() default: diff --git a/cmd/simulator/run_random.go b/cmd/simulator/run_random.go new file mode 100644 index 0000000..612428c --- /dev/null +++ b/cmd/simulator/run_random.go @@ -0,0 +1,197 @@ +package main + +import ( + "fmt" + "math" + "math/rand" + "sort" +) + +// runRandom draws a timeline for a headless run from a seed, within the +// house's physical bounds: house load per phase stays under the fuse less +// 2 A, so the house alone never overloads it and anything over the limit +// is the EMS's doing or a step it must answer. The same seed and spec give +// the same timeline; timeline.json in the output holds it for replay. +type runRandom struct { + Seed int64 `json:"seed"` + DurationS float64 `json:"duration_s"` // events fall in [30 s, duration_s - 30 s] + GapS float64 `json:"gap_s,omitempty"` // mean time between events; default 40 + Kinds []string `json:"kinds,omitempty"` // which kinds may happen; default all + MaxPVW float64 `json:"max_pv_w,omitempty"` // PV ceiling; default 8 000 W + MinSoCPct float64 `json:"min_soc_pct,omitempty"` // the battery's floor; default 5 +} + +// randomKinds are the events a random timeline draws, with their weights. +var randomKinds = []struct { + name string + weight int +}{ + {"loads_step", 6}, {"loads_drift", 3}, {"loads_spike", 4}, {"pv_cloud", 3}, {"soc_floor", 1}, + {"car_limit", 2}, {"silence", 1}, {"offline", 1}, {"reconnect", 2}, {"meter_late", 2}, + {"meter_glitch", 1}, {"signed_currents", 1}, {"load_balancer", 1}, +} + +// randomize fills the unset timing and quirks of the spec's devices from the +// seed and returns the events to add to its timeline. +func randomize(spec *runSpec) []runEvent { + rr := spec.Random + rng := rand.New(rand.NewSource(rr.Seed)) + gap := rr.GapS + if gap <= 0 { + gap = 40 + } + maxPV := rr.MaxPVW + if maxPV <= 0 { + maxPV = 8000 + } + minSoC := rr.MinSoCPct + if minSoC <= 0 { + minSoC = 5 + } + fuse := float64(spec.Site.FuseA) + if fuse <= 0 { + fuse = 16 + } + maxA := fuse - 2 + allowed := map[string]bool{} + for _, k := range rr.Kinds { + allowed[k] = true + } + var kinds []string + total := 0 + for _, k := range randomKinds { + if len(allowed) == 0 || allowed[k.name] { + for i := 0; i < k.weight; i++ { + kinds = append(kinds, k.name) + } + total += k.weight + } + } + + // The devices' timing: whatever the spec leaves open. + var chargers, inverters []string + for i := range spec.Devices { + d := &spec.Devices[i] + if d.Driver == "ocpp" { + chargers = append(chargers, d.ID) + if d.CommandResponseS == nil { + d.CommandResponseS = ptr([]float64{0, 5, 40, 60}[rng.Intn(4)]) + } + if d.StartupDelayS == nil { + d.StartupDelayS = ptr([]float64{0, 0, 5, 30}[rng.Intn(4)]) + } + if d.CarPhases == 0 && rng.Intn(5) == 0 { + d.CarPhases = 1 + } + } else { + inverters = append(inverters, d.ID) + } + } + if spec.Site.SignedPhaseCurrents == nil { + spec.Site.SignedPhaseCurrents = ptr(rng.Intn(2) == 0) + } + if len(kinds) == 0 { + return nil + } + + // House load per phase, in amperes, as the timeline moves it. + var level [3]float64 + for k, w := range spec.Site.HouseW { + level[k] = math.Min(maxA, math.Max(0.3, w/230)) + } + pick := func(xs []string) string { + if len(xs) == 0 { + return "" + } + return xs[rng.Intn(len(xs))] + } + between := func(lo, hi float64) float64 { return math.Round((lo+rng.Float64()*(hi-lo))*10) / 10 } + // later is when a follow-up lands: never after the run. + later := func(at float64) float64 { return math.Min(at, rr.DurationS) } + loads := func(at float64) runEvent { + a := level + return runEvent{AtS: at, Do: "loads", A: &a} + } + + var evs []runEvent + end := rr.DurationS - 30 + for t := 30 + rng.ExpFloat64()*gap; t < end; t += math.Min(3*gap, math.Max(5, rng.ExpFloat64()*gap)) { + t = math.Round(t*10) / 10 + switch kinds[rng.Intn(total)] { + case "loads_step": + if rng.Intn(2) == 0 { + level[rng.Intn(3)] = between(0.3, maxA) + } else { + for k := range level { + level[k] = between(0.3, maxA) + } + } + evs = append(evs, loads(t)) + case "loads_drift": + for i := 0; i < 3+rng.Intn(4); i++ { + for k := range level { + level[k] = math.Min(maxA, math.Max(0.3, level[k]+between(-1.5, 1.5))) + } + evs = append(evs, loads(later(t+float64(10*i)))) + } + case "loads_spike": + k := rng.Intn(3) + if room := maxA - level[k]; room > 1 { + before := level[k] + level[k] += between(1, room) + evs = append(evs, loads(t)) + level[k] = before + evs = append(evs, loads(later(t+between(5, 30)))) + } + case "pv_cloud": + if inv := pick(inverters); inv != "" { + sun := between(0.4, 1) * maxPV + evs = append(evs, runEvent{AtS: t, Do: "pv", Device: inv, PVW: ptr(math.Round(sun * between(0.1, 0.6)))}, + runEvent{AtS: later(t + between(10, 90)), Do: "pv", Device: inv, PVW: ptr(math.Round(sun))}) + } + case "soc_floor": + if inv := pick(inverters); inv != "" { + evs = append(evs, runEvent{AtS: t, Do: "soc", Device: inv, Pct: ptr(minSoC + between(0.5, 3))}) + } + case "car_limit": + if cp := pick(chargers); cp != "" { + a := 0.0 + if rng.Intn(3) > 0 { + a = between(6, 16) + } + evs = append(evs, runEvent{AtS: t, Do: "car", Device: cp, MaxA: ptr(a)}) + } + case "silence": + if inv := pick(inverters); inv != "" { + evs = append(evs, runEvent{AtS: t, Do: "silence", Device: inv, ForS: between(5, 60)}) + } + case "offline": + if inv := pick(inverters); inv != "" { + evs = append(evs, runEvent{AtS: t, Do: "offline", Device: inv, ForS: between(20, 90)}) + } + case "reconnect": + if cp := pick(chargers); cp != "" { + evs = append(evs, runEvent{AtS: t, Do: "reconnect", Device: cp, ForS: between(5, 30)}) + } + case "meter_late": + evs = append(evs, runEvent{AtS: t, Do: "meter_late", ForS: between(5, 40)}) + case "meter_glitch": + evs = append(evs, runEvent{AtS: t, Do: "meter_glitch"}) + case "signed_currents": + evs = append(evs, runEvent{AtS: t, Do: "signed_currents", On: ptr(rng.Intn(2) == 0)}) + case "load_balancer": + if cp := pick(chargers); cp != "" { + evs = append(evs, runEvent{AtS: t, Do: "load_balancer", Device: cp, MaxA: ptr(between(6, 16))}, + runEvent{AtS: later(t + between(30, 180)), Do: "load_balancer", Device: cp, MaxA: ptr(0.0)}) + } + } + } + sort.SliceStable(evs, func(i, j int) bool { return evs[i].AtS < evs[j].AtS }) + return evs +} + +func describeRandom(rr *runRandom, n int) string { + return fmt.Sprintf("random seed %d: %d events over %.0f s", rr.Seed, n, rr.DurationS) +} + +func ptr[T any](v T) *T { return &v } diff --git a/cmd/simulator/run_test.go b/cmd/simulator/run_test.go index 1be8fe3..dd33cde 100644 --- a/cmd/simulator/run_test.go +++ b/cmd/simulator/run_test.go @@ -129,6 +129,17 @@ func TestRunFuseStartsWarm(t *testing.T) { }) } +// wired_phases reaches the charger. +func TestRunWiresAOnePhaseCharger(t *testing.T) { + spec := runSpec{Name: "t", Site: runSiteSpec{FuseA: 25}, + Devices: []runDeviceSpec{{ID: "cp", Driver: "ocpp", OCPPURL: "ws://127.0.0.1:9/", MaxKW: 3.68, WiredPhases: 1}}} + r, _ := runFor(t, spec) + srv := r.devs["cp"].OCPPServer + if srv == nil || !srv.WiredOnOnePhase() { + t.Fatal("the charger is not wired on one phase") + } +} + // "meter" names the site meter in ports.json, so no device may take it. func TestRunRefusesTheMeterID(t *testing.T) { spec := runSpec{Name: "meter-id", Site: runSiteSpec{FuseA: 25}, @@ -239,4 +250,136 @@ func TestRunChargerReconnects(t *testing.T) { } } -func ptr[T any](v T) *T { return &v } +func randomSpec(seed int64) runSpec { + return runSpec{Name: "r", Site: runSiteSpec{FuseA: 20, HouseW: [3]float64{690, 690, 690}}, + Devices: []runDeviceSpec{{ID: "inv", Driver: "sungrow", PVW: 3000}, + {ID: "garage", Driver: "ocpp", OCPPURL: "ws://127.0.0.1:1/"}}, + Random: &runRandom{Seed: seed, DurationS: 1800}} +} + +// A seed gives the same timeline every time, within the house's bounds. +func TestRandomTimelinesAreSeededAndBounded(t *testing.T) { + a, b, c := randomSpec(7), randomSpec(7), randomSpec(8) + ea, eb, ec := randomize(&a), randomize(&b), randomize(&c) + ja, _ := json.Marshal(ea) + jb, _ := json.Marshal(eb) + jc, _ := json.Marshal(ec) + if !bytes.Equal(ja, jb) || bytes.Equal(ja, jc) { + t.Fatal("seed 7 twice differs, or seed 8 is the same") + } + if *a.Devices[1].CommandResponseS != *b.Devices[1].CommandResponseS || a.Site.SignedPhaseCurrents == nil { + t.Fatal("the seed did not fix the devices' timing") + } + if len(ea) < 20 { + t.Fatalf("%d events in 30 min", len(ea)) + } + kinds := map[string]int{} + for seed := int64(1); seed <= 20; seed++ { + s := randomSpec(seed) + for _, ev := range randomize(&s) { + kinds[ev.Do]++ + if ev.AtS < 30 || ev.AtS > 1800 { + t.Fatalf("seed %d: event at %.0f s outside the run", seed, ev.AtS) + } + if ev.A != nil { + for _, x := range ev.A { + if x < 0.3 || x > 18 { + t.Fatalf("seed %d: house %.1f A on a 20 A fuse", seed, x) + } + } + } + } + } + for _, k := range []string{"loads", "pv", "soc", "car", "silence", "offline", "reconnect", "meter_late", "meter_glitch", "signed_currents", "load_balancer"} { + if kinds[k] == 0 { + t.Errorf("20 seeds never drew %s", k) + } + } + only := randomSpec(1) + only.Random.Kinds = []string{"meter_glitch"} + for _, ev := range randomize(&only) { + if ev.Do != "meter_glitch" { + t.Fatalf("kinds filter let %s through", ev.Do) + } + } +} + +// The meter's faults reach what an EMS reads: a late meter stands still, +// a glitch puts 0 W in the total once, and signed currents carry the sign. +// A later silence or outage is not ended by an earlier one's timer. +func TestOverlappingFaultsKeepTheLatest(t *testing.T) { + spec := runSpec{Name: "t", Site: runSiteSpec{FuseA: 25}, + Devices: []runDeviceSpec{{ID: "inv", Driver: "sungrow", EMSControl: ptr(false)}}} + r, _ := runFor(t, spec) + inv := r.devs["inv"] + for _, ev := range []runEvent{{Do: "silence", Device: "inv", ForS: 0.3}, {Do: "silence", Device: "inv"}} { + if err := r.apply(ev); err != nil { + t.Fatal(err) + } + } + time.Sleep(600 * time.Millisecond) + if !inv.Server.Silent() { + t.Fatal("an indefinite silence ended at the earlier silence's deadline") + } + for _, ev := range []runEvent{{Do: "online", Device: "inv"}, {Do: "offline", Device: "inv", ForS: 0.3}, {Do: "offline", Device: "inv", ForS: 5}} { + if err := r.apply(ev); err != nil { + t.Fatal(err) + } + } + time.Sleep(600 * time.Millisecond) + if isRunning(inv) { + t.Fatal("a 5 s outage ended at the earlier outage's deadline") + } +} + +// Energy that flows while the meter's readings stand still shows on its +// counters once they move again. +func TestLateMeterKeepsTheEnergy(t *testing.T) { + spec := runSpec{Name: "t", Site: runSiteSpec{FuseA: 25, HouseW: [3]float64{3000, 3000, 3000}}, + Devices: []runDeviceSpec{{ID: "inv", Driver: "sungrow", EMSControl: ptr(false)}}} + r, _ := runFor(t, spec) + kwh := func() float64 { return getSemanticValue(r.meter, "total_import") } + ocpptest.WaitFor(t, "import on the meter", 5*time.Second, func() bool { return getSemanticValue(r.meter, "meter_power") > 8000 }) + t0, e0 := time.Now(), kwh() + if err := r.apply(runEvent{Do: "meter_late", ForS: 2}); err != nil { + t.Fatal(err) + } + time.Sleep(3 * time.Second) + w := getSemanticValue(r.meter, "meter_power") + got, want := kwh()-e0, w/1000*time.Since(t0).Hours() + if got < 0.8*want || got > 1.2*want { + t.Fatalf("%.5f kWh counted over %v at %.0f W, want about %.5f", got, time.Since(t0).Round(time.Millisecond), w, want) + } +} + +func TestRunMeterFaults(t *testing.T) { + spec := runSpec{Name: "t", Site: runSiteSpec{FuseA: 25, HouseW: [3]float64{690, 690, 690}}, + Devices: []runDeviceSpec{{ID: "inv", Driver: "sungrow", PVW: 5000, EMSControl: ptr(false)}}} + r, _ := runFor(t, spec) + meterW := func() float64 { return getSemanticValue(r.meter, "meter_power") } + l1A := func() float64 { return getSemanticValue(r.meter, "meter_l1_current") } + ocpptest.WaitFor(t, "export on the meter", 5*time.Second, func() bool { return meterW() < -2000 }) + if l1A() < 0 { + t.Fatalf("an SDM630 reports magnitudes, got %v A", l1A()) + } + if err := r.apply(runEvent{Do: "signed_currents", On: ptr(true)}); err != nil { + t.Fatal(err) + } + ocpptest.WaitFor(t, "a signed current", 3*time.Second, func() bool { return l1A() < 0 }) + + if err := r.apply(runEvent{Do: "meter_late", ForS: 2}); err != nil { + t.Fatal(err) + } + r.setLoads([3]float64{3000, 3000, 3000}) + time.Sleep(1200 * time.Millisecond) + if w := meterW(); w > -2000 { + t.Fatalf("a late meter moved to %v W", w) + } + ocpptest.WaitFor(t, "the meter again", 5*time.Second, func() bool { return meterW() > 0 }) + + if err := r.apply(runEvent{Do: "meter_glitch"}); err != nil { + t.Fatal(err) + } + ocpptest.WaitFor(t, "a 0 W total", 3*time.Second, func() bool { return meterW() == 0 && getSemanticValue(r.meter, "meter_l1_power") > 0 }) + ocpptest.WaitFor(t, "the total again", 3*time.Second, func() bool { return meterW() > 0 }) +} diff --git a/cmd/simulator/static/openapi.json b/cmd/simulator/static/openapi.json index bb907cc..070da58 100644 --- a/cmd/simulator/static/openapi.json +++ b/cmd/simulator/static/openapi.json @@ -432,7 +432,7 @@ "post": { "summary": "Headless run only: change the house now and record the event", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "required": ["do"], "properties": { - "do": { "type": "string", "enum": ["mark", "loads", "pv", "soc", "plug", "car", "offline", "online", "reconnect", "stop"] }, + "do": { "type": "string", "enum": ["mark", "loads", "pv", "soc", "plug", "car", "offline", "online", "reconnect", "silence", "meter_late", "meter_glitch", "signed_currents", "load_balancer", "stop"] }, "device": { "type": "string", "description": "Device id from the run's spec" }, "label": { "type": "string" }, "w": { "type": "array", "items": { "type": "number" }, "description": "House load per phase, W" }, diff --git a/docs/HEADLESS_RUN.md b/docs/HEADLESS_RUN.md index 4d23b13..9c2c4e0 100644 --- a/docs/HEADLESS_RUN.md +++ b/docs/HEADLESS_RUN.md @@ -55,7 +55,9 @@ output directory says where each device is. fuses have carried long enough to settle, starts the run warm instead of cold. A size the profile does not model is refused. - A charger dials `ocpp_url`; `user:password@` goes out as Basic auth. - `car_max_a` and `car_phases` are what the car takes. + `car_max_a` and `car_phases` are what the car takes. `wired_phases: 1` + wires a charger on L1 only: it gives an order's current there, whatever + `numberPhases` says, up to `max_kw` (its rating as wired). ## Events @@ -73,8 +75,41 @@ apply it now, or `{"label": "..."}` to `/api/run/mark`. | `offline` | `device`, `for_s` | the device stops answering and drops out of the site; back after `for_s` if given | | `online` | `device` | back again | | `reconnect` | `device`, `for_s` | a charger loses its connection for `for_s`; the car keeps charging | +| `silence` | `device`, `for_s` | a Modbus device keeps its connections but answers nothing (timeouts, not refusals); 0 until `online` | +| `meter_late` | `for_s` | the site meter's readings stand still; the house goes on | +| `meter_glitch` | | the next reading's total reads 0 W while its phases are right | +| `signed_currents` | `on` | the meter's phase currents carry the sign of their power (an SDM630 reports magnitudes) | +| `load_balancer` | `device`, `max_a` | the charger's own cap per phase, whatever it is offered; 0 none | | `stop` | | end the run | +## Model parameters + +Each model's timing and quirks sit in its profile's `model` block and can +be overridden per device in the spec: + +| Parameter | Device | Meaning | +|---|---|---| +| `command_response_s` | hybrid, OCPP charger | the battery follows an EMS write, or the charger applies an accepted profile, this much later | +| `startup_delay_s` | OCPP charger | the car draws this long after an offer rises from zero; the charger says Charging meanwhile | +| `load_balancer_a` | OCPP charger | the charger's own cap per phase | +| `signed_phase_currents` | meter | currents carry a sign (`site.signed_phase_currents` in a spec) | + +## Random timelines + +```json +"random": {"seed": 42, "duration_s": 1800, "gap_s": 40, "kinds": ["loads_step", "meter_late"]} +``` + +draws events between 30 s and `duration_s` − 30 s, on average `gap_s` +apart: house load steps, drift and one-phase spikes (each phase stays under +the fuse less 2 A), PV cloud edges, the battery near its floor, the car +taking less than offered, device silence and outages, charger reconnects, +a late or glitched meter, signed currents and a load-balancer cap. It also +picks each charger's `command_response_s` (0, 5, 40 or 60 s) and +`startup_delay_s`, a one-phase car now and then, and the meter's sign, +where the spec leaves them open. `kinds` limits the draw. The run writes +`timeline.json`: the spec with the drawn timeline, which replays it. + ## Output | File | What | diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 89d0049..38341cb 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -907,7 +907,7 @@ func buildToolSpecs() ([]Tool, map[string]ToolSpec) { { Tool: Tool{ Name: "run.event", - Description: "Headless run only: change the house now (do: mark, loads, pv, soc, plug, car, offline, online, reconnect, stop)", + Description: "Headless run only: change the house now (do: mark, loads, pv, soc, plug, car, offline, online, reconnect, silence, meter_late, meter_glitch, signed_currents, load_balancer, stop)", InputSchema: schemaObject(map[string]interface{}{ "do": schemaString("Event kind"), "device": schemaString("Device id from the run's spec"), diff --git a/internal/modbus/server.go b/internal/modbus/server.go index 49a46f2..db72f62 100644 --- a/internal/modbus/server.go +++ b/internal/modbus/server.go @@ -47,6 +47,23 @@ type Server struct { clientsMu sync.RWMutex nextClientID int RegisterSet *RegisterSet // Device-specific register definitions (nil = uses global Sungrow defs) + silent bool // read requests but never answer +} + +// SetSilent makes the server keep its connections and read every request +// but answer none, as a device that hangs does: a client sees timeouts, +// not a refused connection. +func (s *Server) SetSilent(on bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.silent = on +} + +// Silent says whether the server is ignoring requests. +func (s *Server) Silent() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.silent } // NewServer creates a new Modbus server with the specified register count @@ -375,7 +392,7 @@ func (s *Server) handleConnection(conn net.Conn) { response = s.makeException(transactionID, unitID, functionCode, 0x01) } - if response != nil { + if response != nil && !s.Silent() { conn.Write(response) } } diff --git a/internal/modbus/server_test.go b/internal/modbus/server_test.go index d5671e1..34281aa 100644 --- a/internal/modbus/server_test.go +++ b/internal/modbus/server_test.go @@ -1,6 +1,7 @@ package modbus import ( + "net" "testing" "time" ) @@ -203,3 +204,40 @@ func TestServerBoundaryConditions(t *testing.T) { t.Errorf("Boundary read: got length %d", len(regs)) } } + +// A silent server keeps the connection and answers nothing until it speaks +// again. +func TestSilentServerAnswersNothing(t *testing.T) { + s := NewServer(65536) + if err := s.Listen("127.0.0.1:0"); err != nil { + t.Fatal(err) + } + go s.Serve() + defer s.Close() + conn, err := net.Dial("tcp", s.listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + ask := func() bool { + req := []byte{0, 1, 0, 0, 0, 6, 1, 0x03, 0x13, 0x89, 0, 1} // read holding 5001 + if _, err := conn.Write(req); err != nil { + t.Fatal(err) + } + conn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + buf := make([]byte, 64) + n, err := conn.Read(buf) + return err == nil && n > 0 + } + if !ask() { + t.Fatal("no answer before silence") + } + s.SetSilent(true) + if ask() { + t.Fatal("answered while silent") + } + s.SetSilent(false) + if !ask() { + t.Fatal("no answer after silence") + } +} diff --git a/internal/ocpp/client_test.go b/internal/ocpp/client_test.go index c60d323..f23f6f8 100644 --- a/internal/ocpp/client_test.go +++ b/internal/ocpp/client_test.go @@ -262,6 +262,35 @@ func TestCompositeScheduleKeepsThePhases(t *testing.T) { } } +// A charger wired on one phase gives an order's current on L1 whatever its +// numberPhases, and no more than its rating. +func TestOnePhaseWiring(t *testing.T) { + wired := func(p ChargingProfile) *Server { + s := NewServer(1, 0, "TestVendor", "TestModel", 1, 3.68) + s.SetWiredPhases(1) + s.transactions[1] = &Transaction{ID: 1, ConnectorID: 1, StartTime: time.Now()} + s.connectors[1].Status = StatusCharging + s.applyChargingProfile(1, &p) + s.GenerateValues(time.Now()) + return s + } + for _, c := range []struct { + name string + p ChargingProfile + want float64 + }{ + {"16 A on three phases", txProfileA(16, 3), 3680}, + {"10 A on one phase", txProfileA(10, 1), 2300}, + {"32 A", txProfileA(32, 3), 3680}, + {"3 kW", txProfileW(1, 3000), 3000}, + } { + s := wired(c.p) + if got := s.PhasePowers(); math.Abs(got[0]-c.want) > 1 || got[1] != 0 || got[2] != 0 { + t.Errorf("%s: phase powers %v, want %.0f W on L1", c.name, got, c.want) + } + } +} + // On one phase a charger gives at most its rated current per phase. func TestOnePhaseDrawIsCappedAtRatedCurrent(t *testing.T) { s := charging(t, 11.04, true, txProfileA(24, 1)) // a 16 A charger @@ -372,3 +401,65 @@ func TestCarTakesWhatItCan(t *testing.T) { t.Fatalf("car without limits: %v, want the 16 A offer", got) } } + +// The charger's own load balancer caps the draw per phase below the offer. +func TestLoadBalancerCapsTheDraw(t *testing.T) { + s := charging(t, 22.08, false, txProfileA(16, 3)) + s.SetLoadBalancer(10) + s.GenerateValues(time.Now()) + if got := s.PhasePowers(); got[0]/229 > 10.2 || got[0]/229 < 9.8 { + t.Fatalf("load balancer at 10 A: %v", got) + } + s.mu.RLock() + offer := s.computeEffectivePowerLocked(1, time.Now()) + s.mu.RUnlock() + if offer < 10900 { + t.Fatalf("the offer itself is %v W, want the profile's 16 A", offer) + } +} + +// After an offer rises from zero the car waits the start delay, while the +// charger says Charging. +// Every session waits out the delay, not only the first. +func TestStartupDelayEachSession(t *testing.T) { + s := charging(t, 22.08, false, txProfileA(16, 3)) + s.SetStartupDelay(time.Minute) + s.connectors[1].offerSince = time.Time{} + t0 := time.Now() + s.GenerateValues(t0) + s.GenerateValues(t0.Add(61 * time.Second)) + if got := s.connectors[1].CurrentPower; got < 11000 { + t.Fatalf("first session after the delay: %v W", got) + } + delete(s.transactions, 1) + s.GenerateValues(t0.Add(62 * time.Second)) + s.transactions[2] = &Transaction{ID: 2, ConnectorID: 1, StartTime: t0.Add(63 * time.Second)} + s.connectors[1].Status = StatusCharging + s.GenerateValues(t0.Add(63 * time.Second)) + if got := s.connectors[1].CurrentPower; got != 0 { + t.Fatalf("second session at once: %v W, want 0 until the delay passes", got) + } + s.GenerateValues(t0.Add(124 * time.Second)) + if got := s.connectors[1].CurrentPower; got < 11000 { + t.Fatalf("second session after the delay: %v W", got) + } +} + +func TestStartupDelay(t *testing.T) { + s := charging(t, 22.08, false, txProfileA(16, 3)) + s.SetStartupDelay(time.Minute) + s.connectors[1].offerSince = time.Time{} + t0 := time.Now() + s.GenerateValues(t0) + if got := s.connectors[1].CurrentPower; got != 0 || s.connectors[1].Status != StatusCharging { + t.Fatalf("at the offer: %v W, %s", got, s.connectors[1].Status) + } + s.GenerateValues(t0.Add(30 * time.Second)) + if got := s.connectors[1].CurrentPower; got != 0 { + t.Fatalf("after 30 s: %v W", got) + } + s.GenerateValues(t0.Add(61 * time.Second)) + if got := s.connectors[1].CurrentPower; got < 11000 { + t.Fatalf("after the delay: %v W", got) + } +} diff --git a/internal/ocpp/server.go b/internal/ocpp/server.go index 87bec85..eae1c38 100644 --- a/internal/ocpp/server.go +++ b/internal/ocpp/server.go @@ -67,6 +67,8 @@ type Connector struct { CurrentPower float64 `json:"currentPower"` EnergyWh float64 `json:"energyWh"` Phases int `json:"phases"` // 1 or 3: phases the car draws on + + offerSince time.Time // when the offer last rose from zero } // Transaction represents an active charging transaction @@ -137,8 +139,11 @@ type Server struct { phaseW [3]float64 // what the chargers draw on L1, L2, L3 // Simulation control - carMaxA float64 // most the car draws per phase; 0 = whatever is offered + carMaxA float64 // most the car draws per phase; 0 = whatever is offered + loadBalancerA float64 // the charger's own cap per phase; 0 = none + startupDelay time.Duration // from an offer rising from zero to the car drawing carPhases int // phases the car charges on; 0 = as many as offered + wiredPhases int // phases the charger is wired on (from L1); 0 = three evConnected bool // Whether an EV is plugged in nextTxId int // Counter for local transaction IDs } @@ -223,6 +228,53 @@ func (s *Server) SetCar(maxA float64, phases int) { s.carMaxA, s.carPhases = maxA, phases } +// SetLoadBalancer caps what the charger gives per phase, as a charger with +// its own load balancer (Easee) does whatever the Central System offers. +// Zero turns it off. +func (s *Server) SetLoadBalancer(a float64) { + s.mu.Lock() + defer s.mu.Unlock() + s.loadBalancerA = a +} + +// SetWiredPhases wires the charger on its first n phases from L1; 0 or 3 +// is three. A one-phase charger gives an order's current on L1, whatever +// numberPhases says, and at most its rating there. +func (s *Server) SetWiredPhases(n int) { + s.mu.Lock() + defer s.mu.Unlock() + s.wiredPhases = n +} + +// WiredOnOnePhase says whether SetWiredPhases(1) wired the charger on L1. +func (s *Server) WiredOnOnePhase() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.wiredLocked() == 1 +} + +func (s *Server) wiredLocked() int { + if s.wiredPhases > 0 && s.wiredPhases < 3 { + return s.wiredPhases + } + return 3 +} + +// SetStartupDelay is how long the car takes to draw after an offer rises +// from zero. +func (s *Server) SetStartupDelay(d time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + s.startupDelay = d +} + +func (s *Server) loadBalancedLocked(w float64, phases int) float64 { + if s.loadBalancerA <= 0 { + return w + } + return math.Min(w, s.loadBalancerA*float64(phases)*s.averagePhaseVoltageLocked()) +} + // carDrawLocked is what the car takes of an offer of w watts on phases. func (s *Server) carDrawLocked(w float64, phases int) (float64, int) { if w <= 0 || (s.carMaxA <= 0 && (s.carPhases <= 0 || s.carPhases >= phases)) { @@ -1466,6 +1518,8 @@ func (s *Server) evaluateProfile(profile *ChargingProfile, now time.Time, txStar if activePeriod.NumberPhases != nil && *activePeriod.NumberPhases > 0 { phases = *activePeriod.NumberPhases } + // A current flows on the phases the charger has. + phases = min(phases, s.wiredLocked()) // Convert to watts unit := strings.ToUpper(strings.TrimSpace(schedule.ChargingRateUnit)) switch unit { @@ -1579,6 +1633,11 @@ func (s *Server) computeEffectiveLocked(connectorId int, now time.Time) (float64 phases = 1 maxW /= 3 } + // A charger wired on fewer phases draws on those; max_kw is its rating + // as wired. + if w := s.wiredLocked(); w < phases { + phases = w + } // Cap at hardware max effectiveW = math.Min(effectiveW, maxW) @@ -2471,18 +2530,34 @@ func (s *Server) GenerateValues(simTime time.Time) { // Simulate power consumption for active transactions var totalPower float64 var phaseW [3]float64 + offered := map[int]bool{} // connectors in a session that may draw for _, tx := range s.transactions { conn := s.connectors[tx.ConnectorID] if conn.Status == StatusCharging || conn.Status == StatusSuspendedEVSE { - powerW, phases := s.carDrawLocked(s.computeEffectiveLocked(tx.ConnectorID, simTime)) + offered[tx.ConnectorID] = true + offerW, offerPhases := s.computeEffectiveLocked(tx.ConnectorID, simTime) + offerW = s.loadBalancedLocked(offerW, offerPhases) + powerW, phases := s.carDrawLocked(offerW, offerPhases) conn.Phases = phases + // The car takes a while to start drawing after an offer rises + // from zero; the charger meanwhile reports Charging at 0 W. + if offerW > 0 { + if conn.offerSince.IsZero() { + conn.offerSince = simTime + } + if simTime.Sub(conn.offerSince) < s.startupDelay { + powerW = 0 + } + } else { + conn.offerSince = time.Time{} + } - if powerW <= 0 && conn.Status == StatusCharging { + if offerW <= 0 && conn.Status == StatusCharging { // Power dropped to 0 — transition to SuspendedEVSE conn.Status = StatusSuspendedEVSE conn.CurrentPower = 0 statusChanges = append(statusChanges, statusChange{tx.ConnectorID, StatusSuspendedEVSE}) - } else if powerW > 0 && conn.Status == StatusSuspendedEVSE { + } else if offerW > 0 && conn.Status == StatusSuspendedEVSE { // Power restored — transition back to Charging conn.Status = StatusCharging conn.CurrentPower = powerW @@ -2506,6 +2581,13 @@ func (s *Server) GenerateValues(simTime time.Time) { tx.MeterNow = int(conn.EnergyWh) } } + // A connector out of session waits out the startup delay again on its + // next offer. + for id, conn := range s.connectors { + if !offered[id] && conn != nil { + conn.offerSince = time.Time{} + } + } // Update 3-phase metering from the power on each phase s.phaseW = phaseW diff --git a/internal/profiles/loader.go b/internal/profiles/loader.go index a35d3d6..22fd728 100644 --- a/internal/profiles/loader.go +++ b/internal/profiles/loader.go @@ -48,6 +48,15 @@ type ModelParams struct { // command: a hybrid's battery after an EMS write, an OCPP charger's // output after a charging profile. CommandResponseS float64 `json:"command_response_s,omitempty"` + // StartupDelayS is how long a car on an OCPP charger takes to draw + // after an offer rises from zero. + StartupDelayS float64 `json:"startup_delay_s,omitempty"` + // LoadBalancerA is an OCPP charger's own cap per phase, whatever the + // Central System offers (Easee's load balancer). Zero is none. + LoadBalancerA float64 `json:"load_balancer_a,omitempty"` + // SignedPhaseCurrents: a meter's phase currents carry the sign of their + // phase's power. An SDM630 reports magnitudes. + SignedPhaseCurrents bool `json:"signed_phase_currents,omitempty"` } // Profile defines a device simulation profile