feat(examples): pumpingstation-3pumps-dashboard end-to-end demo + bump generalFunctions
Some checks failed
CI / lint-and-test (push) Has been cancelled
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:
42
examples/README.md
Normal file
42
examples/README.md
Normal 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.
|
||||||
112
examples/pumpingstation-3pumps-dashboard/README.md
Normal file
112
examples/pumpingstation-3pumps-dashboard/README.md
Normal 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 0–300 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 0–100 % (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.
|
||||||
766
examples/pumpingstation-3pumps-dashboard/build_flow.py
Normal file
766
examples/pumpingstation-3pumps-dashboard/build_flow.py
Normal 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) + ' 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()
|
||||||
2369
examples/pumpingstation-3pumps-dashboard/flow.json
Normal file
2369
examples/pumpingstation-3pumps-dashboard/flow.json
Normal file
File diff suppressed because it is too large
Load Diff
Submodule nodes/generalFunctions updated: 43f69066af...29b78a3f9b
Reference in New Issue
Block a user