From 0cab98c1962f111d431cf4da3f0c7b4d2ae99eee Mon Sep 17 00:00:00 2001 From: Rene De Ren Date: Fri, 8 May 2026 11:21:21 +0200 Subject: [PATCH] Pumping-station demo overhaul + cross-node test harness + bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docker-compose.yml | 6 + docker/entrypoint.sh | 90 +- .../provisioning/dashboards/dashboards.yaml | 14 + .../dashboards/pumping-station.json | 435 ++ docker/settings.js | 16 +- examples/README.md | 27 +- examples/WORKFLOW.md | 111 + .../pumpingstation-3pumps-dashboard/README.md | 140 - .../build_flow.py | 1378 ------ .../pumpingstation-complete-example/README.md | 195 + .../build_flow.py | 1909 +++++++++ .../flow.json | 3753 +++++++++++------ nodes/generalFunctions | 2 +- nodes/machineGroupControl | 2 +- nodes/pumpingStation | 2 +- nodes/rotatingMachine | 2 +- scripts/sync-example.sh | 36 + test/README.md | 30 + test/end-to-end-pumpingstation.test.js | 192 + test/lib/recorder.js | 116 + test/lib/wiring.js | 152 + 21 files changed, 5863 insertions(+), 2745 deletions(-) create mode 100644 docker/grafana/provisioning/dashboards/dashboards.yaml create mode 100644 docker/grafana/provisioning/dashboards/pumping-station.json create mode 100644 examples/WORKFLOW.md delete mode 100644 examples/pumpingstation-3pumps-dashboard/README.md delete mode 100644 examples/pumpingstation-3pumps-dashboard/build_flow.py create mode 100644 examples/pumpingstation-complete-example/README.md create mode 100644 examples/pumpingstation-complete-example/build_flow.py rename examples/{pumpingstation-3pumps-dashboard => pumpingstation-complete-example}/flow.json (50%) create mode 100755 scripts/sync-example.sh create mode 100644 test/README.md create mode 100644 test/end-to-end-pumpingstation.test.js create mode 100644 test/lib/recorder.js create mode 100644 test/lib/wiring.js diff --git a/docker-compose.yml b/docker-compose.yml index e7b1472..d861c8f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,10 @@ services: - .:/data/evolv:cached # Named volume: overlay node_modules so host doesn't need native deps - evolv_node_modules:/data/evolv/node_modules + # Persistent Node-RED user dir: flows/projects/sessions survive + # container recreation. Without this, `docker compose down && up` + # wipes the active flow and the entrypoint reseeds demo-flow.json. + - nodered_data:/data environment: - TZ=Europe/Amsterdam - LOCATION_ID=docker-dev @@ -83,6 +87,8 @@ services: volumes: evolv_node_modules: driver: local + nodered_data: + driver: local influxdb_data: driver: local grafana_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b6c047c..2d04fd1 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -63,18 +63,90 @@ npm install --no-save "$EVOLV_DIR" 2>/dev/null || { echo "[entrypoint] EVOLV nodes installed into Node-RED user dir." # ------------------------------------------------------- -# 4. Deploy demo flow if no user flow exists yet +# 4. Bootstrap Node-RED projects from examples/ +# +# Each examples// becomes a project under /data/projects//. +# The Projects feature (settings.js) needs each project to be a Git +# repo, so we git-init each on first copy. After that the projects +# live in the persistent nodered_data volume. +# +# Default project: pumpingstation-complete-example (settable via +# DEFAULT_PROJECT env var). # ------------------------------------------------------- +PROJECTS_DIR="/data/projects" +DEFAULT_PROJECT="${DEFAULT_PROJECT:-pumpingstation-complete-example}" +mkdir -p "$PROJECTS_DIR" + +if [ -d "$EVOLV_DIR/examples" ]; then + for src in "$EVOLV_DIR/examples"/*/; do + [ -d "$src" ] || continue + name=$(basename "$src") + dst="$PROJECTS_DIR/$name" + if [ -d "$dst" ]; then + echo "[entrypoint] Project '$name' already exists in /data/projects, skipping bootstrap." + continue + fi + echo "[entrypoint] Bootstrapping project '$name'..." + cp -r "$src" "$dst" + + # Synthesize a Node-RED project package.json so the project is + # recognised even when the source folder doesn't have one. + if [ ! -f "$dst/package.json" ]; then + cat > "$dst/package.json" << PKGJSON +{ + "name": "$name", + "description": "EVOLV example: $name", + "version": "0.1.0", + "private": true, + "node-red": { + "settings": { + "flowFile": "flow.json", + "credentialsFile": "flow_cred.json" + } + } +} +PKGJSON + fi + + # Git init + initial commit (Node-RED projects require Git). + if [ ! -d "$dst/.git" ]; then + ( + cd "$dst" && \ + git init -q -b main && \ + git config user.email "evolv-dev@local" && \ + git config user.name "EVOLV Dev" && \ + git add . && \ + git commit -q -m "Bootstrap project $name from examples/" || true + ) + fi + echo "[entrypoint] Project '$name' ready at $dst" + done +fi + +# ------------------------------------------------------- +# 4b. Set the active project (Node-RED's projects state lives in +# /data/.config.projects.json). Only set on first run; subsequent +# boots respect the operator's last selection in the editor. +# ------------------------------------------------------- +PROJ_STATE="/data/.config.projects.json" +if [ ! -f "$PROJ_STATE" ] && [ -d "$PROJECTS_DIR/$DEFAULT_PROJECT" ]; then + echo "[entrypoint] Setting active project = $DEFAULT_PROJECT" + cat > "$PROJ_STATE" << JSON +{ + "activeProject": "$DEFAULT_PROJECT", + "projects": { + "$DEFAULT_PROJECT": {} + } +} +JSON +fi + +# Legacy demo-flow.json fallback — kept for the no-projects case if a +# user flips projects.enabled = false in settings.js. DEMO_FLOW="$EVOLV_DIR/docker/demo-flow.json" FLOW_FILE="/data/flows.json" - -if [ -f "$DEMO_FLOW" ]; then - # Deploy demo flow if flows.json is missing or is the default stub - if [ ! -f "$FLOW_FILE" ] || grep -q "WARNING: please check" "$FLOW_FILE" 2>/dev/null; then - echo "[entrypoint] Deploying demo flow..." - cp "$DEMO_FLOW" "$FLOW_FILE" - echo "[entrypoint] Demo flow deployed to $FLOW_FILE" - fi +if [ -f "$DEMO_FLOW" ] && [ ! -f "$FLOW_FILE" ]; then + cp "$DEMO_FLOW" "$FLOW_FILE" fi # ------------------------------------------------------- diff --git a/docker/grafana/provisioning/dashboards/dashboards.yaml b/docker/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 0000000..302bcb2 --- /dev/null +++ b/docker/grafana/provisioning/dashboards/dashboards.yaml @@ -0,0 +1,14 @@ +apiVersion: 1 + +providers: + - name: EVOLV + orgId: 1 + folder: EVOLV + type: file + disableDeletion: false + editable: true + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards + foldersFromFilesStructure: false diff --git a/docker/grafana/provisioning/dashboards/pumping-station.json b/docker/grafana/provisioning/dashboards/pumping-station.json new file mode 100644 index 0000000..dca3b5b --- /dev/null +++ b/docker/grafana/provisioning/dashboards/pumping-station.json @@ -0,0 +1,435 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "refresh": "5s", + "schemaVersion": 39, + "style": "dark", + "tags": ["evolv", "pumping-station"], + "templating": { "list": [] }, + "time": { "from": "now-15m", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "EVOLV — Pumping Station (complete)", + "uid": "evolv-ps-complete", + "version": 1, + "weekStart": "", + "panels": [ + { + "type": "row", + "id": 100, + "title": "Realtime", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "panels": [] + }, + { + "type": "gauge", + "id": 1, + "title": "Basin level", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 7, "w": 6, "x": 0, "y": 1 }, + "fieldConfig": { + "defaults": { + "unit": "lengthm", + "min": 0, + "max": 4, + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 1 }, + { "color": "blue", "value": 2 }, + { "color": "orange", "value": 3.5 }, + { "color": "red", "value": 3.8 } + ] + } + } + }, + "options": { + "showThresholdLabels": false, + "showThresholdMarkers": true, + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"level.predicted.atequipment.default\")\n |> last()" + } + ] + }, + { + "type": "gauge", + "id": 2, + "title": "Basin fill", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 7, "w": 6, "x": 6, "y": 1 }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 10 }, + { "color": "green", "value": 30 }, + { "color": "orange", "value": 80 }, + { "color": "red", "value": 95 } + ] + } + } + }, + "options": { + "showThresholdMarkers": true, + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"volumePercent.predicted.atequipment.default\")\n |> last()" + } + ] + }, + { + "type": "stat", + "id": 3, + "title": "Total flow (MGC)", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 7, "w": 6, "x": 12, "y": 1 }, + "fieldConfig": { + "defaults": { + "unit": "m³/h", + "decimals": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "gray", "value": null }, + { "color": "blue", "value": 50 }, + { "color": "green", "value": 200 } + ] + } + } + }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "colorMode": "background", + "graphMode": "area" + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"MGC — Pump Group\")\n |> filter(fn: (r) => r._field =~ /^flow\\.predicted\\.atequipment\\./)\n |> last()" + } + ] + }, + { + "type": "stat", + "id": 4, + "title": "Total power (MGC)", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 7, "w": 6, "x": 18, "y": 1 }, + "fieldConfig": { + "defaults": { + "unit": "kwatt", + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "gray", "value": null }, + { "color": "blue", "value": 1 }, + { "color": "green", "value": 5 }, + { "color": "orange", "value": 20 } + ] + } + } + }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "colorMode": "background", + "graphMode": "area" + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"MGC — Pump Group\")\n |> filter(fn: (r) => r._field =~ /^power\\.predicted\\.atequipment\\./)\n |> last()" + } + ] + }, + { + "type": "stat", + "id": 5, + "title": "Pump A — state", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 4, "w": 8, "x": 0, "y": 8 }, + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "operational": { "color": "green", "text": "OPERATIONAL" } } }, + { "type": "value", "options": { "starting": { "color": "blue", "text": "STARTING" } } }, + { "type": "value", "options": { "stopping": { "color": "orange", "text": "STOPPING" } } }, + { "type": "value", "options": { "idle": { "color": "gray", "text": "IDLE" } } } + ], + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "gray", "value": null }] } + } + }, + "options": { "colorMode": "background", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"rotatingmachine_pump_a\")\n |> filter(fn: (r) => r._field == \"state\")\n |> last()" + } + ] + }, + { + "type": "stat", + "id": 6, + "title": "Pump B — state", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 4, "w": 8, "x": 8, "y": 8 }, + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "operational": { "color": "green", "text": "OPERATIONAL" } } }, + { "type": "value", "options": { "starting": { "color": "blue", "text": "STARTING" } } }, + { "type": "value", "options": { "stopping": { "color": "orange", "text": "STOPPING" } } }, + { "type": "value", "options": { "idle": { "color": "gray", "text": "IDLE" } } } + ], + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "gray", "value": null }] } + } + }, + "options": { "colorMode": "background", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"rotatingmachine_pump_b\")\n |> filter(fn: (r) => r._field == \"state\")\n |> last()" + } + ] + }, + { + "type": "stat", + "id": 7, + "title": "Pump C — state", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 4, "w": 8, "x": 16, "y": 8 }, + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "operational": { "color": "green", "text": "OPERATIONAL" } } }, + { "type": "value", "options": { "starting": { "color": "blue", "text": "STARTING" } } }, + { "type": "value", "options": { "stopping": { "color": "orange", "text": "STOPPING" } } }, + { "type": "value", "options": { "idle": { "color": "gray", "text": "IDLE" } } } + ], + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "gray", "value": null }] } + } + }, + "options": { "colorMode": "background", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"rotatingmachine_pump_c\")\n |> filter(fn: (r) => r._field == \"state\")\n |> last()" + } + ] + }, + { + "type": "row", + "id": 200, + "title": "Historic", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 }, + "panels": [] + }, + { + "type": "timeseries", + "id": 10, + "title": "Basin — level (m) and fill (%)", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 13 }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 8, + "spanNulls": true + }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [ + { "matcher": { "id": "byName", "options": "level (m)" }, + "properties": [{ "id": "unit", "value": "lengthm" }, { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } }] }, + { "matcher": { "id": "byName", "options": "fill (%)" }, + "properties": [{ "id": "unit", "value": "percent" }, { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } }] } + ] + }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"level.predicted.atequipment.default\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"level (m)\")" + }, + { + "refId": "B", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"volumePercent.predicted.atequipment.default\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"fill (%)\")" + } + ] + }, + { + "type": "timeseries", + "id": 11, + "title": "Inflow / Outflow / Net flow (m³/h)", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 21 }, + "fieldConfig": { + "defaults": { + "unit": "m³/h", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 5, + "spanNulls": true + }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + } + }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field =~ /^flow\\.predicted\\.(in|out)\\./ or r._field == \"netFlowRate.predicted.atequipment.default\")\n |> map(fn: (r) => ({ r with _value: r._value * 3600.0 }))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)" + } + ] + }, + { + "type": "timeseries", + "id": 12, + "title": "Per-pump flow (m³/h) — predicted", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 29 }, + "fieldConfig": { + "defaults": { + "unit": "m³/h", + "custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + } + }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /^rotatingmachine_/)\n |> filter(fn: (r) => r._field =~ /^flow\\.predicted\\.downstream\\./)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)" + } + ] + }, + { + "type": "timeseries", + "id": 13, + "title": "Per-pump power (kW) — predicted", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 }, + "fieldConfig": { + "defaults": { + "unit": "kwatt", + "custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + } + }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /^rotatingmachine_/)\n |> filter(fn: (r) => r._field =~ /^power\\.predicted\\.atequipment\\./)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)" + } + ] + }, + { + "type": "timeseries", + "id": 14, + "title": "Per-pump pressures (mbar) — sensors", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 37 }, + "fieldConfig": { + "defaults": { + "unit": "pressuremmbar", + "custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 3, "spanNulls": true }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + } + }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /-(Up|Dn)$/)\n |> filter(fn: (r) => r._field == \"mAbs\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)" + } + ] + }, + { + "type": "timeseries", + "id": 15, + "title": "Per-pump sensor flow (m³/h) — measured", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 45 }, + "fieldConfig": { + "defaults": { + "unit": "m³/h", + "custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + } + }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /-Flow$/)\n |> filter(fn: (r) => r._field == \"mAbs\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)" + } + ] + }, + { + "type": "timeseries", + "id": 16, + "title": "Per-pump sensor power (kW) — measured", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 45 }, + "fieldConfig": { + "defaults": { + "unit": "kwatt", + "custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + } + }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /-Pwr$/)\n |> filter(fn: (r) => r._field == \"mAbs\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)" + } + ] + } + ] +} diff --git a/docker/settings.js b/docker/settings.js index ac4843d..b503239 100644 --- a/docker/settings.js +++ b/docker/settings.js @@ -15,10 +15,22 @@ module.exports = { // No authentication for dev environment adminAuth: null, - // Disable projects (we use git directly) + // Projects ON: each example folder under /data/projects is a Node-RED + // project (a small Git repo). Operator switches between them in the + // editor (Projects → Open Project). The entrypoint bootstraps every + // examples// into /data/projects// on first run; after + // that, edits live in the persistent nodered_data volume. To copy + // edits back into the EVOLV source tree, run: + // docker cp evolv-nodered:/data/projects//flow.json \ + // examples//flow.json editorTheme: { projects: { - enabled: false + enabled: true, + workflow: { + // Manual: editor doesn't auto-commit. Use the Projects UI + // (or `git` from a shell into the container) to commit. + mode: 'manual' + } } }, diff --git a/examples/README.md b/examples/README.md index 0af6073..adbbb6d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,7 @@ # EVOLV — End-to-End Example Flows +> **Working with these examples?** See [`WORKFLOW.md`](WORKFLOW.md) — the canonical guide for editing, switching projects, persistence, and debugging. + 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//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. @@ -8,25 +10,34 @@ These flows complement the per-node example flows under `nodes//examples/` | 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. | +| [`pumpingstation-complete-example/`](pumpingstation-complete-example/) | End-to-end stack: pumpingStation + MGC + 3 pumps + 12 measurement nodes (4 per pump, physics-coupled), operator-driven inflow with scenario buttons (Constant / Sine / Diurnal / Storm), FlowFuse dashboard (realtime + 1h trends), and provisioned Grafana dashboard backed by InfluxDB. | -## How to import +## How it loads -1. Bring up the EVOLV stack: `docker compose up -d` from the superproject root. +Each subfolder here is a **Node-RED project**. The Docker stack has Node-RED's Projects feature enabled and bootstraps each `examples//` into `/data/projects//` on first container start. + +To run: + +1. `docker compose up -d` from the EVOLV root. 2. Open Node-RED at `http://localhost:1880`. -3. Menu → **Import** → drop in the example's `flow.json` (or paste the contents). +3. Menu → **Projects** → **Open Project** → pick one. 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. +The default active project is `pumpingstation-complete-example` (override via `DEFAULT_PROJECT` env var on the nodered service). Switching is two clicks; persistence is handled by the `evolv_nodered_data` named volume — `docker compose down && up` doesn't lose the active flow. + +Each example uses a unique dashboard `path` so they can coexist if you load multiple in the same runtime. ## Adding new examples When you create a new end-to-end example: 1. Make a subfolder under `examples/` named `-`. -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. +2. Include at least `flow.json` and `README.md`. A `build_flow.py` (or equivalent generator) is recommended so the JSON stays diff-friendly. +3. `docker compose restart nodered` — the entrypoint will bootstrap your new folder as a Node-RED project (synthesizes `package.json`, `git init`, initial commit) under `/data/projects//`. +4. Editor → Projects → Open Project → pick your new one. +5. Add a row to the catalogue table above. + +The bootstrap skips folders that already exist in the volume. To force a refresh of an existing project from the repo source (e.g. after editing `build_flow.py`), use `./scripts/sync-example.sh `. ## Wishlist for future examples diff --git a/examples/WORKFLOW.md b/examples/WORKFLOW.md new file mode 100644 index 0000000..f8dc7bd --- /dev/null +++ b/examples/WORKFLOW.md @@ -0,0 +1,111 @@ +# EVOLV Examples — Team Workflow + +This file is the canonical guide for working with the example flows that live under `examples/`. Each subfolder is a Node-RED **project**; the Docker stack is set up so switching between them is two clicks in the editor. + +## Stack at a glance + +| Container | What | URL | +|---|---|---| +| `evolv-nodered` | Node-RED runtime + dashboard | · dashboard at | +| `evolv-influxdb` | Time-series store (port-1 telemetry) | · `evolv` / `evolv-dev-pw` | +| `evolv-grafana` | Provisioned dashboards (anonymous viewer enabled) | | + +The `evolv_nodered_data` named volume keeps `/data` (flows, projects, sessions) across `docker compose down && up`. The `examples/` directory in this repo is the **source of truth**; the Node-RED Projects feature operates on a copy in the volume. + +## Quick start + +```bash +cd /path/to/EVOLV +docker compose up -d +# Node-RED: http://localhost:1880 +# Dashboard: http://localhost:1880/dashboard +# Grafana: http://localhost:3000 (anonymous viewer) +``` + +The first time you start it, the entrypoint copies every `examples//` into `/data/projects//` and `git init`s each. Subsequent starts skip folders that already exist in the volume. + +## Switching examples + +Open the editor → **menu → Projects → Open Project** → pick another project. The editor reloads the chosen flow. + +The default active project on first boot is `pumpingstation-complete-example`. To change the default for fresh volumes, set `DEFAULT_PROJECT=` on the `nodered` service in `docker-compose.yml`. + +## Editing a flow + +You have two paths. They serve different purposes — pick based on what you're doing. + +### Path A — edit `build_flow.py` (canonical, recommended) + +```bash +# 1. Edit the Python generator +vim examples//build_flow.py + +# 2. Regenerate flow.json +python3 examples//build_flow.py > examples//flow.json + +# 3. Push to the runtime +./scripts/sync-example.sh +``` + +The Python is the **source of truth**. It's diff-friendly and the right place for any change you intend to commit. + +### Path B — edit in the Node-RED editor (experimentation) + +``` +Open editor → Make changes → Deploy +``` + +Edits go into the volume (`/data/projects//flow.json`). They survive `docker compose down && up` but are **not in the EVOLV git repo**. To incorporate them back: + +```bash +docker cp evolv-nodered:/data/projects//flow.json examples//flow.json +``` + +Then commit `examples//flow.json` (and reverse-engineer the change into `build_flow.py` if you want it diff-friendly going forward). + +## Adding a new example + +```bash +mkdir examples/- +# Build a flow.json (recommended: a build_flow.py that generates it) +vim examples/-/{build_flow.py,README.md,flow.json} + +# Restart Node-RED so the entrypoint bootstraps the new project +docker compose restart nodered +``` + +The entrypoint synthesizes `package.json`, runs `git init`, and makes an initial commit so Node-RED recognises it as a project. Bootstrap is idempotent — if a `/data/projects//` already exists, it's left alone. + +After restart, **Projects → Open Project** in the editor will list the new entry. + +## Resetting state + +| Goal | Command | +|---|---| +| Push the repo's `flow.json` into the runtime, reload | `./scripts/sync-example.sh ` | +| Wipe one project's volume copy and re-bootstrap | `docker exec evolv-nodered rm -rf /data/projects/` then `docker compose restart nodered` | +| Wipe **everything** in the volume (flows, sessions, all projects, but NOT InfluxDB/Grafana) | `docker compose down && docker volume rm evolv_nodered_data && docker compose up -d` | +| Wipe everything including telemetry | `docker compose down -v && docker compose up -d` | + +## Debugging + +| Symptom | Where to look | +|---|---| +| Flow not loading after deploy | `docker logs evolv-nodered` for crash backtraces | +| InfluxDB empty / not receiving | Telemetry tab in editor → status of the `Count writes` node. Should show `N POSTs · M lines (0 err)`. | +| Dashboard widget shows `n/a` | Check the Process Plant tab → output formatter function for that node — `c.` keys the dispatcher reads from | +| Grafana dashboard panels empty | Open InfluxDB UI () → Data Explorer → confirm the field name the panel queries actually exists. Field names are flat dotted keys like `level.predicted.atequipment.default`. | +| `interpolation configuration: New f =... is constrained` warnings | The pump curve f-axis is out-of-range. f = downstream − upstream pressure differential, in Pa, must be inside the curve's range (e.g. 70 000 – 390 000 Pa for `hidrostal-H05K-S03R`). Check the per-pump physics feeder formula. | +| High CPU in Node-RED | Per-tick HTTP fan-out to InfluxDB; the pumpingstation example uses a 500 ms batch in the Telemetry tab. If CPU is still high, lower `tickIntervalMs` in the EVOLV node configs (currently 1000). | + +## File map per example + +``` +examples// +├── build_flow.py ← canonical source of flow.json (Python generator) +├── flow.json ← regenerated artefact, also tracked in Git +├── README.md ← topology, control modes, dashboard map, things to try +└── package.json ← (synthesized in volume by entrypoint, not in repo) +``` + +The repo tracks `build_flow.py`, `flow.json`, and `README.md`. The `package.json` and `.git/` directory of the project live only in the named volume — they're created by the entrypoint on first bootstrap and don't leak back into the EVOLV Git history. diff --git a/examples/pumpingstation-3pumps-dashboard/README.md b/examples/pumpingstation-3pumps-dashboard/README.md deleted file mode 100644 index 74bc8e2..0000000 --- a/examples/pumpingstation-3pumps-dashboard/README.md +++ /dev/null @@ -1,140 +0,0 @@ -# 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 , **Import → drop the `flow.json`**, click **Deploy**. - -Then open the dashboard: - -- - -## Tabs - -The flow is split across four tabs by **concern**: - -| Tab | Lives here | Why | -|---|---|---| -| 🏭 **Process Plant** | EVOLV nodes (3 pumps + MGC + PS + 6 measurements) and per-node output formatters | The "real plant" layer. Lift this tab into production unchanged. | -| 📊 **Dashboard UI** | All `ui-*` widgets, button/setpoint wrappers, trend-split functions | Display + operator inputs only. No business logic. | -| 🎛️ **Demo Drivers** | Random demand generator, random-toggle state | Demo-only stimulus. In production, delete this tab and feed `cmd:demand` from your real demand source. | -| ⚙️ **Setup & Init** | One-shot `once: true` injects (MGC scaling/mode, pumps mode, auto-startup, random-on) | Runs at deploy time only. Disable for production runtimes. | - -Cross-tab wiring uses **named link-out / link-in pairs**, never direct cross-tab wires. The channel names form the contract: - -| Channel | Direction | What it carries | -|---|---|---| -| `cmd:demand` | UI / drivers → process | numeric demand in m³/h | -| `cmd:randomToggle` | UI → drivers | `'on'` / `'off'` | -| `cmd:mode` | UI / setup → process | `'auto'` / `'virtualControl'` setMode broadcast | -| `cmd:station-startup` / `cmd:station-shutdown` / `cmd:station-estop` | UI / setup → 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 (flow / power / efficiency) | -| `evt:ps` | process → UI | basin state + level + volume + flows | -| `setup:to-mgc` | setup → process | MGC scaling/mode init | - -See `.claude/rules/node-red-flow-layout.md` for the full layout rule set this demo follows. - -## 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 `minLevel` (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. diff --git a/examples/pumpingstation-3pumps-dashboard/build_flow.py b/examples/pumpingstation-3pumps-dashboard/build_flow.py deleted file mode 100644 index 50a3b10..0000000 --- a/examples/pumpingstation-3pumps-dashboard/build_flow.py +++ /dev/null @@ -1,1378 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate the multi-tab Node-RED flow for the -'pumpingstation-3pumps-dashboard' end-to-end demo. - -Layout philosophy ------------------ -Every node gets a home tab based on its CONCERN, not the data it touches: - - Tab 1 Process Plant only EVOLV nodes (pumps, MGC, PS, measurements) - + small per-node output formatters. NO UI, NO - demo drivers, NO setup logic. This is the - deployable plant model in isolation. - - Tab 2 Dashboard UI only ui-* widgets. NO routing logic beyond - topic-tagging for the chart legends. - - Tab 3 Demo Drivers auto random demand generator + station-wide - command fan-outs. Only here so the demo "lives" - without an operator. Removable in production. - - Tab 4 Setup & Init one-shot deploy-time injects (mode, scaling, - auto-startup). Easy to disable for production. - -Cross-tab wiring is via NAMED link-out / link-in pairs, not direct -wires. The channel names are the contract — see CHANNELS below. - -Spacing -------- -Five lanes per tab, x in [120, 380, 640, 900, 1160]. Row pitch 80 px. -Major sections separated by 200 px y-shift + a comment header. - -To regenerate: - python3 build_flow.py > flow.json -""" -import json -import sys - - -# --------------------------------------------------------------------------- -# Tab IDs -# --------------------------------------------------------------------------- -TAB_PROCESS = "tab_process" -TAB_UI = "tab_ui" -TAB_DRIVERS = "tab_drivers" -TAB_SETUP = "tab_setup" - -# --------------------------------------------------------------------------- -# Spacing constants -# --------------------------------------------------------------------------- -LANE_X = [120, 380, 640, 900, 1160, 1420] -ROW = 80 # standard inter-row pitch -SECTION_GAP = 200 # additional shift between major sections - -# Position icon map — must match generalFunctions/src/menu/physicalPosition.js -POSITION_ICON = { - "upstream": "\u2192", # → - "downstream": "\u2190", # ← - "atEquipment": "\u22a5", # ⊥ -} - - -# --------------------------------------------------------------------------- -# Cross-tab link channel names (the wiring contract) -# --------------------------------------------------------------------------- -# command channels: dashboard or drivers -> process -CH_DEMAND = "cmd:demand" # numeric demand (m3/h) -CH_RANDOM_TOGGLE = "cmd:randomToggle" # 'on' / 'off' -CH_MODE = "cmd:mode" # 'auto' / 'virtualControl' setMode broadcast -CH_STATION_START = "cmd:station-startup" -CH_STATION_STOP = "cmd:station-shutdown" -CH_STATION_ESTOP = "cmd:station-estop" -CH_PUMP_SETPOINT = {"pump_a": "cmd:setpoint-A", - "pump_b": "cmd:setpoint-B", - "pump_c": "cmd:setpoint-C"} -CH_PUMP_SEQUENCE = {"pump_a": "cmd:pump-A-seq", # carries startup/shutdown - "pump_b": "cmd:pump-B-seq", - "pump_c": "cmd:pump-C-seq"} - -# event channels: process -> dashboard -CH_PUMP_EVT = {"pump_a": "evt:pump-A", - "pump_b": "evt:pump-B", - "pump_c": "evt:pump-C"} -CH_MGC_EVT = "evt:mgc" -CH_PS_EVT = "evt:ps" - - -PUMPS = ["pump_a", "pump_b", "pump_c"] -PUMP_LABELS = {"pump_a": "Pump A", "pump_b": "Pump B", "pump_c": "Pump C"} - - -# --------------------------------------------------------------------------- -# Generic node-builder helpers -# --------------------------------------------------------------------------- -def comment(node_id, tab, x, y, name, info=""): - return {"id": node_id, "type": "comment", "z": tab, "name": name, - "info": info, "x": x, "y": y, "wires": []} - - -def inject(node_id, tab, x, y, name, topic, payload, payload_type="str", - once=False, repeat="", once_delay="0.5", wires=None): - """Inject node using the per-prop v/vt form so payload_type=json works.""" - return { - "id": node_id, "type": "inject", "z": tab, "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": once_delay, - "x": x, "y": y, "wires": [wires or []], - } - - -def function_node(node_id, tab, x, y, name, code, outputs=1, wires=None): - return { - "id": node_id, "type": "function", "z": tab, "name": name, - "func": code, "outputs": outputs, - "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": x, "y": y, "wires": wires if wires is not None else [[] for _ in range(outputs)], - } - - -def link_out(node_id, tab, x, y, channel_name, target_in_ids): - """Mode 'link' — fires the named link-in nodes (by id).""" - return { - "id": node_id, "type": "link out", "z": tab, "name": channel_name, - "mode": "link", "links": list(target_in_ids), - "x": x, "y": y, "wires": [], - } - - -def link_in(node_id, tab, x, y, channel_name, source_out_ids, downstream): - return { - "id": node_id, "type": "link in", "z": tab, "name": channel_name, - "links": list(source_out_ids), - "x": x, "y": y, "wires": [downstream or []], - } - - -def debug_node(node_id, tab, x, y, name, target="payload", - target_type="msg", active=False): - return { - "id": node_id, "type": "debug", "z": tab, "name": name, - "active": active, "tosidebar": True, "console": False, "tostatus": False, - "complete": target, "targetType": target_type, - "x": x, "y": y, "wires": [], - } - - -# --------------------------------------------------------------------------- -# Dashboard scaffolding (ui-base / ui-theme / ui-page / ui-group) -# --------------------------------------------------------------------------- -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_control = { - "id": "ui_page_control", "type": "ui-page", - "name": "Control", "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": "", - } - page_short = { - "id": "ui_page_short_trends", "type": "ui-page", - "name": "Trends — 10 min", "ui": "ui_base_ps_demo", - "path": "/pumping-station-demo/trends-short", "icon": "show_chart", - "layout": "grid", "theme": "ui_theme_ps_demo", - "breakpoints": [{"name": "Default", "px": "0", "cols": "12"}], - "order": 2, "className": "", - } - page_long = { - "id": "ui_page_long_trends", "type": "ui-page", - "name": "Trends — 1 hour", "ui": "ui_base_ps_demo", - "path": "/pumping-station-demo/trends-long", "icon": "timeline", - "layout": "grid", "theme": "ui_theme_ps_demo", - "breakpoints": [{"name": "Default", "px": "0", "cols": "12"}], - "order": 3, "className": "", - } - return [base, theme, page_control, page_short, page_long] - - -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, tab, x, y, group, name, label, fmt, layout="row-left"): - return { - "id": node_id, "type": "ui-text", "z": tab, "group": group, - "order": 1, "width": "0", "height": "0", "name": name, "label": label, - "format": fmt, "layout": layout, "style": False, "font": "", - "fontSize": 14, "color": "#000000", - "x": x, "y": y, # editor canvas position — without these - # Node-RED dumps every ui-text at (0,0) - # and you get a pile in the top-left corner - "wires": [], - } - - -def ui_button(node_id, tab, 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, "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, tab, x, y, group, name, label, mn, mx, step=1.0, - topic="", wires=None): - return { - "id": node_id, "type": "ui-slider", "z": tab, "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, tab, x, y, group, name, label, on_value, off_value, - topic, wires=None): - return { - "id": node_id, "type": "ui-switch", "z": tab, "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, tab, x, y, group, name, label, - width=12, height=6, - remove_older="15", remove_older_unit="60", - remove_older_points="", - y_axis_label="", ymin=None, ymax=None, order=1): - """ - FlowFuse ui-chart (line type, time x-axis). - - IMPORTANT: This template is derived from the working charts in - rotatingMachine/examples/03-Dashboard.json. Every field listed below - is required or the chart renders blank. Key gotchas: - - - `width` / `height` must be NUMBERS not strings. - - `interpolation` must be set ("linear", "step", "bezier", - "cubic", "cubic-mono") or no line is drawn. - - `yAxisProperty: "payload"` + `yAxisPropertyType: "msg"` tells - the chart WHERE in the msg to find the y-value. Without these - the chart has no data to plot. - - `xAxisPropertyType: "timestamp"` tells the chart to use - msg.timestamp (or auto-generated timestamp) for the x-axis. - - `removeOlderPoints` should be "" (empty string) to let - removeOlder + removeOlderUnit control retention, OR a number - string to cap points per series. - """ - return { - "id": node_id, "type": "ui-chart", "z": tab, "group": group, - "name": name, "label": label, "order": order, - "chartType": "line", - "interpolation": "linear", - # Series identification - "category": "topic", "categoryType": "msg", - # X-axis (time) - "xAxisLabel": "", "xAxisType": "time", - "xAxisProperty": "", "xAxisPropertyType": "timestamp", - "xAxisFormat": "", "xAxisFormatType": "auto", - "xmin": "", "xmax": "", - # Y-axis (msg.payload) - "yAxisLabel": y_axis_label, - "yAxisProperty": "payload", "yAxisPropertyType": "msg", - "ymin": "" if ymin is None else str(ymin), - "ymax": "" if ymax is None else str(ymax), - # Data retention - "removeOlder": str(remove_older), - "removeOlderUnit": str(remove_older_unit), - "removeOlderPoints": str(remove_older_points), - # Rendering - "action": "append", - "stackSeries": False, - "pointShape": "circle", "pointRadius": 4, - "showLegend": True, - "bins": 10, - # Colours (defaults — chart auto-cycles through these per series) - "colors": [ - "#0095FF", "#FF0000", "#FF7F0E", "#2CA02C", - "#A347E1", "#D62728", "#FF9896", "#9467BD", "#C5B0D5", - ], - "textColor": ["#666666"], "textColorDefault": True, - "gridColor": ["#e5e5e5"], "gridColorDefault": True, - # Editor layout + dimensions (NUMBERS, not strings) - "width": int(width), "height": int(height), "className": "", - "x": x, "y": y, - "wires": [[]], - } - - -# --------------------------------------------------------------------------- -# Tab 1 — PROCESS PLANT -# --------------------------------------------------------------------------- -def build_process_tab(): - nodes = [] - - nodes.append({ - "id": TAB_PROCESS, "type": "tab", - "label": "🏭 Process Plant", - "disabled": False, - "info": "EVOLV plant model: pumpingStation + machineGroupControl + 3 rotatingMachines, each with upstream and downstream pressure measurements.\n\nReceives commands via link-in nodes from the Dashboard / Demo Drivers tabs. Emits per-pump status via link-out per pump.\n\nNo UI, no demo drivers, no one-shot setup logic on this tab — those live on their own tabs so this layer can be lifted into production unchanged.", - }) - - nodes.append(comment("c_process_title", TAB_PROCESS, LANE_X[2], 20, - "🏭 PROCESS PLANT — EVOLV nodes only", - "Per pump: 2 measurement sensors → rotatingMachine → output formatter → link-out to dashboard.\n" - "MGC orchestrates 3 pumps. PS observes basin (manual mode for the demo).\n" - "All cross-tab wires are link-in / link-out by named channel." - )) - - # ---------------- Per-pump rows ---------------- - for i, pump in enumerate(PUMPS): - label = PUMP_LABELS[pump] - # Each pump occupies a 4-row block, separated by SECTION_GAP from the next. - y_section = 100 + i * SECTION_GAP - - nodes.append(comment(f"c_{pump}", TAB_PROCESS, LANE_X[2], y_section, - f"── {label} ──", - "Up + Dn pressure sensors register as children. " - "rotatingMachine emits state on port 0 (formatted then link-out to UI). " - "Port 2 emits registerChild → MGC." - )) - - # Two measurement sensors (upstream + downstream) - # Static pressures: upstream ~100 mbar (basin hydrostatic), - # downstream ~1300 mbar (discharge head ≈ 12 m). - # Differential = 1200 mbar = 120 kPa — mid-range on the - # hidrostal curve (700–3900 mbar). Simulator OFF so the - # value is stable and predictable. - for j, pos in enumerate(("upstream", "downstream")): - mid = f"meas_{pump}_{pos[0]}" - static_val = 100 if pos == "upstream" else 1300 - mid_label = f"PT-{label.split()[1]}-{'Up' if pos == 'upstream' else 'Dn'}" - nodes.append({ - "id": mid, "type": "measurement", "z": TAB_PROCESS, - "name": mid_label, - "mode": "analog", "channels": "[]", - "scaling": False, - "i_min": 0, "i_max": 1, "i_offset": 0, - "o_min": static_val, "o_max": static_val, - "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": False, "logLevel": "warn", - "positionVsParent": pos, "positionIcon": POSITION_ICON.get(pos, ""), - "hasDistance": False, "distance": 0, "distanceUnit": "m", - "distanceDescription": "", - "x": LANE_X[1], "y": y_section + 40 + j * 50, - # Port 2 -> pump (registerChild). Ports 0/1 unused for now. - "wires": [[], [], [pump]], - }) - - # link-in for setpoint slider (from dashboard) - nodes.append(link_in( - f"lin_setpoint_{pump}", TAB_PROCESS, LANE_X[0], y_section + 60, - CH_PUMP_SETPOINT[pump], - source_out_ids=[f"lout_setpoint_{pump}_dash"], - downstream=[f"build_setpoint_{pump}"], - )) - nodes.append(function_node( - f"build_setpoint_{pump}", TAB_PROCESS, LANE_X[1] + 220, y_section + 60, - f"build setpoint cmd ({label})", - "msg.topic = 'execMovement';\n" - "msg.payload = { source: 'GUI', action: 'execMovement', " - "setpoint: Number(msg.payload) };\n" - "return msg;", - outputs=1, wires=[[pump]], - )) - - # link-in for per-pump sequence (start/stop) commands - nodes.append(link_in( - f"lin_seq_{pump}", TAB_PROCESS, LANE_X[0], y_section + 110, - CH_PUMP_SEQUENCE[pump], - source_out_ids=[f"lout_seq_{pump}_dash"], - downstream=[pump], - )) - - # The pump itself - nodes.append({ - "id": pump, "type": "rotatingMachine", "z": TAB_PROCESS, - "name": label, - "speed": "10", - "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": False, "logLevel": "warn", - "positionVsParent": "atEquipment", "positionIcon": POSITION_ICON["atEquipment"], - "hasDistance": False, "distance": 0, "distanceUnit": "m", - "distanceDescription": "", - "x": LANE_X[3], "y": y_section + 80, - "wires": [ - [f"format_{pump}"], # port 0 process -> formatter - [], # port 1 dbase - [MGC_ID], # port 2 -> MGC for registerChild - ], - }) - - # Per-pump output formatter: builds a fat object with all fields. - # The dashboard dispatcher (on the UI tab) then splits it into - # plain-string payloads per ui-text widget. One link-out per pump. - nodes.append(function_node( - f"format_{pump}", TAB_PROCESS, LANE_X[4], y_section + 80, - 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) + ' mbar' : 'n/a',\n" - " pDn: pD != null ? Number(pD).toFixed(0) + ' mbar' : 'n/a',\n" - " flowNum: flow != null ? Number(flow) : null,\n" - " powerNum: power != null ? Number(power) : null,\n" - "};\n" - "return msg;", - outputs=1, wires=[[f"lout_evt_{pump}"]], - )) - - # link-out: one per pump → dashboard dispatcher - nodes.append(link_out( - f"lout_evt_{pump}", TAB_PROCESS, LANE_X[5], y_section + 80, - CH_PUMP_EVT[pump], - target_in_ids=[f"lin_evt_{pump}_dash"], - )) - - # ---------------- MGC ---------------- - y_mgc = 100 + 3 * SECTION_GAP - nodes.append(comment("c_mgc", TAB_PROCESS, LANE_X[2], y_mgc, - "── MGC ── (orchestrates the 3 pumps via optimalcontrol)", - "Receives Qd from cmd:demand link-in. Distributes flow across pumps." - )) - # MGC no longer receives direct Qd from the dashboard — PS drives it - # via level-based control or manual Qd forwarding. The demand_fanout - # has been replaced by: sinus → q_in → PS (levelbased), and - # slider → Qd → PS (manual mode only). - nodes.append({ - "id": MGC_ID, "type": "machineGroupControl", "z": TAB_PROCESS, - "name": "MGC — Pump Group", - "uuid": "mgc-pump-group", - "category": "controller", - "assetType": "machinegroupcontrol", - "model": "default", "unit": "m3/h", "supplier": "evolv", - "enableLog": False, "logLevel": "warn", - "positionVsParent": "atEquipment", "positionIcon": POSITION_ICON["atEquipment"], - "hasDistance": False, "distance": 0, "distanceUnit": "m", - "distanceDescription": "", - "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", - "x": LANE_X[3], "y": y_mgc + 80, - "wires": [ - ["format_mgc"], # port 0 → formatter - [], # port 1 dbase - [PS_ID], # port 2 → PS for registerChild - ], - }) - nodes.append(function_node( - "format_mgc", TAB_PROCESS, LANE_X[4], y_mgc + 80, - "format MGC 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 totalFlow = find('flow.predicted.atequipment.') ?? find('downstream_predicted_flow');\n" - "const totalPower = find('power.predicted.atequipment.') ?? find('atEquipment_predicted_power');\n" - "const eff = find('efficiency.predicted.atequipment.');\n" - "msg.payload = {\n" - " totalFlow: totalFlow != null ? Number(totalFlow).toFixed(1) + ' m³/h' : 'n/a',\n" - " totalPower: totalPower != null ? Number(totalPower).toFixed(2) + ' kW' : 'n/a',\n" - " efficiency: eff != null ? Number(eff).toFixed(3) : 'n/a',\n" - " totalFlowNum: totalFlow != null ? Number(totalFlow) : null,\n" - " totalPowerNum: totalPower != null ? Number(totalPower) : null,\n" - "};\n" - "return msg;", - outputs=1, wires=[["lout_evt_mgc"]], - )) - nodes.append(link_out( - "lout_evt_mgc", TAB_PROCESS, LANE_X[5], y_mgc + 80, - CH_MGC_EVT, target_in_ids=["lin_evt_mgc_dash"], - )) - - # ---------------- PS ---------------- - y_ps = 100 + 4 * SECTION_GAP - nodes.append(comment("c_ps", TAB_PROCESS, LANE_X[2], y_ps, - "── Pumping Station ── (basin model, levelbased control)", - "Receives q_in (simulated inflow) from Demo Drivers tab.\n" - "Level-based control starts/stops pumps via MGC when level crosses start/stop thresholds." - )) - # link-in for simulated inflow from Drivers tab - nodes.append(link_in( - "lin_qin_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 40, - "cmd:q_in", source_out_ids=["lout_qin_drivers"], - downstream=[PS_ID], - )) - # link-in for manual Qd demand from Dashboard slider (only effective in manual mode) - nodes.append(link_in( - "lin_qd_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 80, - "cmd:Qd", source_out_ids=["lout_demand_dash"], - downstream=["qd_to_ps_wrap"], - )) - nodes.append(function_node( - "qd_to_ps_wrap", TAB_PROCESS, LANE_X[1], y_ps + 80, - "wrap slider → PS Qd", - "msg.topic = 'Qd';\n" - "return msg;", - outputs=1, wires=[[PS_ID]], - )) - # link-in for PS mode toggle from Dashboard - nodes.append(link_in( - "lin_ps_mode_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 120, - "cmd:ps-mode", source_out_ids=["lout_ps_mode_dash"], - downstream=[PS_ID], - )) - nodes.append({ - "id": PS_ID, "type": "pumpingStation", "z": TAB_PROCESS, - "name": "Pumping Station", - "uuid": "ps-basin-1", - "category": "station", "assetType": "pumpingstation", - "model": "default", "unit": "m3/s", "supplier": "evolv", - "enableLog": False, "logLevel": "warn", - "positionVsParent": "atEquipment", "positionIcon": POSITION_ICON["atEquipment"], - "hasDistance": False, "distance": 0, "distanceUnit": "m", - "distanceDescription": "", - "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", - # === FULLY CONFIGURED PS — every field explicitly set === - # Rule: ALWAYS configure ALL node fields. Defaults are for - # schema validation, not for realistic operation. - # - # Basin geometry: 30 m³, 4 m tall → surfaceArea = 7.5 m² - # Sized so peak sinus inflow (0.035 m³/s = 126 m³/h) takes - # ~6 min to fill from startLevel to overflow → pumps have time. - "controlMode": "levelbased", - "basinVolume": 30, - "basinHeight": 4, - "inflowLevel": 3.5, - "outflowLevel": 0.3, - "overflowLevel": 3.8, - "inletPipeDiameter": 0.3, - "outletPipeDiameter": 0.3, - # Level-based control thresholds - "minLevel": 1.0, # pumps OFF below 1.0 m (25% of height) - "startLevel": 2.0, # 0% pump demand — ramp begins here - "maxLevel": 3.5, # 100% pump demand at this level - # Hydraulics - "refHeight": "NAP", - "minHeightBasedOn": "outlet", # basin drains to outlet pipe (0.3 m), not inlet - "basinBottomRef": 0, - "staticHead": 12, - "maxDischargeHead": 24, - "pipelineLength": 80, - "defaultFluid": "wastewater", - "temperatureReferenceDegC": 15, - "maxInflowRate": 200, - # Safety guards - "enableDryRunProtection": True, - "enableOverfillProtection": True, - "dryRunThresholdPercent": 5, - "overfillThresholdPercent": 95, - "timeleftToFullOrEmptyThresholdSeconds": 0, - "x": LANE_X[3], "y": y_ps + 80, - "wires": [ - ["format_ps"], - [], - ], - }) - nodes.append(function_node( - "format_ps", TAB_PROCESS, LANE_X[4], y_ps + 80, - "format PS 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 lvl = find('level.predicted.');\n" - "const vol = find('volume.predicted.');\n" - "const qIn = find('flow.predicted.in.');\n" - "const qOut = find('flow.predicted.out.');\n" - "const netFlowRate = find('netFlowRate.predicted.');\n" - "// Compute derived metrics\n" - "// Basin capacity = basinVolume (config). Don't hardcode — read it once.\n" - "if (!context.get('maxVol')) context.set('maxVol', 30.0); // basinVolume from PS config\n" - "const maxVol = context.get('maxVol');\n" - "const fillPct = vol != null ? Math.min(100, Math.max(0, Math.round(Number(vol) / maxVol * 100))) : null;\n" - "const netM3h = netFlowRate != null ? Number(netFlowRate) * 3600 : null;\n" - "const seconds = (c.timeleft != null && Number.isFinite(Number(c.timeleft))) ? Number(c.timeleft) : null;\n" - "const timeStr = seconds != null ? (seconds > 60 ? Math.round(seconds/60) + ' min' : Math.round(seconds) + ' s') : 'n/a';\n" - "msg.payload = {\n" - " direction: c.direction || 'steady',\n" - " level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n" - " volume: vol != null ? Number(vol).toFixed(1) + ' m³' : 'n/a',\n" - " fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n" - " netFlow: netM3h != null ? netM3h.toFixed(0) + ' m³/h' : 'n/a',\n" - " timeLeft: timeStr,\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" - " // Numerics for trends\n" - " levelNum: lvl != null ? Number(lvl) : null,\n" - " volumeNum: vol != null ? Number(vol) : null,\n" - " fillPctNum: fillPct,\n" - " netFlowNum: netM3h,\n" - " percControl: c.percControl != null ? Number(c.percControl) : null,\n" - " qInNum: qIn != null ? Number(qIn) * 3600 : null,\n" - " qOutNum: qOut != null ? Number(qOut) * 3600 : null,\n" - "};\n" - "return msg;", - outputs=1, wires=[["lout_evt_ps"]], - )) - nodes.append(link_out( - "lout_evt_ps", TAB_PROCESS, LANE_X[5], y_ps + 80, - CH_PS_EVT, target_in_ids=["lin_evt_ps_dash"], - )) - - # ---------------- Mode broadcast (Auto/Manual to all pumps) ---------------- - y_mode = 100 + 5 * SECTION_GAP - nodes.append(comment("c_mode_bcast", TAB_PROCESS, LANE_X[2], y_mode, - "── Mode broadcast ──", - "Single 'auto' / 'virtualControl' value fans out as setMode to all 3 pumps." - )) - nodes.append(link_in( - "lin_mode", TAB_PROCESS, LANE_X[0], y_mode + 60, - CH_MODE, - source_out_ids=["lout_mode_dash"], - downstream=["fanout_mode"], - )) - nodes.append(function_node( - "fanout_mode", TAB_PROCESS, LANE_X[1] + 220, y_mode + 60, - "fan setMode → 3 pumps", - "msg.topic = 'setMode';\n" - "return [msg, msg, msg];", - outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]], - )) - - # ---------------- Station-wide commands (start/stop/estop) ---------------- - y_station = 100 + 6 * SECTION_GAP - nodes.append(comment("c_station_cmds", TAB_PROCESS, LANE_X[2], y_station, - "── Station-wide commands ── (Start All / Stop All / Emergency)", - "Each link-in carries a fully-built msg ready for handleInput; we just fan out 3-way." - )) - for k, (chan, link_id, fn_name, label_suffix) in enumerate([ - (CH_STATION_START, "lin_station_start", "fan_station_start", "startup"), - (CH_STATION_STOP, "lin_station_stop", "fan_station_stop", "shutdown"), - (CH_STATION_ESTOP, "lin_station_estop", "fan_station_estop", "emergency stop"), - ]): - y = y_station + 60 + k * 60 - nodes.append(link_in( - link_id, TAB_PROCESS, LANE_X[0], y, chan, - source_out_ids=[f"lout_{chan.replace(':', '_').replace('-', '_')}_dash"], - downstream=[fn_name], - )) - nodes.append(function_node( - fn_name, TAB_PROCESS, LANE_X[1] + 220, y, - f"fan {label_suffix} → 3 pumps", - "return [msg, msg, msg];", - outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]], - )) - - return nodes - - -MGC_ID = "mgc_pumps" -PS_ID = "ps_basin" - - -# --------------------------------------------------------------------------- -# Tab 2 — DASHBOARD UI -# --------------------------------------------------------------------------- -def build_ui_tab(): - nodes = [] - nodes.append({ - "id": TAB_UI, "type": "tab", - "label": "📊 Dashboard UI", - "disabled": False, - "info": "Every ui-* widget lives here. Inputs (sliders/switches/buttons) emit " - "via link-out; status text + charts receive via link-in. No business " - "logic on this tab.", - }) - - # Dashboard scaffold (page + theme + base) + groups - nodes += dashboard_scaffold() - PG = "ui_page_control" # control page is the main page - 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" - # Trend groups live on separate pages, not the control page. - PG_SHORT = "ui_page_short_trends" - PG_LONG = "ui_page_long_trends" - g_trend_short_flow = "ui_grp_trend_short_flow" - g_trend_short_power = "ui_grp_trend_short_power" - g_trend_long_flow = "ui_grp_trend_long_flow" - g_trend_long_power = "ui_grp_trend_long_power" - g_mgc = "ui_grp_mgc" - g_ps = "ui_grp_ps" - nodes += [ - ui_group(g_demand, "1. Process Demand", PG, width=12, order=1), - ui_group(g_station, "2. Station Controls", PG, width=12, order=2), - ui_group(g_mgc, "3a. MGC Status", PG, width=6, order=3), - ui_group(g_ps, "3b. Basin Status", PG, width=6, order=4), - ui_group(g_pump_a, "4a. Pump A", PG, width=4, order=5), - ui_group(g_pump_b, "4b. Pump B", PG, width=4, order=6), - ui_group(g_pump_c, "4c. Pump C", PG, width=4, order=7), - # Trends on separate pages - ui_group(g_trend_short_flow, "Flow (10 min)", PG_SHORT, width=12, order=1), - ui_group(g_trend_short_power, "Power (10 min)", PG_SHORT, width=12, order=2), - ui_group("ui_grp_trend_short_basin_level", "Basin Level (10 min)", PG_SHORT, width=12, order=3), - ui_group("ui_grp_trend_short_basin_fill", "Basin Fill (10 min)", PG_SHORT, width=12, order=4), - ui_group(g_trend_long_flow, "Flow (1 hour)", PG_LONG, width=12, order=1), - ui_group(g_trend_long_power, "Power (1 hour)", PG_LONG, width=12, order=2), - ui_group("ui_grp_trend_long_basin_level", "Basin Level (1 hour)", PG_LONG, width=12, order=3), - ui_group("ui_grp_trend_long_basin_fill", "Basin Fill (1 hour)", PG_LONG, width=12, order=4), - ] - - nodes.append(comment("c_ui_title", TAB_UI, LANE_X[2], 20, - "📊 DASHBOARD UI — only ui-* widgets here", - "Layout: column 1 = inputs (sliders/switches/buttons) → link-outs.\n" - "Column 2 = link-ins from process → routed to text/gauge/chart widgets." - )) - - # ===== SECTION: Process Demand ===== - y = 100 - nodes.append(comment("c_ui_demand", TAB_UI, LANE_X[2], y, - "── Process Demand ──", "")) - nodes.append(ui_slider( - "ui_demand_slider", TAB_UI, LANE_X[0], y + 40, g_demand, - "Manual demand (manual mode only)", "Manual demand (m³/h) — active in manual mode only", - 0, 100, 5.0, "manualDemand", - wires=["lout_demand_dash"] - )) - nodes.append(link_out( - "lout_demand_dash", TAB_UI, LANE_X[1], y + 40, - "cmd:Qd", target_in_ids=["lin_qd_at_ps"] - )) - nodes.append(ui_text( - "ui_demand_text", TAB_UI, LANE_X[3], y + 40, g_demand, - "Manual demand (active in manual mode)", "Manual demand", - "{{msg.payload}} m³/h" - )) - # The slider value routes to PS as Qd — only effective in manual mode. - # Route is: slider → link-out cmd:Qd → process tab link-in → PS. - - # ===== SECTION: Mode + Station Buttons ===== - y = 320 - nodes.append(comment("c_ui_station", TAB_UI, LANE_X[2], y, - "── Mode + Station-wide buttons ──", "")) - # Mode toggle now drives the PUMPING STATION mode (levelbased ↔ manual) - # instead of per-pump setMode. In levelbased mode, PS drives pumps - # automatically. In manual mode, the demand slider takes over. - nodes.append(ui_switch( - "ui_mode_toggle", TAB_UI, LANE_X[0], y + 40, g_station, - "Station mode", - "Station mode (Auto = level-based control · Manual = slider demand)", - on_value="levelbased", off_value="manual", topic="changemode", - wires=["lout_ps_mode_dash"] - )) - nodes.append(link_out( - "lout_ps_mode_dash", TAB_UI, LANE_X[1], y + 40, - "cmd:ps-mode", target_in_ids=["lin_ps_mode_at_ps"] - )) - - for k, (text, payload, color, icon, lout_id, channel) in enumerate([ - ("Start all pumps", '{"topic":"execSequence","payload":{"source":"GUI","action":"execSequence","parameter":"startup"}}', - "#16a34a", "play_arrow", "lout_cmd_station_startup_dash", CH_STATION_START), - ("Stop all pumps", '{"topic":"execSequence","payload":{"source":"GUI","action":"execSequence","parameter":"shutdown"}}', - "#ea580c", "stop", "lout_cmd_station_shutdown_dash", CH_STATION_STOP), - ("EMERGENCY STOP", '{"topic":"emergencystop","payload":{"source":"GUI","action":"emergencystop"}}', - "#dc2626", "stop_circle", "lout_cmd_station_estop_dash", CH_STATION_ESTOP), - ]): - yk = y + 100 + k * 60 - # The ui-button payload becomes msg.payload; we want the button to send - # a fully-formed {topic, payload} for the per-pump nodeClass to dispatch. - # ui-button can't set msg.topic from a constant payload that's an - # object directly — easier path: a small function in front of the - # link-out that wraps the button's plain payload string into the - # right shape per channel. - btn_id = f"btn_station_{k}" - wrap_id = f"wrap_station_{k}" - # Use simple payload (just the button text) and let the wrapper build - # the real msg shape. - if k == 0: # startup - wrap_code = ( - "msg.topic = 'execSequence';\n" - "msg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\n" - "return msg;" - ) - elif k == 1: # shutdown - wrap_code = ( - "msg.topic = 'execSequence';\n" - "msg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\n" - "return msg;" - ) - else: # estop - wrap_code = ( - "msg.topic = 'emergencystop';\n" - "msg.payload = { source:'GUI', action:'emergencystop' };\n" - "return msg;" - ) - - nodes.append(ui_button( - btn_id, TAB_UI, LANE_X[0], yk, g_station, - text, text, "fired", "str", - topic=f"station_{k}", color=color, icon=icon, - wires=[wrap_id] - )) - nodes.append(function_node( - wrap_id, TAB_UI, LANE_X[1] + 100, yk, f"build cmd ({text})", - wrap_code, outputs=1, wires=[[lout_id]] - )) - nodes.append(link_out( - lout_id, TAB_UI, LANE_X[2], yk, - channel, - target_in_ids=[{ - CH_STATION_START: "lin_station_start", - CH_STATION_STOP: "lin_station_stop", - CH_STATION_ESTOP: "lin_station_estop", - }[channel]] - )) - - # ===== SECTION: MGC + PS overview ===== - y = 600 - nodes.append(comment("c_ui_mgc_ps", TAB_UI, LANE_X[2], y, - "── MGC + Basin overview ──", "")) - nodes.append(link_in( - "lin_evt_mgc_dash", TAB_UI, LANE_X[0], y + 40, - CH_MGC_EVT, source_out_ids=["lout_evt_mgc"], - downstream=["dispatch_mgc"] - )) - nodes.append(function_node( - "dispatch_mgc", TAB_UI, LANE_X[1], y + 40, - "dispatch MGC", - "const p = msg.payload || {};\n" - "return [\n" - " {payload: String(p.totalFlow || 'n/a')},\n" - " {payload: String(p.totalPower || 'n/a')},\n" - " {payload: String(p.efficiency || 'n/a')},\n" - "];", - outputs=3, - wires=[["ui_mgc_total_flow"], ["ui_mgc_total_power"], ["ui_mgc_eff"]], - )) - nodes.append(ui_text("ui_mgc_total_flow", TAB_UI, LANE_X[2], y + 40, g_mgc, - "MGC total flow", "Total flow", "{{msg.payload}}")) - nodes.append(ui_text("ui_mgc_total_power", TAB_UI, LANE_X[2], y + 70, g_mgc, - "MGC total power", "Total power", "{{msg.payload}}")) - nodes.append(ui_text("ui_mgc_eff", TAB_UI, LANE_X[2], y + 100, g_mgc, - "MGC efficiency", "Group efficiency", "{{msg.payload}}")) - - nodes.append(link_in( - "lin_evt_ps_dash", TAB_UI, LANE_X[0], y + 160, - CH_PS_EVT, source_out_ids=["lout_evt_ps"], - downstream=["dispatch_ps"] - )) - # PS dispatcher: 10 outputs — 7 text fields + 3 trend numerics - nodes.append(function_node( - "dispatch_ps", TAB_UI, LANE_X[1], y + 160, - "dispatch PS", - "const p = msg.payload || {};\n" - "const ts = Date.now();\n" - "return [\n" - " {payload: String(p.direction || 'steady')},\n" - " {payload: String(p.level || 'n/a')},\n" - " {payload: String(p.volume || 'n/a')},\n" - " {payload: String(p.fillPct || 'n/a')},\n" - " {payload: String(p.netFlow || 'n/a')},\n" - " {payload: String(p.timeLeft || 'n/a')},\n" - " {payload: String(p.qIn || 'n/a')},\n" - " // Trend numerics\n" - " p.fillPctNum != null ? {topic: 'Basin fill', payload: p.fillPctNum, timestamp: ts} : null,\n" - " p.levelNum != null ? {topic: 'Basin level', payload: p.levelNum, timestamp: ts} : null,\n" - " p.netFlowNum != null ? {topic: 'Net flow', payload: p.netFlowNum,timestamp: ts} : null,\n" - " p.percControl != null ? {topic: 'PS demand', payload: p.percControl, timestamp: ts} : null,\n" - " p.qInNum != null ? {topic: 'Inflow', payload: p.qInNum, timestamp: ts} : null,\n" - " p.qOutNum != null ? {topic: 'Outflow', payload: p.qOutNum, timestamp: ts} : null,\n" - "];", - outputs=13, - wires=[ - ["ui_ps_direction"], - ["ui_ps_level"], - ["ui_ps_volume"], - ["ui_ps_fill"], - ["ui_ps_netflow"], - ["ui_ps_timeleft"], - ["ui_ps_qin"], - # Trend + gauge outputs — split level and fill to separate charts - ["trend_short_fill", "trend_long_fill", "gauge_ps_fill", "gauge_ps_fill_long"], # fill % → fill chart + gauges - ["trend_short_level", "trend_long_level", "gauge_ps_level", "gauge_ps_level_long"], # level → level chart + gauges - ["trend_short_flow", "trend_long_flow"], # net flow → flow charts - ["trend_short_fill", "trend_long_fill"], # percControl (%) → fill charts (same y-axis) - ["trend_short_flow", "trend_long_flow"], # inflow m3/h → flow charts - ["trend_short_flow", "trend_long_flow"], # outflow m3/h → flow charts - ], - )) - - # (Basin gauges live on the trend pages, not the control page — - # see the trend section below for gauge_ps_level / gauge_ps_fill.) - - # PS text widgets - nodes.append(ui_text("ui_ps_direction", TAB_UI, LANE_X[2], y + 160, g_ps, - "PS direction", "Direction", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_level", TAB_UI, LANE_X[2], y + 200, g_ps, - "PS level", "Basin level", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_volume", TAB_UI, LANE_X[2], y + 240, g_ps, - "PS volume", "Basin volume", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_fill", TAB_UI, LANE_X[2], y + 280, g_ps, - "PS fill %", "Fill level", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_netflow", TAB_UI, LANE_X[2], y + 320, g_ps, - "PS net flow", "Net flow", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_timeleft", TAB_UI, LANE_X[2], y + 360, g_ps, - "PS time left", "Time to full/empty", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_qin", TAB_UI, LANE_X[2], y + 400, g_ps, - "PS Qin", "Inflow", "{{msg.payload}}")) - - # ===== SECTION: Per-pump panels ===== - y_pumps_start = 1000 - for i, pump in enumerate(PUMPS): - label = PUMP_LABELS[pump] - g = {"pump_a": g_pump_a, "pump_b": g_pump_b, "pump_c": g_pump_c}[pump] - y_p = y_pumps_start + i * SECTION_GAP * 2 - - nodes.append(comment(f"c_ui_{pump}", TAB_UI, LANE_X[2], y_p, - f"── {label} ──", "")) - - # link-in: one fat object per pump → dispatcher splits into - # plain-string payloads per ui-text widget + numeric payloads - # for trend charts. 9 outputs total. - DISPLAY_FIELDS = [ - ("State", "state"), - ("Mode", "mode"), - ("Controller %", "ctrl"), - ("Flow", "flow"), - ("Power", "power"), - ("p Upstream", "pUp"), - ("p Downstream", "pDn"), - ] - nodes.append(link_in( - f"lin_evt_{pump}_dash", TAB_UI, LANE_X[0], y_p + 40, - CH_PUMP_EVT[pump], source_out_ids=[f"lout_evt_{pump}"], - downstream=[f"dispatch_{pump}"], - )) - # Dispatcher: takes the fat object and returns 9 outputs, each - # with a plain payload ready for a ui-text or trend chart. - nodes.append(function_node( - f"dispatch_{pump}", TAB_UI, LANE_X[1], y_p + 40, - f"dispatch {label}", - "const p = msg.payload || {};\n" - "const ts = Date.now();\n" - "return [\n" - " {payload: String(p.state || 'idle')},\n" - " {payload: String(p.mode || 'auto')},\n" - " {payload: String(p.ctrl || 'n/a')},\n" - " {payload: String(p.flow || 'n/a')},\n" - " {payload: String(p.power || 'n/a')},\n" - " {payload: String(p.pUp || 'n/a')},\n" - " {payload: String(p.pDn || 'n/a')},\n" - " p.flowNum != null ? {topic: '" + label + "', payload: p.flowNum, timestamp: ts} : null,\n" - " p.powerNum != null ? {topic: '" + label + "', payload: p.powerNum, timestamp: ts} : null,\n" - "];", - outputs=9, - wires=[ - [f"ui_{pump}_{f}"] for _, f in DISPLAY_FIELDS - ] + [ - ["trend_short_flow", "trend_long_flow"], # output 7: flowNum → both flow charts - ["trend_short_power", "trend_long_power"], # output 8: powerNum → both power charts - ], - )) - # ui-text widgets - for k, (label_txt, field) in enumerate(DISPLAY_FIELDS): - nodes.append(ui_text( - f"ui_{pump}_{field}", TAB_UI, LANE_X[2], y_p + 40 + k * 40, g, - f"{label} {label_txt}", label_txt, - "{{msg.payload}}" # plain string — FlowFuse-safe - )) - - # Setpoint slider → wrapper → link-out → process pump (cmd:setpoint-X) - nodes.append(ui_slider( - f"ui_{pump}_setpoint", TAB_UI, LANE_X[0], y_p + 280, g, - f"{label} setpoint", "Setpoint % (manual mode)", - 0, 100, 5.0, f"setpoint_{pump}", - wires=[f"lout_setpoint_{pump}_dash"] - )) - nodes.append(link_out( - f"lout_setpoint_{pump}_dash", TAB_UI, LANE_X[1], y_p + 280, - CH_PUMP_SETPOINT[pump], - target_in_ids=[f"lin_setpoint_{pump}"] - )) - - # Per-pump start/stop buttons → link-out - # We need wrappers because ui-button payload must be string-typed. - nodes.append(ui_button( - f"btn_{pump}_start", TAB_UI, LANE_X[0], y_p + 330, g, - f"{label} startup", "Startup", "fired", "str", - topic=f"start_{pump}", color="#16a34a", icon="play_arrow", - wires=[f"wrap_{pump}_start"] - )) - nodes.append(function_node( - f"wrap_{pump}_start", TAB_UI, LANE_X[1] + 100, y_p + 330, - f"build start ({label})", - "msg.topic = 'execSequence';\n" - "msg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\n" - "return msg;", - outputs=1, wires=[[f"lout_seq_{pump}_dash"]] - )) - nodes.append(ui_button( - f"btn_{pump}_stop", TAB_UI, LANE_X[0], y_p + 380, g, - f"{label} shutdown", "Shutdown", "fired", "str", - topic=f"stop_{pump}", color="#ea580c", icon="stop", - wires=[f"wrap_{pump}_stop"] - )) - nodes.append(function_node( - f"wrap_{pump}_stop", TAB_UI, LANE_X[1] + 100, y_p + 380, - f"build stop ({label})", - "msg.topic = 'execSequence';\n" - "msg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\n" - "return msg;", - outputs=1, wires=[[f"lout_seq_{pump}_dash"]] - )) - # Both start and stop wrappers feed one shared link-out - nodes.append(link_out( - f"lout_seq_{pump}_dash", TAB_UI, LANE_X[2], y_p + 355, - CH_PUMP_SEQUENCE[pump], - target_in_ids=[f"lin_seq_{pump}"] - )) - - # (Trend feed is handled by dispatcher outputs 7+8 above — no separate - # trend_split function needed.) - - # ===== Trend charts — two pages, two charts per page ===== - # Short-term (10 min rolling window) and long-term (1 hour). - # Same data feed; different removeOlder settings. - y_charts = y_pumps_start + len(PUMPS) * SECTION_GAP * 2 + 80 - nodes.append(comment("c_ui_trends", TAB_UI, LANE_X[2], y_charts, - "── Trend charts ── (feed to 4 charts on 2 pages)", - "Short-term (10 min) and long-term (1 h) trends share the same feed.\n" - "Each chart on its own page." - )) - # Short-term (10 min) - nodes.append(ui_chart( - "trend_short_flow", TAB_UI, LANE_X[3], y_charts + 40, - g_trend_short_flow, - "Flow per pump — 10 min", "Flow per pump", - width=12, height=8, y_axis_label="m³/h", - remove_older="10", remove_older_unit="60", remove_older_points="300", - order=1, - )) - nodes.append(ui_chart( - "trend_short_power", TAB_UI, LANE_X[3], y_charts + 120, - g_trend_short_power, - "Power per pump — 10 min", "Power per pump", - width=12, height=8, y_axis_label="kW", - remove_older="10", remove_older_unit="60", remove_older_points="300", - order=1, - )) - # Long-term (1 hour) - nodes.append(ui_chart( - "trend_long_flow", TAB_UI, LANE_X[3], y_charts + 200, - g_trend_long_flow, - "Flow per pump — 1 hour", "Flow per pump", - width=12, height=8, y_axis_label="m³/h", - remove_older="60", remove_older_unit="60", remove_older_points="1800", - order=1, - )) - nodes.append(ui_chart( - "trend_long_power", TAB_UI, LANE_X[3], y_charts + 280, - g_trend_long_power, - "Power per pump — 1 hour", "Power per pump", - width=12, height=8, y_axis_label="kW", - remove_older="60", remove_older_unit="60", remove_older_points="1800", - order=1, - )) - - # ===== Basin charts + gauges (fill %, level, net flow) ===== - # Gauge segment definitions (reused for both pages) - TANK_SEGMENTS = [ - {"color": "#f44336", "from": 0}, # red: below minLevel (1.0 m) - {"color": "#ff9800", "from": 1.0}, # orange: between stop and start - {"color": "#2196f3", "from": 2.0}, # blue: normal operating (startLevel) - {"color": "#ff9800", "from": 3.5}, # orange: approaching overflow - {"color": "#f44336", "from": 3.8}, # red: overflow zone (overflowLevel) - ] - FILL_SEGMENTS = [ - {"color": "#f44336", "from": 0}, - {"color": "#ff9800", "from": 10}, - {"color": "#4caf50", "from": 30}, - {"color": "#ff9800", "from": 80}, - {"color": "#f44336", "from": 95}, - ] - - for suffix, remove_older, remove_points, y_off in [ - ("short", "10", "300", 360), - ("long", "60", "1800", 540), - ]: - label = "10 min" if suffix == "short" else "1 hour" - grp_level = f"ui_grp_trend_{suffix}_basin_level" - grp_fill = f"ui_grp_trend_{suffix}_basin_fill" - - # Basin LEVEL chart (m) — also receives net flow (m³/h) on right axis - # via eCharts dual-axis configured by the first data message - nodes.append(ui_chart( - f"trend_{suffix}_level", TAB_UI, LANE_X[3], y_charts + y_off, - grp_level, - f"Basin Level — {label}", "Basin Level + Net Flow", - width=8, height=8, y_axis_label="m", - remove_older=remove_older, remove_older_unit="60", - remove_older_points=remove_points, - order=1, - )) - - # Basin FILL chart (%) — simple single-axis - nodes.append(ui_chart( - f"trend_{suffix}_fill", TAB_UI, LANE_X[3], y_charts + y_off + 80, - grp_fill, - f"Basin Fill — {label}", "Basin Fill", - width=8, height=6, y_axis_label="%", - remove_older=remove_older, remove_older_unit="60", - remove_older_points=remove_points, - ymin=0, ymax=100, order=1, - )) - # Tank gauge: basin level 0–4 m — in the level group - gauge_id_suffix = "" if suffix == "short" else "_long" - nodes.append({ - "id": f"gauge_ps_level{gauge_id_suffix}", "type": "ui-gauge", - "z": TAB_UI, "group": grp_level, - "name": f"Basin level gauge ({suffix})", - "gtype": "gauge-tank", "gstyle": "Rounded", - "title": "Level", "units": "m", - "prefix": "", "suffix": " m", - "min": 0, "max": 4, - "segments": TANK_SEGMENTS, - "width": 2, "height": 5, "order": 2, - "icon": "", "sizeGauge": 20, "sizeGap": 2, "sizeSegments": 10, - "x": LANE_X[4], "y": y_charts + y_off, "wires": [], - }) - # 270° arc: fill % — in the fill group - nodes.append({ - "id": f"gauge_ps_fill{gauge_id_suffix}", "type": "ui-gauge", - "z": TAB_UI, "group": grp_fill, - "name": f"Basin fill gauge ({suffix})", - "gtype": "gauge-34", "gstyle": "Rounded", - "title": "Fill", "units": "%", - "prefix": "", "suffix": "%", - "min": 0, "max": 100, - "segments": FILL_SEGMENTS, - "width": 2, "height": 4, "order": 3, - "icon": "water_drop", "sizeGauge": 20, "sizeGap": 2, "sizeSegments": 10, - "x": LANE_X[5], "y": y_charts + y_off, "wires": [], - }) - - return nodes - - -# --------------------------------------------------------------------------- -# Tab 3 — DEMO DRIVERS -# --------------------------------------------------------------------------- -def build_drivers_tab(): - nodes = [] - nodes.append({ - "id": TAB_DRIVERS, "type": "tab", - "label": "🎛️ Demo Drivers", - "disabled": False, - "info": "Simulated inflow for the demo. A slow sinusoid generates " - "inflow into the pumping station basin, which then drives " - "the level-based pump control automatically.\n\n" - "In production, delete this tab — real inflow comes from " - "upstream measurement sensors.", - }) - nodes.append(comment("c_drv_title", TAB_DRIVERS, LANE_X[2], 20, - "🎛️ DEMO DRIVERS — simulated basin inflow", - "Sinus generator → q_in to pumpingStation. Basin fills → level-based\n" - "control starts pumps → basin drains → pumps stop → cycle repeats." - )) - - # Sinus inflow generator: produces a flow value (m³/s) that - # simulates incoming wastewater. Period ~120s so the fill/drain - # cycle is visible on the dashboard. Amplitude scaled so 3 pumps - # can handle the peak. - # Q_in = base + amplitude * (1 + sin(2π t / period)) / 2 - # base = 0.005 m³/s (~18 m³/h) — always some inflow - # amplitude = 0.03 m³/s (~108 m³/h peak) - # period = 120 s - y = 100 - nodes.append(comment("c_drv_sinus", TAB_DRIVERS, LANE_X[2], y, - "── Sinusoidal inflow generator ──", - "Produces a smooth inflow curve (m³/s) and sends to pumpingStation\n" - "via the cmd:q_in link channel. Period = 120s." - )) - nodes.append(inject( - "sinus_tick", TAB_DRIVERS, LANE_X[0], y + 40, - "tick (1s inflow)", - topic="sinusTick", payload="", payload_type="date", - repeat="1", wires=["sinus_fn"] - )) - nodes.append(function_node( - "sinus_fn", TAB_DRIVERS, LANE_X[1] + 220, y + 40, - "sinus inflow (m³/s)", - "// Realistic wastewater inflow profile:\n" - "// base = minimum dry-weather flow (always present)\n" - "// amplitude = peak wet-weather swing on top of base\n" - "// range = base → base+amplitude = 54 → 270 m³/h\n" - "// 1 pump handles up to ~223 m³/h, so peak needs 2 pumps.\n" - "// 3 pumps (669 m³/h) are never needed = realistic headroom.\n" - "// period = 240s (4 min) — slow enough to see pump ramp on dash.\n" - "const base = 0.015; // m³/s (~54 m³/h dry weather)\n" - "const amplitude = 0.06; // m³/s (~216 m³/h peak swing)\n" - "const period = 240; // seconds per full cycle\n" - "const t = Date.now() / 1000; // seconds since epoch\n" - "const q = base + amplitude * (1 + Math.sin(2 * Math.PI * t / period)) / 2;\n" - "return { topic: 'q_in', payload: q, unit: 'm3/s', timestamp: Date.now() };", - outputs=1, wires=[["lout_qin_drivers"]] - )) - nodes.append(link_out( - "lout_qin_drivers", TAB_DRIVERS, LANE_X[3], y + 40, - "cmd:q_in", target_in_ids=["lin_qin_at_ps"] - )) - - return nodes - - -# --------------------------------------------------------------------------- -# Tab 4 — SETUP & INIT -# --------------------------------------------------------------------------- -def build_setup_tab(): - nodes = [] - nodes.append({ - "id": TAB_SETUP, "type": "tab", - "label": "⚙️ Setup & Init", - "disabled": False, - "info": "One-shot deploy-time injects. Sets MGC scaling/mode, broadcasts " - "pumps mode = auto, and auto-starts the pumps + random demand.", - }) - nodes.append(comment("c_setup_title", TAB_SETUP, LANE_X[2], 20, - "⚙️ SETUP & INIT — one-shot deploy-time injects", - "Disable this tab in production — the runtime should be persistent." - )) - - # Setup wires DIRECTLY to the process nodes (cross-tab via link is cleaner - # but for one-shot setups direct wiring keeps the intent obvious). - y = 100 - nodes.append(inject( - "setup_mgc_scaling", TAB_SETUP, LANE_X[0], y, - "MGC scaling = normalized", - topic="setScaling", payload="normalized", payload_type="str", - once=True, once_delay="1.5", - wires=["lout_setup_to_mgc"] - )) - nodes.append(inject( - "setup_mgc_mode", TAB_SETUP, LANE_X[0], y + 60, - "MGC mode = optimalcontrol", - topic="setMode", payload="optimalcontrol", payload_type="str", - once=True, once_delay="1.7", - wires=["lout_setup_to_mgc"] - )) - nodes.append(link_out( - "lout_setup_to_mgc", TAB_SETUP, LANE_X[1], y + 30, - "setup:to-mgc", target_in_ids=["lin_setup_at_mgc"] - )) - - y = 250 - nodes.append(inject( - "setup_pumps_mode", TAB_SETUP, LANE_X[0], y, - "pumps mode = auto", - topic="setMode", payload="auto", payload_type="str", - once=True, once_delay="2.0", - wires=["lout_mode_setup"] - )) - nodes.append(link_out( - "lout_mode_setup", TAB_SETUP, LANE_X[1], y, - CH_MODE, target_in_ids=["lin_mode"] - )) - - # Auto-startup removed: the MGC starts pumps on demand from the PS. - # Starting pumps before the PS requests them causes flow below startLevel. - - # (Random demand removed — sinus inflow drives the demo automatically. - # No explicit "random on" inject needed.) - - return nodes - - -# --------------------------------------------------------------------------- -# Process tab additions: setup link-in feeding MGC -# --------------------------------------------------------------------------- -def add_setup_link_to_process(process_nodes): - """Inject a link-in on the process tab that funnels setup msgs to MGC.""" - y = 100 + 7 * SECTION_GAP - process_nodes.append(comment( - "c_setup_at_mgc", TAB_PROCESS, LANE_X[2], y, - "── Setup feeders ──", - "Cross-tab link from Setup tab → MGC scaling/mode init." - )) - process_nodes.append(link_in( - "lin_setup_at_mgc", TAB_PROCESS, LANE_X[0], y + 60, - "setup:to-mgc", - source_out_ids=["lout_setup_to_mgc"], - downstream=[MGC_ID] - )) - - -# --------------------------------------------------------------------------- -# Assemble + emit -# --------------------------------------------------------------------------- -def main(): - process_nodes = build_process_tab() - add_setup_link_to_process(process_nodes) - nodes = process_nodes + build_ui_tab() + build_drivers_tab() + build_setup_tab() - json.dump(nodes, sys.stdout, indent=2) - sys.stdout.write("\n") - - -if __name__ == "__main__": - main() diff --git a/examples/pumpingstation-complete-example/README.md b/examples/pumpingstation-complete-example/README.md new file mode 100644 index 0000000..0591498 --- /dev/null +++ b/examples/pumpingstation-complete-example/README.md @@ -0,0 +1,195 @@ +# 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 + +```bash +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): +- Grafana dashboard (realtime gauges + historic graphs): (anonymous viewer is on; the dashboard is `EVOLV / Pumping Station (complete)`) +- InfluxDB UI: (user `evolv` / password `evolv-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_` function node on the Process Plant tab. It receives: + +1. The pump's own port-0 stream (state, predicted flow, predicted power). +2. 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`): + +1. Inflow — slider, 4 scenario buttons, live value + active scenario label +2. Station mode + commands — Auto/Manual switch, manual Qd slider, Start All / Stop All / Emergency Stop +3. Basin realtime — direction, level, volume, fill %, net flow, time-to-full/empty, inflow, outflow, safety state, gauges (level + fill) +4. MGC — total flow + power (text + gauges), efficiency +5. 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 + +```bash +# 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: + +```bash +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: + +```bash +./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//` 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//flow.json` (in volume) | Until `docker compose down -v` | +| Edit `examples//build_flow.py` then regenerate | `examples//flow.json` (in repo) | Always — it's in Git | +| Run `scripts/sync-example.sh ` | Copies repo's `flow.json` → volume's project + reloads | Volume copy now matches repo | + +### Adding a new example as a project + +1. Create `examples//flow.json` (build it however you like — `build_flow.py` is one way). +2. Restart the Node-RED container: `docker compose restart nodered`. +3. Editor → Projects → Open Project → pick ``. + +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/`) 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 `levelbased` mode** with `manual` mode 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 new `lout_tlm_` link-out + appending the id to `_all_tlm_lout_ids()` in `build_flow.py`. +- **Dashboard pages split by concern, not data**: realtime widgets never share a page with historical charts. diff --git a/examples/pumpingstation-complete-example/build_flow.py b/examples/pumpingstation-complete-example/build_flow.py new file mode 100644 index 0000000..bd3a40e --- /dev/null +++ b/examples/pumpingstation-complete-example/build_flow.py @@ -0,0 +1,1909 @@ +#!/usr/bin/env python3 +""" +Generate the multi-tab Node-RED flow for the +'pumpingstation-complete-example' end-to-end demo. + +Stack +----- +- 1 pumpingStation (basin model, levelbased control) +- 1 machineGroupControl (orchestrates the 3 pumps) +- 3 rotatingMachine pumps +- 12 measurement nodes (4 per pump: upstream P, downstream P, flow, power) +- All EVOLV node port-1 telemetry routed to InfluxDB via http request +- FlowFuse dashboard (realtime + 1h trends) +- Grafana dashboard (realtime gauges + historic graphs) + +Tabs +---- + Tab 1 Process Plant EVOLV nodes only — pumps, MGC, PS, measurements, + per-node output formatters and per-pump physics + feeders that drive the measurement nodes from live + plant state. + + Tab 2 Dashboard UI only ui-* widgets. No business logic. + + Tab 3 Demo Drivers inflow generator (Constant / Sine / Diurnal / Storm + scenarios chosen by buttons; baseline set by slider). + + Tab 4 Setup & Init one-shot deploy-time injects (MGC scaling/mode, + pumps mode = auto). + + Tab 5 Telemetry collects port-1 InfluxDB payloads from every EVOLV + node, converts to line protocol, POSTs to InfluxDB. + +Cross-tab wiring is via NAMED link-out / link-in pairs only. + +To regenerate: + python3 build_flow.py > flow.json +""" +import json +import sys + +# --------------------------------------------------------------------------- +# Tab IDs +# --------------------------------------------------------------------------- +TAB_PROCESS = "tab_process" +TAB_UI = "tab_ui" +TAB_DRIVERS = "tab_drivers" +TAB_SETUP = "tab_setup" +TAB_TLM = "tab_telemetry" + +# --------------------------------------------------------------------------- +# Spacing constants +# --------------------------------------------------------------------------- +LANE_X = [120, 380, 640, 900, 1160, 1420] +ROW = 80 +SECTION_GAP = 220 + +POSITION_ICON = { + "upstream": "→", + "downstream": "←", + "atEquipment": "⊥", +} + +# --------------------------------------------------------------------------- +# Cross-tab link channels — the wiring contract +# --------------------------------------------------------------------------- +CH_INFLOW_BASELINE = "cmd:inflow-baseline" # m³/h baseline (slider) +CH_INFLOW_SCENARIO = "cmd:inflow-scenario" # 'constant' | 'sine' | 'diurnal' | 'storm' +CH_QIN = "cmd:q_in" # m³/s, generator → PS +CH_QD = "cmd:Qd" # m³/h, slider → PS (manual mode only) +CH_PS_MODE = "cmd:ps-mode" # 'levelbased' | 'manual' + +CH_STATION_START = "cmd:station-startup" +CH_STATION_STOP = "cmd:station-shutdown" +CH_STATION_ESTOP = "cmd:station-estop" + +CH_PUMP_SETPOINT = {"pump_a": "cmd:setpoint-A", + "pump_b": "cmd:setpoint-B", + "pump_c": "cmd:setpoint-C"} +CH_PUMP_SEQUENCE = {"pump_a": "cmd:pump-A-seq", + "pump_b": "cmd:pump-B-seq", + "pump_c": "cmd:pump-C-seq"} + +CH_PUMP_EVT = {"pump_a": "evt:pump-A", + "pump_b": "evt:pump-B", + "pump_c": "evt:pump-C"} +CH_MGC_EVT = "evt:mgc" +CH_PS_EVT = "evt:ps" +CH_INFLOW_EVT = "evt:inflow" + +CH_TLM = "evt:tlm" + +PUMPS = ["pump_a", "pump_b", "pump_c"] +PUMP_LABELS = {"pump_a": "Pump A", "pump_b": "Pump B", "pump_c": "Pump C"} + +MGC_ID = "mgc_pumps" +PS_ID = "ps_basin" + +# Basin geometry — single source of truth. +# Realistic wet-well wastewater pumping station — pumps are oversized +# ~5× nominal inflow for storm tolerance. Sized so: +# - nominal inflow ~25 m³/h refills the dead-band [stopLvl, startLvl] +# (~6.25 m³) in ~15 min while pumps are off +# - one pump at minimum stable flow (~99 m³/h) drains the same band in +# ~5 min once engaged via the stopLevel Schmitt trigger +# - storm inflow ~250 m³/h pushes percControl up the ramp until all 3 +# pumps are engaged at high flow (combined max ≈ 681 m³/h) +# surfaceArea = 50 / 4 = 12.5 m²; band volume = 12.5 × 0.5 = 6.25 m³ +BASIN_VOLUME = 50.0 +BASIN_HEIGHT = 4.0 +OUTFLOW_LEVEL = 0.3 +OVERFLOW_LEVEL = 3.8 + + +# --------------------------------------------------------------------------- +# Generic node-builder helpers +# --------------------------------------------------------------------------- +def comment(node_id, tab, x, y, name, info=""): + return {"id": node_id, "type": "comment", "z": tab, "name": name, + "info": info, "x": x, "y": y, "wires": []} + + +def inject(node_id, tab, x, y, name, topic, payload, payload_type="str", + once=False, repeat="", once_delay="0.5", wires=None): + return { + "id": node_id, "type": "inject", "z": tab, "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": once_delay, + "x": x, "y": y, "wires": [wires or []], + } + + +def function_node(node_id, tab, x, y, name, code, outputs=1, wires=None): + return { + "id": node_id, "type": "function", "z": tab, "name": name, + "func": code, "outputs": outputs, + "noerr": 0, "initialize": "", "finalize": "", "libs": [], + "x": x, "y": y, + "wires": wires if wires is not None else [[] for _ in range(outputs)], + } + + +def link_out(node_id, tab, x, y, channel_name, target_in_ids): + return { + "id": node_id, "type": "link out", "z": tab, "name": channel_name, + "mode": "link", "links": list(target_in_ids), + "x": x, "y": y, "wires": [], + } + + +def link_in(node_id, tab, x, y, channel_name, source_out_ids, downstream): + return { + "id": node_id, "type": "link in", "z": tab, "name": channel_name, + "links": list(source_out_ids), + "x": x, "y": y, "wires": [downstream or []], + } + + +def debug_node(node_id, tab, x, y, name, target="payload", + target_type="msg", active=False): + return { + "id": node_id, "type": "debug", "z": tab, "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", "type": "ui-base", "name": "EVOLV Pumping", + "path": "/dashboard", "appIcon": "", + "includeClientData": True, + "acceptsClientConfig": ["ui-notification", "ui-control"], + "showPathInSidebar": True, "headerContent": "page", + "navigationStyle": "default", "titleBarStyle": "default", + } + theme = { + "id": "ui_theme", "type": "ui-theme", "name": "EVOLV Theme", + "colors": { + "surface": "#ffffff", "primary": "#0c99d9", + "bgPage": "#f4f6fa", "groupBg": "#ffffff", + "groupOutline": "#cccccc", + }, + "sizes": { + "density": "default", "pagePadding": "12px", + "groupGap": "12px", "groupBorderRadius": "6px", + "widgetGap": "8px", + }, + } + page_realtime = { + "id": "ui_page_realtime", "type": "ui-page", + "name": "Realtime", "ui": "ui_base", + "path": "/realtime", "icon": "speed", + "layout": "grid", "theme": "ui_theme", + "breakpoints": [{"name": "Default", "px": "0", "cols": "12"}], + "order": 1, "className": "", + } + page_trends = { + "id": "ui_page_trends", "type": "ui-page", + "name": "Trends — 1 hour", "ui": "ui_base", + "path": "/trends", "icon": "show_chart", + "layout": "grid", "theme": "ui_theme", + "breakpoints": [{"name": "Default", "px": "0", "cols": "12"}], + "order": 2, "className": "", + } + return [base, theme, page_realtime, page_trends] + + +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, tab, x, y, group, name, label, fmt, layout="row-spread"): + return { + "id": node_id, "type": "ui-text", "z": tab, "group": group, + "order": 1, "width": "0", "height": "0", "name": name, "label": label, + "format": fmt, "layout": layout, "style": False, "font": "", + "fontSize": 14, "color": "#000000", + "x": x, "y": y, "wires": [], + } + + +def ui_button(node_id, tab, x, y, group, name, label, payload, payload_type, + topic, color="#0c99d9", icon="play_arrow", wires=None): + return { + "id": node_id, "type": "ui-button", "z": tab, "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, tab, x, y, group, name, label, mn, mx, step=1.0, + topic="", wires=None): + return { + "id": node_id, "type": "ui-slider", "z": tab, "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, tab, x, y, group, name, label, on_value, off_value, + topic, wires=None): + return { + "id": node_id, "type": "ui-switch", "z": tab, "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": "#0c99d9", + "offvalue": off_value, "offvalueType": "str", + "officon": "back_hand", "offcolor": "#888888", + "x": x, "y": y, "wires": [wires or []], + } + + +def ui_chart(node_id, tab, x, y, group, name, label, + width=12, height=6, + remove_older="60", remove_older_unit="60", + remove_older_points="1800", + y_axis_label="", ymin=None, ymax=None, order=1, + interpolation="linear"): + """FlowFuse ui-chart — full required field set per node-red-flow-layout.md.""" + return { + "id": node_id, "type": "ui-chart", "z": tab, "group": group, + "name": name, "label": label, "order": order, + "chartType": "line", + "interpolation": interpolation, + "category": "topic", "categoryType": "msg", + "xAxisLabel": "", "xAxisType": "time", + "xAxisProperty": "", "xAxisPropertyType": "timestamp", + "xAxisFormat": "", "xAxisFormatType": "auto", + "xmin": "", "xmax": "", + "yAxisLabel": y_axis_label, + "yAxisProperty": "payload", "yAxisPropertyType": "msg", + "ymin": "" if ymin is None else str(ymin), + "ymax": "" if ymax is None else str(ymax), + "removeOlder": str(remove_older), + "removeOlderUnit": str(remove_older_unit), + "removeOlderPoints": str(remove_older_points), + "action": "append", + "stackSeries": False, + "pointShape": "circle", "pointRadius": 4, + "showLegend": True, + "bins": 10, + "colors": [ + "#0095FF", "#FF0000", "#FF7F0E", "#2CA02C", + "#A347E1", "#D62728", "#FF9896", "#9467BD", "#C5B0D5", + ], + "textColor": ["#666666"], "textColorDefault": True, + "gridColor": ["#e5e5e5"], "gridColorDefault": True, + "width": int(width), "height": int(height), "className": "", + "x": x, "y": y, "wires": [[]], + } + + +def ui_gauge(node_id, tab, x, y, group, name, title, units, mn, mx, + segments, gtype="gauge-34", suffix="", icon="", + width=3, height=3, order=1): + return { + "id": node_id, "type": "ui-gauge", "z": tab, "group": group, + "name": name, "gtype": gtype, "gstyle": "Rounded", + "title": title, "units": units, "prefix": "", "suffix": suffix, + "min": mn, "max": mx, "segments": segments, + "width": width, "height": height, "order": order, + "icon": icon, "sizeGauge": 20, "sizeGap": 2, "sizeSegments": 10, + "x": x, "y": y, "wires": [], + } + + +# --------------------------------------------------------------------------- +# Tab 1 — PROCESS PLANT +# --------------------------------------------------------------------------- +def build_process_tab(): + nodes = [] + + nodes.append({ + "id": TAB_PROCESS, "type": "tab", + "label": "🏭 Process Plant", + "disabled": False, + "info": ( + "EVOLV plant model: 3 rotatingMachines (each with 4 measurement " + "nodes — upstream P, downstream P, flow, power), MGC, PS.\n\n" + "Per pump there is a 'physics' function node that consumes the " + "pump's own port-0 stream PLUS PS port-0 (basin level) and " + "drives all 4 measurement nodes with physically-coupled values " + "(upstream P from basin head; downstream P from pump state + " + "flow; flow/power mirror predicted with Gaussian noise). This " + "lives on this tab so the plant model is self-contained.\n\n" + "All cross-tab wires use named link-in / link-out channels." + ), + }) + + nodes.append(comment("c_process_title", TAB_PROCESS, LANE_X[2], 20, + "🏭 PROCESS PLANT — EVOLV nodes + per-pump physics feeders", + "")) + + # ---------------- Per-pump rows ---------------- + for i, pump in enumerate(PUMPS): + label = PUMP_LABELS[pump] + y_section = 80 + i * (SECTION_GAP + 60) + + nodes.append(comment(f"c_{pump}", TAB_PROCESS, LANE_X[2], y_section, + f"── {label} ── (pump + 4 sensors + physics feeder)", + "Up/Dn pressure + flow + power sensors register as children of " + "the pump. The physics_ function takes the pump's own " + "port-0 stream and PS port-0 (basin level) and drives all 4 " + "sensors with physically-coupled values." + )) + + # ---- 4 measurement nodes (driven via msg.topic='measurement') ---- + SENSORS = [ + ("u", "Up", "upstream", "mbar", + "pressure", "vega", "vega-pressure-10"), + ("d", "Dn", "downstream", "mbar", + "pressure", "vega", "vega-pressure-10"), + ("f", "Flow", "downstream", "m3/h", + "flow", "endress", "endress-promag-50"), + ("p", "Pwr", "atEquipment","kW", + "power", "siemens", "siemens-sentron-pac4200"), + ] + for j, (suffix, lbl, pos, unit, asset_type, supplier, model) in enumerate(SENSORS): + mid = f"meas_{pump}_{suffix}" + mid_label = f"{label.split()[1]}-{lbl}" + if asset_type == "pressure": + o_min, o_max = 0, 4000 + elif asset_type == "flow": + o_min, o_max = 0, 250 + else: # power + o_min, o_max = 0, 30 + nodes.append({ + "id": mid, "type": "measurement", "z": TAB_PROCESS, + "name": mid_label, + "mode": "analog", "channels": "[]", + "scaling": False, + "i_min": 0, "i_max": 1, "i_offset": 0, + "o_min": o_min, "o_max": o_max, + "simulator": False, + "smooth_method": "mean", "count": "3", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": f"sensor-{pump}-{suffix}", + "supplier": supplier, "category": "sensor", + "assetType": asset_type, "model": model, + "unit": unit, + "assetTagNumber": f"{label.split()[1]}-{suffix.upper()}", + "enableLog": False, "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": pos, + "positionIcon": POSITION_ICON.get(pos, ""), + "hasDistance": False, "distance": 0, "distanceUnit": "m", + "distanceDescription": "", + "x": LANE_X[1], "y": y_section + 40 + j * 35, + # Port 0 unused, port 1 → telemetry, port 2 → pump (registerChild) + "wires": [[], [f"lout_tlm_{mid}"], [pump]], + }) + nodes.append(link_out( + f"lout_tlm_{mid}", TAB_PROCESS, LANE_X[1] + 200, + y_section + 40 + j * 35, + CH_TLM, target_in_ids=["lin_tlm"], + )) + + # ---- The pump itself ---- + nodes.append({ + "id": pump, "type": "rotatingMachine", "z": TAB_PROCESS, + "name": label, + # speed (movement units/s). The state machine doesn't auto- + # return to 'operational' after a routine abort (avoids a + # bounce loop), so any setpoint that arrives while still + # accelerating gets deferred via delayedMove. With MGC + # retargeting every PS tick (2 s) and a 0..100 position + # range, speed must be high enough that the movement + # finishes inside one tick — otherwise the FSM gets parked + # in 'accelerating' and the badge stops advancing. 200 u/s + # gives a worst-case 0..100 traversal of 0.5 s, well inside + # the 2 s window. + "speed": "200", + "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": False, "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "atEquipment", + "positionIcon": POSITION_ICON["atEquipment"], + "hasDistance": False, "distance": 0, "distanceUnit": "m", + "distanceDescription": "", + "x": LANE_X[3], "y": y_section + 90, + "wires": [ + [f"format_{pump}", f"physics_{pump}"], + [f"lout_tlm_{pump}"], + [MGC_ID], + ], + }) + nodes.append(link_out( + f"lout_tlm_{pump}", TAB_PROCESS, LANE_X[3], y_section + 130, + CH_TLM, target_in_ids=["lin_tlm"], + )) + + # ---- Per-pump output formatter (for dashboard) ---- + nodes.append(function_node( + f"format_{pump}", TAB_PROCESS, LANE_X[4], y_section + 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" + "// Throttle dashboard fan-out to ≤ 2 Hz. The pump emits on\n" + "// every state change (multiple per sec while cycling); the\n" + "// dashboard doesn't need that resolution and the websocket\n" + "// fan-out chokes the browser.\n" + "const now = Date.now();\n" + "const last = context.get('_lastEmit') || 0;\n" + "if (now - last < 1000) return null;\n" + "context.set('_lastEmit', now);\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 ctrl = find('ctrl.predicted.atequipment.');\n" + "const pUp = find('pressure.measured.upstream.');\n" + "const pDn = find('pressure.measured.downstream.');\n" + "msg.payload = {\n" + " state: c.state || 'idle',\n" + " mode: c.mode || 'auto',\n" + " ctrl: ctrl != null ? Number(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: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n" + " pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n" + " ctrlNum: ctrl != null ? Number(ctrl ) : null,\n" + " flowNum: flow != null ? Number(flow ) : null,\n" + " powerNum: power != null ? Number(power) : null,\n" + " pUpNum: pUp != null ? Number(pUp ) : null,\n" + " pDnNum: pDn != null ? Number(pDn ) : null,\n" + " // Pump is moving water any time it's between startup and shutdown, not\n" + " // just during steady operational. accelerate/decelerate/warmup count.\n" + " isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n" + "};\n" + "return msg;", + outputs=1, wires=[[f"lout_evt_{pump}"]], + )) + nodes.append(link_out( + f"lout_evt_{pump}", TAB_PROCESS, LANE_X[5], y_section + 90, + CH_PUMP_EVT[pump], + target_in_ids=[f"lin_evt_{pump}_dash"], + )) + + # ---- Physics feeder ---- + nodes.append(function_node( + f"physics_{pump}", TAB_PROCESS, LANE_X[4], y_section + 160, + f"physics {label} → 4 sensors", + _physics_code(pump.split("_", 1)[1]), + outputs=4, + wires=[ + [f"meas_{pump}_u"], + [f"meas_{pump}_d"], + [f"meas_{pump}_f"], + [f"meas_{pump}_p"], + ], + )) + + # ---- Setpoint slider link-in ---- + nodes.append(link_in( + f"lin_setpoint_{pump}", TAB_PROCESS, LANE_X[0], y_section + 60, + CH_PUMP_SETPOINT[pump], + source_out_ids=[f"lout_setpoint_{pump}_dash"], + downstream=[f"build_setpoint_{pump}"], + )) + nodes.append(function_node( + f"build_setpoint_{pump}", TAB_PROCESS, + LANE_X[1] + 220, y_section + 60, + f"build setpoint cmd ({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 link-in ---- + nodes.append(link_in( + f"lin_seq_{pump}", TAB_PROCESS, LANE_X[0], y_section + 110, + CH_PUMP_SEQUENCE[pump], + source_out_ids=[f"lout_seq_{pump}_dash"], + downstream=[pump], + )) + + # ---------------- MGC ---------------- + y_mgc = 80 + len(PUMPS) * (SECTION_GAP + 60) + nodes.append(comment("c_mgc", TAB_PROCESS, LANE_X[2], y_mgc, + "── MGC ── (orchestrates the 3 pumps via optimalcontrol)", + "")) + nodes.append({ + "id": MGC_ID, "type": "machineGroupControl", "z": TAB_PROCESS, + "name": "MGC — Pump Group", + "uuid": "mgc-pump-group", + "category": "controller", + "assetType": "machinegroupcontrol", + "model": "default", "unit": "m3/h", "supplier": "evolv", + "enableLog": True, "logLevel": "debug", + "tickIntervalMs": 2000, + "positionVsParent": "atEquipment", + "positionIcon": POSITION_ICON["atEquipment"], + "hasDistance": False, "distance": 0, "distanceUnit": "m", + "distanceDescription": "", + "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", + "x": LANE_X[3], "y": y_mgc + 80, + "wires": [ + ["format_mgc"], + ["lout_tlm_mgc"], + [PS_ID], + ], + }) + nodes.append(link_out( + "lout_tlm_mgc", TAB_PROCESS, LANE_X[3], y_mgc + 120, + CH_TLM, target_in_ids=["lin_tlm"], + )) + nodes.append(function_node( + "format_mgc", TAB_PROCESS, LANE_X[4], y_mgc + 80, + "format MGC port 0", + "const p = msg.payload || {};\n" + "const c = context.get('c') || {};\n" + "Object.assign(c, p);\n" + "context.set('c', c);\n" + "// Throttle: MGC fires on every distribution change.\n" + "const now = Date.now();\n" + "const last = context.get('_lastEmit') || 0;\n" + "if (now - last < 1000) return null;\n" + "context.set('_lastEmit', now);\n" + "function find(prefix) {\n" + " for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n" + " return null;\n" + "}\n" + "const totalFlow = find('flow.predicted.atequipment.') ?? " + "find('downstream_predicted_flow');\n" + "const totalPower = find('power.predicted.atequipment.') ?? " + "find('atEquipment_predicted_power');\n" + "const eff = find('efficiency.predicted.atequipment.');\n" + "msg.payload = {\n" + " totalFlow: totalFlow != null ? Number(totalFlow ).toFixed(1) + ' m³/h' : 'n/a',\n" + " totalPower: totalPower != null ? Number(totalPower).toFixed(2) + ' kW' : 'n/a',\n" + " efficiency: eff != null ? Number(eff).toFixed(3) : 'n/a',\n" + " totalFlowNum: totalFlow != null ? Number(totalFlow ) : null,\n" + " totalPowerNum: totalPower != null ? Number(totalPower) : null,\n" + " efficiencyNum: eff != null ? Number(eff) : null,\n" + "};\n" + "return msg;", + outputs=1, wires=[["lout_evt_mgc"]], + )) + nodes.append(link_out( + "lout_evt_mgc", TAB_PROCESS, LANE_X[5], y_mgc + 80, + CH_MGC_EVT, target_in_ids=["lin_evt_mgc_dash"], + )) + + # ---------------- PS ---------------- + y_ps = y_mgc + SECTION_GAP + 60 + nodes.append(comment("c_ps", TAB_PROCESS, LANE_X[2], y_ps, + "── Pumping Station ── (basin model, levelbased control)", "")) + + nodes.append(link_in( + "lin_qin_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 40, + CH_QIN, source_out_ids=["lout_qin_drivers"], + downstream=[PS_ID], + )) + nodes.append(link_in( + "lin_qd_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 80, + CH_QD, source_out_ids=["lout_qd_dash"], + downstream=["qd_to_ps_wrap"], + )) + nodes.append(function_node( + "qd_to_ps_wrap", TAB_PROCESS, LANE_X[1], y_ps + 80, + "wrap slider → PS Qd", + "msg.topic = 'Qd';\n" + "return msg;", + outputs=1, wires=[[PS_ID]], + )) + nodes.append(link_in( + "lin_ps_mode_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 120, + CH_PS_MODE, source_out_ids=["lout_ps_mode_dash"], + downstream=[PS_ID], + )) + + nodes.append({ + "id": PS_ID, "type": "pumpingStation", "z": TAB_PROCESS, + "name": "Pumping Station", + "uuid": "ps-basin-1", + "category": "station", "assetType": "pumpingstation", + "model": "default", "unit": "m3/s", "supplier": "evolv", + "enableLog": False, "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "atEquipment", + "positionIcon": POSITION_ICON["atEquipment"], + "hasDistance": False, "distance": 0, "distanceUnit": "m", + "distanceDescription": "", + "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", + "controlMode": "levelbased", + "basinVolume": BASIN_VOLUME, + "basinHeight": BASIN_HEIGHT, + # inflowLevel = top of inlet pipe (geometry) AND foot of the + # demand ramp (control). Setting it equal to maxLevel collapses + # the ramp to a step function — the runtime cycles 0/100 % every + # tick AND the editor's level-mode preview hides the diagonal + # line (mode-preview.js refuses to draw a degenerate ramp). + "inflowLevel": 2.5, + "outflowLevel": OUTFLOW_LEVEL, + "overflowLevel": OVERFLOW_LEVEL, + "inletPipeDiameter": 0.3, + "outletPipeDiameter": 0.3, + "minLevel": 0.5, + # startLevel — ramp foot AND rising-edge engage point. Demand + # scales 0..100 % over [startLevel, maxLevel]. + "startLevel": 2.5, + # stopLevel — falling-edge disengage point. While engaged AND + # level < startLevel (basin draining through the dead band), PS + # emits the keep-alive percControl below so MGC keeps a single + # pump running until level reaches stopLevel. + "stopLevel": 2.0, + # deadZoneKeepAlivePercent — % sent to MGC while engaged in the + # dead band [stopLevel, startLevel). Mapped by MGC's normalized + # scaling to flow.min — i.e., a single pump at minimum stable + # speed. 1 % is small enough to round to flow.min. + "deadZoneKeepAlivePercent": 1, + "maxLevel": 3.5, + "refHeight": "NAP", + "minHeightBasedOn": "outlet", + "basinBottomRef": 0, + "staticHead": 12, + "maxDischargeHead": 24, + "pipelineLength": 80, + "defaultFluid": "wastewater", + "temperatureReferenceDegC": 15, + "maxInflowRate": 200, + "enableDryRunProtection": True, + "enableOverfillProtection": True, + "dryRunThresholdPercent": 5, + "overfillThresholdPercent": 95, + "timeleftToFullOrEmptyThresholdSeconds": 0, + "x": LANE_X[3], "y": y_ps + 80, + "wires": [ + ["format_ps", "ps_to_physics"], + ["lout_tlm_ps"], + ], + }) + nodes.append(link_out( + "lout_tlm_ps", TAB_PROCESS, LANE_X[3], y_ps + 120, + CH_TLM, target_in_ids=["lin_tlm"], + )) + nodes.append(function_node( + "ps_to_physics", TAB_PROCESS, LANE_X[4], y_ps + 130, + "ps → fan basin level to 3 physics feeders", + "const out = { from: 'ps', payload: msg.payload };\n" + "return [out, out, out];", + outputs=3, + wires=[["physics_pump_a"], ["physics_pump_b"], ["physics_pump_c"]], + )) + nodes.append(function_node( + "format_ps", TAB_PROCESS, LANE_X[4], y_ps + 80, + "format PS port 0", + "const p = msg.payload || {};\n" + "const c = context.get('c') || {};\n" + "Object.assign(c, p);\n" + "context.set('c', c);\n" + "// Throttle: PS emits frequently in levelbased mode.\n" + "const now = Date.now();\n" + "const last = context.get('_lastEmit') || 0;\n" + "if (now - last < 1000) return null;\n" + "context.set('_lastEmit', now);\n" + "function find(prefix) {\n" + " for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n" + " return null;\n" + "}\n" + f"const MAX_VOL = {BASIN_VOLUME};\n" + "const lvl = find('level.predicted.');\n" + "const vol = find('volume.predicted.');\n" + "const qIn = find('flow.predicted.in.');\n" + "const qOut = find('flow.predicted.out.');\n" + "const netFlowRate = find('netFlowRate.predicted.');\n" + "const fillPct = vol != null\n" + " ? Math.min(100, Math.max(0, Math.round(Number(vol) / MAX_VOL * 100)))\n" + " : null;\n" + "const netM3h = netFlowRate != null ? Number(netFlowRate) * 3600 : null;\n" + "const seconds = (c.timeleft != null && Number.isFinite(Number(c.timeleft)))\n" + " ? Number(c.timeleft) : null;\n" + "const timeStr = seconds != null\n" + " ? (seconds > 60 ? Math.round(seconds/60) + ' min'\n" + " : Math.round(seconds) + ' s')\n" + " : 'n/a';\n" + "msg.payload = {\n" + " direction: c.direction || 'steady',\n" + " level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n" + " volume: vol != null ? Number(vol).toFixed(1) + ' m³' : 'n/a',\n" + " fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n" + " netFlow: netM3h != null ? netM3h.toFixed(0) + ' m³/h' : 'n/a',\n" + " timeLeft: timeStr,\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" + " levelNum: lvl != null ? Number(lvl) : null,\n" + " volumeNum: vol != null ? Number(vol) : null,\n" + " fillPctNum: fillPct,\n" + " netFlowNum: netM3h,\n" + " percControl: c.percControl != null ? Number(c.percControl) : null,\n" + " qInNum: qIn != null ? Number(qIn ) * 3600 : null,\n" + " qOutNum: qOut != null ? Number(qOut) * 3600 : null,\n" + " safetyState: c.safetyState || 'normal',\n" + "};\n" + "return msg;", + outputs=1, wires=[["lout_evt_ps"]], + )) + nodes.append(link_out( + "lout_evt_ps", TAB_PROCESS, LANE_X[5], y_ps + 80, + CH_PS_EVT, target_in_ids=["lin_evt_ps_dash"], + )) + + # ---------------- Mode broadcast ---------------- + y_mode = y_ps + SECTION_GAP + nodes.append(comment("c_mode_bcast", TAB_PROCESS, LANE_X[2], y_mode, + "── Mode broadcast ──", "")) + nodes.append(link_in( + "lin_mode", TAB_PROCESS, LANE_X[0], y_mode + 60, + "cmd:mode", + source_out_ids=["lout_mode_setup"], + downstream=["fanout_mode"], + )) + nodes.append(function_node( + "fanout_mode", TAB_PROCESS, LANE_X[1] + 220, y_mode + 60, + "fan setMode → 3 pumps", + "msg.topic = 'setMode';\n" + "return [msg, msg, msg];", + outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]], + )) + + # ---------------- Station-wide commands ---------------- + y_station = y_mode + 200 + nodes.append(comment("c_station_cmds", TAB_PROCESS, LANE_X[2], y_station, + "── Station-wide commands ──", "")) + for k, (chan, link_id, fn_name, label_suffix) in enumerate([ + (CH_STATION_START, "lin_station_start", + "fan_station_start", "startup"), + (CH_STATION_STOP, "lin_station_stop", + "fan_station_stop", "shutdown"), + (CH_STATION_ESTOP, "lin_station_estop", + "fan_station_estop", "emergency stop"), + ]): + y = y_station + 60 + k * 60 + slug = chan.replace(":", "_").replace("-", "_") + nodes.append(link_in( + link_id, TAB_PROCESS, LANE_X[0], y, chan, + source_out_ids=[f"lout_{slug}_dash"], + downstream=[fn_name], + )) + nodes.append(function_node( + fn_name, TAB_PROCESS, LANE_X[1] + 220, y, + f"fan {label_suffix} → 3 pumps", + "return [msg, msg, msg];", + outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]], + )) + + # ---------------- Setup feeder link-in ---------------- + y_setup_in = y_station + 280 + nodes.append(comment("c_setup_at_mgc", TAB_PROCESS, LANE_X[2], y_setup_in, + "── Setup feeders ──", "")) + nodes.append(link_in( + "lin_setup_at_mgc", TAB_PROCESS, LANE_X[0], y_setup_in + 60, + "setup:to-mgc", + source_out_ids=["lout_setup_to_mgc"], + downstream=[MGC_ID], + )) + nodes.append(link_in( + "lin_setup_calibrate_ps", TAB_PROCESS, LANE_X[0], y_setup_in + 120, + "setup:calibrate-ps", + source_out_ids=["lout_setup_calibrate"], + downstream=[PS_ID], + )) + + return nodes + + +def _physics_code(pump_letter): + """JS source for the per-pump physics feeder. + + Real parallel-pump installations share suction and discharge headers, + so every pump sees the SAME differential pressure. We therefore + publish each pump's predicted flow into Node-RED `flow` context, sum + across all pumps to get the manifold flow, and derive ONE header + pressure used as p_downstream for ALL pumps. Per-pump diagnostics + still get individually-noisy upstream values (suction header) since + sensor noise is local even on a shared header. + """ + return ( + "const c = context.get('c') || {};\n" + "function find(o, prefix) {\n" + " for (const k in o) { if (k.indexOf(prefix) === 0) return o[k]; }\n" + " return null;\n" + "}\n" + "function gauss(sigma) {\n" + " let s = 0;\n" + " for (let i = 0; i < 12; i++) s += Math.random();\n" + " return (s - 6) * sigma;\n" + "}\n" + "\n" + "if (msg.from === 'ps') {\n" + " const psSnap = c.ps || {};\n" + " Object.assign(psSnap, msg.payload || {});\n" + " c.ps = psSnap;\n" + " const lvl = find(psSnap, 'level.predicted.atequipment.')\n" + " ?? find(psSnap, 'level.measured.atequipment.');\n" + " if (lvl != null) c.basinLevel = Number(lvl);\n" + " context.set('c', c);\n" + " return null;\n" + "}\n" + "\n" + "const pumpSnap = c.pump || {};\n" + "Object.assign(pumpSnap, msg.payload || {});\n" + "c.pump = pumpSnap;\n" + "context.set('c', c);\n" + "// Throttle: 1 Hz sensor updates are plenty for the demo; the\n" + "// pump emits on every state change (5+/sec while cycling).\n" + "const _now = Date.now();\n" + "const _last = context.get('_lastEmit') || 0;\n" + "if (_now - _last < 1000) return null;\n" + "context.set('_lastEmit', _now);\n" + "\n" + "const state = pumpSnap.state || 'idle';\n" + "// 'isRunning' = the rotor is spinning (any non-idle, non-cooled state).\n" + "// MGC retargets flow on every tick, so the pump spends most of its\n" + "// time in 'accelerating' or 'decelerating', not 'operational'. Those\n" + "// transient states are still moving water — flow/power sensors must\n" + "// publish non-zero values during them or the measurement nodes go\n" + "// quiet (formatMsg skips emits on no-diff).\n" + "const isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n" + "// 'pumpFlow' (not 'flow') — `flow` is the Node-RED flow-context object.\n" + "const pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\n" + "const pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\n" + "const basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n" + "\n" + "// Publish this pump's contribution to the flow-context shared\n" + "// header so the other physics feeders can compute total flow.\n" + f"flow.set('pump_flow_{pump_letter}', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\n" + f"flow.set('pump_flow_{pump_letter}_state', state);\n" + "const flowA = Number(flow.get('pump_flow_a') || 0);\n" + "const flowB = Number(flow.get('pump_flow_b') || 0);\n" + "const flowC = Number(flow.get('pump_flow_c') || 0);\n" + "const totalFlow = flowA + flowB + flowC;\n" + "\n" + # Hydrostatic head → mbar. + # Pa = rho * g * h = 9810 * h (rho=1000, g=9.81) + # mbar = Pa / 100 = 98.1 * h + f"const HEAD_M = Math.max(0, basinLevel - {OUTFLOW_LEVEL});\n" + "// Suction (basin) header pressure — same physical value for all\n" + "// pumps; per-pump sensor noise added independently.\n" + "const p_upstream_clean = 98.1 * HEAD_M;\n" + "let p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n" + "\n" + "// Discharge (header) pressure — driven by TOTAL flow leaving the\n" + "// manifold, NOT this pump's individual flow. Static head 12 m\n" + "// + quadratic system curve scaled so totalFlow=300 m³/h gives\n" + "// ~full dynamic head.\n" + "const STATIC_MBAR = 12 * 98.1;\n" + "const DYN_MBAR_MAX = 12 * 98.1;\n" + "const TOTAL_FLOW_MAX = 300;\n" + "const ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\n" + "const p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n" + "// Publish the clean header value to flow context so the MGC's\n" + "// header-pressure measurement child can read it.\n" + "flow.set('header_p_downstream', p_downstream_header);\n" + "flow.set('header_p_upstream', p_upstream_clean);\n" + "// Per-pump downstream sensor: header value with local sensor noise.\n" + "let p_downstream = Math.max(0, p_downstream_header + gauss(8));\n" + "\n" + "const flowMeas = (isRunning && Number.isFinite(pumpFlow))\n" + " ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n" + " : 0;\n" + "\n" + "const powerMeas = (isRunning && Number.isFinite(pumpPower))\n" + " ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n" + " : 0;\n" + "\n" + "return [\n" + " { topic: 'measurement', payload: p_upstream },\n" + " { topic: 'measurement', payload: p_downstream },\n" + " { topic: 'measurement', payload: flowMeas },\n" + " { topic: 'measurement', payload: powerMeas },\n" + "];\n" + ) + + +# --------------------------------------------------------------------------- +# Tab 2 — DASHBOARD UI +# --------------------------------------------------------------------------- +def build_ui_tab(): + nodes = [] + nodes.append({ + "id": TAB_UI, "type": "tab", + "label": "📊 Dashboard UI", + "disabled": False, + "info": ( + "All FlowFuse ui-* widgets. Two pages:\n" + " /dashboard/realtime — gauges + per-pump status (no time history)\n" + " /dashboard/trends — line charts, 1 hour rolling window\n\n" + "All inputs leave via link-out; all process state arrives via link-in." + ), + }) + + nodes += dashboard_scaffold() + + PG_RT = "ui_page_realtime" + PG_TRENDS = "ui_page_trends" + + g_inflow = "ui_grp_inflow" + g_station = "ui_grp_station" + g_basin = "ui_grp_basin" + g_mgc = "ui_grp_mgc" + g_pump_a = "ui_grp_pump_a" + g_pump_b = "ui_grp_pump_b" + g_pump_c = "ui_grp_pump_c" + g_tr_basin = "ui_grp_tr_basin" + g_tr_demand = "ui_grp_tr_demand" + g_tr_dq = "ui_grp_tr_dq" + g_tr_states = "ui_grp_tr_states" + g_tr_flow = "ui_grp_tr_flow" + g_tr_power = "ui_grp_tr_power" + g_tr_press = "ui_grp_tr_press" + + nodes += [ + ui_group(g_inflow, "1. Inflow (operator input)", PG_RT, width=12, order=1), + ui_group(g_station, "2. Station Mode + Commands", PG_RT, width=12, order=2), + ui_group(g_basin, "3. Basin Realtime", PG_RT, width=6, order=3), + ui_group(g_mgc, "4. Pump Group (MGC)", PG_RT, width=6, order=4), + ui_group(g_pump_a, "5a. Pump A", PG_RT, width=4, order=5), + ui_group(g_pump_b, "5b. Pump B", PG_RT, width=4, order=6), + ui_group(g_pump_c, "5c. Pump C", PG_RT, width=4, order=7), + ui_group(g_tr_basin, "Basin level + fill (1h)", PG_TRENDS, width=12, order=1), + ui_group(g_tr_demand, "Process demand — PS percControl (1h)", + PG_TRENDS, width=12, order=2), + ui_group(g_tr_dq, "ΔQ = inflow − outflow (m³/h, +fill / −drain)", + PG_TRENDS, width=12, order=3), + ui_group(g_tr_states, "Pump state timeline (gantt)", + PG_TRENDS, width=12, order=4), + ui_group(g_tr_flow, "Inflow / Outflow / Per-pump flow (1h)", + PG_TRENDS, width=12, order=5), + ui_group(g_tr_power, "Per-pump power (1h)", PG_TRENDS, width=12, order=6), + ui_group(g_tr_press, "Per-pump pressures (1h)", PG_TRENDS, width=12, order=7), + ] + + nodes.append(comment("c_ui_title", TAB_UI, LANE_X[2], 20, + "📊 DASHBOARD UI — only ui-* widgets here", "")) + + # ---------- INFLOW SECTION ---------- + y = 80 + nodes.append(comment("c_ui_inflow", TAB_UI, LANE_X[2], y, + "── Operator inflow input ──", "")) + nodes.append(ui_slider( + "ui_inflow_slider", TAB_UI, LANE_X[0], y + 40, g_inflow, + "Inflow baseline", + "Inflow baseline (m³/h) — scenarios modulate around this value", + 0, 250, 5.0, "inflowBaseline", + wires=["lout_inflow_baseline"], + )) + nodes.append(link_out( + "lout_inflow_baseline", TAB_UI, LANE_X[1], y + 40, + CH_INFLOW_BASELINE, target_in_ids=["lin_inflow_baseline"], + )) + + SCENARIOS = [ + ("constant", "Constant", "#0c99d9", "horizontal_rule"), + ("sine", "Sine wave","#16a34a", "show_chart"), + ("diurnal", "Diurnal", "#f59e0b", "schedule"), + ("storm", "Storm", "#dc2626", "thunderstorm"), + ] + for k, (key, label, color, icon) in enumerate(SCENARIOS): + ybtn = y + 100 + k * 50 + btn_id = f"btn_scn_{key}" + wrap_id = f"wrap_scn_{key}" + nodes.append(ui_button( + btn_id, TAB_UI, LANE_X[0], ybtn, g_inflow, + f"Scenario {label}", label, key, "str", + topic="scenario", color=color, icon=icon, + wires=[wrap_id], + )) + nodes.append(function_node( + wrap_id, TAB_UI, LANE_X[1] + 100, ybtn, + f"build scenario {key}", + f"msg.payload = '{key}';\n" + "return msg;", + outputs=1, wires=[["lout_inflow_scenario"]], + )) + nodes.append(link_out( + "lout_inflow_scenario", TAB_UI, LANE_X[2], y + 100, + CH_INFLOW_SCENARIO, target_in_ids=["lin_inflow_scenario"], + )) + + nodes.append(link_in( + "lin_evt_inflow", TAB_UI, LANE_X[3], y + 40, + CH_INFLOW_EVT, source_out_ids=["lout_evt_inflow"], + downstream=["dispatch_inflow"], + )) + nodes.append(function_node( + "dispatch_inflow", TAB_UI, LANE_X[4], y + 40, + "dispatch inflow", + "const p = msg.payload || {};\n" + "const ts = Date.now();\n" + "return [\n" + " { payload: (p.scenario || 'constant').toUpperCase() },\n" + " { payload: p.q_h != null ? Number(p.q_h).toFixed(1) + ' m³/h' : 'n/a' },\n" + " p.q_h != null ? { topic: 'Inflow', payload: Number(p.q_h), timestamp: ts } : null,\n" + "];", + outputs=3, + wires=[["ui_inflow_scn_text"], ["ui_inflow_value_text"], ["chart_trend_flow"]], + )) + nodes.append(ui_text( + "ui_inflow_scn_text", TAB_UI, LANE_X[5], y + 40, g_inflow, + "Active scenario", "Active scenario", "{{msg.payload}}", + )) + nodes.append(ui_text( + "ui_inflow_value_text", TAB_UI, LANE_X[5], y + 80, g_inflow, + "Live inflow", "Live inflow", "{{msg.payload}}", + )) + + # ---------- MODE + STATION COMMANDS ---------- + y = 380 + nodes.append(comment("c_ui_station", TAB_UI, LANE_X[2], y, + "── Mode + Station-wide buttons ──", "")) + nodes.append(ui_switch( + "ui_mode_toggle", TAB_UI, LANE_X[0], y + 40, g_station, + "Station mode", + "Station mode (Auto = level-based · Manual = slider Qd)", + on_value="levelbased", off_value="manual", topic="changemode", + wires=["lout_ps_mode_dash"], + )) + nodes.append(link_out( + "lout_ps_mode_dash", TAB_UI, LANE_X[1], y + 40, + CH_PS_MODE, target_in_ids=["lin_ps_mode_at_ps"], + )) + + nodes.append(ui_slider( + "ui_qd_slider", TAB_UI, LANE_X[0], y + 90, g_station, + "Manual Qd", + "Manual Qd (m³/h, manual mode only)", 0, 600, 5.0, + "manualDemand", wires=["lout_qd_dash"], + )) + nodes.append(link_out( + "lout_qd_dash", TAB_UI, LANE_X[1], y + 90, + CH_QD, target_in_ids=["lin_qd_at_ps"], + )) + + for k, (text, color, icon, lout_id, channel, + wrap_code) in enumerate([ + ("Start all pumps", "#16a34a", "play_arrow", + "lout_cmd_station_startup_dash", CH_STATION_START, + "msg.topic = 'execSequence';\n" + "msg.payload = { source:'GUI', action:'execSequence', " + "parameter:'startup' };\n" + "return msg;"), + ("Stop all pumps", "#ea580c", "stop", + "lout_cmd_station_shutdown_dash", CH_STATION_STOP, + "msg.topic = 'execSequence';\n" + "msg.payload = { source:'GUI', action:'execSequence', " + "parameter:'shutdown' };\n" + "return msg;"), + ("EMERGENCY STOP", "#dc2626", "stop_circle", + "lout_cmd_station_estop_dash", CH_STATION_ESTOP, + "msg.topic = 'emergencystop';\n" + "msg.payload = { source:'GUI', action:'emergencystop' };\n" + "return msg;"), + ]): + yk = y + 150 + k * 50 + btn_id = f"btn_station_{k}" + wrap_id = f"wrap_station_{k}" + nodes.append(ui_button( + btn_id, TAB_UI, LANE_X[0], yk, g_station, + text, text, "fired", "str", + topic=f"station_{k}", color=color, icon=icon, + wires=[wrap_id], + )) + nodes.append(function_node( + wrap_id, TAB_UI, LANE_X[1] + 100, yk, + f"build cmd ({text})", wrap_code, + outputs=1, wires=[[lout_id]], + )) + nodes.append(link_out( + lout_id, TAB_UI, LANE_X[2], yk, + channel, + target_in_ids=[{ + CH_STATION_START: "lin_station_start", + CH_STATION_STOP: "lin_station_stop", + CH_STATION_ESTOP: "lin_station_estop", + }[channel]], + )) + + # ---------- BASIN REALTIME ---------- + y = 700 + nodes.append(comment("c_ui_basin", TAB_UI, LANE_X[2], y, + "── Basin realtime (gauges + text) ──", "")) + nodes.append(link_in( + "lin_evt_ps_dash", TAB_UI, LANE_X[0], y + 40, + CH_PS_EVT, source_out_ids=["lout_evt_ps"], + downstream=["dispatch_ps"], + )) + nodes.append(function_node( + "dispatch_ps", TAB_UI, LANE_X[1], y + 40, + "dispatch PS", + "const p = msg.payload || {};\n" + "const ts = Date.now();\n" + "// ΔQ = inflow − outflow in m³/h (positive = filling).\n" + "const dQ = (p.qInNum != null && p.qOutNum != null)\n" + " ? p.qInNum - p.qOutNum : null;\n" + "// Demand text formatting.\n" + "const demandStr = p.percControl != null\n" + " ? Number(p.percControl).toFixed(0) + '%' : 'n/a';\n" + "return [\n" + " { payload: String(p.direction || 'steady') },\n" + " { payload: String(p.level || 'n/a') },\n" + " { payload: String(p.volume || 'n/a') },\n" + " { payload: String(p.fillPct || 'n/a') },\n" + " { payload: String(p.netFlow || 'n/a') },\n" + " { payload: String(p.timeLeft || 'n/a') },\n" + " { payload: String(p.qIn || 'n/a') },\n" + " { payload: String(p.qOut || 'n/a') },\n" + " { payload: String(p.safetyState || 'normal') },\n" + " { payload: demandStr },\n" + " p.levelNum != null ? { payload: p.levelNum } : null,\n" + " p.fillPctNum != null ? { payload: p.fillPctNum } : null,\n" + " p.percControl != null ? { payload: p.percControl } : null,\n" + " p.levelNum != null ? { topic: 'Basin level', payload: p.levelNum, timestamp: ts } : null,\n" + " p.fillPctNum != null ? { topic: 'Fill %', payload: p.fillPctNum, timestamp: ts } : null,\n" + " p.qOutNum != null ? { topic: 'Outflow', payload: p.qOutNum, timestamp: ts } : null,\n" + " p.percControl != null ? { topic: 'PS demand', payload: p.percControl, timestamp: ts } : null,\n" + " dQ != null ? { topic: 'ΔQ', payload: dQ, timestamp: ts } : null,\n" + "];", + outputs=18, + wires=[ + ["ui_ps_direction"], + ["ui_ps_level"], + ["ui_ps_volume"], + ["ui_ps_fill"], + ["ui_ps_netflow"], + ["ui_ps_timeleft"], + ["ui_ps_qin"], + ["ui_ps_qout"], + ["ui_ps_safety"], + ["ui_ps_demand"], + ["gauge_basin_level"], + ["gauge_basin_fill"], + ["gauge_ps_demand"], + ["chart_trend_basin"], + ["chart_trend_basin"], + ["chart_trend_flow"], + ["chart_trend_demand"], + ["chart_trend_dq"], + ], + )) + nodes.append(ui_text("ui_ps_direction", TAB_UI, LANE_X[2], y + 40, g_basin, + "Direction", "Direction", "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_level", TAB_UI, LANE_X[2], y + 70, g_basin, + "Basin level", "Basin level","{{msg.payload}}")) + nodes.append(ui_text("ui_ps_volume", TAB_UI, LANE_X[2], y + 100, g_basin, + "Basin volume","Basin volume","{{msg.payload}}")) + nodes.append(ui_text("ui_ps_fill", TAB_UI, LANE_X[2], y + 130, g_basin, + "Fill %", "Fill %", "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_netflow", TAB_UI, LANE_X[2], y + 160, g_basin, + "Net flow", "Net flow", "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_timeleft", TAB_UI, LANE_X[2], y + 190, g_basin, + "Time left", "Time to full/empty", + "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_qin", TAB_UI, LANE_X[2], y + 220, g_basin, + "Inflow", "Inflow", "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_qout", TAB_UI, LANE_X[2], y + 250, g_basin, + "Outflow", "Outflow", "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_safety", TAB_UI, LANE_X[2], y + 280, g_basin, + "Safety", "Safety state","{{msg.payload}}")) + nodes.append(ui_text("ui_ps_demand", TAB_UI, LANE_X[2], y + 310, g_basin, + "PS demand", "Process demand","{{msg.payload}}")) + + LEVEL_SEGMENTS = [ + {"color": "#f44336", "from": 0}, + {"color": "#ff9800", "from": 1.0}, + {"color": "#2196f3", "from": 2.0}, + {"color": "#ff9800", "from": 3.5}, + {"color": "#f44336", "from": 3.8}, + ] + FILL_SEGMENTS = [ + {"color": "#f44336", "from": 0}, + {"color": "#ff9800", "from": 10}, + {"color": "#4caf50", "from": 30}, + {"color": "#ff9800", "from": 80}, + {"color": "#f44336", "from": 95}, + ] + nodes.append(ui_gauge( + "gauge_basin_level", TAB_UI, LANE_X[3], y + 40, g_basin, + "Basin level gauge", "Level", "m", 0, BASIN_HEIGHT, + LEVEL_SEGMENTS, gtype="gauge-tank", suffix=" m", + width=3, height=4, order=10, + )) + nodes.append(ui_gauge( + "gauge_basin_fill", TAB_UI, LANE_X[3], y + 100, g_basin, + "Basin fill gauge", "Fill", "%", 0, 100, + FILL_SEGMENTS, gtype="gauge-34", suffix="%", + icon="water_drop", width=3, height=4, order=11, + )) + # PS process demand gauge — shows the % command PS sends to MGC. + DEMAND_SEGMENTS = [ + {"color": "#cccccc", "from": 0}, + {"color": "#0c99d9", "from": 5}, + {"color": "#16a34a", "from": 30}, + {"color": "#f59e0b", "from": 70}, + {"color": "#dc2626", "from": 95}, + ] + nodes.append(ui_gauge( + "gauge_ps_demand", TAB_UI, LANE_X[3], y + 160, g_basin, + "PS demand gauge", "PS demand", "%", 0, 100, + DEMAND_SEGMENTS, gtype="gauge-34", suffix="%", + icon="speed", width=3, height=4, order=12, + )) + + # ---------- MGC REALTIME ---------- + y = 1080 + nodes.append(comment("c_ui_mgc", TAB_UI, LANE_X[2], y, + "── MGC realtime ──", "")) + nodes.append(link_in( + "lin_evt_mgc_dash", TAB_UI, LANE_X[0], y + 40, + CH_MGC_EVT, source_out_ids=["lout_evt_mgc"], + downstream=["dispatch_mgc"], + )) + nodes.append(function_node( + "dispatch_mgc", TAB_UI, LANE_X[1], y + 40, + "dispatch MGC", + "const p = msg.payload || {};\n" + "return [\n" + " { payload: String(p.totalFlow || 'n/a') },\n" + " { payload: String(p.totalPower || 'n/a') },\n" + " { payload: String(p.efficiency || 'n/a') },\n" + " p.totalFlowNum != null ? { payload: p.totalFlowNum } : null,\n" + " p.totalPowerNum != null ? { payload: p.totalPowerNum } : null,\n" + "];", + outputs=5, + wires=[ + ["ui_mgc_total_flow"], + ["ui_mgc_total_power"], + ["ui_mgc_eff"], + ["gauge_mgc_flow"], + ["gauge_mgc_power"], + ], + )) + nodes.append(ui_text("ui_mgc_total_flow", TAB_UI, LANE_X[2], y + 40, g_mgc, + "MGC total flow", "Total flow", "{{msg.payload}}")) + nodes.append(ui_text("ui_mgc_total_power", TAB_UI, LANE_X[2], y + 70, g_mgc, + "MGC total power", "Total power", "{{msg.payload}}")) + nodes.append(ui_text("ui_mgc_eff", TAB_UI, LANE_X[2], y + 100, g_mgc, + "MGC efficiency", "Group efficiency", "{{msg.payload}}")) + nodes.append(ui_gauge( + "gauge_mgc_flow", TAB_UI, LANE_X[3], y + 40, g_mgc, + "MGC total flow gauge", "Total flow", "m³/h", 0, 600, + [ + {"color": "#cccccc", "from": 0}, + {"color": "#0c99d9", "from": 50}, + {"color": "#16a34a", "from": 200}, + {"color": "#f59e0b", "from": 500}, + ], + gtype="gauge-34", suffix=" m³/h", + width=3, height=4, order=10, + )) + nodes.append(ui_gauge( + "gauge_mgc_power", TAB_UI, LANE_X[3], y + 100, g_mgc, + "MGC total power gauge", "Total power", "kW", 0, 30, + [ + {"color": "#cccccc", "from": 0}, + {"color": "#0c99d9", "from": 1}, + {"color": "#16a34a", "from": 5}, + {"color": "#f59e0b", "from": 20}, + ], + gtype="gauge-34", suffix=" kW", + width=3, height=4, order=11, + )) + + # ---------- PER-PUMP REALTIME PANELS ---------- + y_pumps_start = 1340 + PUMP_FIELDS = [ + ("State", "state", "{{msg.payload}}"), + ("Mode", "mode", "{{msg.payload}}"), + ("Controller %", "ctrl", "{{msg.payload}}"), + ("Flow", "flow", "{{msg.payload}}"), + ("Power", "power", "{{msg.payload}}"), + ("p Upstream", "pUp", "{{msg.payload}}"), + ("p Downstream", "pDn", "{{msg.payload}}"), + ] + for i, pump in enumerate(PUMPS): + label = PUMP_LABELS[pump] + g = {"pump_a": g_pump_a, "pump_b": g_pump_b, "pump_c": g_pump_c}[pump] + y_p = y_pumps_start + i * 480 + state_offset = i * 3 # A=0, B=3, C=6 + + nodes.append(comment(f"c_ui_{pump}", TAB_UI, LANE_X[2], y_p, + f"── {label} ──", "")) + nodes.append(link_in( + f"lin_evt_{pump}_dash", TAB_UI, LANE_X[0], y_p + 40, + CH_PUMP_EVT[pump], + source_out_ids=[f"lout_evt_{pump}"], + downstream=[f"dispatch_{pump}"], + )) + dispatch_code = ( + "const p = msg.payload || {};\n" + "const ts = Date.now();\n" + f"const OFF = {state_offset};\n" + "function stateNum(s) {\n" + " switch (s) {\n" + " case 'operational': return OFF + 2;\n" + " case 'starting':\n" + " case 'warmingup': return OFF + 1;\n" + " case 'stopping': return OFF + 1.5;\n" + " case 'coolingdown': return OFF + 0.5;\n" + " default: return OFF;\n" + " }\n" + "}\n" + "const sNum = p.state ? stateNum(p.state) : null;\n" + "return [\n" + " {payload: String(p.state || 'idle')},\n" + " {payload: String(p.mode || 'auto')},\n" + " {payload: String(p.ctrl || 'n/a')},\n" + " {payload: String(p.flow || 'n/a')},\n" + " {payload: String(p.power || 'n/a')},\n" + " {payload: String(p.pUp || 'n/a')},\n" + " {payload: String(p.pDn || 'n/a')},\n" + " p.flowNum != null ? {topic: '" + label + "', payload: p.flowNum, timestamp: ts} : null,\n" + " p.powerNum != null ? {topic: '" + label + "', payload: p.powerNum, timestamp: ts} : null,\n" + " p.pUpNum != null ? {topic: '" + label + " up', payload: p.pUpNum, timestamp: ts} : null,\n" + " p.pDnNum != null ? {topic: '" + label + " dn', payload: p.pDnNum, timestamp: ts} : null,\n" + " sNum != null ? {topic: '" + label + " state', payload: sNum, timestamp: ts} : null,\n" + "];" + ) + nodes.append(function_node( + f"dispatch_{pump}", TAB_UI, LANE_X[1], y_p + 40, + f"dispatch {label}", dispatch_code, + outputs=12, + wires=[ + [f"ui_{pump}_{f}"] for _, f, _ in PUMP_FIELDS + ] + [ + ["chart_trend_flow"], + ["chart_trend_power"], + ["chart_trend_pressure"], + ["chart_trend_pressure"], + ["chart_trend_states"], + ], + )) + for k, (label_txt, field, fmt) in enumerate(PUMP_FIELDS): + nodes.append(ui_text( + f"ui_{pump}_{field}", TAB_UI, LANE_X[2], y_p + 40 + k * 30, g, + f"{label} {label_txt}", label_txt, fmt, + )) + + nodes.append(ui_slider( + f"ui_{pump}_setpoint", TAB_UI, LANE_X[0], y_p + 280, g, + f"{label} setpoint", "Setpoint % (manual mode)", + 0, 100, 5.0, f"setpoint_{pump}", + wires=[f"lout_setpoint_{pump}_dash"], + )) + nodes.append(link_out( + f"lout_setpoint_{pump}_dash", TAB_UI, LANE_X[1], y_p + 280, + CH_PUMP_SETPOINT[pump], + target_in_ids=[f"lin_setpoint_{pump}"], + )) + + nodes.append(ui_button( + f"btn_{pump}_start", TAB_UI, LANE_X[0], y_p + 330, g, + f"{label} startup", "Startup", "fired", "str", + topic=f"start_{pump}", color="#16a34a", icon="play_arrow", + wires=[f"wrap_{pump}_start"], + )) + nodes.append(function_node( + f"wrap_{pump}_start", TAB_UI, LANE_X[1] + 100, y_p + 330, + f"build start ({label})", + "msg.topic = 'execSequence';\n" + "msg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\n" + "return msg;", + outputs=1, wires=[[f"lout_seq_{pump}_dash"]], + )) + nodes.append(ui_button( + f"btn_{pump}_stop", TAB_UI, LANE_X[0], y_p + 380, g, + f"{label} shutdown", "Shutdown", "fired", "str", + topic=f"stop_{pump}", color="#ea580c", icon="stop", + wires=[f"wrap_{pump}_stop"], + )) + nodes.append(function_node( + f"wrap_{pump}_stop", TAB_UI, LANE_X[1] + 100, y_p + 380, + f"build stop ({label})", + "msg.topic = 'execSequence';\n" + "msg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\n" + "return msg;", + outputs=1, wires=[[f"lout_seq_{pump}_dash"]], + )) + nodes.append(link_out( + f"lout_seq_{pump}_dash", TAB_UI, LANE_X[2], y_p + 355, + CH_PUMP_SEQUENCE[pump], + target_in_ids=[f"lin_seq_{pump}"], + )) + + # ---------- TREND CHARTS ---------- + y_trends = y_pumps_start + len(PUMPS) * 480 + 60 + nodes.append(comment("c_ui_trends", TAB_UI, LANE_X[2], y_trends, + "── Trend charts (1h rolling) ──", "")) + + nodes.append(ui_chart( + "chart_trend_basin", TAB_UI, LANE_X[3], y_trends + 40, + g_tr_basin, + "Basin level + fill %", "Basin level + fill", + width=12, height=8, y_axis_label="m / %", + remove_older="60", remove_older_unit="60", + remove_older_points="3600", + order=1, + )) + nodes.append(ui_chart( + "chart_trend_demand", TAB_UI, LANE_X[3], y_trends + 80, + g_tr_demand, + "PS process demand %", "PS demand", + width=12, height=6, y_axis_label="%", + remove_older="60", remove_older_unit="60", + remove_older_points="3600", + ymin=0, ymax=110, order=1, + )) + nodes.append(ui_chart( + "chart_trend_dq", TAB_UI, LANE_X[3], y_trends + 100, + g_tr_dq, + "ΔQ — inflow − outflow", "ΔQ", + width=12, height=6, y_axis_label="m³/h", + remove_older="60", remove_older_unit="60", + remove_older_points="3600", + order=1, + )) + # State timeline: each pump has a Y-axis "track" (A=0..2, B=3..5, C=6..8) + # with discrete values: 0/3/6 idle, 0.5/3.5/6.5 coolingdown, + # 1/4/7 starting/warmingup, 1.5/4.5/7.5 stopping, 2/5/8 operational. + # Step interpolation so transitions are sharp. + nodes.append(ui_chart( + "chart_trend_states", TAB_UI, LANE_X[3], y_trends + 120, + g_tr_states, + "Pump state timeline", "Pump states (A=0-2, B=3-5, C=6-8)", + width=12, height=6, y_axis_label="A B C tracks", + remove_older="60", remove_older_unit="60", + remove_older_points="3600", + ymin=-0.5, ymax=8.5, order=1, + interpolation="step", + )) + nodes.append(ui_chart( + "chart_trend_flow", TAB_UI, LANE_X[3], y_trends + 120, + g_tr_flow, + "Inflow / Outflow / Per-pump flow", "Flows", + width=12, height=8, y_axis_label="m³/h", + remove_older="60", remove_older_unit="60", + remove_older_points="3600", + order=1, + )) + nodes.append(ui_chart( + "chart_trend_power", TAB_UI, LANE_X[3], y_trends + 200, + g_tr_power, + "Per-pump power", "Power", + width=12, height=8, y_axis_label="kW", + remove_older="60", remove_older_unit="60", + remove_older_points="3600", + order=1, + )) + nodes.append(ui_chart( + "chart_trend_pressure", TAB_UI, LANE_X[3], y_trends + 280, + g_tr_press, + "Per-pump up/dn pressure", "Pressure", + width=12, height=8, y_axis_label="mbar", + remove_older="60", remove_older_unit="60", + remove_older_points="3600", + order=1, + )) + + return nodes + + +# --------------------------------------------------------------------------- +# Tab 3 — DEMO DRIVERS (inflow generator) +# --------------------------------------------------------------------------- +def build_drivers_tab(): + nodes = [] + nodes.append({ + "id": TAB_DRIVERS, "type": "tab", + "label": "🎛️ Demo Drivers", + "disabled": False, + "info": ( + "Inflow generator. The operator picks a SCENARIO (Constant / Sine /" + " Diurnal / Storm) on the dashboard and sets a BASELINE m³/h value." + " Every second this generator emits q_in to the PS based on the " + "active scenario + baseline.\n\n" + "Outflow is implicit: the pumps drain the basin via MGC." + ), + }) + + nodes.append(comment("c_drv_title", TAB_DRIVERS, LANE_X[2], 20, + "🎛️ DEMO DRIVERS — operator-driven inflow generator", "")) + + nodes.append(link_in( + "lin_inflow_scenario", TAB_DRIVERS, LANE_X[0], 100, + CH_INFLOW_SCENARIO, + source_out_ids=["lout_inflow_scenario", "lout_setup_inflow_scn"], + downstream=["inflow_state"], + )) + nodes.append(link_in( + "lin_inflow_baseline", TAB_DRIVERS, LANE_X[0], 140, + CH_INFLOW_BASELINE, + source_out_ids=["lout_inflow_baseline", "lout_setup_inflow_baseline"], + downstream=["inflow_state"], + )) + nodes.append(inject( + "inflow_tick", TAB_DRIVERS, LANE_X[0], 200, + "tick (1 Hz)", topic="tick", payload="", payload_type="date", + repeat="1", wires=["inflow_state"], + )) + + nodes.append(function_node( + "inflow_state", TAB_DRIVERS, LANE_X[2], 160, + "inflow scenario engine", + "let scenario = context.get('scenario') || 'constant';\n" + "let baseline = context.get('baseline');\n" + "if (baseline == null) baseline = 60;\n" + "\n" + "if (msg.topic === 'inflowBaseline') {\n" + " const v = Number(msg.payload);\n" + " if (Number.isFinite(v) && v >= 0) {\n" + " baseline = v;\n" + " context.set('baseline', baseline);\n" + " }\n" + " return null;\n" + "}\n" + "if (msg.topic === 'scenario') {\n" + " const s = String(msg.payload || '').toLowerCase();\n" + " if (['constant','sine','diurnal','storm'].includes(s)) {\n" + " scenario = s;\n" + " context.set('scenario', scenario);\n" + " }\n" + " return null;\n" + "}\n" + "const t = Date.now() / 1000;\n" + "let q_h;\n" + "switch (scenario) {\n" + " case 'sine': {\n" + " q_h = baseline * (1 + 0.5 * Math.sin(2 * Math.PI * t / 240));\n" + " break;\n" + " }\n" + " case 'diurnal': {\n" + " q_h = baseline * (1 + 0.6 * Math.sin(2 * Math.PI * t / 480 - Math.PI/2));\n" + " break;\n" + " }\n" + " case 'storm': {\n" + " const phase = (t % 240) / 240;\n" + " let factor;\n" + " if (phase < 0.15) factor = 1 + (4 / 0.15) * phase;\n" + " else factor = Math.max(1, 5 - (4 / 0.85) * (phase - 0.15));\n" + " q_h = baseline * factor;\n" + " break;\n" + " }\n" + " case 'constant':\n" + " default:\n" + " q_h = baseline;\n" + "}\n" + "q_h = Math.max(0, q_h);\n" + "const q_s = q_h / 3600;\n" + "return [\n" + " { topic: 'q_in', payload: q_s, unit: 'm3/s', timestamp: Date.now() },\n" + " { payload: { scenario, baseline, q_h, q_s, ts: Date.now() } },\n" + "];", + outputs=2, + wires=[["lout_qin_drivers"], ["lout_evt_inflow"]], + )) + nodes.append(link_out( + "lout_qin_drivers", TAB_DRIVERS, LANE_X[3], 140, + CH_QIN, target_in_ids=["lin_qin_at_ps"], + )) + nodes.append(link_out( + "lout_evt_inflow", TAB_DRIVERS, LANE_X[3], 180, + CH_INFLOW_EVT, target_in_ids=["lin_evt_inflow"], + )) + + return nodes + + +# --------------------------------------------------------------------------- +# Tab 4 — SETUP & INIT +# --------------------------------------------------------------------------- +def build_setup_tab(): + nodes = [] + nodes.append({ + "id": TAB_SETUP, "type": "tab", + "label": "⚙️ Setup & Init", + "disabled": False, + "info": ( + "One-shot deploy-time injects:\n" + " • MGC scaling = normalized + mode = optimalcontrol\n" + " • all pumps mode = auto\n" + " • initial inflow baseline + scenario\n\n" + "Disable this tab in production." + ), + }) + + nodes.append(comment("c_setup_title", TAB_SETUP, LANE_X[2], 20, + "⚙️ SETUP & INIT — one-shot deploy-time injects", "")) + + nodes.append(inject( + "setup_mgc_scaling", TAB_SETUP, LANE_X[0], 100, + "MGC scaling = normalized", + topic="setScaling", payload="normalized", payload_type="str", + once=True, once_delay="1.5", + wires=["lout_setup_to_mgc"], + )) + nodes.append(inject( + "setup_mgc_mode", TAB_SETUP, LANE_X[0], 160, + "MGC mode = optimalcontrol", + topic="setMode", payload="optimalcontrol", payload_type="str", + once=True, once_delay="1.7", + wires=["lout_setup_to_mgc"], + )) + nodes.append(link_out( + "lout_setup_to_mgc", TAB_SETUP, LANE_X[1], 130, + "setup:to-mgc", target_in_ids=["lin_setup_at_mgc"], + )) + + nodes.append(inject( + "setup_pumps_mode", TAB_SETUP, LANE_X[0], 240, + "pumps mode = auto", + topic="setMode", payload="auto", payload_type="str", + once=True, once_delay="2.0", + wires=["lout_mode_setup"], + )) + nodes.append(link_out( + "lout_mode_setup", TAB_SETUP, LANE_X[1], 240, + "cmd:mode", target_in_ids=["lin_mode"], + )) + + nodes.append(inject( + "setup_inflow_baseline", TAB_SETUP, LANE_X[0], 320, + "inflow baseline = 25 m³/h (nominal)", + topic="inflowBaseline", payload="25", payload_type="num", + once=True, once_delay="2.5", + wires=["lout_setup_inflow_baseline"], + )) + nodes.append(link_out( + "lout_setup_inflow_baseline", TAB_SETUP, LANE_X[1], 320, + CH_INFLOW_BASELINE, target_in_ids=["lin_inflow_baseline"], + )) + nodes.append(inject( + "setup_inflow_scenario", TAB_SETUP, LANE_X[0], 380, + "inflow scenario = sine", + topic="scenario", payload="sine", payload_type="str", + once=True, once_delay="2.7", + wires=["lout_setup_inflow_scn"], + )) + nodes.append(link_out( + "lout_setup_inflow_scn", TAB_SETUP, LANE_X[1], 380, + CH_INFLOW_SCENARIO, target_in_ids=["lin_inflow_scenario"], + )) + + # Manual calibrate basin button — does NOT auto-fire on deploy. + # Auto-firing on every flow reload would clobber the basin level + # mid-cycle and reset the simulation, so we expose this as an inject + # the user clicks when they actually want to reset (e.g. starting a + # fresh demo run). To use: open the editor's Setup tab and click the + # button on this inject node. + nodes.append(inject( + "setup_calibrate_level", TAB_SETUP, LANE_X[0], 460, + "[manual] calibrate basin = 1.0 m (click to reset)", + topic="calibratePredictedLevel", payload="1.0", payload_type="num", + once=False, # <- never fire on deploy + wires=["lout_setup_calibrate"], + )) + nodes.append(link_out( + "lout_setup_calibrate", TAB_SETUP, LANE_X[1], 460, + "setup:calibrate-ps", target_in_ids=["lin_setup_calibrate_ps"], + )) + + return nodes + + +# --------------------------------------------------------------------------- +# Tab 5 — TELEMETRY (port 1 → InfluxDB line protocol → http POST) +# --------------------------------------------------------------------------- +def build_telemetry_tab(): + nodes = [] + nodes.append({ + "id": TAB_TLM, "type": "tab", + "label": "📈 Telemetry", + "disabled": False, + "info": ( + "InfluxDB writer: every EVOLV node's port-1 telemetry is fanned in " + "via the evt:tlm link channel, converted to line protocol, and " + "POSTed to InfluxDB v2 (org=evolv, bucket=telemetry).\n\n" + "Pattern adapted from docker/demo-flow.json." + ), + }) + + nodes.append(comment("c_tlm_title", TAB_TLM, LANE_X[2], 20, + "📈 TELEMETRY — InfluxDB writer", "")) + + nodes.append(link_in( + "lin_tlm", TAB_TLM, LANE_X[0], 100, + CH_TLM, + source_out_ids=_all_tlm_lout_ids(), + downstream=["fn_tlm_to_lp"], + )) + + # ── Pipeline ── + # link in → fn_tlm_to_lp (one line / msg) + # → join (string mode, joiner=\n, count=100 OR timeout 1s) + # → fn_tlm_post (set headers/url/method) + # → http request → fn_count + nodes.append(function_node( + "fn_tlm_to_lp", TAB_TLM, LANE_X[2], 100, + "→ InfluxDB line protocol", + "const p = msg.payload;\n" + "if (!p || !p.measurement || !p.fields) return null;\n" + "const esc = (s) => String(s)\n" + " .replace(/,/g, '\\\\,').replace(/ /g, '\\\\ ').replace(/=/g, '\\\\=');\n" + "const tags = Object.entries(p.tags || {})\n" + " .filter(([k, v]) => v !== undefined && v !== null && v !== '')\n" + " .map(([k, v]) => `${esc(k)}=${esc(v)}`).join(',');\n" + "const fieldPairs = Object.entries(p.fields)\n" + " .filter(([k, v]) => v !== undefined && v !== null)\n" + " .map(([k, v]) => {\n" + " if (typeof v === 'number' && Number.isFinite(v)) return `${esc(k)}=${v}`;\n" + " if (typeof v === 'boolean') return `${esc(k)}=${v}`;\n" + " return `${esc(k)}=\"${String(v).replace(/\"/g, '\\\\\"')}\"`;\n" + " });\n" + "if (fieldPairs.length === 0) return null;\n" + "const ts = Date.now() * 1000000;\n" + "msg.payload = `${esc(p.measurement)}${tags ? ',' + tags : ''} `\n" + " + `${fieldPairs.join(',')} ${ts}`;\n" + "// Hint the join node to fire on size or timeout.\n" + "msg.topic = 'tlm';\n" + "return msg;", + outputs=1, wires=[["join_tlm"]], + )) + + # Idiomatic Node-RED batching: join collects messages into a single + # newline-joined string, flushed every `count` messages OR `timeout` + # seconds, whichever fires first. + nodes.append({ + "id": "join_tlm", "type": "join", "z": TAB_TLM, + "name": "batch (200 lines / 2 s)", + "mode": "custom", + "build": "string", + "property": "payload", "propertyType": "msg", + "key": "topic", + "joiner": "\\n", "joinerType": "str", + "accumulate": False, + "timeout": "2", + "count": "200", + "reduceRight": False, + "reduceExp": "", "reduceInit": "", + "reduceInitType": "", "reduceFixup": "", + "x": LANE_X[3], "y": 100, + "wires": [["fn_tlm_post"]], + }) + + nodes.append(function_node( + "fn_tlm_post", TAB_TLM, LANE_X[3] + 200, 100, + "wrap as InfluxDB POST", + "// Count lines for status reporting.\n" + "const body = String(msg.payload || '');\n" + "const lineCount = body ? body.split('\\n').length : 0;\n" + "if (lineCount === 0) return null;\n" + "msg.lineCount = lineCount;\n" + "msg.headers = {\n" + " 'Authorization': 'Token evolv-dev-token',\n" + " 'Content-Type': 'text/plain'\n" + "};\n" + "msg.url = 'http://influxdb:8086/api/v2/write?org=evolv&bucket=telemetry&precision=ns';\n" + "msg.method = 'POST';\n" + "return msg;", + outputs=1, wires=[["http_tlm"]], + )) + + nodes.append({ + "id": "http_tlm", "type": "http request", "z": TAB_TLM, + "name": "Write InfluxDB", + "method": "use", "ret": "txt", "paytoqs": "ignore", + "url": "", "tls": "", "persist": False, "proxy": "", + "authType": "", "senderr": False, + "x": LANE_X[4] + 80, "y": 100, + "wires": [["fn_tlm_count"]], + }) + + nodes.append(function_node( + "fn_tlm_count", TAB_TLM, LANE_X[5], 100, + "Count writes", + "const lines = Number(msg.lineCount) || 0;\n" + "const writes = (global.get('influx_writes') || 0) + 1;\n" + "const totalLines = (global.get('influx_lines') || 0) + lines;\n" + "global.set('influx_writes', writes);\n" + "global.set('influx_lines', totalLines);\n" + "const errors = global.get('influx_errors') || 0;\n" + "if (msg.statusCode && msg.statusCode >= 400) {\n" + " global.set('influx_errors', errors + 1);\n" + " node.status({fill:'red', shape:'ring',\n" + " text:`ERR ${errors+1}: ${msg.statusCode}`});\n" + "} else {\n" + " node.status({fill:'green', shape:'dot',\n" + " text:`${writes} POSTs · ${totalLines} lines (${errors} err)`});\n" + "}\n" + "return null;", + outputs=1, wires=[[]], + )) + + return nodes + + +def _all_tlm_lout_ids(): + """Every link-out id that emits to evt:tlm. Listed explicitly for stable + cross-tab wiring.""" + ids = [] + for pump in PUMPS: + ids.append(f"lout_tlm_{pump}") + for suffix in ("u", "d", "f", "p"): + ids.append(f"lout_tlm_meas_{pump}_{suffix}") + ids.append("lout_tlm_mgc") + ids.append("lout_tlm_ps") + return ids + + +# --------------------------------------------------------------------------- +# Assemble + emit +# --------------------------------------------------------------------------- +def main(): + nodes = ( + build_process_tab() + + build_ui_tab() + + build_drivers_tab() + + build_setup_tab() + + build_telemetry_tab() + ) + json.dump(nodes, sys.stdout, indent=2) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/examples/pumpingstation-3pumps-dashboard/flow.json b/examples/pumpingstation-complete-example/flow.json similarity index 50% rename from examples/pumpingstation-3pumps-dashboard/flow.json rename to examples/pumpingstation-complete-example/flow.json index 9c3b2cd..fc8c1b4 100644 --- a/examples/pumpingstation-3pumps-dashboard/flow.json +++ b/examples/pumpingstation-complete-example/flow.json @@ -4,14 +4,14 @@ "type": "tab", "label": "\ud83c\udfed Process Plant", "disabled": false, - "info": "EVOLV plant model: pumpingStation + machineGroupControl + 3 rotatingMachines, each with upstream and downstream pressure measurements.\n\nReceives commands via link-in nodes from the Dashboard / Demo Drivers tabs. Emits per-pump status via link-out per pump.\n\nNo UI, no demo drivers, no one-shot setup logic on this tab \u2014 those live on their own tabs so this layer can be lifted into production unchanged." + "info": "EVOLV plant model: 3 rotatingMachines (each with 4 measurement nodes \u2014 upstream P, downstream P, flow, power), MGC, PS.\n\nPer pump there is a 'physics' function node that consumes the pump's own port-0 stream PLUS PS port-0 (basin level) and drives all 4 measurement nodes with physically-coupled values (upstream P from basin head; downstream P from pump state + flow; flow/power mirror predicted with Gaussian noise). This lives on this tab so the plant model is self-contained.\n\nAll cross-tab wires use named link-in / link-out channels." }, { "id": "c_process_title", "type": "comment", "z": "tab_process", - "name": "\ud83c\udfed PROCESS PLANT \u2014 EVOLV nodes only", - "info": "Per pump: 2 measurement sensors \u2192 rotatingMachine \u2192 output formatter \u2192 link-out to dashboard.\nMGC orchestrates 3 pumps. PS observes basin (manual mode for the demo).\nAll cross-tab wires are link-in / link-out by named channel.", + "name": "\ud83c\udfed PROCESS PLANT \u2014 EVOLV nodes + per-pump physics feeders", + "info": "", "x": 640, "y": 20, "wires": [] @@ -20,39 +20,40 @@ "id": "c_pump_a", "type": "comment", "z": "tab_process", - "name": "\u2500\u2500 Pump A \u2500\u2500", - "info": "Up + Dn pressure sensors register as children. rotatingMachine emits state on port 0 (formatted then link-out to UI). Port 2 emits registerChild \u2192 MGC.", + "name": "\u2500\u2500 Pump A \u2500\u2500 (pump + 4 sensors + physics feeder)", + "info": "Up/Dn pressure + flow + power sensors register as children of the pump. The physics_ function takes the pump's own port-0 stream and PS port-0 (basin level) and drives all 4 sensors with physically-coupled values.", "x": 640, - "y": 100, + "y": 80, "wires": [] }, { "id": "meas_pump_a_u", "type": "measurement", "z": "tab_process", - "name": "PT-A-Up", + "name": "A-Up", "mode": "analog", "channels": "[]", "scaling": false, "i_min": 0, "i_max": 1, "i_offset": 0, - "o_min": 100, - "o_max": 100, - "simulator": true, + "o_min": 0, + "o_max": 4000, + "simulator": false, "smooth_method": "mean", - "count": "5", + "count": "3", "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_a-upstream", + "uuid": "sensor-pump_a-u", "supplier": "vega", "category": "sensor", "assetType": "pressure", "model": "vega-pressure-10", "unit": "mbar", - "assetTagNumber": "PT-1-U", + "assetTagNumber": "A-U", "enableLog": false, "logLevel": "warn", + "tickIntervalMs": 2000, "positionVsParent": "upstream", "positionIcon": "\u2192", "hasDistance": false, @@ -60,42 +61,117 @@ "distanceUnit": "m", "distanceDescription": "", "x": 380, - "y": 140, + "y": 120, "wires": [ [], - [], + [ + "lout_tlm_meas_pump_a_u" + ], [ "pump_a" ] ] }, + { + "id": "lout_tlm_meas_pump_a_u", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 580, + "y": 120, + "wires": [] + }, { "id": "meas_pump_a_d", "type": "measurement", "z": "tab_process", - "name": "PT-A-Dn", + "name": "A-Dn", "mode": "analog", "channels": "[]", "scaling": false, "i_min": 0, "i_max": 1, "i_offset": 0, - "o_min": 1300, - "o_max": 1300, - "simulator": true, + "o_min": 0, + "o_max": 4000, + "simulator": false, "smooth_method": "mean", - "count": "5", + "count": "3", "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_a-downstream", + "uuid": "sensor-pump_a-d", "supplier": "vega", "category": "sensor", "assetType": "pressure", "model": "vega-pressure-10", "unit": "mbar", - "assetTagNumber": "PT-1-D", + "assetTagNumber": "A-D", "enableLog": false, "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "downstream", + "positionIcon": "\u2190", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 155, + "wires": [ + [], + [ + "lout_tlm_meas_pump_a_d" + ], + [ + "pump_a" + ] + ] + }, + { + "id": "lout_tlm_meas_pump_a_d", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 580, + "y": 155, + "wires": [] + }, + { + "id": "meas_pump_a_f", + "type": "measurement", + "z": "tab_process", + "name": "A-Flow", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 0, + "o_max": 250, + "simulator": false, + "smooth_method": "mean", + "count": "3", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_a-f", + "supplier": "endress", + "category": "sensor", + "assetType": "flow", + "model": "endress-promag-50", + "unit": "m3/h", + "assetTagNumber": "A-F", + "enableLog": false, + "logLevel": "warn", + "tickIntervalMs": 2000, "positionVsParent": "downstream", "positionIcon": "\u2190", "hasDistance": false, @@ -106,69 +182,92 @@ "y": 190, "wires": [ [], + [ + "lout_tlm_meas_pump_a_f" + ], + [ + "pump_a" + ] + ] + }, + { + "id": "lout_tlm_meas_pump_a_f", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 580, + "y": 190, + "wires": [] + }, + { + "id": "meas_pump_a_p", + "type": "measurement", + "z": "tab_process", + "name": "A-Pwr", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 0, + "o_max": 30, + "simulator": false, + "smooth_method": "mean", + "count": "3", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_a-p", + "supplier": "siemens", + "category": "sensor", + "assetType": "power", + "model": "siemens-sentron-pac4200", + "unit": "kW", + "assetTagNumber": "A-P", + "enableLog": false, + "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "atEquipment", + "positionIcon": "\u22a5", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 225, + "wires": [ [], + [ + "lout_tlm_meas_pump_a_p" + ], [ "pump_a" ] ] }, { - "id": "lin_setpoint_pump_a", - "type": "link in", + "id": "lout_tlm_meas_pump_a_p", + "type": "link out", "z": "tab_process", - "name": "cmd:setpoint-A", + "name": "evt:tlm", + "mode": "link", "links": [ - "lout_setpoint_pump_a_dash" + "lin_tlm" ], - "x": 120, - "y": 160, - "wires": [ - [ - "build_setpoint_pump_a" - ] - ] - }, - { - "id": "build_setpoint_pump_a", - "type": "function", - "z": "tab_process", - "name": "build setpoint cmd (Pump A)", - "func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 600, - "y": 160, - "wires": [ - [ - "pump_a" - ] - ] - }, - { - "id": "lin_seq_pump_a", - "type": "link in", - "z": "tab_process", - "name": "cmd:pump-A-seq", - "links": [ - "lout_seq_pump_a_dash" - ], - "x": 120, - "y": 210, - "wires": [ - [ - "pump_a" - ] - ] + "x": 580, + "y": 225, + "wires": [] }, { "id": "pump_a", "type": "rotatingMachine", "z": "tab_process", "name": "Pump A", - "speed": "10", + "speed": "200", "startup": "2", "warmup": "1", "shutdown": "2", @@ -187,6 +286,7 @@ "curveControlUnit": "%", "enableLog": false, "logLevel": "warn", + "tickIntervalMs": 2000, "positionVsParent": "atEquipment", "positionIcon": "\u22a5", "hasDistance": false, @@ -194,30 +294,46 @@ "distanceUnit": "m", "distanceDescription": "", "x": 900, - "y": 180, + "y": 170, "wires": [ [ - "format_pump_a" + "format_pump_a", + "physics_pump_a" + ], + [ + "lout_tlm_pump_a" ], - [], [ "mgc_pumps" ] ] }, + { + "id": "lout_tlm_pump_a", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 900, + "y": 210, + "wires": [] + }, { "id": "format_pump_a", "type": "function", "z": "tab_process", "name": "format Pump A port 0", - "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst pU = find('pressure.measured.upstream.');\nconst pD = find('pressure.measured.downstream.');\nmsg.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\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pU != null ? Number(pU).toFixed(0) + ' mbar' : 'n/a',\n pDn: pD != null ? Number(pD).toFixed(0) + ' mbar' : 'n/a',\n flowNum: flow != null ? Number(flow) : null,\n powerNum: power != null ? Number(power) : null,\n};\nreturn msg;", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle dashboard fan-out to \u2264 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1160, - "y": 180, + "y": 170, "wires": [ [ "lout_evt_pump_a" @@ -234,126 +350,58 @@ "lin_evt_pump_a_dash" ], "x": 1420, - "y": 180, + "y": 170, "wires": [] }, { - "id": "c_pump_b", - "type": "comment", - "z": "tab_process", - "name": "\u2500\u2500 Pump B \u2500\u2500", - "info": "Up + Dn pressure sensors register as children. rotatingMachine emits state on port 0 (formatted then link-out to UI). Port 2 emits registerChild \u2192 MGC.", - "x": 640, - "y": 300, - "wires": [] - }, - { - "id": "meas_pump_b_u", - "type": "measurement", - "z": "tab_process", - "name": "PT-B-Up", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 100, - "o_max": 100, - "simulator": true, - "smooth_method": "mean", - "count": "5", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_b-upstream", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "PT-2-U", - "enableLog": false, - "logLevel": "warn", - "positionVsParent": "upstream", - "positionIcon": "\u2192", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 340, - "wires": [ - [], - [], - [ - "pump_b" - ] - ] - }, - { - "id": "meas_pump_b_d", - "type": "measurement", - "z": "tab_process", - "name": "PT-B-Dn", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 1300, - "o_max": 1300, - "simulator": true, - "smooth_method": "mean", - "count": "5", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_b-downstream", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "PT-2-D", - "enableLog": false, - "logLevel": "warn", - "positionVsParent": "downstream", - "positionIcon": "\u2190", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 390, - "wires": [ - [], - [], - [ - "pump_b" - ] - ] - }, - { - "id": "lin_setpoint_pump_b", - "type": "link in", - "z": "tab_process", - "name": "cmd:setpoint-B", - "links": [ - "lout_setpoint_pump_b_dash" - ], - "x": 120, - "y": 360, - "wires": [ - [ - "build_setpoint_pump_b" - ] - ] - }, - { - "id": "build_setpoint_pump_b", + "id": "physics_pump_a", "type": "function", "z": "tab_process", - "name": "build setpoint cmd (Pump B)", + "name": "physics Pump A \u2192 4 sensors", + "func": "const c = context.get('c') || {};\nfunction find(o, prefix) {\n for (const k in o) { if (k.indexOf(prefix) === 0) return o[k]; }\n return null;\n}\nfunction gauss(sigma) {\n let s = 0;\n for (let i = 0; i < 12; i++) s += Math.random();\n return (s - 6) * sigma;\n}\n\nif (msg.from === 'ps') {\n const psSnap = c.ps || {};\n Object.assign(psSnap, msg.payload || {});\n c.ps = psSnap;\n const lvl = find(psSnap, 'level.predicted.atequipment.')\n ?? find(psSnap, 'level.measured.atequipment.');\n if (lvl != null) c.basinLevel = Number(lvl);\n context.set('c', c);\n return null;\n}\n\nconst pumpSnap = c.pump || {};\nObject.assign(pumpSnap, msg.payload || {});\nc.pump = pumpSnap;\ncontext.set('c', c);\n// Throttle: 1 Hz sensor updates are plenty for the demo; the\n// pump emits on every state change (5+/sec while cycling).\nconst _now = Date.now();\nconst _last = context.get('_lastEmit') || 0;\nif (_now - _last < 1000) return null;\ncontext.set('_lastEmit', _now);\n\nconst state = pumpSnap.state || 'idle';\n// 'isRunning' = the rotor is spinning (any non-idle, non-cooled state).\n// MGC retargets flow on every tick, so the pump spends most of its\n// time in 'accelerating' or 'decelerating', not 'operational'. Those\n// transient states are still moving water \u2014 flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') \u2014 `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_a', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_a_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure \u2014 same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure \u2014 driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m\u00b3/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n", + "outputs": 4, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1160, + "y": 240, + "wires": [ + [ + "meas_pump_a_u" + ], + [ + "meas_pump_a_d" + ], + [ + "meas_pump_a_f" + ], + [ + "meas_pump_a_p" + ] + ] + }, + { + "id": "lin_setpoint_pump_a", + "type": "link in", + "z": "tab_process", + "name": "cmd:setpoint-A", + "links": [ + "lout_setpoint_pump_a_dash" + ], + "x": 120, + "y": 140, + "wires": [ + [ + "build_setpoint_pump_a" + ] + ] + }, + { + "id": "build_setpoint_pump_a", + "type": "function", + "z": "tab_process", + "name": "build setpoint cmd (Pump A)", "func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;", "outputs": 1, "noerr": 0, @@ -361,35 +409,281 @@ "finalize": "", "libs": [], "x": 600, - "y": 360, + "y": 140, "wires": [ + [ + "pump_a" + ] + ] + }, + { + "id": "lin_seq_pump_a", + "type": "link in", + "z": "tab_process", + "name": "cmd:pump-A-seq", + "links": [ + "lout_seq_pump_a_dash" + ], + "x": 120, + "y": 190, + "wires": [ + [ + "pump_a" + ] + ] + }, + { + "id": "c_pump_b", + "type": "comment", + "z": "tab_process", + "name": "\u2500\u2500 Pump B \u2500\u2500 (pump + 4 sensors + physics feeder)", + "info": "Up/Dn pressure + flow + power sensors register as children of the pump. The physics_ function takes the pump's own port-0 stream and PS port-0 (basin level) and drives all 4 sensors with physically-coupled values.", + "x": 640, + "y": 360, + "wires": [] + }, + { + "id": "meas_pump_b_u", + "type": "measurement", + "z": "tab_process", + "name": "B-Up", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 0, + "o_max": 4000, + "simulator": false, + "smooth_method": "mean", + "count": "3", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_b-u", + "supplier": "vega", + "category": "sensor", + "assetType": "pressure", + "model": "vega-pressure-10", + "unit": "mbar", + "assetTagNumber": "B-U", + "enableLog": false, + "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "upstream", + "positionIcon": "\u2192", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 400, + "wires": [ + [], + [ + "lout_tlm_meas_pump_b_u" + ], [ "pump_b" ] ] }, { - "id": "lin_seq_pump_b", - "type": "link in", + "id": "lout_tlm_meas_pump_b_u", + "type": "link out", "z": "tab_process", - "name": "cmd:pump-B-seq", + "name": "evt:tlm", + "mode": "link", "links": [ - "lout_seq_pump_b_dash" + "lin_tlm" ], - "x": 120, - "y": 410, + "x": 580, + "y": 400, + "wires": [] + }, + { + "id": "meas_pump_b_d", + "type": "measurement", + "z": "tab_process", + "name": "B-Dn", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 0, + "o_max": 4000, + "simulator": false, + "smooth_method": "mean", + "count": "3", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_b-d", + "supplier": "vega", + "category": "sensor", + "assetType": "pressure", + "model": "vega-pressure-10", + "unit": "mbar", + "assetTagNumber": "B-D", + "enableLog": false, + "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "downstream", + "positionIcon": "\u2190", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 435, "wires": [ + [], + [ + "lout_tlm_meas_pump_b_d" + ], [ "pump_b" ] ] }, + { + "id": "lout_tlm_meas_pump_b_d", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 580, + "y": 435, + "wires": [] + }, + { + "id": "meas_pump_b_f", + "type": "measurement", + "z": "tab_process", + "name": "B-Flow", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 0, + "o_max": 250, + "simulator": false, + "smooth_method": "mean", + "count": "3", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_b-f", + "supplier": "endress", + "category": "sensor", + "assetType": "flow", + "model": "endress-promag-50", + "unit": "m3/h", + "assetTagNumber": "B-F", + "enableLog": false, + "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "downstream", + "positionIcon": "\u2190", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 470, + "wires": [ + [], + [ + "lout_tlm_meas_pump_b_f" + ], + [ + "pump_b" + ] + ] + }, + { + "id": "lout_tlm_meas_pump_b_f", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 580, + "y": 470, + "wires": [] + }, + { + "id": "meas_pump_b_p", + "type": "measurement", + "z": "tab_process", + "name": "B-Pwr", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 0, + "o_max": 30, + "simulator": false, + "smooth_method": "mean", + "count": "3", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_b-p", + "supplier": "siemens", + "category": "sensor", + "assetType": "power", + "model": "siemens-sentron-pac4200", + "unit": "kW", + "assetTagNumber": "B-P", + "enableLog": false, + "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "atEquipment", + "positionIcon": "\u22a5", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 505, + "wires": [ + [], + [ + "lout_tlm_meas_pump_b_p" + ], + [ + "pump_b" + ] + ] + }, + { + "id": "lout_tlm_meas_pump_b_p", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 580, + "y": 505, + "wires": [] + }, { "id": "pump_b", "type": "rotatingMachine", "z": "tab_process", "name": "Pump B", - "speed": "10", + "speed": "200", "startup": "2", "warmup": "1", "shutdown": "2", @@ -408,6 +702,7 @@ "curveControlUnit": "%", "enableLog": false, "logLevel": "warn", + "tickIntervalMs": 2000, "positionVsParent": "atEquipment", "positionIcon": "\u22a5", "hasDistance": false, @@ -415,30 +710,46 @@ "distanceUnit": "m", "distanceDescription": "", "x": 900, - "y": 380, + "y": 450, "wires": [ [ - "format_pump_b" + "format_pump_b", + "physics_pump_b" + ], + [ + "lout_tlm_pump_b" ], - [], [ "mgc_pumps" ] ] }, + { + "id": "lout_tlm_pump_b", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 900, + "y": 490, + "wires": [] + }, { "id": "format_pump_b", "type": "function", "z": "tab_process", "name": "format Pump B port 0", - "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst pU = find('pressure.measured.upstream.');\nconst pD = find('pressure.measured.downstream.');\nmsg.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\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pU != null ? Number(pU).toFixed(0) + ' mbar' : 'n/a',\n pDn: pD != null ? Number(pD).toFixed(0) + ' mbar' : 'n/a',\n flowNum: flow != null ? Number(flow) : null,\n powerNum: power != null ? Number(power) : null,\n};\nreturn msg;", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle dashboard fan-out to \u2264 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1160, - "y": 380, + "y": 450, "wires": [ [ "lout_evt_pump_b" @@ -455,126 +766,58 @@ "lin_evt_pump_b_dash" ], "x": 1420, - "y": 380, + "y": 450, "wires": [] }, { - "id": "c_pump_c", - "type": "comment", - "z": "tab_process", - "name": "\u2500\u2500 Pump C \u2500\u2500", - "info": "Up + Dn pressure sensors register as children. rotatingMachine emits state on port 0 (formatted then link-out to UI). Port 2 emits registerChild \u2192 MGC.", - "x": 640, - "y": 500, - "wires": [] - }, - { - "id": "meas_pump_c_u", - "type": "measurement", - "z": "tab_process", - "name": "PT-C-Up", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 100, - "o_max": 100, - "simulator": true, - "smooth_method": "mean", - "count": "5", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_c-upstream", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "PT-3-U", - "enableLog": false, - "logLevel": "warn", - "positionVsParent": "upstream", - "positionIcon": "\u2192", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 540, - "wires": [ - [], - [], - [ - "pump_c" - ] - ] - }, - { - "id": "meas_pump_c_d", - "type": "measurement", - "z": "tab_process", - "name": "PT-C-Dn", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 1300, - "o_max": 1300, - "simulator": true, - "smooth_method": "mean", - "count": "5", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_c-downstream", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "PT-3-D", - "enableLog": false, - "logLevel": "warn", - "positionVsParent": "downstream", - "positionIcon": "\u2190", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 590, - "wires": [ - [], - [], - [ - "pump_c" - ] - ] - }, - { - "id": "lin_setpoint_pump_c", - "type": "link in", - "z": "tab_process", - "name": "cmd:setpoint-C", - "links": [ - "lout_setpoint_pump_c_dash" - ], - "x": 120, - "y": 560, - "wires": [ - [ - "build_setpoint_pump_c" - ] - ] - }, - { - "id": "build_setpoint_pump_c", + "id": "physics_pump_b", "type": "function", "z": "tab_process", - "name": "build setpoint cmd (Pump C)", + "name": "physics Pump B \u2192 4 sensors", + "func": "const c = context.get('c') || {};\nfunction find(o, prefix) {\n for (const k in o) { if (k.indexOf(prefix) === 0) return o[k]; }\n return null;\n}\nfunction gauss(sigma) {\n let s = 0;\n for (let i = 0; i < 12; i++) s += Math.random();\n return (s - 6) * sigma;\n}\n\nif (msg.from === 'ps') {\n const psSnap = c.ps || {};\n Object.assign(psSnap, msg.payload || {});\n c.ps = psSnap;\n const lvl = find(psSnap, 'level.predicted.atequipment.')\n ?? find(psSnap, 'level.measured.atequipment.');\n if (lvl != null) c.basinLevel = Number(lvl);\n context.set('c', c);\n return null;\n}\n\nconst pumpSnap = c.pump || {};\nObject.assign(pumpSnap, msg.payload || {});\nc.pump = pumpSnap;\ncontext.set('c', c);\n// Throttle: 1 Hz sensor updates are plenty for the demo; the\n// pump emits on every state change (5+/sec while cycling).\nconst _now = Date.now();\nconst _last = context.get('_lastEmit') || 0;\nif (_now - _last < 1000) return null;\ncontext.set('_lastEmit', _now);\n\nconst state = pumpSnap.state || 'idle';\n// 'isRunning' = the rotor is spinning (any non-idle, non-cooled state).\n// MGC retargets flow on every tick, so the pump spends most of its\n// time in 'accelerating' or 'decelerating', not 'operational'. Those\n// transient states are still moving water \u2014 flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') \u2014 `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_b', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_b_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure \u2014 same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure \u2014 driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m\u00b3/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n", + "outputs": 4, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1160, + "y": 520, + "wires": [ + [ + "meas_pump_b_u" + ], + [ + "meas_pump_b_d" + ], + [ + "meas_pump_b_f" + ], + [ + "meas_pump_b_p" + ] + ] + }, + { + "id": "lin_setpoint_pump_b", + "type": "link in", + "z": "tab_process", + "name": "cmd:setpoint-B", + "links": [ + "lout_setpoint_pump_b_dash" + ], + "x": 120, + "y": 420, + "wires": [ + [ + "build_setpoint_pump_b" + ] + ] + }, + { + "id": "build_setpoint_pump_b", + "type": "function", + "z": "tab_process", + "name": "build setpoint cmd (Pump B)", "func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;", "outputs": 1, "noerr": 0, @@ -582,35 +825,281 @@ "finalize": "", "libs": [], "x": 600, - "y": 560, + "y": 420, "wires": [ + [ + "pump_b" + ] + ] + }, + { + "id": "lin_seq_pump_b", + "type": "link in", + "z": "tab_process", + "name": "cmd:pump-B-seq", + "links": [ + "lout_seq_pump_b_dash" + ], + "x": 120, + "y": 470, + "wires": [ + [ + "pump_b" + ] + ] + }, + { + "id": "c_pump_c", + "type": "comment", + "z": "tab_process", + "name": "\u2500\u2500 Pump C \u2500\u2500 (pump + 4 sensors + physics feeder)", + "info": "Up/Dn pressure + flow + power sensors register as children of the pump. The physics_ function takes the pump's own port-0 stream and PS port-0 (basin level) and drives all 4 sensors with physically-coupled values.", + "x": 640, + "y": 640, + "wires": [] + }, + { + "id": "meas_pump_c_u", + "type": "measurement", + "z": "tab_process", + "name": "C-Up", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 0, + "o_max": 4000, + "simulator": false, + "smooth_method": "mean", + "count": "3", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_c-u", + "supplier": "vega", + "category": "sensor", + "assetType": "pressure", + "model": "vega-pressure-10", + "unit": "mbar", + "assetTagNumber": "C-U", + "enableLog": false, + "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "upstream", + "positionIcon": "\u2192", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 680, + "wires": [ + [], + [ + "lout_tlm_meas_pump_c_u" + ], [ "pump_c" ] ] }, { - "id": "lin_seq_pump_c", - "type": "link in", + "id": "lout_tlm_meas_pump_c_u", + "type": "link out", "z": "tab_process", - "name": "cmd:pump-C-seq", + "name": "evt:tlm", + "mode": "link", "links": [ - "lout_seq_pump_c_dash" + "lin_tlm" ], - "x": 120, - "y": 610, + "x": 580, + "y": 680, + "wires": [] + }, + { + "id": "meas_pump_c_d", + "type": "measurement", + "z": "tab_process", + "name": "C-Dn", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 0, + "o_max": 4000, + "simulator": false, + "smooth_method": "mean", + "count": "3", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_c-d", + "supplier": "vega", + "category": "sensor", + "assetType": "pressure", + "model": "vega-pressure-10", + "unit": "mbar", + "assetTagNumber": "C-D", + "enableLog": false, + "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "downstream", + "positionIcon": "\u2190", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 715, "wires": [ + [], + [ + "lout_tlm_meas_pump_c_d" + ], [ "pump_c" ] ] }, + { + "id": "lout_tlm_meas_pump_c_d", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 580, + "y": 715, + "wires": [] + }, + { + "id": "meas_pump_c_f", + "type": "measurement", + "z": "tab_process", + "name": "C-Flow", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 0, + "o_max": 250, + "simulator": false, + "smooth_method": "mean", + "count": "3", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_c-f", + "supplier": "endress", + "category": "sensor", + "assetType": "flow", + "model": "endress-promag-50", + "unit": "m3/h", + "assetTagNumber": "C-F", + "enableLog": false, + "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "downstream", + "positionIcon": "\u2190", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 750, + "wires": [ + [], + [ + "lout_tlm_meas_pump_c_f" + ], + [ + "pump_c" + ] + ] + }, + { + "id": "lout_tlm_meas_pump_c_f", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 580, + "y": 750, + "wires": [] + }, + { + "id": "meas_pump_c_p", + "type": "measurement", + "z": "tab_process", + "name": "C-Pwr", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 0, + "o_max": 30, + "simulator": false, + "smooth_method": "mean", + "count": "3", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_c-p", + "supplier": "siemens", + "category": "sensor", + "assetType": "power", + "model": "siemens-sentron-pac4200", + "unit": "kW", + "assetTagNumber": "C-P", + "enableLog": false, + "logLevel": "warn", + "tickIntervalMs": 2000, + "positionVsParent": "atEquipment", + "positionIcon": "\u22a5", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 785, + "wires": [ + [], + [ + "lout_tlm_meas_pump_c_p" + ], + [ + "pump_c" + ] + ] + }, + { + "id": "lout_tlm_meas_pump_c_p", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 580, + "y": 785, + "wires": [] + }, { "id": "pump_c", "type": "rotatingMachine", "z": "tab_process", "name": "Pump C", - "speed": "10", + "speed": "200", "startup": "2", "warmup": "1", "shutdown": "2", @@ -629,6 +1118,7 @@ "curveControlUnit": "%", "enableLog": false, "logLevel": "warn", + "tickIntervalMs": 2000, "positionVsParent": "atEquipment", "positionIcon": "\u22a5", "hasDistance": false, @@ -636,30 +1126,46 @@ "distanceUnit": "m", "distanceDescription": "", "x": 900, - "y": 580, + "y": 730, "wires": [ [ - "format_pump_c" + "format_pump_c", + "physics_pump_c" + ], + [ + "lout_tlm_pump_c" ], - [], [ "mgc_pumps" ] ] }, + { + "id": "lout_tlm_pump_c", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 900, + "y": 770, + "wires": [] + }, { "id": "format_pump_c", "type": "function", "z": "tab_process", "name": "format Pump C port 0", - "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst pU = find('pressure.measured.upstream.');\nconst pD = find('pressure.measured.downstream.');\nmsg.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\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pU != null ? Number(pU).toFixed(0) + ' mbar' : 'n/a',\n pDn: pD != null ? Number(pD).toFixed(0) + ' mbar' : 'n/a',\n flowNum: flow != null ? Number(flow) : null,\n powerNum: power != null ? Number(power) : null,\n};\nreturn msg;", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle dashboard fan-out to \u2264 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1160, - "y": 580, + "y": 730, "wires": [ [ "lout_evt_pump_c" @@ -676,17 +1182,96 @@ "lin_evt_pump_c_dash" ], "x": 1420, - "y": 580, + "y": 730, "wires": [] }, + { + "id": "physics_pump_c", + "type": "function", + "z": "tab_process", + "name": "physics Pump C \u2192 4 sensors", + "func": "const c = context.get('c') || {};\nfunction find(o, prefix) {\n for (const k in o) { if (k.indexOf(prefix) === 0) return o[k]; }\n return null;\n}\nfunction gauss(sigma) {\n let s = 0;\n for (let i = 0; i < 12; i++) s += Math.random();\n return (s - 6) * sigma;\n}\n\nif (msg.from === 'ps') {\n const psSnap = c.ps || {};\n Object.assign(psSnap, msg.payload || {});\n c.ps = psSnap;\n const lvl = find(psSnap, 'level.predicted.atequipment.')\n ?? find(psSnap, 'level.measured.atequipment.');\n if (lvl != null) c.basinLevel = Number(lvl);\n context.set('c', c);\n return null;\n}\n\nconst pumpSnap = c.pump || {};\nObject.assign(pumpSnap, msg.payload || {});\nc.pump = pumpSnap;\ncontext.set('c', c);\n// Throttle: 1 Hz sensor updates are plenty for the demo; the\n// pump emits on every state change (5+/sec while cycling).\nconst _now = Date.now();\nconst _last = context.get('_lastEmit') || 0;\nif (_now - _last < 1000) return null;\ncontext.set('_lastEmit', _now);\n\nconst state = pumpSnap.state || 'idle';\n// 'isRunning' = the rotor is spinning (any non-idle, non-cooled state).\n// MGC retargets flow on every tick, so the pump spends most of its\n// time in 'accelerating' or 'decelerating', not 'operational'. Those\n// transient states are still moving water \u2014 flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') \u2014 `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_c', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_c_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure \u2014 same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure \u2014 driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m\u00b3/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n", + "outputs": 4, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1160, + "y": 800, + "wires": [ + [ + "meas_pump_c_u" + ], + [ + "meas_pump_c_d" + ], + [ + "meas_pump_c_f" + ], + [ + "meas_pump_c_p" + ] + ] + }, + { + "id": "lin_setpoint_pump_c", + "type": "link in", + "z": "tab_process", + "name": "cmd:setpoint-C", + "links": [ + "lout_setpoint_pump_c_dash" + ], + "x": 120, + "y": 700, + "wires": [ + [ + "build_setpoint_pump_c" + ] + ] + }, + { + "id": "build_setpoint_pump_c", + "type": "function", + "z": "tab_process", + "name": "build setpoint cmd (Pump C)", + "func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 600, + "y": 700, + "wires": [ + [ + "pump_c" + ] + ] + }, + { + "id": "lin_seq_pump_c", + "type": "link in", + "z": "tab_process", + "name": "cmd:pump-C-seq", + "links": [ + "lout_seq_pump_c_dash" + ], + "x": 120, + "y": 750, + "wires": [ + [ + "pump_c" + ] + ] + }, { "id": "c_mgc", "type": "comment", "z": "tab_process", "name": "\u2500\u2500 MGC \u2500\u2500 (orchestrates the 3 pumps via optimalcontrol)", - "info": "Receives Qd from cmd:demand link-in. Distributes flow across pumps.", + "info": "", "x": 640, - "y": 700, + "y": 920, "wires": [] }, { @@ -700,8 +1285,9 @@ "model": "default", "unit": "m3/h", "supplier": "evolv", - "enableLog": false, - "logLevel": "warn", + "enableLog": true, + "logLevel": "debug", + "tickIntervalMs": 2000, "positionVsParent": "atEquipment", "positionIcon": "\u22a5", "hasDistance": false, @@ -711,30 +1297,45 @@ "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", "x": 900, - "y": 780, + "y": 1000, "wires": [ [ "format_mgc" ], - [], + [ + "lout_tlm_mgc" + ], [ "ps_basin" ] ] }, + { + "id": "lout_tlm_mgc", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 900, + "y": 1040, + "wires": [] + }, { "id": "format_mgc", "type": "function", "z": "tab_process", "name": "format MGC port 0", - "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst totalFlow = find('flow.predicted.atequipment.') ?? find('downstream_predicted_flow');\nconst totalPower = find('power.predicted.atequipment.') ?? find('atEquipment_predicted_power');\nconst eff = find('efficiency.predicted.atequipment.');\nmsg.payload = {\n totalFlow: totalFlow != null ? Number(totalFlow).toFixed(1) + ' m\u00b3/h' : 'n/a',\n totalPower: totalPower != null ? Number(totalPower).toFixed(2) + ' kW' : 'n/a',\n efficiency: eff != null ? Number(eff).toFixed(3) : 'n/a',\n totalFlowNum: totalFlow != null ? Number(totalFlow) : null,\n totalPowerNum: totalPower != null ? Number(totalPower) : null,\n};\nreturn msg;", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle: MGC fires on every distribution change.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst totalFlow = find('flow.predicted.atequipment.') ?? find('downstream_predicted_flow');\nconst totalPower = find('power.predicted.atequipment.') ?? find('atEquipment_predicted_power');\nconst eff = find('efficiency.predicted.atequipment.');\nmsg.payload = {\n totalFlow: totalFlow != null ? Number(totalFlow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n totalPower: totalPower != null ? Number(totalPower).toFixed(2) + ' kW' : 'n/a',\n efficiency: eff != null ? Number(eff).toFixed(3) : 'n/a',\n totalFlowNum: totalFlow != null ? Number(totalFlow ) : null,\n totalPowerNum: totalPower != null ? Number(totalPower) : null,\n efficiencyNum: eff != null ? Number(eff) : null,\n};\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1160, - "y": 780, + "y": 1000, "wires": [ [ "lout_evt_mgc" @@ -751,7 +1352,7 @@ "lin_evt_mgc_dash" ], "x": 1420, - "y": 780, + "y": 1000, "wires": [] }, { @@ -759,9 +1360,9 @@ "type": "comment", "z": "tab_process", "name": "\u2500\u2500 Pumping Station \u2500\u2500 (basin model, levelbased control)", - "info": "Receives q_in (simulated inflow) from Demo Drivers tab.\nLevel-based control starts/stops pumps via MGC when level crosses start/stop thresholds.", + "info": "", "x": 640, - "y": 900, + "y": 1200, "wires": [] }, { @@ -773,7 +1374,7 @@ "lout_qin_drivers" ], "x": 120, - "y": 940, + "y": 1240, "wires": [ [ "ps_basin" @@ -786,10 +1387,10 @@ "z": "tab_process", "name": "cmd:Qd", "links": [ - "lout_demand_dash" + "lout_qd_dash" ], "x": 120, - "y": 980, + "y": 1280, "wires": [ [ "qd_to_ps_wrap" @@ -808,7 +1409,7 @@ "finalize": "", "libs": [], "x": 380, - "y": 980, + "y": 1280, "wires": [ [ "ps_basin" @@ -824,7 +1425,7 @@ "lout_ps_mode_dash" ], "x": 120, - "y": 1020, + "y": 1320, "wires": [ [ "ps_basin" @@ -844,6 +1445,7 @@ "supplier": "evolv", "enableLog": false, "logLevel": "warn", + "tickIntervalMs": 2000, "positionVsParent": "atEquipment", "positionIcon": "\u22a5", "hasDistance": false, @@ -853,15 +1455,17 @@ "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", "controlMode": "levelbased", - "basinVolume": 30, - "basinHeight": 4, - "inflowLevel": 3.5, + "basinVolume": 50.0, + "basinHeight": 4.0, + "inflowLevel": 2.5, "outflowLevel": 0.3, "overflowLevel": 3.8, "inletPipeDiameter": 0.3, "outletPipeDiameter": 0.3, - "minLevel": 1.0, - "startLevel": 2.0, + "minLevel": 0.5, + "startLevel": 2.5, + "stopLevel": 2.0, + "deadZoneKeepAlivePercent": 1, "maxLevel": 3.5, "refHeight": "NAP", "minHeightBasedOn": "outlet", @@ -878,12 +1482,53 @@ "overfillThresholdPercent": 95, "timeleftToFullOrEmptyThresholdSeconds": 0, "x": 900, - "y": 980, + "y": 1280, "wires": [ [ - "format_ps" + "format_ps", + "ps_to_physics" ], - [] + [ + "lout_tlm_ps" + ] + ] + }, + { + "id": "lout_tlm_ps", + "type": "link out", + "z": "tab_process", + "name": "evt:tlm", + "mode": "link", + "links": [ + "lin_tlm" + ], + "x": 900, + "y": 1320, + "wires": [] + }, + { + "id": "ps_to_physics", + "type": "function", + "z": "tab_process", + "name": "ps \u2192 fan basin level to 3 physics feeders", + "func": "const out = { from: 'ps', payload: msg.payload };\nreturn [out, out, out];", + "outputs": 3, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1160, + "y": 1330, + "wires": [ + [ + "physics_pump_a" + ], + [ + "physics_pump_b" + ], + [ + "physics_pump_c" + ] ] }, { @@ -891,14 +1536,14 @@ "type": "function", "z": "tab_process", "name": "format PS port 0", - "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst lvl = find('level.predicted.');\nconst vol = find('volume.predicted.');\nconst qIn = find('flow.predicted.in.');\nconst qOut = find('flow.predicted.out.');\nconst netFlowRate = find('netFlowRate.predicted.');\n// Compute derived metrics\n// Basin capacity = basinVolume (config). Don't hardcode \u2014 read it once.\nif (!context.get('maxVol')) context.set('maxVol', 30.0); // basinVolume from PS config\nconst maxVol = context.get('maxVol');\nconst fillPct = vol != null ? Math.min(100, Math.max(0, Math.round(Number(vol) / maxVol * 100))) : null;\nconst netM3h = netFlowRate != null ? Number(netFlowRate) * 3600 : null;\nconst seconds = (c.timeleft != null && Number.isFinite(Number(c.timeleft))) ? Number(c.timeleft) : null;\nconst timeStr = seconds != null ? (seconds > 60 ? Math.round(seconds/60) + ' min' : Math.round(seconds) + ' s') : 'n/a';\nmsg.payload = {\n direction: c.direction || 'steady',\n level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n volume: vol != null ? Number(vol).toFixed(1) + ' m\u00b3' : 'n/a',\n fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n netFlow: netM3h != null ? netM3h.toFixed(0) + ' m\u00b3/h' : 'n/a',\n timeLeft: timeStr,\n qIn: qIn != null ? (Number(qIn) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n // Numerics for trends\n levelNum: lvl != null ? Number(lvl) : null,\n volumeNum: vol != null ? Number(vol) : null,\n fillPctNum: fillPct,\n netFlowNum: netM3h,\n percControl: c.percControl != null ? Number(c.percControl) : null,\n qInNum: qIn != null ? Number(qIn) * 3600 : null,\n qOutNum: qOut != null ? Number(qOut) * 3600 : null,\n};\nreturn msg;", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle: PS emits frequently in levelbased mode.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst MAX_VOL = 50.0;\nconst lvl = find('level.predicted.');\nconst vol = find('volume.predicted.');\nconst qIn = find('flow.predicted.in.');\nconst qOut = find('flow.predicted.out.');\nconst netFlowRate = find('netFlowRate.predicted.');\nconst fillPct = vol != null\n ? Math.min(100, Math.max(0, Math.round(Number(vol) / MAX_VOL * 100)))\n : null;\nconst netM3h = netFlowRate != null ? Number(netFlowRate) * 3600 : null;\nconst seconds = (c.timeleft != null && Number.isFinite(Number(c.timeleft)))\n ? Number(c.timeleft) : null;\nconst timeStr = seconds != null\n ? (seconds > 60 ? Math.round(seconds/60) + ' min'\n : Math.round(seconds) + ' s')\n : 'n/a';\nmsg.payload = {\n direction: c.direction || 'steady',\n level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n volume: vol != null ? Number(vol).toFixed(1) + ' m\u00b3' : 'n/a',\n fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n netFlow: netM3h != null ? netM3h.toFixed(0) + ' m\u00b3/h' : 'n/a',\n timeLeft: timeStr,\n qIn: qIn != null ? (Number(qIn ) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n levelNum: lvl != null ? Number(lvl) : null,\n volumeNum: vol != null ? Number(vol) : null,\n fillPctNum: fillPct,\n netFlowNum: netM3h,\n percControl: c.percControl != null ? Number(c.percControl) : null,\n qInNum: qIn != null ? Number(qIn ) * 3600 : null,\n qOutNum: qOut != null ? Number(qOut) * 3600 : null,\n safetyState: c.safetyState || 'normal',\n};\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1160, - "y": 980, + "y": 1280, "wires": [ [ "lout_evt_ps" @@ -915,7 +1560,7 @@ "lin_evt_ps_dash" ], "x": 1420, - "y": 980, + "y": 1280, "wires": [] }, { @@ -923,9 +1568,9 @@ "type": "comment", "z": "tab_process", "name": "\u2500\u2500 Mode broadcast \u2500\u2500", - "info": "Single 'auto' / 'virtualControl' value fans out as setMode to all 3 pumps.", + "info": "", "x": 640, - "y": 1100, + "y": 1420, "wires": [] }, { @@ -934,10 +1579,10 @@ "z": "tab_process", "name": "cmd:mode", "links": [ - "lout_mode_dash" + "lout_mode_setup" ], "x": 120, - "y": 1160, + "y": 1480, "wires": [ [ "fanout_mode" @@ -956,7 +1601,7 @@ "finalize": "", "libs": [], "x": 600, - "y": 1160, + "y": 1480, "wires": [ [ "pump_a" @@ -973,10 +1618,10 @@ "id": "c_station_cmds", "type": "comment", "z": "tab_process", - "name": "\u2500\u2500 Station-wide commands \u2500\u2500 (Start All / Stop All / Emergency)", - "info": "Each link-in carries a fully-built msg ready for handleInput; we just fan out 3-way.", + "name": "\u2500\u2500 Station-wide commands \u2500\u2500", + "info": "", "x": 640, - "y": 1300, + "y": 1620, "wires": [] }, { @@ -988,7 +1633,7 @@ "lout_cmd_station_startup_dash" ], "x": 120, - "y": 1360, + "y": 1680, "wires": [ [ "fan_station_start" @@ -1007,7 +1652,7 @@ "finalize": "", "libs": [], "x": 600, - "y": 1360, + "y": 1680, "wires": [ [ "pump_a" @@ -1029,7 +1674,7 @@ "lout_cmd_station_shutdown_dash" ], "x": 120, - "y": 1420, + "y": 1740, "wires": [ [ "fan_station_stop" @@ -1048,7 +1693,7 @@ "finalize": "", "libs": [], "x": 600, - "y": 1420, + "y": 1740, "wires": [ [ "pump_a" @@ -1070,7 +1715,7 @@ "lout_cmd_station_estop_dash" ], "x": 120, - "y": 1480, + "y": 1800, "wires": [ [ "fan_station_estop" @@ -1089,7 +1734,7 @@ "finalize": "", "libs": [], "x": 600, - "y": 1480, + "y": 1800, "wires": [ [ "pump_a" @@ -1107,9 +1752,9 @@ "type": "comment", "z": "tab_process", "name": "\u2500\u2500 Setup feeders \u2500\u2500", - "info": "Cross-tab link from Setup tab \u2192 MGC scaling/mode init.", + "info": "", "x": 640, - "y": 1500, + "y": 1900, "wires": [] }, { @@ -1121,24 +1766,40 @@ "lout_setup_to_mgc" ], "x": 120, - "y": 1560, + "y": 1960, "wires": [ [ "mgc_pumps" ] ] }, + { + "id": "lin_setup_calibrate_ps", + "type": "link in", + "z": "tab_process", + "name": "setup:calibrate-ps", + "links": [ + "lout_setup_calibrate" + ], + "x": 120, + "y": 2020, + "wires": [ + [ + "ps_basin" + ] + ] + }, { "id": "tab_ui", "type": "tab", "label": "\ud83d\udcca Dashboard UI", "disabled": false, - "info": "Every ui-* widget lives here. Inputs (sliders/switches/buttons) emit via link-out; status text + charts receive via link-in. No business logic on this tab." + "info": "All FlowFuse ui-* widgets. Two pages:\n /dashboard/realtime \u2014 gauges + per-pump status (no time history)\n /dashboard/trends \u2014 line charts, 1 hour rolling window\n\nAll inputs leave via link-out; all process state arrives via link-in." }, { - "id": "ui_base_ps_demo", + "id": "ui_base", "type": "ui-base", - "name": "EVOLV Demo", + "name": "EVOLV Pumping", "path": "/dashboard", "appIcon": "", "includeClientData": true, @@ -1152,12 +1813,12 @@ "titleBarStyle": "default" }, { - "id": "ui_theme_ps_demo", + "id": "ui_theme", "type": "ui-theme", "name": "EVOLV Theme", "colors": { "surface": "#ffffff", - "primary": "#0f52a5", + "primary": "#0c99d9", "bgPage": "#f4f6fa", "groupBg": "#ffffff", "groupOutline": "#cccccc" @@ -1171,14 +1832,14 @@ } }, { - "id": "ui_page_control", + "id": "ui_page_realtime", "type": "ui-page", - "name": "Control", - "ui": "ui_base_ps_demo", - "path": "/pumping-station-demo", - "icon": "water_pump", + "name": "Realtime", + "ui": "ui_base", + "path": "/realtime", + "icon": "speed", "layout": "grid", - "theme": "ui_theme_ps_demo", + "theme": "ui_theme", "breakpoints": [ { "name": "Default", @@ -1190,14 +1851,14 @@ "className": "" }, { - "id": "ui_page_short_trends", + "id": "ui_page_trends", "type": "ui-page", - "name": "Trends \u2014 10 min", - "ui": "ui_base_ps_demo", - "path": "/pumping-station-demo/trends-short", + "name": "Trends \u2014 1 hour", + "ui": "ui_base", + "path": "/trends", "icon": "show_chart", "layout": "grid", - "theme": "ui_theme_ps_demo", + "theme": "ui_theme", "breakpoints": [ { "name": "Default", @@ -1209,29 +1870,10 @@ "className": "" }, { - "id": "ui_page_long_trends", - "type": "ui-page", - "name": "Trends \u2014 1 hour", - "ui": "ui_base_ps_demo", - "path": "/pumping-station-demo/trends-long", - "icon": "timeline", - "layout": "grid", - "theme": "ui_theme_ps_demo", - "breakpoints": [ - { - "name": "Default", - "px": "0", - "cols": "12" - } - ], - "order": 3, - "className": "" - }, - { - "id": "ui_grp_demand", + "id": "ui_grp_inflow", "type": "ui-group", - "name": "1. Process Demand", - "page": "ui_page_control", + "name": "1. Inflow (operator input)", + "page": "ui_page_realtime", "width": "12", "height": "1", "order": 1, @@ -1244,8 +1886,8 @@ { "id": "ui_grp_station", "type": "ui-group", - "name": "2. Station Controls", - "page": "ui_page_control", + "name": "2. Station Mode + Commands", + "page": "ui_page_realtime", "width": "12", "height": "1", "order": 2, @@ -1256,10 +1898,10 @@ "visible": true }, { - "id": "ui_grp_mgc", + "id": "ui_grp_basin", "type": "ui-group", - "name": "3a. MGC Status", - "page": "ui_page_control", + "name": "3. Basin Realtime", + "page": "ui_page_realtime", "width": "6", "height": "1", "order": 3, @@ -1270,10 +1912,10 @@ "visible": true }, { - "id": "ui_grp_ps", + "id": "ui_grp_mgc", "type": "ui-group", - "name": "3b. Basin Status", - "page": "ui_page_control", + "name": "4. Pump Group (MGC)", + "page": "ui_page_realtime", "width": "6", "height": "1", "order": 4, @@ -1286,8 +1928,8 @@ { "id": "ui_grp_pump_a", "type": "ui-group", - "name": "4a. Pump A", - "page": "ui_page_control", + "name": "5a. Pump A", + "page": "ui_page_realtime", "width": "4", "height": "1", "order": 5, @@ -1300,8 +1942,8 @@ { "id": "ui_grp_pump_b", "type": "ui-group", - "name": "4b. Pump B", - "page": "ui_page_control", + "name": "5b. Pump B", + "page": "ui_page_realtime", "width": "4", "height": "1", "order": 6, @@ -1314,8 +1956,8 @@ { "id": "ui_grp_pump_c", "type": "ui-group", - "name": "4c. Pump C", - "page": "ui_page_control", + "name": "5c. Pump C", + "page": "ui_page_realtime", "width": "4", "height": "1", "order": 7, @@ -1326,10 +1968,10 @@ "visible": true }, { - "id": "ui_grp_trend_short_flow", + "id": "ui_grp_tr_basin", "type": "ui-group", - "name": "Flow (10 min)", - "page": "ui_page_short_trends", + "name": "Basin level + fill (1h)", + "page": "ui_page_trends", "width": "12", "height": "1", "order": 1, @@ -1340,10 +1982,10 @@ "visible": true }, { - "id": "ui_grp_trend_short_power", + "id": "ui_grp_tr_demand", "type": "ui-group", - "name": "Power (10 min)", - "page": "ui_page_short_trends", + "name": "Process demand \u2014 PS percControl (1h)", + "page": "ui_page_trends", "width": "12", "height": "1", "order": 2, @@ -1354,10 +1996,10 @@ "visible": true }, { - "id": "ui_grp_trend_short_basin_level", + "id": "ui_grp_tr_dq", "type": "ui-group", - "name": "Basin Level (10 min)", - "page": "ui_page_short_trends", + "name": "\u0394Q = inflow \u2212 outflow (m\u00b3/h, +fill / \u2212drain)", + "page": "ui_page_trends", "width": "12", "height": "1", "order": 3, @@ -1368,10 +2010,10 @@ "visible": true }, { - "id": "ui_grp_trend_short_basin_fill", + "id": "ui_grp_tr_states", "type": "ui-group", - "name": "Basin Fill (10 min)", - "page": "ui_page_short_trends", + "name": "Pump state timeline (gantt)", + "page": "ui_page_trends", "width": "12", "height": "1", "order": 4, @@ -1382,13 +2024,13 @@ "visible": true }, { - "id": "ui_grp_trend_long_flow", + "id": "ui_grp_tr_flow", "type": "ui-group", - "name": "Flow (1 hour)", - "page": "ui_page_long_trends", + "name": "Inflow / Outflow / Per-pump flow (1h)", + "page": "ui_page_trends", "width": "12", "height": "1", - "order": 1, + "order": 5, "showTitle": true, "className": "", "groupType": "default", @@ -1396,13 +2038,13 @@ "visible": true }, { - "id": "ui_grp_trend_long_power", + "id": "ui_grp_tr_power", "type": "ui-group", - "name": "Power (1 hour)", - "page": "ui_page_long_trends", + "name": "Per-pump power (1h)", + "page": "ui_page_trends", "width": "12", "height": "1", - "order": 2, + "order": 6, "showTitle": true, "className": "", "groupType": "default", @@ -1410,27 +2052,13 @@ "visible": true }, { - "id": "ui_grp_trend_long_basin_level", + "id": "ui_grp_tr_press", "type": "ui-group", - "name": "Basin Level (1 hour)", - "page": "ui_page_long_trends", + "name": "Per-pump pressures (1h)", + "page": "ui_page_trends", "width": "12", "height": "1", - "order": 3, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_trend_long_basin_fill", - "type": "ui-group", - "name": "Basin Fill (1 hour)", - "page": "ui_page_long_trends", - "width": "12", - "height": "1", - "order": 4, + "order": 7, "showTitle": true, "className": "", "groupType": "default", @@ -1442,38 +2070,38 @@ "type": "comment", "z": "tab_ui", "name": "\ud83d\udcca DASHBOARD UI \u2014 only ui-* widgets here", - "info": "Layout: column 1 = inputs (sliders/switches/buttons) \u2192 link-outs.\nColumn 2 = link-ins from process \u2192 routed to text/gauge/chart widgets.", + "info": "", "x": 640, "y": 20, "wires": [] }, { - "id": "c_ui_demand", + "id": "c_ui_inflow", "type": "comment", "z": "tab_ui", - "name": "\u2500\u2500 Process Demand \u2500\u2500", + "name": "\u2500\u2500 Operator inflow input \u2500\u2500", "info": "", "x": 640, - "y": 100, + "y": 80, "wires": [] }, { - "id": "ui_demand_slider", + "id": "ui_inflow_slider", "type": "ui-slider", "z": "tab_ui", - "group": "ui_grp_demand", - "name": "Manual demand (manual mode only)", - "label": "Manual demand (m\u00b3/h) \u2014 active in manual mode only", + "group": "ui_grp_inflow", + "name": "Inflow baseline", + "label": "Inflow baseline (m\u00b3/h) \u2014 scenarios modulate around this value", "tooltip": "", "order": 1, "width": "0", "height": "0", "passthru": true, "outs": "end", - "topic": "manualDemand", + "topic": "inflowBaseline", "topicType": "str", "min": "0", - "max": "100", + "max": "250", "step": "5.0", "showLabel": true, "showValue": true, @@ -1483,44 +2111,310 @@ "iconStart": "", "iconEnd": "", "x": 120, - "y": 140, + "y": 120, "wires": [ [ - "lout_demand_dash" + "lout_inflow_baseline" ] ] }, { - "id": "lout_demand_dash", + "id": "lout_inflow_baseline", "type": "link out", "z": "tab_ui", - "name": "cmd:Qd", + "name": "cmd:inflow-baseline", "mode": "link", "links": [ - "lin_qd_at_ps" + "lin_inflow_baseline" ], "x": 380, - "y": 140, + "y": 120, "wires": [] }, { - "id": "ui_demand_text", - "type": "ui-text", + "id": "btn_scn_constant", + "type": "ui-button", "z": "tab_ui", - "group": "ui_grp_demand", + "group": "ui_grp_inflow", + "name": "Scenario Constant", + "label": "Constant", "order": 1, "width": "0", "height": "0", - "name": "Manual demand (active in manual mode)", - "label": "Manual demand", - "format": "{{msg.payload}} m\u00b3/h", - "layout": "row-left", + "tooltip": "", + "color": "#ffffff", + "bgcolor": "#0c99d9", + "className": "", + "icon": "horizontal_rule", + "iconPosition": "left", + "payload": "constant", + "payloadType": "str", + "topic": "scenario", + "topicType": "str", + "buttonType": "default", + "x": 120, + "y": 180, + "wires": [ + [ + "wrap_scn_constant" + ] + ] + }, + { + "id": "wrap_scn_constant", + "type": "function", + "z": "tab_ui", + "name": "build scenario constant", + "func": "msg.payload = 'constant';\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 480, + "y": 180, + "wires": [ + [ + "lout_inflow_scenario" + ] + ] + }, + { + "id": "btn_scn_sine", + "type": "ui-button", + "z": "tab_ui", + "group": "ui_grp_inflow", + "name": "Scenario Sine wave", + "label": "Sine wave", + "order": 1, + "width": "0", + "height": "0", + "tooltip": "", + "color": "#ffffff", + "bgcolor": "#16a34a", + "className": "", + "icon": "show_chart", + "iconPosition": "left", + "payload": "sine", + "payloadType": "str", + "topic": "scenario", + "topicType": "str", + "buttonType": "default", + "x": 120, + "y": 230, + "wires": [ + [ + "wrap_scn_sine" + ] + ] + }, + { + "id": "wrap_scn_sine", + "type": "function", + "z": "tab_ui", + "name": "build scenario sine", + "func": "msg.payload = 'sine';\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 480, + "y": 230, + "wires": [ + [ + "lout_inflow_scenario" + ] + ] + }, + { + "id": "btn_scn_diurnal", + "type": "ui-button", + "z": "tab_ui", + "group": "ui_grp_inflow", + "name": "Scenario Diurnal", + "label": "Diurnal", + "order": 1, + "width": "0", + "height": "0", + "tooltip": "", + "color": "#ffffff", + "bgcolor": "#f59e0b", + "className": "", + "icon": "schedule", + "iconPosition": "left", + "payload": "diurnal", + "payloadType": "str", + "topic": "scenario", + "topicType": "str", + "buttonType": "default", + "x": 120, + "y": 280, + "wires": [ + [ + "wrap_scn_diurnal" + ] + ] + }, + { + "id": "wrap_scn_diurnal", + "type": "function", + "z": "tab_ui", + "name": "build scenario diurnal", + "func": "msg.payload = 'diurnal';\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 480, + "y": 280, + "wires": [ + [ + "lout_inflow_scenario" + ] + ] + }, + { + "id": "btn_scn_storm", + "type": "ui-button", + "z": "tab_ui", + "group": "ui_grp_inflow", + "name": "Scenario Storm", + "label": "Storm", + "order": 1, + "width": "0", + "height": "0", + "tooltip": "", + "color": "#ffffff", + "bgcolor": "#dc2626", + "className": "", + "icon": "thunderstorm", + "iconPosition": "left", + "payload": "storm", + "payloadType": "str", + "topic": "scenario", + "topicType": "str", + "buttonType": "default", + "x": 120, + "y": 330, + "wires": [ + [ + "wrap_scn_storm" + ] + ] + }, + { + "id": "wrap_scn_storm", + "type": "function", + "z": "tab_ui", + "name": "build scenario storm", + "func": "msg.payload = 'storm';\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 480, + "y": 330, + "wires": [ + [ + "lout_inflow_scenario" + ] + ] + }, + { + "id": "lout_inflow_scenario", + "type": "link out", + "z": "tab_ui", + "name": "cmd:inflow-scenario", + "mode": "link", + "links": [ + "lin_inflow_scenario" + ], + "x": 640, + "y": 180, + "wires": [] + }, + { + "id": "lin_evt_inflow", + "type": "link in", + "z": "tab_ui", + "name": "evt:inflow", + "links": [ + "lout_evt_inflow" + ], + "x": 900, + "y": 120, + "wires": [ + [ + "dispatch_inflow" + ] + ] + }, + { + "id": "dispatch_inflow", + "type": "function", + "z": "tab_ui", + "name": "dispatch inflow", + "func": "const p = msg.payload || {};\nconst ts = Date.now();\nreturn [\n { payload: (p.scenario || 'constant').toUpperCase() },\n { payload: p.q_h != null ? Number(p.q_h).toFixed(1) + ' m\u00b3/h' : 'n/a' },\n p.q_h != null ? { topic: 'Inflow', payload: Number(p.q_h), timestamp: ts } : null,\n];", + "outputs": 3, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1160, + "y": 120, + "wires": [ + [ + "ui_inflow_scn_text" + ], + [ + "ui_inflow_value_text" + ], + [ + "chart_trend_flow" + ] + ] + }, + { + "id": "ui_inflow_scn_text", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_inflow", + "order": 1, + "width": "0", + "height": "0", + "name": "Active scenario", + "label": "Active scenario", + "format": "{{msg.payload}}", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", - "x": 900, - "y": 140, + "x": 1420, + "y": 120, + "wires": [] + }, + { + "id": "ui_inflow_value_text", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_inflow", + "order": 1, + "width": "0", + "height": "0", + "name": "Live inflow", + "label": "Live inflow", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 1420, + "y": 160, "wires": [] }, { @@ -1530,7 +2424,7 @@ "name": "\u2500\u2500 Mode + Station-wide buttons \u2500\u2500", "info": "", "x": 640, - "y": 320, + "y": 380, "wires": [] }, { @@ -1539,7 +2433,7 @@ "z": "tab_ui", "group": "ui_grp_station", "name": "Station mode", - "label": "Station mode (Auto = level-based control \u00b7 Manual = slider demand)", + "label": "Station mode (Auto = level-based \u00b7 Manual = slider Qd)", "tooltip": "", "order": 1, "width": "0", @@ -1554,13 +2448,13 @@ "onvalue": "levelbased", "onvalueType": "str", "onicon": "auto_mode", - "oncolor": "#0f52a5", + "oncolor": "#0c99d9", "offvalue": "manual", "offvalueType": "str", "officon": "back_hand", "offcolor": "#888888", "x": 120, - "y": 360, + "y": 420, "wires": [ [ "lout_ps_mode_dash" @@ -1577,7 +2471,53 @@ "lin_ps_mode_at_ps" ], "x": 380, - "y": 360, + "y": 420, + "wires": [] + }, + { + "id": "ui_qd_slider", + "type": "ui-slider", + "z": "tab_ui", + "group": "ui_grp_station", + "name": "Manual Qd", + "label": "Manual Qd (m\u00b3/h, manual mode only)", + "tooltip": "", + "order": 1, + "width": "0", + "height": "0", + "passthru": true, + "outs": "end", + "topic": "manualDemand", + "topicType": "str", + "min": "0", + "max": "600", + "step": "5.0", + "showLabel": true, + "showValue": true, + "labelPosition": "top", + "valuePosition": "left", + "thumbLabel": false, + "iconStart": "", + "iconEnd": "", + "x": 120, + "y": 470, + "wires": [ + [ + "lout_qd_dash" + ] + ] + }, + { + "id": "lout_qd_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:Qd", + "mode": "link", + "links": [ + "lin_qd_at_ps" + ], + "x": 380, + "y": 470, "wires": [] }, { @@ -1602,7 +2542,7 @@ "topicType": "str", "buttonType": "default", "x": 120, - "y": 420, + "y": 530, "wires": [ [ "wrap_station_0" @@ -1621,7 +2561,7 @@ "finalize": "", "libs": [], "x": 480, - "y": 420, + "y": 530, "wires": [ [ "lout_cmd_station_startup_dash" @@ -1638,7 +2578,7 @@ "lin_station_start" ], "x": 640, - "y": 420, + "y": 530, "wires": [] }, { @@ -1663,7 +2603,7 @@ "topicType": "str", "buttonType": "default", "x": 120, - "y": 480, + "y": 580, "wires": [ [ "wrap_station_1" @@ -1682,7 +2622,7 @@ "finalize": "", "libs": [], "x": 480, - "y": 480, + "y": 580, "wires": [ [ "lout_cmd_station_shutdown_dash" @@ -1699,7 +2639,7 @@ "lin_station_stop" ], "x": 640, - "y": 480, + "y": 580, "wires": [] }, { @@ -1724,7 +2664,7 @@ "topicType": "str", "buttonType": "default", "x": 120, - "y": 540, + "y": 630, "wires": [ [ "wrap_station_2" @@ -1743,7 +2683,7 @@ "finalize": "", "libs": [], "x": 480, - "y": 540, + "y": 630, "wires": [ [ "lout_cmd_station_estop_dash" @@ -1760,117 +2700,16 @@ "lin_station_estop" ], "x": 640, - "y": 540, + "y": 630, "wires": [] }, { - "id": "c_ui_mgc_ps", + "id": "c_ui_basin", "type": "comment", "z": "tab_ui", - "name": "\u2500\u2500 MGC + Basin overview \u2500\u2500", + "name": "\u2500\u2500 Basin realtime (gauges + text) \u2500\u2500", "info": "", "x": 640, - "y": 600, - "wires": [] - }, - { - "id": "lin_evt_mgc_dash", - "type": "link in", - "z": "tab_ui", - "name": "evt:mgc", - "links": [ - "lout_evt_mgc" - ], - "x": 120, - "y": 640, - "wires": [ - [ - "dispatch_mgc" - ] - ] - }, - { - "id": "dispatch_mgc", - "type": "function", - "z": "tab_ui", - "name": "dispatch MGC", - "func": "const p = msg.payload || {};\nreturn [\n {payload: String(p.totalFlow || 'n/a')},\n {payload: String(p.totalPower || 'n/a')},\n {payload: String(p.efficiency || 'n/a')},\n];", - "outputs": 3, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 380, - "y": 640, - "wires": [ - [ - "ui_mgc_total_flow" - ], - [ - "ui_mgc_total_power" - ], - [ - "ui_mgc_eff" - ] - ] - }, - { - "id": "ui_mgc_total_flow", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_mgc", - "order": 1, - "width": "0", - "height": "0", - "name": "MGC total flow", - "label": "Total flow", - "format": "{{msg.payload}}", - "layout": "row-left", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 640, - "wires": [] - }, - { - "id": "ui_mgc_total_power", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_mgc", - "order": 1, - "width": "0", - "height": "0", - "name": "MGC total power", - "label": "Total power", - "format": "{{msg.payload}}", - "layout": "row-left", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 670, - "wires": [] - }, - { - "id": "ui_mgc_eff", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_mgc", - "order": 1, - "width": "0", - "height": "0", - "name": "MGC efficiency", - "label": "Group efficiency", - "format": "{{msg.payload}}", - "layout": "row-left", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, "y": 700, "wires": [] }, @@ -1883,7 +2722,7 @@ "lout_evt_ps" ], "x": 120, - "y": 760, + "y": 740, "wires": [ [ "dispatch_ps" @@ -1895,14 +2734,14 @@ "type": "function", "z": "tab_ui", "name": "dispatch PS", - "func": "const p = msg.payload || {};\nconst ts = Date.now();\nreturn [\n {payload: String(p.direction || 'steady')},\n {payload: String(p.level || 'n/a')},\n {payload: String(p.volume || 'n/a')},\n {payload: String(p.fillPct || 'n/a')},\n {payload: String(p.netFlow || 'n/a')},\n {payload: String(p.timeLeft || 'n/a')},\n {payload: String(p.qIn || 'n/a')},\n // Trend numerics\n p.fillPctNum != null ? {topic: 'Basin fill', payload: p.fillPctNum, timestamp: ts} : null,\n p.levelNum != null ? {topic: 'Basin level', payload: p.levelNum, timestamp: ts} : null,\n p.netFlowNum != null ? {topic: 'Net flow', payload: p.netFlowNum,timestamp: ts} : null,\n p.percControl != null ? {topic: 'PS demand', payload: p.percControl, timestamp: ts} : null,\n p.qInNum != null ? {topic: 'Inflow', payload: p.qInNum, timestamp: ts} : null,\n p.qOutNum != null ? {topic: 'Outflow', payload: p.qOutNum, timestamp: ts} : null,\n];", - "outputs": 13, + "func": "const p = msg.payload || {};\nconst ts = Date.now();\n// \u0394Q = inflow \u2212 outflow in m\u00b3/h (positive = filling).\nconst dQ = (p.qInNum != null && p.qOutNum != null)\n ? p.qInNum - p.qOutNum : null;\n// Demand text formatting.\nconst demandStr = p.percControl != null\n ? Number(p.percControl).toFixed(0) + '%' : 'n/a';\nreturn [\n { payload: String(p.direction || 'steady') },\n { payload: String(p.level || 'n/a') },\n { payload: String(p.volume || 'n/a') },\n { payload: String(p.fillPct || 'n/a') },\n { payload: String(p.netFlow || 'n/a') },\n { payload: String(p.timeLeft || 'n/a') },\n { payload: String(p.qIn || 'n/a') },\n { payload: String(p.qOut || 'n/a') },\n { payload: String(p.safetyState || 'normal') },\n { payload: demandStr },\n p.levelNum != null ? { payload: p.levelNum } : null,\n p.fillPctNum != null ? { payload: p.fillPctNum } : null,\n p.percControl != null ? { payload: p.percControl } : null,\n p.levelNum != null ? { topic: 'Basin level', payload: p.levelNum, timestamp: ts } : null,\n p.fillPctNum != null ? { topic: 'Fill %', payload: p.fillPctNum, timestamp: ts } : null,\n p.qOutNum != null ? { topic: 'Outflow', payload: p.qOutNum, timestamp: ts } : null,\n p.percControl != null ? { topic: 'PS demand', payload: p.percControl, timestamp: ts } : null,\n dQ != null ? { topic: '\u0394Q', payload: dQ, timestamp: ts } : null,\n];", + "outputs": 18, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 380, - "y": 760, + "y": 740, "wires": [ [ "ui_ps_direction" @@ -1926,32 +2765,37 @@ "ui_ps_qin" ], [ - "trend_short_fill", - "trend_long_fill", - "gauge_ps_fill", - "gauge_ps_fill_long" + "ui_ps_qout" ], [ - "trend_short_level", - "trend_long_level", - "gauge_ps_level", - "gauge_ps_level_long" + "ui_ps_safety" ], [ - "trend_short_flow", - "trend_long_flow" + "ui_ps_demand" ], [ - "trend_short_fill", - "trend_long_fill" + "gauge_basin_level" ], [ - "trend_short_flow", - "trend_long_flow" + "gauge_basin_fill" ], [ - "trend_short_flow", - "trend_long_flow" + "gauge_ps_demand" + ], + [ + "chart_trend_basin" + ], + [ + "chart_trend_basin" + ], + [ + "chart_trend_flow" + ], + [ + "chart_trend_demand" + ], + [ + "chart_trend_dq" ] ] }, @@ -1959,34 +2803,54 @@ "id": "ui_ps_direction", "type": "ui-text", "z": "tab_ui", - "group": "ui_grp_ps", + "group": "ui_grp_basin", "order": 1, "width": "0", "height": "0", - "name": "PS direction", + "name": "Direction", "label": "Direction", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 760, + "y": 740, "wires": [] }, { "id": "ui_ps_level", "type": "ui-text", "z": "tab_ui", - "group": "ui_grp_ps", + "group": "ui_grp_basin", "order": 1, "width": "0", "height": "0", - "name": "PS level", + "name": "Basin level", "label": "Basin level", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 770, + "wires": [] + }, + { + "id": "ui_ps_volume", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_basin", + "order": 1, + "width": "0", + "height": "0", + "name": "Basin volume", + "label": "Basin volume", + "format": "{{msg.payload}}", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, @@ -1995,58 +2859,78 @@ "y": 800, "wires": [] }, - { - "id": "ui_ps_volume", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_ps", - "order": 1, - "width": "0", - "height": "0", - "name": "PS volume", - "label": "Basin volume", - "format": "{{msg.payload}}", - "layout": "row-left", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 840, - "wires": [] - }, { "id": "ui_ps_fill", "type": "ui-text", "z": "tab_ui", - "group": "ui_grp_ps", + "group": "ui_grp_basin", "order": 1, "width": "0", "height": "0", - "name": "PS fill %", - "label": "Fill level", + "name": "Fill %", + "label": "Fill %", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 880, + "y": 830, "wires": [] }, { "id": "ui_ps_netflow", "type": "ui-text", "z": "tab_ui", - "group": "ui_grp_ps", + "group": "ui_grp_basin", "order": 1, "width": "0", "height": "0", - "name": "PS net flow", + "name": "Net flow", "label": "Net flow", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 860, + "wires": [] + }, + { + "id": "ui_ps_timeleft", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_basin", + "order": 1, + "width": "0", + "height": "0", + "name": "Time left", + "label": "Time to full/empty", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 890, + "wires": [] + }, + { + "id": "ui_ps_qin", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_basin", + "order": 1, + "width": "0", + "height": "0", + "name": "Inflow", + "label": "Inflow", + "format": "{{msg.payload}}", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, @@ -2056,43 +2940,407 @@ "wires": [] }, { - "id": "ui_ps_timeleft", + "id": "ui_ps_qout", "type": "ui-text", "z": "tab_ui", - "group": "ui_grp_ps", + "group": "ui_grp_basin", "order": 1, "width": "0", "height": "0", - "name": "PS time left", - "label": "Time to full/empty", + "name": "Outflow", + "label": "Outflow", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 960, + "y": 950, "wires": [] }, { - "id": "ui_ps_qin", + "id": "ui_ps_safety", "type": "ui-text", "z": "tab_ui", - "group": "ui_grp_ps", + "group": "ui_grp_basin", "order": 1, "width": "0", "height": "0", - "name": "PS Qin", - "label": "Inflow", + "name": "Safety", + "label": "Safety state", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1000, + "y": 980, + "wires": [] + }, + { + "id": "ui_ps_demand", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_basin", + "order": 1, + "width": "0", + "height": "0", + "name": "PS demand", + "label": "Process demand", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 1010, + "wires": [] + }, + { + "id": "gauge_basin_level", + "type": "ui-gauge", + "z": "tab_ui", + "group": "ui_grp_basin", + "name": "Basin level gauge", + "gtype": "gauge-tank", + "gstyle": "Rounded", + "title": "Level", + "units": "m", + "prefix": "", + "suffix": " m", + "min": 0, + "max": 4.0, + "segments": [ + { + "color": "#f44336", + "from": 0 + }, + { + "color": "#ff9800", + "from": 1.0 + }, + { + "color": "#2196f3", + "from": 2.0 + }, + { + "color": "#ff9800", + "from": 3.5 + }, + { + "color": "#f44336", + "from": 3.8 + } + ], + "width": 3, + "height": 4, + "order": 10, + "icon": "", + "sizeGauge": 20, + "sizeGap": 2, + "sizeSegments": 10, + "x": 900, + "y": 740, + "wires": [] + }, + { + "id": "gauge_basin_fill", + "type": "ui-gauge", + "z": "tab_ui", + "group": "ui_grp_basin", + "name": "Basin fill gauge", + "gtype": "gauge-34", + "gstyle": "Rounded", + "title": "Fill", + "units": "%", + "prefix": "", + "suffix": "%", + "min": 0, + "max": 100, + "segments": [ + { + "color": "#f44336", + "from": 0 + }, + { + "color": "#ff9800", + "from": 10 + }, + { + "color": "#4caf50", + "from": 30 + }, + { + "color": "#ff9800", + "from": 80 + }, + { + "color": "#f44336", + "from": 95 + } + ], + "width": 3, + "height": 4, + "order": 11, + "icon": "water_drop", + "sizeGauge": 20, + "sizeGap": 2, + "sizeSegments": 10, + "x": 900, + "y": 800, + "wires": [] + }, + { + "id": "gauge_ps_demand", + "type": "ui-gauge", + "z": "tab_ui", + "group": "ui_grp_basin", + "name": "PS demand gauge", + "gtype": "gauge-34", + "gstyle": "Rounded", + "title": "PS demand", + "units": "%", + "prefix": "", + "suffix": "%", + "min": 0, + "max": 100, + "segments": [ + { + "color": "#cccccc", + "from": 0 + }, + { + "color": "#0c99d9", + "from": 5 + }, + { + "color": "#16a34a", + "from": 30 + }, + { + "color": "#f59e0b", + "from": 70 + }, + { + "color": "#dc2626", + "from": 95 + } + ], + "width": 3, + "height": 4, + "order": 12, + "icon": "speed", + "sizeGauge": 20, + "sizeGap": 2, + "sizeSegments": 10, + "x": 900, + "y": 860, + "wires": [] + }, + { + "id": "c_ui_mgc", + "type": "comment", + "z": "tab_ui", + "name": "\u2500\u2500 MGC realtime \u2500\u2500", + "info": "", + "x": 640, + "y": 1080, + "wires": [] + }, + { + "id": "lin_evt_mgc_dash", + "type": "link in", + "z": "tab_ui", + "name": "evt:mgc", + "links": [ + "lout_evt_mgc" + ], + "x": 120, + "y": 1120, + "wires": [ + [ + "dispatch_mgc" + ] + ] + }, + { + "id": "dispatch_mgc", + "type": "function", + "z": "tab_ui", + "name": "dispatch MGC", + "func": "const p = msg.payload || {};\nreturn [\n { payload: String(p.totalFlow || 'n/a') },\n { payload: String(p.totalPower || 'n/a') },\n { payload: String(p.efficiency || 'n/a') },\n p.totalFlowNum != null ? { payload: p.totalFlowNum } : null,\n p.totalPowerNum != null ? { payload: p.totalPowerNum } : null,\n];", + "outputs": 5, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 380, + "y": 1120, + "wires": [ + [ + "ui_mgc_total_flow" + ], + [ + "ui_mgc_total_power" + ], + [ + "ui_mgc_eff" + ], + [ + "gauge_mgc_flow" + ], + [ + "gauge_mgc_power" + ] + ] + }, + { + "id": "ui_mgc_total_flow", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_mgc", + "order": 1, + "width": "0", + "height": "0", + "name": "MGC total flow", + "label": "Total flow", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 1120, + "wires": [] + }, + { + "id": "ui_mgc_total_power", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_mgc", + "order": 1, + "width": "0", + "height": "0", + "name": "MGC total power", + "label": "Total power", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 1150, + "wires": [] + }, + { + "id": "ui_mgc_eff", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_mgc", + "order": 1, + "width": "0", + "height": "0", + "name": "MGC efficiency", + "label": "Group efficiency", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 1180, + "wires": [] + }, + { + "id": "gauge_mgc_flow", + "type": "ui-gauge", + "z": "tab_ui", + "group": "ui_grp_mgc", + "name": "MGC total flow gauge", + "gtype": "gauge-34", + "gstyle": "Rounded", + "title": "Total flow", + "units": "m\u00b3/h", + "prefix": "", + "suffix": " m\u00b3/h", + "min": 0, + "max": 600, + "segments": [ + { + "color": "#cccccc", + "from": 0 + }, + { + "color": "#0c99d9", + "from": 50 + }, + { + "color": "#16a34a", + "from": 200 + }, + { + "color": "#f59e0b", + "from": 500 + } + ], + "width": 3, + "height": 4, + "order": 10, + "icon": "", + "sizeGauge": 20, + "sizeGap": 2, + "sizeSegments": 10, + "x": 900, + "y": 1120, + "wires": [] + }, + { + "id": "gauge_mgc_power", + "type": "ui-gauge", + "z": "tab_ui", + "group": "ui_grp_mgc", + "name": "MGC total power gauge", + "gtype": "gauge-34", + "gstyle": "Rounded", + "title": "Total power", + "units": "kW", + "prefix": "", + "suffix": " kW", + "min": 0, + "max": 30, + "segments": [ + { + "color": "#cccccc", + "from": 0 + }, + { + "color": "#0c99d9", + "from": 1 + }, + { + "color": "#16a34a", + "from": 5 + }, + { + "color": "#f59e0b", + "from": 20 + } + ], + "width": 3, + "height": 4, + "order": 11, + "icon": "", + "sizeGauge": 20, + "sizeGap": 2, + "sizeSegments": 10, + "x": 900, + "y": 1180, "wires": [] }, { @@ -2102,7 +3350,7 @@ "name": "\u2500\u2500 Pump A \u2500\u2500", "info": "", "x": 640, - "y": 1000, + "y": 1340, "wires": [] }, { @@ -2114,7 +3362,7 @@ "lout_evt_pump_a" ], "x": 120, - "y": 1040, + "y": 1380, "wires": [ [ "dispatch_pump_a" @@ -2126,14 +3374,14 @@ "type": "function", "z": "tab_ui", "name": "dispatch Pump A", - "func": "const p = msg.payload || {};\nconst ts = Date.now();\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump A', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump A', payload: p.powerNum, timestamp: ts} : null,\n];", - "outputs": 9, + "func": "const p = msg.payload || {};\nconst ts = Date.now();\nconst OFF = 0;\nfunction stateNum(s) {\n switch (s) {\n case 'operational': return OFF + 2;\n case 'starting':\n case 'warmingup': return OFF + 1;\n case 'stopping': return OFF + 1.5;\n case 'coolingdown': return OFF + 0.5;\n default: return OFF;\n }\n}\nconst sNum = p.state ? stateNum(p.state) : null;\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump A', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump A', payload: p.powerNum, timestamp: ts} : null,\n p.pUpNum != null ? {topic: 'Pump A up', payload: p.pUpNum, timestamp: ts} : null,\n p.pDnNum != null ? {topic: 'Pump A dn', payload: p.pDnNum, timestamp: ts} : null,\n sNum != null ? {topic: 'Pump A state', payload: sNum, timestamp: ts} : null,\n];", + "outputs": 12, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 380, - "y": 1040, + "y": 1380, "wires": [ [ "ui_pump_a_state" @@ -2157,12 +3405,19 @@ "ui_pump_a_pDn" ], [ - "trend_short_flow", - "trend_long_flow" + "chart_trend_flow" ], [ - "trend_short_power", - "trend_long_power" + "chart_trend_power" + ], + [ + "chart_trend_pressure" + ], + [ + "chart_trend_pressure" + ], + [ + "chart_trend_states" ] ] }, @@ -2177,13 +3432,13 @@ "name": "Pump A State", "label": "State", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1040, + "y": 1380, "wires": [] }, { @@ -2197,13 +3452,13 @@ "name": "Pump A Mode", "label": "Mode", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1080, + "y": 1410, "wires": [] }, { @@ -2217,13 +3472,13 @@ "name": "Pump A Controller %", "label": "Controller %", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1120, + "y": 1440, "wires": [] }, { @@ -2237,13 +3492,13 @@ "name": "Pump A Flow", "label": "Flow", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1160, + "y": 1470, "wires": [] }, { @@ -2257,13 +3512,13 @@ "name": "Pump A Power", "label": "Power", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1200, + "y": 1500, "wires": [] }, { @@ -2277,13 +3532,13 @@ "name": "Pump A p Upstream", "label": "p Upstream", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1240, + "y": 1530, "wires": [] }, { @@ -2297,13 +3552,13 @@ "name": "Pump A p Downstream", "label": "p Downstream", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1280, + "y": 1560, "wires": [] }, { @@ -2332,7 +3587,7 @@ "iconStart": "", "iconEnd": "", "x": 120, - "y": 1280, + "y": 1620, "wires": [ [ "lout_setpoint_pump_a_dash" @@ -2349,7 +3604,7 @@ "lin_setpoint_pump_a" ], "x": 380, - "y": 1280, + "y": 1620, "wires": [] }, { @@ -2374,7 +3629,7 @@ "topicType": "str", "buttonType": "default", "x": 120, - "y": 1330, + "y": 1670, "wires": [ [ "wrap_pump_a_start" @@ -2393,7 +3648,7 @@ "finalize": "", "libs": [], "x": 480, - "y": 1330, + "y": 1670, "wires": [ [ "lout_seq_pump_a_dash" @@ -2422,7 +3677,7 @@ "topicType": "str", "buttonType": "default", "x": 120, - "y": 1380, + "y": 1720, "wires": [ [ "wrap_pump_a_stop" @@ -2441,7 +3696,7 @@ "finalize": "", "libs": [], "x": 480, - "y": 1380, + "y": 1720, "wires": [ [ "lout_seq_pump_a_dash" @@ -2458,7 +3713,7 @@ "lin_seq_pump_a" ], "x": 640, - "y": 1355, + "y": 1695, "wires": [] }, { @@ -2468,7 +3723,7 @@ "name": "\u2500\u2500 Pump B \u2500\u2500", "info": "", "x": 640, - "y": 1400, + "y": 1820, "wires": [] }, { @@ -2480,7 +3735,7 @@ "lout_evt_pump_b" ], "x": 120, - "y": 1440, + "y": 1860, "wires": [ [ "dispatch_pump_b" @@ -2492,14 +3747,14 @@ "type": "function", "z": "tab_ui", "name": "dispatch Pump B", - "func": "const p = msg.payload || {};\nconst ts = Date.now();\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump B', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump B', payload: p.powerNum, timestamp: ts} : null,\n];", - "outputs": 9, + "func": "const p = msg.payload || {};\nconst ts = Date.now();\nconst OFF = 3;\nfunction stateNum(s) {\n switch (s) {\n case 'operational': return OFF + 2;\n case 'starting':\n case 'warmingup': return OFF + 1;\n case 'stopping': return OFF + 1.5;\n case 'coolingdown': return OFF + 0.5;\n default: return OFF;\n }\n}\nconst sNum = p.state ? stateNum(p.state) : null;\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump B', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump B', payload: p.powerNum, timestamp: ts} : null,\n p.pUpNum != null ? {topic: 'Pump B up', payload: p.pUpNum, timestamp: ts} : null,\n p.pDnNum != null ? {topic: 'Pump B dn', payload: p.pDnNum, timestamp: ts} : null,\n sNum != null ? {topic: 'Pump B state', payload: sNum, timestamp: ts} : null,\n];", + "outputs": 12, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 380, - "y": 1440, + "y": 1860, "wires": [ [ "ui_pump_b_state" @@ -2523,12 +3778,19 @@ "ui_pump_b_pDn" ], [ - "trend_short_flow", - "trend_long_flow" + "chart_trend_flow" ], [ - "trend_short_power", - "trend_long_power" + "chart_trend_power" + ], + [ + "chart_trend_pressure" + ], + [ + "chart_trend_pressure" + ], + [ + "chart_trend_states" ] ] }, @@ -2543,13 +3805,13 @@ "name": "Pump B State", "label": "State", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1440, + "y": 1860, "wires": [] }, { @@ -2563,13 +3825,13 @@ "name": "Pump B Mode", "label": "Mode", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1480, + "y": 1890, "wires": [] }, { @@ -2583,13 +3845,13 @@ "name": "Pump B Controller %", "label": "Controller %", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1520, + "y": 1920, "wires": [] }, { @@ -2603,13 +3865,13 @@ "name": "Pump B Flow", "label": "Flow", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1560, + "y": 1950, "wires": [] }, { @@ -2623,13 +3885,13 @@ "name": "Pump B Power", "label": "Power", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1600, + "y": 1980, "wires": [] }, { @@ -2643,13 +3905,13 @@ "name": "Pump B p Upstream", "label": "p Upstream", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1640, + "y": 2010, "wires": [] }, { @@ -2663,13 +3925,13 @@ "name": "Pump B p Downstream", "label": "p Downstream", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1680, + "y": 2040, "wires": [] }, { @@ -2698,7 +3960,7 @@ "iconStart": "", "iconEnd": "", "x": 120, - "y": 1680, + "y": 2100, "wires": [ [ "lout_setpoint_pump_b_dash" @@ -2715,7 +3977,7 @@ "lin_setpoint_pump_b" ], "x": 380, - "y": 1680, + "y": 2100, "wires": [] }, { @@ -2740,7 +4002,7 @@ "topicType": "str", "buttonType": "default", "x": 120, - "y": 1730, + "y": 2150, "wires": [ [ "wrap_pump_b_start" @@ -2759,7 +4021,7 @@ "finalize": "", "libs": [], "x": 480, - "y": 1730, + "y": 2150, "wires": [ [ "lout_seq_pump_b_dash" @@ -2788,7 +4050,7 @@ "topicType": "str", "buttonType": "default", "x": 120, - "y": 1780, + "y": 2200, "wires": [ [ "wrap_pump_b_stop" @@ -2807,7 +4069,7 @@ "finalize": "", "libs": [], "x": 480, - "y": 1780, + "y": 2200, "wires": [ [ "lout_seq_pump_b_dash" @@ -2824,7 +4086,7 @@ "lin_seq_pump_b" ], "x": 640, - "y": 1755, + "y": 2175, "wires": [] }, { @@ -2834,7 +4096,7 @@ "name": "\u2500\u2500 Pump C \u2500\u2500", "info": "", "x": 640, - "y": 1800, + "y": 2300, "wires": [] }, { @@ -2846,7 +4108,7 @@ "lout_evt_pump_c" ], "x": 120, - "y": 1840, + "y": 2340, "wires": [ [ "dispatch_pump_c" @@ -2858,14 +4120,14 @@ "type": "function", "z": "tab_ui", "name": "dispatch Pump C", - "func": "const p = msg.payload || {};\nconst ts = Date.now();\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump C', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump C', payload: p.powerNum, timestamp: ts} : null,\n];", - "outputs": 9, + "func": "const p = msg.payload || {};\nconst ts = Date.now();\nconst OFF = 6;\nfunction stateNum(s) {\n switch (s) {\n case 'operational': return OFF + 2;\n case 'starting':\n case 'warmingup': return OFF + 1;\n case 'stopping': return OFF + 1.5;\n case 'coolingdown': return OFF + 0.5;\n default: return OFF;\n }\n}\nconst sNum = p.state ? stateNum(p.state) : null;\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump C', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump C', payload: p.powerNum, timestamp: ts} : null,\n p.pUpNum != null ? {topic: 'Pump C up', payload: p.pUpNum, timestamp: ts} : null,\n p.pDnNum != null ? {topic: 'Pump C dn', payload: p.pDnNum, timestamp: ts} : null,\n sNum != null ? {topic: 'Pump C state', payload: sNum, timestamp: ts} : null,\n];", + "outputs": 12, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 380, - "y": 1840, + "y": 2340, "wires": [ [ "ui_pump_c_state" @@ -2889,12 +4151,19 @@ "ui_pump_c_pDn" ], [ - "trend_short_flow", - "trend_long_flow" + "chart_trend_flow" ], [ - "trend_short_power", - "trend_long_power" + "chart_trend_power" + ], + [ + "chart_trend_pressure" + ], + [ + "chart_trend_pressure" + ], + [ + "chart_trend_states" ] ] }, @@ -2909,13 +4178,13 @@ "name": "Pump C State", "label": "State", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1840, + "y": 2340, "wires": [] }, { @@ -2929,13 +4198,13 @@ "name": "Pump C Mode", "label": "Mode", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1880, + "y": 2370, "wires": [] }, { @@ -2949,13 +4218,13 @@ "name": "Pump C Controller %", "label": "Controller %", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1920, + "y": 2400, "wires": [] }, { @@ -2969,13 +4238,13 @@ "name": "Pump C Flow", "label": "Flow", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 1960, + "y": 2430, "wires": [] }, { @@ -2989,13 +4258,13 @@ "name": "Pump C Power", "label": "Power", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 2000, + "y": 2460, "wires": [] }, { @@ -3009,13 +4278,13 @@ "name": "Pump C p Upstream", "label": "p Upstream", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 2040, + "y": 2490, "wires": [] }, { @@ -3029,13 +4298,13 @@ "name": "Pump C p Downstream", "label": "p Downstream", "format": "{{msg.payload}}", - "layout": "row-left", + "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#000000", "x": 640, - "y": 2080, + "y": 2520, "wires": [] }, { @@ -3064,7 +4333,7 @@ "iconStart": "", "iconEnd": "", "x": 120, - "y": 2080, + "y": 2580, "wires": [ [ "lout_setpoint_pump_c_dash" @@ -3081,7 +4350,7 @@ "lin_setpoint_pump_c" ], "x": 380, - "y": 2080, + "y": 2580, "wires": [] }, { @@ -3106,7 +4375,7 @@ "topicType": "str", "buttonType": "default", "x": 120, - "y": 2130, + "y": 2630, "wires": [ [ "wrap_pump_c_start" @@ -3125,7 +4394,7 @@ "finalize": "", "libs": [], "x": 480, - "y": 2130, + "y": 2630, "wires": [ [ "lout_seq_pump_c_dash" @@ -3154,7 +4423,7 @@ "topicType": "str", "buttonType": "default", "x": 120, - "y": 2180, + "y": 2680, "wires": [ [ "wrap_pump_c_stop" @@ -3173,7 +4442,7 @@ "finalize": "", "libs": [], "x": 480, - "y": 2180, + "y": 2680, "wires": [ [ "lout_seq_pump_c_dash" @@ -3190,26 +4459,26 @@ "lin_seq_pump_c" ], "x": 640, - "y": 2155, + "y": 2655, "wires": [] }, { "id": "c_ui_trends", "type": "comment", "z": "tab_ui", - "name": "\u2500\u2500 Trend charts \u2500\u2500 (feed to 4 charts on 2 pages)", - "info": "Short-term (10 min) and long-term (1 h) trends share the same feed.\nEach chart on its own page.", + "name": "\u2500\u2500 Trend charts (1h rolling) \u2500\u2500", + "info": "", "x": 640, - "y": 2280, + "y": 2840, "wires": [] }, { - "id": "trend_short_flow", + "id": "chart_trend_basin", "type": "ui-chart", "z": "tab_ui", - "group": "ui_grp_trend_short_flow", - "name": "Flow per pump \u2014 10 min", - "label": "Flow per pump", + "group": "ui_grp_tr_basin", + "name": "Basin level + fill %", + "label": "Basin level + fill", "order": 1, "chartType": "line", "interpolation": "linear", @@ -3223,14 +4492,14 @@ "xAxisFormatType": "auto", "xmin": "", "xmax": "", - "yAxisLabel": "m\u00b3/h", + "yAxisLabel": "m / %", "yAxisProperty": "payload", "yAxisPropertyType": "msg", "ymin": "", "ymax": "", - "removeOlder": "10", + "removeOlder": "60", "removeOlderUnit": "60", - "removeOlderPoints": "300", + "removeOlderPoints": "3600", "action": "append", "stackSeries": false, "pointShape": "circle", @@ -3260,18 +4529,18 @@ "height": 8, "className": "", "x": 900, - "y": 2320, + "y": 2880, "wires": [ [] ] }, { - "id": "trend_short_power", + "id": "chart_trend_demand", "type": "ui-chart", "z": "tab_ui", - "group": "ui_grp_trend_short_power", - "name": "Power per pump \u2014 10 min", - "label": "Power per pump", + "group": "ui_grp_tr_demand", + "name": "PS process demand %", + "label": "PS demand", "order": 1, "chartType": "line", "interpolation": "linear", @@ -3285,14 +4554,14 @@ "xAxisFormatType": "auto", "xmin": "", "xmax": "", - "yAxisLabel": "kW", + "yAxisLabel": "%", "yAxisProperty": "payload", "yAxisPropertyType": "msg", - "ymin": "", - "ymax": "", - "removeOlder": "10", + "ymin": "0", + "ymax": "110", + "removeOlder": "60", "removeOlderUnit": "60", - "removeOlderPoints": "300", + "removeOlderPoints": "3600", "action": "append", "stackSeries": false, "pointShape": "circle", @@ -3319,21 +4588,21 @@ ], "gridColorDefault": true, "width": 12, - "height": 8, + "height": 6, "className": "", "x": 900, - "y": 2400, + "y": 2920, "wires": [ [] ] }, { - "id": "trend_long_flow", + "id": "chart_trend_dq", "type": "ui-chart", "z": "tab_ui", - "group": "ui_grp_trend_long_flow", - "name": "Flow per pump \u2014 1 hour", - "label": "Flow per pump", + "group": "ui_grp_tr_dq", + "name": "\u0394Q \u2014 inflow \u2212 outflow", + "label": "\u0394Q", "order": 1, "chartType": "line", "interpolation": "linear", @@ -3354,7 +4623,131 @@ "ymax": "", "removeOlder": "60", "removeOlderUnit": "60", - "removeOlderPoints": "1800", + "removeOlderPoints": "3600", + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "showLegend": true, + "bins": 10, + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "width": 12, + "height": 6, + "className": "", + "x": 900, + "y": 2940, + "wires": [ + [] + ] + }, + { + "id": "chart_trend_states", + "type": "ui-chart", + "z": "tab_ui", + "group": "ui_grp_tr_states", + "name": "Pump state timeline", + "label": "Pump states (A=0-2, B=3-5, C=6-8)", + "order": 1, + "chartType": "line", + "interpolation": "step", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "", + "xAxisType": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "xmin": "", + "xmax": "", + "yAxisLabel": "A B C tracks", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "ymin": "-0.5", + "ymax": "8.5", + "removeOlder": "60", + "removeOlderUnit": "60", + "removeOlderPoints": "3600", + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "showLegend": true, + "bins": 10, + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "width": 12, + "height": 6, + "className": "", + "x": 900, + "y": 2960, + "wires": [ + [] + ] + }, + { + "id": "chart_trend_flow", + "type": "ui-chart", + "z": "tab_ui", + "group": "ui_grp_tr_flow", + "name": "Inflow / Outflow / Per-pump flow", + "label": "Flows", + "order": 1, + "chartType": "line", + "interpolation": "linear", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "", + "xAxisType": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "xmin": "", + "xmax": "", + "yAxisLabel": "m\u00b3/h", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "ymin": "", + "ymax": "", + "removeOlder": "60", + "removeOlderUnit": "60", + "removeOlderPoints": "3600", "action": "append", "stackSeries": false, "pointShape": "circle", @@ -3384,18 +4777,18 @@ "height": 8, "className": "", "x": 900, - "y": 2480, + "y": 2960, "wires": [ [] ] }, { - "id": "trend_long_power", + "id": "chart_trend_power", "type": "ui-chart", "z": "tab_ui", - "group": "ui_grp_trend_long_power", - "name": "Power per pump \u2014 1 hour", - "label": "Power per pump", + "group": "ui_grp_tr_power", + "name": "Per-pump power", + "label": "Power", "order": 1, "chartType": "line", "interpolation": "linear", @@ -3416,7 +4809,7 @@ "ymax": "", "removeOlder": "60", "removeOlderUnit": "60", - "removeOlderPoints": "1800", + "removeOlderPoints": "3600", "action": "append", "stackSeries": false, "pointShape": "circle", @@ -3446,18 +4839,18 @@ "height": 8, "className": "", "x": 900, - "y": 2560, + "y": 3040, "wires": [ [] ] }, { - "id": "trend_short_level", + "id": "chart_trend_pressure", "type": "ui-chart", "z": "tab_ui", - "group": "ui_grp_trend_short_basin_level", - "name": "Basin Level \u2014 10 min", - "label": "Basin Level + Net Flow", + "group": "ui_grp_tr_press", + "name": "Per-pump up/dn pressure", + "label": "Pressure", "order": 1, "chartType": "line", "interpolation": "linear", @@ -3471,232 +4864,14 @@ "xAxisFormatType": "auto", "xmin": "", "xmax": "", - "yAxisLabel": "m", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "ymin": "", - "ymax": "", - "removeOlder": "10", - "removeOlderUnit": "60", - "removeOlderPoints": "300", - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "showLegend": true, - "bins": 10, - "colors": [ - "#0095FF", - "#FF0000", - "#FF7F0E", - "#2CA02C", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "width": 8, - "height": 8, - "className": "", - "x": 900, - "y": 2640, - "wires": [ - [] - ] - }, - { - "id": "trend_short_fill", - "type": "ui-chart", - "z": "tab_ui", - "group": "ui_grp_trend_short_basin_fill", - "name": "Basin Fill \u2014 10 min", - "label": "Basin Fill", - "order": 1, - "chartType": "line", - "interpolation": "linear", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "xmin": "", - "xmax": "", - "yAxisLabel": "%", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "ymin": "0", - "ymax": "100", - "removeOlder": "10", - "removeOlderUnit": "60", - "removeOlderPoints": "300", - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "showLegend": true, - "bins": 10, - "colors": [ - "#0095FF", - "#FF0000", - "#FF7F0E", - "#2CA02C", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "width": 8, - "height": 6, - "className": "", - "x": 900, - "y": 2720, - "wires": [ - [] - ] - }, - { - "id": "gauge_ps_level", - "type": "ui-gauge", - "z": "tab_ui", - "group": "ui_grp_trend_short_basin_level", - "name": "Basin level gauge (short)", - "gtype": "gauge-tank", - "gstyle": "Rounded", - "title": "Level", - "units": "m", - "prefix": "", - "suffix": " m", - "min": 0, - "max": 4, - "segments": [ - { - "color": "#f44336", - "from": 0 - }, - { - "color": "#ff9800", - "from": 1.0 - }, - { - "color": "#2196f3", - "from": 2.0 - }, - { - "color": "#ff9800", - "from": 3.5 - }, - { - "color": "#f44336", - "from": 3.8 - } - ], - "width": 2, - "height": 5, - "order": 2, - "icon": "", - "sizeGauge": 20, - "sizeGap": 2, - "sizeSegments": 10, - "x": 1160, - "y": 2640, - "wires": [] - }, - { - "id": "gauge_ps_fill", - "type": "ui-gauge", - "z": "tab_ui", - "group": "ui_grp_trend_short_basin_fill", - "name": "Basin fill gauge (short)", - "gtype": "gauge-34", - "gstyle": "Rounded", - "title": "Fill", - "units": "%", - "prefix": "", - "suffix": "%", - "min": 0, - "max": 100, - "segments": [ - { - "color": "#f44336", - "from": 0 - }, - { - "color": "#ff9800", - "from": 10 - }, - { - "color": "#4caf50", - "from": 30 - }, - { - "color": "#ff9800", - "from": 80 - }, - { - "color": "#f44336", - "from": 95 - } - ], - "width": 2, - "height": 4, - "order": 3, - "icon": "water_drop", - "sizeGauge": 20, - "sizeGap": 2, - "sizeSegments": 10, - "x": 1420, - "y": 2640, - "wires": [] - }, - { - "id": "trend_long_level", - "type": "ui-chart", - "z": "tab_ui", - "group": "ui_grp_trend_long_basin_level", - "name": "Basin Level \u2014 1 hour", - "label": "Basin Level + Net Flow", - "order": 1, - "chartType": "line", - "interpolation": "linear", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "xmin": "", - "xmax": "", - "yAxisLabel": "m", + "yAxisLabel": "mbar", "yAxisProperty": "payload", "yAxisPropertyType": "msg", "ymin": "", "ymax": "", "removeOlder": "60", "removeOlderUnit": "60", - "removeOlderPoints": "1800", + "removeOlderPoints": "3600", "action": "append", "stackSeries": false, "pointShape": "circle", @@ -3722,203 +4897,71 @@ "#e5e5e5" ], "gridColorDefault": true, - "width": 8, + "width": 12, "height": 8, "className": "", "x": 900, - "y": 2820, + "y": 3120, "wires": [ [] ] }, - { - "id": "trend_long_fill", - "type": "ui-chart", - "z": "tab_ui", - "group": "ui_grp_trend_long_basin_fill", - "name": "Basin Fill \u2014 1 hour", - "label": "Basin Fill", - "order": 1, - "chartType": "line", - "interpolation": "linear", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "xmin": "", - "xmax": "", - "yAxisLabel": "%", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "ymin": "0", - "ymax": "100", - "removeOlder": "60", - "removeOlderUnit": "60", - "removeOlderPoints": "1800", - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "showLegend": true, - "bins": 10, - "colors": [ - "#0095FF", - "#FF0000", - "#FF7F0E", - "#2CA02C", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "width": 8, - "height": 6, - "className": "", - "x": 900, - "y": 2900, - "wires": [ - [] - ] - }, - { - "id": "gauge_ps_level_long", - "type": "ui-gauge", - "z": "tab_ui", - "group": "ui_grp_trend_long_basin_level", - "name": "Basin level gauge (long)", - "gtype": "gauge-tank", - "gstyle": "Rounded", - "title": "Level", - "units": "m", - "prefix": "", - "suffix": " m", - "min": 0, - "max": 4, - "segments": [ - { - "color": "#f44336", - "from": 0 - }, - { - "color": "#ff9800", - "from": 1.0 - }, - { - "color": "#2196f3", - "from": 2.0 - }, - { - "color": "#ff9800", - "from": 3.5 - }, - { - "color": "#f44336", - "from": 3.8 - } - ], - "width": 2, - "height": 5, - "order": 2, - "icon": "", - "sizeGauge": 20, - "sizeGap": 2, - "sizeSegments": 10, - "x": 1160, - "y": 2820, - "wires": [] - }, - { - "id": "gauge_ps_fill_long", - "type": "ui-gauge", - "z": "tab_ui", - "group": "ui_grp_trend_long_basin_fill", - "name": "Basin fill gauge (long)", - "gtype": "gauge-34", - "gstyle": "Rounded", - "title": "Fill", - "units": "%", - "prefix": "", - "suffix": "%", - "min": 0, - "max": 100, - "segments": [ - { - "color": "#f44336", - "from": 0 - }, - { - "color": "#ff9800", - "from": 10 - }, - { - "color": "#4caf50", - "from": 30 - }, - { - "color": "#ff9800", - "from": 80 - }, - { - "color": "#f44336", - "from": 95 - } - ], - "width": 2, - "height": 4, - "order": 3, - "icon": "water_drop", - "sizeGauge": 20, - "sizeGap": 2, - "sizeSegments": 10, - "x": 1420, - "y": 2820, - "wires": [] - }, { "id": "tab_drivers", "type": "tab", "label": "\ud83c\udf9b\ufe0f Demo Drivers", "disabled": false, - "info": "Simulated inflow for the demo. A slow sinusoid generates inflow into the pumping station basin, which then drives the level-based pump control automatically.\n\nIn production, delete this tab \u2014 real inflow comes from upstream measurement sensors." + "info": "Inflow generator. The operator picks a SCENARIO (Constant / Sine / Diurnal / Storm) on the dashboard and sets a BASELINE m\u00b3/h value. Every second this generator emits q_in to the PS based on the active scenario + baseline.\n\nOutflow is implicit: the pumps drain the basin via MGC." }, { "id": "c_drv_title", "type": "comment", "z": "tab_drivers", - "name": "\ud83c\udf9b\ufe0f DEMO DRIVERS \u2014 simulated basin inflow", - "info": "Sinus generator \u2192 q_in to pumpingStation. Basin fills \u2192 level-based\ncontrol starts pumps \u2192 basin drains \u2192 pumps stop \u2192 cycle repeats.", + "name": "\ud83c\udf9b\ufe0f DEMO DRIVERS \u2014 operator-driven inflow generator", + "info": "", "x": 640, "y": 20, "wires": [] }, { - "id": "c_drv_sinus", - "type": "comment", + "id": "lin_inflow_scenario", + "type": "link in", "z": "tab_drivers", - "name": "\u2500\u2500 Sinusoidal inflow generator \u2500\u2500", - "info": "Produces a smooth inflow curve (m\u00b3/s) and sends to pumpingStation\nvia the cmd:q_in link channel. Period = 120s.", - "x": 640, + "name": "cmd:inflow-scenario", + "links": [ + "lout_inflow_scenario", + "lout_setup_inflow_scn" + ], + "x": 120, "y": 100, - "wires": [] + "wires": [ + [ + "inflow_state" + ] + ] }, { - "id": "sinus_tick", + "id": "lin_inflow_baseline", + "type": "link in", + "z": "tab_drivers", + "name": "cmd:inflow-baseline", + "links": [ + "lout_inflow_baseline", + "lout_setup_inflow_baseline" + ], + "x": 120, + "y": 140, + "wires": [ + [ + "inflow_state" + ] + ] + }, + { + "id": "inflow_tick", "type": "inject", "z": "tab_drivers", - "name": "tick (1s inflow)", + "name": "tick (1 Hz)", "props": [ { "p": "topic", @@ -3930,7 +4973,7 @@ "vt": "date" } ], - "topic": "sinusTick", + "topic": "tick", "payload": "", "payloadType": "date", "repeat": "1", @@ -3938,29 +4981,32 @@ "once": false, "onceDelay": "0.5", "x": 120, - "y": 140, + "y": 200, "wires": [ [ - "sinus_fn" + "inflow_state" ] ] }, { - "id": "sinus_fn", + "id": "inflow_state", "type": "function", "z": "tab_drivers", - "name": "sinus inflow (m\u00b3/s)", - "func": "// Realistic wastewater inflow profile:\n// base = minimum dry-weather flow (always present)\n// amplitude = peak wet-weather swing on top of base\n// range = base \u2192 base+amplitude = 54 \u2192 270 m\u00b3/h\n// 1 pump handles up to ~223 m\u00b3/h, so peak needs 2 pumps.\n// 3 pumps (669 m\u00b3/h) are never needed = realistic headroom.\n// period = 240s (4 min) \u2014 slow enough to see pump ramp on dash.\nconst base = 0.015; // m\u00b3/s (~54 m\u00b3/h dry weather)\nconst amplitude = 0.06; // m\u00b3/s (~216 m\u00b3/h peak swing)\nconst period = 240; // seconds per full cycle\nconst t = Date.now() / 1000; // seconds since epoch\nconst q = base + amplitude * (1 + Math.sin(2 * Math.PI * t / period)) / 2;\nreturn { topic: 'q_in', payload: q, unit: 'm3/s', timestamp: Date.now() };", - "outputs": 1, + "name": "inflow scenario engine", + "func": "let scenario = context.get('scenario') || 'constant';\nlet baseline = context.get('baseline');\nif (baseline == null) baseline = 60;\n\nif (msg.topic === 'inflowBaseline') {\n const v = Number(msg.payload);\n if (Number.isFinite(v) && v >= 0) {\n baseline = v;\n context.set('baseline', baseline);\n }\n return null;\n}\nif (msg.topic === 'scenario') {\n const s = String(msg.payload || '').toLowerCase();\n if (['constant','sine','diurnal','storm'].includes(s)) {\n scenario = s;\n context.set('scenario', scenario);\n }\n return null;\n}\nconst t = Date.now() / 1000;\nlet q_h;\nswitch (scenario) {\n case 'sine': {\n q_h = baseline * (1 + 0.5 * Math.sin(2 * Math.PI * t / 240));\n break;\n }\n case 'diurnal': {\n q_h = baseline * (1 + 0.6 * Math.sin(2 * Math.PI * t / 480 - Math.PI/2));\n break;\n }\n case 'storm': {\n const phase = (t % 240) / 240;\n let factor;\n if (phase < 0.15) factor = 1 + (4 / 0.15) * phase;\n else factor = Math.max(1, 5 - (4 / 0.85) * (phase - 0.15));\n q_h = baseline * factor;\n break;\n }\n case 'constant':\n default:\n q_h = baseline;\n}\nq_h = Math.max(0, q_h);\nconst q_s = q_h / 3600;\nreturn [\n { topic: 'q_in', payload: q_s, unit: 'm3/s', timestamp: Date.now() },\n { payload: { scenario, baseline, q_h, q_s, ts: Date.now() } },\n];", + "outputs": 2, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 600, - "y": 140, + "x": 640, + "y": 160, "wires": [ [ "lout_qin_drivers" + ], + [ + "lout_evt_inflow" ] ] }, @@ -3977,19 +5023,32 @@ "y": 140, "wires": [] }, + { + "id": "lout_evt_inflow", + "type": "link out", + "z": "tab_drivers", + "name": "evt:inflow", + "mode": "link", + "links": [ + "lin_evt_inflow" + ], + "x": 900, + "y": 180, + "wires": [] + }, { "id": "tab_setup", "type": "tab", "label": "\u2699\ufe0f Setup & Init", "disabled": false, - "info": "One-shot deploy-time injects. Sets MGC scaling/mode, broadcasts pumps mode = auto, and auto-starts the pumps + random demand." + "info": "One-shot deploy-time injects:\n \u2022 MGC scaling = normalized + mode = optimalcontrol\n \u2022 all pumps mode = auto\n \u2022 initial inflow baseline + scenario\n\nDisable this tab in production." }, { "id": "c_setup_title", "type": "comment", "z": "tab_setup", "name": "\u2699\ufe0f SETUP & INIT \u2014 one-shot deploy-time injects", - "info": "Disable this tab in production \u2014 the runtime should be persistent.", + "info": "", "x": 640, "y": 20, "wires": [] @@ -4093,7 +5152,7 @@ "once": true, "onceDelay": "2.0", "x": 120, - "y": 250, + "y": 240, "wires": [ [ "lout_mode_setup" @@ -4110,7 +5169,293 @@ "lin_mode" ], "x": 380, - "y": 250, + "y": 240, "wires": [] + }, + { + "id": "setup_inflow_baseline", + "type": "inject", + "z": "tab_setup", + "name": "inflow baseline = 25 m\u00b3/h (nominal)", + "props": [ + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload", + "v": "25", + "vt": "num" + } + ], + "topic": "inflowBaseline", + "payload": "25", + "payloadType": "num", + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": "2.5", + "x": 120, + "y": 320, + "wires": [ + [ + "lout_setup_inflow_baseline" + ] + ] + }, + { + "id": "lout_setup_inflow_baseline", + "type": "link out", + "z": "tab_setup", + "name": "cmd:inflow-baseline", + "mode": "link", + "links": [ + "lin_inflow_baseline" + ], + "x": 380, + "y": 320, + "wires": [] + }, + { + "id": "setup_inflow_scenario", + "type": "inject", + "z": "tab_setup", + "name": "inflow scenario = sine", + "props": [ + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload", + "v": "sine", + "vt": "str" + } + ], + "topic": "scenario", + "payload": "sine", + "payloadType": "str", + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": "2.7", + "x": 120, + "y": 380, + "wires": [ + [ + "lout_setup_inflow_scn" + ] + ] + }, + { + "id": "lout_setup_inflow_scn", + "type": "link out", + "z": "tab_setup", + "name": "cmd:inflow-scenario", + "mode": "link", + "links": [ + "lin_inflow_scenario" + ], + "x": 380, + "y": 380, + "wires": [] + }, + { + "id": "setup_calibrate_level", + "type": "inject", + "z": "tab_setup", + "name": "[manual] calibrate basin = 1.0 m (click to reset)", + "props": [ + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload", + "v": "1.0", + "vt": "num" + } + ], + "topic": "calibratePredictedLevel", + "payload": "1.0", + "payloadType": "num", + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": "0.5", + "x": 120, + "y": 460, + "wires": [ + [ + "lout_setup_calibrate" + ] + ] + }, + { + "id": "lout_setup_calibrate", + "type": "link out", + "z": "tab_setup", + "name": "setup:calibrate-ps", + "mode": "link", + "links": [ + "lin_setup_calibrate_ps" + ], + "x": 380, + "y": 460, + "wires": [] + }, + { + "id": "tab_telemetry", + "type": "tab", + "label": "\ud83d\udcc8 Telemetry", + "disabled": false, + "info": "InfluxDB writer: every EVOLV node's port-1 telemetry is fanned in via the evt:tlm link channel, converted to line protocol, and POSTed to InfluxDB v2 (org=evolv, bucket=telemetry).\n\nPattern adapted from docker/demo-flow.json." + }, + { + "id": "c_tlm_title", + "type": "comment", + "z": "tab_telemetry", + "name": "\ud83d\udcc8 TELEMETRY \u2014 InfluxDB writer", + "info": "", + "x": 640, + "y": 20, + "wires": [] + }, + { + "id": "lin_tlm", + "type": "link in", + "z": "tab_telemetry", + "name": "evt:tlm", + "links": [ + "lout_tlm_pump_a", + "lout_tlm_meas_pump_a_u", + "lout_tlm_meas_pump_a_d", + "lout_tlm_meas_pump_a_f", + "lout_tlm_meas_pump_a_p", + "lout_tlm_pump_b", + "lout_tlm_meas_pump_b_u", + "lout_tlm_meas_pump_b_d", + "lout_tlm_meas_pump_b_f", + "lout_tlm_meas_pump_b_p", + "lout_tlm_pump_c", + "lout_tlm_meas_pump_c_u", + "lout_tlm_meas_pump_c_d", + "lout_tlm_meas_pump_c_f", + "lout_tlm_meas_pump_c_p", + "lout_tlm_mgc", + "lout_tlm_ps" + ], + "x": 120, + "y": 100, + "wires": [ + [ + "fn_tlm_to_lp" + ] + ] + }, + { + "id": "fn_tlm_to_lp", + "type": "function", + "z": "tab_telemetry", + "name": "\u2192 InfluxDB line protocol", + "func": "const p = msg.payload;\nif (!p || !p.measurement || !p.fields) return null;\nconst esc = (s) => String(s)\n .replace(/,/g, '\\\\,').replace(/ /g, '\\\\ ').replace(/=/g, '\\\\=');\nconst tags = Object.entries(p.tags || {})\n .filter(([k, v]) => v !== undefined && v !== null && v !== '')\n .map(([k, v]) => `${esc(k)}=${esc(v)}`).join(',');\nconst fieldPairs = Object.entries(p.fields)\n .filter(([k, v]) => v !== undefined && v !== null)\n .map(([k, v]) => {\n if (typeof v === 'number' && Number.isFinite(v)) return `${esc(k)}=${v}`;\n if (typeof v === 'boolean') return `${esc(k)}=${v}`;\n return `${esc(k)}=\"${String(v).replace(/\"/g, '\\\\\"')}\"`;\n });\nif (fieldPairs.length === 0) return null;\nconst ts = Date.now() * 1000000;\nmsg.payload = `${esc(p.measurement)}${tags ? ',' + tags : ''} `\n + `${fieldPairs.join(',')} ${ts}`;\n// Hint the join node to fire on size or timeout.\nmsg.topic = 'tlm';\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 640, + "y": 100, + "wires": [ + [ + "join_tlm" + ] + ] + }, + { + "id": "join_tlm", + "type": "join", + "z": "tab_telemetry", + "name": "batch (200 lines / 2 s)", + "mode": "custom", + "build": "string", + "property": "payload", + "propertyType": "msg", + "key": "topic", + "joiner": "\\n", + "joinerType": "str", + "accumulate": false, + "timeout": "2", + "count": "200", + "reduceRight": false, + "reduceExp": "", + "reduceInit": "", + "reduceInitType": "", + "reduceFixup": "", + "x": 900, + "y": 100, + "wires": [ + [ + "fn_tlm_post" + ] + ] + }, + { + "id": "fn_tlm_post", + "type": "function", + "z": "tab_telemetry", + "name": "wrap as InfluxDB POST", + "func": "// Count lines for status reporting.\nconst body = String(msg.payload || '');\nconst lineCount = body ? body.split('\\n').length : 0;\nif (lineCount === 0) return null;\nmsg.lineCount = lineCount;\nmsg.headers = {\n 'Authorization': 'Token evolv-dev-token',\n 'Content-Type': 'text/plain'\n};\nmsg.url = 'http://influxdb:8086/api/v2/write?org=evolv&bucket=telemetry&precision=ns';\nmsg.method = 'POST';\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1100, + "y": 100, + "wires": [ + [ + "http_tlm" + ] + ] + }, + { + "id": "http_tlm", + "type": "http request", + "z": "tab_telemetry", + "name": "Write InfluxDB", + "method": "use", + "ret": "txt", + "paytoqs": "ignore", + "url": "", + "tls": "", + "persist": false, + "proxy": "", + "authType": "", + "senderr": false, + "x": 1240, + "y": 100, + "wires": [ + [ + "fn_tlm_count" + ] + ] + }, + { + "id": "fn_tlm_count", + "type": "function", + "z": "tab_telemetry", + "name": "Count writes", + "func": "const lines = Number(msg.lineCount) || 0;\nconst writes = (global.get('influx_writes') || 0) + 1;\nconst totalLines = (global.get('influx_lines') || 0) + lines;\nglobal.set('influx_writes', writes);\nglobal.set('influx_lines', totalLines);\nconst errors = global.get('influx_errors') || 0;\nif (msg.statusCode && msg.statusCode >= 400) {\n global.set('influx_errors', errors + 1);\n node.status({fill:'red', shape:'ring',\n text:`ERR ${errors+1}: ${msg.statusCode}`});\n} else {\n node.status({fill:'green', shape:'dot',\n text:`${writes} POSTs \u00b7 ${totalLines} lines (${errors} err)`});\n}\nreturn null;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1420, + "y": 100, + "wires": [ + [] + ] } ] diff --git a/nodes/generalFunctions b/nodes/generalFunctions index 94bcc90..9a99819 160000 --- a/nodes/generalFunctions +++ b/nodes/generalFunctions @@ -1 +1 @@ -Subproject commit 94bcc90b4b5864fd721c500f50bd911fd538f0d5 +Subproject commit 9a998191cdcf68254188de1c0487ee37acf8c202 diff --git a/nodes/machineGroupControl b/nodes/machineGroupControl index 9c79dac..9916527 160000 --- a/nodes/machineGroupControl +++ b/nodes/machineGroupControl @@ -1 +1 @@ -Subproject commit 9c79dac4e3d5c607f63febab7f616b1a3023a93f +Subproject commit 99165277901ef839098f15ab4f4ed2bf58afa8ae diff --git a/nodes/pumpingStation b/nodes/pumpingStation index 6ab585b..e2ebb31 160000 --- a/nodes/pumpingStation +++ b/nodes/pumpingStation @@ -1 +1 @@ -Subproject commit 6ab585bcc23f7a779600583c0907ed394394ae8d +Subproject commit e2ebb31816dcfdf3c2841b19de6b35cc0598f920 diff --git a/nodes/rotatingMachine b/nodes/rotatingMachine index 399e0a8..5a8113a 160000 --- a/nodes/rotatingMachine +++ b/nodes/rotatingMachine @@ -1 +1 @@ -Subproject commit 399e0a8c018b1261abce77cbeae19eaf92c6b3f2 +Subproject commit 5a8113a9d1680a8751b4f81937bd938ea5692a15 diff --git a/scripts/sync-example.sh b/scripts/sync-example.sh new file mode 100755 index 0000000..97c33bc --- /dev/null +++ b/scripts/sync-example.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Copy examples//flow.json into the running Node-RED project's +# flow.json. Use this after regenerating flow.json from build_flow.py +# when you want the runtime to reload the canonical source. +# +# Usage: +# scripts/sync-example.sh +# +# Example: +# scripts/sync-example.sh pumpingstation-complete-example +set -e + +NAME="${1:-pumpingstation-complete-example}" +SRC="examples/$NAME/flow.json" +CONTAINER="evolv-nodered" +DST="/data/projects/$NAME/flow.json" + +if [ ! -f "$SRC" ]; then + echo "error: $SRC not found (run from EVOLV repo root)" >&2 + exit 1 +fi + +if ! docker ps --format '{{.Names}}' | grep -q "^$CONTAINER$"; then + echo "error: $CONTAINER is not running" >&2 + exit 1 +fi + +echo "Copying $SRC → $CONTAINER:$DST" +docker cp "$SRC" "$CONTAINER:$DST" + +echo "Reloading flows..." +curl -s -X POST "http://localhost:1880/flows" \ + -H "Content-Type: application/json" \ + -H "Node-RED-Deployment-Type: full" \ + --data-binary "@$SRC" \ + -w 'HTTP %{http_code}\n' diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..590f6ad --- /dev/null +++ b/test/README.md @@ -0,0 +1,30 @@ +# EVOLV cross-node test harness + +This folder hosts end-to-end tests that wire **multiple** EVOLV domain +classes together the same way Node-RED would, but in pure Node.js so the +simulation runs deterministically and every internal value is inspectable. + +**Scope rule.** Tests that exercise a single node's behaviour live in that +node's submodule under `nodes//test/`. Tests here cross node +boundaries — they instantiate `pumpingStation` + `machineGroupControl` + +multiple `rotatingMachine`s together and drive the wired graph. + +Examples of what belongs where: + +| Concern | Lives in | +|---|---| +| MGC optimizer combination choice for a given demand | `nodes/machineGroupControl/test/integration/optimizer-combination-choice.integration.test.js` | +| Pump curve interpolation across head values | `nodes/rotatingMachine/test/integration/...` | +| PS hysteresis logic with mocked groups | `nodes/pumpingStation/test/integration/shifted-ramp-end-to-end.test.js` | +| **Whole plant**: PS basin level + MGC dispatch + 3 pumps + physics simulator | `test/end-to-end-pumpingstation.test.js` (this folder) | + +Run: + +``` +node --test test/end-to-end-pumpingstation.test.js +``` + +The harness in `lib/wiring.js` builds the parent-child relationships +Node-RED would build via `registerChild`, lets you advance a controllable +clock, and `lib/recorder.js` records every measurement / state / demand +event into a flat trace. diff --git a/test/end-to-end-pumpingstation.test.js b/test/end-to-end-pumpingstation.test.js new file mode 100644 index 0000000..8d04ab7 --- /dev/null +++ b/test/end-to-end-pumpingstation.test.js @@ -0,0 +1,192 @@ +// End-to-end test: PS + MGC + 3 pumps wired exactly like the +// pumpingstation-complete-example demo, driven by a controllable clock. +// +// Verifies: +// 1. Basin starts low (below stopLevel) — pumps OFF. +// 2. Basin fills to startLevel — first pump engages. +// 3. Basin drains through the dead band [stopLevel, startLevel] — +// pump stays engaged at minimum flow. +// 4. Basin reaches stopLevel — pump disengages, basin refills. +// 5. Storm inflow → all 3 pumps engage at high flow. + +const test = require('node:test'); +const { buildPlant, injectPumpPressure } = require('./lib/wiring'); +const { attachRecorder, snapshotFull, snapshotMachineState } = require('./lib/recorder'); + +const TICK_MS = 1000; +const STATIC_HEAD_M = 12; +const RHO_G = 9810; +const DYN_HEAD_M_AT_FULL_FLOW = 12; +const TOTAL_FLOW_MAX_M3H = 300; +const OUTFLOW_LEVEL_M = 0.3; + +function physics({ basinLevelM, totalPumpFlow_m3h }) { + const headM = Math.max(0, basinLevelM - OUTFLOW_LEVEL_M); + const upstreamPa = RHO_G * headM; + const ratio = Math.min(1, totalPumpFlow_m3h / TOTAL_FLOW_MAX_M3H); + const downstreamPa = RHO_G * (STATIC_HEAD_M + ratio * ratio * DYN_HEAD_M_AT_FULL_FLOW); + return { upstreamPa, downstreamPa }; +} + +function totalPumpFlow_m3h(pumps) { + let s = 0; + for (const p of pumps) { + const f = p.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue('m3/h') || 0; + s += Number(f); + } + return s; +} + +async function tick(plant, { qIn_m3s }) { + const { ps, pumps, advance } = plant; + const basinLevelM = ps.measurements.type('level').variant('predicted') + .position('atequipment').getCurrentValue('m') ?? 0; + const tot = totalPumpFlow_m3h(pumps); + const { upstreamPa, downstreamPa } = physics({ basinLevelM, totalPumpFlow_m3h: tot }); + for (const p of pumps) injectPumpPressure(p, upstreamPa, downstreamPa); + ps.setManualInflow(qIn_m3s, Date.now(), 'm3/s'); + advance(TICK_MS); + ps.tick(); + await new Promise((r) => setImmediate(r)); +} + +test('PS + MGC + 3 pumps — full hysteresis cycle (5/15 nominal)', async () => { + // Start at 2.4 m — just below startLevel(2.5) — so we see the rising + // edge in a few minutes instead of 30. Then observe the full cycle. + const plant = buildPlant({ initialBasinLevel: 2.4 }); + const rec = attachRecorder(plant); + const { ps, mgc, pumps, restore } = plant; + + try { + console.log('\n========================================================='); + console.log(' POST-WIRING SNAPSHOT'); + console.log('========================================================='); + const initSnap = snapshotFull(ps, mgc, pumps); + console.log(JSON.stringify(initSnap, null, 2)); + console.log('\nMGC absoluteTotals (m³/h):', + `min=${(mgc.absoluteTotals.flow.min*3600).toFixed(0)}, max=${(mgc.absoluteTotals.flow.max*3600).toFixed(0)}`); + console.log('MGC dynamicTotals (m³/h):', + `min=${(mgc.dynamicTotals.flow.min*3600).toFixed(0)}, max=${(mgc.dynamicTotals.flow.max*3600).toFixed(0)}`); + + // Phase 1: nominal inflow ≈ 25 m³/h → expect cycle ~5 on / ~15 off. + const NOMINAL_QIN = 25 / 3600; // m³/s + console.log('\n========================================================='); + console.log(' PHASE 1: nominal inflow 25 m³/h — observe one full cycle.'); + console.log(' Expected: basin rises from 1.5 m to 2.5 m (off, ~?? min), pump kicks on, drains to 2.0 m (on, ~5 min), repeats.'); + console.log('========================================================='); + + const phase1Trace = []; + let firstEngageTick = null; + let firstDisengageTick = null; + let secondEngageTick = null; + for (let i = 0; i < 1800; i++) { // 30 min sim + await tick(plant, { qIn_m3s: NOMINAL_QIN }); + const snap = snapshotFull(ps, mgc, pumps); + const tickIdx = i + 1; + phase1Trace.push({ s: tickIdx, ...snap }); + const anyEngaged = pumps.some(p => + ['operational', 'starting', 'warmingup', 'accelerating'].includes(p.state.getCurrentState()) + ); + if (anyEngaged && firstEngageTick == null) firstEngageTick = tickIdx; + if (firstEngageTick != null && firstDisengageTick == null && !anyEngaged) firstDisengageTick = tickIdx; + if (firstDisengageTick != null && secondEngageTick == null && anyEngaged) secondEngageTick = tickIdx; + // Stop after we observe a full off→on→off→on cycle so we can measure both phases. + if (secondEngageTick != null && tickIdx > secondEngageTick + 60) break; + } + printCompactTrace(decimateTrace(phase1Trace, 30)); + + console.log('\n-- cycle landmarks --'); + console.log(`First pump engage : tick ${firstEngageTick} (level=${phase1Trace[firstEngageTick - 1]?.psLevel})`); + console.log(`First pump disengage: tick ${firstDisengageTick} (level=${phase1Trace[firstDisengageTick - 1]?.psLevel})`); + console.log(`Second engage : tick ${secondEngageTick} (level=${phase1Trace[secondEngageTick - 1]?.psLevel})`); + if (firstEngageTick && firstDisengageTick) { + const onMin = (firstDisengageTick - firstEngageTick) / 60; + console.log(`On phase duration : ${onMin.toFixed(1)} min (target ≈ 5 min)`); + } + if (firstDisengageTick && secondEngageTick) { + const offMin = (secondEngageTick - firstDisengageTick) / 60; + console.log(`Off phase duration : ${offMin.toFixed(1)} min (target ≈ 15 min)`); + } + + // Phase 2: storm inflow → all 3 pumps should engage. + console.log('\n========================================================='); + console.log(' PHASE 2: storm inflow 250 m³/h — expect all 3 pumps engaged.'); + console.log('========================================================='); + const STORM_QIN = 250 / 3600; + const phase2Trace = []; + for (let i = 0; i < 600; i++) { // 10 min storm + await tick(plant, { qIn_m3s: STORM_QIN }); + const snap = snapshotFull(ps, mgc, pumps); + phase2Trace.push({ s: phase1Trace.length + i + 1, ...snap }); + } + printCompactTrace(decimateTrace(phase2Trace, 30)); + + const peak = phase2Trace.reduce((acc, s) => { + const running = Object.values(s.pumps).filter(p => + ['operational', 'accelerating', 'warmingup', 'starting'].includes(p.state) + ).length; + return Math.max(acc, running); + }, 0); + console.log(`\nPeak concurrent running pumps during storm: ${peak} / 3`); + const maxLvl = phase2Trace.reduce((acc, s) => Math.max(acc, s.psLevel ?? 0), 0); + console.log(`Max basin level during storm: ${maxLvl.toFixed(2)} m`); + + // Phase 3: inflow drops back to nominal — expect graceful unwind. + console.log('\n========================================================='); + console.log(' PHASE 3: storm subsides → 25 m³/h. Expect graceful unwind.'); + console.log('========================================================='); + const phase3Trace = []; + for (let i = 0; i < 900; i++) { + await tick(plant, { qIn_m3s: NOMINAL_QIN }); + const snap = snapshotFull(ps, mgc, pumps); + phase3Trace.push({ s: phase1Trace.length + phase2Trace.length + i + 1, ...snap }); + const anyEngaged = pumps.some(p => + ['operational', 'starting'].includes(p.state.getCurrentState()) + ); + if (!anyEngaged) break; + } + printCompactTrace(decimateTrace(phase3Trace, 30)); + + // Diagnostics summary. + console.log('\n========================================================='); + console.log(' SUMMARY'); + console.log('========================================================='); + const ctrlAnomalies = phase1Trace.filter(s => + Object.values(s.pumps).some(p => + p.state === 'operational' && (p.ctrl_pct === 0 || p.ctrl_pct == null) && p.flow_m3h > 1 + ) + ).length; + console.log(`Bug 3 leftover (ctrl=0 while operational delivering flow): ${ctrlAnomalies} ticks`); + const optimalEvents = rec.events.filter(e => e.kind === 'mgc.optimalControl.out' && e.Qd > 0); + console.log(`MGC optimalControl invocations with Qd>0: ${optimalEvents.length}`); + } finally { + restore(); + } +}); + +// Reduce noise by sampling every Nth tick + always include first/last. +function decimateTrace(rows, step) { + if (rows.length <= step * 2) return rows; + const out = [rows[0]]; + for (let i = step; i < rows.length - 1; i += step) out.push(rows[i]); + out.push(rows[rows.length - 1]); + return out; +} + +function printCompactTrace(rows) { + if (rows.length === 0) { console.log('(empty)'); return; } + console.log(' s level vol dir pct d_min d_max pumpA pumpB pumpC'); + console.log(' ─ ───── ───── ──────── ─────── ───── ───── ─────────────── ─────────────── ───────────────'); + for (const r of rows) { + const fmtPump = (p) => { + if (!p) return ''.padEnd(15); + return `${(p.state ?? '?').slice(0,8).padEnd(8)} c${(p.ctrl_pct ?? 0).toFixed(0).padStart(3)} f${(p.flow_m3h ?? 0).toFixed(0).padStart(3)}`.padEnd(15); + }; + const a = fmtPump(r.pumps.pump_a); + const b = fmtPump(r.pumps.pump_b); + const c = fmtPump(r.pumps.pump_c); + console.log( + `${String(r.s).padStart(4)} ${(r.psLevel ?? 0).toFixed(3)} ${(r.psVolume ?? 0).toFixed(2).padStart(5)} ${(r.psDirection ?? '?').padEnd(8)} ${(r.psPercControl ?? 0).toFixed(2).padStart(7)} ${(r.mgc?.dynamicMin_m3h ?? 0).toFixed(0).padStart(5)} ${(r.mgc?.dynamicMax_m3h ?? 0).toFixed(0).padStart(5)} ${a} ${b} ${c}` + ); + } +} diff --git a/test/lib/recorder.js b/test/lib/recorder.js new file mode 100644 index 0000000..314c26b --- /dev/null +++ b/test/lib/recorder.js @@ -0,0 +1,116 @@ +// Trace recorder — hooks into every emitter and timer-driven path on a +// wired plant and records ALL events into a flat list with timestamps. +// +// Captures: +// - Per-pump state transitions (state.emitter on 'state-change' or via +// polling getCurrentState() before/after each tick). +// - Per-pump pressure events (measurements.emitter on +// 'pressure.measured.{upstream,downstream,differential}'). +// - Per-pump flow / power / ctrl events (predicted variants). +// - MGC dynamic totals (after each calcDynamicTotals). +// - PS percControl + level + volume + safetyState (after each tick). +// - MGC bestCombination (instrument by wrapping optimalControl). +// - Pump operating points: individual predictFlow.currentF and +// groupPredictFlow.currentF (per tick, post-equalization). + +const POSITIONS = ['upstream', 'downstream', 'differential']; + +function attachRecorder({ ps, mgc, pumps }) { + const events = []; + const push = (kind, data) => events.push({ t: Date.now(), kind, ...data }); + + // --- pump-level: pressure events --- + for (const pump of pumps) { + const id = pump.config.general.id; + for (const pos of POSITIONS) { + const ev = `pressure.measured.${pos}`; + pump.measurements.emitter.on(ev, (e) => push('pump.pressure', { + pump: id, pos, value: e?.value, unit: e?.unit, + })); + } + // flow / power predicted (rotatingMachine emits these on state changes + // and movement updates). + pump.measurements.emitter.on('flow.predicted.downstream', (e) => push('pump.flow.predicted', { + pump: id, value: e?.value, unit: e?.unit, + })); + pump.measurements.emitter.on('power.predicted.atequipment', (e) => push('pump.power.predicted', { + pump: id, value: e?.value, unit: e?.unit, + })); + pump.measurements.emitter.on('ctrl.predicted.atequipment', (e) => push('pump.ctrl.predicted', { + pump: id, value: e?.value, unit: e?.unit, + })); + } + + // --- MGC bestCombination: wrap optimalControl --- + const origOptimal = mgc.optimalControl.bind(mgc); + mgc.optimalControl = async function (Qd, powerCap = Infinity) { + push('mgc.optimalControl.in', { Qd, powerCap }); + const before = snapshotMachineState(pumps); + const result = await origOptimal(Qd, powerCap); + const after = snapshotMachineState(pumps); + push('mgc.optimalControl.out', { + Qd, + headerDiffPa: pumps[0]?.groupPredictFlow?.currentF, + indivDiffPaPerPump: Object.fromEntries(pumps.map(p => [p.config.general.id, p.predictFlow?.currentF])), + groupDiffPaPerPump: Object.fromEntries(pumps.map(p => [p.config.general.id, p.groupPredictFlow?.currentF])), + // capture state before/after to spot transitions caused by this optimal + stateBefore: before, stateAfter: after, + }); + return result; + }; + + return { events, push }; +} + +function snapshotMachineState(pumps) { + return Object.fromEntries(pumps.map(p => [ + p.config.general.id, + p.state?.getCurrentState?.() ?? '?' + ])); +} + +function snapshotFull(ps, mgc, pumps) { + const level = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m'); + const volume = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3'); + return { + psLevel: round3(level), + psVolume: round3(volume), + psPercControl: round3(ps.percControl), + psSafety: ps.safetyControllerActive, + psDirection: ps.state?.direction, + psNetFlow_m3h: round3((ps.state?.netFlow ?? 0) * 3600), + pumps: Object.fromEntries(pumps.map(p => { + const id = p.config.general.id; + const flowPred = p.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue('m3/h'); + const powerPred = p.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('kW'); + const ctrlPred = p.measurements.type('ctrl').variant('predicted').position('atEquipment').getCurrentValue(); + const upPred = p.measurements.type('pressure').variant('measured').position('upstream').getCurrentValue('mbar'); + const dnPred = p.measurements.type('pressure').variant('measured').position('downstream').getCurrentValue('mbar'); + return [id, { + state: p.state?.getCurrentState?.(), + ctrl_pct: round3(ctrlPred), + flow_m3h: round3(flowPred), + power_kW: round3(powerPred), + pUp_mbar: round3(upPred), + pDn_mbar: round3(dnPred), + indivDiff_mbar: round3((p.predictFlow?.currentF ?? 0) / 100), + groupDiff_mbar: round3((p.groupPredictFlow?.currentF ?? 0) / 100), + NCog: round3(p.NCog), + groupNCog: round3(p.groupNCog), + }]; + })), + mgc: { + scaling: mgc.scaling, + mode: mgc.mode, + dynamicMin_m3h: round3((mgc.dynamicTotals?.flow?.min ?? 0) * 3600), + dynamicMax_m3h: round3((mgc.dynamicTotals?.flow?.max ?? 0) * 3600), + }, + }; +} + +function round3(v) { + if (typeof v !== 'number' || !Number.isFinite(v)) return v; + return Math.round(v * 1000) / 1000; +} + +module.exports = { attachRecorder, snapshotFull, snapshotMachineState }; diff --git a/test/lib/wiring.js b/test/lib/wiring.js new file mode 100644 index 0000000..87effb9 --- /dev/null +++ b/test/lib/wiring.js @@ -0,0 +1,152 @@ +// Wiring helpers for cross-node end-to-end tests. +// +// Builds a small physical plant in pure JS: +// - 3 rotatingMachine pumps (centrifugal, identical curves) +// - 1 machineGroupControl coordinating them +// - 1 pumpingStation owning a wet-well basin and the MGC +// +// Pumps register as children of the MGC. The MGC registers as a child of +// the PS. This mirrors what Node-RED's registerChild messages do at runtime. +// +// A controllable clock replaces Date.now so _updatePredictedVolume's deltaT +// is exact regardless of wall-clock time. + +const PumpingStation = require('../../nodes/pumpingStation/src/specificClass'); +const MachineGroup = require('../../nodes/machineGroupControl/src/specificClass'); +const Machine = require('../../nodes/rotatingMachine/src/specificClass'); + +// ---------------- configs (mirror what the demo flow ships) ---------------- + +function pumpConfig(id) { + return { + general: { id, name: id, unit: 'm3/h', + logging: { enabled: false, logLevel: 'error' } }, + functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller', + positionVsParent: 'atEquipment' }, + asset: { category: 'pump', type: 'centrifugal', + model: 'hidrostal-H05K-S03R', supplier: 'hidrostal', + curveUnits: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' } }, + mode: { + current: 'auto', + allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] }, + allowedSources: { auto: ['parent', 'GUI'] }, + }, + sequences: { + startup: ['starting', 'warmingup', 'operational'], + shutdown: ['stopping', 'coolingdown', 'idle'], + emergencystop: ['emergencystop', 'off'], + }, + }; +} + +function pumpStateConfig() { + return { + general: { logging: { enabled: false, logLevel: 'error' } }, + state: { current: 'idle' }, + movement: { mode: 'staticspeed', speed: 1200, maxSpeed: 1800, interval: 10 }, + time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 }, + }; +} + +function mgcConfig() { + return { + general: { name: 'mgc', id: 'mgc', logging: { enabled: false, logLevel: 'error' } }, + functionality: { softwareType: 'machinegroup', role: 'groupcontroller', + positionVsParent: 'atEquipment' }, + scaling: { current: 'normalized' }, + mode: { current: 'optimalcontrol' }, + }; +} + +function psConfig(overrides = {}) { + return { + general: { id: 'ps', name: 'ps', unit: 'm3/h', + logging: { enabled: false, logLevel: 'error' }, + flowThreshold: 1e-4 }, + functionality: { softwareType: 'pumpingstation', role: 'stationcontroller', + positionVsParent: 'atEquipment' }, + basin: { + // Sized so the [stopLevel,startLevel] band holds enough water that + // a single pump at min flow (~99 m³/h) drains for ~5 min while + // nominal inflow (~25 m³/h) refills it in ~15 min. + // 0.5 m × 12.5 m² = 6.25 m³ (drain time = 6.25 / (99-25) m³/h ≈ 5 min) + volume: 50, height: 4, + inflowLevel: 2.5, outflowLevel: 0.3, overflowLevel: 3.8, + inletPipeDiameter: 0.4, outletPipeDiameter: 0.3, + }, + hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' }, + control: { + mode: 'levelbased', + allowedModes: new Set(['levelbased', 'manual']), + levelbased: { + minLevel: 0.5, startLevel: 2.5, stopLevel: 2.0, maxLevel: 3.5, + curveType: 'linear', logCurveFactor: 9, + deadZoneKeepAlivePercent: 1, // % sent to MGC while engaged in [stopLvl, startLevel] + enableShiftedRamp: false, shiftLevel: null, shiftArmPercent: 95, + }, + }, + safety: { + enableDryRunProtection: true, enableOverfillProtection: true, + dryRunThresholdPercent: 5, highVolumeSafetyThresholdPercent: 95, + overfillThresholdPercent: 95, timeleftToFullOrEmptyThresholdSeconds: 0, + }, + ...overrides, + }; +} + +// ---------------- harness ---------------- + +function buildPlant({ initialBasinLevel = 2.0 } = {}) { + const ps = new PumpingStation(psConfig()); + const mgc = new MachineGroup(mgcConfig()); + const pumps = ['pump_a', 'pump_b', 'pump_c'].map(id => new Machine(pumpConfig(id), pumpStateConfig())); + + // Inject initial pressure on each pump so predictFlow / predictPower / + // predictCtrl have a real fDimension before MGC starts asking. Real + // values are set every tick by the physics step. + for (const m of pumps) injectPumpPressure(m, /* upstreamPa */ 19620, /* downstreamPa */ 117720); + + // Wire pumps → MGC. + for (const m of pumps) mgc.childRegistrationUtils.registerChild(m, m.config.functionality.positionVsParent); + // Wire MGC → PS. + ps.childRegistrationUtils.registerChild(mgc, mgc.config.functionality.positionVsParent); + + mgc.calcAbsoluteTotals(); + mgc.calcDynamicTotals(); + + // Calibrate basin level to start point. + ps.calibratePredictedLevel(initialBasinLevel); + + // Controllable clock — overrides Date.now ONLY for our process. + let now = Date.now(); + const realNow = Date.now; + Date.now = () => now; + ps._predictedFlowState.lastTimestamp = now; + + function advance(ms) { now += ms; } + function restore() { Date.now = realNow; } + + return { ps, mgc, pumps, advance, restore, get now() { return now; } }; +} + +// Convert mbar to Pa for the rotatingMachine canonical pressure unit. +function mbarToPa(mbar) { return mbar * 100; } +function paToMbar(Pa) { return Pa / 100; } + +// Inject upstream + downstream pressure measurements onto a pump as if a +// pressure-sensor child had emitted them. updateMeasuredPressure is the +// same path the rotatingMachine listens on for sensor children, so this +// fires the pump's "pressure.measured." emitter — which the MGC +// is also subscribed to, so totals recompute identically. +function injectPumpPressure(pump, upstreamPa, downstreamPa, ts = Date.now()) { + pump.updateMeasuredPressure(paToMbar(upstreamPa), 'upstream', + { timestamp: ts, unit: 'mbar', childName: 'PT-up', childId: `up-${pump.config.general.id}` }); + pump.updateMeasuredPressure(paToMbar(downstreamPa), 'downstream', + { timestamp: ts, unit: 'mbar', childName: 'PT-dn', childId: `dn-${pump.config.general.id}` }); +} + +module.exports = { + buildPlant, + injectPumpPressure, + mbarToPa, paToMbar, +};