#!/usr/bin/env python3 """ Generate the full Node-RED flow JSON for the 'pumpingstation-3pumps-dashboard' end-to-end example. The flow encodes: - 1 pumpingStation (basin model) - 1 machineGroupControl (orchestrator, optimal control mode) - 3 rotatingMachine (pumps, hidrostal-H05K-S03R curve) - 6 measurement nodes (per pump: upstream + downstream pressure, mbar, simulator mode so they tick continuously) - Process demand input (dashboard slider + random generator) routed to both pumpingStation (q_in) and MGC (Qd) - Mode toggle between AUTO (MGC drives pumps) and MANUAL (dashboard drives each pump individually) - Per-pump and station-wide dashboard groups with status, setpoint, flow / power gauges, and trend charts - Inject-driven setup at deploy time so the stack auto-wires children and starts in a known state This file is the SOURCE OF TRUTH for the demo. To regenerate flow.json: python3 build_flow.py > flow.json To deploy directly to a running Dockerized Node-RED: python3 build_flow.py | curl -s -X POST http://localhost:1880/flows \ -H "Content-Type: application/json" \ -H "Node-RED-Deployment-Type: full" \ --data-binary @- """ import json import sys TAB_ID = "ps_demo_tab" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def mk_id(name): return name.replace(" ", "_").replace("-", "_") def comment(node_id, x, y, name, info=""): return { "id": node_id, "type": "comment", "z": TAB_ID, "name": name, "info": info, "x": x, "y": y, "wires": [] } def inject(node_id, x, y, name, topic, payload, payload_type="str", once=False, repeat="", wires=None): # Use the per-prop v/vt form so the runtime correctly types the # payload (especially for payload_type='json' which otherwise gets # passed through as a plain string when only the legacy top-level # payload/payloadType fields are populated). return { "id": node_id, "type": "inject", "z": TAB_ID, "name": name, "props": [ {"p": "topic", "vt": "str"}, {"p": "payload", "v": str(payload), "vt": payload_type}, ], "topic": topic, "payload": str(payload), "payloadType": payload_type, "repeat": repeat, "crontab": "", "once": once, "onceDelay": "0.5", "x": x, "y": y, "wires": [wires or []], } def function_node(node_id, x, y, name, code, outputs=1, wires=None): return { "id": node_id, "type": "function", "z": TAB_ID, "name": name, "func": code, "outputs": outputs, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": x, "y": y, "wires": wires or [[] for _ in range(outputs)], } def change_node(node_id, x, y, name, rules, wires=None): return { "id": node_id, "type": "change", "z": TAB_ID, "name": name, "rules": rules, "action": "", "property": "", "from": "", "to": "", "reg": False, "x": x, "y": y, "wires": [wires or []], } def link_in(node_id, x, y, name, links): return { "id": node_id, "type": "link in", "z": TAB_ID, "name": name, "links": links, "x": x, "y": y, "wires": [[]] } def link_out(node_id, x, y, name, links): return { "id": node_id, "type": "link out", "z": TAB_ID, "name": name, "mode": "link", "links": links, "x": x, "y": y, "wires": [] } def debug_node(node_id, x, y, name, target="payload", target_type="msg", active=False): return { "id": node_id, "type": "debug", "z": TAB_ID, "name": name, "active": active, "tosidebar": True, "console": False, "tostatus": False, "complete": target, "targetType": target_type, "x": x, "y": y, "wires": [] } # --------------------------------------------------------------------------- # Dashboard scaffolding # --------------------------------------------------------------------------- def dashboard_scaffold(): base = { "id": "ui_base_ps_demo", "type": "ui-base", "name": "EVOLV Demo", "path": "/dashboard", "appIcon": "", "includeClientData": True, "acceptsClientConfig": ["ui-notification", "ui-control"], "showPathInSidebar": True, "headerContent": "page", "navigationStyle": "default", "titleBarStyle": "default" } theme = { "id": "ui_theme_ps_demo", "type": "ui-theme", "name": "EVOLV Theme", "colors": { "surface": "#ffffff", "primary": "#0f52a5", "bgPage": "#f4f6fa", "groupBg": "#ffffff", "groupOutline": "#cccccc" }, "sizes": { "density": "default", "pagePadding": "12px", "groupGap": "12px", "groupBorderRadius": "6px", "widgetGap": "8px" } } page = { "id": "ui_page_ps_demo", "type": "ui-page", "name": "Pumping Station — 3 Pumps", "ui": "ui_base_ps_demo", "path": "/pumping-station-demo", "icon": "water_pump", "layout": "grid", "theme": "ui_theme_ps_demo", "breakpoints": [{"name": "Default", "px": "0", "cols": "12"}], "order": 1, "className": "" } return [base, theme, page] def ui_group(group_id, name, page_id, width=6, order=1): return { "id": group_id, "type": "ui-group", "name": name, "page": page_id, "width": str(width), "height": "1", "order": order, "showTitle": True, "className": "", "groupType": "default", "disabled": False, "visible": True } def ui_text(node_id, x, y, group, name, label, fmt, layout="row-left"): return { "id": node_id, "type": "ui-text", "z": TAB_ID, "group": group, "order": 1, "width": "0", "height": "0", "name": name, "label": label, "format": fmt, "layout": layout, "style": False, "font": "", "fontSize": 14, "color": "#000000", "wires": [] } def ui_button(node_id, x, y, group, name, label, payload, payload_type, topic, color="#0f52a5", icon="play_arrow", wires=None): return { "id": node_id, "type": "ui-button", "z": TAB_ID, "group": group, "name": name, "label": label, "order": 1, "width": "0", "height": "0", "tooltip": "", "color": "#ffffff", "bgcolor": color, "className": "", "icon": icon, "iconPosition": "left", "payload": payload, "payloadType": payload_type, "topic": topic, "topicType": "str", "buttonType": "default", "x": x, "y": y, "wires": [wires or []] } def ui_slider(node_id, x, y, group, name, label, mn, mx, step=1.0, topic="", wires=None): return { "id": node_id, "type": "ui-slider", "z": TAB_ID, "group": group, "name": name, "label": label, "tooltip": "", "order": 1, "width": "0", "height": "0", "passthru": True, "outs": "end", "topic": topic, "topicType": "str", "min": str(mn), "max": str(mx), "step": str(step), "showLabel": True, "showValue": True, "labelPosition": "top", "valuePosition": "left", "thumbLabel": False, "iconStart": "", "iconEnd": "", "x": x, "y": y, "wires": [wires or []] } def ui_switch(node_id, x, y, group, name, label, on_value="auto", off_value="manual", topic="modeToggle", wires=None): return { "id": node_id, "type": "ui-switch", "z": TAB_ID, "group": group, "name": name, "label": label, "tooltip": "", "order": 1, "width": "0", "height": "0", "passthru": True, "decouple": "false", "topic": topic, "topicType": "str", "style": "", "className": "", "evaluate": "true", "onvalue": on_value, "onvalueType": "str", "onicon": "auto_mode", "oncolor": "#0f52a5", "offvalue": off_value, "offvalueType": "str", "officon": "back_hand", "offcolor": "#888888", "x": x, "y": y, "wires": [wires or []] } def ui_chart(node_id, x, y, group, name, label, series_topics, ymin=None, ymax=None): return { "id": node_id, "type": "ui-chart", "z": TAB_ID, "group": group, "name": name, "label": label, "order": 1, "chartType": "line", "category": "topic", "categoryType": "msg", "xAxisLabel": "", "xAxisType": "time", "xAxisTimeFormat": "auto", "yAxisLabel": "", "ymin": "" if ymin is None else str(ymin), "ymax": "" if ymax is None else str(ymax), "action": "append", "pointShape": "circle", "pointRadius": 2, "showLegend": True, "removeOlder": "10", "removeOlderUnit": "60", "removeOlderPoints": "200", "colors": [], "textColor": [], "textColorDefault": True, "width": "0", "height": "0", "className": "", "wires": [[]] } def ui_gauge(node_id, x, y, group, name, label, mn, mx, units, segments=None): return { "id": node_id, "type": "ui-gauge", "z": TAB_ID, "group": group, "name": name, "label": label, "order": 1, "width": "0", "height": "0", "min": str(mn), "max": str(mx), "type": "gauge", "units": units, "valueColor": "#0f52a5", "valueRound": True, "valueFontSize": 16, "labelFontSize": 12, "iconColor": "", "valueFormat": "value | number:1", "wires": [] } # --------------------------------------------------------------------------- # Build the flow # --------------------------------------------------------------------------- def build(): nodes = [] nodes += dashboard_scaffold() # ----- Tab ----- nodes.append({ "id": TAB_ID, "type": "tab", "label": "Pumping Station — 3 Pumps Demo", "disabled": False, "info": "End-to-end demo: pumpingStation + machineGroupControl + 3 rotatingMachines, each with upstream/downstream pressure sensors. Process demand input via dashboard slider OR auto random generator. Dashboard at /dashboard/pumping-station-demo." }) # ----- Dashboard groups ----- g_demand = "ui_grp_demand" g_station = "ui_grp_station" g_pump_a = "ui_grp_pump_a" g_pump_b = "ui_grp_pump_b" g_pump_c = "ui_grp_pump_c" g_trend = "ui_grp_trend" PG = "ui_page_ps_demo" nodes += [ ui_group(g_demand, "1. Process Demand", PG, width=12, order=1), ui_group(g_station, "2. Pumping Station", PG, width=12, order=2), ui_group(g_pump_a, "3a. Pump A", PG, width=4, order=3), ui_group(g_pump_b, "3b. Pump B", PG, width=4, order=4), ui_group(g_pump_c, "3c. Pump C", PG, width=4, order=5), ui_group(g_trend, "4. Trends", PG, width=12, order=6), ] # ----- Comments / sections ----- nodes.append(comment("c_title", 200, 40, "Pumping Station — 3 Pumps Demo", "Process demand → pumpingStation (basin model) + machineGroupControl (orchestrator) → 3 rotatingMachines. Each pump has upstream + downstream pressure measurements. Auto/Manual mode toggle on the dashboard." )) # =========================================================== # Backend node IDs # =========================================================== PS_ID = "ps_basin" MGC_ID = "mgc_pumps" PUMPS = ["pump_a", "pump_b", "pump_c"] PUMP_LABELS = {"pump_a": "Pump A", "pump_b": "Pump B", "pump_c": "Pump C"} PUMP_GROUPS = {"pump_a": g_pump_a, "pump_b": g_pump_b, "pump_c": g_pump_c} # =========================================================== # MEASUREMENT NODES (6 total — 2 per pump) # =========================================================== meas_nodes = [] for i, pump in enumerate(PUMPS): for j, pos in enumerate(("upstream", "downstream")): mid = f"meas_{pump}_{pos[0]}" x = 200 + j * 240 y = 1000 + i * 220 # Different ranges/setpoints so each pump has plausible pressure absmin, absmax = (50, 400) if pos == "upstream" else (800, 2200) mid_label = f"PT-{PUMP_LABELS[pump].split()[1]}-{'Up' if pos=='upstream' else 'Dn'}" nodes.append({ "id": mid, "type": "measurement", "z": TAB_ID, "name": mid_label, "mode": "analog", "channels": "[]", "scaling": False, "i_min": 0, "i_max": 1, "i_offset": 0, "o_min": absmin, "o_max": absmax, "simulator": True, "smooth_method": "mean", "count": "5", "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", "uuid": f"sensor-{pump}-{pos}", "supplier": "vega", "category": "sensor", "assetType": "pressure", "model": "vega-pressure-10", "unit": "mbar", "assetTagNumber": f"PT-{i+1}-{pos[0].upper()}", "enableLog": True, "logLevel": "warn", "positionVsParent": pos, "positionIcon": "", "hasDistance": False, "distance": 0, "distanceUnit": "m", "distanceDescription": "", "x": x, "y": y, "wires": [[], [], [pump]] # Port 2 -> pump }) meas_nodes.append(mid) # =========================================================== # ROTATING MACHINES (3 pumps) # =========================================================== for i, pump in enumerate(PUMPS): x = 700 y = 700 + i * 120 # Wires: port 0 -> pump-port-0 router; port 1 -> debug; port 2 -> MGC nodes.append({ "id": pump, "type": "rotatingMachine", "z": TAB_ID, "name": PUMP_LABELS[pump], "speed": "10", # 10 %/s ramp — fast enough for demo "startup": "2", "warmup": "1", "shutdown": "2", "cooldown": "1", "movementMode": "staticspeed", "machineCurve": "", "uuid": f"pump-{pump}", "supplier": "hidrostal", "category": "pump", "assetType": "pump-centrifugal", "model": "hidrostal-H05K-S03R", "unit": "m3/h", "curvePressureUnit": "mbar", "curveFlowUnit": "m3/h", "curvePowerUnit": "kW", "curveControlUnit": "%", "enableLog": True, "logLevel": "warn", "positionVsParent": "atEquipment", "positionIcon": "", "hasDistance": False, "distance": 0, "distanceUnit": "m", "distanceDescription": "", "x": x, "y": y, "wires": [ [f"router_p0_{pump}"], # port 0 process [], # port 1 dbase [MGC_ID], # port 2 -> MGC for registration ] }) # =========================================================== # MACHINE GROUP CONTROL # =========================================================== nodes.append({ "id": MGC_ID, "type": "machineGroupControl", "z": TAB_ID, "name": "MGC — Pump Group", "uuid": "mgc-pump-group", "category": "controller", "assetType": "machinegroupcontrol", "model": "default", "unit": "m3/h", "supplier": "evolv", "enableLog": True, "logLevel": "warn", "positionVsParent": "atEquipment", "positionIcon": "", "hasDistance": False, "distance": 0, "distanceUnit": "m", "distanceDescription": "", "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", "x": 1100, "y": 760, "wires": [ [], # port 0 process [], # port 1 dbase [PS_ID], # port 2 -> pumpingStation registration ] }) # =========================================================== # PUMPING STATION # =========================================================== nodes.append({ "id": PS_ID, "type": "pumpingStation", "z": TAB_ID, "name": "Pumping Station", "uuid": "ps-basin-1", "category": "station", "assetType": "pumpingstation", "model": "default", "unit": "m3/s", "supplier": "evolv", "enableLog": True, "logLevel": "info", "positionVsParent": "atEquipment", "positionIcon": "", "hasDistance": False, "distance": 0, "distanceUnit": "m", "distanceDescription": "", "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", # Default PS control mode 'levelbased' will auto-shut all pumps as # soon as basin level dips below stopLevel (1m default). For an # operator-driven demo we want PS to OBSERVE only — set it to # 'manual' from boot via the controlMode UI field. "controlMode": "manual", # Bigger basin so level metrics are interesting on the dashboard. "basinVolume": 50, "basinHeight": 4, # Disable safety guards. The defaults turn ON dry-run + overfill # protection which shut every pump as soon as basin vol drops below # 2% of minVol (or rises above 98% of maxVolOverflow). For an # operator-driven demo where there is no real inflow source, the # basin will frequently look "empty" — that's expected, not a fault. "enableDryRunProtection": False, "enableOverfillProtection": False, "dryRunThresholdPercent": 0, "overfillThresholdPercent": 100, "timeleftToFullOrEmptyThresholdSeconds": 0, "x": 1450, "y": 760, "wires": [ ["ps_to_dashboard"], # port 0 process -> dashboard formatter [], # port 1 dbase ] }) # =========================================================== # PROCESS DEMAND — slider + random generator + router # =========================================================== # Demand value enters a router that fans out to: # - pumpingStation (q_in) # - MGC (Qd) # - dashboard text + chart nodes.append(ui_slider( "ui_demand_slider", 100, 200, g_demand, "Process demand slider", "Process Demand (m³/h)", 0, 300, 5.0, "manualDemand", wires=["demand_router"] )) nodes.append(ui_switch( "ui_random_toggle", 100, 260, g_demand, "Random demand", "Random demand generator (auto)", on_value="on", off_value="off", topic="randomToggle", wires=["random_state"] )) nodes.append(ui_text( "ui_demand_text", 100, 320, g_demand, "Current demand text", "Current demand", "{{msg.payload}} m³/h", )) # Random generator: every 3s pick a value in [40, 240] m³/h. nodes.append(inject( "demand_rand_tick", 100, 380, "tick (random demand)", topic="randomTick", payload="", payload_type="date", repeat="3", wires=["demand_rand_fn"] )) nodes.append(function_node( "random_state", 280, 260, "store random state", "context.set('on', msg.payload === 'on'); return null;", outputs=0 )) nodes.append(function_node( "demand_rand_fn", 280, 380, "random demand", "if (!context.get('on')) { return null; }\n" "const v = Math.round(40 + Math.random() * 200);\n" "msg.payload = v;\n" "msg.topic = 'manualDemand';\n" "return msg;", outputs=1, wires=[["demand_router", "ui_demand_text"]] )) # Router: fan out current demand to pumpingStation (q_in) and MGC (Qd). # NOTE: We only forward when demand > 0. A demand of 0 would # - shut every running pump via MGC.turnOffAllMachines (Qd=0 path), and # - drive the basin level toward 'draining', which makes # pumpingStation._applyLevelBasedControl call group.turnOffAllMachines # ANYWAY, even without the MGC Qd wire. # The single guard below short-circuits the whole chain so the demo # doesn't auto-shutdown pumps when no operator demand has been entered yet. # Use the Stop All button to actually take pumps down. nodes.append(function_node( "demand_router", 480, 260, "fan-out demand", "const v = Number(msg.payload);\n" "if (!Number.isFinite(v) || v <= 0) return null;\n" "// Feed pumpingStation q_in (m3/s canonical) and MGC Qd (m3/h).\n" "const qin = { topic: 'q_in', payload: v / 3600, unit: 'm3/s' };\n" "const qd = { topic: 'Qd', payload: v };\n" "const text = { topic: 'demand', payload: v };\n" "return [qin, qd, text];", outputs=3, wires=[[PS_ID], [MGC_ID, "dbg_demand_to_mgc"], ["ui_demand_text"]] )) nodes.append(debug_node("dbg_demand_to_mgc", 700, 320, "→ MGC Qd", target="payload", active=True)) # =========================================================== # MODE toggle (auto / manual) # =========================================================== nodes.append(ui_switch( "ui_mode_toggle", 100, 460, g_station, "Auto/Manual mode", "Mode (Auto = MGC orchestrates · Manual = dashboard per-pump)", on_value="auto", off_value="virtualControl", topic="setMode", wires=["mode_fanout"] )) nodes.append(function_node( "mode_fanout", 320, 460, "broadcast setMode to all pumps", "msg.topic = 'setMode';\n" "// Send same setMode payload to all 3 pumps.\n" "return [msg, msg, msg];", outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]] )) # =========================================================== # STATION-WIDE BUTTONS # =========================================================== nodes.append(ui_button( "btn_station_startup", 100, 520, g_station, "Start all pumps", "Startup all", "startup", "str", topic="stationStartup", color="#16a34a", icon="play_arrow", wires=["station_startup_fan"] )) nodes.append(function_node( "station_startup_fan", 320, 520, "fan startup to pumps", "const cmd = { topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } };\n" "return [cmd, cmd, cmd];", outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]] )) nodes.append(ui_button( "btn_station_shutdown", 100, 580, g_station, "Stop all pumps", "Shutdown all", "shutdown", "str", topic="stationShutdown", color="#ea580c", icon="stop", wires=["station_shutdown_fan"] )) nodes.append(function_node( "station_shutdown_fan", 320, 580, "fan shutdown to pumps", "const cmd = { topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'shutdown' } };\n" "return [cmd, cmd, cmd];", outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]] )) nodes.append(ui_button( "btn_station_estop", 100, 640, g_station, "EMERGENCY STOP", "EMERGENCY STOP", "estop", "str", topic="stationEstop", color="#dc2626", icon="stop_circle", wires=["station_estop_fan"] )) nodes.append(function_node( "station_estop_fan", 320, 640, "fan estop to pumps", "const cmd = { topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } };\n" "return [cmd, cmd, cmd];", outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]] )) # =========================================================== # PER-PUMP DASHBOARD (status text + setpoint slider + buttons) # =========================================================== for i, pump in enumerate(PUMPS): g = PUMP_GROUPS[pump] label = PUMP_LABELS[pump] y_base = 100 + i * 320 # Status text — fed by router_p0_ formatter nodes.append(ui_text( f"ui_{pump}_state", 1500, y_base + 0, g, f"{label} state", "State", "{{msg.payload.state}}" )) nodes.append(ui_text( f"ui_{pump}_mode", 1500, y_base + 30, g, f"{label} mode", "Mode", "{{msg.payload.mode}}" )) nodes.append(ui_text( f"ui_{pump}_ctrl", 1500, y_base + 60, g, f"{label} ctrl", "Controller %", "{{msg.payload.ctrl}}" )) nodes.append(ui_text( f"ui_{pump}_flow", 1500, y_base + 90, g, f"{label} flow", "Flow (m³/h)", "{{msg.payload.flow}}" )) nodes.append(ui_text( f"ui_{pump}_power", 1500, y_base + 120, g, f"{label} power", "Power (kW)", "{{msg.payload.power}}" )) nodes.append(ui_text( f"ui_{pump}_pUp", 1500, y_base + 150, g, f"{label} pUp", "p Upstream (mbar)", "{{msg.payload.pUp}}" )) nodes.append(ui_text( f"ui_{pump}_pDn", 1500, y_base + 180, g, f"{label} pDn", "p Downstream (mbar)", "{{msg.payload.pDn}}" )) # Per-pump manual setpoint slider (only effective in virtualControl mode) nodes.append(ui_slider( f"ui_{pump}_setpoint", 100, y_base, g, f"{label} setpoint", "Setpoint % (manual mode)", 0, 100, 5.0, f"setpoint_{pump}", wires=[f"setpoint_to_pump_{pump}"] )) nodes.append(function_node( f"setpoint_to_pump_{pump}", 320, y_base, f"build setpoint cmd for {label}", "msg.topic = 'execMovement';\n" "msg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\n" "return msg;", outputs=1, wires=[[pump]] )) # Per-pump start/stop buttons nodes.append(ui_button( f"btn_{pump}_startup", 100, y_base + 60, g, f"{label} startup", "Startup", "startup", "str", topic=f"start_{pump}", color="#16a34a", icon="play_arrow", wires=[f"start_to_pump_{pump}"] )) nodes.append(function_node( f"start_to_pump_{pump}", 320, y_base + 60, f"build startup for {label}", "msg.topic = 'execSequence';\n" "msg.payload = { source: 'GUI', action: 'execSequence', parameter: 'startup' };\n" "return msg;", outputs=1, wires=[[pump]] )) nodes.append(ui_button( f"btn_{pump}_shutdown", 100, y_base + 120, g, f"{label} shutdown", "Shutdown", "shutdown", "str", topic=f"stop_{pump}", color="#ea580c", icon="stop", wires=[f"stop_to_pump_{pump}"] )) nodes.append(function_node( f"stop_to_pump_{pump}", 320, y_base + 120, f"build shutdown for {label}", "msg.topic = 'execSequence';\n" "msg.payload = { source: 'GUI', action: 'execSequence', parameter: 'shutdown' };\n" "return msg;", outputs=1, wires=[[pump]] )) # Port 0 router — merge delta updates into a per-pump cache and # produce a tidy {state, mode, ctrl, flow, power, pUp, pDn} object # for the dashboard widgets. nodes.append(function_node( f"router_p0_{pump}", 1100, y_base + 90, f"format {label} port 0", "const p = msg.payload || {};\n" "const c = context.get('c') || {};\n" "Object.assign(c, p);\n" "context.set('c', c);\n" "function find(prefix) {\n" " for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n" " return null;\n" "}\n" "const flow = find('flow.predicted.downstream.');\n" "const power = find('power.predicted.atequipment.');\n" "const pU = find('pressure.measured.upstream.');\n" "const pD = find('pressure.measured.downstream.');\n" "msg.payload = {\n" " state: c.state || 'idle',\n" " mode: c.mode || 'auto',\n" " ctrl: c.ctrl != null ? Number(c.ctrl).toFixed(1) + '%' : 'n/a',\n" " flow: flow != null ? Number(flow).toFixed(1) + ' m³/h' : 'n/a',\n" " power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n" " pUp: pU != null ? Number(pU).toFixed(0) : 'n/a',\n" " pDn: pD != null ? Number(pD).toFixed(0) : 'n/a',\n" " flowNum: flow != null ? Number(flow) : null,\n" " powerNum: power != null ? Number(power) : null\n" "};\n" "return msg;", outputs=1, wires=[[ f"ui_{pump}_state", f"ui_{pump}_mode", f"ui_{pump}_ctrl", f"ui_{pump}_flow", f"ui_{pump}_power", f"ui_{pump}_pUp", f"ui_{pump}_pDn", f"trend_split_{pump}" ]] )) # Trend feeders — one stream per metric for the chart nodes.append(function_node( f"trend_split_{pump}", 1300, y_base + 150, f"emit trend points for {label}", "const p = msg.payload || {};\n" "const out = [];\n" "if (p.flowNum != null) out.push({ topic: '" + label + " flow', payload: p.flowNum });\n" "if (p.powerNum != null) out.push({ topic: '" + label + " power', payload: p.powerNum });\n" "return [out];", outputs=1, wires=[["trend_chart_flow", "trend_chart_power"]] )) # =========================================================== # TREND CHARTS # =========================================================== nodes.append(ui_chart( "trend_chart_flow", 1500, 1450, g_trend, "Flow per pump (m³/h)", "Flow per pump", ["Pump A flow", "Pump B flow", "Pump C flow"] )) nodes.append(ui_chart( "trend_chart_power", 1500, 1500, g_trend, "Power per pump (kW)", "Power per pump", ["Pump A power", "Pump B power", "Pump C power"] )) # =========================================================== # PUMPING STATION DASHBOARD (basin level + total flow) # =========================================================== nodes.append(function_node( "ps_to_dashboard", 1700, 700, "format PS port 0 for dashboard", "const p = msg.payload || {};\n" "const c = context.get('c') || {};\n" "Object.assign(c, p);\n" "context.set('c', c);\n" "function find(prefix) {\n" " for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n" " return null;\n" "}\n" "const lvl = find('level.predicted.');\n" "const vol = find('volume.predicted.');\n" "const qIn = find('flow.measured.upstream.') || find('flow.measured.in.');\n" "const qOut = find('flow.measured.downstream.') || find('flow.measured.out.');\n" "msg.payload = {\n" " level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n" " volume: vol != null ? Number(vol).toFixed(1) + ' m³' : 'n/a',\n" " qIn: qIn != null ? (Number(qIn) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n" " qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n" " state: c.state || c.direction || 'idle',\n" " netNum: (qIn != null && qOut != null) ? (Number(qIn) - Number(qOut)) * 3600 : null\n" "};\n" "return msg;", outputs=1, wires=[[ "ui_ps_level", "ui_ps_volume", "ui_ps_qin", "ui_ps_qout", "ui_ps_state" ]] )) nodes.append(ui_text("ui_ps_state", 1900, 700, g_station, "PS state", "Basin state", "{{msg.payload.state}}")) nodes.append(ui_text("ui_ps_level", 1900, 730, g_station, "PS level", "Basin level", "{{msg.payload.level}}")) nodes.append(ui_text("ui_ps_volume", 1900, 760, g_station, "PS volume","Basin volume", "{{msg.payload.volume}}")) nodes.append(ui_text("ui_ps_qin", 1900, 790, g_station, "PS Qin", "Inflow", "{{msg.payload.qIn}}")) nodes.append(ui_text("ui_ps_qout", 1900, 820, g_station, "PS Qout", "Pumped out", "{{msg.payload.qOut}}")) # =========================================================== # SETUP INJECTS (fire once on deploy) # =========================================================== # MGC needs scaling=absolute + mode=optimalcontrol so Qd is treated # in m³/h directly and pumps are dispatched by BEP optimization. nodes.append(inject( "setup_mgc_scaling", 100, 800, "setup: MGC scaling=absolute", topic="setScaling", payload="absolute", payload_type="str", once=True, wires=[MGC_ID] )) nodes.append(inject( "setup_mgc_mode", 100, 840, "setup: MGC mode=optimalcontrol", topic="setMode", payload="optimalcontrol", payload_type="str", once=True, wires=[MGC_ID] )) # Default the pumps + MGC to auto so the slider drives them out of the box. nodes.append(inject( "setup_pumps_mode", 100, 880, "setup: pumps mode=auto", topic="setMode", payload="auto", payload_type="str", once=True, wires=[ "mode_setup_fan" ] )) nodes.append(function_node( "mode_setup_fan", 320, 880, "fan setup mode to pumps", "msg.topic = 'setMode';\n" "return [msg, msg, msg];", outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]] )) # Auto-startup all pumps after registration settles (~3s) nodes.append(inject( "setup_pumps_startup", 100, 920, "setup: pumps startup", topic="execSequence", payload='{"source":"GUI","action":"execSequence","parameter":"startup"}', payload_type="json", once=True, wires=["startup_setup_fan"] )) nodes[-1]["onceDelay"] = "4" nodes.append(function_node( "startup_setup_fan", 320, 920, "fan startup to pumps", "return [msg, msg, msg];", outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]] )) # Auto-enable the random demand generator + seed an initial demand # value so the dashboard shows activity immediately on first deploy. # Operator can flip the random toggle off + drag the slider any time. nodes.append(inject( "setup_random_on", 100, 960, "setup: random demand ON", topic="randomToggle", payload="on", payload_type="str", once=True, wires=["random_state"] )) nodes[-1]["onceDelay"] = "5" return nodes def main(): nodes = build() json.dump(nodes, sys.stdout, indent=2) sys.stdout.write("\n") if __name__ == "__main__": main()