From d0bba7685dbfa485a6bc32c042c28e63b6eaff9b Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 19 Aug 2026 16:04:00 +0000
Subject: [PATCH 1/5] Add a physical plant model and Sungrow Lua-driver
register coverage
Introduce a conserved site-node power balance (Modelica-style DAE) so
automation can be more than independent sine waves, and fill the Sungrow
addresses srcfl/device-drivers actually polls (serial at 4990, device
type, running state, active-power limit). Scenario "physical" steps the
plant and writes semantic registers. See docs/LUA_DRIVER_PLANT.md.
Co-authored-by: Fredrik Ahlgren
---
README.md | 1 +
cmd/simulator/main.go | 210 ++++++--
cmd/simulator/static/js/components.js | 10 +-
cmd/simulator/static/js/protocols/modbus.js | 3 +-
docs/LUA_DRIVER_PLANT.md | 110 ++++
internal/modbus/devices/sungrow.go | 9 +
.../modbus/devices/sungrow_lua_compat_test.go | 58 +++
internal/modbus/registers.go | 29 +-
internal/plant/plant.go | 474 ++++++++++++++++++
internal/plant/plant_test.go | 147 ++++++
internal/plant/semantic.go | 54 ++
11 files changed, 1043 insertions(+), 62 deletions(-)
create mode 100644 docs/LUA_DRIVER_PLANT.md
create mode 100644 internal/modbus/devices/sungrow_lua_compat_test.go
create mode 100644 internal/plant/plant.go
create mode 100644 internal/plant/plant_test.go
create mode 100644 internal/plant/semantic.go
diff --git a/README.md b/README.md
index 5a01c63..6be1e7c 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,7 @@ docker compose up --build
- **Site Management** - Group simulators into sites with energy meters
- **Device Profiles** - Sungrow, SolarEdge, Fronius, Huawei, and more
- **Time Acceleration** - Compress 24 hours into minutes for testing
+- **Physical plant** - Conserved site power balance and battery SoC (Modelica-style DAE) for closed-loop Lua driver tests β see [docs/LUA_DRIVER_PLANT.md](docs/LUA_DRIVER_PLANT.md)
- **Real-time Logging** - See every protocol operation as it happens
## Supported Profiles
diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go
index 257552f..a5ac952 100644
--- a/cmd/simulator/main.go
+++ b/cmd/simulator/main.go
@@ -21,12 +21,13 @@ import (
"time"
"github.com/srcfl/device-simulator/internal/device"
+ "github.com/srcfl/device-simulator/internal/mcp"
simmdns "github.com/srcfl/device-simulator/internal/mdns"
"github.com/srcfl/device-simulator/internal/modbus"
"github.com/srcfl/device-simulator/internal/modbus/devices"
- "github.com/srcfl/device-simulator/internal/mcp"
"github.com/srcfl/device-simulator/internal/mqtt"
"github.com/srcfl/device-simulator/internal/ocpp"
+ "github.com/srcfl/device-simulator/internal/plant"
"github.com/srcfl/device-simulator/internal/ports"
"github.com/srcfl/device-simulator/internal/profiles"
"github.com/srcfl/device-simulator/internal/settings"
@@ -42,25 +43,26 @@ var embeddedFS embed.FS
// Simulator represents a single simulator instance
type Simulator struct {
- ID int `json:"id"`
- Serial string `json:"serial"`
- Protocol string `json:"protocol"`
- Category string `json:"category"`
- SlaveID uint8 `json:"slave_id,omitempty"`
- Port int `json:"port"`
- Running bool `json:"running"`
- SiteID int `json:"site_id"` // Which site this simulator belongs to (0 = orphan)
- DeviceOn bool `json:"device_on"` // Whether device is contributing power (can be off but still on site)
- BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
- Server *modbus.Server `json:"-"`
- MQTTServer *mqtt.Server `json:"-"`
- OCPPServer *ocpp.Server `json:"-"`
- Device device.DeviceServer `json:"-"`
- ProfilesManager *profiles.Manager `json:"-"`
- RegisterSet *modbus.RegisterSet `json:"-"` // Device-specific register definitions
- Automation *AutomationState `json:"-"` // Legacy - will be removed once migration complete
- internalSOC float64 // High-precision SOC accumulator (avoids register truncation)
- socInitialized bool // Whether internalSOC has been initialized from register
+ ID int `json:"id"`
+ Serial string `json:"serial"`
+ Protocol string `json:"protocol"`
+ Category string `json:"category"`
+ SlaveID uint8 `json:"slave_id,omitempty"`
+ Port int `json:"port"`
+ Running bool `json:"running"`
+ SiteID int `json:"site_id"` // Which site this simulator belongs to (0 = orphan)
+ DeviceOn bool `json:"device_on"` // Whether device is contributing power (can be off but still on site)
+ BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
+ Server *modbus.Server `json:"-"`
+ MQTTServer *mqtt.Server `json:"-"`
+ OCPPServer *ocpp.Server `json:"-"`
+ Device device.DeviceServer `json:"-"`
+ ProfilesManager *profiles.Manager `json:"-"`
+ RegisterSet *modbus.RegisterSet `json:"-"` // Device-specific register definitions
+ Automation *AutomationState `json:"-"` // Legacy - will be removed once migration complete
+ internalSOC float64 // High-precision SOC accumulator (avoids register truncation)
+ socInitialized bool // Whether internalSOC has been initialized from register
+ plant *plant.Plant // Physical DAE; nil until first physical step
mu sync.RWMutex
}
@@ -167,10 +169,10 @@ var (
simulatorsMu sync.RWMutex
nextSimulatorID int
nextIDMu sync.Mutex
- portAllocator *ports.Allocator
- mdnsAdvertiser *simmdns.Advertiser
- isDesktopMode bool
- runtimeSettings *settings.AppSettings
+ portAllocator *ports.Allocator
+ mdnsAdvertiser *simmdns.Advertiser
+ isDesktopMode bool
+ runtimeSettings *settings.AppSettings
stateDirty bool
stateMu sync.Mutex
stateWarningsMu sync.Mutex
@@ -1469,13 +1471,13 @@ func handleSimulatorsCreate(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
- "id": sim.ID,
- "serial": sim.Serial,
- "protocol": sim.Protocol,
- "category": sim.Category,
- "port": sim.Port,
- "running": sim.Running,
- "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
+ "id": sim.ID,
+ "serial": sim.Serial,
+ "protocol": sim.Protocol,
+ "category": sim.Category,
+ "port": sim.Port,
+ "running": sim.Running,
+ "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
})
}
@@ -1497,17 +1499,17 @@ func handleSimulatorsListGet(w http.ResponseWriter, r *http.Request) {
sim.mu.RLock()
entry := map[string]interface{}{
- "id": sim.ID,
- "serial": sim.Serial,
- "protocol": sim.Protocol,
- "category": sim.Category,
- "port": sim.Port,
- "running": sim.Running,
- "automation": sim.Automation.Enabled,
- "profile": sim.ProfilesManager.GetActiveName(),
- "site_id": sim.SiteID,
- "device_on": sim.DeviceOn,
- "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
+ "id": sim.ID,
+ "serial": sim.Serial,
+ "protocol": sim.Protocol,
+ "category": sim.Category,
+ "port": sim.Port,
+ "running": sim.Running,
+ "automation": sim.Automation.Enabled,
+ "profile": sim.ProfilesManager.GetActiveName(),
+ "site_id": sim.SiteID,
+ "device_on": sim.DeviceOn,
+ "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
}
// Add protocol-specific fields
@@ -3438,6 +3440,95 @@ func runAutomationLoop(sim *Simulator) {
}
}
+func simulatorUsesPlant(sim *Simulator) bool {
+ if sim == nil || sim.Automation == nil {
+ return false
+ }
+ sim.Automation.mu.RLock()
+ defer sim.Automation.mu.RUnlock()
+ return sim.Automation.Scenario == "physical"
+}
+
+func ensurePlant(sim *Simulator) *plant.Plant {
+ sim.mu.Lock()
+ defer sim.mu.Unlock()
+ if sim.plant != nil {
+ return sim.plant
+ }
+ pr := plant.DefaultParams()
+ if sim.BatteryCapacityKWh > 0 {
+ pr.BatteryCapacityWh = sim.BatteryCapacityKWh * 1000
+ }
+ sim.plant = plant.New(pr)
+ return sim.plant
+}
+
+func controlFromRegisters(sim *Simulator) plant.Control {
+ ratioPct := getSemanticValue(sim, "pv_limit_ratio")
+ ratio := 1.0
+ if ratioPct > 0 {
+ ratio = ratioPct / 100.0
+ }
+ maxChgKW := getSemanticValue(sim, "max_charge_power")
+ maxDisKW := getSemanticValue(sim, "max_discharge_power")
+ return plant.Control{
+ EMSMode: int(getSemanticValue(sim, "grid_mode")),
+ BatteryCmd: int(getSemanticValue(sim, "battery_cmd")),
+ BatterySetpointW: getSemanticValue(sim, "battery_setpoint"),
+ PVLimitW: getSemanticValue(sim, "pv_cmd"),
+ PVLimitEnable: int(getSemanticValue(sim, "export_limit")) == plant.EnableOn,
+ ExportLimitEnable: int(getSemanticValue(sim, "export_limit")) == plant.EnableOn,
+ ActiveLimitEnable: int(getSemanticValue(sim, "pv_limit_enable")) == plant.EnableOn,
+ ActiveLimitRatio: ratio,
+ MaxChargeW: maxChgKW * 1000,
+ MaxDischargeW: maxDisKW * 1000,
+ MinSOC: getSemanticValue(sim, "battery_min_soc"),
+ MaxSOC: getSemanticValue(sim, "battery_max_soc"),
+ }
+}
+
+func stepPhysicalPlant(sim *Simulator, simTime time.Time, scenario string, loadW float64) {
+ if sim == nil || sim.Server == nil {
+ return
+ }
+ p := ensurePlant(sim)
+ if rated := getSemanticValue(sim, "nominal_power"); rated > 0 {
+ p.Params.RatedACW = rated * 1000
+ p.Params.RatedPVW = rated * 1000
+ }
+ if sim.BatteryCapacityKWh > 0 {
+ p.Params.BatteryCapacityWh = sim.BatteryCapacityKWh * 1000
+ }
+ if soc := getSemanticValue(sim, "battery_soc"); soc > 0 {
+ p.SeedSOC(soc)
+ }
+ if loadW <= 0 {
+ loadW = plant.LoadForScenario(simTime, scenario)
+ }
+ out := p.Step(plant.Inputs{
+ Time: simTime,
+ LoadW: loadW,
+ Weather: plant.WeatherForScenario(simTime, scenario),
+ Control: controlFromRegisters(sim),
+ })
+ applyPlantOutputs(sim, out)
+}
+
+func applyPlantOutputs(sim *Simulator, out plant.Outputs) {
+ for name, value := range plant.SemanticValues(out) {
+ if name == "meter_power" || name == "meter_l1_power" || name == "meter_l2_power" || name == "meter_l3_power" {
+ if isSimulatorSiteMeter(sim.ID) {
+ continue
+ }
+ }
+ setSemanticValue(sim, name, value)
+ }
+ sim.mu.Lock()
+ sim.internalSOC = out.SOC
+ sim.socInitialized = true
+ sim.mu.Unlock()
+}
+
func generateValues(sim *Simulator, simTime time.Time) {
// Handle different protocols
switch sim.Protocol {
@@ -3459,12 +3550,17 @@ func generateValues(sim *Simulator, simTime time.Time) {
}
// Modbus inverter simulation
- hour := float64(simTime.Hour()) + float64(simTime.Minute())/60.0
-
sim.Automation.mu.RLock()
scenario := sim.Automation.Scenario
sim.Automation.mu.RUnlock()
+ if scenario == "physical" {
+ stepPhysicalPlant(sim, simTime, scenario, 0)
+ return
+ }
+
+ hour := float64(simTime.Hour()) + float64(simTime.Minute())/60.0
+
var solarPower, loadPower float64
switch scenario {
@@ -4250,7 +4346,11 @@ func runSiteSimulationLoop(site *Site, stopChan chan struct{}) {
if simScenario == "" {
simScenario = scenario
}
- generateValuesForSite(sim, simTime, simScenario)
+ if simScenario == "physical" {
+ stepPhysicalPlant(sim, simTime, simScenario, 0)
+ } else {
+ generateValuesForSite(sim, simTime, simScenario)
+ }
}
}
@@ -4269,7 +4369,11 @@ func runSiteSimulationLoop(site *Site, stopChan chan struct{}) {
if meterScenario == "" {
meterScenario = scenario
}
- generateValuesForSite(meterSim, simTime, meterScenario)
+ if meterScenario == "physical" {
+ stepPhysicalPlant(meterSim, simTime, meterScenario, 0)
+ } else {
+ generateValuesForSite(meterSim, simTime, meterScenario)
+ }
}
}
}
@@ -4300,6 +4404,11 @@ func generateValuesForSite(sim *Simulator, simTime time.Time, scenario string) {
return
}
+ if scenario == "physical" {
+ stepPhysicalPlant(sim, simTime, scenario, 0)
+ return
+ }
+
// Modbus inverter simulation
hour := float64(simTime.Hour()) + float64(simTime.Minute())/60.0
@@ -4486,8 +4595,8 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) {
// If meter is an inverter, read its PV/Battery values and include them in the calculation
// Distribute PV/battery contributions using phase factors
if meterCategory == "inverter" {
- pvPower := getSemanticValue(meterSim, "pv_power") // PV power
- batteryPower := getBatterySignedPower(meterSim) // Signed battery power
+ pvPower := getSemanticValue(meterSim, "pv_power") // PV power
+ batteryPower := getBatterySignedPower(meterSim) // Signed battery power
// Distribute PV generation (reduces load) and battery across phases
pvBatteryNet := -pvPower + batteryPower
@@ -4546,7 +4655,7 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) {
if simHours > 0 {
// Update battery SOC and PV energy for all inverters using the central clock
- if meterCategory == "inverter" && meterSim.Server != nil {
+ if meterCategory == "inverter" && meterSim.Server != nil && !simulatorUsesPlant(meterSim) {
updateBatterySOC(meterSim, simHours, meterCapacityKWh)
accumulatePVEnergy(meterSim, simHours)
updateBatteryFlags(meterSim)
@@ -4571,6 +4680,9 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) {
if !running || !deviceOn || category != "inverter" || protocol != "modbus" || server == nil {
continue
}
+ if simulatorUsesPlant(sim) {
+ continue
+ }
updateBatterySOC(sim, simHours, capacityKWh)
accumulatePVEnergy(sim, simHours)
updateBatteryFlags(sim)
diff --git a/cmd/simulator/static/js/components.js b/cmd/simulator/static/js/components.js
index 00cb272..4c99e25 100644
--- a/cmd/simulator/static/js/components.js
+++ b/cmd/simulator/static/js/components.js
@@ -248,7 +248,9 @@ document.addEventListener('alpine:init', () => {
'overcast': 'βοΈ Overcast',
'rainy': 'π§οΈ Rainy',
'night': 'π Night',
- 'peak_load': 'β‘ Peak Load'
+ 'peak_load': 'β‘ Peak Load',
+ 'battery_test': 'π Battery Cycle',
+ 'physical': 'πΏ Physical plant'
},
get connectedSiteId() {
@@ -320,7 +322,8 @@ document.addEventListener('alpine:init', () => {
{ value: 'rainy', label: 'π§οΈ Rainy Day' },
{ value: 'night', label: 'π Night Mode' },
{ value: 'peak_load', label: 'β‘ Peak Load' },
- { value: 'battery_test', label: 'π Battery Cycle' }
+ { value: 'battery_test', label: 'π Battery Cycle' },
+ { value: 'physical', label: 'πΏ Physical plant' }
],
speeds: [
@@ -861,7 +864,8 @@ document.addEventListener('alpine:init', () => {
{ value: 'overcast', label: 'βοΈ Overcast' },
{ value: 'rainy', label: 'π§οΈ Rainy' },
{ value: 'night', label: 'π Night' },
- { value: 'peak_load', label: 'β‘ Peak Load' }
+ { value: 'peak_load', label: 'β‘ Peak Load' },
+ { value: 'physical', label: 'πΏ Physical plant' }
],
init() {
diff --git a/cmd/simulator/static/js/protocols/modbus.js b/cmd/simulator/static/js/protocols/modbus.js
index fdc5ab6..17154e3 100644
--- a/cmd/simulator/static/js/protocols/modbus.js
+++ b/cmd/simulator/static/js/protocols/modbus.js
@@ -13,7 +13,8 @@ export function modbusControls() {
{ value: 'rainy', label: 'π§οΈ Rainy Day' },
{ value: 'night', label: 'π Night Mode' },
{ value: 'peak_load', label: 'β‘ Peak Load' },
- { value: 'battery_test', label: 'π Battery Cycle' }
+ { value: 'battery_test', label: 'π Battery Cycle' },
+ { value: 'physical', label: 'πΏ Physical plant' }
],
speeds: [
diff --git a/docs/LUA_DRIVER_PLANT.md b/docs/LUA_DRIVER_PLANT.md
new file mode 100644
index 0000000..43b67b1
--- /dev/null
+++ b/docs/LUA_DRIVER_PLANT.md
@@ -0,0 +1,110 @@
+# Lua drivers and a physical plant
+
+This repository should be a **hardware stand-in** for [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers): the same Lua that polls a Sungrow on a roof should poll this process over Modbus TCP, and writes from `driver_command` should move energy through a small DAE rather than a scripted sine wave.
+
+Drivers never talk to this app's HTTP API. They talk to a device. The job of the simulator is to *be* that device, with a plant behind the register map.
+
+## Two layers
+
+```text
+ srcfl/device-drivers this repo
+ ββββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββββββ
+ β sungrow.lua β Modbus β vendor facade physical plant β
+ β driver_poll() β TCP β (addresses, (Modelica-style) β
+ β driver_command()βββββββββββΊβ scale, algebraic node β
+ β emit(pv, batt, β FC 03/04β endianness, + SoC / energy ODEs β
+ β meter) β 06/10 β input vs hold) β
+ ββββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββββββ
+ β² β
+ β host.modbus_read/write β semantic names
+ β (FTW / Blixt / hugin-agent) β pv_power, battery_soc, β¦
+```
+
+**The plant does not know Sungrow.** It knows watts, SoC, irradiance and setpoints.
+
+**The register map does not know physics.** It encodes plant outputs the way that inverter's firmware would.
+
+That split is what makes a second vendor cheap: new Lua driver β fill in `internal/modbus/devices/.go` β same plant.
+
+## Sign convention (same as the drivers)
+
+Positive watts flow **into** the site node:
+
+```text
+P_grid + P_pv + P_batt + P_load + P_ev = 0
+```
+
+| Stream | Positive means | On the wire |
+|----------|-----------------------|-------------|
+| grid | import | signed meter register |
+| pv | never (always β€ 0) | device usually reports **magnitude**; Lua negates at `emit` |
+| battery | charging | many maps are unsigned + flag bits |
+| load, ev | consumption | |
+
+If a Lua driver and this plant disagree on sign, EMS tests will look green and still be wrong. Match `blueprint/BLUEPRINT.lua`.
+
+## What βModelica-styleβ means here
+
+Not a Modelica compiler. The same modelling split:
+
+| Kind | Examples | Role |
+|------|----------|------|
+| Algebraic | Kirchhoff-style power balance; inverter clip; MPPT split | Instantaneous, no memory |
+| Differential | Battery SoC, lifetime kWh, inverter temperature lag | Integrated on the **site/automation clock** (including time acceleration) |
+| Inputs | GHI, ambient T, household load, Modbus setpoints | Exogenous |
+| Outputs | Terminal powers, SoC, Hz, string V/I | What the facade encodes |
+
+The site electrical node is the slack connection. In self-consumption the battery tries to zero the grid; in EMS/forced mode the battery follows the Lua setpoint and the grid takes the residual. Export limits curtail PV rather than fabricating meter zeros.
+
+Implementation: `internal/plant`. Scenario **Physical plant** in the UI (`physical`) steps this model and writes semantic registers.
+
+## Lua compatibility (Sungrow first)
+
+`srcfl/device-drivers` `drivers/lua/sungrow.lua` uses **host addresses** (often documented register minus one for the 13xxx control block). A failed `modbus_read` fails the **entire poll**, so every address the driver asks for must exist.
+
+Covered in this pass (see `TestSungrowLuaDriverRegisterCoverage`):
+
+| Host address | Kind | Why the driver asks |
+|--------------|------|---------------------|
+| 4990 | input STR | serial (`modbus_read(4990, 10)`) β not 4989 |
+| 4999 | input U16 | device type fingerprint (`0x0E0E` = SH8.0RT) |
+| 5000, 5007, 5010, 5016, 5241 | input | rated, temp, MPPT, PV W, Hz |
+| 5600β5607, 5740β5745 | input | meter P/V/I |
+| 12999, 13000 | input | running state + charge/discharge flags |
+| 13019β13022, 13026, 13040 | input | battery V/A/W/SoC and energy |
+| 13049β13051 | holding | EMS mode, cmd, setpoint (`driver_command`) |
+| 13057β13058, 33046β33047 | holding | SoC and power limits |
+| 13073, 13086, 13088β13089 | holding | feed-in / active power curtail |
+
+Holding and input are mirrored on the TCP server, which matters because the driver also does a 30-register **input** read at 13049 for diagnostics.
+
+## How to test a driver against this process
+
+1. Start the simulator (Docker or desktop).
+2. Create a **sungrow-hybrid** inverter; note its Modbus TCP port (desktop: 5000+, Docker: 5100+).
+3. Set scenario to **Physical plant** and start automation (or site simulation).
+4. Point FTW, [hugin-agent](https://github.com/srcfl/hugin-agent), or the device-drivers Lua harness at `host:`, unit id 1.
+5. `driver_poll` should emit `pv`, `battery`, `meter`. `driver_command("charge"|"discharge", watts)` should change SoC over accelerated time.
+
+The device-drivers repo's own tests (`drivers/tests/lua_harness`) mock the host. That is the **contract** test. This simulator is the **closed-loop** test: real TCP, real integration, physics that can refuse an impossible charge.
+
+## What is still curve-based
+
+Sunny / cloudy / β¦ still write independent PV and load sines, then a late meter = load β PV + battery. That is useful for demos. It is not a plant: the battery does not close a self-consumption loop unless something else writes `battery_cmd`.
+
+Next couplings, in order:
+
+1. Drive **all** weather scenarios through `plant.WeatherForScenario` so Lua tests do not need a special scenario.
+2. Step the plant on the **site** clock with the site's phase loads as `P_load`, one plant per inverter, grid slack on the site meter.
+3. Per-driver fixture packs: the register window each Lua file reads, generated from the driver source (same idea as `test_modbus_drivers.py`).
+4. MQTT / OCPP facades on the same plant (Ambibox, OCPP chargers) so a site with mixed protocols still conserves energy.
+
+## Package map
+
+| Path | Responsibility |
+|------|----------------|
+| `internal/plant` | DAE: balance, SoC, GHI, semantic projection |
+| `internal/modbus/devices` | Vendor addresses and encoding |
+| `internal/modbus/server.go` | Modbus TCP (what Lua actually hits) |
+| `cmd/simulator/main.go` | `physical` scenario β `stepPhysicalPlant` |
+| `srcfl/device-drivers` | Lua, host API, emit schema β **not copied here** |
diff --git a/internal/modbus/devices/sungrow.go b/internal/modbus/devices/sungrow.go
index fc64eab..f3a0fc2 100644
--- a/internal/modbus/devices/sungrow.go
+++ b/internal/modbus/devices/sungrow.go
@@ -7,6 +7,8 @@ import "github.com/srcfl/device-simulator/internal/modbus"
func SungrowRegisters() *modbus.RegisterSet {
return modbus.NewRegisterSet("sungrow", []modbus.RegisterDef{
// PV Registers (Input Registers)
+ {Address: 4990, Name: "Serial Number (Lua)", SemanticName: "serial_number_lua", Description: "Serial as addressed by srcfl/device-drivers Sungrow Lua", Unit: "", Category: "pv", DataType: modbus.STR, Scale: 1.0, Words: 10, Endianness: modbus.Big},
+ {Address: 4999, Name: "Device Type", SemanticName: "device_type", Description: "Sungrow device type (0x0E0E = SH8.0RT-V112)", Unit: "", Category: "pv", DataType: modbus.U16, Scale: 1.0, Words: 1, Endianness: modbus.Big},
{Address: 5000, Name: "Nominal Output Power", SemanticName: "nominal_power", Description: "Nominal output power", Unit: "kW", Category: "pv", DataType: modbus.U16, Scale: 0.1, Words: 1, Endianness: modbus.Big},
{Address: 5007, Name: "Inside Temperature", SemanticName: "inverter_temperature", Description: "Internal temperature", Unit: "Β°C", Category: "pv", DataType: modbus.I16, Scale: 0.1, Words: 1, Endianness: modbus.Big},
{Address: 5010, Name: "MPPT 1 Voltage", SemanticName: "pv1_voltage", Description: "MPPT 1 Voltage", Unit: "V", Category: "pv", DataType: modbus.U16, Scale: 0.1, Words: 1, Endianness: modbus.Big},
@@ -16,8 +18,11 @@ func SungrowRegisters() *modbus.RegisterSet {
{Address: 5016, Name: "PV Power", SemanticName: "pv_power", Description: "Total DC power", Unit: "W", Category: "pv", DataType: modbus.U32, Scale: 1.0, Words: 2, Endianness: modbus.Little},
{Address: 13002, Name: "Total PV Generation", SemanticName: "total_pv_gen", Description: "Total PV generation", Unit: "Wh", Category: "pv", DataType: modbus.U32, Scale: 100.0, Words: 2, Endianness: modbus.Little},
{Address: 13073, Name: "PV Command", SemanticName: "pv_cmd", Description: "PV power limit", Unit: "W", Category: "pv", DataType: modbus.U16, Scale: 1.0, Words: 2, Endianness: modbus.Big, UseHolding: true},
+ {Address: 13088, Name: "Active Power Limit Enable", SemanticName: "pv_limit_enable", Description: "170=on; 85=off", Unit: "", Category: "pv", DataType: modbus.U16, Scale: 1.0, Words: 1, Endianness: modbus.Big, UseHolding: true},
+ {Address: 13089, Name: "Active Power Limit Ratio", SemanticName: "pv_limit_ratio", Description: "Active power limit (0.1% units)", Unit: "%", Category: "pv", DataType: modbus.U16, Scale: 0.1, Words: 1, Endianness: modbus.Big, UseHolding: true},
// Battery Registers
+ {Address: 12999, Name: "Running State", SemanticName: "running_state", Description: "Sungrow running state (Lua host address 12999)", Unit: "", Category: "battery", DataType: modbus.U16, Scale: 1.0, Words: 1, Endianness: modbus.Big},
{Address: 13000, Name: "System State Flags", SemanticName: "battery_flags", Description: "Inverter running states", Unit: "", Category: "battery", DataType: modbus.U16, Scale: 1.0, Words: 1, Endianness: modbus.Big},
{Address: 13019, Name: "Battery Voltage", SemanticName: "battery_voltage", Description: "Battery voltage", Unit: "V", Category: "battery", DataType: modbus.U16, Scale: 0.1, Words: 1, Endianness: modbus.Big},
{Address: 13020, Name: "Battery Current", SemanticName: "battery_current", Description: "Battery current", Unit: "A", Category: "battery", DataType: modbus.U16, Scale: 0.1, Words: 1, Endianness: modbus.Big},
@@ -58,6 +63,7 @@ func SungrowRegisters() *modbus.RegisterSet {
// Serial Number (STR at address 4989, 10 registers = 20 chars)
{Address: 4989, Name: "Serial Number", SemanticName: "serial_number", Description: "Device serial number", Unit: "", Category: "pv", DataType: modbus.STR, Scale: 1.0, Words: 10, Endianness: modbus.Big},
}, map[uint16]int64{
+ 4999: 0x0E0E, // SH8.0RT-V112, the type the Lua driver fingerprints
5000: 100,
5241: 50,
5016: 0,
@@ -65,11 +71,14 @@ func SungrowRegisters() *modbus.RegisterSet {
13022: 78,
5600: 0,
13052: 0,
+ 12999: 1, // running
13000: 0,
13049: 0,
13050: 204,
13051: 0,
13073: 10000,
+ 13088: 85, // active power limit off
+ 13089: 100, // 100% (scale 0.1 β raw 1000)
13086: 170,
33046: 500,
33047: 500,
diff --git a/internal/modbus/devices/sungrow_lua_compat_test.go b/internal/modbus/devices/sungrow_lua_compat_test.go
new file mode 100644
index 0000000..3875c66
--- /dev/null
+++ b/internal/modbus/devices/sungrow_lua_compat_test.go
@@ -0,0 +1,58 @@
+package devices
+
+import (
+ "testing"
+
+ "github.com/srcfl/device-simulator/internal/modbus"
+)
+
+// Addresses the srcfl/device-drivers Sungrow Lua driver actually reads or writes.
+// The simulator must serve these so a live driver poll does not fail the whole poll.
+func TestSungrowLuaDriverRegisterCoverage(t *testing.T) {
+ rs := SungrowRegisters()
+ need := []uint16{
+ 4990, 4999, 5000, 5007, 5010, 5016, 5241, 5600, 5602, 5740, 5743,
+ 12999, 13000, 13002, 13019, 13026, 13036, 13040, 13045,
+ 13049, 13050, 13051, 13057, 13073, 13086, 13088, 13089,
+ 33046, 33047,
+ }
+ for _, addr := range need {
+ if rs.Lookup[addr] == nil {
+ t.Errorf("Sungrow map missing address %d (required by drivers/lua/sungrow.lua)", addr)
+ }
+ }
+ if rs.Semantic["device_type"] == nil {
+ t.Error("missing semantic device_type")
+ }
+ if rs.Semantic["running_state"] == nil {
+ t.Error("missing semantic running_state")
+ }
+ if rs.Semantic["pv_limit_enable"] == nil || rs.Semantic["pv_limit_ratio"] == nil {
+ t.Error("missing active power limit holding registers 13088/13089")
+ }
+}
+
+func TestSungrowLuaFingerprintAndSerial(t *testing.T) {
+ s := modbus.NewServer(65535)
+ rs := SungrowRegisters()
+ s.SetSerialNumber("INVSIM01")
+ s.InitDefaultsFromSet(rs)
+
+ dev := s.GetInputRegisters(4999, 1)
+ if len(dev) != 1 || dev[0] != 0x0E0E {
+ t.Fatalf("device type at 4999 = %v, want [0x0E0E] so sungrow.lua detect_model sees an SH hybrid", dev)
+ }
+
+ sn := s.GetInputRegisters(4990, 10)
+ if len(sn) != 10 {
+ t.Fatalf("serial word count %d", len(sn))
+ }
+ if sn[0] == 0 && sn[1] == 0 {
+ t.Fatal("Lua serial window at 4990 is empty; sungrow.lua reads 4990 not 4989")
+ }
+
+ run := s.GetInputRegisters(12999, 1)
+ if len(run) != 1 || run[0] == 0 {
+ t.Fatalf("running state at 12999 = %v, want non-zero", run)
+ }
+}
diff --git a/internal/modbus/registers.go b/internal/modbus/registers.go
index f17312f..634ae5f 100644
--- a/internal/modbus/registers.go
+++ b/internal/modbus/registers.go
@@ -14,9 +14,9 @@ const (
I16
U32
I32
- F32 // IEEE 754 single-precision float
- STR // Multi-word ASCII string
- U64 // Unsigned 64-bit integer (4 words)
+ F32 // IEEE 754 single-precision float
+ STR // Multi-word ASCII string
+ U64 // Unsigned 64-bit integer (4 words)
)
func (d DataType) String() string {
@@ -55,9 +55,9 @@ type RegisterDef struct {
type RegisterSet struct {
Name string
Definitions []RegisterDef
- Lookup map[uint16]*RegisterDef // by address
- Semantic map[string]*RegisterDef // by semantic name
- Defaults map[uint16]int64 // default register values
+ Lookup map[uint16]*RegisterDef // by address
+ Semantic map[string]*RegisterDef // by semantic name
+ Defaults map[uint16]int64 // default register values
}
// NewRegisterSet creates a RegisterSet from a slice of definitions and defaults.
@@ -84,6 +84,8 @@ func NewRegisterSet(name string, defs []RegisterDef, defaults map[uint16]int64)
// Categories: pv, battery, meter, grid, control
var RegisterDefinitions = []RegisterDef{
// PV Registers (Sungrow SH Hybrid - Input Registers)
+ {Address: 4990, Name: "Serial Number (Lua)", SemanticName: "serial_number_lua", Description: "Sungrow serial as addressed by srcfl/device-drivers (host 4990)", Unit: "", Category: "pv", DataType: STR, Scale: 1.0, Words: 10, Endianness: Big, UseHolding: false},
+ {Address: 4999, Name: "Device Type", SemanticName: "device_type", Description: "Sungrow device type (0x0E0E = SH8.0RT-V112)", Unit: "", Category: "pv", DataType: U16, Scale: 1.0, Words: 1, Endianness: Big, UseHolding: false},
{Address: 5000, Name: "Nominal Output Power", SemanticName: "nominal_power", Description: "Nominal output power of the inverter", Unit: "kW", Category: "pv", DataType: U16, Scale: 0.1, Words: 1, Endianness: Big, UseHolding: false},
{Address: 5007, Name: "Inside Temperature", SemanticName: "", Description: "Internal temperature of the inverter", Unit: "Β°C", Category: "pv", DataType: I16, Scale: 0.1, Words: 1, Endianness: Big, UseHolding: false},
{Address: 5010, Name: "MPPT 1 Voltage", SemanticName: "", Description: "Maximum Power Point Tracker 1 Voltage", Unit: "V", Category: "pv", DataType: U16, Scale: 0.1, Words: 1, Endianness: Big, UseHolding: false},
@@ -93,8 +95,11 @@ var RegisterDefinitions = []RegisterDef{
{Address: 5016, Name: "PV Power", SemanticName: "pv_power", Description: "Total DC power", Unit: "W", Category: "pv", DataType: U32, Scale: 1.0, Words: 2, Endianness: Little, UseHolding: false},
{Address: 13002, Name: "Total PV Generation", SemanticName: "total_pv_gen", Description: "Total photovoltaic generation", Unit: "Wh", Category: "pv", DataType: U32, Scale: 100.0, Words: 2, Endianness: Little, UseHolding: false},
{Address: 13073, Name: "PV Command", SemanticName: "pv_cmd", Description: "PV power limit", Unit: "W", Category: "pv", DataType: U16, Scale: 1.0, Words: 2, Endianness: Big, UseHolding: true},
+ {Address: 13088, Name: "Active Power Limit Enable", SemanticName: "pv_limit_enable", Description: "170=on; 85=off (Lua host address, documented 13089)", Unit: "", Category: "pv", DataType: U16, Scale: 1.0, Words: 1, Endianness: Big, UseHolding: true},
+ {Address: 13089, Name: "Active Power Limit Ratio", SemanticName: "pv_limit_ratio", Description: "Active power limit in 0.1% units (1000=100%)", Unit: "%", Category: "pv", DataType: U16, Scale: 0.1, Words: 1, Endianness: Big, UseHolding: true},
// Battery Registers (Sungrow SH Hybrid - Input Registers)
+ {Address: 12999, Name: "Running State", SemanticName: "running_state", Description: "Sungrow documented running state (host address 12999)", Unit: "", Category: "battery", DataType: U16, Scale: 1.0, Words: 1, Endianness: Big, UseHolding: false},
{Address: 13000, Name: "System State Flags", SemanticName: "battery_flags", Description: "Bitmask of inverter running states", Unit: "", Category: "battery", DataType: U16, Scale: 1.0, Words: 1, Endianness: Big, UseHolding: false},
{Address: 13019, Name: "Battery Voltage", SemanticName: "", Description: "Battery voltage", Unit: "V", Category: "battery", DataType: U16, Scale: 0.1, Words: 1, Endianness: Big, UseHolding: false},
{Address: 13020, Name: "Battery Current", SemanticName: "", Description: "Battery current", Unit: "A", Category: "battery", DataType: U16, Scale: 0.1, Words: 1, Endianness: Big, UseHolding: false},
@@ -215,11 +220,15 @@ var DefaultValues = map[uint16]int64{
13050: 204, // Charge/Discharge Command (204=Stop)
13051: 0, // Battery Setpoint
13073: 10000, // PV Command Power
+ 13088: 85, // Active power limit off
+ 13089: 100, // 100%
13086: 170, // Export Limit Enable (170=Enabled)
- 33046: 500, // Max Charge Power (5kW)
- 33047: 500, // Max Discharge Power (5kW)
+ 4999: 0x0E0E,
+ 12999: 1,
+ 33046: 500, // Max Charge Power (5kW)
+ 33047: 500, // Max Discharge Power (5kW)
13057: 100, // Battery Max SoC (100.0%)
- 13058: 0, // Battery Min SoC (0.0%)
+ 13058: 0, // Battery Min SoC (0.0%)
// Energy totals will be randomized at startup
13036: 150000, // Total Import Energy (Wh)
@@ -443,6 +452,8 @@ func SetSerialNumber(serial string, setFunc func(addr uint16, values []uint16))
}
setFunc(4989, registers)
+ // srcfl/device-drivers Sungrow Lua reads serial at host address 4990.
+ setFunc(4990, registers)
// SDM630: Write numeric serial to register 64512/0xFC00 (U32, Big Endian)
// If serial is numeric, use it directly; otherwise hash the string
diff --git a/internal/plant/plant.go b/internal/plant/plant.go
new file mode 100644
index 0000000..fe7c88d
--- /dev/null
+++ b/internal/plant/plant.go
@@ -0,0 +1,474 @@
+// Package plant is a lumped, Modelica-style electrical energy system:
+// algebraic power balance at the site node, and a few explicit ODEs
+// (battery SoC, lifetime energy, inverter temperature).
+//
+// Sign convention matches srcfl/device-drivers Lua drivers:
+// positive watts flow INTO the site electrical node.
+//
+// P_grid + P_pv + P_batt + P_load + P_ev = 0
+//
+// grid positive = importing, negative = exporting
+// pv always β€ 0 (generation)
+// battery positive = charging, negative = discharging
+// load/ev positive = consumption
+package plant
+
+import (
+ "math"
+ "time"
+)
+
+const (
+ CmdCharge = 170
+ CmdDischarge = 187
+ CmdStop = 204
+
+ ModeSelfConsumption = 0
+ ModeForced = 2
+ ModeEMS = 3
+
+ EnableOn = 170 // 0xAA
+ EnableOff = 85 // 0x55
+)
+
+// Params are slowly varying physical ratings. They are not the live control
+// setpoints written by a Lua driver.
+type Params struct {
+ RatedACW float64 // inverter AC clip, W
+ RatedPVW float64 // STC array rating, W
+ BatteryCapacityWh float64
+ BatteryNominalV float64
+ ChargeEfficiency float64 // 0-1, energy stored / energy taken from bus
+ DischargeEfficiency float64 // 0-1, energy delivered / energy taken from pack
+ MaxChargeW float64
+ MaxDischargeW float64
+ TempCoeffPerK float64 // PV power temp coefficient (negative), 1/K
+ InverterEta float64 // DCβAC, constant for now
+ ThermalTauS float64 // inverter temperature first-order lag
+ AmbientC float64
+}
+
+// Control is the live command surface a driver writes over Modbus.
+type Control struct {
+ EMSMode int
+ BatteryCmd int
+ BatterySetpointW float64 // magnitude, W
+ PVLimitW float64 // feed-in / PV watt cap; 0 = no extra cap
+ PVLimitEnable bool
+ ActiveLimitEnable bool
+ ActiveLimitRatio float64 // 0-1 of rated AC
+ ExportLimitEnable bool
+ MaxChargeW float64 // live, from holding regs; 0 = use Params
+ MaxDischargeW float64
+ MinSOC float64 // 0-100
+ MaxSOC float64 // 0-100
+}
+
+// Weather is the exogenous environment.
+type Weather struct {
+ GHIWm2 float64
+ AmbientC float64
+}
+
+// Inputs are everything the plant needs for one step besides its own state.
+type Inputs struct {
+ Time time.Time
+ LoadW float64
+ EVW float64
+ Weather Weather
+ Control Control
+}
+
+// MPPT is one DC string, derived from total DC power.
+type MPPT struct {
+ V float64
+ A float64
+ W float64
+}
+
+// Outputs are the quantities a vendor register map should project.
+type Outputs struct {
+ GridW float64
+ PVW float64 // β€ 0
+ BatteryW float64
+ LoadW float64
+ EVW float64
+ PVDCW float64
+ MPPTs [2]MPPT
+ BatteryV float64
+ BatteryA float64
+ SOC float64
+ GridHz float64
+ InverterC float64
+ BatteryC float64
+ ResidualW float64 // |power-balance error|; should be ~0
+
+ TotalPVWh float64
+ TotalChargeWh float64
+ TotalDischargeWh float64
+ TotalImportWh float64
+ TotalExportWh float64
+}
+
+// State is the differential part of the model.
+type State struct {
+ SOC float64
+ InverterC float64
+ TotalPVWh float64
+ TotalChargeWh float64
+ TotalDischargeWh float64
+ TotalImportWh float64
+ TotalExportWh float64
+}
+
+// Plant is one hybrid inverter + battery + on-board meter.
+type Plant struct {
+ Params Params
+ State State
+ lastT time.Time
+ haveT bool
+}
+
+func DefaultParams() Params {
+ return Params{
+ RatedACW: 8000,
+ RatedPVW: 8000,
+ BatteryCapacityWh: 10000,
+ BatteryNominalV: 48,
+ ChargeEfficiency: 0.95,
+ DischargeEfficiency: 0.95,
+ MaxChargeW: 5000,
+ MaxDischargeW: 5000,
+ TempCoeffPerK: -0.004,
+ InverterEta: 0.97,
+ ThermalTauS: 300,
+ AmbientC: 20,
+ }
+}
+
+func New(p Params) *Plant {
+ if p.RatedACW <= 0 {
+ p = DefaultParams()
+ }
+ return &Plant{
+ Params: p,
+ State: State{
+ SOC: 50,
+ InverterC: p.AmbientC + 10,
+ },
+ }
+}
+
+// Step advances the plant to in.Time. dt is taken from the previous call so
+// the site clock (including time acceleration) is the only integrator clock.
+func (p *Plant) Step(in Inputs) Outputs {
+ dt := 0.0
+ if p.haveT && !in.Time.IsZero() {
+ dt = in.Time.Sub(p.lastT).Seconds()
+ }
+ if dt < 0 {
+ dt = 0
+ }
+ // Cap a single step so a paused UI does not dump days of energy.
+ // Time acceleration uses the simulated clock; 3600Γ with a 1s tick is 1h.
+ if dt > 6*3600 {
+ dt = 6 * 3600
+ }
+ p.lastT = in.Time
+ p.haveT = true
+ return p.step(dt, in)
+}
+
+// SeedSOC sets the pack SoC before the first step, so a device that already
+// has a register value does not jump back to the plant default.
+func (p *Plant) SeedSOC(soc float64) {
+ if p.haveT {
+ return
+ }
+ if soc < 0 {
+ soc = 0
+ }
+ if soc > 100 {
+ soc = 100
+ }
+ p.State.SOC = soc
+}
+
+func (p *Plant) step(dt float64, in Inputs) Outputs {
+ pr := p.Params
+ ctrl := in.Control
+ ambient := in.Weather.AmbientC
+ if ambient == 0 {
+ ambient = pr.AmbientC
+ }
+
+ maxChg := firstPositive(ctrl.MaxChargeW, pr.MaxChargeW)
+ maxDis := firstPositive(ctrl.MaxDischargeW, pr.MaxDischargeW)
+ minSOC := ctrl.MinSOC
+ maxSOC := ctrl.MaxSOC
+ if maxSOC <= 0 || maxSOC > 100 {
+ maxSOC = 100
+ }
+ if minSOC < 0 || minSOC >= maxSOC {
+ minSOC = 0
+ }
+
+ cellC := ambient + 5
+ ghi := math.Max(0, in.Weather.GHIWm2)
+ pAvailDC := (ghi / 1000.0) * pr.RatedPVW * (1 + pr.TempCoeffPerK*(cellC-25))
+ if pAvailDC < 0 {
+ pAvailDC = 0
+ }
+
+ eta := pr.InverterEta
+ if eta <= 0 || eta > 1 {
+ eta = 0.97
+ }
+ pAvailAC := pAvailDC * eta
+ if pAvailAC > pr.RatedACW {
+ pAvailAC = pr.RatedACW
+ }
+
+ cap := pAvailAC
+ if ctrl.ActiveLimitEnable && ctrl.ActiveLimitRatio > 0 {
+ cap = math.Min(cap, pr.RatedACW*ctrl.ActiveLimitRatio)
+ }
+ if ctrl.PVLimitEnable && ctrl.PVLimitW > 0 {
+ cap = math.Min(cap, ctrl.PVLimitW)
+ }
+ pPV := -cap // generation is negative at the site node
+
+ pLoad := math.Max(0, in.LoadW)
+ pEV := math.Max(0, in.EVW)
+
+ pBatt := batterySetpoint(ctrl, pPV, pLoad, pEV)
+ pBatt = clip(pBatt, -maxDis, maxChg)
+ pBatt = applySOCLimits(pBatt, p.State.SOC, minSOC, maxSOC)
+
+ pGrid := -pPV - pBatt - pLoad - pEV
+
+ if ctrl.ExportLimitEnable && ctrl.PVLimitW > 0 && pGrid < -ctrl.PVLimitW {
+ // Curtail PV to honour a feed-in cap. Battery already committed.
+ // P_grid_target = -PVLimitW (max export)
+ // -pPV = pGrid_target + pBatt + pLoad + pEV
+ wantPV := -(-ctrl.PVLimitW + pBatt + pLoad + pEV)
+ if wantPV > 0 {
+ wantPV = 0
+ }
+ if wantPV < pPV { // pPV is more negative than allowed
+ pPV = wantPV
+ }
+ pGrid = -pPV - pBatt - pLoad - pEV
+ }
+
+ // Integrate SoC. Charging: bus power enters the pack through eta_c.
+ // Discharging: pack energy leaves through 1/eta_d to deliver pBatt.
+ hours := dt / 3600.0
+ capWh := pr.BatteryCapacityWh
+ if capWh <= 0 {
+ capWh = 10000
+ }
+ if hours > 0 && pBatt != 0 {
+ var dWh float64
+ if pBatt > 0 {
+ dWh = pBatt * hours * clamp01(pr.ChargeEfficiency)
+ p.State.TotalChargeWh += pBatt * hours
+ } else {
+ dWh = pBatt * hours / clamp01(pr.DischargeEfficiency) // pBatt negative
+ p.State.TotalDischargeWh += -pBatt * hours
+ }
+ p.State.SOC = clip(p.State.SOC+(dWh/capWh)*100.0, minSOC, maxSOC)
+ }
+
+ if hours > 0 {
+ p.State.TotalPVWh += -pPV * hours
+ if pGrid > 0 {
+ p.State.TotalImportWh += pGrid * hours
+ } else if pGrid < 0 {
+ p.State.TotalExportWh += -pGrid * hours
+ }
+ }
+
+ // First-order inverter temperature from conversion losses.
+ lossW := pAvailDC - (-pPV)
+ if lossW < 0 {
+ lossW = 0
+ }
+ tTarget := ambient + 10 + 20*(lossW/math.Max(pr.RatedACW, 1))
+ tau := pr.ThermalTauS
+ if tau < 1 {
+ tau = 1
+ }
+ if dt > 0 {
+ alpha := 1 - math.Exp(-dt/tau)
+ p.State.InverterC += (tTarget - p.State.InverterC) * alpha
+ }
+
+ vBatt := pr.BatteryNominalV
+ if vBatt <= 0 {
+ vBatt = 48
+ }
+ aBatt := 0.0
+ if pBatt != 0 {
+ aBatt = math.Abs(pBatt) / vBatt
+ }
+
+ mppts := splitMPPT(-pPV, ghi)
+
+ hz := 50.0
+ if !in.Time.IsZero() {
+ hz = 50.0 + 0.04*math.Sin(float64(in.Time.Second())*0.1)
+ }
+
+ out := Outputs{
+ GridW: pGrid,
+ PVW: pPV,
+ BatteryW: pBatt,
+ LoadW: pLoad,
+ EVW: pEV,
+ PVDCW: pAvailDC,
+ MPPTs: mppts,
+ BatteryV: vBatt,
+ BatteryA: aBatt,
+ SOC: p.State.SOC,
+ GridHz: hz,
+ InverterC: p.State.InverterC,
+ BatteryC: ambient + 5,
+ ResidualW: pGrid + pPV + pBatt + pLoad + pEV,
+ TotalPVWh: p.State.TotalPVWh,
+ TotalChargeWh: p.State.TotalChargeWh,
+ TotalDischargeWh: p.State.TotalDischargeWh,
+ TotalImportWh: p.State.TotalImportWh,
+ TotalExportWh: p.State.TotalExportWh,
+ }
+ return out
+}
+
+func batterySetpoint(ctrl Control, pPV, pLoad, pEV float64) float64 {
+ switch ctrl.EMSMode {
+ case ModeForced, ModeEMS:
+ switch ctrl.BatteryCmd {
+ case CmdCharge:
+ return math.Abs(ctrl.BatterySetpointW)
+ case CmdDischarge:
+ return -math.Abs(ctrl.BatterySetpointW)
+ default:
+ return 0
+ }
+ default:
+ // Self-consumption: battery is the slack that tries to zero the grid.
+ return -pPV - pLoad - pEV
+ }
+}
+
+func applySOCLimits(pBatt, soc, minSOC, maxSOC float64) float64 {
+ if pBatt > 0 && soc >= maxSOC-0.05 {
+ return 0
+ }
+ if pBatt < 0 && soc <= minSOC+0.05 {
+ return 0
+ }
+ return pBatt
+}
+
+func splitMPPT(pvPosW, ghi float64) [2]MPPT {
+ if pvPosW <= 0 {
+ return [2]MPPT{}
+ }
+ v := 300.0 + 80.0*(ghi/1000.0)
+ if v < 80 {
+ v = 80
+ }
+ half := pvPosW / 2
+ a := half / v
+ m := MPPT{V: v, A: a, W: half}
+ return [2]MPPT{m, m}
+}
+
+func clip(v, lo, hi float64) float64 {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+
+func clamp01(v float64) float64 {
+ if v <= 0.05 {
+ return 0.05
+ }
+ if v > 1 {
+ return 1
+ }
+ return v
+}
+
+func firstPositive(a, b float64) float64 {
+ if a > 0 {
+ return a
+ }
+ return b
+}
+
+// GHIClearSky is a simple elevation sine between sunrise and sunset, W/mΒ².
+func GHIClearSky(t time.Time, peakWm2, sunriseH, sunsetH float64) float64 {
+ if peakWm2 <= 0 {
+ peakWm2 = 1000
+ }
+ hour := float64(t.Hour()) + float64(t.Minute())/60.0 + float64(t.Second())/3600.0
+ if hour < sunriseH || hour > sunsetH {
+ return 0
+ }
+ day := sunsetH - sunriseH
+ if day <= 0 {
+ return 0
+ }
+ return peakWm2 * math.Sin((hour-sunriseH)/day*math.Pi)
+}
+
+// WeatherForScenario maps the existing UI weather names onto GHI.
+func WeatherForScenario(t time.Time, scenario string) Weather {
+ ghi := GHIClearSky(t, 1000, 6, 20)
+ switch scenario {
+ case "cloudy":
+ ghi *= 0.45 * (0.7 + 0.3*math.Sin(hourAngle(t)*3))
+ case "overcast":
+ ghi *= 0.15
+ case "rainy":
+ ghi *= 0.05
+ case "night":
+ ghi = 0
+ case "peak_load":
+ // still a sunny irradiance; load is applied separately
+ case "physical", "sunny", "battery_test", "":
+ // clear sky
+ }
+ return Weather{GHIWm2: math.Max(0, ghi), AmbientC: 20}
+}
+
+func hourAngle(t time.Time) float64 {
+ return float64(t.Hour()) + float64(t.Minute())/60.0
+}
+
+// LoadForScenario is a coarse household load in watts. Site aggregation can
+// replace this with its own phase loads.
+func LoadForScenario(t time.Time, scenario string) float64 {
+ hour := hourAngle(t)
+ load := 800.0
+ if (hour >= 7 && hour <= 9) || (hour >= 17 && hour <= 22) {
+ load = 2000
+ }
+ switch scenario {
+ case "peak_load":
+ return 4500
+ case "night":
+ return 600
+ case "rainy":
+ return 1500
+ case "battery_test":
+ return 1500
+ }
+ return load
+}
diff --git a/internal/plant/plant_test.go b/internal/plant/plant_test.go
new file mode 100644
index 0000000..6cea612
--- /dev/null
+++ b/internal/plant/plant_test.go
@@ -0,0 +1,147 @@
+package plant
+
+import (
+ "math"
+ "testing"
+ "time"
+)
+
+func TestPowerBalanceSelfConsumption(t *testing.T) {
+ p := New(DefaultParams())
+ p.State.SOC = 50
+ start := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
+ _ = p.Step(Inputs{Time: start, LoadW: 1200, Weather: Weather{GHIWm2: 400}})
+ out := p.Step(Inputs{
+ Time: start.Add(time.Second),
+ LoadW: 1200,
+ Weather: Weather{GHIWm2: 400},
+ Control: Control{EMSMode: ModeSelfConsumption, MaxSOC: 100},
+ })
+ if math.Abs(out.ResidualW) > 1e-6 {
+ t.Fatalf("power residual %v, want ~0", out.ResidualW)
+ }
+ if out.PVW >= 0 {
+ t.Fatalf("PV should be negative generation, got %v", out.PVW)
+ }
+ if math.Abs(out.GridW) > 50 {
+ t.Fatalf("self-consumption should nearly zero the grid, got %v (pv=%v batt=%v)", out.GridW, out.PVW, out.BatteryW)
+ }
+ if out.BatteryW <= 0 {
+ t.Fatalf("excess PV should charge the battery, got %v", out.BatteryW)
+ }
+}
+
+func TestForcedDischargeRespectsSignConvention(t *testing.T) {
+ p := New(DefaultParams())
+ p.State.SOC = 80
+ start := time.Date(2026, 6, 21, 2, 0, 0, 0, time.UTC)
+ _ = p.Step(Inputs{Time: start, LoadW: 800, Weather: Weather{}})
+ out := p.Step(Inputs{
+ Time: start.Add(time.Second),
+ LoadW: 800,
+ Control: Control{
+ EMSMode: ModeEMS,
+ BatteryCmd: CmdDischarge,
+ BatterySetpointW: 2000,
+ MinSOC: 10,
+ MaxSOC: 100,
+ },
+ })
+ if out.BatteryW >= 0 {
+ t.Fatalf("discharge must be negative battery power, got %v", out.BatteryW)
+ }
+ if math.Abs(out.BatteryW+2000) > 1 {
+ t.Fatalf("battery power %v, want -2000", out.BatteryW)
+ }
+ // Grid + PV(0) + Batt(-2000) + Load(800) = 0 β grid = 1200 import
+ if math.Abs(out.GridW-1200) > 1 {
+ t.Fatalf("grid %v, want 1200 import", out.GridW)
+ }
+}
+
+func TestSOCStopsAtMax(t *testing.T) {
+ p := New(DefaultParams())
+ p.State.SOC = 99.99
+ start := time.Now()
+ _ = p.Step(Inputs{Time: start})
+ out := p.Step(Inputs{
+ Time: start.Add(time.Second),
+ LoadW: 0,
+ Weather: Weather{GHIWm2: 1000},
+ Control: Control{EMSMode: ModeSelfConsumption, MaxSOC: 100},
+ })
+ if out.BatteryW != 0 {
+ t.Fatalf("full battery must refuse charge, got %v", out.BatteryW)
+ }
+}
+
+func TestSOCIntegratesWithEfficiency(t *testing.T) {
+ pr := DefaultParams()
+ pr.ChargeEfficiency = 0.9
+ p := New(pr)
+ p.State.SOC = 50
+ start := time.Now()
+ _ = p.Step(Inputs{Time: start, Control: Control{MaxSOC: 100}})
+ hours := 1.0
+ out := p.Step(Inputs{
+ Time: start.Add(time.Duration(hours * float64(time.Hour))),
+ LoadW: 0,
+ Control: Control{
+ EMSMode: ModeForced,
+ BatteryCmd: CmdCharge,
+ BatterySetpointW: 1000,
+ MaxSOC: 100,
+ },
+ })
+ // 1000 W * 1 h * 0.9 / 10 kWh = 9 percentage points
+ want := 59.0
+ if math.Abs(out.SOC-want) > 0.2 {
+ t.Fatalf("SOC %v, want ~%v", out.SOC, want)
+ }
+}
+
+func TestExportLimitCurtailsPV(t *testing.T) {
+ p := New(DefaultParams())
+ p.State.SOC = 100
+ start := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
+ _ = p.Step(Inputs{Time: start})
+ out := p.Step(Inputs{
+ Time: start.Add(time.Second),
+ LoadW: 0,
+ Weather: Weather{GHIWm2: 1000},
+ Control: Control{
+ EMSMode: ModeSelfConsumption,
+ MaxSOC: 100,
+ ExportLimitEnable: true,
+ PVLimitW: 500,
+ },
+ })
+ if out.GridW < -510 {
+ t.Fatalf("export %v W exceeded 500 W cap", -out.GridW)
+ }
+}
+
+func TestSemanticPVIsPositiveForDeviceRegisters(t *testing.T) {
+ out := Outputs{PVW: -3000, GridW: -2000, BatteryW: -1000, LoadW: 0, SOC: 40}
+ m := SemanticValues(out)
+ if m["pv_power"] != 3000 {
+ t.Fatalf("pv_power %v, want 3000 (device magnitude)", m["pv_power"])
+ }
+ if m["meter_power"] != -2000 {
+ t.Fatalf("meter_power %v, want -2000 (export)", m["meter_power"])
+ }
+ if m["battery_flags"] != 4 {
+ t.Fatalf("battery_flags %v, want 4 discharging", m["battery_flags"])
+ }
+}
+
+func TestGHIZeroAtNight(t *testing.T) {
+ night := time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC)
+ if g := GHIClearSky(night, 1000, 6, 20); g != 0 {
+ t.Fatalf("night GHI %v", g)
+ }
+ noon := time.Date(2026, 6, 21, 13, 0, 0, 0, time.UTC)
+ if g := GHIClearSky(noon, 1000, 6, 20); g < 500 {
+ t.Fatalf("noon GHI too low: %v", g)
+ }
+}
diff --git a/internal/plant/semantic.go b/internal/plant/semantic.go
new file mode 100644
index 0000000..ee7a656
--- /dev/null
+++ b/internal/plant/semantic.go
@@ -0,0 +1,54 @@
+package plant
+
+// SemanticValues projects plant outputs onto the simulator's device-agnostic
+// register names. Vendor maps (Sungrow, Huawei, β¦) then encode these into
+// the addresses a Lua driver actually reads.
+//
+// Device registers usually report PV as a positive generation magnitude.
+// The Lua driver negates that at the emit boundary. We therefore store
+// positive PV watts here, matching existing simulator behaviour.
+func SemanticValues(out Outputs) map[string]float64 {
+ pvPos := -out.PVW
+ if pvPos < 0 {
+ pvPos = 0
+ }
+ battFlags := 0.0
+ if out.BatteryW > 1 {
+ battFlags = 2 // charging
+ } else if out.BatteryW < -1 {
+ battFlags = 4 // discharging
+ }
+ return map[string]float64{
+ "pv_power": pvPos,
+ "pv1_voltage": out.MPPTs[0].V,
+ "pv1_current": out.MPPTs[0].A,
+ "pv2_voltage": out.MPPTs[1].V,
+ "pv2_current": out.MPPTs[1].A,
+ "load_power": out.LoadW,
+ "meter_power": out.GridW,
+ "meter_l1_power": out.GridW / 3,
+ "meter_l2_power": out.GridW / 3,
+ "meter_l3_power": out.GridW / 3,
+ "battery_power": mathAbs(out.BatteryW),
+ "battery_voltage": out.BatteryV,
+ "battery_current": out.BatteryA,
+ "battery_soc": out.SOC,
+ "battery_flags": battFlags,
+ "battery_temperature": out.BatteryC,
+ "inverter_temperature": out.InverterC,
+ "grid_frequency": out.GridHz,
+ "total_pv_gen": out.TotalPVWh,
+ "total_charge": out.TotalChargeWh,
+ "total_discharge": out.TotalDischargeWh,
+ "total_import": out.TotalImportWh,
+ "total_export": out.TotalExportWh,
+ "running_state": 1,
+ }
+}
+
+func mathAbs(v float64) float64 {
+ if v < 0 {
+ return -v
+ }
+ return v
+}
From 39fc0cc2fa1717681ed0080b25610d90fa61daa4 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 19 Aug 2026 16:23:35 +0000
Subject: [PATCH 2/5] Add a pluggable site plant: Modelica source plus native
AC bus
Site physics lives in modelica/SiteEnergy.mo (Kirchhoff power connector,
hybrids, house load, grid slack). The simulator talks to plant.Model so
an FMI FMU can replace the Go native backend later. NativeSite already
solves the same bus: self-consumption batteries share house residual,
EMS units follow Lua setpoints, the site clock writes registers.
Physical site scenario skips per-inverter sine generation and steps the
bus from calculateAndUpdateMeter.
Co-authored-by: Fredrik Ahlgren
---
.gitignore | 1 +
README.md | 2 +-
cmd/simulator/main.go | 262 +++++++++++++++++++++++++++---------
docs/LUA_DRIVER_PLANT.md | 43 +++++-
internal/plant/fmu.go | 25 ++++
internal/plant/model.go | 44 ++++++
internal/plant/plant.go | 114 +++++++++-------
internal/plant/site.go | 161 ++++++++++++++++++++++
internal/plant/site_test.go | 81 +++++++++++
modelica/SiteEnergy.mo | 155 +++++++++++++++++++++
10 files changed, 768 insertions(+), 120 deletions(-)
create mode 100644 internal/plant/fmu.go
create mode 100644 internal/plant/model.go
create mode 100644 internal/plant/site.go
create mode 100644 internal/plant/site_test.go
create mode 100644 modelica/SiteEnergy.mo
diff --git a/.gitignore b/.gitignore
index 820b725..e5445fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,6 +62,7 @@ cover/
# Translations
*.mo
*.pot
+!modelica/**/*.mo
# Django stuff:
*.log
diff --git a/README.md b/README.md
index 6be1e7c..2118c08 100644
--- a/README.md
+++ b/README.md
@@ -63,7 +63,7 @@ docker compose up --build
- **Site Management** - Group simulators into sites with energy meters
- **Device Profiles** - Sungrow, SolarEdge, Fronius, Huawei, and more
- **Time Acceleration** - Compress 24 hours into minutes for testing
-- **Physical plant** - Conserved site power balance and battery SoC (Modelica-style DAE) for closed-loop Lua driver tests β see [docs/LUA_DRIVER_PLANT.md](docs/LUA_DRIVER_PLANT.md)
+- **Physical plant** - Site AC bus with conserved power and battery SoC; Modelica source in `modelica/SiteEnergy.mo` (Go native backend today, FMU plug later) β [docs/LUA_DRIVER_PLANT.md](docs/LUA_DRIVER_PLANT.md)
- **Real-time Logging** - See every protocol operation as it happens
## Supported Profiles
diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go
index a5ac952..65deb5c 100644
--- a/cmd/simulator/main.go
+++ b/cmd/simulator/main.go
@@ -162,6 +162,7 @@ type Site struct {
mu sync.RWMutex
stopChan chan struct{}
simStopChan chan struct{} // Stop channel for simulation loop
+ sitePlant *plant.NativeSite
}
var (
@@ -3529,6 +3530,132 @@ func applyPlantOutputs(sim *Simulator, out plant.Outputs) {
sim.mu.Unlock()
}
+func siteUsesPhysicalPlant(site *Site) bool {
+ if site == nil {
+ return false
+ }
+ site.mu.RLock()
+ defer site.mu.RUnlock()
+ return site.Scenario == "physical"
+}
+
+func ensureSitePlant(site *Site) *plant.NativeSite {
+ site.mu.Lock()
+ defer site.mu.Unlock()
+ if site.sitePlant == nil {
+ site.sitePlant = plant.NewNativeSite()
+ }
+ return site.sitePlant
+}
+
+func collectSiteInverters(site *Site) []*Simulator {
+ site.mu.RLock()
+ meterID := site.MeterID
+ ids := append([]int{}, site.SimulatorIDs...)
+ site.mu.RUnlock()
+
+ seen := map[int]struct{}{}
+ var out []*Simulator
+ add := func(id int) {
+ if id == 0 {
+ return
+ }
+ if _, ok := seen[id]; ok {
+ return
+ }
+ seen[id] = struct{}{}
+ sim := getSimulator(id)
+ if sim == nil {
+ return
+ }
+ sim.mu.RLock()
+ ok := sim.Running && sim.DeviceOn && sim.Category == "inverter" && sim.Protocol == "modbus" && sim.Server != nil
+ sim.mu.RUnlock()
+ if ok {
+ out = append(out, sim)
+ }
+ }
+ add(meterID)
+ for _, id := range ids {
+ add(id)
+ }
+ return out
+}
+
+// runSitePlantAndApply steps the site DAE on the site clock and writes
+// member registers. The meter is written by calculateAndUpdateMeter.
+func runSitePlantAndApply(site *Site, simDelta time.Duration) (gridW float64, ok bool) {
+ if !siteUsesPhysicalPlant(site) {
+ return 0, false
+ }
+ inverters := collectSiteInverters(site)
+ if len(inverters) == 0 {
+ return 0, false
+ }
+ site.mu.RLock()
+ simTime := site.SimulatedTime
+ loadW := site.SitePhaseLoads.Total()
+ if loadW == 0 {
+ loadW = site.BaseLoadW
+ }
+ scenario := site.Scenario
+ site.mu.RUnlock()
+ if simTime.IsZero() {
+ simTime = time.Now()
+ }
+
+ model := ensureSitePlant(site)
+ members := make([]plant.MemberStep, 0, len(inverters))
+ exportLimit := false
+ exportW := 0.0
+ for _, sim := range inverters {
+ key := strconv.Itoa(sim.ID)
+ u := ensurePlant(sim)
+ if rated := getSemanticValue(sim, "nominal_power"); rated > 0 {
+ u.Params.RatedACW = rated * 1000
+ u.Params.RatedPVW = rated * 1000
+ }
+ if sim.BatteryCapacityKWh > 0 {
+ u.Params.BatteryCapacityWh = sim.BatteryCapacityKWh * 1000
+ }
+ if soc := getSemanticValue(sim, "battery_soc"); soc > 0 {
+ u.SeedSOC(soc)
+ }
+ model.Attach(key, u)
+ ctrl := controlFromRegisters(sim)
+ members = append(members, plant.MemberStep{Key: key, Control: ctrl})
+ if ctrl.ExportLimitEnable && ctrl.PVLimitW > 0 {
+ exportLimit = true
+ exportW = ctrl.PVLimitW
+ }
+ }
+
+ res, err := model.StepSite(simDelta.Seconds(), plant.SiteStep{
+ Time: simTime,
+ Weather: plant.WeatherForScenario(simTime, scenario),
+ LoadW: loadW,
+ ExportLimitEnable: exportLimit,
+ ExportLimitW: exportW,
+ Members: members,
+ })
+ if err != nil {
+ log.Printf("[Site %d] plant step: %v", site.ID, err)
+ return 0, false
+ }
+ for _, m := range res.Members {
+ id, convErr := strconv.Atoi(m.Key)
+ if convErr != nil {
+ continue
+ }
+ sim := getSimulator(id)
+ if sim == nil {
+ continue
+ }
+ applyPlantOutputs(sim, m.Out)
+ }
+ return res.GridW, true
+}
+
func generateValues(sim *Simulator, simTime time.Time) {
// Handle different protocols
switch sim.Protocol {
@@ -4323,56 +4450,58 @@ func runSiteSimulationLoop(site *Site, stopChan chan struct{}) {
}
site.mu.Unlock()
- // Generate values for each device in the site
- for _, simID := range simIDs {
- sim := getSimulator(simID)
- if sim == nil {
- continue
- }
+ if scenario != "physical" {
+ // Generate values for each device in the site
+ for _, simID := range simIDs {
+ sim := getSimulator(simID)
+ if sim == nil {
+ continue
+ }
- sim.mu.RLock()
- running := sim.Running
- deviceOn := sim.DeviceOn
- category := sim.Category
- sim.mu.RUnlock()
+ sim.mu.RLock()
+ running := sim.Running
+ deviceOn := sim.DeviceOn
+ category := sim.Category
+ sim.mu.RUnlock()
- if category != "inverter" {
- continue
- }
- if running && deviceOn {
- sim.Automation.mu.RLock()
- simScenario := sim.Automation.Scenario
- sim.Automation.mu.RUnlock()
- if simScenario == "" {
- simScenario = scenario
+ if category != "inverter" {
+ continue
}
- if simScenario == "physical" {
- stepPhysicalPlant(sim, simTime, simScenario, 0)
- } else {
- generateValuesForSite(sim, simTime, simScenario)
+ if running && deviceOn {
+ sim.Automation.mu.RLock()
+ simScenario := sim.Automation.Scenario
+ sim.Automation.mu.RUnlock()
+ if simScenario == "" {
+ simScenario = scenario
+ }
+ if simScenario == "physical" {
+ stepPhysicalPlant(sim, simTime, simScenario, 0)
+ } else {
+ generateValuesForSite(sim, simTime, simScenario)
+ }
}
}
- }
- // Also generate for the meter if it's an inverter
- meterSim := getSimulator(meterID)
- if meterSim != nil {
- meterSim.mu.RLock()
- running := meterSim.Running
- category := meterSim.Category
- meterSim.mu.RUnlock()
-
- if running && category == "inverter" {
- meterSim.Automation.mu.RLock()
- meterScenario := meterSim.Automation.Scenario
- meterSim.Automation.mu.RUnlock()
- if meterScenario == "" {
- meterScenario = scenario
- }
- if meterScenario == "physical" {
- stepPhysicalPlant(meterSim, simTime, meterScenario, 0)
- } else {
- generateValuesForSite(meterSim, simTime, meterScenario)
+ // Also generate for the meter if it's an inverter
+ meterSim := getSimulator(meterID)
+ if meterSim != nil {
+ meterSim.mu.RLock()
+ running := meterSim.Running
+ category := meterSim.Category
+ meterSim.mu.RUnlock()
+
+ if running && category == "inverter" {
+ meterSim.Automation.mu.RLock()
+ meterScenario := meterSim.Automation.Scenario
+ meterSim.Automation.mu.RUnlock()
+ if meterScenario == "" {
+ meterScenario = scenario
+ }
+ if meterScenario == "physical" {
+ stepPhysicalPlant(meterSim, simTime, meterScenario, 0)
+ } else {
+ generateValuesForSite(meterSim, simTime, meterScenario)
+ }
}
}
}
@@ -4592,9 +4721,14 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) {
}
}
- // If meter is an inverter, read its PV/Battery values and include them in the calculation
- // Distribute PV/battery contributions using phase factors
- if meterCategory == "inverter" {
+ plantGrid, usedPlant := runSitePlantAndApply(site, simDelta)
+ if usedPlant {
+ phasePowers = PhasePowers{
+ L1: plantGrid * phaseFactors.L1,
+ L2: plantGrid * phaseFactors.L2,
+ L3: plantGrid * phaseFactors.L3,
+ }
+ } else if meterCategory == "inverter" {
pvPower := getSemanticValue(meterSim, "pv_power") // PV power
batteryPower := getBatterySignedPower(meterSim) // Signed battery power
@@ -4605,24 +4739,24 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) {
phasePowers.L3 += pvBatteryNet * phaseFactors.L3
}
- // Add contributions from other simulators (distributed using phase factors)
- var otherPower float64
- for _, simID := range simIDs {
- // Skip meter simulator to avoid double-counting its own power
- if simID == meterID {
- continue
- }
- sim := getSimulator(simID)
- if sim == nil {
- continue
+ if !usedPlant {
+ var otherPower float64
+ for _, simID := range simIDs {
+ // Skip meter simulator to avoid double-counting its own power
+ if simID == meterID {
+ continue
+ }
+ sim := getSimulator(simID)
+ if sim == nil {
+ continue
+ }
+ otherPower += getPowerContribution(sim)
}
- otherPower += getPowerContribution(sim)
- }
- // Distribute other power contributions across phases
- phasePowers.L1 += otherPower * phaseFactors.L1
- phasePowers.L2 += otherPower * phaseFactors.L2
- phasePowers.L3 += otherPower * phaseFactors.L3
+ phasePowers.L1 += otherPower * phaseFactors.L1
+ phasePowers.L2 += otherPower * phaseFactors.L2
+ phasePowers.L3 += otherPower * phaseFactors.L3
+ }
// Update fuse state based on overload profile (before applying blown phases)
fuseBlown := updateFuseState(site, phasePowers, simDelta)
@@ -4653,7 +4787,7 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) {
energyDeltaKWh := math.Abs(totalPower) / 1000.0 * simHours
energyDeltaWh := math.Abs(totalPower) * simHours
- if simHours > 0 {
+ if simHours > 0 && !usedPlant {
// Update battery SOC and PV energy for all inverters using the central clock
if meterCategory == "inverter" && meterSim.Server != nil && !simulatorUsesPlant(meterSim) {
updateBatterySOC(meterSim, simHours, meterCapacityKWh)
diff --git a/docs/LUA_DRIVER_PLANT.md b/docs/LUA_DRIVER_PLANT.md
index 43b67b1..75b2e79 100644
--- a/docs/LUA_DRIVER_PLANT.md
+++ b/docs/LUA_DRIVER_PLANT.md
@@ -56,7 +56,39 @@ Not a Modelica compiler. The same modelling split:
The site electrical node is the slack connection. In self-consumption the battery tries to zero the grid; in EMS/forced mode the battery follows the Lua setpoint and the grid takes the residual. Export limits curtail PV rather than fabricating meter zeros.
-Implementation: `internal/plant`. Scenario **Physical plant** in the UI (`physical`) steps this model and writes semantic registers.
+Implementation: `internal/plant`. Scenario **Physical plant** (`physical`) on a **site** steps the site bus (house load + every hybrid) on the site clock. A lone inverter still steps a one-unit plant from its automation loop.
+
+## Site bus + a pluggable Modelica module
+
+The site is one AC node, not N independent sine waves. The connection law is the same as in Modelica:
+
+```text
+0 = sum(inverter.P) + load.P + ev.P + grid.P
+```
+
+`flow P` is positive **into** the component. Lua's meter (positive import) is `-grid.P`.
+
+The easiest way to keep that physics editable without dragging OpenModelica into the Go binary:
+
+```text
+modelica/SiteEnergy.mo <-- source of truth (connectors, SoC ODE)
+ |
+ | omc / Dymola --> Site.fmu (optional)
+ |
+internal/plant.Model <-- plug: NativeSite today, LoadFMU later
+ |
+site clock (calculateAndUpdateMeter)
+ |
+vendor register maps / Lua
+```
+
+`NativeSite` implements the same bus equation in Go so `go test` and Docker work with zero extra tools. Self-consumption slack (mode 0) is assigned by the **master** each step: it depends on every unit's live Lua writes, so it must not sit inside the FMU as a hidden algebraic loop.
+
+```bash
+# later, when you want the Modelica slave on the same plug:
+omc script to export FMI 2.0 CS for SiteEnergy.Site
+# then plant.LoadFMU("Site.fmu") β not linked yet; see internal/plant/fmu.go
+```
## Lua compatibility (Sungrow first)
@@ -95,16 +127,17 @@ Sunny / cloudy / β¦ still write independent PV and load sines, then a late mete
Next couplings, in order:
1. Drive **all** weather scenarios through `plant.WeatherForScenario` so Lua tests do not need a special scenario.
-2. Step the plant on the **site** clock with the site's phase loads as `P_load`, one plant per inverter, grid slack on the site meter.
+2. Link `LoadFMU` (FMI 2.0 CS) so `modelica/SiteEnergy.mo` can replace NativeSite without API changes.
3. Per-driver fixture packs: the register window each Lua file reads, generated from the driver source (same idea as `test_modbus_drivers.py`).
-4. MQTT / OCPP facades on the same plant (Ambibox, OCPP chargers) so a site with mixed protocols still conserves energy.
+4. MQTT / OCPP facades on the same bus (Ambibox, OCPP chargers) so mixed-protocol sites still conserve energy.
## Package map
| Path | Responsibility |
|------|----------------|
-| `internal/plant` | DAE: balance, SoC, GHI, semantic projection |
+| `modelica/SiteEnergy.mo` | Modelica site (ACBus, hybrids, grid slack) |
+| `internal/plant` | NativeSite + FMU plug, semantic projection |
| `internal/modbus/devices` | Vendor addresses and encoding |
| `internal/modbus/server.go` | Modbus TCP (what Lua actually hits) |
-| `cmd/simulator/main.go` | `physical` scenario β `stepPhysicalPlant` |
+| `cmd/simulator/main.go` | site clock β `runSitePlantAndApply` when scenario is `physical` |
| `srcfl/device-drivers` | Lua, host API, emit schema β **not copied here** |
diff --git a/internal/plant/fmu.go b/internal/plant/fmu.go
new file mode 100644
index 0000000..9d29ed5
--- /dev/null
+++ b/internal/plant/fmu.go
@@ -0,0 +1,25 @@
+package plant
+
+import (
+ "fmt"
+ "os"
+)
+
+// LoadFMU plugs in a Modelica-compiled FMI 2.0 co-simulation FMU as the
+// site backend. The public variable names must match modelica/SiteEnergy.mo.
+//
+// Compile (OpenModelica):
+//
+// omc -s -s FMI -n=SiteEnergy.Site modelica/SiteEnergy.mo
+//
+// This repository does not vendor libfmi; when an FMU exists, implement the
+// mapping here (SetReal / DoStep / GetReal) and return it from this function.
+func LoadFMU(path string) (Model, error) {
+ if path == "" {
+ return nil, fmt.Errorf("plant: empty FMU path")
+ }
+ if _, err := os.Stat(path); err != nil {
+ return nil, fmt.Errorf("plant: FMU %s: %w", path, err)
+ }
+ return nil, fmt.Errorf("plant: FMU backend not linked yet (found %s); native site model is the default", path)
+}
diff --git a/internal/plant/model.go b/internal/plant/model.go
new file mode 100644
index 0000000..8d1942e
--- /dev/null
+++ b/internal/plant/model.go
@@ -0,0 +1,44 @@
+package plant
+
+import "time"
+
+// Model is the plug the simulator talks to. Native Go and a future
+// FMI-2 co-simulation FMU both implement it. Names match public
+// variables in modelica/SiteEnergy.mo.
+type Model interface {
+ Backend() string
+ StepSite(dt float64, in SiteStep) (SiteResult, error)
+}
+
+// SiteStep is everything the site bus needs for one integrator tick.
+// Load is the house (site meter), not a per-inverter phantom load.
+type SiteStep struct {
+ Time time.Time
+ Weather Weather
+ LoadW float64
+ EVW float64
+ ExportLimitW float64
+ ExportLimitEnable bool
+ Members []MemberStep
+}
+
+// MemberStep is one inverter/battery attached to the AC bus.
+type MemberStep struct {
+ Key string
+ Control Control
+}
+
+// MemberResult is one member's terminals after the bus has been solved.
+type MemberResult struct {
+ Key string
+ Out Outputs
+}
+
+// SiteResult is the solved AC node plus per-member terminals.
+type SiteResult struct {
+ GridW float64
+ LoadW float64
+ EVW float64
+ ResidualW float64
+ Members []MemberResult
+}
diff --git a/internal/plant/plant.go b/internal/plant/plant.go
index fe7c88d..341cfbe 100644
--- a/internal/plant/plant.go
+++ b/internal/plant/plant.go
@@ -202,33 +202,52 @@ func (p *Plant) step(dt float64, in Inputs) Outputs {
ambient = pr.AmbientC
}
- maxChg := firstPositive(ctrl.MaxChargeW, pr.MaxChargeW)
- maxDis := firstPositive(ctrl.MaxDischargeW, pr.MaxDischargeW)
- minSOC := ctrl.MinSOC
- maxSOC := ctrl.MaxSOC
- if maxSOC <= 0 || maxSOC > 100 {
- maxSOC = 100
- }
- if minSOC < 0 || minSOC >= maxSOC {
- minSOC = 0
+ minSOC, maxSOC := socBounds(ctrl)
+ pPV, pDC := p.previewPV(in.Weather, ctrl)
+
+ pLoad := math.Max(0, in.LoadW)
+ pEV := math.Max(0, in.EVW)
+
+ pBatt := batterySetpoint(ctrl, pPV, pLoad, pEV)
+ pBatt = p.clipBattery(pBatt, ctrl)
+
+ pGrid := -pPV - pBatt - pLoad - pEV
+
+ if ctrl.ExportLimitEnable && ctrl.PVLimitW > 0 && pGrid < -ctrl.PVLimitW {
+ wantPV := -(-ctrl.PVLimitW + pBatt + pLoad + pEV)
+ if wantPV > 0 {
+ wantPV = 0
+ }
+ if wantPV < pPV {
+ pPV = wantPV
+ }
+ pGrid = -pPV - pBatt - pLoad - pEV
}
+ return p.commit(dt, in.Time, in.Weather, pPV, pDC, pBatt, pLoad, pEV, pGrid, ambient, minSOC, maxSOC)
+}
+
+// previewPV is the algebraic PV injection (negative watts) before battery/grid.
+func (p *Plant) previewPV(w Weather, ctrl Control) (pPV, pDC float64) {
+ pr := p.Params
+ ambient := w.AmbientC
+ if ambient == 0 {
+ ambient = pr.AmbientC
+ }
cellC := ambient + 5
- ghi := math.Max(0, in.Weather.GHIWm2)
- pAvailDC := (ghi / 1000.0) * pr.RatedPVW * (1 + pr.TempCoeffPerK*(cellC-25))
- if pAvailDC < 0 {
- pAvailDC = 0
+ ghi := math.Max(0, w.GHIWm2)
+ pDC = (ghi / 1000.0) * pr.RatedPVW * (1 + pr.TempCoeffPerK*(cellC-25))
+ if pDC < 0 {
+ pDC = 0
}
-
eta := pr.InverterEta
if eta <= 0 || eta > 1 {
eta = 0.97
}
- pAvailAC := pAvailDC * eta
+ pAvailAC := pDC * eta
if pAvailAC > pr.RatedACW {
pAvailAC = pr.RatedACW
}
-
cap := pAvailAC
if ctrl.ActiveLimitEnable && ctrl.ActiveLimitRatio > 0 {
cap = math.Min(cap, pr.RatedACW*ctrl.ActiveLimitRatio)
@@ -236,33 +255,32 @@ func (p *Plant) step(dt float64, in Inputs) Outputs {
if ctrl.PVLimitEnable && ctrl.PVLimitW > 0 {
cap = math.Min(cap, ctrl.PVLimitW)
}
- pPV := -cap // generation is negative at the site node
-
- pLoad := math.Max(0, in.LoadW)
- pEV := math.Max(0, in.EVW)
+ return -cap, pDC
+}
- pBatt := batterySetpoint(ctrl, pPV, pLoad, pEV)
+func (p *Plant) clipBattery(pBatt float64, ctrl Control) float64 {
+ pr := p.Params
+ maxChg := firstPositive(ctrl.MaxChargeW, pr.MaxChargeW)
+ maxDis := firstPositive(ctrl.MaxDischargeW, pr.MaxDischargeW)
+ minSOC, maxSOC := socBounds(ctrl)
pBatt = clip(pBatt, -maxDis, maxChg)
- pBatt = applySOCLimits(pBatt, p.State.SOC, minSOC, maxSOC)
-
- pGrid := -pPV - pBatt - pLoad - pEV
+ return applySOCLimits(pBatt, p.State.SOC, minSOC, maxSOC)
+}
- if ctrl.ExportLimitEnable && ctrl.PVLimitW > 0 && pGrid < -ctrl.PVLimitW {
- // Curtail PV to honour a feed-in cap. Battery already committed.
- // P_grid_target = -PVLimitW (max export)
- // -pPV = pGrid_target + pBatt + pLoad + pEV
- wantPV := -(-ctrl.PVLimitW + pBatt + pLoad + pEV)
- if wantPV > 0 {
- wantPV = 0
- }
- if wantPV < pPV { // pPV is more negative than allowed
- pPV = wantPV
- }
- pGrid = -pPV - pBatt - pLoad - pEV
+func socBounds(ctrl Control) (minSOC, maxSOC float64) {
+ maxSOC = ctrl.MaxSOC
+ minSOC = ctrl.MinSOC
+ if maxSOC <= 0 || maxSOC > 100 {
+ maxSOC = 100
}
+ if minSOC < 0 || minSOC >= maxSOC {
+ minSOC = 0
+ }
+ return minSOC, maxSOC
+}
- // Integrate SoC. Charging: bus power enters the pack through eta_c.
- // Discharging: pack energy leaves through 1/eta_d to deliver pBatt.
+func (p *Plant) commit(dt float64, t time.Time, w Weather, pPV, pDC, pBatt, pLoad, pEV, pGrid, ambient, minSOC, maxSOC float64) Outputs {
+ pr := p.Params
hours := dt / 3600.0
capWh := pr.BatteryCapacityWh
if capWh <= 0 {
@@ -274,7 +292,7 @@ func (p *Plant) step(dt float64, in Inputs) Outputs {
dWh = pBatt * hours * clamp01(pr.ChargeEfficiency)
p.State.TotalChargeWh += pBatt * hours
} else {
- dWh = pBatt * hours / clamp01(pr.DischargeEfficiency) // pBatt negative
+ dWh = pBatt * hours / clamp01(pr.DischargeEfficiency)
p.State.TotalDischargeWh += -pBatt * hours
}
p.State.SOC = clip(p.State.SOC+(dWh/capWh)*100.0, minSOC, maxSOC)
@@ -289,8 +307,7 @@ func (p *Plant) step(dt float64, in Inputs) Outputs {
}
}
- // First-order inverter temperature from conversion losses.
- lossW := pAvailDC - (-pPV)
+ lossW := pDC - (-pPV)
if lossW < 0 {
lossW = 0
}
@@ -312,22 +329,20 @@ func (p *Plant) step(dt float64, in Inputs) Outputs {
if pBatt != 0 {
aBatt = math.Abs(pBatt) / vBatt
}
-
- mppts := splitMPPT(-pPV, ghi)
-
+ ghi := math.Max(0, w.GHIWm2)
hz := 50.0
- if !in.Time.IsZero() {
- hz = 50.0 + 0.04*math.Sin(float64(in.Time.Second())*0.1)
+ if !t.IsZero() {
+ hz = 50.0 + 0.04*math.Sin(float64(t.Second())*0.1)
}
- out := Outputs{
+ return Outputs{
GridW: pGrid,
PVW: pPV,
BatteryW: pBatt,
LoadW: pLoad,
EVW: pEV,
- PVDCW: pAvailDC,
- MPPTs: mppts,
+ PVDCW: pDC,
+ MPPTs: splitMPPT(-pPV, ghi),
BatteryV: vBatt,
BatteryA: aBatt,
SOC: p.State.SOC,
@@ -341,7 +356,6 @@ func (p *Plant) step(dt float64, in Inputs) Outputs {
TotalImportWh: p.State.TotalImportWh,
TotalExportWh: p.State.TotalExportWh,
}
- return out
}
func batterySetpoint(ctrl Control, pPV, pLoad, pEV float64) float64 {
diff --git a/internal/plant/site.go b/internal/plant/site.go
new file mode 100644
index 0000000..214b9ab
--- /dev/null
+++ b/internal/plant/site.go
@@ -0,0 +1,161 @@
+package plant
+
+import "math"
+
+// NativeSite is the in-process backend. It implements the same connection
+// equations as modelica/SiteEnergy.mo so the simulator runs without OpenModelica.
+// Swap it for an FMU via LoadFMU when one is compiled from that package.
+type NativeSite struct {
+ units map[string]*Plant
+ order []string
+}
+
+func NewNativeSite() *NativeSite {
+ return &NativeSite{units: make(map[string]*Plant)}
+}
+
+func (s *NativeSite) Backend() string { return "native" }
+
+// Attach adds or replaces a member unit. The key should be stable (simulator id).
+func (s *NativeSite) Attach(key string, unit *Plant) {
+ if _, ok := s.units[key]; !ok {
+ s.order = append(s.order, key)
+ }
+ s.units[key] = unit
+}
+
+func (s *NativeSite) Unit(key string) *Plant { return s.units[key] }
+
+func (s *NativeSite) StepSite(dt float64, in SiteStep) (SiteResult, error) {
+ if dt < 0 {
+ dt = 0
+ }
+ if dt > 6*3600 {
+ dt = 6 * 3600
+ }
+ pLoad := math.Max(0, in.LoadW)
+ pEV := math.Max(0, in.EVW)
+
+ type row struct {
+ key string
+ unit *Plant
+ ctrl Control
+ pPV, pDC float64
+ pBatt float64
+ self bool
+ maxChg float64
+ maxDis float64
+ }
+
+ rows := make([]row, 0, len(in.Members))
+ for _, m := range in.Members {
+ u := s.units[m.Key]
+ if u == nil {
+ u = New(DefaultParams())
+ s.Attach(m.Key, u)
+ }
+ pPV, pDC := u.previewPV(in.Weather, m.Control)
+ self := m.Control.EMSMode != ModeForced && m.Control.EMSMode != ModeEMS
+ pBatt := 0.0
+ if !self {
+ pBatt = u.clipBattery(batterySetpoint(m.Control, pPV, 0, 0), m.Control)
+ }
+ rows = append(rows, row{
+ key: m.Key,
+ unit: u,
+ ctrl: m.Control,
+ pPV: pPV,
+ pDC: pDC,
+ pBatt: pBatt,
+ self: self,
+ maxChg: firstPositive(m.Control.MaxChargeW, u.Params.MaxChargeW),
+ maxDis: firstPositive(m.Control.MaxDischargeW, u.Params.MaxDischargeW),
+ })
+ }
+
+ // Site self-consumption: batteries in default mode share the residual
+ // that would otherwise hit the grid. EMS/forced packs already have a
+ // setpoint and are not slacks.
+ var pPVsum, pBattEMS float64
+ var slackChg, slackDis float64
+ for _, r := range rows {
+ pPVsum += r.pPV
+ if r.self {
+ slackChg += r.maxChg
+ slackDis += r.maxDis
+ } else {
+ pBattEMS += r.pBatt
+ }
+ }
+ residual := pLoad + pEV + pPVsum + pBattEMS // power that wants to leave the bus to the grid
+ // residual > 0 β need discharge (or import). residual < 0 β need charge (or export).
+ wantBatt := -residual
+ for i := range rows {
+ if !rows[i].self {
+ continue
+ }
+ share := 0.0
+ if wantBatt > 0 && slackChg > 0 {
+ share = wantBatt * (rows[i].maxChg / slackChg)
+ } else if wantBatt < 0 && slackDis > 0 {
+ share = wantBatt * (rows[i].maxDis / slackDis)
+ }
+ rows[i].pBatt = rows[i].unit.clipBattery(share, rows[i].ctrl)
+ }
+
+ var pBattSum float64
+ for _, r := range rows {
+ pBattSum += r.pBatt
+ }
+ pGrid := -pPVsum - pBattSum - pLoad - pEV
+
+ if in.ExportLimitEnable && in.ExportLimitW > 0 && pGrid < -in.ExportLimitW {
+ // Curtail PV (prefer self-consumption units) so export stays at the cap.
+ need := -in.ExportLimitW - pGrid // positive watts of extra curtailment
+ for i := range rows {
+ if need <= 0 {
+ break
+ }
+ avail := -rows[i].pPV
+ if avail <= 0 {
+ continue
+ }
+ cut := math.Min(avail, need)
+ rows[i].pPV += cut // pPV is negative; adding cut reduces generation
+ need -= cut
+ }
+ pPVsum = 0
+ pBattSum = 0
+ for _, r := range rows {
+ pPVsum += r.pPV
+ pBattSum += r.pBatt
+ }
+ pGrid = -pPVsum - pBattSum - pLoad - pEV
+ }
+
+ ambient := in.Weather.AmbientC
+ if ambient == 0 {
+ ambient = 20
+ }
+
+ out := SiteResult{
+ GridW: pGrid,
+ LoadW: pLoad,
+ EVW: pEV,
+ Members: make([]MemberResult, 0, len(rows)),
+ }
+ var check float64
+ check += pGrid + pLoad + pEV
+ for _, r := range rows {
+ minSOC, maxSOC := socBounds(r.ctrl)
+ memOut := r.unit.commit(dt, in.Time, in.Weather, r.pPV, r.pDC, r.pBatt, 0, 0, 0, ambient, minSOC, maxSOC)
+ memOut.LoadW = pLoad
+ memOut.EVW = pEV
+ memOut.GridW = pGrid
+ memOut.ResidualW = 0
+ out.Members = append(out.Members, MemberResult{Key: r.key, Out: memOut})
+ check += r.pPV + r.pBatt
+ }
+ out.ResidualW = check
+ return out, nil
+}
diff --git a/internal/plant/site_test.go b/internal/plant/site_test.go
new file mode 100644
index 0000000..ada326e
--- /dev/null
+++ b/internal/plant/site_test.go
@@ -0,0 +1,81 @@
+package plant
+
+import (
+ "math"
+ "testing"
+ "time"
+)
+
+func TestNativeSiteBalancesTwoHybridsAndHouseLoad(t *testing.T) {
+ site := NewNativeSite()
+ a := New(DefaultParams())
+ b := New(DefaultParams())
+ a.State.SOC = 50
+ b.State.SOC = 50
+ site.Attach("inv-a", a)
+ site.Attach("inv-b", b)
+
+ noon := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
+ out, err := site.StepSite(1, SiteStep{
+ Time: noon,
+ Weather: Weather{GHIWm2: 400, AmbientC: 20},
+ LoadW: 2000,
+ Members: []MemberStep{
+ {Key: "inv-a", Control: Control{EMSMode: ModeSelfConsumption, MaxSOC: 100}},
+ {Key: "inv-b", Control: Control{EMSMode: ModeSelfConsumption, MaxSOC: 100}},
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if math.Abs(out.ResidualW) > 1e-6 {
+ t.Fatalf("site residual %v", out.ResidualW)
+ }
+ if len(out.Members) != 2 {
+ t.Fatalf("members %d", len(out.Members))
+ }
+ if math.Abs(out.GridW) > 80 {
+ t.Fatalf("two self-consumption hybrids should nearly zero the meter, grid=%v pvA=%v battA=%v pvB=%v battB=%v",
+ out.GridW, out.Members[0].Out.PVW, out.Members[0].Out.BatteryW, out.Members[1].Out.PVW, out.Members[1].Out.BatteryW)
+ }
+}
+
+func TestNativeSiteEMSDoesNotStealSelfConsumptionSlack(t *testing.T) {
+ site := NewNativeSite()
+ hybrid := New(DefaultParams())
+ hybrid.State.SOC = 80
+ site.Attach("h", hybrid)
+
+ out, err := site.StepSite(1, SiteStep{
+ Time: time.Now(),
+ Weather: Weather{},
+ LoadW: 1000,
+ Members: []MemberStep{{
+ Key: "h",
+ Control: Control{
+ EMSMode: ModeEMS,
+ BatteryCmd: CmdDischarge,
+ BatterySetpointW: 2500,
+ MinSOC: 10,
+ MaxSOC: 100,
+ },
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if math.Abs(out.Members[0].Out.BatteryW+2500) > 1 {
+ t.Fatalf("EMS battery %v, want -2500", out.Members[0].Out.BatteryW)
+ }
+ // P_grid + 0 + (-2500) + 1000 = 0 β import 1500
+ if math.Abs(out.GridW-1500) > 1 {
+ t.Fatalf("grid %v, want 1500", out.GridW)
+ }
+}
+
+func TestLoadFMUMissingFile(t *testing.T) {
+ _, err := LoadFMU("/no/such/site.fmu")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
diff --git a/modelica/SiteEnergy.mo b/modelica/SiteEnergy.mo
new file mode 100644
index 0000000..b66b2e3
--- /dev/null
+++ b/modelica/SiteEnergy.mo
@@ -0,0 +1,155 @@
+within ;
+
+package SiteEnergy
+ "Lumped AC site plant for srcfl/device-simulator.
+
+ Physics source of truth. internal/plant.NativeSite implements the same
+ bus equation so the app runs without a Modelica tool. Compile this
+ package to an FMI 2.0 CS FMU and plant.LoadFMU replaces the backend
+ without changing Modbus maps or Lua drivers.
+
+ Connector flow P is positive INTO the component.
+ Lua meter (positive import) is -grid.ac.P."
+
+ type Power = Real(final unit = "W");
+ type Irradiance = Real(final unit = "W/m2");
+ type Temperature = Real(final unit = "degC");
+ type Energy = Real(final unit = "W.h");
+
+ connector PowerPort
+ flow Power P;
+ end PowerPort;
+
+ model ACBus
+ parameter Integer nInv(min = 1) = 1;
+ PowerPort inv[nInv];
+ PowerPort load;
+ PowerPort ev;
+ PowerPort grid;
+ equation
+ 0 = sum(inv.P) + load.P + ev.P + grid.P;
+ end ACBus;
+
+ model HouseLoad
+ PowerPort ac;
+ input Power P_set;
+ equation
+ ac.P = noEvent(max(0.0, P_set));
+ end HouseLoad;
+
+ model EVCharger
+ PowerPort ac;
+ input Power P_set;
+ equation
+ ac.P = noEvent(max(0.0, P_set));
+ end EVCharger;
+
+ model GridSlack
+ PowerPort ac;
+ output Power P_meter "Lua convention: positive import";
+ equation
+ P_meter = -ac.P;
+ end GridSlack;
+
+ model Battery
+ input Power P_cmd;
+ input Real soc_min = 0;
+ input Real soc_max = 100;
+ parameter Energy E_nom = 10000;
+ parameter Power P_chg_max = 5000;
+ parameter Power P_dis_max = 5000;
+ parameter Real eta_c = 0.95;
+ parameter Real eta_d = 0.95;
+ output Real soc(start = 50);
+ output Power P;
+ equation
+ P = min(P_chg_max, max(-P_dis_max, P_cmd));
+ der(soc) = 100.0 * (if P > 0 then P * eta_c / E_nom else P / (eta_d * E_nom));
+ end Battery;
+
+ model PVArray
+ input Irradiance ghi;
+ input Temperature T_amb;
+ input Power P_limit;
+ input Real u_ratio(min = 0, max = 1) = 1;
+ parameter Power P_stc = 8000;
+ parameter Power P_rated = 8000;
+ parameter Real eta = 0.97;
+ parameter Real gamma = -0.004;
+ output Power P_pv "<= 0, site convention";
+ output Power P_dc;
+ protected
+ Power p_ac;
+ equation
+ P_dc = noEvent(max(0.0, (ghi / 1000.0) * P_stc * (1.0 + gamma * (T_amb - 20.0))));
+ p_ac = min(P_rated, min(P_rated * u_ratio, P_dc * eta));
+ P_pv = -min(p_ac, if P_limit > 0 then P_limit else p_ac);
+ end PVArray;
+
+ model InverterUnit
+ "Hybrid AC terminal: P_ac = P_pv + P_batt (both site convention)."
+ PowerPort ac;
+ input Irradiance ghi;
+ input Temperature T_amb;
+ input Integer u_mode;
+ input Integer u_cmd;
+ input Power u_sp;
+ input Power u_pv_limit = 0;
+ input Power u_self_batt = 0 "FMI master assigns site self-consumption slack";
+ PVArray pv;
+ Battery bat;
+ output Power P_pv;
+ output Power P_batt;
+ output Real soc;
+ equation
+ pv.ghi = ghi;
+ pv.T_amb = T_amb;
+ pv.P_limit = u_pv_limit;
+ P_pv = pv.P_pv;
+ P_batt = bat.P;
+ soc = bat.soc;
+ bat.P_cmd = if u_mode == 0 then u_self_batt else
+ (if u_cmd == 170 then abs(u_sp) elseif u_cmd == 187 then -abs(u_sp) else 0.0);
+ ac.P = P_pv + P_batt;
+ end InverterUnit;
+
+ model Site
+ "nInv hybrids + house + EV + grid. Mode-0 self-consumption slack is
+ assigned by the FMI master (Go) each step, because it depends on
+ the live Lua writes of every unit. Do not put that outer loop in
+ the FMU β keep the slave causal."
+ parameter Integer nInv(min = 1) = 1;
+ input Irradiance ghi;
+ input Temperature T_amb;
+ input Power P_load;
+ input Power P_ev = 0;
+ input Integer u_mode[nInv];
+ input Integer u_cmd[nInv];
+ input Power u_sp[nInv];
+ input Power u_self_batt[nInv] "master-assigned slack for mode 0";
+ ACBus bus(nInv = nInv);
+ InverterUnit unit[nInv];
+ HouseLoad house;
+ EVCharger ev;
+ GridSlack grid;
+ output Power P_meter;
+ output Real soc[nInv];
+ equation
+ connect(house.ac, bus.load);
+ connect(ev.ac, bus.ev);
+ connect(grid.ac, bus.grid);
+ house.P_set = P_load;
+ ev.P_set = P_ev;
+ for i in 1:nInv loop
+ connect(unit[i].ac, bus.inv[i]);
+ unit[i].ghi = ghi;
+ unit[i].T_amb = T_amb;
+ unit[i].u_mode = u_mode[i];
+ unit[i].u_cmd = u_cmd[i];
+ unit[i].u_sp = u_sp[i];
+ unit[i].u_self_batt = u_self_batt[i];
+ soc[i] = unit[i].soc;
+ end for;
+ P_meter = grid.P_meter;
+ end Site;
+end SiteEnergy;
From 63b73809b599cd40a2d596c269fdf7c7c47bb6ef Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 19 Aug 2026 16:34:20 +0000
Subject: [PATCH 3/5] Add a site-plant sketch: bus, Modelica plug, and Lua
facade
SVG plus short notes so the site DAE is reviewable as a picture:
one AC node, NativeSite/FMU plug, vendor maps, Modbus to Lua.
Co-authored-by: Fredrik Ahlgren
---
.gitignore | 5 +-
README.md | 2 +-
docs/LUA_DRIVER_PLANT.md | 10 ++-
docs/SITE_PLANT_SKETCH.md | 87 +++++++++++++++++++++++++
docs/site-plant-sketch.svg | 130 +++++++++++++++++++++++++++++++++++++
5 files changed, 230 insertions(+), 4 deletions(-)
create mode 100644 docs/SITE_PLANT_SKETCH.md
create mode 100644 docs/site-plant-sketch.svg
diff --git a/.gitignore b/.gitignore
index e5445fe..e3d8a9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,10 +59,11 @@ coverage.xml
.pytest_cache/
cover/
-# Translations
+# Translations (gettext). Keep Modelica sources.
*.mo
+!modelica/
+!modelica/*.mo
*.pot
-!modelica/**/*.mo
# Django stuff:
*.log
diff --git a/README.md b/README.md
index 2118c08..8f431d5 100644
--- a/README.md
+++ b/README.md
@@ -63,7 +63,7 @@ docker compose up --build
- **Site Management** - Group simulators into sites with energy meters
- **Device Profiles** - Sungrow, SolarEdge, Fronius, Huawei, and more
- **Time Acceleration** - Compress 24 hours into minutes for testing
-- **Physical plant** - Site AC bus with conserved power and battery SoC; Modelica source in `modelica/SiteEnergy.mo` (Go native backend today, FMU plug later) β [docs/LUA_DRIVER_PLANT.md](docs/LUA_DRIVER_PLANT.md)
+- **Physical plant** - Site AC bus with conserved power and battery SoC; Modelica source in `modelica/SiteEnergy.mo` β [sketch](docs/SITE_PLANT_SKETCH.md)
- **Real-time Logging** - See every protocol operation as it happens
## Supported Profiles
diff --git a/docs/LUA_DRIVER_PLANT.md b/docs/LUA_DRIVER_PLANT.md
index 75b2e79..1a09756 100644
--- a/docs/LUA_DRIVER_PLANT.md
+++ b/docs/LUA_DRIVER_PLANT.md
@@ -1,6 +1,14 @@
# Lua drivers and a physical plant
-This repository should be a **hardware stand-in** for [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers): the same Lua that polls a Sungrow on a roof should poll this process over Modbus TCP, and writes from `driver_command` should move energy through a small DAE rather than a scripted sine wave.
+This is the picture of a **site** as one electrical node, with physics as a
+module that can be swapped.
+
+
+
+Full notes: [SITE_PLANT_SKETCH.md](SITE_PLANT_SKETCH.md). Source of the bus
+equation: [`modelica/SiteEnergy.mo`](../modelica/SiteEnergy.mo).
+Go stand-in (no OpenModelica required): `internal/plant.NativeSite`.
+Lua talks only Modbus: [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers).
Drivers never talk to this app's HTTP API. They talk to a device. The job of the simulator is to *be* that device, with a plant behind the register map.
diff --git a/docs/SITE_PLANT_SKETCH.md b/docs/SITE_PLANT_SKETCH.md
new file mode 100644
index 0000000..7fda8f6
--- /dev/null
+++ b/docs/SITE_PLANT_SKETCH.md
@@ -0,0 +1,87 @@
+# Site plant sketch
+
+This is the picture of a **site** as one electrical node, with physics as a
+module that can be swapped. The drawing:
+
+
+
+Source of the bus equation: [`modelica/SiteEnergy.mo`](../modelica/SiteEnergy.mo).
+Go stand-in (no OpenModelica required): `internal/plant.NativeSite`.
+Lua talks only Modbus: [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers).
+
+## What is in the sketch
+
+Three strips, left to right:
+
+1. **Plant** β Modelica connectors on an AC bus. Inverters inject `P_pv + P_batt`. House and EV consume. Grid is slack. `P_meter = -grid.P` so Lua import is positive.
+2. **Facade** β semantic watts become vendor registers. No physics here.
+3. **Lua** β `driver_poll` / `driver_command` over Modbus TCP. Not the HTTP API.
+
+Under the plant: the **site clock** is the only integrator. Scenario `physical` calls `StepSite(dt)` once per tick.
+
+## Connection law
+
+```mermaid
+flowchart LR
+ subgraph plant["SiteEnergy.Site"]
+ PV1["Inverter 1\nP_pv + P_batt"]
+ PVN["Inverter N"]
+ Load["House load"]
+ EV["EV"]
+ Bus(("AC bus\nΞ£P = 0"))
+ Grid["Grid slack"]
+ PV1 --> Bus
+ PVN --> Bus
+ Load --> Bus
+ EV --> Bus
+ Bus --> Grid
+ end
+ GHI[GHI / T_amb] --> PV1
+ GHI --> PVN
+ Lua[Lua setpoints] --> PV1
+ Lua --> PVN
+ Grid --> Meter["P_meter import+"]
+```
+
+`flow P` is positive **into** the component. That is the Modelica convention and it matches the Lua sign table after `P_meter = -grid.P`.
+
+## Plug
+
+```mermaid
+flowchart TB
+ MO["modelica/SiteEnergy.mo"]
+ MO -->|same equations| Native["NativeSite\nGo, default"]
+ MO -.->|omc / Dymola later| FMU["Site.fmu\nFMI 2.0 CS"]
+ Native --> I["plant.Model"]
+ FMU -.-> I
+ I --> Clock["site clock"]
+ Clock --> Maps["vendor maps"]
+ Maps --> TCP["Modbus TCP"]
+ TCP --> Lua["sungrow.lua"]
+```
+
+Keep self-consumption slack in the **master** (Go). A mode-0 battery must see every other unit's live Lua write in the same step. Putting that algebraic loop inside the FMU makes the slave non-causal.
+
+## Tick
+
+```text
+site tick (dt includes time acceleration)
+ read P_load from site phase loads
+ read Control from each inverter's holding registers β Lua writes land here
+ NativeSite.StepSite(dt)
+ preview PV from GHI
+ EMS/forced batteries follow setpoint
+ mode-0 batteries share residual so the meter wants to be ~0
+ export cap curtails PV if needed
+ integrate SoC
+ write semantic registers
+ write site meter (P_grid on phases, then fuses)
+```
+
+## Out of scope for this sketch
+
+- Full 3-phase electrical (only power split by `phase_factors`)
+- Thermal networks, MPPT tracking dynamics, transformer impedance
+- Compiling and loading the FMU (`LoadFMU` is the hole for that)
+
+Those can grow **inside** `SiteEnergy.mo` without changing Lua.
diff --git a/docs/site-plant-sketch.svg b/docs/site-plant-sketch.svg
new file mode 100644
index 0000000..834f63a
--- /dev/null
+++ b/docs/site-plant-sketch.svg
@@ -0,0 +1,130 @@
+
From be6297798541d98a18a2e34140950efdd88bca43 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 19 Aug 2026 17:01:10 +0000
Subject: [PATCH 4/5] Plug compiled Modelica into the site plant
siteplant.Open loads a Site4 FMI 2.0 co-simulation FMU when present
(linux/amd64 + CGO) and falls back to the native Go DAE otherwise.
make fmu runs omc against modelica/SiteEnergy.mo. Mode-0 slack stays
in the Go master. Empty FMI slots are gated with u_on.
Co-authored-by: Fredrik Ahlgren
---
.gitignore | 17 ++
Makefile | 14 +-
README.md | 2 +-
cmd/simulator/main.go | 13 +-
docs/LUA_DRIVER_PLANT.md | 25 +-
docs/SITE_PLANT_SKETCH.md | 10 +-
internal/plant/fmu.go | 25 --
internal/plant/model.go | 5 +-
internal/plant/plant.go | 6 +-
internal/plant/site.go | 2 +-
internal/plant/site_test.go | 7 -
modelica/README.md | 22 ++
modelica/SiteEnergy.mo | 80 ++++--
modelica/check.mos | 4 +
modelica/export.mos | 8 +
siteplant/fmi2.h | 58 +++++
siteplant/fmi_wrap.c | 113 +++++++++
siteplant/fmu.go | 483 ++++++++++++++++++++++++++++++++++++
siteplant/fmu_stub.go | 13 +
siteplant/fmu_test.go | 119 +++++++++
siteplant/open.go | 124 +++++++++
siteplant/open_test.go | 111 +++++++++
22 files changed, 1189 insertions(+), 72 deletions(-)
delete mode 100644 internal/plant/fmu.go
create mode 100644 modelica/README.md
create mode 100644 modelica/check.mos
create mode 100644 modelica/export.mos
create mode 100644 siteplant/fmi2.h
create mode 100644 siteplant/fmi_wrap.c
create mode 100644 siteplant/fmu.go
create mode 100644 siteplant/fmu_stub.go
create mode 100644 siteplant/fmu_test.go
create mode 100644 siteplant/open.go
create mode 100644 siteplant/open_test.go
diff --git a/.gitignore b/.gitignore
index e3d8a9e..53cffc4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -179,3 +179,20 @@ data/state.json
# Go binaries
/simulator
+
+# OpenModelica / FMI build products (compile locally with `make fmu`)
+modelica/*.fmu
+modelica/Site4_*
+modelica/*.inst.mo
+modelica/*.libs
+modelica/*.makefile
+modelica/*.json
+modelica/*.html
+modelica/*.c
+modelica/*.h
+modelica/*.o
+modelica/*.xml
+modelica/*.log
+modelica/*.exe
+modelica/*.so
+
diff --git a/Makefile b/Makefile
index 0954582..17b5ebb 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
.PHONY: up down restart build logs clean test env \
build-macos build-linux build-linux-server build-windows \
- run-macos package help
+ run-macos package help fmu fmu-clean
# Auto-detect host IP for Mac (en0 = WiFi, en1 = Ethernet)
HOST_IP ?= $(shell ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "")
@@ -32,6 +32,17 @@ clean:
test:
go test ./...
+# Compile modelica/SiteEnergy.Site4 to an FMI 2.0 co-simulation FMU.
+# Requires OpenModelica (`omc`). The simulator loads it via siteplant.Open.
+fmu:
+ @command -v omc >/dev/null || (echo "omc not found. Install OpenModelica: https://openmodelica.org" && exit 1)
+ cd modelica && omc export.mos
+
+fmu-clean:
+ cd modelica && rm -f Site4.fmu Site4_*.c Site4_*.h Site4_*.o Site4_*.json \
+ Site4_*.libs Site4_*.makefile Site4_*.log Site4.inst.mo index.html \
+ Site4_FMU.* 2>/dev/null || true
+
# Build macOS desktop app (native, requires macOS)
build-macos:
@echo "Building macOS desktop app..."
@@ -126,5 +137,6 @@ help:
@echo ""
@echo "Other:"
@echo " make test - Run Go tests"
+ @echo " make fmu - Compile SiteEnergy.Site4 to FMI 2.0 CS (needs omc)"
@echo " make package - Create release packages"
@echo " make clean - Remove build artifacts"
diff --git a/README.md b/README.md
index 8f431d5..6cfbd98 100644
--- a/README.md
+++ b/README.md
@@ -63,7 +63,7 @@ docker compose up --build
- **Site Management** - Group simulators into sites with energy meters
- **Device Profiles** - Sungrow, SolarEdge, Fronius, Huawei, and more
- **Time Acceleration** - Compress 24 hours into minutes for testing
-- **Physical plant** - Site AC bus with conserved power and battery SoC; Modelica source in `modelica/SiteEnergy.mo` β [sketch](docs/SITE_PLANT_SKETCH.md)
+- **Physical plant** - Site AC bus with conserved power and battery SoC. Modelica in `modelica/SiteEnergy.mo`; compile with `make fmu` and plug in via `siteplant`. Sketch: [docs/SITE_PLANT_SKETCH.md](docs/SITE_PLANT_SKETCH.md)
- **Real-time Logging** - See every protocol operation as it happens
## Supported Profiles
diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go
index 65deb5c..297fe27 100644
--- a/cmd/simulator/main.go
+++ b/cmd/simulator/main.go
@@ -32,6 +32,7 @@ import (
"github.com/srcfl/device-simulator/internal/profiles"
"github.com/srcfl/device-simulator/internal/settings"
"github.com/srcfl/device-simulator/internal/state"
+ "github.com/srcfl/device-simulator/siteplant"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
@@ -162,7 +163,7 @@ type Site struct {
mu sync.RWMutex
stopChan chan struct{}
simStopChan chan struct{} // Stop channel for simulation loop
- sitePlant *plant.NativeSite
+ sitePlant plant.Model
}
var (
@@ -3539,11 +3540,17 @@ func siteUsesPhysicalPlant(site *Site) bool {
return site.Scenario == "physical"
}
-func ensureSitePlant(site *Site) *plant.NativeSite {
+func ensureSitePlant(site *Site) plant.Model {
site.mu.Lock()
defer site.mu.Unlock()
if site.sitePlant == nil {
- site.sitePlant = plant.NewNativeSite()
+ m, err := siteplant.Open(siteplant.Options{})
+ if err != nil {
+ log.Printf("[Site %d] siteplant: %v; using native Go plant", site.ID, err)
+ m = plant.NewNativeSite()
+ }
+ site.sitePlant = m
+ log.Printf("[Site %d] plant backend %s", site.ID, m.Backend())
}
return site.sitePlant
}
diff --git a/docs/LUA_DRIVER_PLANT.md b/docs/LUA_DRIVER_PLANT.md
index 1a09756..9af9855 100644
--- a/docs/LUA_DRIVER_PLANT.md
+++ b/docs/LUA_DRIVER_PLANT.md
@@ -8,6 +8,7 @@ module that can be swapped.
Full notes: [SITE_PLANT_SKETCH.md](SITE_PLANT_SKETCH.md). Source of the bus
equation: [`modelica/SiteEnergy.mo`](../modelica/SiteEnergy.mo).
Go stand-in (no OpenModelica required): `internal/plant.NativeSite`.
+Compiled Modelica: `make fmu` β `siteplant.Open` loads `Site4.fmu`.
Lua talks only Modbus: [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers).
Drivers never talk to this app's HTTP API. They talk to a device. The job of the simulator is to *be* that device, with a plant behind the register map.
@@ -81,9 +82,11 @@ The easiest way to keep that physics editable without dragging OpenModelica into
```text
modelica/SiteEnergy.mo <-- source of truth (connectors, SoC ODE)
|
- | omc / Dymola --> Site.fmu (optional)
+ | make fmu (omc) --> Site4.fmu (optional, linux/amd64)
|
-internal/plant.Model <-- plug: NativeSite today, LoadFMU later
+siteplant.Open <-- plug: FMU if present, else NativeSite
+ |
+internal/plant.Model
|
site clock (calculateAndUpdateMeter)
|
@@ -93,9 +96,11 @@ vendor register maps / Lua
`NativeSite` implements the same bus equation in Go so `go test` and Docker work with zero extra tools. Self-consumption slack (mode 0) is assigned by the **master** each step: it depends on every unit's live Lua writes, so it must not sit inside the FMU as a hidden algebraic loop.
```bash
-# later, when you want the Modelica slave on the same plug:
-omc script to export FMI 2.0 CS for SiteEnergy.Site
-# then plant.LoadFMU("Site.fmu") β not linked yet; see internal/plant/fmu.go
+make fmu # needs omc; writes modelica/Site4.fmu
+# or point at one:
+SITEPLANT_FMU=/path/to/Site4.fmu
+# force Go even if an FMU is on disk:
+SITEPLANT_NATIVE=1
```
## Lua compatibility (Sungrow first)
@@ -135,16 +140,16 @@ Sunny / cloudy / β¦ still write independent PV and load sines, then a late mete
Next couplings, in order:
1. Drive **all** weather scenarios through `plant.WeatherForScenario` so Lua tests do not need a special scenario.
-2. Link `LoadFMU` (FMI 2.0 CS) so `modelica/SiteEnergy.mo` can replace NativeSite without API changes.
-3. Per-driver fixture packs: the register window each Lua file reads, generated from the driver source (same idea as `test_modbus_drivers.py`).
-4. MQTT / OCPP facades on the same bus (Ambibox, OCPP chargers) so mixed-protocol sites still conserve energy.
+2. Per-driver fixture packs: the register window each Lua file reads, generated from the driver source (same idea as `test_modbus_drivers.py`).
+3. MQTT / OCPP facades on the same bus (Ambibox, OCPP chargers) so mixed-protocol sites still conserve energy.
## Package map
| Path | Responsibility |
|------|----------------|
-| `modelica/SiteEnergy.mo` | Modelica site (ACBus, hybrids, grid slack) |
-| `internal/plant` | NativeSite + FMU plug, semantic projection |
+| `modelica/SiteEnergy.mo` | Modelica site (ACBus, hybrids, grid slack, Site4 FMU) |
+| `siteplant` | Plug: compile FMU or native Go |
+| `internal/plant` | NativeSite + semantic projection |
| `internal/modbus/devices` | Vendor addresses and encoding |
| `internal/modbus/server.go` | Modbus TCP (what Lua actually hits) |
| `cmd/simulator/main.go` | site clock β `runSitePlantAndApply` when scenario is `physical` |
diff --git a/docs/SITE_PLANT_SKETCH.md b/docs/SITE_PLANT_SKETCH.md
index 7fda8f6..7a93685 100644
--- a/docs/SITE_PLANT_SKETCH.md
+++ b/docs/SITE_PLANT_SKETCH.md
@@ -7,6 +7,7 @@ module that can be swapped. The drawing:
Source of the bus equation: [`modelica/SiteEnergy.mo`](../modelica/SiteEnergy.mo).
Go stand-in (no OpenModelica required): `internal/plant.NativeSite`.
+Compiled Modelica: `make fmu` then `siteplant.Open`.
Lua talks only Modbus: [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers).
## What is in the sketch
@@ -51,9 +52,10 @@ flowchart LR
flowchart TB
MO["modelica/SiteEnergy.mo"]
MO -->|same equations| Native["NativeSite\nGo, default"]
- MO -.->|omc / Dymola later| FMU["Site.fmu\nFMI 2.0 CS"]
+ MO -->|make fmu / omc| FMU["Site4.fmu\nFMI 2.0 CS"]
Native --> I["plant.Model"]
- FMU -.-> I
+ FMU --> Open["siteplant.Open"]
+ Open --> I
I --> Clock["site clock"]
Clock --> Maps["vendor maps"]
Maps --> TCP["Modbus TCP"]
@@ -68,7 +70,7 @@ Keep self-consumption slack in the **master** (Go). A mode-0 battery must see ev
site tick (dt includes time acceleration)
read P_load from site phase loads
read Control from each inverter's holding registers β Lua writes land here
- NativeSite.StepSite(dt)
+ NativeSite.StepSite(dt) or Site4.fmu via siteplant
preview PV from GHI
EMS/forced batteries follow setpoint
mode-0 batteries share residual so the meter wants to be ~0
@@ -82,6 +84,6 @@ site tick (dt includes time acceleration)
- Full 3-phase electrical (only power split by `phase_factors`)
- Thermal networks, MPPT tracking dynamics, transformer impedance
-- Compiling and loading the FMU (`LoadFMU` is the hole for that)
+- FMU parameterisation of rated power (Site4 is fixed at 8 kW / 10 kWh per slot)
Those can grow **inside** `SiteEnergy.mo` without changing Lua.
diff --git a/internal/plant/fmu.go b/internal/plant/fmu.go
deleted file mode 100644
index 9d29ed5..0000000
--- a/internal/plant/fmu.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package plant
-
-import (
- "fmt"
- "os"
-)
-
-// LoadFMU plugs in a Modelica-compiled FMI 2.0 co-simulation FMU as the
-// site backend. The public variable names must match modelica/SiteEnergy.mo.
-//
-// Compile (OpenModelica):
-//
-// omc -s -s FMI -n=SiteEnergy.Site modelica/SiteEnergy.mo
-//
-// This repository does not vendor libfmi; when an FMU exists, implement the
-// mapping here (SetReal / DoStep / GetReal) and return it from this function.
-func LoadFMU(path string) (Model, error) {
- if path == "" {
- return nil, fmt.Errorf("plant: empty FMU path")
- }
- if _, err := os.Stat(path); err != nil {
- return nil, fmt.Errorf("plant: FMU %s: %w", path, err)
- }
- return nil, fmt.Errorf("plant: FMU backend not linked yet (found %s); native site model is the default", path)
-}
diff --git a/internal/plant/model.go b/internal/plant/model.go
index 8d1942e..debfc5e 100644
--- a/internal/plant/model.go
+++ b/internal/plant/model.go
@@ -2,11 +2,12 @@ package plant
import "time"
-// Model is the plug the simulator talks to. Native Go and a future
-// FMI-2 co-simulation FMU both implement it. Names match public
+// Model is the plug the simulator talks to. Native Go and the compiled
+// Modelica FMU (siteplant.Open) both implement it. Names match public
// variables in modelica/SiteEnergy.mo.
type Model interface {
Backend() string
+ Attach(key string, unit *Plant)
StepSite(dt float64, in SiteStep) (SiteResult, error)
}
diff --git a/internal/plant/plant.go b/internal/plant/plant.go
index 341cfbe..582ffff 100644
--- a/internal/plant/plant.go
+++ b/internal/plant/plant.go
@@ -227,7 +227,11 @@ func (p *Plant) step(dt float64, in Inputs) Outputs {
return p.commit(dt, in.Time, in.Weather, pPV, pDC, pBatt, pLoad, pEV, pGrid, ambient, minSOC, maxSOC)
}
-// previewPV is the algebraic PV injection (negative watts) before battery/grid.
+// PreviewPV is the algebraic PV injection (negative watts) before battery/grid.
+func (p *Plant) PreviewPV(w Weather, ctrl Control) (pPV, pDC float64) {
+ return p.previewPV(w, ctrl)
+}
+
func (p *Plant) previewPV(w Weather, ctrl Control) (pPV, pDC float64) {
pr := p.Params
ambient := w.AmbientC
diff --git a/internal/plant/site.go b/internal/plant/site.go
index 214b9ab..7ef5209 100644
--- a/internal/plant/site.go
+++ b/internal/plant/site.go
@@ -4,7 +4,7 @@ import "math"
// NativeSite is the in-process backend. It implements the same connection
// equations as modelica/SiteEnergy.mo so the simulator runs without OpenModelica.
-// Swap it for an FMU via LoadFMU when one is compiled from that package.
+// Swap it for a compiled FMU via siteplant.Open.
type NativeSite struct {
units map[string]*Plant
order []string
diff --git a/internal/plant/site_test.go b/internal/plant/site_test.go
index ada326e..a899053 100644
--- a/internal/plant/site_test.go
+++ b/internal/plant/site_test.go
@@ -72,10 +72,3 @@ func TestNativeSiteEMSDoesNotStealSelfConsumptionSlack(t *testing.T) {
t.Fatalf("grid %v, want 1500", out.GridW)
}
}
-
-func TestLoadFMUMissingFile(t *testing.T) {
- _, err := LoadFMU("/no/such/site.fmu")
- if err == nil {
- t.Fatal("expected error")
- }
-}
diff --git a/modelica/README.md b/modelica/README.md
new file mode 100644
index 0000000..501aeaf
--- /dev/null
+++ b/modelica/README.md
@@ -0,0 +1,22 @@
+# SiteEnergy (Modelica)
+
+Lumped AC site plant: hybrids + house load + EV + grid slack.
+
+```text
+omc export.mos # or: make fmu
+β Site4.fmu # FMI 2.0 co-simulation, 4 inverters
+```
+
+The Go module `siteplant` loads that FMU when present (`SITEPLANT_FMU`, or
+`modelica/Site4.fmu`). Otherwise the simulator uses the native DAE in
+`internal/plant`, which implements the same bus equation.
+
+`Site` keeps Modelica connectors. FMI matching cannot export that as a
+causal CS FMU, so `Site4` is the flatten: named Real inputs/outputs, no
+connectors. Mode-0 self-consumption slack is **not** inside the FMU; the
+FMI master (`siteplant`) assigns `u_self_batt` each step.
+
+Empty slots are gated with `u_on[i]=0` so unused inverters do not inject PV.
+
+Requires [OpenModelica](https://openmodelica.org) (`omc` on PATH). The FMU
+binary is linux/amd64; it is not checked in.
diff --git a/modelica/SiteEnergy.mo b/modelica/SiteEnergy.mo
index b66b2e3..51a9264 100644
--- a/modelica/SiteEnergy.mo
+++ b/modelica/SiteEnergy.mo
@@ -1,12 +1,9 @@
within ;
package SiteEnergy
- "Lumped AC site plant for srcfl/device-simulator.
-
- Physics source of truth. internal/plant.NativeSite implements the same
- bus equation so the app runs without a Modelica tool. Compile this
- package to an FMI 2.0 CS FMU and plant.LoadFMU replaces the backend
- without changing Modbus maps or Lua drivers.
+ "Lumped AC site plant. Compile SiteEnergy.Site4 to an FMI 2.0 CS FMU
+ (see export.mos). The Go module siteplant loads that FMU, or falls
+ back to a native backend that implements the same equations.
Connector flow P is positive INTO the component.
Lua meter (positive import) is -grid.ac.P."
@@ -17,6 +14,8 @@ package SiteEnergy
type Energy = Real(final unit = "W.h");
connector PowerPort
+ "Balanced connector: one potential (frequency) and one flow (power)."
+ Real freq "shared frequency in Hz";
flow Power P;
end PowerPort;
@@ -28,6 +27,11 @@ package SiteEnergy
PowerPort grid;
equation
0 = sum(inv.P) + load.P + ev.P + grid.P;
+ for i in 1:nInv loop
+ inv[i].freq = grid.freq;
+ end for;
+ load.freq = grid.freq;
+ ev.freq = grid.freq;
end ACBus;
model HouseLoad
@@ -49,12 +53,13 @@ package SiteEnergy
output Power P_meter "Lua convention: positive import";
equation
P_meter = -ac.P;
+ ac.freq = 50;
end GridSlack;
model Battery
input Power P_cmd;
- input Real soc_min = 0;
- input Real soc_max = 100;
+ parameter Real soc_min = 0;
+ parameter Real soc_max = 100;
parameter Energy E_nom = 10000;
parameter Power P_chg_max = 5000;
parameter Power P_dis_max = 5000;
@@ -64,14 +69,15 @@ package SiteEnergy
output Power P;
equation
P = min(P_chg_max, max(-P_dis_max, P_cmd));
- der(soc) = 100.0 * (if P > 0 then P * eta_c / E_nom else P / (eta_d * E_nom));
+ // E_nom is W.h; Modelica time is seconds.
+ der(soc) = 100.0 * (if P > 0 then P * eta_c else P / eta_d) / (E_nom * 3600.0);
end Battery;
model PVArray
input Irradiance ghi;
input Temperature T_amb;
input Power P_limit;
- input Real u_ratio(min = 0, max = 1) = 1;
+ parameter Real u_ratio(min = 0, max = 1) = 1;
parameter Power P_stc = 8000;
parameter Power P_rated = 8000;
parameter Real eta = 0.97;
@@ -94,8 +100,8 @@ package SiteEnergy
input Integer u_mode;
input Integer u_cmd;
input Power u_sp;
- input Power u_pv_limit = 0;
- input Power u_self_batt = 0 "FMI master assigns site self-consumption slack";
+ input Power u_pv_limit;
+ input Power u_self_batt "FMI master assigns site self-consumption slack";
PVArray pv;
Battery bat;
output Power P_pv;
@@ -115,18 +121,17 @@ package SiteEnergy
model Site
"nInv hybrids + house + EV + grid. Mode-0 self-consumption slack is
- assigned by the FMI master (Go) each step, because it depends on
- the live Lua writes of every unit. Do not put that outer loop in
- the FMU β keep the slave causal."
+ assigned by the FMI master each step."
parameter Integer nInv(min = 1) = 1;
input Irradiance ghi;
input Temperature T_amb;
input Power P_load;
- input Power P_ev = 0;
+ input Power P_ev;
input Integer u_mode[nInv];
input Integer u_cmd[nInv];
input Power u_sp[nInv];
- input Power u_self_batt[nInv] "master-assigned slack for mode 0";
+ input Power u_self_batt[nInv];
+ input Power u_pv_limit[nInv];
ACBus bus(nInv = nInv);
InverterUnit unit[nInv];
HouseLoad house;
@@ -134,6 +139,8 @@ package SiteEnergy
GridSlack grid;
output Power P_meter;
output Real soc[nInv];
+ output Power P_pv[nInv];
+ output Power P_batt[nInv];
equation
connect(house.ac, bus.load);
connect(ev.ac, bus.ev);
@@ -148,8 +155,47 @@ package SiteEnergy
unit[i].u_cmd = u_cmd[i];
unit[i].u_sp = u_sp[i];
unit[i].u_self_batt = u_self_batt[i];
+ unit[i].u_pv_limit = u_pv_limit[i];
soc[i] = unit[i].soc;
+ P_pv[i] = unit[i].P_pv;
+ P_batt[i] = unit[i].P_batt;
end for;
P_meter = grid.P_meter;
end Site;
+
+ model Site4
+ "Causal FMI 2.0 co-simulation slave. Same physics as Site, no connectors.
+ The master (siteplant) assigns u_self_batt for mode-0 slack."
+ constant Integer n = 4;
+ input Irradiance ghi;
+ input Temperature T_amb;
+ input Power P_load;
+ input Power P_ev;
+ input Real u_mode[n] "0=self, 2=forced, 3=EMS (Real for FMI)";
+ input Real u_cmd[n] "170=charge, 187=discharge, 204=stop";
+ input Power u_sp[n];
+ input Power u_self_batt[n];
+ input Power u_pv_limit[n];
+ input Real u_on[n] "1=inverter present, 0=empty FMI slot";
+ PVArray pv[n];
+ Battery bat[n];
+ output Power P_meter;
+ output Real soc[n];
+ output Power P_pv[n];
+ output Power P_batt[n];
+ equation
+ for i in 1:n loop
+ pv[i].ghi = ghi;
+ pv[i].T_amb = T_amb;
+ pv[i].P_limit = u_pv_limit[i];
+ bat[i].P_cmd = if u_on[i] < 0.5 then 0.0 elseif u_mode[i] < 0.5 then u_self_batt[i] else
+ (if u_cmd[i] > 169.5 and u_cmd[i] < 170.5 then abs(u_sp[i])
+ elseif u_cmd[i] > 186.5 and u_cmd[i] < 187.5 then -abs(u_sp[i])
+ else 0.0);
+ P_pv[i] = if u_on[i] < 0.5 then 0.0 else pv[i].P_pv;
+ P_batt[i] = if u_on[i] < 0.5 then 0.0 else bat[i].P;
+ soc[i] = bat[i].soc;
+ end for;
+ P_meter = -(sum(P_pv) + sum(P_batt) + noEvent(max(0.0, P_load)) + noEvent(max(0.0, P_ev)));
+ end Site4;
end SiteEnergy;
diff --git a/modelica/check.mos b/modelica/check.mos
new file mode 100644
index 0000000..8d12788
--- /dev/null
+++ b/modelica/check.mos
@@ -0,0 +1,4 @@
+loadFile("SiteEnergy.mo");
+getErrorString();
+instantiateModel(SiteEnergy.Site);
+getErrorString();
diff --git a/modelica/export.mos b/modelica/export.mos
new file mode 100644
index 0000000..e61d40e
--- /dev/null
+++ b/modelica/export.mos
@@ -0,0 +1,8 @@
+// Export SiteEnergy.Site4 as FMI 2.0 co-simulation FMU.
+// Run from this directory: omc export.mos
+cd(".");
+loadFile("SiteEnergy.mo");
+getErrorString();
+buildModelFMU(SiteEnergy.Site4, version="2.0", fmuType="cs", fileNamePrefix="Site4");
+getErrorString();
+system("ls -la Site4.fmu");
diff --git a/siteplant/fmi2.h b/siteplant/fmi2.h
new file mode 100644
index 0000000..53e3cef
--- /dev/null
+++ b/siteplant/fmi2.h
@@ -0,0 +1,58 @@
+#ifndef SITEPLANT_FMI2_H
+#define SITEPLANT_FMI2_H
+
+#include
+
+typedef void* fmi2Component;
+typedef void* fmi2ComponentEnvironment;
+typedef void* fmi2FMUstate;
+typedef unsigned int fmi2ValueReference;
+typedef double fmi2Real;
+typedef int fmi2Integer;
+typedef int fmi2Boolean;
+typedef char fmi2Char;
+typedef const fmi2Char* fmi2String;
+typedef char fmi2Byte;
+
+typedef enum {
+ fmi2OK,
+ fmi2Warning,
+ fmi2Discard,
+ fmi2Error,
+ fmi2Fatal,
+ fmi2Pending
+} fmi2Status;
+
+typedef enum {
+ fmi2ModelExchange,
+ fmi2CoSimulation
+} fmi2Type;
+
+#define fmi2True 1
+#define fmi2False 0
+
+typedef void (*fmi2CallbackLogger)(fmi2ComponentEnvironment, fmi2String, fmi2Status, fmi2String, fmi2String, ...);
+typedef void* (*fmi2CallbackAllocateMemory)(size_t, size_t);
+typedef void (*fmi2CallbackFreeMemory)(void*);
+typedef void (*fmi2StepFinished)(fmi2ComponentEnvironment, fmi2Status);
+
+typedef struct {
+ fmi2CallbackLogger logger;
+ fmi2CallbackAllocateMemory allocateMemory;
+ fmi2CallbackFreeMemory freeMemory;
+ fmi2StepFinished stepFinished;
+ fmi2ComponentEnvironment componentEnvironment;
+} fmi2CallbackFunctions;
+
+typedef fmi2Component (*fmi2InstantiateTYPE)(fmi2String, fmi2Type, fmi2String, fmi2String, const fmi2CallbackFunctions*, fmi2Boolean, fmi2Boolean);
+typedef void (*fmi2FreeInstanceTYPE)(fmi2Component);
+typedef fmi2Status (*fmi2SetupExperimentTYPE)(fmi2Component, fmi2Boolean, fmi2Real, fmi2Real, fmi2Boolean, fmi2Real);
+typedef fmi2Status (*fmi2EnterInitializationModeTYPE)(fmi2Component);
+typedef fmi2Status (*fmi2ExitInitializationModeTYPE)(fmi2Component);
+typedef fmi2Status (*fmi2DoStepTYPE)(fmi2Component, fmi2Real, fmi2Real, fmi2Boolean);
+typedef fmi2Status (*fmi2SetRealTYPE)(fmi2Component, const fmi2ValueReference[], size_t, const fmi2Real[]);
+typedef fmi2Status (*fmi2GetRealTYPE)(fmi2Component, const fmi2ValueReference[], size_t, fmi2Real[]);
+typedef fmi2Status (*fmi2TerminateTYPE)(fmi2Component);
+typedef fmi2Status (*fmi2ResetTYPE)(fmi2Component);
+
+#endif
diff --git a/siteplant/fmi_wrap.c b/siteplant/fmi_wrap.c
new file mode 100644
index 0000000..8e3ee92
--- /dev/null
+++ b/siteplant/fmi_wrap.c
@@ -0,0 +1,113 @@
+//go:build cgo && linux && amd64
+
+#include "fmi2.h"
+
+#include
+#include
+#include
+#include
+
+static void siteplant_logger(fmi2ComponentEnvironment env, fmi2String inst, fmi2Status status, fmi2String category, fmi2String message, ...) {
+ (void)env;
+ if (status <= fmi2Warning) {
+ return;
+ }
+ va_list ap;
+ va_start(ap, message);
+ fprintf(stderr, "siteplant fmi2[%s/%s]: ", inst ? inst : "?", category ? category : "?");
+ vfprintf(stderr, message ? message : "", ap);
+ fprintf(stderr, "\n");
+ va_end(ap);
+}
+
+static fmi2CallbackFunctions siteplant_cbs = {
+ .logger = siteplant_logger,
+ .allocateMemory = calloc,
+ .freeMemory = free,
+ .stepFinished = NULL,
+ .componentEnvironment = NULL,
+};
+
+void* siteplant_dlopen(const char* path) {
+ return dlopen(path, RTLD_NOW);
+}
+
+const char* siteplant_dlerror(void) {
+ return dlerror();
+}
+
+void siteplant_dlclose(void* h) {
+ if (h) {
+ dlclose(h);
+ }
+}
+
+fmi2Component siteplant_instantiate(void* h, const char* name, const char* guid, const char* resources) {
+ fmi2InstantiateTYPE fn = (fmi2InstantiateTYPE)dlsym(h, "fmi2Instantiate");
+ if (!fn) {
+ return NULL;
+ }
+ return fn(name, fmi2CoSimulation, guid, resources, &siteplant_cbs, fmi2False, fmi2False);
+}
+
+void siteplant_free_instance(void* h, fmi2Component c) {
+ fmi2FreeInstanceTYPE fn = (fmi2FreeInstanceTYPE)dlsym(h, "fmi2FreeInstance");
+ if (fn && c) {
+ fn(c);
+ }
+}
+
+fmi2Status siteplant_setup(void* h, fmi2Component c, double start) {
+ fmi2SetupExperimentTYPE fn = (fmi2SetupExperimentTYPE)dlsym(h, "fmi2SetupExperiment");
+ if (!fn) {
+ return fmi2Fatal;
+ }
+ return fn(c, fmi2False, 0, start, fmi2False, 0);
+}
+
+fmi2Status siteplant_enter_init(void* h, fmi2Component c) {
+ fmi2EnterInitializationModeTYPE fn = (fmi2EnterInitializationModeTYPE)dlsym(h, "fmi2EnterInitializationMode");
+ if (!fn) {
+ return fmi2Fatal;
+ }
+ return fn(c);
+}
+
+fmi2Status siteplant_exit_init(void* h, fmi2Component c) {
+ fmi2ExitInitializationModeTYPE fn = (fmi2ExitInitializationModeTYPE)dlsym(h, "fmi2ExitInitializationMode");
+ if (!fn) {
+ return fmi2Fatal;
+ }
+ return fn(c);
+}
+
+fmi2Status siteplant_dostep(void* h, fmi2Component c, double t, double dt) {
+ fmi2DoStepTYPE fn = (fmi2DoStepTYPE)dlsym(h, "fmi2DoStep");
+ if (!fn) {
+ return fmi2Fatal;
+ }
+ return fn(c, t, dt, fmi2True);
+}
+
+fmi2Status siteplant_set_real(void* h, fmi2Component c, const fmi2ValueReference vr[], size_t n, const fmi2Real v[]) {
+ fmi2SetRealTYPE fn = (fmi2SetRealTYPE)dlsym(h, "fmi2SetReal");
+ if (!fn) {
+ return fmi2Fatal;
+ }
+ return fn(c, vr, n, v);
+}
+
+fmi2Status siteplant_get_real(void* h, fmi2Component c, const fmi2ValueReference vr[], size_t n, fmi2Real v[]) {
+ fmi2GetRealTYPE fn = (fmi2GetRealTYPE)dlsym(h, "fmi2GetReal");
+ if (!fn) {
+ return fmi2Fatal;
+ }
+ return fn(c, vr, n, v);
+}
+
+void siteplant_terminate(void* h, fmi2Component c) {
+ fmi2TerminateTYPE fn = (fmi2TerminateTYPE)dlsym(h, "fmi2Terminate");
+ if (fn && c) {
+ fn(c);
+ }
+}
diff --git a/siteplant/fmu.go b/siteplant/fmu.go
new file mode 100644
index 0000000..e62a5c1
--- /dev/null
+++ b/siteplant/fmu.go
@@ -0,0 +1,483 @@
+//go:build cgo && linux && amd64
+
+package siteplant
+
+import (
+ "archive/zip"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "unsafe"
+
+ "github.com/srcfl/device-simulator/internal/plant"
+)
+
+/*
+#cgo CFLAGS: -I${SRCDIR}
+#cgo LDFLAGS: -ldl -lm
+#include
+#include "fmi2.h"
+
+void* siteplant_dlopen(const char* path);
+const char* siteplant_dlerror(void);
+void siteplant_dlclose(void* h);
+fmi2Component siteplant_instantiate(void* h, const char* name, const char* guid, const char* resources);
+void siteplant_free_instance(void* h, fmi2Component c);
+void siteplant_terminate(void* h, fmi2Component c);
+fmi2Status siteplant_setup(void* h, fmi2Component c, double start);
+fmi2Status siteplant_enter_init(void* h, fmi2Component c);
+fmi2Status siteplant_exit_init(void* h, fmi2Component c);
+fmi2Status siteplant_dostep(void* h, fmi2Component c, double t, double dt);
+fmi2Status siteplant_set_real(void* h, fmi2Component c, const fmi2ValueReference vr[], size_t n, const fmi2Real v[]);
+fmi2Status siteplant_get_real(void* h, fmi2Component c, const fmi2ValueReference vr[], size_t n, fmi2Real v[]);
+*/
+import "C"
+
+type fmuModelDesc struct {
+ XMLName xml.Name `xml:"fmiModelDescription"`
+ Guid string `xml:"guid,attr"`
+ CoSim struct {
+ ModelIdentifier string `xml:"modelIdentifier,attr"`
+ } `xml:"CoSimulation"`
+ Vars struct {
+ Scalar []struct {
+ Name string `xml:"name,attr"`
+ VR uint32 `xml:"valueReference,attr"`
+ Causality string `xml:"causality,attr"`
+ } `xml:"ScalarVariable"`
+ } `xml:"ModelVariables"`
+}
+
+// FMU is an FMI 2.0 co-simulation slave compiled from SiteEnergy.Site4.
+type FMU struct {
+ dir string
+ h unsafe.Pointer
+ c C.fmi2Component
+ vr map[string]uint32
+ slots []string
+ units map[string]*plant.Plant
+ t float64
+ ready bool
+}
+
+func (f *FMU) Backend() string { return "fmu" }
+
+func (f *FMU) Attach(key string, unit *plant.Plant) {
+ if f.units == nil {
+ f.units = map[string]*plant.Plant{}
+ }
+ if _, ok := f.units[key]; !ok {
+ if len(f.slots) >= MaxUnits {
+ return
+ }
+ f.slots = append(f.slots, key)
+ }
+ f.units[key] = unit
+}
+
+// LoadFMU opens a Site4 FMU as a plant.Model.
+func LoadFMU(path string) (plant.Model, error) {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return nil, err
+ }
+ dir, err := os.MkdirTemp("", "siteplant-fmu-*")
+ if err != nil {
+ return nil, err
+ }
+ if err := unzip(abs, dir); err != nil {
+ os.RemoveAll(dir)
+ return nil, err
+ }
+ desc, err := os.ReadFile(filepath.Join(dir, "modelDescription.xml"))
+ if err != nil {
+ os.RemoveAll(dir)
+ return nil, err
+ }
+ var md fmuModelDesc
+ if err := xml.Unmarshal(desc, &md); err != nil {
+ os.RemoveAll(dir)
+ return nil, err
+ }
+ if md.CoSim.ModelIdentifier == "" {
+ os.RemoveAll(dir)
+ return nil, fmt.Errorf("fmu: missing CoSimulation modelIdentifier")
+ }
+ so := filepath.Join(dir, "binaries", "linux64", md.CoSim.ModelIdentifier+".so")
+ cpath := C.CString(so)
+ h := C.siteplant_dlopen(cpath)
+ C.free(unsafe.Pointer(cpath))
+ if h == nil {
+ errStr := C.GoString(C.siteplant_dlerror())
+ os.RemoveAll(dir)
+ return nil, fmt.Errorf("dlopen %s: %s", so, errStr)
+ }
+ resDir := filepath.Join(dir, "resources")
+ _ = os.MkdirAll(resDir, 0o755)
+ res := "file://" + filepath.ToSlash(resDir) + "/"
+ cname := C.CString("siteplant")
+ cguid := C.CString(md.Guid)
+ cres := C.CString(res)
+ comp := C.siteplant_instantiate(h, cname, cguid, cres)
+ C.free(unsafe.Pointer(cname))
+ C.free(unsafe.Pointer(cguid))
+ C.free(unsafe.Pointer(cres))
+ if comp == nil {
+ C.siteplant_dlclose(h)
+ os.RemoveAll(dir)
+ return nil, fmt.Errorf("fmi2Instantiate failed (guid %s)", md.Guid)
+ }
+ if st := C.siteplant_setup(h, comp, 0); st != C.fmi2OK {
+ C.siteplant_terminate(h, comp)
+ C.siteplant_free_instance(h, comp)
+ C.siteplant_dlclose(h)
+ os.RemoveAll(dir)
+ return nil, fmt.Errorf("fmi2SetupExperiment status %d", int(st))
+ }
+ f := &FMU{
+ dir: dir,
+ h: h,
+ c: comp,
+ vr: map[string]uint32{},
+ units: map[string]*plant.Plant{},
+ }
+ for _, sv := range md.Vars.Scalar {
+ f.vr[sv.Name] = sv.VR
+ }
+ for _, name := range []string{"ghi", "P_load", "P_meter", "u_on[1]", "P_pv[1]"} {
+ if _, ok := f.vr[name]; !ok {
+ f.close()
+ return nil, fmt.Errorf("fmu: %s is not a SiteEnergy.Site4 FMU (missing %s)", path, name)
+ }
+ }
+ runtime.SetFinalizer(f, (*FMU).close)
+ return f, nil
+}
+
+// Close releases the FMU instance and unpacked files.
+func (f *FMU) Close() {
+ runtime.SetFinalizer(f, nil)
+ f.close()
+}
+
+func (f *FMU) close() {
+ if f.h != nil && f.c != nil {
+ C.siteplant_terminate(f.h, f.c)
+ C.siteplant_free_instance(f.h, f.c)
+ f.c = nil
+ }
+ if f.h != nil {
+ C.siteplant_dlclose(f.h)
+ f.h = nil
+ }
+ if f.dir != "" {
+ os.RemoveAll(f.dir)
+ f.dir = ""
+ }
+}
+
+func (f *FMU) StepSite(dt float64, in plant.SiteStep) (plant.SiteResult, error) {
+ if dt < 0 {
+ dt = 0
+ }
+ if dt > 6*3600 {
+ dt = 6 * 3600
+ }
+ for _, m := range in.Members {
+ if _, ok := f.units[m.Key]; !ok {
+ f.Attach(m.Key, plant.New(plant.DefaultParams()))
+ }
+ }
+ selfBatt := planSelfBatt(f, in)
+
+ sets := map[string]float64{
+ "ghi": in.Weather.GHIWm2,
+ "T_amb": in.Weather.AmbientC,
+ "P_load": math.Max(0, in.LoadW),
+ "P_ev": math.Max(0, in.EVW),
+ }
+ if sets["T_amb"] == 0 {
+ sets["T_amb"] = 20
+ }
+ for i := 0; i < MaxUnits; i++ {
+ idx := i + 1
+ ctrl := plant.Control{}
+ key := ""
+ on := 0.0
+ if i < len(f.slots) {
+ key = f.slots[i]
+ on = 1
+ for _, m := range in.Members {
+ if m.Key == key {
+ ctrl = m.Control
+ break
+ }
+ }
+ }
+ sets[fmt.Sprintf("u_on[%d]", idx)] = on
+ sets[fmt.Sprintf("u_mode[%d]", idx)] = float64(ctrl.EMSMode)
+ sets[fmt.Sprintf("u_cmd[%d]", idx)] = float64(ctrl.BatteryCmd)
+ sets[fmt.Sprintf("u_sp[%d]", idx)] = ctrl.BatterySetpointW
+ sets[fmt.Sprintf("u_self_batt[%d]", idx)] = selfBatt[i]
+ limit := 0.0
+ if ctrl.PVLimitEnable {
+ limit = ctrl.PVLimitW
+ }
+ sets[fmt.Sprintf("u_pv_limit[%d]", idx)] = limit
+ }
+ if err := f.setReals(sets); err != nil {
+ return plant.SiteResult{}, err
+ }
+ if !f.ready {
+ if st := C.siteplant_enter_init(f.h, f.c); st != C.fmi2OK {
+ return plant.SiteResult{}, fmt.Errorf("fmi2EnterInitializationMode %d", int(st))
+ }
+ if st := C.siteplant_exit_init(f.h, f.c); st != C.fmi2OK {
+ return plant.SiteResult{}, fmt.Errorf("fmi2ExitInitializationMode %d", int(st))
+ }
+ f.ready = true
+ }
+ if dt > 0 {
+ if st := C.siteplant_dostep(f.h, f.c, C.double(f.t), C.double(dt)); st != C.fmi2OK {
+ return plant.SiteResult{}, fmt.Errorf("fmi2DoStep t=%v dt=%v status %d", f.t, dt, int(st))
+ }
+ f.t += dt
+ }
+ gets := []string{"P_meter"}
+ for i := 1; i <= MaxUnits; i++ {
+ gets = append(gets,
+ fmt.Sprintf("P_pv[%d]", i),
+ fmt.Sprintf("P_batt[%d]", i),
+ fmt.Sprintf("soc[%d]", i),
+ )
+ }
+ vals, err := f.getReals(gets)
+ if err != nil {
+ return plant.SiteResult{}, err
+ }
+ out := plant.SiteResult{
+ GridW: vals["P_meter"],
+ LoadW: math.Max(0, in.LoadW),
+ EVW: math.Max(0, in.EVW),
+ Members: make([]plant.MemberResult, 0, len(f.slots)),
+ }
+ var check float64
+ check += out.GridW + out.LoadW + out.EVW
+ for i, key := range f.slots {
+ idx := i + 1
+ pPV := vals[fmt.Sprintf("P_pv[%d]", idx)]
+ pBatt := vals[fmt.Sprintf("P_batt[%d]", idx)]
+ soc := vals[fmt.Sprintf("soc[%d]", idx)]
+ check += pPV + pBatt
+ u := f.units[key]
+ vBatt := 48.0
+ ambient := in.Weather.AmbientC
+ if ambient == 0 {
+ ambient = 20
+ }
+ invC := ambient + 10
+ if u != nil {
+ if u.Params.BatteryNominalV > 0 {
+ vBatt = u.Params.BatteryNominalV
+ }
+ u.State.SOC = soc
+ invC = u.State.InverterC
+ }
+ aBatt := 0.0
+ if pBatt != 0 && vBatt != 0 {
+ aBatt = math.Abs(pBatt) / vBatt
+ }
+ mem := plant.Outputs{
+ GridW: out.GridW,
+ PVW: pPV,
+ BatteryW: pBatt,
+ LoadW: out.LoadW,
+ EVW: out.EVW,
+ SOC: soc,
+ GridHz: 50,
+ BatteryV: vBatt,
+ BatteryA: aBatt,
+ InverterC: invC,
+ BatteryC: ambient + 5,
+ ResidualW: out.GridW + pPV + pBatt + out.LoadW + out.EVW,
+ }
+ if pPV < 0 {
+ half := -pPV / 2
+ v := 300.0
+ if in.Weather.GHIWm2 > 0 {
+ v = 300 + 80*(in.Weather.GHIWm2/1000)
+ }
+ a := 0.0
+ if v > 0 {
+ a = half / v
+ }
+ mem.MPPTs = [2]plant.MPPT{{V: v, A: a, W: half}, {V: v, A: a, W: half}}
+ mem.PVDCW = -pPV
+ }
+ out.Members = append(out.Members, plant.MemberResult{Key: key, Out: mem})
+ }
+ out.ResidualW = check
+ return out, nil
+}
+
+func planSelfBatt(f *FMU, in plant.SiteStep) [MaxUnits]float64 {
+ var selfBatt [MaxUnits]float64
+ pLoad := math.Max(0, in.LoadW)
+ pEV := math.Max(0, in.EVW)
+ var pPVsum, pBattEMS, slackChg, slackDis float64
+ type row struct {
+ i int
+ self bool
+ pPV float64
+ maxChg float64
+ maxDis float64
+ pBatt float64
+ }
+ rows := make([]row, 0, MaxUnits)
+ for i, key := range f.slots {
+ u := f.units[key]
+ if u == nil {
+ u = plant.New(plant.DefaultParams())
+ }
+ ctrl := plant.Control{}
+ for _, m := range in.Members {
+ if m.Key == key {
+ ctrl = m.Control
+ break
+ }
+ }
+ pPV, _ := u.PreviewPV(in.Weather, ctrl)
+ self := ctrl.EMSMode != plant.ModeForced && ctrl.EMSMode != plant.ModeEMS
+ pBatt := 0.0
+ maxChg := u.Params.MaxChargeW
+ maxDis := u.Params.MaxDischargeW
+ if ctrl.MaxChargeW > 0 {
+ maxChg = ctrl.MaxChargeW
+ }
+ if ctrl.MaxDischargeW > 0 {
+ maxDis = ctrl.MaxDischargeW
+ }
+ if !self {
+ switch ctrl.BatteryCmd {
+ case plant.CmdCharge:
+ pBatt = math.Abs(ctrl.BatterySetpointW)
+ case plant.CmdDischarge:
+ pBatt = -math.Abs(ctrl.BatterySetpointW)
+ }
+ }
+ rows = append(rows, row{i: i, self: self, pPV: pPV, maxChg: maxChg, maxDis: maxDis, pBatt: pBatt})
+ pPVsum += pPV
+ if self {
+ slackChg += maxChg
+ slackDis += maxDis
+ } else {
+ pBattEMS += pBatt
+ }
+ }
+ wantBatt := -(pLoad + pEV + pPVsum + pBattEMS)
+ for _, r := range rows {
+ if !r.self {
+ selfBatt[r.i] = r.pBatt
+ continue
+ }
+ share := 0.0
+ if wantBatt > 0 && slackChg > 0 {
+ share = wantBatt * (r.maxChg / slackChg)
+ } else if wantBatt < 0 && slackDis > 0 {
+ share = wantBatt * (r.maxDis / slackDis)
+ }
+ selfBatt[r.i] = share
+ }
+ return selfBatt
+}
+
+func (f *FMU) setReals(vals map[string]float64) error {
+ vrs := make([]C.fmi2ValueReference, 0, len(vals))
+ vs := make([]C.fmi2Real, 0, len(vals))
+ for name, v := range vals {
+ vr, ok := f.vr[name]
+ if !ok {
+ return fmt.Errorf("fmu missing variable %s", name)
+ }
+ vrs = append(vrs, C.fmi2ValueReference(vr))
+ vs = append(vs, C.fmi2Real(v))
+ }
+ if len(vrs) == 0 {
+ return nil
+ }
+ st := C.siteplant_set_real(f.h, f.c, &vrs[0], C.size_t(len(vrs)), &vs[0])
+ if st != C.fmi2OK {
+ return fmt.Errorf("fmi2SetReal status %d", int(st))
+ }
+ return nil
+}
+
+func (f *FMU) getReals(names []string) (map[string]float64, error) {
+ vrs := make([]C.fmi2ValueReference, len(names))
+ vs := make([]C.fmi2Real, len(names))
+ for i, name := range names {
+ vr, ok := f.vr[name]
+ if !ok {
+ return nil, fmt.Errorf("fmu missing variable %s", name)
+ }
+ vrs[i] = C.fmi2ValueReference(vr)
+ }
+ st := C.siteplant_get_real(f.h, f.c, &vrs[0], C.size_t(len(vrs)), &vs[0])
+ if st != C.fmi2OK {
+ return nil, fmt.Errorf("fmi2GetReal status %d", int(st))
+ }
+ out := make(map[string]float64, len(names))
+ for i, name := range names {
+ out[name] = float64(vs[i])
+ }
+ return out, nil
+}
+
+func unzip(src, dest string) error {
+ r, err := zip.OpenReader(src)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ root := filepath.Clean(dest) + string(os.PathSeparator)
+ for _, f := range r.File {
+ name := filepath.Clean(f.Name)
+ if name == "." || strings.HasPrefix(name, "..") {
+ continue
+ }
+ path := filepath.Join(dest, name)
+ if path != filepath.Clean(dest) && !strings.HasPrefix(path, root) {
+ continue
+ }
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(path, 0o755); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ rc, err := f.Open()
+ if err != nil {
+ return err
+ }
+ w, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode())
+ if err != nil {
+ rc.Close()
+ return err
+ }
+ _, err = io.Copy(w, rc)
+ w.Close()
+ rc.Close()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/siteplant/fmu_stub.go b/siteplant/fmu_stub.go
new file mode 100644
index 0000000..2925f42
--- /dev/null
+++ b/siteplant/fmu_stub.go
@@ -0,0 +1,13 @@
+//go:build !cgo || !linux || !amd64
+
+package siteplant
+
+import (
+ "fmt"
+
+ "github.com/srcfl/device-simulator/internal/plant"
+)
+
+func LoadFMU(path string) (plant.Model, error) {
+ return nil, fmt.Errorf("siteplant: FMU loader needs CGO on linux/amd64 (file %s)", path)
+}
diff --git a/siteplant/fmu_test.go b/siteplant/fmu_test.go
new file mode 100644
index 0000000..a734c12
--- /dev/null
+++ b/siteplant/fmu_test.go
@@ -0,0 +1,119 @@
+//go:build cgo && linux && amd64
+
+package siteplant
+
+import (
+ "math"
+ "testing"
+ "time"
+
+ "github.com/srcfl/device-simulator/internal/plant"
+)
+
+func TestFMUSiteStep(t *testing.T) {
+ path := testFMU(t)
+ m, err := LoadFMU(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if closer, ok := m.(interface{ Close() }); ok {
+ defer closer.Close()
+ }
+ if m.Backend() != "fmu" {
+ t.Fatalf("backend %s", m.Backend())
+ }
+ u := plant.New(plant.DefaultParams())
+ m.Attach("inv-a", u)
+
+ noon := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
+ out, err := m.StepSite(60, plant.SiteStep{
+ Time: noon,
+ Weather: plant.Weather{GHIWm2: 0, AmbientC: 20},
+ LoadW: 0,
+ Members: []plant.MemberStep{{
+ Key: "inv-a",
+ Control: plant.Control{
+ EMSMode: plant.ModeEMS,
+ BatteryCmd: plant.CmdStop,
+ BatterySetpointW: 0,
+ MaxSOC: 100,
+ },
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if math.Abs(out.ResidualW) > 1 {
+ t.Fatalf("residual %v members=%+v", out.ResidualW, out.Members)
+ }
+ if math.Abs(out.GridW) > 1 {
+ t.Fatalf("idle grid %v, want ~0", out.GridW)
+ }
+
+ out, err = m.StepSite(1, plant.SiteStep{
+ Time: noon,
+ Weather: plant.Weather{GHIWm2: 1000, AmbientC: 20},
+ Members: []plant.MemberStep{{
+ Key: "inv-a",
+ Control: plant.Control{
+ EMSMode: plant.ModeEMS,
+ BatteryCmd: plant.CmdStop,
+ MaxSOC: 100,
+ },
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(out.Members) != 1 {
+ t.Fatalf("members %d", len(out.Members))
+ }
+ if out.Members[0].Out.PVW >= -100 {
+ t.Fatalf("expected PV generation, got %v", out.Members[0].Out.PVW)
+ }
+ if math.Abs(out.GridW+out.Members[0].Out.PVW) > 5 {
+ t.Fatalf("grid %v pv %v should sum to 0", out.GridW, out.Members[0].Out.PVW)
+ }
+ if math.Abs(out.ResidualW) > 5 {
+ t.Fatalf("residual %v", out.ResidualW)
+ }
+}
+
+func TestOpenMuxFallsBackPastFourUnits(t *testing.T) {
+ path := testFMU(t)
+ m, err := Open(Options{FMU: path})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Backend() != "fmu" {
+ t.Fatalf("backend %s", m.Backend())
+ }
+ members := make([]plant.MemberStep, 5)
+ for i := 0; i < 5; i++ {
+ key := string(rune('a' + i))
+ m.Attach(key, plant.New(plant.DefaultParams()))
+ members[i] = plant.MemberStep{
+ Key: key,
+ Control: plant.Control{EMSMode: plant.ModeEMS, BatteryCmd: plant.CmdStop, MaxSOC: 100},
+ }
+ }
+ out, err := m.StepSite(1, plant.SiteStep{
+ Weather: plant.Weather{AmbientC: 20},
+ LoadW: 1000,
+ Members: members,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(out.Members) != 5 {
+ t.Fatalf("native fallback should keep 5 members, got %d", len(out.Members))
+ }
+}
+
+func testFMU(t *testing.T) string {
+ t.Helper()
+ if p := lookupDefaultFMU(); p != "" {
+ return p
+ }
+ return compileTestFMU(t)
+}
diff --git a/siteplant/open.go b/siteplant/open.go
new file mode 100644
index 0000000..f58b533
--- /dev/null
+++ b/siteplant/open.go
@@ -0,0 +1,124 @@
+// Package siteplant is the pluggable site physics backend.
+//
+// modelica/SiteEnergy.mo --(omc)--> Site4.fmu
+// |
+// siteplant.Open --> plant.Model
+// |
+// native Go DAE if no FMU / CGO / >4 units
+package siteplant
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+
+ "github.com/srcfl/device-simulator/internal/plant"
+)
+
+// MaxUnits is the inverter count compiled into SiteEnergy.Site4.
+const MaxUnits = 4
+
+// Options select which physics backend to plug in.
+type Options struct {
+ // FMU is a path to an FMI 2.0 CS FMU compiled from modelica/SiteEnergy.Site4.
+ // Empty means: SITEPLANT_FMU, then /modelica/Site4.fmu, then native.
+ // An explicit path (or SITEPLANT_FMU) fails closed if the FMU cannot load.
+ FMU string
+ // Native forces the Go backend even if an FMU is present.
+ Native bool
+}
+
+type mux struct {
+ fmu plant.Model
+ native *plant.NativeSite
+}
+
+func (m *mux) Backend() string {
+ if m.fmu != nil {
+ return m.fmu.Backend()
+ }
+ return m.native.Backend()
+}
+
+func (m *mux) Attach(key string, unit *plant.Plant) {
+ if m.fmu != nil {
+ m.fmu.Attach(key, unit)
+ }
+ m.native.Attach(key, unit)
+}
+
+func (m *mux) StepSite(dt float64, in plant.SiteStep) (plant.SiteResult, error) {
+ if m.fmu != nil && len(in.Members) <= MaxUnits {
+ return m.fmu.StepSite(dt, in)
+ }
+ return m.native.StepSite(dt, in)
+}
+
+// Open returns a site plant. The simulator talks only to plant.Model.
+//
+// Default (no FMU on disk, Docker CGO_ENABLED=0, macOS/Windows): native Go.
+// Linux amd64 with a compiled Site4.fmu: FMI 2.0 co-simulation, falling back
+// to native when the site has more than MaxUnits inverters.
+func Open(opts Options) (plant.Model, error) {
+ native := plant.NewNativeSite()
+ if opts.Native || os.Getenv("SITEPLANT_NATIVE") == "1" {
+ return native, nil
+ }
+
+ explicit := opts.FMU != ""
+ path := opts.FMU
+ if path == "" {
+ if env := os.Getenv("SITEPLANT_FMU"); env != "" {
+ path = env
+ explicit = true
+ }
+ }
+ if path == "" {
+ path = lookupDefaultFMU()
+ }
+ if path == "" {
+ return native, nil
+ }
+ if st, err := os.Stat(path); err != nil || st.IsDir() {
+ if explicit {
+ return nil, fmt.Errorf("siteplant: FMU not found: %s", path)
+ }
+ return native, nil
+ }
+
+ m, err := LoadFMU(path)
+ if err != nil {
+ if explicit {
+ return nil, fmt.Errorf("siteplant: fmu %s: %w", path, err)
+ }
+ return native, nil
+ }
+ return &mux{fmu: m, native: native}, nil
+}
+
+func lookupDefaultFMU() string {
+ var candidates []string
+ if _, file, _, ok := runtime.Caller(0); ok {
+ root := filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
+ candidates = append(candidates, filepath.Join(root, "modelica", "Site4.fmu"))
+ }
+ if wd, err := os.Getwd(); err == nil {
+ candidates = append(candidates, filepath.Join(wd, "modelica", "Site4.fmu"))
+ }
+ candidates = append(candidates, "modelica/Site4.fmu", "Site4.fmu")
+ seen := map[string]struct{}{}
+ for _, p := range candidates {
+ if p == "" {
+ continue
+ }
+ if _, ok := seen[p]; ok {
+ continue
+ }
+ seen[p] = struct{}{}
+ if st, err := os.Stat(p); err == nil && !st.IsDir() {
+ return p
+ }
+ }
+ return ""
+}
diff --git a/siteplant/open_test.go b/siteplant/open_test.go
new file mode 100644
index 0000000..24f0d3b
--- /dev/null
+++ b/siteplant/open_test.go
@@ -0,0 +1,111 @@
+package siteplant
+
+import (
+ "math"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/srcfl/device-simulator/internal/plant"
+)
+
+func TestOpenNative(t *testing.T) {
+ m, err := Open(Options{Native: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Backend() != "native" {
+ t.Fatalf("backend %s", m.Backend())
+ }
+ u := plant.New(plant.DefaultParams())
+ m.Attach("a", u)
+ out, err := m.StepSite(1, plant.SiteStep{
+ Time: time.Now(),
+ Weather: plant.Weather{},
+ LoadW: 1000,
+ Members: []plant.MemberStep{{
+ Key: "a",
+ Control: plant.Control{
+ EMSMode: plant.ModeEMS,
+ BatteryCmd: plant.CmdDischarge,
+ BatterySetpointW: 2500,
+ MaxSOC: 100,
+ },
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if math.Abs(out.GridW-1500) > 1 {
+ t.Fatalf("grid %v want 1500", out.GridW)
+ }
+}
+
+func TestOpenExplicitMissingFMU(t *testing.T) {
+ _, err := Open(Options{FMU: filepath.Join(t.TempDir(), "nope.fmu")})
+ if err == nil {
+ t.Fatal("expected error for missing explicit FMU")
+ }
+}
+
+func TestOpenEnvNative(t *testing.T) {
+ t.Setenv("SITEPLANT_NATIVE", "1")
+ t.Setenv("SITEPLANT_FMU", filepath.Join(t.TempDir(), "missing.fmu"))
+ m, err := Open(Options{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Backend() != "native" {
+ t.Fatalf("backend %s", m.Backend())
+ }
+}
+
+func TestCompileModelicaWhenAsked(t *testing.T) {
+ if os.Getenv("SITEPLANT_COMPILE") != "1" {
+ t.Skip("set SITEPLANT_COMPILE=1 to force omc export")
+ }
+ p := compileTestFMU(t)
+ if _, err := os.Stat(p); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func compileTestFMU(t *testing.T) string {
+ t.Helper()
+ if _, err := exec.LookPath("omc"); err != nil {
+ t.Skip("Site4.fmu not built and omc not on PATH")
+ }
+ _, file, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller")
+ }
+ src := filepath.Join(filepath.Dir(file), "..", "modelica", "SiteEnergy.mo")
+ body, err := os.ReadFile(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "SiteEnergy.mo"), body, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ mos := "loadFile(\"SiteEnergy.mo\"); getErrorString();\n" +
+ "buildModelFMU(SiteEnergy.Site4, version=\"2.0\", fmuType=\"cs\", fileNamePrefix=\"Site4\");\n" +
+ "getErrorString();\n"
+ if err := os.WriteFile(filepath.Join(dir, "export.mos"), []byte(mos), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("omc", "export.mos")
+ cmd.Dir = dir
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("omc: %v\n%s", err, out)
+ }
+ fmu := filepath.Join(dir, "Site4.fmu")
+ if _, err := os.Stat(fmu); err != nil {
+ t.Fatalf("omc did not write Site4.fmu:\n%s", out)
+ }
+ return fmu
+}
From a618d3edda6013254a9405638d683ceb3869a969 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 20 Aug 2026 05:01:02 +0000
Subject: [PATCH 5/5] Add a site simulator: facility presets, thermal plant,
and live schematic
The dashboard can instantiate a house or apartment (ASHP or GSHP, DHW,
occupancy) whose physics plant drives Sungrow and heat-pump Modbus facades
on the site clock, so an EMS can talk to it as if it were a real site.
Co-authored-by: Fredrik Ahlgren
---
CLAUDE.md | 7 +-
README.md | 2 +
cmd/simulator/app.go | 54 +++-
cmd/simulator/facility.go | 327 +++++++++++++++++++++++
cmd/simulator/main.go | 67 ++++-
cmd/simulator/static/css/plant-viz.css | 164 ++++++++++++
cmd/simulator/static/js/plant-viz.js | 128 +++++++++
cmd/simulator/static/js/stores.js | 47 +++-
cmd/simulator/static/openapi.json | 48 +++-
cmd/simulator/templates/docs.html | 6 +
cmd/simulator/templates/index.html | 141 ++++++++++
docs/LUA_DRIVER_PLANT.md | 7 +-
docs/SITE_PLANT_SKETCH.md | 7 +-
docs/SITE_SIMULATOR.md | 78 ++++++
internal/mcp/server.go | 76 ++++--
internal/modbus/devices/heatpump.go | 31 +++
internal/modbus/devices/heatpump_test.go | 48 ++++
internal/modbus/devices/registry.go | 3 +
internal/plant/facility.go | 124 +++++++++
internal/plant/model.go | 4 +
internal/plant/plant.go | 29 +-
internal/plant/site.go | 33 ++-
internal/plant/snapshot.go | 119 +++++++++
internal/plant/thermal.go | 253 ++++++++++++++++++
internal/plant/thermal_test.go | 209 +++++++++++++++
internal/profiles/loader.go | 46 +++-
internal/state/state.go | 1 +
modelica/README.md | 14 +-
modelica/SiteEnergy.mo | 90 +++++++
siteplant/open.go | 9 +
30 files changed, 2107 insertions(+), 65 deletions(-)
create mode 100644 cmd/simulator/facility.go
create mode 100644 cmd/simulator/static/css/plant-viz.css
create mode 100644 cmd/simulator/static/js/plant-viz.js
create mode 100644 docs/SITE_SIMULATOR.md
create mode 100644 internal/modbus/devices/heatpump.go
create mode 100644 internal/modbus/devices/heatpump_test.go
create mode 100644 internal/plant/facility.go
create mode 100644 internal/plant/snapshot.go
create mode 100644 internal/plant/thermal.go
create mode 100644 internal/plant/thermal_test.go
diff --git a/CLAUDE.md b/CLAUDE.md
index 98cc62d..78f844f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -99,6 +99,7 @@ The app supports two modes controlled by the `--desktop` flag:
- Pixii PowerShaper
- FerroAmp EnergyHub
- SDM630 Energy Meter
+- Generic SG Ready heat pump (site plant)
## API Endpoints
@@ -107,7 +108,7 @@ HTTP API (port 80 in Docker, 8762 in desktop mode):
```
GET / # Dashboard UI
GET /api/simulators # List all simulators
-POST /api/simulators # Create simulator {type: "inverter"|"energy_meter"|"v2x_charger"|"ocpp_charger"}
+POST /api/simulators # Create simulator {type: "inverter"|"energy_meter"|"heat_pump"|"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
@@ -119,6 +120,10 @@ POST /api/simulators/{id}/automation/stop # Stop data generation
POST /api/simulators/{id}/reset # Reset to defaults
GET /api/sites # List all sites
POST /api/sites # Create site
+GET /api/facilities # List facility presets
+GET /api/sites/{id}/facility # Get site facility
+POST /api/sites/{id}/facility # Apply facility preset (plant + devices)
+GET /api/sites/{id}/plant # Live electrical + thermal snapshot
GET /api/system # Get system info (local IP)
GET /api/version # Get app version
GET /api/update/check # Check for updates
diff --git a/README.md b/README.md
index 6cfbd98..ce7a237 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,7 @@ docker compose up --build
- **Device Profiles** - Sungrow, SolarEdge, Fronius, Huawei, and more
- **Time Acceleration** - Compress 24 hours into minutes for testing
- **Physical plant** - Site AC bus with conserved power and battery SoC. Modelica in `modelica/SiteEnergy.mo`; compile with `make fmu` and plug in via `siteplant`. Sketch: [docs/SITE_PLANT_SKETCH.md](docs/SITE_PLANT_SKETCH.md)
+- **Site simulator** - Facility presets (ASHP/GSHP, occupancy, DHW) behind Modbus facades so an EMS can treat the process as a real site. [docs/SITE_SIMULATOR.md](docs/SITE_SIMULATOR.md)
- **Real-time Logging** - See every protocol operation as it happens
## Supported Profiles
@@ -80,6 +81,7 @@ docker compose up --build
| deye | Deye hybrid inverter |
| pixii-powershaper | Pixii PowerShaper |
| ferroamp | FerroAmp EnergyHub |
+| heat-pump | Generic SG Ready heat pump (site plant) |
| sdm630 | SDM630 Energy Meter |
> **Disclaimer:** Device Simulator is an independent testing tool and is **not affiliated with, endorsed by, or sponsored by** any of the manufacturers listed above. Product and company names are trademarks of their respective owners and are used only to identify the device behavior being simulated.
diff --git a/cmd/simulator/app.go b/cmd/simulator/app.go
index aabbeb8..2e6ed29 100644
--- a/cmd/simulator/app.go
+++ b/cmd/simulator/app.go
@@ -9,6 +9,7 @@ import (
"github.com/srcfl/device-simulator/internal/modbus"
"github.com/srcfl/device-simulator/internal/modbus/devices"
+ "github.com/srcfl/device-simulator/internal/plant"
"github.com/srcfl/device-simulator/internal/settings"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
@@ -732,6 +733,7 @@ func (a *App) GetSites() []map[string]interface{} {
"time_multiplier": site.TimeMultiplier,
"sim_running": site.SimRunning,
"scenario": site.Scenario,
+ "facility_id": site.FacilityID,
}
p1Mode := P1ModeDirect
if site.P1Buffer != nil {
@@ -818,6 +820,8 @@ func (a *App) GetSite(id int) (map[string]interface{}, error) {
"phase_loads": site.SitePhaseLoads,
"load_scenario": site.LoadScenario,
"load_linked": site.LoadLinked,
+ "facility_id": site.FacilityID,
+ "plant": site.lastPlant,
}
meterID := site.MeterID
@@ -1181,15 +1185,15 @@ func (a *App) CreateSimulatorInSite(siteId int, simType string) (map[string]inte
a.emitSiteUpdate()
return map[string]interface{}{
- "id": sim.ID,
- "serial": sim.Serial,
- "protocol": sim.Protocol,
- "category": sim.Category,
- "port": sim.Port,
- "running": sim.Running,
- "site_id": siteId,
- "device_on": sim.DeviceOn,
- "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
+ "id": sim.ID,
+ "serial": sim.Serial,
+ "protocol": sim.Protocol,
+ "category": sim.Category,
+ "port": sim.Port,
+ "running": sim.Running,
+ "site_id": siteId,
+ "device_on": sim.DeviceOn,
+ "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
}, nil
}
@@ -1547,3 +1551,35 @@ func (a *App) SetSiteLoadScenario(id int, scenario string) (map[string]interface
return result, nil
}
+
+// GetFacilities returns site physics templates (heat pumps, loads, envelope).
+func (a *App) GetFacilities() []plant.Facility {
+ return plant.Presets()
+}
+
+// ApplySiteFacility instantiates a facility preset on a site (devices + plant).
+func (a *App) ApplySiteFacility(id int, preset string) (map[string]interface{}, error) {
+ site := getSite(id)
+ if site == nil {
+ return nil, fmt.Errorf("site %d not found", id)
+ }
+ if err := applyFacilityPreset(site, preset); err != nil {
+ return nil, err
+ }
+ if err := startSiteSimulationWithServers(site); err != nil {
+ log.Printf("[Site %d] start after facility: %v", id, err)
+ }
+ markStateDirty()
+ a.emitSimulatorUpdate()
+ a.emitSiteUpdate()
+ return sitePlantJSON(site), nil
+}
+
+// GetSitePlant returns the live physics snapshot for the schematic.
+func (a *App) GetSitePlant(id int) (map[string]interface{}, error) {
+ site := getSite(id)
+ if site == nil {
+ return nil, fmt.Errorf("site %d not found", id)
+ }
+ return sitePlantJSON(site), nil
+}
diff --git a/cmd/simulator/facility.go b/cmd/simulator/facility.go
new file mode 100644
index 0000000..abb7492
--- /dev/null
+++ b/cmd/simulator/facility.go
@@ -0,0 +1,327 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+
+ "github.com/srcfl/device-simulator/internal/modbus"
+ "github.com/srcfl/device-simulator/internal/modbus/devices"
+ "github.com/srcfl/device-simulator/internal/plant"
+)
+
+func handleFacilitiesList(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{"facilities": plant.Presets()})
+}
+
+func handleSiteFacility(w http.ResponseWriter, r *http.Request, site *Site) {
+ switch r.Method {
+ case http.MethodGet:
+ site.mu.RLock()
+ id := site.FacilityID
+ site.mu.RUnlock()
+ var fac plant.Facility
+ if f, ok := plant.Preset(id); ok {
+ fac = f
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "facility_id": id,
+ "facility": fac,
+ })
+ case http.MethodPost:
+ var req struct {
+ Preset string `json:"preset"`
+ Start *bool `json:"start"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+ if err := applyFacilityPreset(site, req.Preset); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ start := true
+ if req.Start != nil {
+ start = *req.Start
+ }
+ if start {
+ if err := startSiteSimulationWithServers(site); err != nil {
+ log.Printf("[Site %d] start after facility: %v", site.ID, err)
+ }
+ }
+ markStateDirty()
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(sitePlantJSON(site))
+ default:
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func handleSitePlant(w http.ResponseWriter, r *http.Request, site *Site) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(sitePlantJSON(site))
+}
+
+func sitePlantJSON(site *Site) map[string]interface{} {
+ site.mu.RLock()
+ snap := site.lastPlant
+ id := site.FacilityID
+ backend := ""
+ if site.sitePlant != nil {
+ backend = site.sitePlant.Backend()
+ }
+ site.mu.RUnlock()
+ if snap.Backend == "" {
+ snap.Backend = backend
+ }
+ if snap.FacilityID == "" {
+ snap.FacilityID = id
+ if f, ok := plant.Preset(id); ok {
+ snap.FacilityName = f.Name
+ }
+ }
+ return map[string]interface{}{
+ "facility_id": id,
+ "plant": snap,
+ "facilities": plant.Presets(),
+ }
+}
+
+func applyFacilityPreset(site *Site, presetID string) error {
+ spec, ok := plant.Preset(presetID)
+ if !ok {
+ return fmt.Errorf("unknown facility preset %q", presetID)
+ }
+
+ site.mu.Lock()
+ site.FacilityID = spec.ID
+ site.Scenario = "physical"
+ site.sitePlant = nil
+ site.lastPlant = plant.Snapshot{}
+ // Occupancy schedule is the household. Extra slider load starts at 0 so
+ // the default 1 kW site load does not double-count the plant.
+ site.SitePhaseLoads = PhaseLoads{}
+ site.BaseLoadW = 0
+ site.mu.Unlock()
+
+ if err := ensureFacilityInverter(site, spec); err != nil {
+ return err
+ }
+ if spec.HasHeatPump() {
+ if err := ensureFacilityHeatPump(site); err != nil {
+ return err
+ }
+ }
+
+ applyFacilityRatings(site, spec)
+
+ model := ensureSitePlant(site)
+ if host, ok := model.(interface{ SetFacility(plant.Facility) }); ok {
+ host.SetFacility(spec)
+ }
+
+ setSiteInverterPhysical(site)
+ log.Printf("[Site %d] facility %s", site.ID, spec.ID)
+ return nil
+}
+
+func applyFacilityRatings(site *Site, spec plant.Facility) {
+ site.mu.RLock()
+ ids := append([]int{}, site.SimulatorIDs...)
+ meter := site.MeterID
+ site.mu.RUnlock()
+ ids = append(ids, meter)
+ for _, id := range ids {
+ sim := getSimulator(id)
+ if sim == nil {
+ continue
+ }
+ sim.mu.RLock()
+ cat := sim.Category
+ sim.mu.RUnlock()
+ if cat != "inverter" {
+ continue
+ }
+ if spec.BatteryKWh > 0 {
+ sim.mu.Lock()
+ sim.BatteryCapacityKWh = spec.BatteryKWh
+ sim.mu.Unlock()
+ }
+ u := ensurePlant(sim)
+ if spec.PVRatedW > 0 {
+ u.Params.RatedPVW = spec.PVRatedW
+ u.Params.RatedACW = spec.PVRatedW
+ }
+ if spec.BatteryKWh > 0 {
+ u.Params.BatteryCapacityWh = spec.BatteryKWh * 1000
+ }
+ }
+}
+
+func ensureFacilityInverter(site *Site, spec plant.Facility) error {
+ if siteHasCategory(site, "inverter") {
+ return nil
+ }
+ sim, err := createSimulator("inverter")
+ if err != nil {
+ return err
+ }
+ applyProfileLocked(sim, "sungrow-hybrid")
+ if spec.BatteryKWh > 0 {
+ sim.mu.Lock()
+ sim.BatteryCapacityKWh = spec.BatteryKWh
+ sim.mu.Unlock()
+ }
+ attachSimulatorToSite(site, sim)
+ return nil
+}
+
+func ensureFacilityHeatPump(site *Site) error {
+ if siteHasCategory(site, "heat_pump") {
+ return nil
+ }
+ sim, err := createSimulator("heat_pump")
+ if err != nil {
+ return err
+ }
+ attachSimulatorToSite(site, sim)
+ return nil
+}
+
+func attachSimulatorToSite(site *Site, sim *Simulator) {
+ site.mu.Lock()
+ site.SimulatorIDs = append(site.SimulatorIDs, sim.ID)
+ siteID := site.ID
+ site.mu.Unlock()
+ sim.mu.Lock()
+ sim.SiteID = siteID
+ sim.mu.Unlock()
+}
+
+func siteHasCategory(site *Site, category string) bool {
+ site.mu.RLock()
+ ids := append([]int{}, site.SimulatorIDs...)
+ meter := site.MeterID
+ site.mu.RUnlock()
+ check := func(id int) bool {
+ sim := getSimulator(id)
+ if sim == nil {
+ return false
+ }
+ sim.mu.RLock()
+ defer sim.mu.RUnlock()
+ return sim.Category == category
+ }
+ if check(meter) {
+ return true
+ }
+ for _, id := range ids {
+ if check(id) {
+ return true
+ }
+ }
+ return false
+}
+
+func setSiteInverterPhysical(site *Site) {
+ for _, sim := range collectSiteInverters(site) {
+ sim.Automation.mu.Lock()
+ sim.Automation.Scenario = "physical"
+ sim.Automation.mu.Unlock()
+ }
+}
+
+func applyProfileLocked(sim *Simulator, name string) {
+ sim.mu.Lock()
+ defer sim.mu.Unlock()
+ profile := sim.ProfilesManager.Get(name)
+ if profile == nil {
+ return
+ }
+ sim.ProfilesManager.SetActive(name)
+ if sim.Protocol != "modbus" || sim.Server == nil {
+ return
+ }
+ if profile.RegisterSetName != "" {
+ if rs := devices.GetRegisterSet(profile.RegisterSetName); rs != nil {
+ sim.RegisterSet = rs
+ sim.Server.RegisterSet = rs
+ sim.Server.InitDefaultsFromSet(rs)
+ }
+ }
+ for addr, value := range profile.Defaults {
+ var def *modbus.RegisterDef
+ if sim.RegisterSet != nil {
+ def = sim.RegisterSet.Lookup[addr]
+ }
+ if def == nil {
+ def = modbus.RegisterLookup[addr]
+ }
+ if def != nil {
+ _ = sim.Server.WriteRegisterValue(def, value)
+ }
+ }
+}
+
+func collectSiteHeatPumps(site *Site) []*Simulator {
+ site.mu.RLock()
+ ids := append([]int{}, site.SimulatorIDs...)
+ site.mu.RUnlock()
+ var out []*Simulator
+ for _, id := range ids {
+ sim := getSimulator(id)
+ if sim == nil {
+ continue
+ }
+ sim.mu.RLock()
+ ok := sim.Running && sim.DeviceOn && sim.Category == "heat_pump" && sim.Server != nil
+ sim.mu.RUnlock()
+ if ok {
+ out = append(out, sim)
+ }
+ }
+ return out
+}
+
+func heatPumpCommandFromSite(site *Site) plant.HeatPumpCommand {
+ pumps := collectSiteHeatPumps(site)
+ if len(pumps) == 0 {
+ return plant.HeatPumpCommand{}
+ }
+ sim := pumps[0]
+ return plant.HeatPumpCommand{
+ Connected: true,
+ Enable: getSemanticValue(sim, "hp_enable") >= 0.5,
+ SetpointC: getSemanticValue(sim, "hp_setpoint"),
+ Mode: int(getSemanticValue(sim, "hp_mode")),
+ SGReady: int(getSemanticValue(sim, "hp_sg_ready")),
+ }
+}
+
+func applyHeatPumpOutputs(sim *Simulator, th plant.Thermal) {
+ setSemanticValue(sim, "indoor_temp", th.IndoorC)
+ setSemanticValue(sim, "outdoor_temp", th.AmbientC)
+ setSemanticValue(sim, "dhw_temp", th.DHWC)
+ setSemanticValue(sim, "hp_power", th.ElectricalW+th.BackupW)
+ setSemanticValue(sim, "hp_thermal", th.QHpW)
+ setSemanticValue(sim, "hp_cop", th.COP)
+ status := 0.0
+ if th.QDHWW > th.QSpaceW && th.QHpW > 10 {
+ status = 2
+ } else if th.QHpW > 10 {
+ status = 1
+ }
+ setSemanticValue(sim, "hp_status", status)
+}
diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go
index 297fe27..2c33d1a 100644
--- a/cmd/simulator/main.go
+++ b/cmd/simulator/main.go
@@ -164,6 +164,8 @@ type Site struct {
stopChan chan struct{}
simStopChan chan struct{} // Stop channel for simulation loop
sitePlant plant.Model
+ lastPlant plant.Snapshot
+ FacilityID string `json:"facility_id"`
}
var (
@@ -468,6 +470,9 @@ func createSimulator(simType string) (*Simulator, error) {
case "ocpp_charger":
protocol = "ocpp"
category = "ocpp_charger"
+ case "heat_pump":
+ protocol = "modbus"
+ category = "heat_pump"
default:
return nil, fmt.Errorf("unknown simulator type: %s", simType)
}
@@ -488,8 +493,11 @@ func createSimulator(simType string) (*Simulator, error) {
// Set default profile based on device type
if category == "energy_meter" {
- // Energy meters use the sdm630 profile (native gateway support)
pm.SetActive("sdm630")
+ } else if category == "heat_pump" {
+ pm.SetActive("heat-pump")
+ } else if category == "inverter" && pm.Get("sungrow-hybrid") != nil {
+ pm.SetActive("sungrow-hybrid")
} else if len(availableProfiles) > 0 {
pm.SetActive(availableProfiles[0].Name)
}
@@ -518,6 +526,8 @@ func createSimulator(simType string) (*Simulator, error) {
if category == "energy_meter" {
// Use numeric serial for meters (fits in SDM630's U32 register at 0xFC00)
serial = fmt.Sprintf("%d", 1000000+id)
+ } else if category == "heat_pump" {
+ serial = fmt.Sprintf("HPSIM%02d", id)
} else {
serial = fmt.Sprintf("INVSIM%02d", id)
}
@@ -696,6 +706,10 @@ func restoreSimulator(saved state.SimulatorState) (*Simulator, error) {
pm.SetActive(saved.Profile)
} else if category == "energy_meter" {
pm.SetActive("sdm630")
+ } else if category == "heat_pump" {
+ pm.SetActive("heat-pump")
+ } else if category == "inverter" && pm.Get("sungrow-hybrid") != nil {
+ pm.SetActive("sungrow-hybrid")
} else if len(availableProfiles) > 0 {
pm.SetActive(availableProfiles[0].Name)
}
@@ -917,6 +931,7 @@ func restoreSite(saved state.SiteState) (*Site, error) {
SitePhaseLoads: phaseLoads,
LoadScenario: saved.LoadScenario,
LoadLinked: saved.LoadLinked,
+ FacilityID: saved.FacilityID,
P1Buffer: newP1IntervalBuffer(),
}
if site.FuseSize == 0 {
@@ -1199,6 +1214,7 @@ func buildHTTPMux() *http.ServeMux {
// Load scenarios endpoint
mux.HandleFunc("/api/load-scenarios", handleLoadScenarios)
+ mux.HandleFunc("/api/facilities", handleFacilitiesList)
// MCP endpoints
mcpServer := mcp.NewServer(mux)
@@ -1892,8 +1908,9 @@ func handleProfiles(w http.ResponseWriter, r *http.Request, sim *Simulator) {
// Filter profiles by the simulator's protocol and category
var list []profiles.ProfileInfo
if sim.Category == "energy_meter" {
- // Energy meters only show meter-specific profiles
list = sim.ProfilesManager.ListByProtocolAndCategory(sim.Protocol, "meter")
+ } else if sim.Category == "heat_pump" {
+ list = sim.ProfilesManager.ListByProtocolAndCategory(sim.Protocol, "heat")
} else {
list = sim.ProfilesManager.ListByProtocol(sim.Protocol)
}
@@ -2348,6 +2365,7 @@ func handleSitesListGet(w http.ResponseWriter, r *http.Request) {
"time_multiplier": site.TimeMultiplier,
"sim_running": site.SimRunning,
"scenario": site.Scenario,
+ "facility_id": site.FacilityID,
}
// Add current meter reading if meter is valid
@@ -2477,6 +2495,10 @@ func handleSiteAPI(w http.ResponseWriter, r *http.Request) {
handleSitePhaseLoads(w, r, site)
case action == "load-scenario":
handleSiteLoadScenario(w, r, site)
+ case action == "facility":
+ handleSiteFacility(w, r, site)
+ case action == "plant":
+ handleSitePlant(w, r, site)
default:
http.Error(w, "Unknown action", http.StatusNotFound)
}
@@ -2510,6 +2532,8 @@ func handleSiteStatus(w http.ResponseWriter, r *http.Request, site *Site) {
"phase_loads": site.SitePhaseLoads,
"load_scenario": site.LoadScenario,
"load_linked": site.LoadLinked,
+ "facility_id": site.FacilityID,
+ "plant": site.lastPlant,
}
// Add current meter reading if meter is valid
@@ -2924,7 +2948,7 @@ func startSiteSimulationWithServers(site *Site) error {
}
}
// Only stop automation for inverters controlled by site simulation
- if sim.Category == "inverter" {
+ if sim.Category == "inverter" || sim.Category == "heat_pump" {
stopSimulatorAutomation(sim)
}
}
@@ -3537,20 +3561,28 @@ func siteUsesPhysicalPlant(site *Site) bool {
}
site.mu.RLock()
defer site.mu.RUnlock()
- return site.Scenario == "physical"
+ return site.Scenario == "physical" || site.FacilityID != ""
}
func ensureSitePlant(site *Site) plant.Model {
site.mu.Lock()
defer site.mu.Unlock()
if site.sitePlant == nil {
- m, err := siteplant.Open(siteplant.Options{})
+ opts := siteplant.Options{Native: site.FacilityID != ""}
+ m, err := siteplant.Open(opts)
if err != nil {
log.Printf("[Site %d] siteplant: %v; using native Go plant", site.ID, err)
m = plant.NewNativeSite()
}
+ if site.FacilityID != "" {
+ if f, ok := plant.Preset(site.FacilityID); ok {
+ if host, ok := m.(interface{ SetFacility(plant.Facility) }); ok {
+ host.SetFacility(f)
+ }
+ }
+ }
site.sitePlant = m
- log.Printf("[Site %d] plant backend %s", site.ID, m.Backend())
+ log.Printf("[Site %d] plant backend %s facility %s", site.ID, m.Backend(), site.FacilityID)
}
return site.sitePlant
}
@@ -3596,9 +3628,6 @@ func runSitePlantAndApply(site *Site, simDelta time.Duration) (gridW float64, ok
return 0, false
}
inverters := collectSiteInverters(site)
- if len(inverters) == 0 {
- return 0, false
- }
site.mu.RLock()
simTime := site.SimulatedTime
loadW := site.SitePhaseLoads.Total()
@@ -3606,7 +3635,11 @@ func runSitePlantAndApply(site *Site, simDelta time.Duration) (gridW float64, ok
loadW = site.BaseLoadW
}
scenario := site.Scenario
+ facilityID := site.FacilityID
site.mu.RUnlock()
+ if len(inverters) == 0 && facilityID == "" {
+ return 0, false
+ }
if simTime.IsZero() {
simTime = time.Now()
}
@@ -3643,6 +3676,7 @@ func runSitePlantAndApply(site *Site, simDelta time.Duration) (gridW float64, ok
LoadW: loadW,
ExportLimitEnable: exportLimit,
ExportLimitW: exportW,
+ HeatPump: heatPumpCommandFromSite(site),
Members: members,
})
if err != nil {
@@ -3660,6 +3694,13 @@ func runSitePlantAndApply(site *Site, simDelta time.Duration) (gridW float64, ok
}
applyPlantOutputs(sim, m.Out)
}
+ for _, hp := range collectSiteHeatPumps(site) {
+ applyHeatPumpOutputs(hp, res.Thermal)
+ }
+ snap := plant.BuildSnapshot(model.Backend(), facilityID, simTime, res)
+ site.mu.Lock()
+ site.lastPlant = snap
+ site.mu.Unlock()
return res.GridW, true
}
@@ -3678,8 +3719,8 @@ func generateValues(sim *Simulator, simTime time.Time) {
return
}
- // Energy meters don't generate their own values - they are controlled by site aggregation
- if sim.Category == "energy_meter" {
+ // Energy meters and heat pumps are driven by the site plant, not local sine waves.
+ if sim.Category == "energy_meter" || sim.Category == "heat_pump" {
return
}
@@ -3996,6 +4037,7 @@ func buildStateSnapshot() *state.AppState {
LoadScenario: site.LoadScenario,
LoadLinked: site.LoadLinked,
P1Mode: string(p1Mode),
+ FacilityID: site.FacilityID,
})
site.mu.RUnlock()
@@ -4252,6 +4294,9 @@ func getPowerContribution(sim *Simulator) float64 {
batteryPower := getBatterySignedPower(sim)
return -pv + batteryPower
}
+ if sim.Category == "heat_pump" {
+ return getSemanticValue(sim, "hp_power")
+ }
// For other devices (meters, etc.): load - pv + battery
load := getSemanticValue(sim, "load_power")
pv := getSemanticValue(sim, "pv_power")
diff --git a/cmd/simulator/static/css/plant-viz.css b/cmd/simulator/static/css/plant-viz.css
new file mode 100644
index 0000000..b419acd
--- /dev/null
+++ b/cmd/simulator/static/css/plant-viz.css
@@ -0,0 +1,164 @@
+/* Site plant schematic */
+
+.site-plant-band {
+ flex-shrink: 0;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+ padding: 10px 16px 12px;
+}
+
+.site-plant-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.site-plant-title {
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+}
+
+.site-plant-select {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color-light);
+ border-radius: 8px;
+ padding: 4px 8px;
+ font-size: 0.8rem;
+ font-family: inherit;
+}
+
+.site-plant-apply {
+ background: rgba(var(--color-primary-rgb), 0.15);
+ color: var(--color-primary);
+ border: 1px solid rgba(var(--color-primary-rgb), 0.35);
+ border-radius: 8px;
+ padding: 4px 10px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.site-plant-apply:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.site-plant-hint {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ margin-left: auto;
+}
+
+.site-plant-desc {
+ font-size: 0.72rem;
+ color: var(--text-secondary);
+ margin: 0 0 8px;
+}
+
+.site-plant-layout {
+ display: grid;
+ grid-template-columns: 1fr 220px;
+ gap: 12px;
+ align-items: stretch;
+}
+
+.site-plant-svg {
+ width: 100%;
+ height: 196px;
+ display: block;
+}
+
+.site-plant-node {
+ fill: var(--bg-elevated);
+ stroke: var(--border-color-light);
+ stroke-width: 1.2;
+}
+
+.site-plant-node.active {
+ stroke: var(--color-primary);
+ filter: drop-shadow(0 0 5px rgba(0, 255, 132, 0.45));
+}
+
+.site-plant-node.heat.active {
+ stroke: #ff9f43;
+ filter: drop-shadow(0 0 5px rgba(255, 159, 67, 0.45));
+}
+
+.site-plant-label {
+ fill: var(--text-secondary);
+ font-size: 10px;
+ font-family: Manrope, system-ui, sans-serif;
+ font-weight: 600;
+}
+
+.site-plant-value {
+ fill: var(--text-primary);
+ font-size: 11px;
+ font-family: "JetBrains Mono", ui-monospace, monospace;
+}
+
+.site-plant-flow {
+ fill: none;
+ stroke-linecap: round;
+ stroke-dasharray: 6 8;
+ animation: plant-flow 1.1s linear infinite;
+}
+
+.site-plant-flow.heat {
+ animation-duration: 1.6s;
+}
+
+.site-plant-flow.reverse {
+ animation-direction: reverse;
+}
+
+@keyframes plant-flow {
+ from { stroke-dashoffset: 28; }
+ to { stroke-dashoffset: 0; }
+}
+
+.site-plant-stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+}
+
+.site-plant-stat {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 8px 10px;
+}
+
+.site-plant-stat-k {
+ font-size: 0.65rem;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.site-plant-stat-v {
+ font-size: 0.92rem;
+ font-family: "JetBrains Mono", ui-monospace, monospace;
+ color: var(--text-primary);
+ margin-top: 2px;
+}
+
+.site-plant-stat-v.heat {
+ color: #ff9f43;
+}
+
+.site-plant-stat-v.teal {
+ color: var(--color-primary);
+}
+
+@media (max-width: 960px) {
+ .site-plant-layout {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/cmd/simulator/static/js/plant-viz.js b/cmd/simulator/static/js/plant-viz.js
new file mode 100644
index 0000000..95e8f3c
--- /dev/null
+++ b/cmd/simulator/static/js/plant-viz.js
@@ -0,0 +1,128 @@
+document.addEventListener('alpine:init', () => {
+ Alpine.data('sitePlantViz', () => ({
+ facilities: [],
+ selected: '',
+ applying: false,
+
+ async init() {
+ try {
+ this.facilities = await siteApi.getFacilities();
+ } catch (e) {
+ console.error('facilities', e);
+ this.facilities = [];
+ }
+ this.syncSelected();
+ this.$watch('$store.site.data', () => this.syncSelected());
+ },
+
+ syncSelected() {
+ const id = Alpine.store('site').data?.facility_id;
+ if (id) this.selected = id;
+ },
+
+ plant() {
+ return Alpine.store('site').data?.plant || {};
+ },
+
+ el() {
+ return this.plant().electrical || {};
+ },
+
+ th() {
+ return this.plant().thermal || {};
+ },
+
+ preset() {
+ return this.facilities.find(f => f.id === this.selected) || null;
+ },
+
+ hint() {
+ const p = this.plant();
+ if (p.facility_name) {
+ return p.facility_name + ' Β· ' + (p.backend || 'native');
+ }
+ return 'Physics behind the devices. EMS talks Modbus as if this were a real site.';
+ },
+
+ fmtW(w) {
+ const n = Number(w) || 0;
+ const a = Math.abs(n);
+ if (a >= 1000) return (n / 1000).toFixed(2) + ' kW';
+ return Math.round(n) + ' W';
+ },
+
+ fmtC(c) {
+ if (c === undefined || c === null || c === '') return 'β';
+ const n = Number(c);
+ if (!Number.isFinite(n)) return 'β';
+ if (n === 0 && this.th().indoor_c === undefined) return 'β';
+ return n.toFixed(1) + ' Β°C';
+ },
+
+ flowW(from, to) {
+ const flows = this.plant().flows || [];
+ const hit = flows.find(f => f.from === from && f.to === to);
+ return hit ? Math.abs(hit.w) : 0;
+ },
+
+ stroke(from, to, kind) {
+ const w = this.flowW(from, to);
+ if (w < 8) return 'stroke:transparent;stroke-width:0';
+ const width = Math.max(1.6, Math.min(8, w / 800));
+ const color = kind === 'heat' ? '#ff9f43' : '#00FF84';
+ return `stroke:${color};stroke-width:${width};opacity:0.9`;
+ },
+
+ nodeClass(key) {
+ const live = {
+ pv: Math.abs(this.el().pv_w) > 10,
+ batt: Math.abs(this.el().battery_w) > 10,
+ hp: (this.el().hp_w || 0) > 10,
+ house: (this.el().house_w || 0) > 10,
+ grid: Math.abs(this.el().grid_w) > 10,
+ building: (this.th().q_space_w || 0) > 10,
+ dhw: (this.th().q_dhw_w || 0) > 10 || (this.th().q_draw_w || 0) > 400,
+ outdoor: (this.th().q_loss_w || 0) > 10
+ };
+ const heat = key === 'hp' || key === 'building' || key === 'dhw' || key === 'outdoor';
+ let cls = 'site-plant-node';
+ if (live[key]) cls += ' active';
+ if (heat) cls += ' heat';
+ return cls;
+ },
+
+ gridLabel() {
+ const g = this.el().grid_w || 0;
+ if (g > 20) return 'import';
+ if (g < -20) return 'export';
+ return 'idle';
+ },
+
+ copLabel() {
+ const cop = Number(this.th().cop);
+ if (!cop) return '';
+ return ' Β· COP ' + cop.toFixed(1);
+ },
+
+ sgLabel() {
+ const n = Number(this.th().sg_ready) || 0;
+ return ['normal', 'block', 'rec', 'boost'][n] || String(n);
+ },
+
+ async applyFacility() {
+ const siteId = Alpine.store('site').selectedId;
+ if (!siteId || !this.selected) return;
+ this.applying = true;
+ try {
+ await siteApi.applyFacility(siteId, this.selected);
+ await Promise.all([
+ Alpine.store('site').loadSiteData(),
+ Alpine.store('app').loadSimulators()
+ ]);
+ } catch (e) {
+ await Alpine.store('confirm').alert('Facility', e.message || String(e));
+ }
+ this.applying = false;
+ }
+ }));
+});
diff --git a/cmd/simulator/static/js/stores.js b/cmd/simulator/static/js/stores.js
index dcca676..b14859a 100644
--- a/cmd/simulator/static/js/stores.js
+++ b/cmd/simulator/static/js/stores.js
@@ -626,6 +626,50 @@ const siteApi = {
throw new Error(text);
}
return resp.json();
+ },
+
+ async getFacilities() {
+ if (isWails && window.go?.main?.App?.GetFacilities) {
+ const data = await window.go.main.App.GetFacilities();
+ return Array.isArray(data) ? data : [];
+ }
+ const resp = await fetch('/api/facilities');
+ if (!resp.ok) {
+ const text = await resp.text();
+ throw new Error(text);
+ }
+ const data = await resp.json();
+ if (Array.isArray(data)) return data;
+ if (Array.isArray(data.facilities)) return data.facilities;
+ return [];
+ },
+
+ async applyFacility(siteId, preset) {
+ if (isWails && window.go?.main?.App?.ApplySiteFacility) {
+ return await window.go.main.App.ApplySiteFacility(siteId, preset);
+ }
+ const resp = await fetch(`/api/sites/${siteId}/facility`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ preset, start: true })
+ });
+ if (!resp.ok) {
+ const text = await resp.text();
+ throw new Error(text);
+ }
+ return resp.json();
+ },
+
+ async getSitePlant(siteId) {
+ if (isWails && window.go?.main?.App?.GetSitePlant) {
+ return await window.go.main.App.GetSitePlant(siteId);
+ }
+ const resp = await fetch(`/api/sites/${siteId}/plant`);
+ if (!resp.ok) {
+ const text = await resp.text();
+ throw new Error(text);
+ }
+ return resp.json();
}
};
@@ -857,6 +901,7 @@ const storage = {
const categories = {
inverter: { name: 'Inverters', icon: 'β‘', collapsed: false },
energy_meter: { name: 'Energy Meters', icon: 'π', collapsed: false },
+ heat_pump: { name: 'Heat Pumps', icon: 'π₯', collapsed: false },
v2x_charger: { name: 'V2X Chargers', icon: 'π', collapsed: false },
ocpp_charger: { name: 'OCPP Chargers', icon: 'π', collapsed: false }
};
@@ -1115,7 +1160,7 @@ document.addEventListener('alpine:init', () => {
// Get simulators for selected site, sorted flat by category priority then port
getSiteSortedSimulators() {
- const priority = { energy_meter: 1, inverter: 2, v2x_charger: 3, ocpp_charger: 4 };
+ const priority = { energy_meter: 1, inverter: 2, heat_pump: 3, v2x_charger: 4, ocpp_charger: 5 };
return this.getSiteSimulators().slice().sort((a, b) => {
const pa = priority[a.category] || 99;
const pb = priority[b.category] || 99;
diff --git a/cmd/simulator/static/openapi.json b/cmd/simulator/static/openapi.json
index dec0416..fe44138 100644
--- a/cmd/simulator/static/openapi.json
+++ b/cmd/simulator/static/openapi.json
@@ -3,7 +3,7 @@
"info": {
"title": "Device Simulator API",
"version": "dev",
- "description": "HTTP API for the Device Simulator."
+ "description": "HTTP API for the Device Simulator and site plant (electrical + thermal physics behind Modbus facades)."
},
"servers": [
{ "url": "/" }
@@ -421,6 +421,52 @@
"responses": { "200": { "description": "OK" } }
}
},
+ "/api/facilities": {
+ "get": {
+ "summary": "List facility presets (heat pumps, loads, envelope)",
+ "responses": {
+ "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+ "/api/sites/{id}/facility": {
+ "get": {
+ "summary": "Get the site's facility preset",
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "responses": { "200": { "description": "OK" } }
+ },
+ "post": {
+ "summary": "Apply a facility preset (plant + inverter + heat pump facades)",
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "preset": { "type": "string", "description": "house-ashp | house-gshp | apartment-ashp | pv-battery" },
+ "start": { "type": "boolean", "description": "Start site simulation (default true)" }
+ }
+ }
+ }
+ }
+ },
+ "responses": { "200": { "description": "Applied" } }
+ }
+ },
+ "/api/sites/{id}/plant": {
+ "get": {
+ "summary": "Get live site-plant snapshot (electrical + thermal + flows)",
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "responses": { "200": { "description": "OK" } }
+ }
+ },
"/api/system": {
"get": {
"summary": "Get system info",
diff --git a/cmd/simulator/templates/docs.html b/cmd/simulator/templates/docs.html
index aacb961..ddb2209 100644
--- a/cmd/simulator/templates/docs.html
+++ b/cmd/simulator/templates/docs.html
@@ -38,6 +38,12 @@
API Reference (OpenAPI)
This page renders the OpenAPI spec shipped with the app. If an endpoint changes, update
/openapi.json, the MCP tools, and these docs together.
+
+
Site simulator:GET /api/facilities,
+ POST /api/sites/{id}/facility, GET /api/sites/{id}/plant.
+ Apply a facility preset (house ASHP/GSHP, apartment, PV+battery) and the site clock
+ drives inverter + heat-pump Modbus facades from the physics plant.
+
Base URL:http://localhost:8762
Use this as the root for API requests (e.g. /api/simulators).