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
61 changes: 56 additions & 5 deletions cmd/simulator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Comment on lines +4788 to +4790

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve energy accumulated while meter readings are frozen

During a meter_late event with nonzero import or export, this branch skips both the register updates and energy accumulation. Each tick's energyDeltaKWh is discarded, and after recovery only subsequent ticks are added, so the cumulative meter permanently loses all energy that flowed during the delay. This turns a temporarily stale reading into incorrect energy totals for the rest of the experiment. Accumulate the energy internally while frozen and publish the accumulated totals when the meter resumes.

Useful? React with 👍 / 👎.

// 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
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -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)
Expand Down
121 changes: 117 additions & 4 deletions cmd/simulator/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -514,21 +581,67 @@ 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":
var sim *Simulator
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:
Expand Down
Loading