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. + +![Site plant sketch](site-plant-sketch.svg) + +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: + +![Site plant sketch](site-plant-sketch.svg) + +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 @@ + + Site plant sketch + Lumped AC site bus with pluggable Modelica physics, vendor register maps, and Lua drivers over Modbus TCP. + + + + + + + + + + + + + + Site plant  sketch + One AC node. Modelica module on a plug. Lua drivers never see the plant, only Modbus. + + + + + Weather + GHI, T_amb + + + + House / EV + P_load, P_ev e 0 + + + + Lua setpoints + mode, cmd, watts, curtail + + + + + + + + + PLANT · modelica/SiteEnergy.mo = internal/plant.NativeSite + flow P into component · 0 = £ inv.P + load.P + ev.P + grid.P + + + + + Inverter 1 + PV d 0 batt +charge + ac.P = P_pv + P_batt + der(SoC) = · P / E + + + + Inverter N + same terminal + EMS follows Lua + mode 0 = site slack + + + + Load + P e 0 + + + + EV + P e 0 + + + + + BUS + + + + + + + + Grid slack · P_meter = grid.P + + + + + + plug: NativeSite | FMU + + + + VENDOR FACADE + no physics · addresses / scale / endian + + + + semantic names + pv_power battery_soc meter_power + + + + Sungrow / Huawei / & + internal/modbus/devices + + + + Modbus TCP + FC 03/04/06/10 · unit 1 + + + + watts + + + + + srcfl/device-drivers + sungrow.lua · driver_poll / driver_command + emit(pv, battery, meter) · never HTTP + + + + + + + Site clock · calculateAndUpdateMeter + Scenario Physical plant: one StepSite(dt) per tick, including time acceleration. + Master assigns mode-0 battery slack from live Lua writes  not inside the FMU. + + 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).
diff --git a/cmd/simulator/templates/index.html b/cmd/simulator/templates/index.html index d2571e1..545f051 100644 --- a/cmd/simulator/templates/index.html +++ b/cmd/simulator/templates/index.html @@ -14,10 +14,12 @@ + + @@ -394,6 +396,121 @@

Site Settings

+ +