feat(examples): pumpingstation-3pumps-dashboard end-to-end demo + bump generalFunctions
Some checks failed
CI / lint-and-test (push) Has been cancelled

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>
This commit is contained in:
znetsixe
2026-04-13 15:53:47 +02:00
parent d7d106773e
commit 7aacee6482
5 changed files with 3290 additions and 1 deletions

42
examples/README.md Normal file
View File

@@ -0,0 +1,42 @@
# EVOLV — End-to-End Example Flows
Demo flows that show how multiple EVOLV nodes work together in a realistic wastewater-automation scenario. Each example is self-contained: its folder has a `flow.json` you can import directly into Node-RED plus a `README.md` that walks through the topology, control modes, and dashboard layout.
These flows complement the per-node example flows under `nodes/<name>/examples/` (which exercise a single node in isolation). Use the per-node flows for smoke tests during development; use the flows here when you want to see how a real plant section behaves end-to-end.
## Catalogue
| Folder | What it shows |
|---|---|
| [`pumpingstation-3pumps-dashboard/`](pumpingstation-3pumps-dashboard/) | Wet-well basin + machineGroupControl orchestrating 3 pumps (each with up/downstream pressure measurements), individual + auto control, process-demand input via dashboard slider or random generator, full FlowFuse dashboard. |
## How to import
1. Bring up the EVOLV stack: `docker compose up -d` from the superproject root.
2. Open Node-RED at `http://localhost:1880`.
3. Menu → **Import** → drop in the example's `flow.json` (or paste the contents).
4. Open the FlowFuse dashboard at `http://localhost:1880/dashboard`.
Each example uses a unique dashboard `path` so they can coexist in the same Node-RED runtime.
## Adding new examples
When you create a new end-to-end example:
1. Make a subfolder under `examples/` named `<scenario>-<focus>`.
2. Include `flow.json` (Node-RED export) and `README.md` (topology, control modes, dashboard map, things to try).
3. Test it on a fresh Dockerized Node-RED — clean import, no errors, dashboard loads.
4. Add a row to the catalogue table above.
## Wishlist for future examples
These are scenarios worth building when there's a session for it:
- **Pump failure + MGC re-routing** — kill pump 2 mid-run, watch MGC redistribute to pumps 1 and 3.
- **Energy-optimal vs equal-flow control** — same demand profile run through `optimalcontrol` and `prioritycontrol` modes side-by-side, energy comparison chart.
- **Schedule-driven demand** — diurnal flow pattern (low at night, peak at 7 am), MGC auto-tuning over 24 simulated hours.
- **Reactor + clarifier loop** — `reactor` upstream feeding `settler`, return sludge controlled by a small `pumpingStation`.
- **Diffuser + DO control** — aeration grid driven by a PID controller from a dissolved-oxygen sensor.
- **Digital sensor bundle** — MQTT-style sensor (BME280, ATAS, etc.) feeding a `measurement` node in digital mode + parent equipment node.
- **Maintenance window** — entermaintenance / exitmaintenance cycle with operator handover dashboard.
- **Calibration walk-through** — measurement node calibrate cycle with stable / unstable input demonstrations.

View File

@@ -0,0 +1,112 @@
# Pumping Station — 3 Pumps with Dashboard
A complete end-to-end EVOLV stack: a wet-well basin model, a `machineGroupControl` orchestrating three `rotatingMachine` pumps (each with upstream/downstream pressure measurements), process-demand input from either a dashboard slider or an auto random generator, individual + auto control modes, and a FlowFuse dashboard with status, gauges, and trend charts.
This is the canonical "make sure everything works together" demo for the platform. Use it after any cross-node refactor to confirm the architecture still hangs together end-to-end.
## Quick start
```bash
cd /mnt/d/gitea/EVOLV
docker compose up -d
# Wait for http://localhost:1880/nodes to return 200, then:
curl -s -X POST http://localhost:1880/flows \
-H "Content-Type: application/json" \
-H "Node-RED-Deployment-Type: full" \
--data-binary @examples/pumpingstation-3pumps-dashboard/flow.json
```
Or open Node-RED at <http://localhost:1880>, **Import → drop the `flow.json`**, click **Deploy**.
Then open the dashboard:
- <http://localhost:1880/dashboard/pumping-station-demo>
## What the flow contains
| Layer | Node(s) | Role |
|---|---|---|
| Top | `pumpingStation` "Pumping Station" | Wet-well basin model. Tracks inflow (`q_in`), outflow (from machine-group child predictions), basin level/volume. PS is in `manual` control mode for the demo so it observes without taking control. |
| Mid | `machineGroupControl` "MGC — Pump Group" | Distributes Qd flow demand across the 3 pumps via `optimalcontrol` (BEP-driven). Scaling: `absolute` (Qd is in m³/h directly). |
| Low | `rotatingMachine` × 3 — Pump A / B / C | Hidrostal H05K-S03R curve. `auto` mode by default so MGC's `parent` commands are accepted. Manual setpoint slider overrides per-pump when each is in `virtualControl`. |
| Sensors | `measurement` × 6 | Per pump: upstream + downstream pressure (mbar). Simulator mode — each ticks a random-walk value continuously. Registered as children of their pump. |
| Demand | inject `demand_rand_tick` + function `demand_rand_fn` + `ui-slider` | Random generator (3 s tick, [40, 240] m³/h) AND a manual slider. Both feed a router that fans out to PS (`q_in` in m³/s) and MGC (`Qd` in m³/h). |
| Glue | `setMode` fanouts + station-wide buttons | Mode toggle broadcasts `setMode` to all 3 pumps. Station-wide Start / Stop / Emergency-Stop buttons fan out to all 3. |
| Dashboard | FlowFuse `ui-page` + 6 groups | Process Demand · Pumping Station · Pump A · Pump B · Pump C · Trends. |
## Dashboard map
The page (`/dashboard/pumping-station-demo`) is laid out top-to-bottom:
1. **Process Demand**
- Slider 0300 m³/h (`manualDemand` topic)
- Random demand toggle (auto cycles every 3 s)
- Live "current demand" text
2. **Pumping Station**
- Auto/Manual mode toggle (drives all pumps' `setMode` simultaneously)
- Station-wide buttons: Start all · Stop all · Emergency stop
- Basin state, level (m), volume (m³), inflow / pumped-out flow (m³/h)
3. **Pump A / B / C** (one group each)
- Setpoint slider 0100 % (only effective when that pump is in `virtualControl`)
- Per-pump Startup + Shutdown buttons
- Live state, mode, controller %, flow, power, upstream/downstream pressure
4. **Trends**
- Flow per pump chart (m³/h)
- Power per pump chart (kW)
## Control model
- **AUTO** — the default. `setMode auto` → MGC's `optimalcontrol` decides which pumps run and at what flow. Operator drives only the **Process Demand** slider (or leaves the random generator on); the per-pump setpoint sliders are ignored.
- **MANUAL** — flip the Auto/Manual switch. All 3 pumps go to `virtualControl`. MGC commands are now ignored. Per-pump setpoint sliders / Start / Stop are the only inputs that affect the pumps.
The Emergency Stop button always works regardless of mode and uses the new interruptible-movement path so it stops a pump mid-ramp.
## Notable design choices
- **PS is in `manual` control mode** (`controlMode: "manual"`). The default `levelbased` mode would auto-shut all pumps as soon as basin level dips below `stopLevel` (1 m default), which masks the demo. Manual = observation only.
- **PS safety guards (dry-run / overfill) disabled.** With no real inflow the basin will frequently look "empty" — that's expected for a demo, not a fault. In production you'd configure a real `q_in` source and leave safeties on.
- **MGC scaling = `absolute`, mode = `optimalcontrol`.** Set via inject at deploy. Demand in m³/h, BEP-driven distribution.
- **demand_router gates Qd ≤ 0.** A demand of 0 would shut every running pump (via MGC.turnOffAllMachines). Use the explicit Stop All button to actually take pumps down.
- **Auto-startup on deploy.** All three pumps fire `execSequence startup` 4 s after deploy so the dashboard shows activity immediately.
- **Auto-enable random demand** 5 s after deploy so the trends fill in without operator action.
- **Verbose logging is OFF.** All EVOLV nodes are at `warn`. Crank the per-node `logLevel` to `info` or `debug` if you're diagnosing a flow.
## Things to try
- Drag the **Process Demand slider** with random off — watch MGC distribute that target across pumps and the basin start filling/draining accordingly.
- Flip to **Manual** mode and use the per-pump setpoint sliders — note that MGC stops driving them.
- Hit **Emergency Stop** while a pump is ramping — confirms the interruptible-movement fix shipped in `rotatingMachine` v1.0.3.
- Watch the **Trends** chart over a few minutes — flow distribution shifts as MGC re-balances around the BEP.
## Verification (last green run, 2026-04-13)
Deployed via `POST /flows` to a Dockerized Node-RED, observed for ~15 s after auto-startup:
- All 3 measurement nodes per pump tick (6 total): pressure values stream every second.
- Each pump reaches `operational` ~5 s after the auto-startup inject (3 s starting + 1 s warmup + 1 s for setpoint=0 settle).
- MGC reports `3 machine(s) connected` with mode `optimalcontrol`.
- Pumping Station shows non-zero basin volume + tracks net flow direction (⬆ / ⬇ / ⏸).
- Random demand cycles between ~40 and ~240 m³/h every 3 s.
- Per-pump status text + trend chart update on every tick.
## Regenerating `flow.json`
`flow.json` is generated from `build_flow.py`. Edit the Python (cleaner diff) and regenerate:
```bash
cd examples/pumpingstation-3pumps-dashboard
python3 build_flow.py > flow.json
```
The `build_flow.py` is the source of truth — keep it in sync if you tweak the demo.
## Wishlist (not in this demo, build separately)
- **Pump failure + MGC re-routing** — kill pump 2 mid-run, watch MGC redistribute. Would demonstrate fault-tolerance.
- **Energy-optimal vs equal-flow control** — same demand profile run through `optimalcontrol` and `prioritycontrol` modes side-by-side, energy comparison chart.
- **Schedule-driven demand** — diurnal flow pattern (low at night, peak at 7 am), MGC auto-tuning over 24 simulated hours.
- **PS with real `q_in` source + safeties on** — show the basin auto-shut behaviour as a feature, not a bug.
- **Real flow sensor per pump** (vs. relying on rotatingMachine's predicted flow) — would let the demo also show measurement-vs-prediction drift indicators.
- **Reactor or settler downstream** — close the loop on a real wastewater scenario.
See the parent `examples/README.md` for the full follow-up catalogue.

View File

@@ -0,0 +1,766 @@
#!/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()

File diff suppressed because it is too large Load Diff