Submodule bumps land the deadlock fix (state.js residue unpark + MGC optimalControl dispatch reorder) and pumpingStation stopLevel hysteresis. - Renames examples/pumpingstation-3pumps-dashboard → pumpingstation-complete-example with regenerated flow.json. New dashboard groups, demand-broadcast wiring, S88 placement rule applied, ui-chart trend-split and link-channel naming follow .claude/rules/node-red-flow-layout.md. - New cross-node test harness under test/: end-to-end-pumpingstation drives PS + MGC + 3 pumps + physics simulator end-to-end and verifies the ~5/15 min cycle. - Adds Grafana provisioning dashboards (pumping-station.json) and a helper sync-example.sh script for export/import to live Node-RED. - Docker entrypoint + settings + compose tweaks for the persistent user dir layout used by the demo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pumping Station — Complete Example
End-to-end EVOLV stack: 1 pumpingStation + 1 machineGroupControl + 3 rotatingMachine pumps + 12 measurement nodes (4 per pump), wired through Node-RED to InfluxDB and Grafana.
This is the canonical "everything works together" demo. After any cross-node refactor, run this and verify the Node-RED dashboard, the InfluxDB writes, and the Grafana dashboard all populate.
Quick start
cd /home/znetsixe/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-complete-example/flow.json
Then open:
- Node-RED dashboard (realtime + 1h trends): http://localhost:1880/dashboard
- Grafana dashboard (realtime gauges + historic graphs): http://localhost:3000 (anonymous viewer is on; the dashboard is
EVOLV / Pumping Station (complete)) - InfluxDB UI: http://localhost:8086 (user
evolv/ passwordevolv-dev-pw)
What the flow contains
| Layer | Node(s) | Role |
|---|---|---|
| Process Cell | pumpingStation "Pumping Station" |
Wet-well basin model. Levelbased control: drives MGC by basin level. Inflow comes from the Drivers tab; outflow is computed from the pumps. |
| Unit | machineGroupControl "MGC — Pump Group" |
Distributes flow across the 3 pumps via optimalcontrol. |
| Equipment | rotatingMachine × 3 — Pump A / B / C |
Hidrostal H05K-S03R curve. Auto by default; manual setpoint slider per pump when in virtualControl. |
| Control Modules | measurement × 12 (4 per pump) |
Upstream pressure, downstream pressure, flow, power. Each pump's 4 sensors are driven by a per-pump physics function — values are physically coupled to plant state, not random. |
| Telemetry | shared evt:tlm link channel → http POST → InfluxDB |
Every EVOLV node's port-1 payload is converted to v2 line protocol and POSTed to telemetry bucket. |
Tabs
The flow is split across 5 tabs, by concern:
| Tab | Lives here | Why |
|---|---|---|
| 🏭 Process Plant | EVOLV nodes (PS, MGC, 3 pumps, 12 sensors) + per-node output formatters + per-pump physics feeders | The deployable plant model. |
| 📊 Dashboard UI | All ui-* widgets, button/setpoint wrappers, dispatch functions |
Display + operator inputs. No business logic. |
| 🎛️ Demo Drivers | Inflow generator (Constant / Sine / Diurnal / Storm) + 1Hz tick | Inflow is operator-driven via slider + scenario buttons. Outflow is implicit (the pumps drain the basin). |
| ⚙️ Setup & Init | One-shot once: true injects (MGC scaling/mode, pumps mode, initial inflow scenario) |
Runs at deploy time only. |
| 📈 Telemetry | link-in evt:tlm → line-protocol function → http POST |
InfluxDB writer. |
Cross-tab wiring uses named link-out / link-in pairs, never direct cross-tab wires.
Channel contract
| Channel | Direction | What it carries |
|---|---|---|
cmd:inflow-baseline |
UI → Drivers | numeric m³/h baseline |
cmd:inflow-scenario |
UI → Drivers | 'constant' | 'sine' | 'diurnal' | 'storm' |
cmd:q_in |
Drivers → process | computed inflow in m³/s |
cmd:Qd |
UI → process | manual demand m³/h (manual mode only) |
cmd:ps-mode |
UI → process | 'levelbased' | 'manual' |
cmd:mode |
Setup → process | per-pump setMode broadcast |
cmd:station-startup / -shutdown / -estop |
UI → process | station-wide command, fanned to all 3 pumps |
cmd:setpoint-A / -B / -C |
UI → process | per-pump setpoint slider value |
cmd:pump-A-seq / -B-seq / -C-seq |
UI → process | per-pump start/stop |
evt:pump-A / -B / -C |
process → UI | formatted per-pump status |
evt:mgc |
process → UI | MGC totals |
evt:ps |
process → UI | basin state, level, fill |
evt:inflow |
Drivers → UI | live inflow value + active scenario |
evt:tlm |
every EVOLV node → Telemetry | port-1 payload in {measurement, fields, tags} shape |
setup:to-mgc |
Setup → process | one-shot MGC scaling/mode init |
Per-pump physics feeder
Each pump has a physics_<pump> function node on the Process Plant tab. It receives:
- The pump's own port-0 stream (state, predicted flow, predicted power).
- PS port-0 stream (basin level), fanned out by
ps_to_physics.
It computes physically-coupled values for each sensor and emits them to the 4 measurement nodes:
| Sensor | Computation |
|---|---|
| Upstream pressure | ρ g h where h = max(0, basinLevel − outflowLevel); pump suction sees the basin's hydrostatic head. |
| Downstream pressure | Idle → static head only (12 m → 1177 mbar). Running → static + flow²-scaled dynamic head (up to ~2354 mbar at q=200 m³/h). |
| Flow | Mirrors rotatingMachine's predicted flow with 1% Gaussian noise. Zero when the pump is idle. |
| Power | Mirrors rotatingMachine's predicted power with 0.5% Gaussian noise. Zero when the pump is idle. |
Gaussian noise uses a 12-uniform-sum approximation (no external libs).
Inflow scenarios
Pick a scenario on the Realtime dashboard page (group "Inflow"):
| Scenario | Behaviour |
|---|---|
| Constant | q_h = baseline (no modulation) |
| Sine | baseline · (1 + 0.5 · sin(2πt/240)) — period 4 min |
| Diurnal | baseline · (1 + 0.6 · sin(2πt/480 − π/2)) — period 8 min, peak offset |
| Storm | 4-min cycle: rapid 5× ramp, then linear decay back to baseline |
Slider sets baseline in m³/h (0–250). The generator emits q_in to PS every second.
Dashboard map
Node-RED — /dashboard
Realtime page (/dashboard/realtime):
- Inflow — slider, 4 scenario buttons, live value + active scenario label
- Station mode + commands — Auto/Manual switch, manual Qd slider, Start All / Stop All / Emergency Stop
- Basin realtime — direction, level, volume, fill %, net flow, time-to-full/empty, inflow, outflow, safety state, gauges (level + fill)
- MGC — total flow + power (text + gauges), efficiency
- Pump A / B / C — state, mode, controller %, flow, power, up/dn pressure (text), setpoint slider, Startup / Shutdown buttons
Trends page (/dashboard/trends) — 1-hour rolling windows:
- Basin level + fill %
- Inflow / Outflow / Per-pump flow (one chart, multi-series)
- Per-pump power
- Per-pump up/dn pressure
Grafana — EVOLV / Pumping Station (complete)
Two rows:
- Realtime — gauges for basin level + fill, stat panels for total flow / total power / per-pump state.
- Historic — line charts for level + fill, inflow/outflow/net, per-pump flow + power (predicted), per-pump pressure, per-pump sensor flow + power (measured).
Default time range: last 15 minutes. Adjust with the Grafana picker for longer history.
Verification
# 1. Bring up the stack
docker compose up -d
sleep 10 # wait for Node-RED ready
# 2. Deploy the flow
curl -s -X POST http://localhost:1880/flows \
-H 'Content-Type: application/json' \
-H 'Node-RED-Deployment-Type: full' \
--data-binary @examples/pumpingstation-complete-example/flow.json | jq .
# 3. Quick sanity check on Influx writes
curl -s -X POST 'http://localhost:8086/api/v2/query?org=evolv' \
-H 'Authorization: Token evolv-dev-token' \
-H 'Accept: application/csv' \
-H 'Content-type: application/vnd.flux' \
--data 'from(bucket:"telemetry") |> range(start: -1m) |> count() |> group(columns: ["_measurement"])'
You should see counts per measurement (Pumping Station, Pump A, MGC — Pump Group, the per-pump sensors, …) growing in real time.
Regenerating flow.json
flow.json is generated from build_flow.py. Edit the Python (cleaner diff) and regenerate:
cd examples/pumpingstation-complete-example
python3 build_flow.py > flow.json
The Python is the source of truth.
After regenerating, push the new flow into the running runtime:
./scripts/sync-example.sh pumpingstation-complete-example
Projects + persistence (Node-RED)
The Docker stack uses a named volume (evolv_nodered_data) for /data, and Node-RED's Projects feature is enabled. Each folder under examples/ is bootstrapped into /data/projects/<name>/ on first container start with its own git init and a synthesized package.json. Switching between projects is two clicks in the editor: menu → Projects → Open Project.
| What you do | Where it lives | What persists |
|---|---|---|
docker compose down && up |
Container is recreated; named volume survives | Active flow + project list survive |
| Edit a flow in the Node-RED editor | /data/projects/<name>/flow.json (in volume) |
Until docker compose down -v |
Edit examples/<name>/build_flow.py then regenerate |
examples/<name>/flow.json (in repo) |
Always — it's in Git |
Run scripts/sync-example.sh <name> |
Copies repo's flow.json → volume's project + reloads |
Volume copy now matches repo |
Adding a new example as a project
- Create
examples/<your-name>/flow.json(build it however you like —build_flow.pyis one way). - Restart the Node-RED container:
docker compose restart nodered. - Editor → Projects → Open Project → pick
<your-name>.
The bootstrap is idempotent: existing projects in the volume aren't overwritten. To force a refresh from the repo: delete the project in the volume (docker exec evolv-nodered rm -rf /data/projects/<name>) and restart, or use scripts/sync-example.sh for a flow-only refresh.
To start fresh (wipe all volume state including flows, sessions, project history): docker compose down -v.
Notable design choices
- PS in
levelbasedmode withmanualmode toggleable from the UI. Levelbased = PS commands MGC by basin level; manual = operator drives MGC via the Qd slider. - Inflow is operator-driven, outflow is implicit (computed from pump activity). Single steerable knob (the Inflow group) keeps the demo focused.
- Sensors driven externally, not by the measurement node's built-in simulator. The physics feeder is a function node on the Process Plant tab — disable it and sensors freeze, which is a useful failure mode to demonstrate.
- All EVOLV port 1 → one shared telemetry channel (
evt:tlm) → one writer. Adding a new EVOLV node anywhere in the flow only needs a newlout_tlm_<id>link-out + appending the id to_all_tlm_lout_ids()inbuild_flow.py. - Dashboard pages split by concern, not data: realtime widgets never share a page with historical charts.