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
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ make build-linux
- MQTT: 1883+
- OCPP: 9000+

### Headless Run

```bash
go run ./cmd/simulator run -spec house.json -port-base 18700 -out run/
```

One house from a spec, a timeline of events, a record a second
(`cmd/simulator/run.go`, [docs/HEADLESS_RUN.md](docs/HEADLESS_RUN.md)).

### Run Tests

```bash
Expand Down Expand Up @@ -113,7 +122,7 @@ GET /api/simulators # List all simulators
POST /api/simulators # Create simulator {type: "inverter"|"energy_meter"|"v2x_charger"|"ocpp_charger"}
DELETE /api/simulators/{id} # Delete simulator
GET /api/simulators/{id} # Get simulator details
POST /api/simulators/{id}/config # Update serial/slave ID
POST /api/simulators/{id}/config # Serial, slave ID, OCPP URL, ems_control, command_response_s
GET /api/simulators/{id}/registers # Get all registers
POST /api/simulators/{id}/registers/{addr} # Write register value
GET /api/simulators/{id}/logs # Get protocol operation logs
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ All simulators are accessible through a unified API:
```
GET /api/simulators # List all simulators
GET /api/simulators/{id} # Get simulator details
POST /api/simulators/{id}/config # Update serial/slave ID
POST /api/simulators/{id}/config # Serial, slave ID, OCPP URL, ems_control, command_response_s
GET /api/simulators/{id}/registers # Get all registers
POST /api/simulators/{id}/registers/{addr} # Write register value
GET /api/simulators/{id}/logs # Get Modbus operation logs
Expand All @@ -103,6 +103,12 @@ POST /api/simulators/{id}/automation/config # Configure time multiplier
GET /health # Health check (always 42... I mean OK)
```

## Headless runs

`simulator run -spec house.json -port-base 18700 -out run/` builds one house
from a JSON spec, runs a timeline of events and records it once a second, for
an EMS's automated tests. See [docs/HEADLESS_RUN.md](docs/HEADLESS_RUN.md).

## Configuration

| Environment Variable | Default | Description |
Expand Down
105 changes: 105 additions & 0 deletions cmd/simulator/ems.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package main

import (
"math"
"time"

"github.com/srcfl/device-simulator/internal/profiles"
)

// EMS control for a Sungrow hybrid's battery.
//
// A real SH applies what an EMS writes to its control block: 13049 is the
// mode, 13050 the forced command (170 charge, 187 discharge, 204 stop) and
// 13051 the forced power in watts. In any mode other than forced it runs
// self-consumption, steering the battery against its own meter. Without
// ems_control the simulator stores those writes and leaves the battery
// where the dashboard or a register write put it. How long the battery takes
// to follow is the model's command_response_s (2 s for sungrow-hybrid).

const (
emsModeForced = 2
emsCmdCharge = 170
emsCmdDischarge = 187

flagCharging = 0x0002
flagDischarging = 0x0004
)

// emsBattery is what the battery is doing in answer to its EMS registers.
type emsBattery struct {
pending []emsTarget
appliedW float64 // + charging, - discharging
}

type emsTarget struct {
at time.Time
w float64
}

// emsTargetW is the battery power the inverter heads for. siteW is the
// site meter's latest net power (+ import), which includes appliedW.
func emsTargetW(sim *Simulator, siteW, appliedW float64) float64 {
var w float64
if int(getSemanticValue(sim, "grid_mode")) == emsModeForced {
sp := math.Abs(getSemanticValue(sim, "battery_setpoint"))
switch int(getSemanticValue(sim, "battery_cmd")) {
case emsCmdCharge:
w = sp
case emsCmdDischarge:
w = -sp
}
} else {
w = appliedW - siteW
}
// A model without the limit registers has no limit. One that has them
// enforces each, 0 included, as an SH does.
if getSemanticDef(sim, "max_charge_power") != nil {
w = math.Min(w, getSemanticValue(sim, "max_charge_power")*1000)
}
if getSemanticDef(sim, "max_discharge_power") != nil {
w = math.Max(w, -getSemanticValue(sim, "max_discharge_power")*1000)
}
soc := getSemanticValue(sim, "battery_soc")
if maxSoC := getSemanticValue(sim, "battery_max_soc"); maxSoC > 0 && soc >= maxSoC && w > 0 {
w = 0
}
if soc <= getSemanticValue(sim, "battery_min_soc") && w < 0 {
w = 0
}
return w
}

// stepEMSBattery moves an EMS-controlled battery to its target once the
// response time has passed, and writes the power and the power-flow flags
// the Lua driver signs it by.
func stepEMSBattery(sim *Simulator, now time.Time, siteW float64) {
sim.mu.Lock()
defer sim.mu.Unlock()
if !sim.EMSControl || !sim.Running || !sim.DeviceOn || sim.Server == nil || sim.Category != "inverter" {
return
}
b := &sim.ems
b.pending = append(b.pending, emsTarget{at: now.Add(sim.EMSResponse), w: emsTargetW(sim, siteW, b.appliedW)})
for len(b.pending) > 0 && !b.pending[0].at.After(now) {
b.appliedW = b.pending[0].w
b.pending = b.pending[1:]
}

setSemanticValue(sim, "battery_power", math.Abs(b.appliedW))
var flags float64
switch {
case b.appliedW > 0:
flags = flagCharging
case b.appliedW < 0:
flags = flagDischarging
}
setSemanticValue(sim, "battery_flags", flags)
}

func seconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) }

// commandResponse is the model's command_response_s.
func commandResponse(p *profiles.Profile) time.Duration {
return seconds(p.Params().CommandResponseS)
}
138 changes: 138 additions & 0 deletions cmd/simulator/ems_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package main

import (
"math"
"testing"
"time"

"github.com/srcfl/device-simulator/internal/modbus/devices"
)

// sungrowInSite is a running Sungrow hybrid in a site with houseW per
// phase, its battery at soc percent with a 5 % floor.
func sungrowInSite(t *testing.T, houseW, soc float64, ems bool) (*Simulator, *Site, *Simulator) {
t.Helper()
inv, err := createSimulator("inverter")
if err != nil {
t.Fatal(err)
}
rs := devices.GetRegisterSet("sungrow")
inv.RegisterSet = rs
inv.Server.InitDefaultsFromSet(rs)
inv.Running = true
inv.EMSControl = ems
inv.EMSResponse = 0
setSemanticValue(inv, "battery_soc", soc)
setSemanticValue(inv, "battery_min_soc", 5)
site, meter := testSite(t, houseW, inv)
return inv, site, meter
}

func writeEMS(sim *Simulator, mode, cmd, watts float64) {
setSemanticValue(sim, "grid_mode", mode)
setSemanticValue(sim, "battery_cmd", cmd)
setSemanticValue(sim, "battery_setpoint", watts)
}

// tick runs the site meter n times and returns its last net power.
func tick(site *Site, meter *Simulator, n int) float64 {
for i := 0; i < n; i++ {
calculateAndUpdateMeter(site, 0)
}
return getSemanticValue(meter, "meter_power")
}

func TestEMSForcedModeDrivesTheBattery(t *testing.T) {
inv, site, meter := sungrowInSite(t, 1000, 50, true)

writeEMS(inv, 2, emsCmdCharge, 3000)
if got := tick(site, meter, 2); math.Abs(got-6000) > 1 {
t.Fatalf("charging 3 kW: meter %.0f W, want house 3000 + 3000", got)
}
if got := getBatterySignedPower(inv); got != 3000 {
t.Fatalf("battery %.0f W, want +3000", got)
}

writeEMS(inv, 2, emsCmdDischarge, 2000)
if got := tick(site, meter, 2); math.Abs(got-1000) > 1 {
t.Fatalf("discharging 2 kW: meter %.0f W, want 3000 - 2000", got)
}
if got := getSemanticValue(inv, "battery_flags"); got != flagDischarging {
t.Fatalf("power-flow flags %v, want discharging (4): the driver signs by them", got)
}

writeEMS(inv, 2, 204, 2000)
if got := tick(site, meter, 2); math.Abs(got-3000) > 1 {
t.Fatalf("stop: meter %.0f W, want the house alone", got)
}

writeEMS(inv, 2, emsCmdCharge, 9000)
if got := (tick(site, meter, 2) - 3000); math.Abs(got-5000) > 1 {
t.Fatalf("9 kW asked: battery %.0f W, want the 5 kW limit (33046)", got)
}
}

// A limit register holding 0 allows no power that way.
func TestEMSKeepsZeroPowerLimits(t *testing.T) {
inv, site, meter := sungrowInSite(t, 1000, 50, true)
setSemanticValue(inv, "max_charge_power", 0)
writeEMS(inv, 2, emsCmdCharge, 3000)
if got := tick(site, meter, 2) - 3000; math.Abs(got) > 1 {
t.Fatalf("charge limit 0: battery %.0f W, want 0", got)
}
setSemanticValue(inv, "max_discharge_power", 0)
writeEMS(inv, 0, 204, 0)
if got := tick(site, meter, 3); math.Abs(got-3000) > 1 {
t.Fatalf("discharge limit 0: meter %.0f W, want the house alone", got)
}
}

// Self-consumption (any mode but forced) cancels the meter, as a real SH
// does without an EMS.
func TestEMSSelfConsumptionCancelsTheMeter(t *testing.T) {
inv, site, meter := sungrowInSite(t, 1000, 50, true)
writeEMS(inv, 0, 204, 0)
if got := tick(site, meter, 3); math.Abs(got) > 1 {
t.Fatalf("meter %.0f W, want 0 with the battery covering the house", got)
}
if got := getBatterySignedPower(inv); math.Abs(got+3000) > 1 {
t.Fatalf("battery %.0f W, want -3000", got)
}
}

func TestEMSKeepsTheSoCLimits(t *testing.T) {
inv, site, meter := sungrowInSite(t, 1000, 5, true)
writeEMS(inv, 2, emsCmdDischarge, 3000)
if got := tick(site, meter, 2); math.Abs(got-3000) > 1 {
t.Fatalf("at the 5 %% floor: meter %.0f W, want no discharge", got)
}
setSemanticValue(inv, "battery_soc", 100)
writeEMS(inv, 2, emsCmdCharge, 3000)
if got := tick(site, meter, 2); math.Abs(got-3000) > 1 {
t.Fatalf("full: meter %.0f W, want no charge", got)
}
}

// The battery answers after its response time, not on the write.
func TestEMSResponseTime(t *testing.T) {
inv, site, meter := sungrowInSite(t, 1000, 50, true)
inv.EMSResponse = 300 * time.Millisecond
writeEMS(inv, 2, emsCmdCharge, 3000)
if got := tick(site, meter, 2); math.Abs(got-3000) > 1 {
t.Fatalf("right after the write: meter %.0f W, want the house alone", got)
}
time.Sleep(350 * time.Millisecond)
if got := tick(site, meter, 2); math.Abs(got-6000) > 1 {
t.Fatalf("after 350 ms: meter %.0f W, want house + 3000", got)
}
}

// Without ems_control the registers stay as written: the dashboard sets the
// battery itself.
func TestEMSWritesIgnoredWithoutEMSControl(t *testing.T) {
inv, site, meter := sungrowInSite(t, 1000, 50, false)
writeEMS(inv, 2, emsCmdCharge, 3000)
if got := tick(site, meter, 2); math.Abs(got-3000) > 1 {
t.Fatalf("meter %.0f W, want the house alone", got)
}
}
Loading
Loading