Compare commits
1 Commits
d2384b1a2d
...
ed22f01932
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed22f01932 |
340
examples/01-Basic.json
Normal file
340
examples/01-Basic.json
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"ps_basic_node",
|
||||||
|
"ps_basic_format"
|
||||||
|
],
|
||||||
|
"x": 1290,
|
||||||
|
"y": 230,
|
||||||
|
"w": 500,
|
||||||
|
"h": 140
|
||||||
|
}
|
||||||
|
]
|
||||||
686
examples/02-Integration.json
Normal file
686
examples/02-Integration.json
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
1325
examples/03-Dashboard.json
Normal file
1325
examples/03-Dashboard.json
Normal file
File diff suppressed because it is too large
Load Diff
99
examples/README.md
Normal file
99
examples/README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# pumpingStation - Example Flows
|
||||||
|
|
||||||
|
Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the
|
||||||
|
canonical topic API (`set.mode`, `set.inflow`, `set.demand`,
|
||||||
|
`cmd.calibrate.volume`, `cmd.calibrate.level`). Legacy aliases
|
||||||
|
(`changemode`, `q_in`, `Qd`, `calibratePredictedVolume`,
|
||||||
|
`calibratePredictedLevel`, `registerChild`) still work but log a
|
||||||
|
one-time deprecation warning; these fresh flows use the canonical names only.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| 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). |
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
## How to load
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Drop a file into a running Node-RED instance using its Admin API.
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data @nodes/pumpingStation/examples/01-Basic.json \
|
||||||
|
http://localhost:1880/flows
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in the editor: **Menu -> Import -> select file -> Import**. The flows
|
||||||
|
import into their own tabs and can be deployed immediately.
|
||||||
|
|
||||||
|
## 01-Basic - what to try
|
||||||
|
|
||||||
|
1. Deploy.
|
||||||
|
2. Inject `set.mode = manual`.
|
||||||
|
3. Inject `set.inflow = 60 m3/h` - the basin starts filling. Watch the
|
||||||
|
formatted Port 0 payload in the debug sidebar.
|
||||||
|
4. Inject `set.demand = 40 %` - in manual mode this would feed any
|
||||||
|
registered children; here there are no pump children so it is logged
|
||||||
|
and shown on Port 0.
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Layout conventions
|
||||||
|
|
||||||
|
These flows follow the EVOLV layout rule set in
|
||||||
|
`.claude/rules/node-red-flow-layout.md`:
|
||||||
|
|
||||||
|
- Tabs split by **concern**: Process Plant (EVOLV nodes) / Dashboard UI
|
||||||
|
(`ui-*` widgets) / Setup (once-true injects).
|
||||||
|
- Cross-tab wiring via **named link out / link in channels**:
|
||||||
|
`setup:to-ps-mode`, `setup:to-ps-inflow`, `setup:to-mgc-mode`,
|
||||||
|
`cmd:ps-mode`, `cmd:ps-demand`, `evt:flow`, `evt:level`,
|
||||||
|
`evt:volpct`, `evt:state`, `evt:perc`, `evt:dir`, `evt:tempty`.
|
||||||
|
- **Lane positions** L0-L7 = `[120, 360, 600, 840, 1080, 1320, 1560, 1800]`,
|
||||||
|
driven by each node's S88 level (Process Cell on L5, Unit on L4,
|
||||||
|
Equipment on L3, Control Module on L2).
|
||||||
|
- **Group boxes** wrap each parent + its direct children, coloured by the
|
||||||
|
parent's S88 level.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
"description": "Control module",
|
"description": "Control module",
|
||||||
"main": "pumpingStation.js",
|
"main": "pumpingStation.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test test/"
|
"test": "node --test test/",
|
||||||
|
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
|
||||||
|
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
|
||||||
|
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
949
tools/build-examples.js
Normal file
949
tools/build-examples.js
Normal file
@@ -0,0 +1,949 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* build-examples.js — regenerate the three example flows for pumpingStation.
|
||||||
|
*
|
||||||
|
* Source of truth for the Tier 1/2/3 example flows under examples/.
|
||||||
|
* Follows EVOLV/.claude/rules/node-red-flow-layout.md:
|
||||||
|
* - Lane positions L0..L7 = [120, 360, 600, 840, 1080, 1320, 1560, 1800]
|
||||||
|
* - S88 colours per Node-RED group (Process Cell = #0c99d9, Unit = #50a8d9,
|
||||||
|
* Equipment Module = #86bbdd, Control Module = #a9daee, neutral = #dddddd)
|
||||||
|
* - Cross-tab wiring via named link out/link in channels (cmd:* / evt:* / setup:*)
|
||||||
|
* - ui-chart objects carry every mandatory key (interpolation, yAxisProperty,
|
||||||
|
* xAxisPropertyType, action, removeOlder*, colors, etc.) — omitting any
|
||||||
|
* causes FlowFuse to render the chart blank with no error.
|
||||||
|
*
|
||||||
|
* Only canonical pumpingStation topic names are used (per CONTRACT.md):
|
||||||
|
* set.mode, set.inflow, set.demand, cmd.calibrate.volume, cmd.calibrate.level.
|
||||||
|
*
|
||||||
|
* Run from repo root or any cwd:
|
||||||
|
* node nodes/pumpingStation/tools/build-examples.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const OUT_DIR = path.join(__dirname, '..', 'examples');
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Layout constants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const LANE_X = [120, 360, 600, 840, 1080, 1320, 1560, 1800];
|
||||||
|
const S88 = {
|
||||||
|
AR: '#0f52a5',
|
||||||
|
PC: '#0c99d9',
|
||||||
|
UN: '#50a8d9',
|
||||||
|
EM: '#86bbdd',
|
||||||
|
CM: '#a9daee',
|
||||||
|
neutral: '#dddddd',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHART_COLORS = [
|
||||||
|
'#0095FF', '#FF0000', '#FF7F0E', '#2CA02C', '#A347E1',
|
||||||
|
'#D62728', '#FF9896', '#9467BD', '#C5B0D5',
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function tab(id, label, info) {
|
||||||
|
return { id, type: 'tab', label, disabled: false, info: info || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function comment(id, z, name, x, y) {
|
||||||
|
return { id, type: 'comment', z, name, info: '', x, y, wires: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkOut(id, z, name, x, y, links) {
|
||||||
|
return { id, type: 'link out', z, name, mode: 'link', links: links || [], x, y, wires: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkIn(id, z, name, x, y, links, downstream) {
|
||||||
|
return { id, type: 'link in', z, name, links: links || [], x, y, wires: [downstream || []] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inject(id, z, name, topic, payload, payloadType, x, y, wires, opts) {
|
||||||
|
const o = opts || {};
|
||||||
|
return {
|
||||||
|
id, type: 'inject', z, name,
|
||||||
|
props: [
|
||||||
|
{ p: 'topic', vt: 'str' },
|
||||||
|
{ p: 'payload', v: String(payload), vt: payloadType },
|
||||||
|
],
|
||||||
|
topic,
|
||||||
|
repeat: o.repeat || '',
|
||||||
|
crontab: '',
|
||||||
|
once: !!o.once,
|
||||||
|
onceDelay: o.onceDelay || '',
|
||||||
|
x, y,
|
||||||
|
wires: [wires || []],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn(id, z, name, code, x, y, wires, outputs) {
|
||||||
|
return {
|
||||||
|
id, type: 'function', z, name,
|
||||||
|
func: code,
|
||||||
|
outputs: outputs || 1,
|
||||||
|
noerr: 0,
|
||||||
|
initialize: '',
|
||||||
|
finalize: '',
|
||||||
|
libs: [],
|
||||||
|
x, y,
|
||||||
|
wires: wires || [[]],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugNode(id, z, name, x, y, complete, targetType, active) {
|
||||||
|
return {
|
||||||
|
id, type: 'debug', z, name,
|
||||||
|
active: active !== false,
|
||||||
|
tosidebar: true,
|
||||||
|
console: false,
|
||||||
|
tostatus: false,
|
||||||
|
complete: complete || 'payload',
|
||||||
|
targetType: targetType || 'msg',
|
||||||
|
x, y, wires: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(id, z, name, color, nodes, bbox) {
|
||||||
|
return {
|
||||||
|
id, type: 'group', z, name,
|
||||||
|
style: { label: true, stroke: '#000000', fill: color, 'fill-opacity': '0.10' },
|
||||||
|
nodes,
|
||||||
|
x: bbox.x, y: bbox.y, w: bbox.w, h: bbox.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bboxOf(nodeList, ids, pad) {
|
||||||
|
const p = pad == null ? 20 : pad;
|
||||||
|
const ns = nodeList.filter((n) => ids.includes(n.id));
|
||||||
|
const xs = ns.map((n) => n.x || 0);
|
||||||
|
const ys = ns.map((n) => n.y || 0);
|
||||||
|
const minX = Math.min(...xs) - p;
|
||||||
|
const minY = Math.min(...ys) - p - 20;
|
||||||
|
const w = Math.max(...xs) - Math.min(...xs) + 200 + 2 * p;
|
||||||
|
const h = Math.max(...ys) - Math.min(...ys) + 60 + 2 * p;
|
||||||
|
return { x: minX, y: minY, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build a fully-specified pumpingStation node. Every config field is set
|
||||||
|
* explicitly per rule §9 (no schema-default reliance for operational
|
||||||
|
* parameters). 50 m³ basin, 3.5 m height, inflow at 3 m, outflow at 0.2 m,
|
||||||
|
* overflow at 3.2 m. Level thresholds chosen so levelbased control activates
|
||||||
|
* mid-tank and saturates near overflow.
|
||||||
|
*/
|
||||||
|
function pumpingStationNode(id, z, name, x, y, wires) {
|
||||||
|
return {
|
||||||
|
id, type: 'pumpingStation', z, name,
|
||||||
|
simulator: false,
|
||||||
|
basinVolume: 50,
|
||||||
|
basinHeight: 3.5,
|
||||||
|
inflowLevel: 3.0,
|
||||||
|
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, y,
|
||||||
|
wires: wires || [[], [], []],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function measurementLevelNode(id, z, name, x, y, wires) {
|
||||||
|
return {
|
||||||
|
id, type: 'measurement', z, name,
|
||||||
|
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, y,
|
||||||
|
wires: wires || [[], [], []],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function machineGroupControlNode(id, z, name, x, y, wires) {
|
||||||
|
return {
|
||||||
|
id, type: 'machineGroupControl', z, name,
|
||||||
|
enableLog: true,
|
||||||
|
logLevel: 'info',
|
||||||
|
positionVsParent: 'atEquipment',
|
||||||
|
positionIcon: '',
|
||||||
|
hasDistance: false,
|
||||||
|
distance: '',
|
||||||
|
distanceUnit: 'm',
|
||||||
|
x, y,
|
||||||
|
wires: wires || [[], [], []],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotatingMachineNode(id, z, name, uuid, x, y, wires) {
|
||||||
|
return {
|
||||||
|
id, type: 'rotatingMachine', z, name,
|
||||||
|
speed: '1',
|
||||||
|
startup: '2', warmup: '1', shutdown: '2', cooldown: '1',
|
||||||
|
movementMode: 'staticspeed',
|
||||||
|
machineCurve: '',
|
||||||
|
uuid,
|
||||||
|
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, y,
|
||||||
|
wires: wires || [[], [], []],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FlowFuse ui-chart with every required key (per layout rule §4). */
|
||||||
|
function uiChart(id, z, group, name, label, order, yAxisLabel, x, y, color) {
|
||||||
|
return {
|
||||||
|
id, type: 'ui-chart', z, group, name, label,
|
||||||
|
order, 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: color ? [color, ...CHART_COLORS.slice(1)] : CHART_COLORS,
|
||||||
|
textColor: ['#666666'],
|
||||||
|
textColorDefault: true,
|
||||||
|
gridColor: ['#e5e5e5'],
|
||||||
|
gridColorDefault: true,
|
||||||
|
x, y, wires: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uiText(id, z, group, name, label, order, x, y, format) {
|
||||||
|
return {
|
||||||
|
id, type: 'ui-text', z, group, name, label,
|
||||||
|
order, width: 4, height: 1,
|
||||||
|
format: format || '{{msg.payload}}',
|
||||||
|
layout: 'row-spread',
|
||||||
|
x, y, wires: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uiSlider(id, z, group, name, label, order, x, y, topic, min, max, step) {
|
||||||
|
return {
|
||||||
|
id, type: 'ui-slider', z, group, name, label,
|
||||||
|
order, width: 6, height: 1,
|
||||||
|
passthru: true,
|
||||||
|
outs: 'end',
|
||||||
|
topic,
|
||||||
|
topicType: 'str',
|
||||||
|
min, max, step,
|
||||||
|
icon: '',
|
||||||
|
thumbLabel: 'always',
|
||||||
|
showValue: true,
|
||||||
|
className: '',
|
||||||
|
x, y, wires: [[]],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uiDropdown(id, z, group, name, label, order, x, y, topic, options, wires) {
|
||||||
|
return {
|
||||||
|
id, type: 'ui-dropdown', z, group, name, label,
|
||||||
|
order, width: 6, height: 1,
|
||||||
|
passthru: true,
|
||||||
|
multiple: false,
|
||||||
|
options: options.map((o) => ({ label: o, value: o, type: 'str' })),
|
||||||
|
payload: '',
|
||||||
|
topic,
|
||||||
|
topicType: 'str',
|
||||||
|
x, y,
|
||||||
|
wires: [wires || []],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uiBase(id) {
|
||||||
|
return {
|
||||||
|
id, type: 'ui-base',
|
||||||
|
name: 'EVOLV Demo',
|
||||||
|
path: '/dashboard',
|
||||||
|
appIcon: '',
|
||||||
|
includeClientData: true,
|
||||||
|
acceptsClientConfig: ['ui-notification', 'ui-control'],
|
||||||
|
showPathInSidebar: false,
|
||||||
|
headerContent: 'page',
|
||||||
|
navigationStyle: 'default',
|
||||||
|
titleBarStyle: 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uiTheme(id) {
|
||||||
|
return {
|
||||||
|
id, 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uiPage(id, base, theme, name, path, order) {
|
||||||
|
return {
|
||||||
|
id, type: 'ui-page', name, ui: base, path,
|
||||||
|
icon: 'water',
|
||||||
|
layout: 'grid', theme,
|
||||||
|
breakpoints: [{ name: 'Default', px: '0', cols: '12' }],
|
||||||
|
order, className: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uiGroup(id, page, name, width, height, order) {
|
||||||
|
return {
|
||||||
|
id, type: 'ui-group', name, page, width, height, order,
|
||||||
|
showTitle: true, className: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Tier 1 — 01-Basic.json */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function buildBasic() {
|
||||||
|
const Z = 'ps_basic_tab';
|
||||||
|
const nodes = [];
|
||||||
|
|
||||||
|
nodes.push(tab(Z, 'PumpingStation - Basic',
|
||||||
|
'Tier 1: single pumpingStation node driven by inject nodes only. ' +
|
||||||
|
'Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand.'));
|
||||||
|
|
||||||
|
nodes.push(comment('ps_basic_title', Z,
|
||||||
|
'PumpingStation - Basic\n' +
|
||||||
|
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||||
|
'A 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\n' +
|
||||||
|
'overflow at 3.2 m). controlMode = levelbased, manual demand allowed\n' +
|
||||||
|
'only when set.mode = manual.\n\n' +
|
||||||
|
'HOW 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\n' +
|
||||||
|
'Aliases (changemode, q_in, Qd, …) still work but log a deprecation\n' +
|
||||||
|
'warning - fresh flows use the canonical names.', 600, 40));
|
||||||
|
|
||||||
|
// Lane 0: link-in placeholders (none for Tier 1 - all inputs are local).
|
||||||
|
// Lane 2..3: inject nodes (we keep them in lane 1 for proximity).
|
||||||
|
const injectMode = inject('ps_basic_inj_mode', Z, 'set.mode = manual', 'set.mode', 'manual', 'str', 200, 160, ['ps_basic_node']);
|
||||||
|
const injectModeLvl = inject('ps_basic_inj_mode_lvl',Z, 'set.mode = levelbased','set.mode', 'levelbased', 'str', 220, 200, ['ps_basic_node']);
|
||||||
|
const injectInflow = inject('ps_basic_inj_inflow', Z, 'set.inflow = 60 m3/h', 'set.inflow', '60', 'num', 200, 260, ['ps_basic_node']);
|
||||||
|
const injectDemand = inject('ps_basic_inj_demand', Z, 'set.demand = 40 %', 'set.demand', '40', 'num', 200, 300, ['ps_basic_node']);
|
||||||
|
const injectCalVol = inject('ps_basic_inj_calvol', Z, 'calibrate volume 25 m3','cmd.calibrate.volume','25','num', 220, 360, ['ps_basic_node']);
|
||||||
|
const injectCalLvl = inject('ps_basic_inj_callvl', Z, 'calibrate level 1.5 m','cmd.calibrate.level','1.5','num', 220, 400, ['ps_basic_node']);
|
||||||
|
nodes.push(injectMode, injectModeLvl, injectInflow, injectDemand, injectCalVol, injectCalLvl);
|
||||||
|
|
||||||
|
// Lane 5 (PC): the pumpingStation itself.
|
||||||
|
const ps = pumpingStationNode('ps_basic_node', Z, 'Pumping Station', LANE_X[5], 300,
|
||||||
|
[['ps_basic_format'], ['ps_basic_dbg_influx'], ['ps_basic_dbg_parent']]);
|
||||||
|
nodes.push(ps);
|
||||||
|
|
||||||
|
// Lane 6: format/merge function for Port 0.
|
||||||
|
const formatFn = fn('ps_basic_format', Z, 'Merge deltas + format',
|
||||||
|
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
|
||||||
|
"const cache = context.get('c') || {};\n" +
|
||||||
|
"Object.assign(cache, p);\n" +
|
||||||
|
"context.set('c', cache);\n" +
|
||||||
|
"function 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" +
|
||||||
|
"}\n" +
|
||||||
|
"const vol = pick('volume.predicted.atequipment');\n" +
|
||||||
|
"const lvl = pick('level.predicted.atequipment');\n" +
|
||||||
|
"const flIn = pick('flow.predicted.in');\n" +
|
||||||
|
"msg.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;",
|
||||||
|
LANE_X[6], 280, [['ps_basic_dbg_process']]);
|
||||||
|
nodes.push(formatFn);
|
||||||
|
|
||||||
|
// Lane 7: debug taps.
|
||||||
|
nodes.push(debugNode('ps_basic_dbg_process', Z, 'Port 0: Process', LANE_X[7], 240, 'payload', 'msg', true));
|
||||||
|
nodes.push(debugNode('ps_basic_dbg_influx', Z, 'Port 1: InfluxDB', LANE_X[7], 320, 'true', 'full', false));
|
||||||
|
nodes.push(debugNode('ps_basic_dbg_parent', Z, 'Port 2: Parent reg', LANE_X[7], 380, 'true', 'full', true));
|
||||||
|
|
||||||
|
// Wrap the station + its formatter in a Process Cell group box.
|
||||||
|
const psGroupIds = ['ps_basic_node', 'ps_basic_format'];
|
||||||
|
nodes.push(group('grp_ps_basic', Z, 'Pumping Station (PC)', S88.PC, psGroupIds,
|
||||||
|
bboxOf(nodes, psGroupIds, 30)));
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Tier 2 — 02-Integration.json */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function buildIntegration() {
|
||||||
|
const TAB_PROC = 'ps_int_proc';
|
||||||
|
const TAB_SETUP = 'ps_int_setup';
|
||||||
|
const nodes = [];
|
||||||
|
|
||||||
|
nodes.push(tab(TAB_PROC, 'Process Plant',
|
||||||
|
'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.'));
|
||||||
|
nodes.push(tab(TAB_SETUP, 'Setup',
|
||||||
|
'Deploy-time once-true injects that initialise control modes on the EVOLV nodes.'));
|
||||||
|
|
||||||
|
/* ---------- Process Plant tab ---------------------------------- */
|
||||||
|
|
||||||
|
nodes.push(comment('ps_int_title', TAB_PROC,
|
||||||
|
'PumpingStation - Integration\n' +
|
||||||
|
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||||
|
'L0 link-ins | L2 level sensor (CM) | L3 pumps (EM) | L4 MGC (UN) | L5 station (PC).\n' +
|
||||||
|
'Pumps register with MGC via Port 2; MGC and the level sensor register with the station via Port 2.\n' +
|
||||||
|
'Cross-tab channels: setup:* drive once-true initialisation from the Setup tab.', 600, 40));
|
||||||
|
|
||||||
|
/* Link-ins on L0 receive from the Setup tab. */
|
||||||
|
const linInMode = linkIn('lin_setup_mode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 500, [], ['ps_int_station']);
|
||||||
|
const linInInflow = linkIn('lin_setup_inflow', TAB_PROC, 'setup:to-ps-inflow', LANE_X[0], 560, [], ['ps_int_station']);
|
||||||
|
const linInMgcMode = linkIn('lin_setup_mgcmode', TAB_PROC, 'setup:to-mgc-mode', LANE_X[0], 360, [], ['ps_int_mgc']);
|
||||||
|
nodes.push(linInMode, linInInflow, linInMgcMode);
|
||||||
|
|
||||||
|
/* L2: level measurement (Control Module). */
|
||||||
|
const levelMeas = measurementLevelNode('meas_level', TAB_PROC, 'Basin level sensor',
|
||||||
|
LANE_X[2], 700, [['ps_int_dbg_level'], [], ['ps_int_station']]);
|
||||||
|
nodes.push(levelMeas);
|
||||||
|
// Simulator measurement injector for the level sensor (push a varying level so PS sees something).
|
||||||
|
const levelInj = inject('ps_int_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num', LANE_X[0], 700, ['meas_level']);
|
||||||
|
nodes.push(levelInj);
|
||||||
|
|
||||||
|
/* L3: two rotatingMachine pumps (Equipment Module). */
|
||||||
|
const pumpA = rotatingMachineNode('pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
|
||||||
|
LANE_X[3], 320, [['ps_int_dbg_pa'], [], ['ps_int_mgc']]);
|
||||||
|
const pumpB = rotatingMachineNode('pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
|
||||||
|
LANE_X[3], 400, [['ps_int_dbg_pb'], [], ['ps_int_mgc']]);
|
||||||
|
nodes.push(pumpA, pumpB);
|
||||||
|
|
||||||
|
/* L4: MGC (Unit). */
|
||||||
|
const mgc = machineGroupControlNode('ps_int_mgc', TAB_PROC, 'Pump Group',
|
||||||
|
LANE_X[4], 360, [['ps_int_dbg_mgc'], [], ['ps_int_station']]);
|
||||||
|
nodes.push(mgc);
|
||||||
|
|
||||||
|
/* L5: pumpingStation (Process Cell). */
|
||||||
|
const station = pumpingStationNode('ps_int_station', TAB_PROC, 'Pumping Station',
|
||||||
|
LANE_X[5], 520, [['ps_int_format'], ['ps_int_dbg_influx'], []]);
|
||||||
|
nodes.push(station);
|
||||||
|
|
||||||
|
/* L6: formatter for the station's Port 0. */
|
||||||
|
const formatFn = fn('ps_int_format', TAB_PROC, 'Merge deltas + format',
|
||||||
|
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
|
||||||
|
"const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
|
||||||
|
"function 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; }\n" +
|
||||||
|
"const vol=pick('volume.predicted.atequipment'), lvl=pick('level.predicted.atequipment'), flIn=pick('flow.predicted.in'), flOut=pick('flow.predicted.out');\n" +
|
||||||
|
"msg.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;",
|
||||||
|
LANE_X[6], 520, [['ps_int_dbg_process']]);
|
||||||
|
nodes.push(formatFn);
|
||||||
|
|
||||||
|
/* L7: debug taps for the various ports. */
|
||||||
|
nodes.push(debugNode('ps_int_dbg_process', TAB_PROC, 'PS Port 0: Process', LANE_X[7], 480, 'payload', 'msg', true));
|
||||||
|
nodes.push(debugNode('ps_int_dbg_influx', TAB_PROC, 'PS Port 1: InfluxDB', LANE_X[7], 540, 'true', 'full', false));
|
||||||
|
nodes.push(debugNode('ps_int_dbg_mgc', TAB_PROC, 'MGC Port 0', LANE_X[7], 360, 'payload', 'msg', true));
|
||||||
|
nodes.push(debugNode('ps_int_dbg_pa', TAB_PROC, 'Pump A Port 0', LANE_X[7], 320, 'payload', 'msg', false));
|
||||||
|
nodes.push(debugNode('ps_int_dbg_pb', TAB_PROC, 'Pump B Port 0', LANE_X[7], 400, 'payload', 'msg', false));
|
||||||
|
nodes.push(debugNode('ps_int_dbg_level', TAB_PROC, 'Level Port 0', LANE_X[7], 700, 'payload', 'msg', false));
|
||||||
|
|
||||||
|
/* Group boxes. */
|
||||||
|
const pumpAIds = ['pump_a', 'ps_int_dbg_pa'];
|
||||||
|
const pumpBIds = ['pump_b', 'ps_int_dbg_pb'];
|
||||||
|
const mgcIds = ['ps_int_mgc', 'ps_int_dbg_mgc', 'lin_setup_mgcmode'];
|
||||||
|
const stationIds = ['ps_int_station', 'ps_int_format', 'ps_int_dbg_process', 'ps_int_dbg_influx', 'lin_setup_mode', 'lin_setup_inflow'];
|
||||||
|
const levelIds = ['meas_level', 'ps_int_inj_level', 'ps_int_dbg_level'];
|
||||||
|
nodes.push(group('grp_pumpa', TAB_PROC, 'Pump A (EM)', S88.EM, pumpAIds, bboxOf(nodes, pumpAIds, 25)));
|
||||||
|
nodes.push(group('grp_pumpb', TAB_PROC, 'Pump B (EM)', S88.EM, pumpBIds, bboxOf(nodes, pumpBIds, 25)));
|
||||||
|
nodes.push(group('grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, mgcIds, bboxOf(nodes, mgcIds, 25)));
|
||||||
|
nodes.push(group('grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, stationIds, bboxOf(nodes, stationIds, 25)));
|
||||||
|
nodes.push(group('grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, levelIds, bboxOf(nodes, levelIds, 25)));
|
||||||
|
|
||||||
|
/* ---------- Setup tab ----------------------------------------- */
|
||||||
|
|
||||||
|
nodes.push(comment('setup_title', TAB_SETUP,
|
||||||
|
'Deploy-time setup\n' +
|
||||||
|
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||||
|
'Fires once after each deploy: pushes the canonical set.mode / set.inflow /\n' +
|
||||||
|
'set.demand topics across cross-tab channels into the Process Plant tab.',
|
||||||
|
LANE_X[2], 40));
|
||||||
|
|
||||||
|
const setMode = inject('setup_inj_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str', LANE_X[0], 160, ['lout_setup_mode'], { once: true, onceDelay: '0.5' });
|
||||||
|
const setMgc = inject('setup_inj_mgcmode', TAB_SETUP, 'MGC set.mode = auto', 'set.mode', 'auto', 'str', LANE_X[0], 220, ['lout_setup_mgcmode'],{ once: true, onceDelay: '0.5' });
|
||||||
|
const setInflow = inject('setup_inj_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num', LANE_X[0], 280, ['lout_setup_inflow'], { once: true, onceDelay: '1.0' });
|
||||||
|
nodes.push(setMode, setMgc, setInflow);
|
||||||
|
|
||||||
|
const loutMode = linkOut('lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_setup_mode']);
|
||||||
|
const loutMgcMode = linkOut('lout_setup_mgcmode', TAB_SETUP, 'setup:to-mgc-mode', LANE_X[7], 220, ['lin_setup_mgcmode']);
|
||||||
|
const loutInflow = linkOut('lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 280, ['lin_setup_inflow']);
|
||||||
|
nodes.push(loutMode, loutMgcMode, loutInflow);
|
||||||
|
|
||||||
|
// Setup tab group.
|
||||||
|
const setupIds = ['setup_inj_mode', 'setup_inj_mgcmode', 'setup_inj_inflow',
|
||||||
|
'lout_setup_mode', 'lout_setup_mgcmode', 'lout_setup_inflow'];
|
||||||
|
nodes.push(group('grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Tier 3 — 03-Dashboard.json */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function buildDashboard() {
|
||||||
|
const TAB_PROC = 'ps_dash_proc';
|
||||||
|
const TAB_UI = 'ps_dash_ui';
|
||||||
|
const TAB_SETUP = 'ps_dash_setup';
|
||||||
|
const nodes = [];
|
||||||
|
|
||||||
|
nodes.push(tab(TAB_PROC, 'Process Plant',
|
||||||
|
'Tier 3: full station with measurement + MGC + 2 pumps, formatted for live dashboard.'));
|
||||||
|
nodes.push(tab(TAB_UI, 'Dashboard UI',
|
||||||
|
'FlowFuse dashboard 2.0: 3 charts (flow / level / volumePercent), text widgets and 2 sliders.'));
|
||||||
|
nodes.push(tab(TAB_SETUP, 'Setup',
|
||||||
|
'Once-true injects: initial mode + initial inflow seed.'));
|
||||||
|
|
||||||
|
/* ---------- FlowFuse dashboard scaffolding -------------------- */
|
||||||
|
|
||||||
|
nodes.push(uiBase('ps_dash_base'));
|
||||||
|
nodes.push(uiTheme('ps_dash_theme'));
|
||||||
|
nodes.push(uiPage('ps_dash_page', 'ps_dash_base', 'ps_dash_theme', 'PumpingStation Demo', '/pumping-station', 1));
|
||||||
|
nodes.push(uiGroup('ps_dash_grp_ctrl', 'ps_dash_page', 'Controls', 6, 1, 1));
|
||||||
|
nodes.push(uiGroup('ps_dash_grp_status', 'ps_dash_page', 'Operator Status', 6, 1, 2));
|
||||||
|
nodes.push(uiGroup('ps_dash_grp_trend', 'ps_dash_page', 'Live Trends', 12, 1, 3));
|
||||||
|
|
||||||
|
/* ---------- Process Plant tab --------------------------------- */
|
||||||
|
|
||||||
|
nodes.push(comment('ps_dash_proc_title', TAB_PROC,
|
||||||
|
'Process Plant\n━━━━━━━━━━━━━━━━━\nFull station with parent (MGC) and 2 pump children.\n' +
|
||||||
|
'Events go to Dashboard UI through evt:ps; commands come back through cmd:ps-mode and cmd:ps-demand.',
|
||||||
|
600, 40));
|
||||||
|
|
||||||
|
/* L0 link-ins: setup + dashboard commands. */
|
||||||
|
const linModeProc = linkIn('lin_proc_mode', TAB_PROC, 'cmd:ps-mode', LANE_X[0], 480, [], ['ps_dash_station']);
|
||||||
|
const linDemandProc = linkIn('lin_proc_demand', TAB_PROC, 'cmd:ps-demand', LANE_X[0], 540, [], ['ps_dash_station']);
|
||||||
|
const linSetupMode = linkIn('lin_proc_setupmode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 420, [], ['ps_dash_station']);
|
||||||
|
const linSetupInflow= linkIn('lin_proc_setupinflow', TAB_PROC, 'setup:to-ps-inflow',LANE_X[0], 600, [], ['ps_dash_station']);
|
||||||
|
nodes.push(linModeProc, linDemandProc, linSetupMode, linSetupInflow);
|
||||||
|
|
||||||
|
/* L2 level sensor with simulator. */
|
||||||
|
const levelMeas = measurementLevelNode('ps_dash_meas_level', TAB_PROC, 'Basin level sensor',
|
||||||
|
LANE_X[2], 700, [[], [], ['ps_dash_station']]);
|
||||||
|
nodes.push(levelMeas);
|
||||||
|
nodes.push(inject('ps_dash_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num',
|
||||||
|
LANE_X[0], 700, ['ps_dash_meas_level']));
|
||||||
|
|
||||||
|
/* L3 pumps. */
|
||||||
|
const pumpA = rotatingMachineNode('ps_dash_pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
|
||||||
|
LANE_X[3], 320, [[], [], ['ps_dash_mgc']]);
|
||||||
|
const pumpB = rotatingMachineNode('ps_dash_pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
|
||||||
|
LANE_X[3], 400, [[], [], ['ps_dash_mgc']]);
|
||||||
|
nodes.push(pumpA, pumpB);
|
||||||
|
|
||||||
|
/* L4 MGC. */
|
||||||
|
const mgc = machineGroupControlNode('ps_dash_mgc', TAB_PROC, 'Pump Group',
|
||||||
|
LANE_X[4], 360, [[], [], ['ps_dash_station']]);
|
||||||
|
nodes.push(mgc);
|
||||||
|
|
||||||
|
/* L5 pumpingStation. */
|
||||||
|
const station = pumpingStationNode('ps_dash_station', TAB_PROC, 'Pumping Station',
|
||||||
|
LANE_X[5], 520, [['ps_dash_trend_split'], [], []]);
|
||||||
|
nodes.push(station);
|
||||||
|
|
||||||
|
/* L6 trend-split fn: one output per chart + one output for the status text widgets.
|
||||||
|
* Outputs:
|
||||||
|
* 0 -> chart_flow ({topic: 'Inflow', payload: m3/h}, {topic: 'Outflow', payload: m3/h})
|
||||||
|
* 1 -> chart_level ({topic: 'Level', payload: m})
|
||||||
|
* 2 -> chart_volpct ({topic: 'Volume%', payload: %})
|
||||||
|
* 3 -> text_status (compact state string)
|
||||||
|
* 4 -> text_perc (percControl)
|
||||||
|
* 5 -> text_direction (direction)
|
||||||
|
* 6 -> text_timetoempty(timeToEmpty)
|
||||||
|
*/
|
||||||
|
const trendCode =
|
||||||
|
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
|
||||||
|
"const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
|
||||||
|
"function 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; }\n" +
|
||||||
|
"const flowIn = pick('flow.predicted.in');\n" +
|
||||||
|
"const flowOut = pick('flow.predicted.out');\n" +
|
||||||
|
"const level = pick('level.predicted.atequipment');\n" +
|
||||||
|
"const volPct = Number(cache.volumePercent);\n" +
|
||||||
|
"const ts = Date.now();\n" +
|
||||||
|
"const flowMsgs = [];\n" +
|
||||||
|
"if (flowIn != null) flowMsgs.push({ topic: 'Inflow', payload: flowIn * 3600, timestamp: ts });\n" +
|
||||||
|
"if (flowOut != null) flowMsgs.push({ topic: 'Outflow', payload: flowOut * 3600, timestamp: ts });\n" +
|
||||||
|
"const flowOut1 = flowMsgs.length ? flowMsgs : null;\n" +
|
||||||
|
"const levelOut = level != null ? { topic: 'Level', payload: level, timestamp: ts } : null;\n" +
|
||||||
|
"const volOut = Number.isFinite(volPct) ? { topic: 'Volume%', payload: volPct, timestamp: ts } : null;\n" +
|
||||||
|
"const stateStr = `state=${cache.state||'?'} | mode=${cache.controlMode||cache.mode||'?'}`;\n" +
|
||||||
|
"const percStr = cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a';\n" +
|
||||||
|
"const dirStr = cache.direction || 'n/a';\n" +
|
||||||
|
"const tEmpty = cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a';\n" +
|
||||||
|
"return [\n" +
|
||||||
|
" flowOut1,\n" +
|
||||||
|
" levelOut,\n" +
|
||||||
|
" volOut,\n" +
|
||||||
|
" { payload: stateStr },\n" +
|
||||||
|
" { payload: percStr },\n" +
|
||||||
|
" { payload: dirStr },\n" +
|
||||||
|
" { payload: tEmpty }\n" +
|
||||||
|
"];";
|
||||||
|
const trendSplit = fn('ps_dash_trend_split', TAB_PROC, 'Trend split + status', trendCode,
|
||||||
|
LANE_X[6], 520,
|
||||||
|
[
|
||||||
|
['lout_evt_flow'],
|
||||||
|
['lout_evt_level'],
|
||||||
|
['lout_evt_volpct'],
|
||||||
|
['lout_evt_state'],
|
||||||
|
['lout_evt_perc'],
|
||||||
|
['lout_evt_dir'],
|
||||||
|
['lout_evt_tempty'],
|
||||||
|
], 7);
|
||||||
|
nodes.push(trendSplit);
|
||||||
|
|
||||||
|
/* L7 link-outs into the Dashboard UI tab. */
|
||||||
|
const loutFlow = linkOut('lout_evt_flow', TAB_PROC, 'evt:flow', LANE_X[7], 420, ['lin_ui_flow']);
|
||||||
|
const loutLevel = linkOut('lout_evt_level', TAB_PROC, 'evt:level', LANE_X[7], 460, ['lin_ui_level']);
|
||||||
|
const loutVolPct = linkOut('lout_evt_volpct', TAB_PROC, 'evt:volpct', LANE_X[7], 500, ['lin_ui_volpct']);
|
||||||
|
const loutState = linkOut('lout_evt_state', TAB_PROC, 'evt:state', LANE_X[7], 540, ['lin_ui_state']);
|
||||||
|
const loutPerc = linkOut('lout_evt_perc', TAB_PROC, 'evt:perc', LANE_X[7], 580, ['lin_ui_perc']);
|
||||||
|
const loutDir = linkOut('lout_evt_dir', TAB_PROC, 'evt:dir', LANE_X[7], 620, ['lin_ui_dir']);
|
||||||
|
const loutTempty = linkOut('lout_evt_tempty', TAB_PROC, 'evt:tempty', LANE_X[7], 660, ['lin_ui_tempty']);
|
||||||
|
nodes.push(loutFlow, loutLevel, loutVolPct, loutState, loutPerc, loutDir, loutTempty);
|
||||||
|
|
||||||
|
/* Process tab groups. */
|
||||||
|
const procStationIds = ['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'];
|
||||||
|
const procPumpAIds = ['ps_dash_pump_a'];
|
||||||
|
const procPumpBIds = ['ps_dash_pump_b'];
|
||||||
|
const procMgcIds = ['ps_dash_mgc'];
|
||||||
|
const procLevelIds = ['ps_dash_meas_level', 'ps_dash_inj_level'];
|
||||||
|
nodes.push(group('ps_dash_grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, procStationIds, bboxOf(nodes, procStationIds, 25)));
|
||||||
|
nodes.push(group('ps_dash_grp_pa', TAB_PROC, 'Pump A (EM)', S88.EM, procPumpAIds, bboxOf(nodes, procPumpAIds, 25)));
|
||||||
|
nodes.push(group('ps_dash_grp_pb', TAB_PROC, 'Pump B (EM)', S88.EM, procPumpBIds, bboxOf(nodes, procPumpBIds, 25)));
|
||||||
|
nodes.push(group('ps_dash_grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, procMgcIds, bboxOf(nodes, procMgcIds, 25)));
|
||||||
|
nodes.push(group('ps_dash_grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, procLevelIds, bboxOf(nodes, procLevelIds, 25)));
|
||||||
|
|
||||||
|
/* ---------- Dashboard UI tab ---------------------------------- */
|
||||||
|
|
||||||
|
nodes.push(comment('ps_dash_ui_title', TAB_UI,
|
||||||
|
'Dashboard UI\n━━━━━━━━━━━━━━━\nLink-ins on L0 receive evt:* from Process Plant.\n' +
|
||||||
|
'Sliders on L2 emit cmd:* back to Process Plant.\n' +
|
||||||
|
'Charts use the trend-split pattern: one chart per metric, series labelled by msg.topic.',
|
||||||
|
600, 40));
|
||||||
|
|
||||||
|
/* L0 link-ins from the process side. */
|
||||||
|
nodes.push(linkIn('lin_ui_flow', TAB_UI, 'evt:flow', LANE_X[0], 220, [], ['ui_chart_flow']));
|
||||||
|
nodes.push(linkIn('lin_ui_level', TAB_UI, 'evt:level', LANE_X[0], 320, [], ['ui_chart_level']));
|
||||||
|
nodes.push(linkIn('lin_ui_volpct', TAB_UI, 'evt:volpct', LANE_X[0], 420, [], ['ui_chart_volpct']));
|
||||||
|
nodes.push(linkIn('lin_ui_state', TAB_UI, 'evt:state', LANE_X[0], 520, [], ['ui_text_state']));
|
||||||
|
nodes.push(linkIn('lin_ui_perc', TAB_UI, 'evt:perc', LANE_X[0], 560, [], ['ui_text_perc']));
|
||||||
|
nodes.push(linkIn('lin_ui_dir', TAB_UI, 'evt:dir', LANE_X[0], 600, [], ['ui_text_dir']));
|
||||||
|
nodes.push(linkIn('lin_ui_tempty', TAB_UI, 'evt:tempty', LANE_X[0], 640, [], ['ui_text_tempty']));
|
||||||
|
|
||||||
|
/* L4 charts and text widgets. */
|
||||||
|
nodes.push(uiChart('ui_chart_flow', TAB_UI, 'ps_dash_grp_trend', 'Flow trend', 'Flow (m³/h)', 1, 'm³/h', LANE_X[4], 220));
|
||||||
|
nodes.push(uiChart('ui_chart_level', TAB_UI, 'ps_dash_grp_trend', 'Level trend', 'Level (m)', 2, 'm', LANE_X[4], 320));
|
||||||
|
nodes.push(uiChart('ui_chart_volpct', TAB_UI, 'ps_dash_grp_trend', 'Volume %', 'Volume (%)', 3, '%', LANE_X[4], 420));
|
||||||
|
nodes.push(uiText( 'ui_text_state', TAB_UI, 'ps_dash_grp_status','State', 'Station state',1, LANE_X[4], 520));
|
||||||
|
nodes.push(uiText( 'ui_text_perc', TAB_UI, 'ps_dash_grp_status','percControl', 'Control %', 2, LANE_X[4], 560));
|
||||||
|
nodes.push(uiText( 'ui_text_dir', TAB_UI, 'ps_dash_grp_status','direction', 'Direction', 3, LANE_X[4], 600));
|
||||||
|
nodes.push(uiText( 'ui_text_tempty', TAB_UI, 'ps_dash_grp_status','timeToEmpty', 'Time to empty',4, LANE_X[4], 640));
|
||||||
|
|
||||||
|
/* L2 controls: dropdown for mode + slider for demand. */
|
||||||
|
const modeDropdown = uiDropdown('ui_dd_mode', TAB_UI, 'ps_dash_grp_ctrl',
|
||||||
|
'Mode', 'Control mode', 1, LANE_X[2], 160, 'set.mode',
|
||||||
|
['manual', 'levelbased', 'flowbased', 'none'], ['ui_wrap_mode']);
|
||||||
|
const demandSlider = uiSlider('ui_sl_demand', TAB_UI, 'ps_dash_grp_ctrl',
|
||||||
|
'Demand', 'Manual demand (m³/h)', 2, LANE_X[2], 220, 'set.demand', 0, 200, 5);
|
||||||
|
nodes.push(modeDropdown, demandSlider);
|
||||||
|
// Slider wires need explicit wiring (uiSlider helper leaves wires empty so we set them post-creation).
|
||||||
|
demandSlider.wires = [['ui_wrap_demand']];
|
||||||
|
|
||||||
|
/* L4 wrappers: enforce the canonical topic on the outgoing msg. */
|
||||||
|
const wrapMode = fn('ui_wrap_mode', TAB_UI, 'topic=set.mode',
|
||||||
|
"msg.topic = 'set.mode';\nmsg.payload = String(msg.payload || 'manual');\nreturn msg;",
|
||||||
|
LANE_X[4], 160, [['lout_cmd_mode']]);
|
||||||
|
const wrapDemand = fn('ui_wrap_demand', TAB_UI, 'topic=set.demand',
|
||||||
|
"msg.topic = 'set.demand';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
|
||||||
|
LANE_X[4], 220, [['lout_cmd_demand']]);
|
||||||
|
nodes.push(wrapMode, wrapDemand);
|
||||||
|
|
||||||
|
/* L7 link-outs to the process plant. */
|
||||||
|
nodes.push(linkOut('lout_cmd_mode', TAB_UI, 'cmd:ps-mode', LANE_X[7], 160, ['lin_proc_mode']));
|
||||||
|
nodes.push(linkOut('lout_cmd_demand', TAB_UI, 'cmd:ps-demand', LANE_X[7], 220, ['lin_proc_demand']));
|
||||||
|
|
||||||
|
/* UI tab groups (mirror the dashboard groups). */
|
||||||
|
const uiCtrlIds = ['ui_dd_mode', 'ui_sl_demand', 'ui_wrap_mode', 'ui_wrap_demand',
|
||||||
|
'lout_cmd_mode', 'lout_cmd_demand'];
|
||||||
|
const uiStatusIds = ['ui_text_state', 'ui_text_perc', 'ui_text_dir', 'ui_text_tempty',
|
||||||
|
'lin_ui_state', 'lin_ui_perc', 'lin_ui_dir', 'lin_ui_tempty'];
|
||||||
|
const uiTrendIds = ['ui_chart_flow', 'ui_chart_level', 'ui_chart_volpct',
|
||||||
|
'lin_ui_flow', 'lin_ui_level', 'lin_ui_volpct'];
|
||||||
|
nodes.push(group('grp_ui_ctrl', TAB_UI, 'Controls (PC)', S88.PC, uiCtrlIds, bboxOf(nodes, uiCtrlIds, 25)));
|
||||||
|
nodes.push(group('grp_ui_status', TAB_UI, 'Operator status (PC)', S88.PC, uiStatusIds, bboxOf(nodes, uiStatusIds, 25)));
|
||||||
|
nodes.push(group('grp_ui_trend', TAB_UI, 'Live trends (PC)', S88.PC, uiTrendIds, bboxOf(nodes, uiTrendIds, 25)));
|
||||||
|
|
||||||
|
/* ---------- Setup tab ----------------------------------------- */
|
||||||
|
|
||||||
|
nodes.push(comment('ps_dash_setup_title', TAB_SETUP, 'Deploy-time setup\n━━━━━━━━━━━━━━━━━━━\n' +
|
||||||
|
'Initialises set.mode = levelbased and seeds an inflow at deploy time.',
|
||||||
|
LANE_X[2], 40));
|
||||||
|
|
||||||
|
nodes.push(inject('ps_dash_setup_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str',
|
||||||
|
LANE_X[0], 160, ['ps_dash_lout_setup_mode'], { once: true, onceDelay: '0.5' }));
|
||||||
|
nodes.push(inject('ps_dash_setup_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num',
|
||||||
|
LANE_X[0], 220, ['ps_dash_lout_setup_inflow'], { once: true, onceDelay: '1.0' }));
|
||||||
|
|
||||||
|
nodes.push(linkOut('ps_dash_lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_proc_setupmode']));
|
||||||
|
nodes.push(linkOut('ps_dash_lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 220, ['lin_proc_setupinflow']));
|
||||||
|
|
||||||
|
const setupIds = ['ps_dash_setup_mode', 'ps_dash_setup_inflow',
|
||||||
|
'ps_dash_lout_setup_mode', 'ps_dash_lout_setup_inflow'];
|
||||||
|
nodes.push(group('ps_dash_grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* README */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const README = `# pumpingStation - Example Flows
|
||||||
|
|
||||||
|
Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the
|
||||||
|
canonical topic API (\`set.mode\`, \`set.inflow\`, \`set.demand\`,
|
||||||
|
\`cmd.calibrate.volume\`, \`cmd.calibrate.level\`). Legacy aliases
|
||||||
|
(\`changemode\`, \`q_in\`, \`Qd\`, \`calibratePredictedVolume\`,
|
||||||
|
\`calibratePredictedLevel\`, \`registerChild\`) still work but log a
|
||||||
|
one-time deprecation warning; these fresh flows use the canonical names only.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| 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). |
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
## How to load
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# Drop a file into a running Node-RED instance using its Admin API.
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \\
|
||||||
|
--data @nodes/pumpingStation/examples/01-Basic.json \\
|
||||||
|
http://localhost:1880/flows
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Or in the editor: **Menu -> Import -> select file -> Import**. The flows
|
||||||
|
import into their own tabs and can be deployed immediately.
|
||||||
|
|
||||||
|
## 01-Basic - what to try
|
||||||
|
|
||||||
|
1. Deploy.
|
||||||
|
2. Inject \`set.mode = manual\`.
|
||||||
|
3. Inject \`set.inflow = 60 m3/h\` - the basin starts filling. Watch the
|
||||||
|
formatted Port 0 payload in the debug sidebar.
|
||||||
|
4. Inject \`set.demand = 40 %\` - in manual mode this would feed any
|
||||||
|
registered children; here there are no pump children so it is logged
|
||||||
|
and shown on Port 0.
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Layout conventions
|
||||||
|
|
||||||
|
These flows follow the EVOLV layout rule set in
|
||||||
|
\`.claude/rules/node-red-flow-layout.md\`:
|
||||||
|
|
||||||
|
- Tabs split by **concern**: Process Plant (EVOLV nodes) / Dashboard UI
|
||||||
|
(\`ui-*\` widgets) / Setup (once-true injects).
|
||||||
|
- Cross-tab wiring via **named link out / link in channels**:
|
||||||
|
\`setup:to-ps-mode\`, \`setup:to-ps-inflow\`, \`setup:to-mgc-mode\`,
|
||||||
|
\`cmd:ps-mode\`, \`cmd:ps-demand\`, \`evt:flow\`, \`evt:level\`,
|
||||||
|
\`evt:volpct\`, \`evt:state\`, \`evt:perc\`, \`evt:dir\`, \`evt:tempty\`.
|
||||||
|
- **Lane positions** L0-L7 = \`[120, 360, 600, 840, 1080, 1320, 1560, 1800]\`,
|
||||||
|
driven by each node's S88 level (Process Cell on L5, Unit on L4,
|
||||||
|
Equipment on L3, Control Module on L2).
|
||||||
|
- **Group boxes** wrap each parent + its direct children, coloured by the
|
||||||
|
parent's S88 level.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
`;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function writeFlow(filename, builder) {
|
||||||
|
const flow = builder();
|
||||||
|
const dest = path.join(OUT_DIR, filename);
|
||||||
|
fs.writeFileSync(dest, JSON.stringify(flow, null, 2) + '\n', 'utf8');
|
||||||
|
console.log(`wrote ${dest} (${flow.length} nodes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
writeFlow('01-Basic.json', buildBasic);
|
||||||
|
writeFlow('02-Integration.json', buildIntegration);
|
||||||
|
writeFlow('03-Dashboard.json', buildDashboard);
|
||||||
|
fs.writeFileSync(path.join(OUT_DIR, 'README.md'), README, 'utf8');
|
||||||
|
console.log(`wrote ${path.join(OUT_DIR, 'README.md')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
285
wiki/Home.md
Normal file
285
wiki/Home.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# pumpingStation
|
||||||
|
|
||||||
|
> **Reflects code as of `d2384b1` · regenerated `<YYYY-MM-DD>` via `npm run wiki:all`**
|
||||||
|
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
|
||||||
|
|
||||||
|
## 1. What this node is
|
||||||
|
|
||||||
|
**pumpingStation** is an S88 Process Cell that owns a wet-well basin and orchestrates the pumps that drain it. It tracks measured + predicted volume, evaluates safety interlocks (dry-run, overfill), and dispatches a control strategy that hands a demand setpoint to one or more downstream machine groups or individual pumps.
|
||||||
|
|
||||||
|
## 2. Position in the platform
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
ps[pumpingStation<br/>Process Cell]:::pc
|
||||||
|
meas_lvl[measurement<br/>type=level<br/>position=atequipment]:::ctrl
|
||||||
|
meas_in[measurement<br/>type=flow<br/>position=upstream]:::ctrl
|
||||||
|
mgc[machineGroupControl<br/>Unit]:::unit
|
||||||
|
|
||||||
|
meas_lvl -.data.-> ps
|
||||||
|
meas_in -.data.-> ps
|
||||||
|
ps -->|set.demand| mgc
|
||||||
|
mgc -.evt.flow-predicted.-> ps
|
||||||
|
mgc -->|child.register| ps
|
||||||
|
classDef pc fill:#0c99d9,color:#fff
|
||||||
|
classDef unit fill:#50a8d9,color:#000
|
||||||
|
classDef ctrl fill:#a9daee,color:#000
|
||||||
|
```
|
||||||
|
|
||||||
|
S88 colours: Process Cell `#0c99d9`, Unit `#50a8d9`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
|
||||||
|
|
||||||
|
## 3. Capability matrix
|
||||||
|
|
||||||
|
| Capability | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Predicts basin volume from net flow | ✅ | Integrator seeded from `basin.minVol`; recomputes level. |
|
||||||
|
| Accepts measured level / volume / pressure | ✅ | Routed via `measurementRouter` on child registration. |
|
||||||
|
| Level-based control strategy | ✅ | Linear or log ramp between `minLevel` and `maxLevel`. |
|
||||||
|
| Flow-based control strategy | ✅ | PID against `flowSetpoint`. |
|
||||||
|
| Manual demand passthrough | ✅ | `set.demand` only honoured in `manual` mode. |
|
||||||
|
| Dry-run safety interlock | ✅ | Stops downstream pumps when volume < `minVol` while draining. |
|
||||||
|
| Overfill safety interlock | ✅ | Stops upstream equipment when volume crosses overfill threshold. |
|
||||||
|
| Cascaded children (sub-stations) | ⚠️ | Accepted via `pumpingstation` softwareType but not exercised in production. |
|
||||||
|
|
||||||
|
## 4. Code map
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
|
||||||
|
nc["buildDomainConfig()<br/>static DomainClass, commands<br/>static tickInterval = 1000ms"]
|
||||||
|
end
|
||||||
|
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
|
||||||
|
sc["PumpingStation.configure()<br/>declares ChildRouter rules<br/>tick() → safety → control"]
|
||||||
|
end
|
||||||
|
subgraph concerns["src/ concern modules"]
|
||||||
|
basin["basin/<br/>BasinGeometry + thresholdValidator"]
|
||||||
|
measurement["measurement/<br/>flowAggregator + router + calibration"]
|
||||||
|
control["control/<br/>levelbased / flowbased / manual"]
|
||||||
|
safety["safety/<br/>SafetyController"]
|
||||||
|
commands["commands/<br/>topic registry + handlers"]
|
||||||
|
end
|
||||||
|
nc --> sc
|
||||||
|
sc --> basin
|
||||||
|
sc --> measurement
|
||||||
|
sc --> control
|
||||||
|
sc --> safety
|
||||||
|
nc --> commands
|
||||||
|
```
|
||||||
|
|
||||||
|
| Module | Owns | Read first if you're changing… |
|
||||||
|
|---|---|---|
|
||||||
|
| `basin/` | Geometry, volume↔level conversion, threshold ordering | Capacity, level↔volume math, fill %. |
|
||||||
|
| `measurement/` | Net-flow aggregation, predicted-volume integrator, calibration | Predicted volume / time-to-full. |
|
||||||
|
| `control/` | Control strategy dispatch (`levelbased`, `flowbased`, `manual`) | Demand calculation, mode behaviour. |
|
||||||
|
| `safety/` | Dry-run + overfill rules, pump-shutdown side-effects | Safety envelope, alarm reactions. |
|
||||||
|
| `commands/` | Input-topic registry and handlers | New input topics, payload validation. |
|
||||||
|
|
||||||
|
## 5. Topic contract
|
||||||
|
|
||||||
|
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
|
||||||
|
|
||||||
|
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
| Canonical topic | Aliases | Payload | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `set.mode` | `changemode` | `string` | Replaces the named state value with the supplied payload. |
|
||||||
|
| `child.register` | `registerChild` | `string` | Parent/child plumbing — registers or unregisters a child node. |
|
||||||
|
| `cmd.calibrate.volume` | `calibratePredictedVolume` | `any` | Triggers an action / sequence — not idempotent. |
|
||||||
|
| `cmd.calibrate.level` | `calibratePredictedLevel` | `any` | Triggers an action / sequence — not idempotent. |
|
||||||
|
| `set.inflow` | `q_in` | `any` | Replaces the named state value with the supplied payload. |
|
||||||
|
| `set.demand` | `Qd` | `any` | Replaces the named state value with the supplied payload. |
|
||||||
|
|
||||||
|
<!-- END AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
## 6. Child registration
|
||||||
|
|
||||||
|
Mirrors the `ChildRouter` declarations in `specificClass.js → configure()`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph kids["accepted children (softwareType)"]
|
||||||
|
m["measurement"]:::ctrl
|
||||||
|
mach["machine<br/>(rotatingMachine)"]:::equip
|
||||||
|
mgc["machinegroup"]:::unit
|
||||||
|
sub["pumpingstation<br/>(sub-station)"]:::pc
|
||||||
|
end
|
||||||
|
m -->|"<type>.measured.<position>"| route1[_subscribeMeasurement<br/>routes to measurementRouter]
|
||||||
|
mach -->|flow.predicted.<in or out>| route2[_subscribePredictedFlow<br/>+ flowAggregator]
|
||||||
|
mgc -->|flow.predicted.<in or out>| route2
|
||||||
|
sub -->|flow.predicted.<in or out>| route2
|
||||||
|
route1 --> tick[tick]
|
||||||
|
route2 --> tick
|
||||||
|
classDef ctrl fill:#a9daee,color:#000
|
||||||
|
classDef equip fill:#86bbdd,color:#000
|
||||||
|
classDef unit fill:#50a8d9,color:#000
|
||||||
|
classDef pc fill:#0c99d9,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
| softwareType | onRegister side-effect | Subscribed events |
|
||||||
|
|---|---|---|
|
||||||
|
| `measurement` | `_subscribeMeasurement(child)` — registers in MeasurementContainer. | `<type>.measured.<position>` for any type (pressure, level, flow, …). |
|
||||||
|
| `machine` | Stored in `this.machines[id]`. **Skipped when a machineGroup parent is present** to avoid double-counting. | `flow.predicted.<in|out>` per the child's `positionVsParent`. |
|
||||||
|
| `machinegroup` | Stored in `this.machineGroups[id]`. | `flow.predicted.<in|out>`. |
|
||||||
|
| `pumpingstation` | Stored in `this.stations[id]`. | `flow.predicted.<in|out>`. |
|
||||||
|
|
||||||
|
## 7. Lifecycle — what one tick does
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant child as measurement / pump child
|
||||||
|
participant ps as pumpingStation
|
||||||
|
participant fa as flowAggregator
|
||||||
|
participant sf as safetyController
|
||||||
|
participant ctl as control strategy
|
||||||
|
participant out as Port-0 output
|
||||||
|
|
||||||
|
child->>ps: data event (measured.level / flow.predicted.out)
|
||||||
|
ps->>ps: ChildRouter dispatches to handler
|
||||||
|
Note over ps: every 1000 ms (static tickInterval)
|
||||||
|
ps->>fa: tick() — net flow, ETA, predicted volume
|
||||||
|
ps->>sf: evaluate({direction, secondsRemaining})
|
||||||
|
alt safety blocked
|
||||||
|
sf-->>ps: blocked=true, reason
|
||||||
|
Note over ctl: skipped this tick
|
||||||
|
else safety clear
|
||||||
|
ps->>ctl: dispatch(mode, ctx, controlState)
|
||||||
|
ctl-->>ps: percControl updated
|
||||||
|
end
|
||||||
|
ps->>ps: notifyOutputChanged()
|
||||||
|
ps->>out: msg{topic, payload (delta-compressed)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Data model — `getOutput()`
|
||||||
|
|
||||||
|
What lands on Port 0. Built in `getOutput()`, then delta-compressed by `outputUtils.formatMsg`.
|
||||||
|
|
||||||
|
<!-- BEGIN AUTOGEN: data-model -->
|
||||||
|
|
||||||
|
| Key | Type | Unit | Sample |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `direction` | string | — | `"steady"` |
|
||||||
|
| `flowSource` | null | — | `null` |
|
||||||
|
| `heightBasin` | number | m | `1` |
|
||||||
|
| `inflowLevel` | number | m | `2` |
|
||||||
|
| `maxVol` | number | m3 | `1` |
|
||||||
|
| `maxVolAtOverflow` | number | m3 | `2.5` |
|
||||||
|
| `minHeightBasedOn` | string | — | `"outlet"` |
|
||||||
|
| `minVol` | number | m3 | `0.2` |
|
||||||
|
| `minVolAtInflow` | number | m3 | `2` |
|
||||||
|
| `minVolAtOutflow` | number | m3 | `0.2` |
|
||||||
|
| `outflowLevel` | number | m | `0.2` |
|
||||||
|
| `overflowLevel` | number | m | `2.5` |
|
||||||
|
| `percControl` | number | % | `0` |
|
||||||
|
| `surfaceArea` | number | m2 | `1` |
|
||||||
|
| `timeleft` | null | s | `null` |
|
||||||
|
| `volEmptyBasin` | number | m3 | `1` |
|
||||||
|
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `0.2` |
|
||||||
|
|
||||||
|
<!-- END AUTOGEN: data-model -->
|
||||||
|
|
||||||
|
The `<nodeId>` segment of the MeasurementContainer key is the Node-RED node id assigned at deploy time; auto-gen substitutes a placeholder stub.
|
||||||
|
|
||||||
|
## 9. Configuration — editor form ↔ config keys
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph editor["Node-RED editor form"]
|
||||||
|
f1[Basin: volume / height]
|
||||||
|
f2[Levels: inflow / outflow / overflow]
|
||||||
|
f3[Control mode]
|
||||||
|
f4[Level setpoints: min / start / max]
|
||||||
|
f5[Safety: dry-run % / overfill %]
|
||||||
|
end
|
||||||
|
subgraph config["Domain config slice"]
|
||||||
|
c1[basin.volume<br/>basin.height]
|
||||||
|
c2[basin.inflowLevel<br/>basin.outflowLevel<br/>basin.overflowLevel]
|
||||||
|
c3[control.mode]
|
||||||
|
c4[control.levelbased.minLevel<br/>control.levelbased.startLevel<br/>control.levelbased.maxLevel]
|
||||||
|
c5[safety.dryRunThresholdPercent<br/>safety.overfillThresholdPercent]
|
||||||
|
end
|
||||||
|
f1 --> c1
|
||||||
|
f2 --> c2
|
||||||
|
f3 --> c3
|
||||||
|
f4 --> c4
|
||||||
|
f5 --> c5
|
||||||
|
```
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Range | Where used |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `basinVolume` | `basin.volume` | `1` | > 0 (m³) | `BasinGeometry` |
|
||||||
|
| `basinHeight` | `basin.height` | `1` | > 0 (m) | `BasinGeometry` |
|
||||||
|
| `inflowLevel` | `basin.inflowLevel` | `2` | ≥ 0 (m) | threshold validator, control |
|
||||||
|
| `outflowLevel` | `basin.outflowLevel` | `0.2` | ≥ 0 (m) | dead-volume floor |
|
||||||
|
| `overflowLevel` | `basin.overflowLevel` | `2.5` | > 0 (m) | overfill safety |
|
||||||
|
| `controlMode` | `control.mode` | `levelbased` | enum | `control/dispatch` |
|
||||||
|
| `minLevel` | `control.levelbased.minLevel` | `1` | ≥ 0 (m) | `levelBased.run` |
|
||||||
|
| `startLevel` | `control.levelbased.startLevel` | `1` | ≥ minLevel | ramp foot |
|
||||||
|
| `maxLevel` | `control.levelbased.maxLevel` | `4` | ≤ overflowLevel | ramp top |
|
||||||
|
| `enableDryRunProtection` | `safety.enableDryRunProtection` | `true` | bool | `SafetyController` |
|
||||||
|
| `dryRunThresholdPercent` | `safety.dryRunThresholdPercent` | `2` | 0–100 % | dry-run trip |
|
||||||
|
| `enableOverfillProtection` | `safety.enableOverfillProtection` | `true` | bool | overfill safety |
|
||||||
|
| `overfillThresholdPercent` | `safety.overfillThresholdPercent` | `98` | 0–100 % | overfill trip |
|
||||||
|
|
||||||
|
## 10. State chart
|
||||||
|
|
||||||
|
Two orthogonal state vectors: **control mode** (operator-driven) and **safety state** (data-driven). The diagram shows them together — most transitions are independent.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
state ControlMode {
|
||||||
|
[*] --> none
|
||||||
|
none --> levelbased: set.mode
|
||||||
|
levelbased --> flowbased: set.mode
|
||||||
|
flowbased --> manual: set.mode
|
||||||
|
manual --> levelbased: set.mode
|
||||||
|
levelbased --> none: set.mode
|
||||||
|
}
|
||||||
|
state SafetyState {
|
||||||
|
[*] --> nominal
|
||||||
|
nominal --> dryRun: vol < minVol AND draining
|
||||||
|
nominal --> overfill: vol > overfillThreshold AND filling
|
||||||
|
dryRun --> nominal: vol ≥ minVol
|
||||||
|
overfill --> nominal: vol ≤ overfillThreshold
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
While the safety state is `dryRun`, control dispatch is **skipped** entirely. While `overfill`, control still runs (pumps must keep draining) but upstream equipment is shut down.
|
||||||
|
|
||||||
|
## 11. Examples
|
||||||
|
|
||||||
|
Example flows live under `examples/` in the repo. The structured tier-1/2/3 flows for this node are still in progress; until they land, the standalone simulator demo is the only runnable artefact.
|
||||||
|
|
||||||
|
| Tier | File | What it shows | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Basic | `examples/01-Basic.flow.json` | Inject + dashboard, single basin, no parent | ⏳ TBD |
|
||||||
|
| Integration | `examples/02-Integration.flow.json` | pumpingStation + MGC + 2 pumps + measurement children | ⏳ TBD |
|
||||||
|
| Dashboard | `examples/03-Dashboard.flow.json` | Live FlowFuse charts (level, net flow, ETA) | ⏳ TBD |
|
||||||
|
| Headless | `examples/standalone-demo.js` | Node.js-only simulator, no Node-RED | ✅ in repo |
|
||||||
|
|
||||||
|
## 12. Debug recipes
|
||||||
|
|
||||||
|
| Symptom | First thing to check | Where to look |
|
||||||
|
|---|---|---|
|
||||||
|
| Status badge stuck on `❔ 0.0%` | Did any volume / level measurement register? Watch Port 2 + first-child event. | Editor debug tap on Port 2 + `_subscribeMeasurement` log line. |
|
||||||
|
| `direction` always `steady` | Net flow inside `general.flowThreshold` dead-band (default 0.0001 m³/s). | `flowAggregator.deriveDirection`. |
|
||||||
|
| `set.demand` ignored | Mode isn't `manual`. Check `set.mode` history. | `handlers.setDemand` debug log. |
|
||||||
|
| Predicted volume drifts off measured | Calibration needed — fire `cmd.calibrate.volume` with a known reading. | `measurement/calibration.js`. |
|
||||||
|
| Pumps don't stop on dry-run | `safety.enableDryRunProtection` must be `true` AND the orchestrator must see `direction='draining'`. | `SafetyController.evaluate`. |
|
||||||
|
| Threshold-ordering warnings on startup | `validateThresholdOrdering` printed `inflowLevel < overflowLevel` style violations. | `basin/thresholdValidator.js`. |
|
||||||
|
|
||||||
|
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors. Use only for live debugging.
|
||||||
|
|
||||||
|
## 13. When you would NOT use this node
|
||||||
|
|
||||||
|
- Use pumpingStation for a **wet-well basin** that needs orchestrated drainage. For a single pump with no basin model, use `rotatingMachine` directly.
|
||||||
|
- Don't use pumpingStation to schedule a fixed pump rota — its modes are reactive (level / flow / manual). Use an external scheduler if you need a calendar-driven schedule.
|
||||||
|
- Skip pumpingStation if you don't need predicted volume / time-to-full. A bare `machineGroupControl` is lighter when the upstream basin is modelled elsewhere.
|
||||||
|
|
||||||
|
## 14. Known limitations / current issues
|
||||||
|
|
||||||
|
| # | Issue | Tracked in |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Cascaded `pumpingstation` children accepted but not exercised in production — semantics of nested stations are not test-covered. | TBD |
|
||||||
|
| 2 | `pressureBased`, `percentageBased`, `powerBased`, and `hybrid` are in the config enum but not implemented as control strategies. | `control/index.js` — only `levelbased` / `flowbased` / `manual` dispatched. |
|
||||||
|
| 3 | Predicted-volume integrator can drift over long horizons without a measured-level calibration source. | `cmd.calibrate.volume` is operator-triggered, not automatic. |
|
||||||
|
| 4 | Tier 1/2/3 example flows not yet written — current `examples/` only contains the standalone simulator. | P2.14 (Docker E2E) + P9 wiki cleanup. |
|
||||||
Reference in New Issue
Block a user