Pumping-station demo overhaul + cross-node test harness + bumps
Some checks failed
CI / lint-and-test (push) Has been cancelled

Submodule bumps land the deadlock fix (state.js residue unpark + MGC
optimalControl dispatch reorder) and pumpingStation stopLevel hysteresis.

- Renames examples/pumpingstation-3pumps-dashboard →
  pumpingstation-complete-example with regenerated flow.json. New
  dashboard groups, demand-broadcast wiring, S88 placement rule
  applied, ui-chart trend-split and link-channel naming follow
  .claude/rules/node-red-flow-layout.md.
- New cross-node test harness under test/: end-to-end-pumpingstation
  drives PS + MGC + 3 pumps + physics simulator end-to-end and
  verifies the ~5/15 min cycle.
- Adds Grafana provisioning dashboards (pumping-station.json) and a
  helper sync-example.sh script for export/import to live Node-RED.
- Docker entrypoint + settings + compose tweaks for the persistent
  user dir layout used by the demo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-08 11:21:21 +02:00
parent ca0644d689
commit 0cab98c196
21 changed files with 5863 additions and 2745 deletions

View File

@@ -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:

View File

@@ -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/<name>/ becomes a project under /data/projects/<name>/.
# 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
# -------------------------------------------------------

View File

@@ -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

View File

@@ -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)"
}
]
}
]
}

View File

@@ -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/<name>/ into /data/projects/<name>/ 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/<name>/flow.json \
// examples/<name>/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'
}
}
},

View File

@@ -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/<name>/examples/` (which exercise a single node in isolation). Use the per-node flows for smoke tests during development; use the flows here when you want to see how a real plant section behaves end-to-end.
@@ -8,25 +10,34 @@ These flows complement the per-node example flows under `nodes/<name>/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/<name>/` into `/data/projects/<name>/` 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 `<scenario>-<focus>`.
2. Include `flow.json` (Node-RED export) and `README.md` (topology, control modes, dashboard map, things to try).
3. Test it on a fresh Dockerized Node-RED — clean import, no errors, dashboard loads.
4. Add a row to the catalogue table above.
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/<name>/`.
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 <name>`.
## Wishlist for future examples

111
examples/WORKFLOW.md Normal file
View File

@@ -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 | <http://localhost:1880> · dashboard at <http://localhost:1880/dashboard> |
| `evolv-influxdb` | Time-series store (port-1 telemetry) | <http://localhost:8086> · `evolv` / `evolv-dev-pw` |
| `evolv-grafana` | Provisioned dashboards (anonymous viewer enabled) | <http://localhost:3000> |
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/<name>/` into `/data/projects/<name>/` 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=<name>` 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/<name>/build_flow.py
# 2. Regenerate flow.json
python3 examples/<name>/build_flow.py > examples/<name>/flow.json
# 3. Push to the runtime
./scripts/sync-example.sh <name>
```
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/<name>/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/<name>/flow.json examples/<name>/flow.json
```
Then commit `examples/<name>/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/<scenario>-<focus>
# Build a flow.json (recommended: a build_flow.py that generates it)
vim examples/<scenario>-<focus>/{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/<name>/` 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 <name>` |
| Wipe one project's volume copy and re-bootstrap | `docker exec evolv-nodered rm -rf /data/projects/<name>` 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.<key>` keys the dispatcher reads from |
| Grafana dashboard panels empty | Open InfluxDB UI (<http://localhost:8086>) → 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/<name>/
├── 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.

View File

@@ -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 <http://localhost:1880>, **Import → drop the `flow.json`**, click **Deploy**.
Then open the dashboard:
- <http://localhost:1880/dashboard/pumping-station-demo>
## 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 0300 m³/h (`manualDemand` topic)
- Random demand toggle (auto cycles every 3 s)
- Live "current demand" text
2. **Pumping Station**
- Auto/Manual mode toggle (drives all pumps' `setMode` simultaneously)
- Station-wide buttons: Start all · Stop all · Emergency stop
- Basin state, level (m), volume (m³), inflow / pumped-out flow (m³/h)
3. **Pump A / B / C** (one group each)
- Setpoint slider 0100 % (only effective when that pump is in `virtualControl`)
- Per-pump Startup + Shutdown buttons
- Live state, mode, controller %, flow, power, upstream/downstream pressure
4. **Trends**
- Flow per pump chart (m³/h)
- Power per pump chart (kW)
## Control model
- **AUTO** — the default. `setMode auto` → MGC's `optimalcontrol` decides which pumps run and at what flow. Operator drives only the **Process Demand** slider (or leaves the random generator on); the per-pump setpoint sliders are ignored.
- **MANUAL** — flip the Auto/Manual switch. All 3 pumps go to `virtualControl`. MGC commands are now ignored. Per-pump setpoint sliders / Start / Stop are the only inputs that affect the pumps.
The Emergency Stop button always works regardless of mode and uses the new interruptible-movement path so it stops a pump mid-ramp.
## Notable design choices
- **PS is in `manual` control mode** (`controlMode: "manual"`). The default `levelbased` mode would auto-shut all pumps as soon as basin level dips below `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.

File diff suppressed because it is too large Load Diff

View File

@@ -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): <http://localhost:1880/dashboard>
- Grafana dashboard (realtime gauges + historic graphs): <http://localhost:3000> (anonymous viewer is on; the dashboard is `EVOLV / Pumping Station (complete)`)
- InfluxDB UI: <http://localhost:8086> (user `evolv` / 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_<pump>` 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 (0250). 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/<name>/` on first container start with its own `git init` and a synthesized `package.json`. Switching between projects is two clicks in the editor: **menu → Projects → Open Project**.
| What you do | Where it lives | What persists |
|---|---|---|
| `docker compose down && up` | Container is recreated; named volume survives | Active flow + project list survive |
| Edit a flow in the Node-RED editor | `/data/projects/<name>/flow.json` (in volume) | Until `docker compose down -v` |
| Edit `examples/<name>/build_flow.py` then regenerate | `examples/<name>/flow.json` (in repo) | Always — it's in Git |
| Run `scripts/sync-example.sh <name>` | Copies repo's `flow.json` → volume's project + reloads | Volume copy now matches repo |
### Adding a new example as a project
1. Create `examples/<your-name>/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 `<your-name>`.
The bootstrap is idempotent: existing projects in the volume aren't overwritten. To force a refresh from the repo: delete the project in the volume (`docker exec evolv-nodered rm -rf /data/projects/<name>`) and restart, or use `scripts/sync-example.sh` for a flow-only refresh.
To start fresh (wipe all volume state including flows, sessions, project history): `docker compose down -v`.
## Notable design choices
- **PS in `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_<id>` 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.

File diff suppressed because it is too large Load Diff

36
scripts/sync-example.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/sh
# Copy examples/<name>/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 <project-name>
#
# 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'

30
test/README.md Normal file
View File

@@ -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/<name>/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.

View File

@@ -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}`
);
}
}

116
test/lib/recorder.js Normal file
View File

@@ -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 };

152
test/lib/wiring.js Normal file
View File

@@ -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.<position>" 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,
};