diff --git a/.gitignore b/.gitignore index 820b725..53cffc4 100644 --- a/.gitignore +++ b/.gitignore @@ -59,8 +59,10 @@ coverage.xml .pytest_cache/ cover/ -# Translations +# Translations (gettext). Keep Modelica sources. *.mo +!modelica/ +!modelica/*.mo *.pot # Django stuff: @@ -177,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/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/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 5a01c63..ce7a237 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ 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 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 @@ -79,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 257552f..2c33d1a 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -21,16 +21,18 @@ 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" "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" @@ -42,25 +44,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 } @@ -160,6 +163,9 @@ type Site struct { mu sync.RWMutex stopChan chan struct{} simStopChan chan struct{} // Stop channel for simulation loop + sitePlant plant.Model + lastPlant plant.Snapshot + FacilityID string `json:"facility_id"` } var ( @@ -167,10 +173,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 @@ -464,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) } @@ -484,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) } @@ -514,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) } @@ -692,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) } @@ -913,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 { @@ -1195,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) @@ -1469,13 +1489,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 +1517,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 @@ -1888,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) } @@ -2344,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 @@ -2473,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) } @@ -2506,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 @@ -2920,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) } } @@ -3438,6 +3466,244 @@ 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 siteUsesPhysicalPlant(site *Site) bool { + if site == nil { + return false + } + site.mu.RLock() + defer site.mu.RUnlock() + return site.Scenario == "physical" || site.FacilityID != "" +} + +func ensureSitePlant(site *Site) plant.Model { + site.mu.Lock() + defer site.mu.Unlock() + if site.sitePlant == nil { + 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 facility %s", site.ID, m.Backend(), site.FacilityID) + } + 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) + site.mu.RLock() + simTime := site.SimulatedTime + loadW := site.SitePhaseLoads.Total() + if loadW == 0 { + 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() + } + + 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, + HeatPump: heatPumpCommandFromSite(site), + 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) + } + 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 +} + func generateValues(sim *Simulator, simTime time.Time) { // Handle different protocols switch sim.Protocol { @@ -3453,18 +3719,23 @@ 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 } // 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 { @@ -3766,6 +4037,7 @@ func buildStateSnapshot() *state.AppState { LoadScenario: site.LoadScenario, LoadLinked: site.LoadLinked, P1Mode: string(p1Mode), + FacilityID: site.FacilityID, }) site.mu.RUnlock() @@ -4022,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") @@ -4227,49 +4502,59 @@ 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 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) + } } - 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 + // 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) + } } - generateValuesForSite(meterSim, simTime, meterScenario) } } } @@ -4300,6 +4585,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 @@ -4483,11 +4773,16 @@ 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 + 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 // Distribute PV generation (reduces load) and battery across phases pvBatteryNet := -pvPower + batteryPower @@ -4496,24 +4791,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) @@ -4544,9 +4839,9 @@ 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 { + if meterCategory == "inverter" && meterSim.Server != nil && !simulatorUsesPlant(meterSim) { updateBatterySOC(meterSim, simHours, meterCapacityKWh) accumulatePVEnergy(meterSim, simHours) updateBatteryFlags(meterSim) @@ -4571,6 +4866,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/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/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/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/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/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

+ +