Files
EVOLV/examples/pumpingstation-3pumps-dashboard/build_flow.py
znetsixe 7aacee6482
Some checks failed
CI / lint-and-test (push) Has been cancelled
feat(examples): pumpingstation-3pumps-dashboard end-to-end demo + bump generalFunctions
New top-level examples/ folder for end-to-end demos that show how multiple
EVOLV nodes work together (complementing the per-node example flows under
nodes/<name>/examples/). Future end-to-end demos will live as siblings.

First demo: pumpingstation-3pumps-dashboard
- 1 pumpingStation (basin model, manual mode for the demo so it observes
  rather than auto-shutting pumps; safety guards disabled — see README)
- 1 machineGroupControl (optimalcontrol mode, absolute scaling)
- 3 rotatingMachine pumps (hidrostal-H05K-S03R curve)
- 6 measurement nodes (per pump: upstream + downstream pressure mbar,
  simulator mode for continuous activity)
- Process demand input via dashboard slider (0-300 m3/h) AND auto random
  generator (3s tick, [40, 240] m3/h) — both feed PS q_in + MGC Qd
- Auto/Manual mode toggle (broadcasts setMode to all 3 pumps)
- Station-wide Start / Stop / Emergency-Stop buttons
- Per-pump setpoint slider, individual buttons, full status text
- Two trend charts (flow per pump, power per pump)
- FlowFuse dashboard at /dashboard/pumping-station-demo

build_flow.py is the source of truth — it generates flow.json
deterministically and is the right place to extend the demo.

Bumps:
  nodes/generalFunctions  43f6906 -> 29b78a3
    Fix: childRegistrationUtils now aliases the production
    softwareType values (rotatingmachine, machinegroupcontrol) to the
    dispatch keys parent nodes check for (machine, machinegroup). Without
    this, MGC <-> rotatingMachine and pumpingStation <-> MGC wiring
    silently never matched in production even though tests passed.
    Demo confirms: MGC reports '3 machine(s) connected'.

Verified end-to-end on Dockerized Node-RED 2026-04-13: pumps reach
operational ~5s after deploy, MGC distributes random demand across them,
basin tracks net flow direction, all dashboard widgets update each second.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:53:47 +02:00

767 lines
34 KiB
Python

#!/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_<pump> 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) + '' : '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()