diff --git a/examples/01-Basic.json b/examples/01-Basic.json
index 56a4223..4bb75bd 100644
--- a/examples/01-Basic.json
+++ b/examples/01-Basic.json
@@ -1,340 +1,479 @@
[
- {
- "id": "ps_basic_tab",
- "type": "tab",
- "label": "PumpingStation - Basic",
- "disabled": false,
- "info": "Tier 1: single pumpingStation node driven by inject nodes only. Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand."
- },
- {
- "id": "ps_basic_title",
- "type": "comment",
- "z": "ps_basic_tab",
- "name": "PumpingStation - Basic\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nA 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\noverflow at 3.2 m). controlMode = levelbased, manual demand allowed\nonly when set.mode = manual.\n\nHOW TO USE:\n 1. Deploy the flow.\n 2. Click \"set.mode = manual\" so set.demand is honoured.\n 3. Click \"set.inflow = 60 m3/h\" to push wastewater into the basin.\n 4. Watch the basin fill on Port 0 (level, volume, percControl rise).\n 5. Click \"calibrate volume 25 m3\" to jump straight to half-full.\n\nAliases (changemode, q_in, Qd, …) still work but log a deprecation\nwarning - fresh flows use the canonical names.",
- "info": "",
- "x": 600,
- "y": 40,
- "wires": []
- },
- {
- "id": "ps_basic_inj_mode",
- "type": "inject",
- "z": "ps_basic_tab",
- "name": "set.mode = manual",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "manual",
- "vt": "str"
- }
- ],
- "topic": "set.mode",
- "repeat": "",
- "crontab": "",
- "once": false,
- "onceDelay": "",
- "x": 200,
- "y": 160,
- "wires": [
- [
- "ps_basic_node"
- ]
- ]
- },
- {
- "id": "ps_basic_inj_mode_lvl",
- "type": "inject",
- "z": "ps_basic_tab",
- "name": "set.mode = levelbased",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "levelbased",
- "vt": "str"
- }
- ],
- "topic": "set.mode",
- "repeat": "",
- "crontab": "",
- "once": false,
- "onceDelay": "",
- "x": 220,
- "y": 200,
- "wires": [
- [
- "ps_basic_node"
- ]
- ]
- },
- {
- "id": "ps_basic_inj_inflow",
- "type": "inject",
- "z": "ps_basic_tab",
- "name": "set.inflow = 60 m3/h",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "60",
- "vt": "num"
- }
- ],
- "topic": "set.inflow",
- "repeat": "",
- "crontab": "",
- "once": false,
- "onceDelay": "",
- "x": 200,
- "y": 260,
- "wires": [
- [
- "ps_basic_node"
- ]
- ]
- },
- {
- "id": "ps_basic_inj_demand",
- "type": "inject",
- "z": "ps_basic_tab",
- "name": "set.demand = 40 %",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "40",
- "vt": "num"
- }
- ],
- "topic": "set.demand",
- "repeat": "",
- "crontab": "",
- "once": false,
- "onceDelay": "",
- "x": 200,
- "y": 300,
- "wires": [
- [
- "ps_basic_node"
- ]
- ]
- },
- {
- "id": "ps_basic_inj_calvol",
- "type": "inject",
- "z": "ps_basic_tab",
- "name": "calibrate volume 25 m3",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "25",
- "vt": "num"
- }
- ],
- "topic": "cmd.calibrate.volume",
- "repeat": "",
- "crontab": "",
- "once": false,
- "onceDelay": "",
- "x": 220,
- "y": 360,
- "wires": [
- [
- "ps_basic_node"
- ]
- ]
- },
- {
- "id": "ps_basic_inj_callvl",
- "type": "inject",
- "z": "ps_basic_tab",
- "name": "calibrate level 1.5 m",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "1.5",
- "vt": "num"
- }
- ],
- "topic": "cmd.calibrate.level",
- "repeat": "",
- "crontab": "",
- "once": false,
- "onceDelay": "",
- "x": 220,
- "y": 400,
- "wires": [
- [
- "ps_basic_node"
- ]
- ]
- },
- {
- "id": "ps_basic_node",
- "type": "pumpingStation",
- "z": "ps_basic_tab",
- "name": "Pumping Station",
- "simulator": false,
- "basinVolume": 50,
- "basinHeight": 3.5,
- "inflowLevel": 3,
- "outflowLevel": 0.2,
- "overflowLevel": 3.2,
- "defaultFluid": "wastewater",
- "inletPipeDiameter": 0.3,
- "outletPipeDiameter": 0.3,
- "pipelineLength": 80,
- "maxDischargeHead": 24,
- "staticHead": 12,
- "maxInflowRate": 200,
- "temperatureReferenceDegC": 15,
- "timeleftToFullOrEmptyThresholdSeconds": 0,
- "enableDryRunProtection": true,
- "enableOverfillProtection": true,
- "dryRunThresholdPercent": 2,
- "overfillThresholdPercent": 98,
- "minHeightBasedOn": "outlet",
- "processOutputFormat": "process",
- "dbaseOutputFormat": "influxdb",
- "refHeight": "NAP",
- "basinBottomRef": 1,
- "uuid": "example-ps-001",
- "supplier": "WBD-RD",
- "category": "station",
- "assetType": "pumpingstation",
- "model": "demo-50m3",
- "unit": "m3/h",
- "enableLog": true,
- "logLevel": "info",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": "",
- "distanceUnit": "m",
- "distanceDescription": "",
- "controlMode": "levelbased",
- "startLevel": 1.2,
- "minLevel": 0.4,
- "maxLevel": 2.8,
- "flowSetpoint": null,
- "flowDeadband": null,
- "x": 1320,
- "y": 300,
- "wires": [
- [
- "ps_basic_format"
- ],
- [
- "ps_basic_dbg_influx"
- ],
- [
- "ps_basic_dbg_parent"
- ]
- ]
- },
- {
- "id": "ps_basic_format",
- "type": "function",
- "z": "ps_basic_tab",
- "name": "Merge deltas + format",
- "func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {};\nObject.assign(cache, p);\ncontext.set('c', cache);\nfunction pick(prefix) {\n for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n } return null;\n}\nconst vol = pick('volume.predicted.atequipment');\nconst lvl = pick('level.predicted.atequipment');\nconst flIn = pick('flow.predicted.in');\nmsg.payload = {\n state: cache.state || 'unknown',\n controlMode: cache.controlMode || cache.mode || 'n/a',\n direction: cache.direction || 'n/a',\n percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n};\nreturn msg;",
- "outputs": 1,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 1560,
- "y": 280,
- "wires": [
- [
- "ps_basic_dbg_process"
- ]
- ]
- },
- {
- "id": "ps_basic_dbg_process",
- "type": "debug",
- "z": "ps_basic_tab",
- "name": "Port 0: Process",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "payload",
- "targetType": "msg",
- "x": 1800,
- "y": 240,
- "wires": []
- },
- {
- "id": "ps_basic_dbg_influx",
- "type": "debug",
- "z": "ps_basic_tab",
- "name": "Port 1: InfluxDB",
- "active": false,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "true",
- "targetType": "full",
- "x": 1800,
- "y": 320,
- "wires": []
- },
- {
- "id": "ps_basic_dbg_parent",
- "type": "debug",
- "z": "ps_basic_tab",
- "name": "Port 2: Parent reg",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "true",
- "targetType": "full",
- "x": 1800,
- "y": 380,
- "wires": []
- },
- {
- "id": "grp_ps_basic",
- "type": "group",
- "z": "ps_basic_tab",
- "name": "Pumping Station (PC)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#0c99d9",
- "fill-opacity": "0.10"
+ {
+ "id": "77f00aef1c966167",
+ "type": "tab",
+ "label": "PumpingStation - Basic",
+ "disabled": false,
+ "info": "Tier 1: single pumpingStation node driven by inject nodes only. Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand."
},
- "nodes": [
- "ps_basic_node",
- "ps_basic_format"
- ],
- "x": 1290,
- "y": 230,
- "w": 500,
- "h": 140
- }
-]
+ {
+ "id": "aa3381b896eb2cfb",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "Pumping Station (Process Cell)",
+ "style": {
+ "label": true,
+ "stroke": "#000000",
+ "fill": "#0c99d9",
+ "fill-opacity": "0.10"
+ },
+ "nodes": [
+ "8e78b6607deb33a7"
+ ],
+ "x": 534,
+ "y": 351.5,
+ "w": 232,
+ "h": 97
+ },
+ {
+ "id": "4996420d47442fad",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "1. Control mode",
+ "style": {
+ "stroke": "#666666",
+ "fill": "#ffdf7f",
+ "fill-opacity": "0.15",
+ "label": true,
+ "color": "#333333"
+ },
+ "nodes": [
+ "1155bbbde7c65363",
+ "e9bea0f95b557f5d"
+ ],
+ "x": 94,
+ "y": 119,
+ "w": 272,
+ "h": 122
+ },
+ {
+ "id": "a9f9b38b0e00c1d7",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "2. Flow signals (inflow / outflow)",
+ "style": {
+ "stroke": "#666666",
+ "fill": "#ffdf7f",
+ "fill-opacity": "0.15",
+ "label": true,
+ "color": "#333333"
+ },
+ "nodes": [
+ "7b2b5eb919b1ab15",
+ "3350187815774b95"
+ ],
+ "x": 94,
+ "y": 279,
+ "w": 262,
+ "h": 122
+ },
+ {
+ "id": "42bf82c87d05f498",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "3. Operator demand (manual mode only)",
+ "style": {
+ "stroke": "#666666",
+ "fill": "#ffdf7f",
+ "fill-opacity": "0.15",
+ "label": true,
+ "color": "#333333"
+ },
+ "nodes": [
+ "48c2262c345c46b9"
+ ],
+ "x": 94,
+ "y": 479,
+ "w": 261,
+ "h": 82
+ },
+ {
+ "id": "234bdce20170061a",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "4. Calibration",
+ "style": {
+ "stroke": "#666666",
+ "fill": "#ffdf7f",
+ "fill-opacity": "0.15",
+ "label": true,
+ "color": "#333333"
+ },
+ "nodes": [
+ "463eefdd54df89a5",
+ "2e0642275899fc79"
+ ],
+ "x": 94,
+ "y": 599,
+ "w": 272,
+ "h": 122
+ },
+ {
+ "id": "f4ba4542514ed853",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "Expected outputs",
+ "style": {
+ "stroke": "#666666",
+ "fill": "#d1d1d1",
+ "fill-opacity": "0.2",
+ "label": true,
+ "color": "#333333"
+ },
+ "nodes": [
+ "b2450e5ee2eebfaa",
+ "386af1ad8aa8ed12",
+ "c27c2655f199b530"
+ ],
+ "x": 874,
+ "y": 299,
+ "w": 252,
+ "h": 202
+ },
+ {
+ "id": "b30af582f935bcb7",
+ "type": "comment",
+ "z": "77f00aef1c966167",
+ "name": "PumpingStation — Basic (Tier 1)",
+ "info": "Single pumpingStation node driven by inject buttons. Shows the canonical msg.topic command surface.\n\nDefault controlMode = levelbased. Switch to manual to honour set.demand.\n\nHOW TO USE\n1. Deploy the flow.\n2. (optional) Click \"set.mode = manual\" if you want set.demand to forward; otherwise leave it on levelbased and the ramp drives demand from level.\n3. Click \"set.inflow = 60 m³/h\" to push wastewater into the basin.\n4. Watch the basin fill on Port 0 (level, volume rise) and Port 1 (InfluxDB-shaped payload).\n5. In manual mode: click \"set.demand = 40\" — the value surfaces as `manualDemand` on Port 0/1 and in the node status badge.\n6. Click \"calibrate volume 25 m³\" or \"calibrate level 1.5 m\" to snap the predicted-volume integrator.\n\nPORTS\n- Port 0: process output (changed fields only)\n- Port 1: InfluxDB-shaped {measurement, fields, tags, timestamp}\n- Port 2: parent registration (child handshake)",
+ "x": 650,
+ "y": 300,
+ "wires": []
+ },
+ {
+ "id": "1155bbbde7c65363",
+ "type": "inject",
+ "z": "77f00aef1c966167",
+ "g": "4996420d47442fad",
+ "name": "set.mode = manual",
+ "props": [
+ {
+ "p": "topic",
+ "vt": "str"
+ },
+ {
+ "p": "payload",
+ "v": "manual",
+ "vt": "str"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": false,
+ "onceDelay": "",
+ "topic": "set.mode",
+ "x": 230,
+ "y": 160,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "e9bea0f95b557f5d",
+ "type": "inject",
+ "z": "77f00aef1c966167",
+ "g": "4996420d47442fad",
+ "name": "set.mode = levelbased",
+ "props": [
+ {
+ "p": "topic",
+ "vt": "str"
+ },
+ {
+ "p": "payload",
+ "v": "levelbased",
+ "vt": "str"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": false,
+ "onceDelay": "",
+ "topic": "set.mode",
+ "x": 240,
+ "y": 200,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "7b2b5eb919b1ab15",
+ "type": "inject",
+ "z": "77f00aef1c966167",
+ "g": "a9f9b38b0e00c1d7",
+ "name": "set.inflow = 60 m3/h",
+ "props": [
+ {
+ "p": "topic",
+ "vt": "str"
+ },
+ {
+ "p": "payload",
+ "v": "60",
+ "vt": "num"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": false,
+ "onceDelay": "",
+ "topic": "set.inflow",
+ "x": 240,
+ "y": 360,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "48c2262c345c46b9",
+ "type": "inject",
+ "z": "77f00aef1c966167",
+ "g": "42bf82c87d05f498",
+ "name": "set.demand = 40 %",
+ "props": [
+ {
+ "p": "topic",
+ "vt": "str"
+ },
+ {
+ "p": "payload",
+ "v": "40",
+ "vt": "num"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": false,
+ "onceDelay": "",
+ "topic": "set.demand",
+ "x": 230,
+ "y": 520,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "463eefdd54df89a5",
+ "type": "inject",
+ "z": "77f00aef1c966167",
+ "g": "234bdce20170061a",
+ "name": "calibrate volume 25 m3",
+ "props": [
+ {
+ "p": "topic",
+ "vt": "str"
+ },
+ {
+ "p": "payload",
+ "v": "25",
+ "vt": "num"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": false,
+ "onceDelay": "",
+ "topic": "cmd.calibrate.volume",
+ "x": 240,
+ "y": 640,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "2e0642275899fc79",
+ "type": "inject",
+ "z": "77f00aef1c966167",
+ "g": "234bdce20170061a",
+ "name": "calibrate level 1.5 m",
+ "props": [
+ {
+ "p": "topic",
+ "vt": "str"
+ },
+ {
+ "p": "payload",
+ "v": "1.5",
+ "vt": "num"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": false,
+ "onceDelay": "",
+ "topic": "cmd.calibrate.level",
+ "x": 240,
+ "y": 680,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "b2450e5ee2eebfaa",
+ "type": "debug",
+ "z": "77f00aef1c966167",
+ "g": "f4ba4542514ed853",
+ "name": "Port 0: Process",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "payload",
+ "targetType": "msg",
+ "x": 980,
+ "y": 340,
+ "wires": []
+ },
+ {
+ "id": "386af1ad8aa8ed12",
+ "type": "debug",
+ "z": "77f00aef1c966167",
+ "g": "f4ba4542514ed853",
+ "name": "Port 1: InfluxDB",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "x": 980,
+ "y": 400,
+ "wires": []
+ },
+ {
+ "id": "c27c2655f199b530",
+ "type": "debug",
+ "z": "77f00aef1c966167",
+ "g": "f4ba4542514ed853",
+ "name": "Port 2: Parent reg",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "x": 990,
+ "y": 460,
+ "wires": []
+ },
+ {
+ "id": "8e78b6607deb33a7",
+ "type": "pumpingStation",
+ "z": "77f00aef1c966167",
+ "g": "aa3381b896eb2cfb",
+ "name": "",
+ "simulator": false,
+ "basinVolume": 50,
+ "basinHeight": 4,
+ "inflowLevel": 1.5,
+ "outflowLevel": 0.2,
+ "overflowLevel": 3.8,
+ "defaultFluid": "wastewater",
+ "inletPipeDiameter": 0.3,
+ "outletPipeDiameter": 0.3,
+ "pipelineLength": 80,
+ "maxDischargeHead": 24,
+ "staticHead": 12,
+ "maxInflowRate": 200,
+ "temperatureReferenceDegC": 15,
+ "timeleftToFullOrEmptyThresholdSeconds": 0,
+ "enableDryRunProtection": true,
+ "enableHighVolumeSafety": true,
+ "enableOverfillProtection": true,
+ "dryRunThresholdPercent": 2,
+ "highVolumeSafetyThresholdPercent": 98,
+ "overfillThresholdPercent": 98,
+ "minHeightBasedOn": "outlet",
+ "processOutputFormat": "process",
+ "dbaseOutputFormat": "influxdb",
+ "refHeight": "NAP",
+ "basinBottomRef": 1,
+ "uuid": "",
+ "supplier": "",
+ "category": "",
+ "assetType": "",
+ "model": "",
+ "unit": "",
+ "enableLog": false,
+ "logLevel": "error",
+ "positionVsParent": "atEquipment",
+ "positionIcon": "⊥",
+ "hasDistance": false,
+ "distance": "",
+ "controlMode": "levelbased",
+ "levelCurveType": "linear",
+ "logCurveFactor": 9,
+ "enableShiftedRamp": false,
+ "shiftLevel": 0,
+ "shiftArmPercent": 95,
+ "startLevel": 1,
+ "stopLevel": 0.5,
+ "minLevel": 0.20400000000000001,
+ "maxLevel": 3.8,
+ "flowSetpoint": null,
+ "flowDeadband": null,
+ "x": 650,
+ "y": 400,
+ "wires": [
+ [
+ "b2450e5ee2eebfaa"
+ ],
+ [
+ "386af1ad8aa8ed12"
+ ],
+ [
+ "c27c2655f199b530"
+ ]
+ ]
+ },
+ {
+ "id": "3350187815774b95",
+ "type": "inject",
+ "z": "77f00aef1c966167",
+ "g": "a9f9b38b0e00c1d7",
+ "name": "set.outflow= 80 m3/h",
+ "props": [
+ {
+ "p": "topic",
+ "vt": "str"
+ },
+ {
+ "p": "payload"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": false,
+ "onceDelay": "",
+ "topic": "set.outflow",
+ "payload": "80",
+ "payloadType": "num",
+ "x": 230,
+ "y": 320,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "ef77c1819422a098",
+ "type": "global-config",
+ "env": [],
+ "modules": {
+ "EVOLV": "1.0.29"
+ }
+ }
+]
\ No newline at end of file
diff --git a/examples/02-Dashboard.json b/examples/02-Dashboard.json
new file mode 100644
index 0000000..ab52731
--- /dev/null
+++ b/examples/02-Dashboard.json
@@ -0,0 +1,1070 @@
+[
+ {
+ "id": "77f00aef1c966167",
+ "type": "tab",
+ "label": "PumpingStation - Dashboard",
+ "disabled": false,
+ "info": "Tier 2: single pumpingStation node driven by a FlowFuse dashboard. Same command surface as the Basic flow, plus live status rows, four trend charts, and a raw-output table."
+ },
+ {
+ "id": "aa3381b896eb2cfb",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "Pumping Station (Process Cell)",
+ "style": {
+ "label": true,
+ "stroke": "#000000",
+ "fill": "#0c99d9",
+ "fill-opacity": "0.10"
+ },
+ "nodes": [
+ "8e78b6607deb33a7"
+ ],
+ "x": 534,
+ "y": 371.5,
+ "w": 232,
+ "h": 97
+ },
+ {
+ "id": "4996420d47442fad",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "1. Control mode",
+ "style": {
+ "stroke": "#666666",
+ "fill": "#ffdf7f",
+ "fill-opacity": "0.15",
+ "label": true,
+ "color": "#333333"
+ },
+ "nodes": [
+ "ui_btn_mode_manual",
+ "ui_btn_mode_lvl"
+ ],
+ "x": 94,
+ "y": 119,
+ "w": 272,
+ "h": 122
+ },
+ {
+ "id": "a9f9b38b0e00c1d7",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "2. Flow signals (inflow / outflow)",
+ "style": {
+ "stroke": "#666666",
+ "fill": "#ffdf7f",
+ "fill-opacity": "0.15",
+ "label": true,
+ "color": "#333333"
+ },
+ "nodes": [
+ "ui_btn_inflow",
+ "ui_btn_outflow"
+ ],
+ "x": 94,
+ "y": 279,
+ "w": 272,
+ "h": 122
+ },
+ {
+ "id": "42bf82c87d05f498",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "3. Operator demand (manual mode only)",
+ "style": {
+ "stroke": "#666666",
+ "fill": "#ffdf7f",
+ "fill-opacity": "0.15",
+ "label": true,
+ "color": "#333333"
+ },
+ "nodes": [
+ "ui_btn_demand"
+ ],
+ "x": 94,
+ "y": 459,
+ "w": 261,
+ "h": 82
+ },
+ {
+ "id": "234bdce20170061a",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "4. Calibration",
+ "style": {
+ "stroke": "#666666",
+ "fill": "#ffdf7f",
+ "fill-opacity": "0.15",
+ "label": true,
+ "color": "#333333"
+ },
+ "nodes": [
+ "ui_btn_cal_vol",
+ "ui_btn_cal_lvl"
+ ],
+ "x": 94,
+ "y": 579,
+ "w": 272,
+ "h": 122
+ },
+ {
+ "id": "grp_status_panel",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "Live status, trends, raw output",
+ "style": {
+ "stroke": "#666666",
+ "fill": "#bde0fe",
+ "fill-opacity": "0.20",
+ "label": true,
+ "color": "#333333"
+ },
+ "nodes": [
+ "fn_status_split",
+ "ui_txt_mode",
+ "ui_txt_direction",
+ "ui_txt_level",
+ "ui_txt_volume",
+ "ui_txt_volpct",
+ "ui_txt_pct",
+ "ui_txt_demand",
+ "ui_chart_level",
+ "ui_chart_volume",
+ "ui_chart_volpct",
+ "ui_chart_flow",
+ "ui_tpl_raw"
+ ],
+ "x": 854,
+ "y": 99,
+ "w": 712,
+ "h": 642
+ },
+ {
+ "id": "f4ba4542514ed853",
+ "type": "group",
+ "z": "77f00aef1c966167",
+ "name": "Debug outputs (sidebar)",
+ "style": {
+ "stroke": "#666666",
+ "fill": "#d1d1d1",
+ "fill-opacity": "0.2",
+ "label": true,
+ "color": "#333333"
+ },
+ "nodes": [
+ "b2450e5ee2eebfaa",
+ "386af1ad8aa8ed12",
+ "c27c2655f199b530"
+ ],
+ "x": 854,
+ "y": 779,
+ "w": 252,
+ "h": 202
+ },
+ {
+ "id": "b30af582f935bcb7",
+ "type": "comment",
+ "z": "77f00aef1c966167",
+ "name": "PumpingStation — Dashboard (Tier 2)",
+ "info": "Same command surface as the Basic example, driven by a FlowFuse dashboard.\n\nOpen /dashboard/pumpingstation-basic after deploy.\n\nCONTROLS panel\n- Mode buttons → set.mode (manual / levelbased)\n- Inflow / Outflow buttons → set.inflow / set.outflow (60 / 80 m³/h)\n- Demand button → set.demand (40 m³/h, manual mode only)\n- Calibrate buttons → cmd.calibrate.volume / cmd.calibrate.level\n\nSTATUS panel\n- 7 text rows: Mode, Direction, Level, Volume, Volume %, percControl, Manual demand\n\nTRENDS panel\n- 4 charts: Level (m), Volume (m³), Volume %, Flow (in/out/net m³/h)\n\nRAW OUTPUT panel\n- Full key/value dump of the latest Port 0 cache (sorted). Shows every field the node emits including basin geometry, safety thresholds, predicted overflow/underflow.\n\nThe fan-out function caches last-known values so delta-only Port 0 updates never blank a row.",
+ "x": 660,
+ "y": 320,
+ "wires": []
+ },
+ {
+ "id": "ui_base_ps",
+ "type": "ui-base",
+ "name": "EVOLV Demo",
+ "path": "/dashboard",
+ "appIcon": "",
+ "includeClientData": true,
+ "acceptsClientConfig": [
+ "ui-notification",
+ "ui-control"
+ ],
+ "showPathInSidebar": false,
+ "headerContent": "page",
+ "navigationStyle": "default",
+ "titleBarStyle": "default"
+ },
+ {
+ "id": "ui_theme_ps",
+ "type": "ui-theme",
+ "name": "EVOLV Basic Theme",
+ "colors": {
+ "surface": "#ffffff",
+ "primary": "#0c99d9",
+ "bgPage": "#eeeeee",
+ "groupBg": "#ffffff",
+ "groupOutline": "#cccccc"
+ },
+ "sizes": {
+ "density": "default",
+ "pagePadding": "14px",
+ "groupGap": "14px",
+ "groupBorderRadius": "6px",
+ "widgetGap": "12px"
+ }
+ },
+ {
+ "id": "ui_page_ps",
+ "type": "ui-page",
+ "name": "PumpingStation Basic",
+ "ui": "ui_base_ps",
+ "path": "/pumpingstation-basic",
+ "icon": "water-pump",
+ "layout": "grid",
+ "theme": "ui_theme_ps",
+ "breakpoints": [
+ {
+ "name": "Default",
+ "px": "0",
+ "cols": "12"
+ }
+ ],
+ "order": 1,
+ "className": ""
+ },
+ {
+ "id": "ui_group_ctrl",
+ "type": "ui-group",
+ "name": "Controls",
+ "page": "ui_page_ps",
+ "width": "6",
+ "height": "1",
+ "order": 1,
+ "showTitle": true,
+ "className": ""
+ },
+ {
+ "id": "ui_group_status",
+ "type": "ui-group",
+ "name": "Status",
+ "page": "ui_page_ps",
+ "width": "6",
+ "height": "1",
+ "order": 2,
+ "showTitle": true,
+ "className": ""
+ },
+ {
+ "id": "ui_group_trends",
+ "type": "ui-group",
+ "name": "Trends",
+ "page": "ui_page_ps",
+ "width": "12",
+ "height": "1",
+ "order": 3,
+ "showTitle": true,
+ "className": ""
+ },
+ {
+ "id": "ui_group_raw",
+ "type": "ui-group",
+ "name": "Raw output (Port 0 cache)",
+ "page": "ui_page_ps",
+ "width": "12",
+ "height": "1",
+ "order": 4,
+ "showTitle": true,
+ "className": ""
+ },
+ {
+ "id": "ui_btn_mode_manual",
+ "type": "ui-button",
+ "z": "77f00aef1c966167",
+ "g": "4996420d47442fad",
+ "group": "ui_group_ctrl",
+ "name": "Mode: Manual",
+ "label": "Mode: Manual",
+ "order": 1,
+ "width": "3",
+ "height": "1",
+ "emulateClick": false,
+ "tooltip": "Switch control mode to manual (set.demand is honoured)",
+ "color": "",
+ "bgcolor": "",
+ "icon": "pan_tool",
+ "payload": "manual",
+ "payloadType": "str",
+ "topic": "set.mode",
+ "topicType": "str",
+ "x": 230,
+ "y": 160,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "ui_btn_mode_lvl",
+ "type": "ui-button",
+ "z": "77f00aef1c966167",
+ "g": "4996420d47442fad",
+ "group": "ui_group_ctrl",
+ "name": "Mode: Levelbased",
+ "label": "Mode: Levelbased",
+ "order": 2,
+ "width": "3",
+ "height": "1",
+ "emulateClick": false,
+ "tooltip": "Switch control mode to levelbased (ramp drives demand from level)",
+ "color": "",
+ "bgcolor": "",
+ "icon": "stacked_line_chart",
+ "payload": "levelbased",
+ "payloadType": "str",
+ "topic": "set.mode",
+ "topicType": "str",
+ "x": 240,
+ "y": 200,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "ui_btn_inflow",
+ "type": "ui-button",
+ "z": "77f00aef1c966167",
+ "g": "a9f9b38b0e00c1d7",
+ "group": "ui_group_ctrl",
+ "name": "Inflow 60 m³/h",
+ "label": "Inflow 60 m³/h",
+ "order": 3,
+ "width": "3",
+ "height": "1",
+ "emulateClick": false,
+ "tooltip": "Push a measured inflow of 60 m³/h into the basin balance",
+ "color": "",
+ "bgcolor": "",
+ "icon": "south",
+ "payload": "60",
+ "payloadType": "num",
+ "topic": "set.inflow",
+ "topicType": "str",
+ "x": 240,
+ "y": 320,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "ui_btn_outflow",
+ "type": "ui-button",
+ "z": "77f00aef1c966167",
+ "g": "a9f9b38b0e00c1d7",
+ "group": "ui_group_ctrl",
+ "name": "Outflow 80 m³/h",
+ "label": "Outflow 80 m³/h",
+ "order": 4,
+ "width": "3",
+ "height": "1",
+ "emulateClick": false,
+ "tooltip": "Push a measured outflow of 80 m³/h into the basin balance",
+ "color": "",
+ "bgcolor": "",
+ "icon": "north",
+ "payload": "80",
+ "payloadType": "num",
+ "topic": "set.outflow",
+ "topicType": "str",
+ "x": 240,
+ "y": 360,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "ui_btn_demand",
+ "type": "ui-button",
+ "z": "77f00aef1c966167",
+ "g": "42bf82c87d05f498",
+ "group": "ui_group_ctrl",
+ "name": "Demand 40 m³/h",
+ "label": "Demand 40 m³/h (manual)",
+ "order": 5,
+ "width": "6",
+ "height": "1",
+ "emulateClick": false,
+ "tooltip": "Operator outflow demand — only forwarded when mode = manual",
+ "color": "",
+ "bgcolor": "",
+ "icon": "speed",
+ "payload": "40",
+ "payloadType": "num",
+ "topic": "set.demand",
+ "topicType": "str",
+ "x": 240,
+ "y": 500,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "ui_btn_cal_vol",
+ "type": "ui-button",
+ "z": "77f00aef1c966167",
+ "g": "234bdce20170061a",
+ "group": "ui_group_ctrl",
+ "name": "Calibrate V=25 m³",
+ "label": "Calibrate V = 25 m³",
+ "order": 6,
+ "width": "3",
+ "height": "1",
+ "emulateClick": false,
+ "tooltip": "Snap the predicted-volume integrator to 25 m³",
+ "color": "",
+ "bgcolor": "",
+ "icon": "tune",
+ "payload": "25",
+ "payloadType": "num",
+ "topic": "cmd.calibrate.volume",
+ "topicType": "str",
+ "x": 240,
+ "y": 620,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "ui_btn_cal_lvl",
+ "type": "ui-button",
+ "z": "77f00aef1c966167",
+ "g": "234bdce20170061a",
+ "group": "ui_group_ctrl",
+ "name": "Calibrate L=1.5 m",
+ "label": "Calibrate L = 1.5 m",
+ "order": 7,
+ "width": "3",
+ "height": "1",
+ "emulateClick": false,
+ "tooltip": "Snap the predicted-volume integrator to a known level of 1.5 m",
+ "color": "",
+ "bgcolor": "",
+ "icon": "tune",
+ "payload": "1.5",
+ "payloadType": "num",
+ "topic": "cmd.calibrate.level",
+ "topicType": "str",
+ "x": 240,
+ "y": 660,
+ "wires": [
+ [
+ "8e78b6607deb33a7"
+ ]
+ ]
+ },
+ {
+ "id": "fn_status_split",
+ "type": "function",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "name": "fan-out Port 0 (status + charts + raw)",
+ "func": "// Port 0 emits delta-only — cache last-known so deltas never blank a row.\n// Keys with dots use the runtime childId (= node id), so we pattern-match\n// by prefix rather than hardcoding.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\nconst findByPrefix = (prefix) => {\n for (const k of Object.keys(cache)) if (k.startsWith(prefix)) return cache[k];\n return null;\n};\nconst num = (v, dp, unit) => {\n const n = +v;\n if (!Number.isFinite(n)) return '—';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\n\nconst level = findByPrefix('level.predicted.atequipment.');\nconst volume = findByPrefix('volume.predicted.atequipment.');\nconst volPct = findByPrefix('volumePercent.predicted.atequipment.');\nconst qInS = findByPrefix('flow.predicted.in.');\nconst qOutS = findByPrefix('flow.predicted.out.');\nconst qNetS = findByPrefix('netFlowRate.predicted.atequipment.');\nconst qInH = Number.isFinite(+qInS) ? +qInS * 3600 : null;\nconst qOutH = Number.isFinite(+qOutS) ? +qOutS * 3600 : null;\nconst qNetH = Number.isFinite(+qNetS) ? +qNetS * 3600 : null;\nconst pct = cache.percControl;\nconst dem = cache.manualDemand;\nconst mode = cache.mode || '—';\nconst dir = cache.direction || '—';\n\nconst chart = (topic, v) => Number.isFinite(+v) ? { topic, payload: +v } : null;\n\n// Raw view: every cached key, sorted, with values prettified for display.\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '—';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 0–6: status text widgets\n { payload: mode },\n { payload: dir },\n { payload: num(level, 2, 'm') },\n { payload: num(volume, 2, 'm³') },\n { payload: num(volPct, 2, '%') },\n { payload: num(pct, 1, '%') },\n { payload: mode === 'manual'\n ? (Number.isFinite(+dem) ? num(dem, 1, 'm³/h') : 'not set')\n : '—' },\n // 7–9: single-series charts\n chart('Level', level),\n chart('Volume', volume),\n chart('Volume %', volPct),\n // 10–12: flow chart (three series share the same chart node)\n chart('Inflow', qInH),\n chart('Outflow', qOutH),\n chart('Net', qNetH),\n // 13: raw key/value rows for the ui-template\n { payload: rawRows },\n];\n",
+ "outputs": 14,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 980,
+ "y": 140,
+ "wires": [
+ [
+ "ui_txt_mode"
+ ],
+ [
+ "ui_txt_direction"
+ ],
+ [
+ "ui_txt_level"
+ ],
+ [
+ "ui_txt_volume"
+ ],
+ [
+ "ui_txt_volpct"
+ ],
+ [
+ "ui_txt_pct"
+ ],
+ [
+ "ui_txt_demand"
+ ],
+ [
+ "ui_chart_level"
+ ],
+ [
+ "ui_chart_volume"
+ ],
+ [
+ "ui_chart_volpct"
+ ],
+ [
+ "ui_chart_flow"
+ ],
+ [
+ "ui_chart_flow"
+ ],
+ [
+ "ui_chart_flow"
+ ],
+ [
+ "ui_tpl_raw"
+ ]
+ ]
+ },
+ {
+ "id": "ui_txt_mode",
+ "type": "ui-text",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_status",
+ "order": 1,
+ "width": "6",
+ "height": "1",
+ "name": "Mode",
+ "label": "Mode",
+ "format": "{{msg.payload}}",
+ "layout": "row-spread",
+ "style": false,
+ "font": "",
+ "fontSize": 14,
+ "color": "#1F4E79",
+ "x": 1240,
+ "y": 100,
+ "wires": []
+ },
+ {
+ "id": "ui_txt_direction",
+ "type": "ui-text",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_status",
+ "order": 2,
+ "width": "6",
+ "height": "1",
+ "name": "Direction",
+ "label": "Direction",
+ "format": "{{msg.payload}}",
+ "layout": "row-spread",
+ "style": false,
+ "font": "",
+ "fontSize": 14,
+ "color": "#1F4E79",
+ "x": 1250,
+ "y": 140,
+ "wires": []
+ },
+ {
+ "id": "ui_txt_level",
+ "type": "ui-text",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_status",
+ "order": 3,
+ "width": "6",
+ "height": "1",
+ "name": "Level",
+ "label": "Level",
+ "format": "{{msg.payload}}",
+ "layout": "row-spread",
+ "style": false,
+ "font": "",
+ "fontSize": 14,
+ "color": "#1F4E79",
+ "x": 1240,
+ "y": 180,
+ "wires": []
+ },
+ {
+ "id": "ui_txt_volume",
+ "type": "ui-text",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_status",
+ "order": 4,
+ "width": "6",
+ "height": "1",
+ "name": "Volume",
+ "label": "Volume",
+ "format": "{{msg.payload}}",
+ "layout": "row-spread",
+ "style": false,
+ "font": "",
+ "fontSize": 14,
+ "color": "#1F4E79",
+ "x": 1250,
+ "y": 220,
+ "wires": []
+ },
+ {
+ "id": "ui_txt_volpct",
+ "type": "ui-text",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_status",
+ "order": 5,
+ "width": "6",
+ "height": "1",
+ "name": "Volume %",
+ "label": "Volume %",
+ "format": "{{msg.payload}}",
+ "layout": "row-spread",
+ "style": false,
+ "font": "",
+ "fontSize": 14,
+ "color": "#1F4E79",
+ "x": 1250,
+ "y": 260,
+ "wires": []
+ },
+ {
+ "id": "ui_txt_pct",
+ "type": "ui-text",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_status",
+ "order": 6,
+ "width": "6",
+ "height": "1",
+ "name": "percControl",
+ "label": "percControl",
+ "format": "{{msg.payload}}",
+ "layout": "row-spread",
+ "style": false,
+ "font": "",
+ "fontSize": 14,
+ "color": "#1F4E79",
+ "x": 1260,
+ "y": 300,
+ "wires": []
+ },
+ {
+ "id": "ui_txt_demand",
+ "type": "ui-text",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_status",
+ "order": 7,
+ "width": "6",
+ "height": "1",
+ "name": "Manual demand",
+ "label": "Manual demand",
+ "format": "{{msg.payload}}",
+ "layout": "row-spread",
+ "style": false,
+ "font": "",
+ "fontSize": 14,
+ "color": "#7D3C98",
+ "x": 1270,
+ "y": 340,
+ "wires": []
+ },
+ {
+ "id": "ui_chart_level",
+ "type": "ui-chart",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_trends",
+ "name": "Level (m)",
+ "label": "Level (m)",
+ "order": 1,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "category": "topic",
+ "categoryType": "msg",
+ "xAxisLabel": "time",
+ "xAxisType": "time",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisLabel": "m",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "",
+ "ymax": "",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "removeOlderPoints": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "showLegend": false,
+ "className": "",
+ "colors": [
+ "#0095FF",
+ "#FF0000",
+ "#FF7F0E",
+ "#2CA02C",
+ "#A347E1",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true,
+ "x": 1240,
+ "y": 400,
+ "wires": []
+ },
+ {
+ "id": "ui_chart_volume",
+ "type": "ui-chart",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_trends",
+ "name": "Volume (m³)",
+ "label": "Volume (m³)",
+ "order": 2,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "category": "topic",
+ "categoryType": "msg",
+ "xAxisLabel": "time",
+ "xAxisType": "time",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisLabel": "m³",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "",
+ "ymax": "",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "removeOlderPoints": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "showLegend": false,
+ "className": "",
+ "colors": [
+ "#2CA02C",
+ "#FF0000",
+ "#FF7F0E",
+ "#0095FF",
+ "#A347E1",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true,
+ "x": 1250,
+ "y": 440,
+ "wires": []
+ },
+ {
+ "id": "ui_chart_volpct",
+ "type": "ui-chart",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_trends",
+ "name": "Volume %",
+ "label": "Volume %",
+ "order": 3,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "category": "topic",
+ "categoryType": "msg",
+ "xAxisLabel": "time",
+ "xAxisType": "time",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisLabel": "%",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "0",
+ "ymax": "100",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "removeOlderPoints": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "showLegend": false,
+ "className": "",
+ "colors": [
+ "#A347E1",
+ "#FF0000",
+ "#FF7F0E",
+ "#2CA02C",
+ "#0095FF",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true,
+ "x": 1240,
+ "y": 480,
+ "wires": []
+ },
+ {
+ "id": "ui_chart_flow",
+ "type": "ui-chart",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_trends",
+ "name": "Flow (m³/h)",
+ "label": "Flow (m³/h) — Inflow / Outflow / Net",
+ "order": 4,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "category": "topic",
+ "categoryType": "msg",
+ "xAxisLabel": "time",
+ "xAxisType": "time",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisLabel": "m³/h",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "",
+ "ymax": "",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "removeOlderPoints": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "showLegend": true,
+ "className": "",
+ "colors": [
+ "#0095FF",
+ "#FF7F0E",
+ "#2CA02C",
+ "#FF0000",
+ "#A347E1",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true,
+ "x": 1250,
+ "y": 520,
+ "wires": []
+ },
+ {
+ "id": "ui_tpl_raw",
+ "type": "ui-template",
+ "z": "77f00aef1c966167",
+ "g": "grp_status_panel",
+ "group": "ui_group_raw",
+ "name": "Raw output table",
+ "order": 1,
+ "width": "12",
+ "height": "8",
+ "head": "",
+ "format": "\n \n
\n \n | {{ row.key }} | \n {{ row.value }} | \n
\n
\n
\n\n\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 1260,
+ "y": 580,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "b2450e5ee2eebfaa",
+ "type": "debug",
+ "z": "77f00aef1c966167",
+ "g": "f4ba4542514ed853",
+ "name": "Port 0: Process",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "payload",
+ "targetType": "msg",
+ "x": 980,
+ "y": 820,
+ "wires": []
+ },
+ {
+ "id": "386af1ad8aa8ed12",
+ "type": "debug",
+ "z": "77f00aef1c966167",
+ "g": "f4ba4542514ed853",
+ "name": "Port 1: InfluxDB",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "x": 980,
+ "y": 880,
+ "wires": []
+ },
+ {
+ "id": "c27c2655f199b530",
+ "type": "debug",
+ "z": "77f00aef1c966167",
+ "g": "f4ba4542514ed853",
+ "name": "Port 2: Parent reg",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "x": 990,
+ "y": 940,
+ "wires": []
+ },
+ {
+ "id": "8e78b6607deb33a7",
+ "type": "pumpingStation",
+ "z": "77f00aef1c966167",
+ "g": "aa3381b896eb2cfb",
+ "name": "",
+ "simulator": false,
+ "basinVolume": 50,
+ "basinHeight": 4,
+ "inflowLevel": 1.5,
+ "outflowLevel": 0.2,
+ "overflowLevel": 3.8,
+ "defaultFluid": "wastewater",
+ "inletPipeDiameter": 0.3,
+ "outletPipeDiameter": 0.3,
+ "pipelineLength": 80,
+ "maxDischargeHead": 24,
+ "staticHead": 12,
+ "maxInflowRate": 200,
+ "temperatureReferenceDegC": 15,
+ "timeleftToFullOrEmptyThresholdSeconds": 0,
+ "enableDryRunProtection": true,
+ "enableHighVolumeSafety": true,
+ "enableOverfillProtection": true,
+ "dryRunThresholdPercent": 2,
+ "highVolumeSafetyThresholdPercent": 98,
+ "overfillThresholdPercent": 98,
+ "minHeightBasedOn": "outlet",
+ "processOutputFormat": "process",
+ "dbaseOutputFormat": "influxdb",
+ "refHeight": "NAP",
+ "basinBottomRef": 1,
+ "uuid": "",
+ "supplier": "",
+ "category": "",
+ "assetType": "",
+ "model": "",
+ "unit": "",
+ "enableLog": false,
+ "logLevel": "error",
+ "positionVsParent": "atEquipment",
+ "positionIcon": "⊥",
+ "hasDistance": false,
+ "distance": "",
+ "controlMode": "levelbased",
+ "levelCurveType": "linear",
+ "logCurveFactor": 9,
+ "enableShiftedRamp": false,
+ "shiftLevel": 0,
+ "shiftArmPercent": 95,
+ "startLevel": 1,
+ "stopLevel": 0.5,
+ "minLevel": 0.3,
+ "maxLevel": 3.8,
+ "flowSetpoint": null,
+ "flowDeadband": null,
+ "x": 650,
+ "y": 420,
+ "wires": [
+ [
+ "b2450e5ee2eebfaa",
+ "fn_status_split"
+ ],
+ [
+ "386af1ad8aa8ed12"
+ ],
+ [
+ "c27c2655f199b530"
+ ]
+ ]
+ },
+ {
+ "id": "ef77c1819422a098",
+ "type": "global-config",
+ "env": [],
+ "modules": {
+ "EVOLV": "1.0.29"
+ }
+ }
+]
diff --git a/examples/02-Integration.json b/examples/02-Integration.json
deleted file mode 100644
index ac386dc..0000000
--- a/examples/02-Integration.json
+++ /dev/null
@@ -1,686 +0,0 @@
-[
- {
- "id": "ps_int_proc",
- "type": "tab",
- "label": "Process Plant",
- "disabled": false,
- "info": "Tier 2: pumpingStation + measurement child + machineGroupControl parent with two rotatingMachine pumps. Demonstrates Phase-2 parent/child handshakes and the canonical set.mode/set.inflow/set.demand topics."
- },
- {
- "id": "ps_int_setup",
- "type": "tab",
- "label": "Setup",
- "disabled": false,
- "info": "Deploy-time once-true injects that initialise control modes on the EVOLV nodes."
- },
- {
- "id": "ps_int_title",
- "type": "comment",
- "z": "ps_int_proc",
- "name": "PumpingStation - Integration\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nL0 link-ins | L2 level sensor (CM) | L3 pumps (EM) | L4 MGC (UN) | L5 station (PC).\nPumps register with MGC via Port 2; MGC and the level sensor register with the station via Port 2.\nCross-tab channels: setup:* drive once-true initialisation from the Setup tab.",
- "info": "",
- "x": 600,
- "y": 40,
- "wires": []
- },
- {
- "id": "lin_setup_mode",
- "type": "link in",
- "z": "ps_int_proc",
- "name": "setup:to-ps-mode",
- "links": [],
- "x": 120,
- "y": 500,
- "wires": [
- [
- "ps_int_station"
- ]
- ]
- },
- {
- "id": "lin_setup_inflow",
- "type": "link in",
- "z": "ps_int_proc",
- "name": "setup:to-ps-inflow",
- "links": [],
- "x": 120,
- "y": 560,
- "wires": [
- [
- "ps_int_station"
- ]
- ]
- },
- {
- "id": "lin_setup_mgcmode",
- "type": "link in",
- "z": "ps_int_proc",
- "name": "setup:to-mgc-mode",
- "links": [],
- "x": 120,
- "y": 360,
- "wires": [
- [
- "ps_int_mgc"
- ]
- ]
- },
- {
- "id": "meas_level",
- "type": "measurement",
- "z": "ps_int_proc",
- "name": "Basin level sensor",
- "mode": "analog",
- "channels": "[]",
- "scaling": false,
- "i_min": 0,
- "i_max": 0,
- "i_offset": 0,
- "o_min": 0,
- "o_max": 1,
- "simulator": true,
- "smooth_method": "mean",
- "count": 5,
- "processOutputFormat": "process",
- "dbaseOutputFormat": "influxdb",
- "uuid": "example-level-001",
- "supplier": "vega",
- "category": "sensor",
- "assetType": "level",
- "model": "VEGAPULS-31",
- "unit": "m",
- "assetTagNumber": "LT-001",
- "enableLog": false,
- "logLevel": "error",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": 0,
- "distanceUnit": "m",
- "distanceDescription": "",
- "x": 600,
- "y": 700,
- "wires": [
- [
- "ps_int_dbg_level"
- ],
- [],
- [
- "ps_int_station"
- ]
- ]
- },
- {
- "id": "ps_int_inj_level",
- "type": "inject",
- "z": "ps_int_proc",
- "name": "sim level 1.6 m",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "1.6",
- "vt": "num"
- }
- ],
- "topic": "measurement",
- "repeat": "",
- "crontab": "",
- "once": false,
- "onceDelay": "",
- "x": 120,
- "y": 700,
- "wires": [
- [
- "meas_level"
- ]
- ]
- },
- {
- "id": "pump_a",
- "type": "rotatingMachine",
- "z": "ps_int_proc",
- "name": "Pump A",
- "speed": "1",
- "startup": "2",
- "warmup": "1",
- "shutdown": "2",
- "cooldown": "1",
- "movementMode": "staticspeed",
- "machineCurve": "",
- "uuid": "example-pump-a",
- "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": "error",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": "",
- "distanceUnit": "m",
- "distanceDescription": "",
- "x": 840,
- "y": 320,
- "wires": [
- [
- "ps_int_dbg_pa"
- ],
- [],
- [
- "ps_int_mgc"
- ]
- ]
- },
- {
- "id": "pump_b",
- "type": "rotatingMachine",
- "z": "ps_int_proc",
- "name": "Pump B",
- "speed": "1",
- "startup": "2",
- "warmup": "1",
- "shutdown": "2",
- "cooldown": "1",
- "movementMode": "staticspeed",
- "machineCurve": "",
- "uuid": "example-pump-b",
- "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": "error",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": "",
- "distanceUnit": "m",
- "distanceDescription": "",
- "x": 840,
- "y": 400,
- "wires": [
- [
- "ps_int_dbg_pb"
- ],
- [],
- [
- "ps_int_mgc"
- ]
- ]
- },
- {
- "id": "ps_int_mgc",
- "type": "machineGroupControl",
- "z": "ps_int_proc",
- "name": "Pump Group",
- "enableLog": true,
- "logLevel": "info",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": "",
- "distanceUnit": "m",
- "x": 1080,
- "y": 360,
- "wires": [
- [
- "ps_int_dbg_mgc"
- ],
- [],
- [
- "ps_int_station"
- ]
- ]
- },
- {
- "id": "ps_int_station",
- "type": "pumpingStation",
- "z": "ps_int_proc",
- "name": "Pumping Station",
- "simulator": false,
- "basinVolume": 50,
- "basinHeight": 3.5,
- "inflowLevel": 3,
- "outflowLevel": 0.2,
- "overflowLevel": 3.2,
- "defaultFluid": "wastewater",
- "inletPipeDiameter": 0.3,
- "outletPipeDiameter": 0.3,
- "pipelineLength": 80,
- "maxDischargeHead": 24,
- "staticHead": 12,
- "maxInflowRate": 200,
- "temperatureReferenceDegC": 15,
- "timeleftToFullOrEmptyThresholdSeconds": 0,
- "enableDryRunProtection": true,
- "enableOverfillProtection": true,
- "dryRunThresholdPercent": 2,
- "overfillThresholdPercent": 98,
- "minHeightBasedOn": "outlet",
- "processOutputFormat": "process",
- "dbaseOutputFormat": "influxdb",
- "refHeight": "NAP",
- "basinBottomRef": 1,
- "uuid": "example-ps-001",
- "supplier": "WBD-RD",
- "category": "station",
- "assetType": "pumpingstation",
- "model": "demo-50m3",
- "unit": "m3/h",
- "enableLog": true,
- "logLevel": "info",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": "",
- "distanceUnit": "m",
- "distanceDescription": "",
- "controlMode": "levelbased",
- "startLevel": 1.2,
- "minLevel": 0.4,
- "maxLevel": 2.8,
- "flowSetpoint": null,
- "flowDeadband": null,
- "x": 1320,
- "y": 520,
- "wires": [
- [
- "ps_int_format"
- ],
- [
- "ps_int_dbg_influx"
- ],
- []
- ]
- },
- {
- "id": "ps_int_format",
- "type": "function",
- "z": "ps_int_proc",
- "name": "Merge deltas + format",
- "func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\nfunction pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\nconst vol=pick('volume.predicted.atequipment'), lvl=pick('level.predicted.atequipment'), flIn=pick('flow.predicted.in'), flOut=pick('flow.predicted.out');\nmsg.payload = {\n state: cache.state || 'unknown',\n controlMode: cache.controlMode || cache.mode || 'n/a',\n direction: cache.direction || 'n/a',\n percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1)+' %' : 'n/a',\n volume: vol != null ? vol.toFixed(2)+' m3' : 'n/a',\n volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1)+' %' : 'n/a',\n level: lvl != null ? lvl.toFixed(3)+' m' : 'n/a',\n inflow: flIn != null ? (flIn*3600).toFixed(1)+' m3/h' : 'n/a',\n outflow: flOut != null ? (flOut*3600).toFixed(1)+' m3/h' : 'n/a',\n childCount: cache.childCount != null ? cache.childCount : 'n/a'\n};\nreturn msg;",
- "outputs": 1,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 1560,
- "y": 520,
- "wires": [
- [
- "ps_int_dbg_process"
- ]
- ]
- },
- {
- "id": "ps_int_dbg_process",
- "type": "debug",
- "z": "ps_int_proc",
- "name": "PS Port 0: Process",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "payload",
- "targetType": "msg",
- "x": 1800,
- "y": 480,
- "wires": []
- },
- {
- "id": "ps_int_dbg_influx",
- "type": "debug",
- "z": "ps_int_proc",
- "name": "PS Port 1: InfluxDB",
- "active": false,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "true",
- "targetType": "full",
- "x": 1800,
- "y": 540,
- "wires": []
- },
- {
- "id": "ps_int_dbg_mgc",
- "type": "debug",
- "z": "ps_int_proc",
- "name": "MGC Port 0",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "payload",
- "targetType": "msg",
- "x": 1800,
- "y": 360,
- "wires": []
- },
- {
- "id": "ps_int_dbg_pa",
- "type": "debug",
- "z": "ps_int_proc",
- "name": "Pump A Port 0",
- "active": false,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "payload",
- "targetType": "msg",
- "x": 1800,
- "y": 320,
- "wires": []
- },
- {
- "id": "ps_int_dbg_pb",
- "type": "debug",
- "z": "ps_int_proc",
- "name": "Pump B Port 0",
- "active": false,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "payload",
- "targetType": "msg",
- "x": 1800,
- "y": 400,
- "wires": []
- },
- {
- "id": "ps_int_dbg_level",
- "type": "debug",
- "z": "ps_int_proc",
- "name": "Level Port 0",
- "active": false,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "payload",
- "targetType": "msg",
- "x": 1800,
- "y": 700,
- "wires": []
- },
- {
- "id": "grp_pumpa",
- "type": "group",
- "z": "ps_int_proc",
- "name": "Pump A (EM)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#86bbdd",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "pump_a",
- "ps_int_dbg_pa"
- ],
- "x": 815,
- "y": 275,
- "w": 1210,
- "h": 110
- },
- {
- "id": "grp_pumpb",
- "type": "group",
- "z": "ps_int_proc",
- "name": "Pump B (EM)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#86bbdd",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "pump_b",
- "ps_int_dbg_pb"
- ],
- "x": 815,
- "y": 355,
- "w": 1210,
- "h": 110
- },
- {
- "id": "grp_mgc",
- "type": "group",
- "z": "ps_int_proc",
- "name": "Pump Group MGC (UN)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#50a8d9",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "ps_int_mgc",
- "ps_int_dbg_mgc",
- "lin_setup_mgcmode"
- ],
- "x": 95,
- "y": 315,
- "w": 1930,
- "h": 110
- },
- {
- "id": "grp_station",
- "type": "group",
- "z": "ps_int_proc",
- "name": "Pumping Station (PC)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#0c99d9",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "ps_int_station",
- "ps_int_format",
- "ps_int_dbg_process",
- "ps_int_dbg_influx",
- "lin_setup_mode",
- "lin_setup_inflow"
- ],
- "x": 95,
- "y": 435,
- "w": 1930,
- "h": 190
- },
- {
- "id": "grp_level",
- "type": "group",
- "z": "ps_int_proc",
- "name": "Level Sensor (CM)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#a9daee",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "meas_level",
- "ps_int_inj_level",
- "ps_int_dbg_level"
- ],
- "x": 95,
- "y": 655,
- "w": 1930,
- "h": 110
- },
- {
- "id": "setup_title",
- "type": "comment",
- "z": "ps_int_setup",
- "name": "Deploy-time setup\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nFires once after each deploy: pushes the canonical set.mode / set.inflow /\nset.demand topics across cross-tab channels into the Process Plant tab.",
- "info": "",
- "x": 600,
- "y": 40,
- "wires": []
- },
- {
- "id": "setup_inj_mode",
- "type": "inject",
- "z": "ps_int_setup",
- "name": "set.mode = levelbased",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "levelbased",
- "vt": "str"
- }
- ],
- "topic": "set.mode",
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": "0.5",
- "x": 120,
- "y": 160,
- "wires": [
- [
- "lout_setup_mode"
- ]
- ]
- },
- {
- "id": "setup_inj_mgcmode",
- "type": "inject",
- "z": "ps_int_setup",
- "name": "MGC set.mode = auto",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "auto",
- "vt": "str"
- }
- ],
- "topic": "set.mode",
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": "0.5",
- "x": 120,
- "y": 220,
- "wires": [
- [
- "lout_setup_mgcmode"
- ]
- ]
- },
- {
- "id": "setup_inj_inflow",
- "type": "inject",
- "z": "ps_int_setup",
- "name": "seed inflow 60 m3/h",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "60",
- "vt": "num"
- }
- ],
- "topic": "set.inflow",
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": "1.0",
- "x": 120,
- "y": 280,
- "wires": [
- [
- "lout_setup_inflow"
- ]
- ]
- },
- {
- "id": "lout_setup_mode",
- "type": "link out",
- "z": "ps_int_setup",
- "name": "setup:to-ps-mode",
- "mode": "link",
- "links": [
- "lin_setup_mode"
- ],
- "x": 1800,
- "y": 160,
- "wires": []
- },
- {
- "id": "lout_setup_mgcmode",
- "type": "link out",
- "z": "ps_int_setup",
- "name": "setup:to-mgc-mode",
- "mode": "link",
- "links": [
- "lin_setup_mgcmode"
- ],
- "x": 1800,
- "y": 220,
- "wires": []
- },
- {
- "id": "lout_setup_inflow",
- "type": "link out",
- "z": "ps_int_setup",
- "name": "setup:to-ps-inflow",
- "mode": "link",
- "links": [
- "lin_setup_inflow"
- ],
- "x": 1800,
- "y": 280,
- "wires": []
- },
- {
- "id": "grp_setup",
- "type": "group",
- "z": "ps_int_setup",
- "name": "Deploy-time setup",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#dddddd",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "setup_inj_mode",
- "setup_inj_mgcmode",
- "setup_inj_inflow",
- "lout_setup_mode",
- "lout_setup_mgcmode",
- "lout_setup_inflow"
- ],
- "x": 95,
- "y": 115,
- "w": 1930,
- "h": 230
- }
-]
diff --git a/examples/03-Dashboard.json b/examples/03-Dashboard.json
deleted file mode 100644
index 6a486e5..0000000
--- a/examples/03-Dashboard.json
+++ /dev/null
@@ -1,1325 +0,0 @@
-[
- {
- "id": "ps_dash_proc",
- "type": "tab",
- "label": "Process Plant",
- "disabled": false,
- "info": "Tier 3: full station with measurement + MGC + 2 pumps, formatted for live dashboard."
- },
- {
- "id": "ps_dash_ui",
- "type": "tab",
- "label": "Dashboard UI",
- "disabled": false,
- "info": "FlowFuse dashboard 2.0: 3 charts (flow / level / volumePercent), text widgets and 2 sliders."
- },
- {
- "id": "ps_dash_setup",
- "type": "tab",
- "label": "Setup",
- "disabled": false,
- "info": "Once-true injects: initial mode + initial inflow seed."
- },
- {
- "id": "ps_dash_base",
- "type": "ui-base",
- "name": "EVOLV Demo",
- "path": "/dashboard",
- "appIcon": "",
- "includeClientData": true,
- "acceptsClientConfig": [
- "ui-notification",
- "ui-control"
- ],
- "showPathInSidebar": false,
- "headerContent": "page",
- "navigationStyle": "default",
- "titleBarStyle": "default"
- },
- {
- "id": "ps_dash_theme",
- "type": "ui-theme",
- "name": "EVOLV Theme",
- "colors": {
- "surface": "#ffffff",
- "primary": "#0c99d9",
- "bgPage": "#eeeeee",
- "groupBg": "#ffffff",
- "groupOutline": "#cccccc"
- },
- "sizes": {
- "density": "default",
- "pagePadding": "14px",
- "groupGap": "14px",
- "groupBorderRadius": "6px",
- "widgetGap": "12px"
- }
- },
- {
- "id": "ps_dash_page",
- "type": "ui-page",
- "name": "PumpingStation Demo",
- "ui": "ps_dash_base",
- "path": "/pumping-station",
- "icon": "water",
- "layout": "grid",
- "theme": "ps_dash_theme",
- "breakpoints": [
- {
- "name": "Default",
- "px": "0",
- "cols": "12"
- }
- ],
- "order": 1,
- "className": ""
- },
- {
- "id": "ps_dash_grp_ctrl",
- "type": "ui-group",
- "name": "Controls",
- "page": "ps_dash_page",
- "width": 6,
- "height": 1,
- "order": 1,
- "showTitle": true,
- "className": ""
- },
- {
- "id": "ps_dash_grp_status",
- "type": "ui-group",
- "name": "Operator Status",
- "page": "ps_dash_page",
- "width": 6,
- "height": 1,
- "order": 2,
- "showTitle": true,
- "className": ""
- },
- {
- "id": "ps_dash_grp_trend",
- "type": "ui-group",
- "name": "Live Trends",
- "page": "ps_dash_page",
- "width": 12,
- "height": 1,
- "order": 3,
- "showTitle": true,
- "className": ""
- },
- {
- "id": "ps_dash_proc_title",
- "type": "comment",
- "z": "ps_dash_proc",
- "name": "Process Plant\n━━━━━━━━━━━━━━━━━\nFull station with parent (MGC) and 2 pump children.\nEvents go to Dashboard UI through evt:ps; commands come back through cmd:ps-mode and cmd:ps-demand.",
- "info": "",
- "x": 600,
- "y": 40,
- "wires": []
- },
- {
- "id": "lin_proc_mode",
- "type": "link in",
- "z": "ps_dash_proc",
- "name": "cmd:ps-mode",
- "links": [],
- "x": 120,
- "y": 480,
- "wires": [
- [
- "ps_dash_station"
- ]
- ]
- },
- {
- "id": "lin_proc_demand",
- "type": "link in",
- "z": "ps_dash_proc",
- "name": "cmd:ps-demand",
- "links": [],
- "x": 120,
- "y": 540,
- "wires": [
- [
- "ps_dash_station"
- ]
- ]
- },
- {
- "id": "lin_proc_setupmode",
- "type": "link in",
- "z": "ps_dash_proc",
- "name": "setup:to-ps-mode",
- "links": [],
- "x": 120,
- "y": 420,
- "wires": [
- [
- "ps_dash_station"
- ]
- ]
- },
- {
- "id": "lin_proc_setupinflow",
- "type": "link in",
- "z": "ps_dash_proc",
- "name": "setup:to-ps-inflow",
- "links": [],
- "x": 120,
- "y": 600,
- "wires": [
- [
- "ps_dash_station"
- ]
- ]
- },
- {
- "id": "ps_dash_meas_level",
- "type": "measurement",
- "z": "ps_dash_proc",
- "name": "Basin level sensor",
- "mode": "analog",
- "channels": "[]",
- "scaling": false,
- "i_min": 0,
- "i_max": 0,
- "i_offset": 0,
- "o_min": 0,
- "o_max": 1,
- "simulator": true,
- "smooth_method": "mean",
- "count": 5,
- "processOutputFormat": "process",
- "dbaseOutputFormat": "influxdb",
- "uuid": "example-level-001",
- "supplier": "vega",
- "category": "sensor",
- "assetType": "level",
- "model": "VEGAPULS-31",
- "unit": "m",
- "assetTagNumber": "LT-001",
- "enableLog": false,
- "logLevel": "error",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": 0,
- "distanceUnit": "m",
- "distanceDescription": "",
- "x": 600,
- "y": 700,
- "wires": [
- [],
- [],
- [
- "ps_dash_station"
- ]
- ]
- },
- {
- "id": "ps_dash_inj_level",
- "type": "inject",
- "z": "ps_dash_proc",
- "name": "sim level 1.6 m",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "1.6",
- "vt": "num"
- }
- ],
- "topic": "measurement",
- "repeat": "",
- "crontab": "",
- "once": false,
- "onceDelay": "",
- "x": 120,
- "y": 700,
- "wires": [
- [
- "ps_dash_meas_level"
- ]
- ]
- },
- {
- "id": "ps_dash_pump_a",
- "type": "rotatingMachine",
- "z": "ps_dash_proc",
- "name": "Pump A",
- "speed": "1",
- "startup": "2",
- "warmup": "1",
- "shutdown": "2",
- "cooldown": "1",
- "movementMode": "staticspeed",
- "machineCurve": "",
- "uuid": "example-pump-a",
- "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": "error",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": "",
- "distanceUnit": "m",
- "distanceDescription": "",
- "x": 840,
- "y": 320,
- "wires": [
- [],
- [],
- [
- "ps_dash_mgc"
- ]
- ]
- },
- {
- "id": "ps_dash_pump_b",
- "type": "rotatingMachine",
- "z": "ps_dash_proc",
- "name": "Pump B",
- "speed": "1",
- "startup": "2",
- "warmup": "1",
- "shutdown": "2",
- "cooldown": "1",
- "movementMode": "staticspeed",
- "machineCurve": "",
- "uuid": "example-pump-b",
- "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": "error",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": "",
- "distanceUnit": "m",
- "distanceDescription": "",
- "x": 840,
- "y": 400,
- "wires": [
- [],
- [],
- [
- "ps_dash_mgc"
- ]
- ]
- },
- {
- "id": "ps_dash_mgc",
- "type": "machineGroupControl",
- "z": "ps_dash_proc",
- "name": "Pump Group",
- "enableLog": true,
- "logLevel": "info",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": "",
- "distanceUnit": "m",
- "x": 1080,
- "y": 360,
- "wires": [
- [],
- [],
- [
- "ps_dash_station"
- ]
- ]
- },
- {
- "id": "ps_dash_station",
- "type": "pumpingStation",
- "z": "ps_dash_proc",
- "name": "Pumping Station",
- "simulator": false,
- "basinVolume": 50,
- "basinHeight": 3.5,
- "inflowLevel": 3,
- "outflowLevel": 0.2,
- "overflowLevel": 3.2,
- "defaultFluid": "wastewater",
- "inletPipeDiameter": 0.3,
- "outletPipeDiameter": 0.3,
- "pipelineLength": 80,
- "maxDischargeHead": 24,
- "staticHead": 12,
- "maxInflowRate": 200,
- "temperatureReferenceDegC": 15,
- "timeleftToFullOrEmptyThresholdSeconds": 0,
- "enableDryRunProtection": true,
- "enableOverfillProtection": true,
- "dryRunThresholdPercent": 2,
- "overfillThresholdPercent": 98,
- "minHeightBasedOn": "outlet",
- "processOutputFormat": "process",
- "dbaseOutputFormat": "influxdb",
- "refHeight": "NAP",
- "basinBottomRef": 1,
- "uuid": "example-ps-001",
- "supplier": "WBD-RD",
- "category": "station",
- "assetType": "pumpingstation",
- "model": "demo-50m3",
- "unit": "m3/h",
- "enableLog": true,
- "logLevel": "info",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": "",
- "distanceUnit": "m",
- "distanceDescription": "",
- "controlMode": "levelbased",
- "startLevel": 1.2,
- "minLevel": 0.4,
- "maxLevel": 2.8,
- "flowSetpoint": null,
- "flowDeadband": null,
- "x": 1320,
- "y": 520,
- "wires": [
- [
- "ps_dash_trend_split"
- ],
- [],
- []
- ]
- },
- {
- "id": "ps_dash_trend_split",
- "type": "function",
- "z": "ps_dash_proc",
- "name": "Trend split + status",
- "func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\nfunction pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\nconst flowIn = pick('flow.predicted.in');\nconst flowOut = pick('flow.predicted.out');\nconst level = pick('level.predicted.atequipment');\nconst volPct = Number(cache.volumePercent);\nconst ts = Date.now();\nconst flowMsgs = [];\nif (flowIn != null) flowMsgs.push({ topic: 'Inflow', payload: flowIn * 3600, timestamp: ts });\nif (flowOut != null) flowMsgs.push({ topic: 'Outflow', payload: flowOut * 3600, timestamp: ts });\nconst flowOut1 = flowMsgs.length ? flowMsgs : null;\nconst levelOut = level != null ? { topic: 'Level', payload: level, timestamp: ts } : null;\nconst volOut = Number.isFinite(volPct) ? { topic: 'Volume%', payload: volPct, timestamp: ts } : null;\nconst stateStr = `state=${cache.state||'?'} | mode=${cache.controlMode||cache.mode||'?'}`;\nconst percStr = cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a';\nconst dirStr = cache.direction || 'n/a';\nconst tEmpty = cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a';\nreturn [\n flowOut1,\n levelOut,\n volOut,\n { payload: stateStr },\n { payload: percStr },\n { payload: dirStr },\n { payload: tEmpty }\n];",
- "outputs": 7,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 1560,
- "y": 520,
- "wires": [
- [
- "lout_evt_flow"
- ],
- [
- "lout_evt_level"
- ],
- [
- "lout_evt_volpct"
- ],
- [
- "lout_evt_state"
- ],
- [
- "lout_evt_perc"
- ],
- [
- "lout_evt_dir"
- ],
- [
- "lout_evt_tempty"
- ]
- ]
- },
- {
- "id": "lout_evt_flow",
- "type": "link out",
- "z": "ps_dash_proc",
- "name": "evt:flow",
- "mode": "link",
- "links": [
- "lin_ui_flow"
- ],
- "x": 1800,
- "y": 420,
- "wires": []
- },
- {
- "id": "lout_evt_level",
- "type": "link out",
- "z": "ps_dash_proc",
- "name": "evt:level",
- "mode": "link",
- "links": [
- "lin_ui_level"
- ],
- "x": 1800,
- "y": 460,
- "wires": []
- },
- {
- "id": "lout_evt_volpct",
- "type": "link out",
- "z": "ps_dash_proc",
- "name": "evt:volpct",
- "mode": "link",
- "links": [
- "lin_ui_volpct"
- ],
- "x": 1800,
- "y": 500,
- "wires": []
- },
- {
- "id": "lout_evt_state",
- "type": "link out",
- "z": "ps_dash_proc",
- "name": "evt:state",
- "mode": "link",
- "links": [
- "lin_ui_state"
- ],
- "x": 1800,
- "y": 540,
- "wires": []
- },
- {
- "id": "lout_evt_perc",
- "type": "link out",
- "z": "ps_dash_proc",
- "name": "evt:perc",
- "mode": "link",
- "links": [
- "lin_ui_perc"
- ],
- "x": 1800,
- "y": 580,
- "wires": []
- },
- {
- "id": "lout_evt_dir",
- "type": "link out",
- "z": "ps_dash_proc",
- "name": "evt:dir",
- "mode": "link",
- "links": [
- "lin_ui_dir"
- ],
- "x": 1800,
- "y": 620,
- "wires": []
- },
- {
- "id": "lout_evt_tempty",
- "type": "link out",
- "z": "ps_dash_proc",
- "name": "evt:tempty",
- "mode": "link",
- "links": [
- "lin_ui_tempty"
- ],
- "x": 1800,
- "y": 660,
- "wires": []
- },
- {
- "id": "ps_dash_grp_station",
- "type": "group",
- "z": "ps_dash_proc",
- "name": "Pumping Station (PC)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#0c99d9",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "ps_dash_station",
- "ps_dash_trend_split",
- "lin_proc_mode",
- "lin_proc_demand",
- "lin_proc_setupmode",
- "lin_proc_setupinflow",
- "lout_evt_flow",
- "lout_evt_level",
- "lout_evt_volpct",
- "lout_evt_state",
- "lout_evt_perc",
- "lout_evt_dir",
- "lout_evt_tempty"
- ],
- "x": 95,
- "y": 375,
- "w": 1930,
- "h": 350
- },
- {
- "id": "ps_dash_grp_pa",
- "type": "group",
- "z": "ps_dash_proc",
- "name": "Pump A (EM)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#86bbdd",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "ps_dash_pump_a"
- ],
- "x": 815,
- "y": 275,
- "w": 250,
- "h": 110
- },
- {
- "id": "ps_dash_grp_pb",
- "type": "group",
- "z": "ps_dash_proc",
- "name": "Pump B (EM)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#86bbdd",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "ps_dash_pump_b"
- ],
- "x": 815,
- "y": 355,
- "w": 250,
- "h": 110
- },
- {
- "id": "ps_dash_grp_mgc",
- "type": "group",
- "z": "ps_dash_proc",
- "name": "Pump Group MGC (UN)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#50a8d9",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "ps_dash_mgc"
- ],
- "x": 1055,
- "y": 315,
- "w": 250,
- "h": 110
- },
- {
- "id": "ps_dash_grp_level",
- "type": "group",
- "z": "ps_dash_proc",
- "name": "Level Sensor (CM)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#a9daee",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "ps_dash_meas_level",
- "ps_dash_inj_level"
- ],
- "x": 95,
- "y": 655,
- "w": 730,
- "h": 110
- },
- {
- "id": "ps_dash_ui_title",
- "type": "comment",
- "z": "ps_dash_ui",
- "name": "Dashboard UI\n━━━━━━━━━━━━━━━\nLink-ins on L0 receive evt:* from Process Plant.\nSliders on L2 emit cmd:* back to Process Plant.\nCharts use the trend-split pattern: one chart per metric, series labelled by msg.topic.",
- "info": "",
- "x": 600,
- "y": 40,
- "wires": []
- },
- {
- "id": "lin_ui_flow",
- "type": "link in",
- "z": "ps_dash_ui",
- "name": "evt:flow",
- "links": [],
- "x": 120,
- "y": 220,
- "wires": [
- [
- "ui_chart_flow"
- ]
- ]
- },
- {
- "id": "lin_ui_level",
- "type": "link in",
- "z": "ps_dash_ui",
- "name": "evt:level",
- "links": [],
- "x": 120,
- "y": 320,
- "wires": [
- [
- "ui_chart_level"
- ]
- ]
- },
- {
- "id": "lin_ui_volpct",
- "type": "link in",
- "z": "ps_dash_ui",
- "name": "evt:volpct",
- "links": [],
- "x": 120,
- "y": 420,
- "wires": [
- [
- "ui_chart_volpct"
- ]
- ]
- },
- {
- "id": "lin_ui_state",
- "type": "link in",
- "z": "ps_dash_ui",
- "name": "evt:state",
- "links": [],
- "x": 120,
- "y": 520,
- "wires": [
- [
- "ui_text_state"
- ]
- ]
- },
- {
- "id": "lin_ui_perc",
- "type": "link in",
- "z": "ps_dash_ui",
- "name": "evt:perc",
- "links": [],
- "x": 120,
- "y": 560,
- "wires": [
- [
- "ui_text_perc"
- ]
- ]
- },
- {
- "id": "lin_ui_dir",
- "type": "link in",
- "z": "ps_dash_ui",
- "name": "evt:dir",
- "links": [],
- "x": 120,
- "y": 600,
- "wires": [
- [
- "ui_text_dir"
- ]
- ]
- },
- {
- "id": "lin_ui_tempty",
- "type": "link in",
- "z": "ps_dash_ui",
- "name": "evt:tempty",
- "links": [],
- "x": 120,
- "y": 640,
- "wires": [
- [
- "ui_text_tempty"
- ]
- ]
- },
- {
- "id": "ui_chart_flow",
- "type": "ui-chart",
- "z": "ps_dash_ui",
- "group": "ps_dash_grp_trend",
- "name": "Flow trend",
- "label": "Flow (m³/h)",
- "order": 1,
- "width": 12,
- "height": 6,
- "chartType": "line",
- "category": "topic",
- "categoryType": "msg",
- "xAxisLabel": "time",
- "xAxisType": "time",
- "xAxisProperty": "",
- "xAxisPropertyType": "timestamp",
- "xAxisFormat": "",
- "xAxisFormatType": "auto",
- "yAxisLabel": "m³/h",
- "yAxisProperty": "payload",
- "yAxisPropertyType": "msg",
- "xmin": "",
- "xmax": "",
- "ymin": "",
- "ymax": "",
- "bins": 10,
- "action": "append",
- "stackSeries": false,
- "pointShape": "circle",
- "pointRadius": 4,
- "interpolation": "linear",
- "showLegend": true,
- "className": "",
- "removeOlder": "15",
- "removeOlderUnit": "60",
- "removeOlderPoints": "200",
- "colors": [
- "#0095FF",
- "#FF0000",
- "#FF7F0E",
- "#2CA02C",
- "#A347E1",
- "#D62728",
- "#FF9896",
- "#9467BD",
- "#C5B0D5"
- ],
- "textColor": [
- "#666666"
- ],
- "textColorDefault": true,
- "gridColor": [
- "#e5e5e5"
- ],
- "gridColorDefault": true,
- "x": 1080,
- "y": 220,
- "wires": []
- },
- {
- "id": "ui_chart_level",
- "type": "ui-chart",
- "z": "ps_dash_ui",
- "group": "ps_dash_grp_trend",
- "name": "Level trend",
- "label": "Level (m)",
- "order": 2,
- "width": 12,
- "height": 6,
- "chartType": "line",
- "category": "topic",
- "categoryType": "msg",
- "xAxisLabel": "time",
- "xAxisType": "time",
- "xAxisProperty": "",
- "xAxisPropertyType": "timestamp",
- "xAxisFormat": "",
- "xAxisFormatType": "auto",
- "yAxisLabel": "m",
- "yAxisProperty": "payload",
- "yAxisPropertyType": "msg",
- "xmin": "",
- "xmax": "",
- "ymin": "",
- "ymax": "",
- "bins": 10,
- "action": "append",
- "stackSeries": false,
- "pointShape": "circle",
- "pointRadius": 4,
- "interpolation": "linear",
- "showLegend": true,
- "className": "",
- "removeOlder": "15",
- "removeOlderUnit": "60",
- "removeOlderPoints": "200",
- "colors": [
- "#0095FF",
- "#FF0000",
- "#FF7F0E",
- "#2CA02C",
- "#A347E1",
- "#D62728",
- "#FF9896",
- "#9467BD",
- "#C5B0D5"
- ],
- "textColor": [
- "#666666"
- ],
- "textColorDefault": true,
- "gridColor": [
- "#e5e5e5"
- ],
- "gridColorDefault": true,
- "x": 1080,
- "y": 320,
- "wires": []
- },
- {
- "id": "ui_chart_volpct",
- "type": "ui-chart",
- "z": "ps_dash_ui",
- "group": "ps_dash_grp_trend",
- "name": "Volume %",
- "label": "Volume (%)",
- "order": 3,
- "width": 12,
- "height": 6,
- "chartType": "line",
- "category": "topic",
- "categoryType": "msg",
- "xAxisLabel": "time",
- "xAxisType": "time",
- "xAxisProperty": "",
- "xAxisPropertyType": "timestamp",
- "xAxisFormat": "",
- "xAxisFormatType": "auto",
- "yAxisLabel": "%",
- "yAxisProperty": "payload",
- "yAxisPropertyType": "msg",
- "xmin": "",
- "xmax": "",
- "ymin": "",
- "ymax": "",
- "bins": 10,
- "action": "append",
- "stackSeries": false,
- "pointShape": "circle",
- "pointRadius": 4,
- "interpolation": "linear",
- "showLegend": true,
- "className": "",
- "removeOlder": "15",
- "removeOlderUnit": "60",
- "removeOlderPoints": "200",
- "colors": [
- "#0095FF",
- "#FF0000",
- "#FF7F0E",
- "#2CA02C",
- "#A347E1",
- "#D62728",
- "#FF9896",
- "#9467BD",
- "#C5B0D5"
- ],
- "textColor": [
- "#666666"
- ],
- "textColorDefault": true,
- "gridColor": [
- "#e5e5e5"
- ],
- "gridColorDefault": true,
- "x": 1080,
- "y": 420,
- "wires": []
- },
- {
- "id": "ui_text_state",
- "type": "ui-text",
- "z": "ps_dash_ui",
- "group": "ps_dash_grp_status",
- "name": "State",
- "label": "Station state",
- "order": 1,
- "width": 4,
- "height": 1,
- "format": "{{msg.payload}}",
- "layout": "row-spread",
- "x": 1080,
- "y": 520,
- "wires": []
- },
- {
- "id": "ui_text_perc",
- "type": "ui-text",
- "z": "ps_dash_ui",
- "group": "ps_dash_grp_status",
- "name": "percControl",
- "label": "Control %",
- "order": 2,
- "width": 4,
- "height": 1,
- "format": "{{msg.payload}}",
- "layout": "row-spread",
- "x": 1080,
- "y": 560,
- "wires": []
- },
- {
- "id": "ui_text_dir",
- "type": "ui-text",
- "z": "ps_dash_ui",
- "group": "ps_dash_grp_status",
- "name": "direction",
- "label": "Direction",
- "order": 3,
- "width": 4,
- "height": 1,
- "format": "{{msg.payload}}",
- "layout": "row-spread",
- "x": 1080,
- "y": 600,
- "wires": []
- },
- {
- "id": "ui_text_tempty",
- "type": "ui-text",
- "z": "ps_dash_ui",
- "group": "ps_dash_grp_status",
- "name": "timeToEmpty",
- "label": "Time to empty",
- "order": 4,
- "width": 4,
- "height": 1,
- "format": "{{msg.payload}}",
- "layout": "row-spread",
- "x": 1080,
- "y": 640,
- "wires": []
- },
- {
- "id": "ui_dd_mode",
- "type": "ui-dropdown",
- "z": "ps_dash_ui",
- "group": "ps_dash_grp_ctrl",
- "name": "Mode",
- "label": "Control mode",
- "order": 1,
- "width": 6,
- "height": 1,
- "passthru": true,
- "multiple": false,
- "options": [
- {
- "label": "manual",
- "value": "manual",
- "type": "str"
- },
- {
- "label": "levelbased",
- "value": "levelbased",
- "type": "str"
- },
- {
- "label": "flowbased",
- "value": "flowbased",
- "type": "str"
- },
- {
- "label": "none",
- "value": "none",
- "type": "str"
- }
- ],
- "payload": "",
- "topic": "set.mode",
- "topicType": "str",
- "x": 600,
- "y": 160,
- "wires": [
- [
- "ui_wrap_mode"
- ]
- ]
- },
- {
- "id": "ui_sl_demand",
- "type": "ui-slider",
- "z": "ps_dash_ui",
- "group": "ps_dash_grp_ctrl",
- "name": "Demand",
- "label": "Manual demand (m³/h)",
- "order": 2,
- "width": 6,
- "height": 1,
- "passthru": true,
- "outs": "end",
- "topic": "set.demand",
- "topicType": "str",
- "min": 0,
- "max": 200,
- "step": 5,
- "icon": "",
- "thumbLabel": "always",
- "showValue": true,
- "className": "",
- "x": 600,
- "y": 220,
- "wires": [
- [
- "ui_wrap_demand"
- ]
- ]
- },
- {
- "id": "ui_wrap_mode",
- "type": "function",
- "z": "ps_dash_ui",
- "name": "topic=set.mode",
- "func": "msg.topic = 'set.mode';\nmsg.payload = String(msg.payload || 'manual');\nreturn msg;",
- "outputs": 1,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 1080,
- "y": 160,
- "wires": [
- [
- "lout_cmd_mode"
- ]
- ]
- },
- {
- "id": "ui_wrap_demand",
- "type": "function",
- "z": "ps_dash_ui",
- "name": "topic=set.demand",
- "func": "msg.topic = 'set.demand';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
- "outputs": 1,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 1080,
- "y": 220,
- "wires": [
- [
- "lout_cmd_demand"
- ]
- ]
- },
- {
- "id": "lout_cmd_mode",
- "type": "link out",
- "z": "ps_dash_ui",
- "name": "cmd:ps-mode",
- "mode": "link",
- "links": [
- "lin_proc_mode"
- ],
- "x": 1800,
- "y": 160,
- "wires": []
- },
- {
- "id": "lout_cmd_demand",
- "type": "link out",
- "z": "ps_dash_ui",
- "name": "cmd:ps-demand",
- "mode": "link",
- "links": [
- "lin_proc_demand"
- ],
- "x": 1800,
- "y": 220,
- "wires": []
- },
- {
- "id": "grp_ui_ctrl",
- "type": "group",
- "z": "ps_dash_ui",
- "name": "Controls (PC)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#0c99d9",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "ui_dd_mode",
- "ui_sl_demand",
- "ui_wrap_mode",
- "ui_wrap_demand",
- "lout_cmd_mode",
- "lout_cmd_demand"
- ],
- "x": 575,
- "y": 115,
- "w": 1450,
- "h": 170
- },
- {
- "id": "grp_ui_status",
- "type": "group",
- "z": "ps_dash_ui",
- "name": "Operator status (PC)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#0c99d9",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "ui_text_state",
- "ui_text_perc",
- "ui_text_dir",
- "ui_text_tempty",
- "lin_ui_state",
- "lin_ui_perc",
- "lin_ui_dir",
- "lin_ui_tempty"
- ],
- "x": 95,
- "y": 475,
- "w": 1210,
- "h": 230
- },
- {
- "id": "grp_ui_trend",
- "type": "group",
- "z": "ps_dash_ui",
- "name": "Live trends (PC)",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#0c99d9",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "ui_chart_flow",
- "ui_chart_level",
- "ui_chart_volpct",
- "lin_ui_flow",
- "lin_ui_level",
- "lin_ui_volpct"
- ],
- "x": 95,
- "y": 175,
- "w": 1210,
- "h": 310
- },
- {
- "id": "ps_dash_setup_title",
- "type": "comment",
- "z": "ps_dash_setup",
- "name": "Deploy-time setup\n━━━━━━━━━━━━━━━━━━━\nInitialises set.mode = levelbased and seeds an inflow at deploy time.",
- "info": "",
- "x": 600,
- "y": 40,
- "wires": []
- },
- {
- "id": "ps_dash_setup_mode",
- "type": "inject",
- "z": "ps_dash_setup",
- "name": "set.mode = levelbased",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "levelbased",
- "vt": "str"
- }
- ],
- "topic": "set.mode",
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": "0.5",
- "x": 120,
- "y": 160,
- "wires": [
- [
- "ps_dash_lout_setup_mode"
- ]
- ]
- },
- {
- "id": "ps_dash_setup_inflow",
- "type": "inject",
- "z": "ps_dash_setup",
- "name": "seed inflow 60 m3/h",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload",
- "v": "60",
- "vt": "num"
- }
- ],
- "topic": "set.inflow",
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": "1.0",
- "x": 120,
- "y": 220,
- "wires": [
- [
- "ps_dash_lout_setup_inflow"
- ]
- ]
- },
- {
- "id": "ps_dash_lout_setup_mode",
- "type": "link out",
- "z": "ps_dash_setup",
- "name": "setup:to-ps-mode",
- "mode": "link",
- "links": [
- "lin_proc_setupmode"
- ],
- "x": 1800,
- "y": 160,
- "wires": []
- },
- {
- "id": "ps_dash_lout_setup_inflow",
- "type": "link out",
- "z": "ps_dash_setup",
- "name": "setup:to-ps-inflow",
- "mode": "link",
- "links": [
- "lin_proc_setupinflow"
- ],
- "x": 1800,
- "y": 220,
- "wires": []
- },
- {
- "id": "ps_dash_grp_setup",
- "type": "group",
- "z": "ps_dash_setup",
- "name": "Deploy-time setup",
- "style": {
- "label": true,
- "stroke": "#000000",
- "fill": "#dddddd",
- "fill-opacity": "0.10"
- },
- "nodes": [
- "ps_dash_setup_mode",
- "ps_dash_setup_inflow",
- "ps_dash_lout_setup_mode",
- "ps_dash_lout_setup_inflow"
- ],
- "x": 95,
- "y": 115,
- "w": 1930,
- "h": 170
- }
-]
diff --git a/examples/README.md b/examples/README.md
index 593d26f..4e1e480 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,9 +1,9 @@
# pumpingStation - Example Flows
-Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the
-canonical topic API (`set.mode`, `set.inflow`, `set.demand`,
+Node-RED flows demonstrating the Phase-2 pumpingStation node on the
+canonical topic API (`set.mode`, `set.inflow`, `set.outflow`, `set.demand`,
`cmd.calibrate.volume`, `cmd.calibrate.level`). Legacy aliases
-(`changemode`, `q_in`, `Qd`, `calibratePredictedVolume`,
+(`changemode`, `q_in`, `q_out`, `Qd`, `calibratePredictedVolume`,
`calibratePredictedLevel`, `registerChild`) still work but log a
one-time deprecation warning; these fresh flows use the canonical names only.
@@ -12,15 +12,14 @@ one-time deprecation warning; these fresh flows use the canonical names only.
| File | Tier | Tabs | Purpose |
|---|---|---|---|
| `01-Basic.json` | 1 | Process Plant | Single pumpingStation driven by inject nodes - no parent, no dashboard. |
-| `02-Integration.json` | 2 | Process Plant + Setup | Adds a `measurement` level child and a `machineGroupControl` parent with two `rotatingMachine` pumps. Demonstrates the Phase-2 parent/child handshake. |
-| `03-Dashboard.json` | 3 | Process Plant + Dashboard UI + Setup | Tier 2 plumbing plus a FlowFuse Dashboard 2.0 page with 3 charts (flow / level / volume %), text widgets, and 2 controls (mode dropdown + demand slider). |
+| `02-Dashboard.json` | 2 | Process Plant + Dashboard UI | Same command surface as Basic, but driven by FlowFuse Dashboard 2.0 widgets — `ui-button` controls + `ui-text` live status panel. |
## Prerequisites
- Node-RED with the EVOLV package installed (so the `pumpingStation`,
`measurement`, `machineGroupControl`, and `rotatingMachine` node
types are registered).
-- For `03-Dashboard.json`: `@flowfuse/node-red-dashboard` (Dashboard 2.0).
+- For `02-Dashboard.json`: `@flowfuse/node-red-dashboard` (Dashboard 2.0).
## How to load
@@ -46,28 +45,22 @@ import into their own tabs and can be deployed immediately.
5. Inject `cmd.calibrate.volume = 25 m3` to jump the predicted-volume
integrator to half-full.
-## 02-Integration - what to try
-
-1. Deploy. The Setup tab fires `set.mode = levelbased` to the station
- and `set.mode = auto` to the MGC.
-2. The two pumps register with the MGC via Port 2; the MGC and the level
- sensor register with the station via Port 2. Watch the registration
- debug taps to confirm.
-3. The level inject pushes a 1.6 m measurement so the station sees a
- non-zero starting level. Setup also seeds `set.inflow = 60 m3/h`.
-4. The station's `controlMode = levelbased` then drives the MGC, which
- dispatches to Pump A / Pump B.
-
-## 03-Dashboard - what to try
+## 02-Dashboard - what to try
1. Deploy.
-2. Open the dashboard at `http://localhost:1880/dashboard/page/pumping-station`.
-3. Use the **Control mode** dropdown to switch between `manual`,
- `levelbased`, `flowbased`, `none`.
-4. In manual mode, drag the **Manual demand** slider - the demand cascades
- to the MGC and on to the pumps.
-5. The three charts (flow, level, volume %) plot live data; the four text
- widgets show state, percControl, direction, and time-to-empty.
+2. Open the dashboard at `http://localhost:1880/dashboard/pumpingstation-basic`.
+3. Click **Mode: Manual** or **Mode: Levelbased** in the Controls panel.
+4. Click **Inflow 60 m³/h** to push wastewater into the basin — the Status
+ panel on the right shows level / volume / volume % rising.
+5. In manual mode, click **Demand 40 m³/h** — the value surfaces as
+ `Manual demand` in the Status panel and in the node's status badge.
+6. Use **Calibrate V = 25 m³** or **Calibrate L = 1.5 m** to snap the
+ predicted-volume integrator.
+
+All buttons fire the same canonical `msg.topic` as the Basic flow's inject
+nodes; the only difference is the trigger. The Live status panel is fed by
+Port 0 via a small fan-out function that caches last-known values so
+delta-only updates never blank a row.
## Layout conventions
@@ -88,12 +81,6 @@ These flows follow the EVOLV layout rule set in
## Regenerating
-These flows are generated from `tools/build-examples.js`. Edit the
-generator, never the JSON, then:
-
-```bash
-node nodes/pumpingStation/tools/build-examples.js
-```
-
-The script writes `01-Basic.json`, `02-Integration.json`, and
-`03-Dashboard.json` into this directory.
+The current example JSON files are hand-maintained. If you re-introduce a
+generator, regenerate `01-Basic.json` and `02-Dashboard.json` from it
+rather than editing the JSON directly.
diff --git a/examples/basic-dashboard.flow.json b/examples/basic-dashboard.flow.json
deleted file mode 100644
index 9eaddea..0000000
--- a/examples/basic-dashboard.flow.json
+++ /dev/null
@@ -1,589 +0,0 @@
-[
- {
- "id": "ps_tab_basic_dashboard",
- "type": "tab",
- "label": "PumpingStation Dashboard",
- "disabled": false,
- "info": "Basic level-based pumpingStation dashboard with basin trends and safety state."
- },
- {
- "id": "ui_base_ps_basic",
- "type": "ui-base",
- "name": "EVOLV Demo",
- "path": "/dashboard",
- "appIcon": "",
- "includeClientData": true,
- "acceptsClientConfig": [
- "ui-notification",
- "ui-control"
- ],
- "showPathInSidebar": false,
- "headerContent": "page",
- "navigationStyle": "default",
- "titleBarStyle": "default"
- },
- {
- "id": "ui_theme_ps_basic",
- "type": "ui-theme",
- "name": "EVOLV Pumping Theme",
- "colors": {
- "surface": "#ffffff",
- "primary": "#0c99d9",
- "bgPage": "#f1f3f5",
- "groupBg": "#ffffff",
- "groupOutline": "#cfd7de"
- },
- "sizes": {
- "density": "default",
- "pagePadding": "14px",
- "groupGap": "14px",
- "groupBorderRadius": "6px",
- "widgetGap": "12px"
- }
- },
- {
- "id": "ui_page_ps_basic",
- "type": "ui-page",
- "name": "PumpingStation",
- "ui": "ui_base_ps_basic",
- "path": "/pumping-station",
- "icon": "water_drop",
- "layout": "grid",
- "theme": "ui_theme_ps_basic",
- "breakpoints": [
- {
- "name": "Default",
- "px": "0",
- "cols": "12"
- }
- ],
- "order": 1,
- "className": ""
- },
- {
- "id": "ui_group_ps_inputs",
- "type": "ui-group",
- "name": "Simulation Inputs",
- "page": "ui_page_ps_basic",
- "width": "4",
- "height": "1",
- "order": 1,
- "showTitle": true,
- "className": ""
- },
- {
- "id": "ui_group_ps_trends",
- "type": "ui-group",
- "name": "Basin Trends",
- "page": "ui_page_ps_basic",
- "width": "8",
- "height": "1",
- "order": 2,
- "showTitle": true,
- "className": ""
- },
- {
- "id": "ui_group_ps_state",
- "type": "ui-group",
- "name": "State",
- "page": "ui_page_ps_basic",
- "width": "12",
- "height": "1",
- "order": 3,
- "showTitle": true,
- "className": ""
- },
- {
- "id": "ps_node_basic",
- "type": "pumpingStation",
- "z": "ps_tab_basic_dashboard",
- "name": "PS Dashboard Demo",
- "basinVolume": 50,
- "basinHeight": 5,
- "inflowLevel": 3,
- "outflowLevel": 0.2,
- "overflowLevel": 4.5,
- "defaultFluid": "wastewater",
- "inletPipeDiameter": 0.4,
- "outletPipeDiameter": 0.3,
- "pipelineLength": 80,
- "maxDischargeHead": 24,
- "staticHead": 12,
- "maxInflowRate": 200,
- "temperatureReferenceDegC": 15,
- "timeleftToFullOrEmptyThresholdSeconds": 0,
- "enableDryRunProtection": true,
- "enableHighVolumeSafety": true,
- "enableOverfillProtection": true,
- "dryRunThresholdPercent": 2,
- "highVolumeSafetyThresholdPercent": 98,
- "overfillThresholdPercent": 98,
- "minHeightBasedOn": "outlet",
- "processOutputFormat": "process",
- "dbaseOutputFormat": "influxdb",
- "refHeight": "NAP",
- "basinBottomRef": 0,
- "unit": "m3/h",
- "enableLog": false,
- "logLevel": "error",
- "positionVsParent": "atEquipment",
- "positionIcon": "",
- "hasDistance": false,
- "distance": 0,
- "distanceUnit": "m",
- "distanceDescription": "",
- "controlMode": "levelbased",
- "levelCurveType": "linear",
- "logCurveFactor": 9,
- "minLevel": 1,
- "startLevel": 2,
- "maxLevel": 4,
- "x": 720,
- "y": 260,
- "wires": [
- [
- "ps_parse_output"
- ],
- [
- "ps_debug_influx"
- ],
- [
- "ps_debug_parent"
- ]
- ]
- },
- {
- "id": "ps_calibrate_initial",
- "type": "inject",
- "z": "ps_tab_basic_dashboard",
- "name": "Set start level 2 m",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- },
- {
- "p": "payload"
- }
- ],
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": "0.5",
- "topic": "calibratePredictedLevel",
- "payload": "2",
- "payloadType": "num",
- "x": 180,
- "y": 180,
- "wires": [
- [
- "ps_node_basic"
- ]
- ]
- },
- {
- "id": "ps_auto_inflow",
- "type": "inject",
- "z": "ps_tab_basic_dashboard",
- "name": "Auto inflow 0.008 m3/s",
- "props": [
- {
- "p": "payload"
- }
- ],
- "repeat": "1",
- "crontab": "",
- "once": true,
- "onceDelay": "1",
- "topic": "",
- "payload": "0.008",
- "payloadType": "num",
- "x": 180,
- "y": 240,
- "wires": [
- [
- "ps_build_qin"
- ]
- ]
- },
- {
- "id": "ps_inflow_input",
- "type": "ui-number-input",
- "z": "ps_tab_basic_dashboard",
- "group": "ui_group_ps_inputs",
- "name": "Inflow",
- "label": "Inflow (m3/s)",
- "order": 1,
- "width": "4",
- "height": "1",
- "passthru": true,
- "topic": "",
- "min": 0,
- "max": 0.05,
- "step": 0.001,
- "x": 190,
- "y": 300,
- "wires": [
- [
- "ps_build_qin"
- ]
- ]
- },
- {
- "id": "ps_build_qin",
- "type": "function",
- "z": "ps_tab_basic_dashboard",
- "name": "Build q_in",
- "func": "msg.topic = 'q_in';\nmsg.unit = 'm3/s';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
- "outputs": 1,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 440,
- "y": 260,
- "wires": [
- [
- "ps_node_basic"
- ]
- ]
- },
- {
- "id": "ps_outflow_input",
- "type": "ui-number-input",
- "z": "ps_tab_basic_dashboard",
- "group": "ui_group_ps_inputs",
- "name": "Outflow",
- "label": "Outflow (m3/s)",
- "order": 2,
- "width": "4",
- "height": "1",
- "passthru": true,
- "topic": "",
- "min": 0,
- "max": 0.05,
- "step": 0.001,
- "x": 190,
- "y": 360,
- "wires": [
- [
- "ps_build_qout"
- ]
- ]
- },
- {
- "id": "ps_build_qout",
- "type": "function",
- "z": "ps_tab_basic_dashboard",
- "name": "Build q_out",
- "func": "msg.topic = 'q_out';\nmsg.unit = 'm3/s';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
- "outputs": 1,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 440,
- "y": 360,
- "wires": [
- [
- "ps_node_basic"
- ]
- ]
- },
- {
- "id": "ps_parse_output",
- "type": "function",
- "z": "ps_tab_basic_dashboard",
- "name": "Parse PS output",
- "func": "// MeasurementContainer flat keys are `${type}.${variant}.${position}.${childId}`.\n// When PS writes without an explicit .child(), the childId is the literal\n// string 'default' — DON'T strip it. See generalFunctions/src/measurements/\n// MeasurementContainer.js getFlattenedOutput for details.\nconst fields = (msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst snapshot = Object.assign({}, context.get('snapshot') || {}, fields);\ncontext.set('snapshot', snapshot);\nconst firstFinite = (...keys) => {\n for (const key of keys) {\n const value = Number(snapshot[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\nconst level = firstFinite('level.predicted.atequipment.default', 'level.measured.atequipment.default');\nconst volume = firstFinite('volume.predicted.atequipment.default', 'volume.measured.atequipment.default');\nconst netFlow = firstFinite('netFlowRate.predicted.atequipment.default', 'netFlowRate.measured.atequipment.default');\nconst demand = firstFinite('percControl');\nconst safety = snapshot.safetyState || 'normal';\nconst direction = snapshot.direction || 'unknown';\nconst overflow = snapshot.isOverflowing === true || snapshot.isOverflowing === 'true';\nconst timeleft = Number(snapshot.timeleft);\nconst fmt = (value, digits = 2) => Number.isFinite(value) ? value.toFixed(digits) : '-';\nreturn [\n level == null ? null : { topic: 'level', payload: level },\n volume == null ? null : { topic: 'volume', payload: volume },\n demand == null ? null : { topic: 'demand', payload: demand },\n netFlow == null ? null : { topic: 'net_flow', payload: netFlow },\n { topic: 'safety', payload: `${safety} | overflowing=${overflow}` },\n { topic: 'snapshot', payload: `level=${fmt(level)} m | volume=${fmt(volume)} m3 | demand=${fmt(demand, 0)}% | direction=${direction} | t=${Number.isFinite(timeleft) ? Math.round(timeleft) + ' s' : '-'}` }\n];",
- "outputs": 6,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 980,
- "y": 220,
- "wires": [
- [
- "ps_chart_level"
- ],
- [
- "ps_chart_volume"
- ],
- [
- "ps_chart_demand"
- ],
- [
- "ps_chart_netflow"
- ],
- [
- "ps_text_safety"
- ],
- [
- "ps_text_snapshot"
- ]
- ]
- },
- {
- "id": "ps_chart_level",
- "type": "ui-chart",
- "z": "ps_tab_basic_dashboard",
- "group": "ui_group_ps_trends",
- "name": "Level",
- "label": "Level (m)",
- "order": 1,
- "width": 4,
- "height": 4,
- "chartType": "line",
- "category": "topic",
- "xAxisType": "time",
- "yAxisLabel": "m",
- "removeOlder": "15",
- "removeOlderUnit": "60",
- "x": 1230,
- "y": 140,
- "wires": [],
- "showLegend": false,
- "categoryType": "msg",
- "xAxisProperty": "",
- "xAxisPropertyType": "timestamp",
- "xAxisFormat": "",
- "xAxisFormatType": "auto",
- "yAxisProperty": "payload",
- "yAxisPropertyType": "msg",
- "xmin": "",
- "xmax": "",
- "ymin": "0",
- "ymax": "5",
- "bins": 10,
- "action": "append",
- "stackSeries": false,
- "pointShape": "circle",
- "pointRadius": 4,
- "interpolation": "linear",
- "className": "",
- "colors": [
- "#0c99d9"
- ],
- "textColor": [
- "#666666"
- ],
- "textColorDefault": true,
- "gridColor": [
- "#e5e5e5"
- ],
- "gridColorDefault": true
- },
- {
- "id": "ps_chart_volume",
- "type": "ui-chart",
- "z": "ps_tab_basic_dashboard",
- "group": "ui_group_ps_trends",
- "name": "Volume",
- "label": "Volume (m3)",
- "order": 2,
- "width": 4,
- "height": 4,
- "chartType": "line",
- "category": "topic",
- "xAxisType": "time",
- "yAxisLabel": "m3",
- "removeOlder": "15",
- "removeOlderUnit": "60",
- "x": 1230,
- "y": 200,
- "wires": [],
- "showLegend": false,
- "categoryType": "msg",
- "xAxisProperty": "",
- "xAxisPropertyType": "timestamp",
- "xAxisFormat": "",
- "xAxisFormatType": "auto",
- "yAxisProperty": "payload",
- "yAxisPropertyType": "msg",
- "xmin": "",
- "xmax": "",
- "ymin": "0",
- "ymax": "50",
- "bins": 10,
- "action": "append",
- "stackSeries": false,
- "pointShape": "circle",
- "pointRadius": 4,
- "interpolation": "linear",
- "className": "",
- "colors": [
- "#2ca02c"
- ],
- "textColor": [
- "#666666"
- ],
- "textColorDefault": true,
- "gridColor": [
- "#e5e5e5"
- ],
- "gridColorDefault": true
- },
- {
- "id": "ps_chart_demand",
- "type": "ui-chart",
- "z": "ps_tab_basic_dashboard",
- "group": "ui_group_ps_trends",
- "name": "Demand",
- "label": "Demand (%)",
- "order": 3,
- "width": 4,
- "height": 4,
- "chartType": "line",
- "category": "topic",
- "xAxisType": "time",
- "yAxisLabel": "%",
- "removeOlder": "15",
- "removeOlderUnit": "60",
- "x": 1230,
- "y": 260,
- "wires": [],
- "showLegend": false,
- "categoryType": "msg",
- "xAxisProperty": "",
- "xAxisPropertyType": "timestamp",
- "xAxisFormat": "",
- "xAxisFormatType": "auto",
- "yAxisProperty": "payload",
- "yAxisPropertyType": "msg",
- "xmin": "",
- "xmax": "",
- "ymin": "0",
- "ymax": "120",
- "bins": 10,
- "action": "append",
- "stackSeries": false,
- "pointShape": "circle",
- "pointRadius": 4,
- "interpolation": "linear",
- "className": "",
- "colors": [
- "#d68910"
- ],
- "textColor": [
- "#666666"
- ],
- "textColorDefault": true,
- "gridColor": [
- "#e5e5e5"
- ],
- "gridColorDefault": true
- },
- {
- "id": "ps_chart_netflow",
- "type": "ui-chart",
- "z": "ps_tab_basic_dashboard",
- "group": "ui_group_ps_trends",
- "name": "Net Flow",
- "label": "Net flow (m3/s)",
- "order": 4,
- "width": 4,
- "height": 4,
- "chartType": "line",
- "category": "topic",
- "xAxisType": "time",
- "yAxisLabel": "m3/s",
- "removeOlder": "15",
- "removeOlderUnit": "60",
- "x": 1240,
- "y": 320,
- "wires": [],
- "showLegend": false,
- "categoryType": "msg",
- "xAxisProperty": "",
- "xAxisPropertyType": "timestamp",
- "xAxisFormat": "",
- "xAxisFormatType": "auto",
- "yAxisProperty": "payload",
- "yAxisPropertyType": "msg",
- "xmin": "",
- "xmax": "",
- "ymin": "",
- "ymax": "",
- "bins": 10,
- "action": "append",
- "stackSeries": false,
- "pointShape": "circle",
- "pointRadius": 4,
- "interpolation": "linear",
- "className": "",
- "colors": [
- "#9467bd"
- ],
- "textColor": [
- "#666666"
- ],
- "textColorDefault": true,
- "gridColor": [
- "#e5e5e5"
- ],
- "gridColorDefault": true
- },
- {
- "id": "ps_text_safety",
- "type": "ui-text",
- "z": "ps_tab_basic_dashboard",
- "group": "ui_group_ps_state",
- "name": "Safety",
- "label": "Safety",
- "order": 1,
- "width": 4,
- "height": 1,
- "format": "{{msg.payload}}",
- "layout": "row-spread",
- "x": 1230,
- "y": 380,
- "wires": []
- },
- {
- "id": "ps_text_snapshot",
- "type": "ui-text",
- "z": "ps_tab_basic_dashboard",
- "group": "ui_group_ps_state",
- "name": "Snapshot",
- "label": "Snapshot",
- "order": 2,
- "width": 8,
- "height": 1,
- "format": "{{msg.payload}}",
- "layout": "row-spread",
- "x": 1240,
- "y": 440,
- "wires": []
- },
- {
- "id": "ps_debug_influx",
- "type": "debug",
- "z": "ps_tab_basic_dashboard",
- "name": "Influx output",
- "active": false,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "true",
- "targetType": "full",
- "x": 980,
- "y": 320,
- "wires": []
- },
- {
- "id": "ps_debug_parent",
- "type": "debug",
- "z": "ps_tab_basic_dashboard",
- "name": "Parent output",
- "active": false,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "true",
- "targetType": "full",
- "x": 980,
- "y": 380,
- "wires": []
- }
-]
diff --git a/examples/standalone-demo.js b/examples/standalone-demo.js
deleted file mode 100644
index 08a1839..0000000
--- a/examples/standalone-demo.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * Standalone PumpingStation demo — run with `node examples/standalone-demo.js`.
- * Builds a station + one pump, calibrates predicted volume, ticks once.
- * Useful for sanity-checking the orchestrator without Node-RED.
- */
-const PumpingStation = require('../src/specificClass');
-const RotatingMachine = require('../../rotatingMachine/src/specificClass');
-
-function createPumpingStationConfig(name) {
- return {
- general: {
- logging: { enabled: true, logLevel: 'debug' },
- name,
- id: `${name}-${Date.now()}`,
- flowThreshold: 1e-4,
- },
- functionality: { softwareType: 'pumpingStation', role: 'stationcontroller' },
- basin: { volume: 43.75, height: 10, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 3.2 },
- hydraulics: { refHeight: 'NAP', basinBottomRef: 0 },
- safety: { enableDryRunProtection: false, enableOverfillProtection: false },
- };
-}
-
-function createMachineConfig(name, position) {
- return {
- general: { name, logging: { enabled: false, logLevel: 'debug' } },
- functionality: { softwareType: 'machine', positionVsParent: position },
- asset: { supplier: 'Hydrostal', type: 'pump', category: 'centrifugal', model: 'hidrostal-H05K-S03R' },
- };
-}
-
-function createMachineStateConfig() {
- return {
- general: { logging: { enabled: true, logLevel: 'debug' } },
- movement: { speed: 1 },
- time: { starting: 2, warmingup: 3, stopping: 2, coolingdown: 3 },
- };
-}
-
-(async function demo() {
- const station = new PumpingStation(createPumpingStationConfig('PumpingStationDemo'));
- const pump1 = new RotatingMachine(createMachineConfig('Pump1', 'downstream'), createMachineStateConfig());
-
- station.childRegistrationUtils.registerChild(pump1, 'machine');
-
- setInterval(() => station.tick(), 1000);
- await new Promise((resolve) => setTimeout(resolve, 10));
-
- console.log('Initial state:', station.state);
- station.setManualInflow(300, Date.now(), 'l/s');
- station.calibratePredictedVolume(3.4);
-
- console.log('Station state:', station.state);
- console.log('Station output:', station.getOutput());
-})().catch((err) => {
- console.error('Demo failed:', err);
-});
diff --git a/pumpingStation.html b/pumpingStation.html
index 29b2e60..3255d05 100644
--- a/pumpingStation.html
+++ b/pumpingStation.html
@@ -29,11 +29,11 @@
// Define station-specific properties
simulator: { value: false },
- basinVolume: { value: 1 }, // m³, total empty basin
- basinHeight: { value: 1 }, // m, floor to top
- inflowLevel: { value: 0.8 }, // m, bottom/invert of inlet pipe above floor
+ basinVolume: { value: 50 }, // m³, total empty basin
+ basinHeight: { value: 4 }, // m, floor to top
+ inflowLevel: { value: 1.5 }, // m, bottom/invert of inlet pipe above floor
outflowLevel: { value: 0.2 }, // m, top of outlet/suction pipe above floor
- overflowLevel: { value: 0.9 }, // m, overflow elevation
+ overflowLevel: { value: 3.8 }, // m, overflow elevation
defaultFluid: { value: "wastewater" },
inletPipeDiameter: { value: 0.3 }, // m
outletPipeDiameter: { value: 0.3 }, // m
@@ -84,10 +84,10 @@
enableShiftedRamp: { value: false },
shiftLevel: { value: 0 },
shiftArmPercent: { value: 95 },
- startLevel: { value: null },
- stopLevel: { value: null },
- minLevel: { value: null },
- maxLevel: { value: null },
+ startLevel: { value: 1 }, // m, pump-on threshold (engagement edge)
+ stopLevel: { value: 0.5 }, // m, pump-off threshold (hysteresis fall-back)
+ minLevel: { value: 0.3 }, // m, hard-stop (just above outflow pipe top)
+ maxLevel: { value: 3.8 }, // m, 100% demand saturation
flowSetpoint: { value: null },
flowDeadband: { value: null }
diff --git a/src/editor/mode-preview.js b/src/editor/mode-preview.js
index 65d5783..be793ba 100644
--- a/src/editor/mode-preview.js
+++ b/src/editor/mode-preview.js
@@ -90,14 +90,20 @@
return pts.join(' ');
};
- // Up curve. Foot is startLevel (the configured pump-on threshold and
- // ramp foot per the runtime in _controlLevelBased). The OFF baseline
- // is drawn for level < startLevel; at startLevel demand jumps from
- // OFF to 0 % and ramps up to 100 % at maxLevel.
+ // Up curve. Engagement edge is startLevel (pump-on threshold); the
+ // ramp foot is inflowLevel — matching the runtime in
+ // _controlLevelBased, which scales demand over [inflowLevel, maxLevel].
+ // The OFF baseline is drawn for level < startLevel; between startLevel
+ // and inflowLevel demand sits flat at 0 % (system armed but not yet
+ // ramping); from inflowLevel demand ramps to 100 % at maxLevel.
const up = document.getElementById('ps-mode-curve-up');
const down = document.getElementById('ps-mode-curve-down');
const downLabel = document.getElementById('ps-mode-curve-down-label');
- if (up) up.setAttribute('points', buildPath(start, start, max));
+ // Runtime falls back to startLevel when inflowLevel is missing
+ // (basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel); mirror that
+ // in the preview so the curve is still drawn instead of blank.
+ const upFoot = Number.isFinite(inlet) && inlet > start ? inlet : start;
+ if (up) up.setAttribute('points', buildPath(start, upFoot, max));
// Shifted-DOWN curve (only when shift enabled): represents the
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
diff --git a/src/specificClass.js b/src/specificClass.js
index 6162dab..fc46a2d 100644
--- a/src/specificClass.js
+++ b/src/specificClass.js
@@ -44,6 +44,12 @@ class PumpingStation extends BaseDomain {
this.controlState = { percControl: 0 };
this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null };
+ // Last operator demand from set.demand in manual mode. Stored on the
+ // host so getOutput()/status reflect it even when no children are
+ // registered yet (otherwise forwardDemand is invisible on Port 0/1).
+ // Cleared on mode change away from manual.
+ this._manualDemand = null;
+
// Level-armed hysteresis state — ported from basin-docs `_controlLevelBased`.
// Exposed as instance fields because the e2e/basic tests assert on them
// directly. levelBased strategy reads/writes via the same names.
@@ -172,6 +178,8 @@ class PumpingStation extends BaseDomain {
if (this.config.control.allowedModes?.has?.(newMode)) {
this.logger.info(`Control mode changing from ${this.mode} to ${newMode}`);
this.mode = newMode;
+ if (newMode !== 'manual') this._manualDemand = null;
+ this.notifyOutputChanged();
} else {
this.logger.warn(`Attempted to change to unsupported control mode: ${newMode}`);
}
@@ -183,7 +191,11 @@ class PumpingStation extends BaseDomain {
setManualInflow(value, ts = Date.now(), unit) { calibration.setManualInflow(this, value, ts, unit); }
setManualOutflow(value, ts = Date.now(), unit) { calibration.setManualOutflow(this, value, ts, unit); }
- forwardDemandToChildren(demand) { return control.manual.forwardDemand(this.context(), demand); }
+ forwardDemandToChildren(demand) {
+ this._manualDemand = Number.isFinite(demand) ? demand : null;
+ this.notifyOutputChanged();
+ return control.manual.forwardDemand(this.context(), demand);
+ }
// Direct delegations preserved so existing tests can drive the strategy
// without re-mocking the dispatch layer.
@@ -220,6 +232,8 @@ class PumpingStation extends BaseDomain {
out.flowSource = this.state.flowSource;
out.timeleft = this.state.seconds;
out.percControl = this.controlState.percControl;
+ out.mode = this.mode;
+ out.manualDemand = this._manualDemand;
// Derived safety thresholds — exposed so editor + dashboards can show
// the dryRunLevel and highVolumeSafetyLevel without recomputing.
@@ -247,15 +261,14 @@ class PumpingStation extends BaseDomain {
steady: { arrow: '⏸️', fill: 'green' },
};
const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {};
- const vol = this.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
const pct = this.measurements.type('volumePercent').variant('predicted').position('atequipment').getCurrentValue() ?? 0;
- const maxVol = this.basin?.maxVolAtOverflow ?? 0;
const netFlowM3h = (this.state?.netFlow ?? 0) * 3600;
- const seconds = this.state?.seconds;
- const tStr = seconds != null ? `t≈${Math.round(seconds / 60)} min` : null;
+ const mode = this.mode || '?';
+ const manualPart = this.mode === 'manual' && Number.isFinite(this._manualDemand)
+ ? `Qd=${this._manualDemand.toFixed(0)} m³/h` : null;
return statusBadge.compose(
- [`${arrow} ${pct.toFixed(1)}%`, `V=${vol.toFixed(2)} / ${maxVol.toFixed(2)} m³`, `net: ${netFlowM3h.toFixed(0)} m³/h`, tStr],
+ [mode, `${arrow} ${pct.toFixed(1)}%`, `net: ${netFlowM3h.toFixed(0)} m³/h`, manualPart],
{ fill, shape: 'dot' }
);
}
diff --git a/wiki/Home.md b/wiki/Home.md
index 9e9a85f..f4a0fcb 100644
--- a/wiki/Home.md
+++ b/wiki/Home.md
@@ -20,17 +20,7 @@ A `pumpingStation` models a wet-well lift station: one basin with sensors, and o
## How it looks in Node-RED
-> [!IMPORTANT]
-> **Screenshot needed.** Drop a `pumpingStation` node onto a fresh Node-RED canvas and capture:
-> - The node tile itself (its colour, badge text, label).
-> - The full edit dialog when you double-click it (basin geometry section visible).
->
-> Save as `wiki/_partial-screenshots/pumpingStation/01-node-and-editor.png` (PNG, target 1200×800, optimise to ≤ 200 KB).
-> Then replace this callout with:
->
-> ```markdown
-> 
-> ```
+
---
@@ -62,15 +52,7 @@ curl -X POST -H 'Content-Type: application/json' \
http://localhost:1880/flow
```
-> [!IMPORTANT]
-> **Flow screenshot needed.** Open the imported `01-Basic.json` flow in the Node-RED editor and capture the whole tab. The inject row should be visible on the left, the pumpingStation in the middle, the debug taps on the right.
->
-> Save as `wiki/_partial-screenshots/pumpingStation/02-basic-flow.png` (PNG, target 1600×900, optimise to ≤ 250 KB).
-> Replace this callout with:
->
-> ```markdown
-> 
-> ```
+
What to click in the dashboard after deploy:
@@ -79,21 +61,7 @@ What to click in the dashboard after deploy:
3. `cmd.calibrate.level = 1.5 m` → the volume integrator syncs to a known level.
4. Watch Port 0 in the debug pane: level rises, predicted volume integrates, demand follows the curve.
-> [!IMPORTANT]
-> **GIF needed.** Record the dashboard reacting to the four clicks above. 15–25 seconds is enough. Use `peek` (Linux), LICEcap (Win/Mac), or any screen recorder; convert to GIF and optimise:
->
-> ```bash
-> # if you started from an mp4:
-> ffmpeg -i raw.mp4 -vf "fps=15,scale=720:-1" -loop 0 stage.gif
-> gifsicle -O3 --lossy=80 stage.gif -o final.gif
-> ```
->
-> Save as `wiki/_partial-gifs/pumpingStation/01-basic-demo.gif` (target ≤ 1 MB).
-> Replace this callout with:
->
-> ```markdown
-> 
-> ```
+
---
@@ -103,27 +71,11 @@ The two patterns you'll see most.
### Standalone (`01-Basic.json`)
-> [!IMPORTANT]
-> **Screenshot needed.** From the imported `01-Basic.json`, crop a tight view of just the inject column → pumpingStation → debug nodes. Skip the comment header.
->
-> Save as `wiki/_partial-screenshots/pumpingStation/03-wiring-standalone.png` (PNG, target 1400×700).
-> Replace this callout with:
->
-> ```markdown
-> 
-> ```
+
-### With a measurement child and an MGC parent (`02-Integration.json`)
+### With a measurement child and an MGC parent
-> [!IMPORTANT]
-> **Screenshot needed.** From the imported `02-Integration.json`, capture the whole tab. The measurement node feeding the pumpingStation should be visible on the left; the MGC with its two `rotatingMachine` pumps on the right.
->
-> Save as `wiki/_partial-screenshots/pumpingStation/04-wiring-integrated.png` (PNG, target 1600×900).
-> Replace this callout with:
->
-> ```markdown
-> 
-> ```
+
---
diff --git a/wiki/Reference-Examples.md b/wiki/Reference-Examples.md
index 00d2ee2..6950529 100644
--- a/wiki/Reference-Examples.md
+++ b/wiki/Reference-Examples.md
@@ -9,12 +9,10 @@
## Shipped examples
-| File | Tier | Tabs | What it shows |
-|:---|:---:|:---|:---|
-| `examples/01-Basic.json` | 1 | Process Plant | Single pumpingStation driven by inject nodes — no parent, no dashboard. |
-| `examples/02-Integration.json` | 2 | Process Plant + Setup | Adds a `measurement` level child and a `machineGroupControl` parent with two `rotatingMachine` pumps. Demonstrates the Phase-2 parent / child handshake. |
-| `examples/03-Dashboard.json` | 3 | Process Plant + Dashboard + Setup | Tier-2 plumbing plus a FlowFuse Dashboard 2.0 page with 3 charts (flow / level / volume %), text widgets, and 2 controls (mode dropdown + demand slider). |
-| `examples/basic-dashboard.flow.json` | legacy | mixed | Pre-refactor flow kept for reference. Use `03-Dashboard.json` instead. |
+| File | Tier | What it shows |
+|:---|:---:|:---|
+| `examples/01-Basic.json` | 1 | Single pumpingStation driven by inject nodes — no parent, no dashboard. Numbered driver groups for Mode / Flow signals / Operator demand / Calibration. |
+| `examples/02-Dashboard.json` | 2 | Same command surface as Basic, driven by a FlowFuse Dashboard 2.0 page (Controls + live Status rows + 4 trend charts + raw-output table). |
---
@@ -39,93 +37,67 @@ curl -X POST -H 'Content-Type: application/json' \
## Example 01 — Basic standalone
-> [!IMPORTANT]
-> **Screenshot needed.** After importing `01-Basic.json`, capture the full Process Plant tab.
->
-> Save as `wiki/_partial-screenshots/pumpingStation/05-ex01-basic.png`.
-> Replace this callout with the image link.
+
### Nodes on the tab
| Type | Purpose |
|:---|:---|
| `comment` | Tab header / instructions |
-| `inject` × 6 | Buttons to send `set.mode`, `set.inflow`, `set.demand`, `cmd.calibrate.volume`, `cmd.calibrate.level` |
+| `inject` × 7 | Buttons to send `set.mode` (manual / levelbased), `set.inflow`, `set.outflow`, `set.demand`, `cmd.calibrate.volume`, `cmd.calibrate.level` |
| `pumpingStation` | The unit under test |
-| `function` | Merge Port-0 deltas into a single rolling snapshot |
| `debug` × 3 | Port 0 (process), Port 1 (InfluxDB), Port 2 (parent reg) |
+Driver injects are wrapped in four numbered groups: **1. Control mode**, **2. Flow signals (inflow / outflow)**, **3. Operator demand (manual mode only)**, **4. Calibration**. Debug nodes sit in a separate **Debug outputs (sidebar)** group on the right.
+
### What to do after deploy
-1. Click `set.mode = levelbased`.
-2. Click `cmd.calibrate.level = 1.5 m` to anchor the volume integrator.
-3. Click `set.inflow = 60 m³/h`.
-4. Watch the Port-0 debug pane: `direction` flips to `filling`, `level` rises, `demand` follows the level curve, `etaSeconds` decreases.
-5. Click `set.demand = 40 %` (only honoured in manual mode — for level-based, the controller decides demand from level).
+1. (optional) Click `set.mode = manual` if you want `set.demand` to forward; otherwise leave it on the default `levelbased` and the ramp drives demand from level.
+2. Click `set.inflow = 60 m³/h` — the basin starts filling. Watch Port 0 in the debug pane: `direction` flips to `filling`, `level` rises, predicted volume integrates.
+3. In manual mode: click `set.demand = 40` — the value surfaces as `manualDemand` on Port 0/1 and in the node status badge.
+4. Click `cmd.calibrate.volume = 25 m³` (or `cmd.calibrate.level = 1.5 m`) to snap the predicted-volume integrator.
-> [!IMPORTANT]
-> **GIF needed.** Record steps 1–4. Target 15–25 s, ≤ 1 MB after `gifsicle -O3 --lossy=80`.
->
-> Save as `wiki/_partial-gifs/pumpingStation/02-ex01-demo.gif`.
-> Replace this callout with the image link.
+
---
-## Example 02 — Integration with parent + children
+## Example 02 — Dashboard
> [!IMPORTANT]
-> **Screenshot needed.** After importing `02-Integration.json`, capture the full Process Plant tab.
+> **Screenshot needed.** Two captures from `02-Dashboard.json`:
+> 1. The editor tab (left controls column + pumpingStation + Live-status group on the right).
+> 2. The rendered dashboard at `http://localhost:1880/dashboard/pumpingstation-basic`.
>
-> Save as `wiki/_partial-screenshots/pumpingStation/06-ex02-integration.png`.
-> Replace this callout with the image link.
+> Save as `wiki/_partial-screenshots/pumpingStation/05-ex02-editor.png` and `06-ex02-dashboard.png`.
+> Replace this callout with both image links.
### What it adds vs Example 01
| Addition | Why |
|:---|:---|
-| `measurement` node feeding `level` | Replaces the inject-driven level path with a real measurement child |
-| `machineGroupControl` (MGC) parent | Demand goes upward to the MGC instead of being applied directly |
-| Two `rotatingMachine` pumps under the MGC | The MGC load-shares demand across them |
-| `Setup` tab | Initial calibration injects fire once via `once: true` |
+| FlowFuse `ui-base` + `ui-theme` + `ui-page` setup | One dashboard page hosting four widget groups |
+| `ui-button` × 7 (Controls group) | Replace the inject buttons one-for-one — each carries the canonical `msg.topic` directly |
+| `ui-text` × 7 (Status group) | Live readouts: Mode, Direction, Level, Volume, Volume %, percControl, Manual demand |
+| `ui-chart` × 4 (Trends group) | Level (m), Volume (m³), Volume % (0–100), Flow (m³/h, multi-series Inflow / Outflow / Net) |
+| `ui-template` (Raw output group) | Full key/value table of the latest Port 0 cache — every field the node emits, sorted |
+| Fan-out function | Caches last-known values so delta-only Port 0 updates never blank a status row, and forwards numeric values to the charts |
-This exercises the Phase-2 parent / child handshake: `child.register` is sent on Port 2 of each child to its parent, and the parent's `commandRegistry` dispatches into `ChildRouter.onRegister(...)`.
-
-### What to do after deploy
-
-1. Setup tab fires once, calibrating volume and setting mode.
-2. The MGC reports its predicted flow back to the pumpingStation.
-3. Click any inject in the Process Plant tab to perturb the basin.
-4. Watch all three Port-0 debug taps: PS, MGC, both pumps.
-
----
-
-## Example 03 — Dashboard
-
-> [!IMPORTANT]
-> **Screenshot needed.** Two captures from `03-Dashboard.json`:
-> 1. The editor tab (Dashboard UI) showing the dashboard widgets and trend-feeder functions.
-> 2. The rendered dashboard at `http://localhost:1880/dashboard`.
->
-> Save as `wiki/_partial-screenshots/pumpingStation/07-ex03-editor.png` and `08-ex03-dashboard.png`.
-> Replace this callout with both image links.
-
-### What it adds vs Example 02
-
-| Addition | Why |
-|:---|:---|
-| FlowFuse ui-base + ui-page + ui-group setup | One page, multiple grouped widgets |
-| 3 ui-chart widgets | flow / level / volume % trends |
-| ui-text widgets | live mode, demand, direction display |
-| ui-dropdown for mode | operator-facing mode switch |
-| ui-slider for demand | manual setpoint |
-| Trend-feeder function | splits Port-0 deltas into one msg per chart with `msg.topic` set as series label |
+The buttons fire the **same canonical `msg.topic`** as the inject nodes in Example 01 — there is no separate dashboard command surface to learn.
Required: `@flowfuse/node-red-dashboard` (Dashboard 2.0) installed in the Node-RED instance.
+### What to do after deploy
+
+1. Open `http://localhost:1880/dashboard/pumpingstation-basic`.
+2. Click `Mode: Manual` or `Mode: Levelbased`.
+3. Click `Inflow 60 m³/h` — Status panel level / volume / vol% rise; the Level / Volume / Flow charts plot the trends.
+4. In manual mode click `Demand 40 m³/h` — `Manual demand` row updates, node badge appends `Qd=40 m³/h`.
+5. Inspect the **Raw output** table at the bottom of the page for the full Port 0 surface (basin geometry, dryRunLevel, highVolumeSafetyLevel, predictedOverflowVolume, …).
+
> [!IMPORTANT]
-> **GIF needed.** Slide the demand control and watch the trend charts react. 20–30 s is enough.
+> **GIF needed.** Capture clicking through Mode → Inflow → Demand and the charts reacting. 20–30 s is enough.
>
-> Save as `wiki/_partial-gifs/pumpingStation/03-ex03-dashboard.gif`.
+> Save as `wiki/_partial-gifs/pumpingStation/02-ex02-dashboard.gif`.
> Replace this callout with the image link.
---
@@ -159,7 +131,6 @@ Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/bran
| Level rises but `volume` stays at `minVol` | Volume integrator hasn't been calibrated. Send `cmd.calibrate.level = ` once. |
| Demand stays at 0 % even though level is high | Mode might be `manual` — check `set.mode`. Or the safety layer is blocking (look at `safety.blocked` on Port 0). |
| Predicted volume drifts | Net-flow source is wrong. Look at `flowSource` on Port 0; it should match the highest-level aggregator you have wired in. |
-| MGC and pumps don't see demand | `02-Integration.json` requires the MGC to register **before** the pumps. The Setup tab handles ordering. |
| `enableLog: 'debug'` floods the container log | Toggle it off in the node's config. Never ship a demo with debug logging enabled. |
---
diff --git a/wiki/_partial-gifs/pumpingStation/01-basic-demo.gif b/wiki/_partial-gifs/pumpingStation/01-basic-demo.gif
new file mode 100644
index 0000000..1c2aadc
Binary files /dev/null and b/wiki/_partial-gifs/pumpingStation/01-basic-demo.gif differ
diff --git a/wiki/_partial-screenshots/pumpingStation/01-node-and-editor.png b/wiki/_partial-screenshots/pumpingStation/01-node-and-editor.png
new file mode 100644
index 0000000..2122af1
Binary files /dev/null and b/wiki/_partial-screenshots/pumpingStation/01-node-and-editor.png differ
diff --git a/wiki/_partial-screenshots/pumpingStation/02-basic-flow.png b/wiki/_partial-screenshots/pumpingStation/02-basic-flow.png
new file mode 100644
index 0000000..58037b0
Binary files /dev/null and b/wiki/_partial-screenshots/pumpingStation/02-basic-flow.png differ
diff --git a/wiki/_partial-screenshots/pumpingStation/03-wiring-standalone.png b/wiki/_partial-screenshots/pumpingStation/03-wiring-standalone.png
new file mode 100644
index 0000000..65d83a2
Binary files /dev/null and b/wiki/_partial-screenshots/pumpingStation/03-wiring-standalone.png differ
diff --git a/wiki/_partial-screenshots/pumpingStation/04-wiring-integrated.png b/wiki/_partial-screenshots/pumpingStation/04-wiring-integrated.png
new file mode 100644
index 0000000..a6b8c03
Binary files /dev/null and b/wiki/_partial-screenshots/pumpingStation/04-wiring-integrated.png differ