diff --git a/examples/01-Basic.json b/examples/01-Basic.json new file mode 100644 index 0000000..56a4223 --- /dev/null +++ b/examples/01-Basic.json @@ -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 + } +] diff --git a/examples/02-Integration.json b/examples/02-Integration.json new file mode 100644 index 0000000..ac386dc --- /dev/null +++ b/examples/02-Integration.json @@ -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 + } +] diff --git a/examples/03-Dashboard.json b/examples/03-Dashboard.json new file mode 100644 index 0000000..6a486e5 --- /dev/null +++ b/examples/03-Dashboard.json @@ -0,0 +1,1325 @@ +[ + { + "id": "ps_dash_proc", + "type": "tab", + "label": "Process Plant", + "disabled": false, + "info": "Tier 3: full station with measurement + MGC + 2 pumps, formatted for live dashboard." + }, + { + "id": "ps_dash_ui", + "type": "tab", + "label": "Dashboard UI", + "disabled": false, + "info": "FlowFuse dashboard 2.0: 3 charts (flow / level / volumePercent), text widgets and 2 sliders." + }, + { + "id": "ps_dash_setup", + "type": "tab", + "label": "Setup", + "disabled": false, + "info": "Once-true injects: initial mode + initial inflow seed." + }, + { + "id": "ps_dash_base", + "type": "ui-base", + "name": "EVOLV Demo", + "path": "/dashboard", + "appIcon": "", + "includeClientData": true, + "acceptsClientConfig": [ + "ui-notification", + "ui-control" + ], + "showPathInSidebar": false, + "headerContent": "page", + "navigationStyle": "default", + "titleBarStyle": "default" + }, + { + "id": "ps_dash_theme", + "type": "ui-theme", + "name": "EVOLV Theme", + "colors": { + "surface": "#ffffff", + "primary": "#0c99d9", + "bgPage": "#eeeeee", + "groupBg": "#ffffff", + "groupOutline": "#cccccc" + }, + "sizes": { + "density": "default", + "pagePadding": "14px", + "groupGap": "14px", + "groupBorderRadius": "6px", + "widgetGap": "12px" + } + }, + { + "id": "ps_dash_page", + "type": "ui-page", + "name": "PumpingStation Demo", + "ui": "ps_dash_base", + "path": "/pumping-station", + "icon": "water", + "layout": "grid", + "theme": "ps_dash_theme", + "breakpoints": [ + { + "name": "Default", + "px": "0", + "cols": "12" + } + ], + "order": 1, + "className": "" + }, + { + "id": "ps_dash_grp_ctrl", + "type": "ui-group", + "name": "Controls", + "page": "ps_dash_page", + "width": 6, + "height": 1, + "order": 1, + "showTitle": true, + "className": "" + }, + { + "id": "ps_dash_grp_status", + "type": "ui-group", + "name": "Operator Status", + "page": "ps_dash_page", + "width": 6, + "height": 1, + "order": 2, + "showTitle": true, + "className": "" + }, + { + "id": "ps_dash_grp_trend", + "type": "ui-group", + "name": "Live Trends", + "page": "ps_dash_page", + "width": 12, + "height": 1, + "order": 3, + "showTitle": true, + "className": "" + }, + { + "id": "ps_dash_proc_title", + "type": "comment", + "z": "ps_dash_proc", + "name": "Process Plant\n━━━━━━━━━━━━━━━━━\nFull station with parent (MGC) and 2 pump children.\nEvents go to Dashboard UI through evt:ps; commands come back through cmd:ps-mode and cmd:ps-demand.", + "info": "", + "x": 600, + "y": 40, + "wires": [] + }, + { + "id": "lin_proc_mode", + "type": "link in", + "z": "ps_dash_proc", + "name": "cmd:ps-mode", + "links": [], + "x": 120, + "y": 480, + "wires": [ + [ + "ps_dash_station" + ] + ] + }, + { + "id": "lin_proc_demand", + "type": "link in", + "z": "ps_dash_proc", + "name": "cmd:ps-demand", + "links": [], + "x": 120, + "y": 540, + "wires": [ + [ + "ps_dash_station" + ] + ] + }, + { + "id": "lin_proc_setupmode", + "type": "link in", + "z": "ps_dash_proc", + "name": "setup:to-ps-mode", + "links": [], + "x": 120, + "y": 420, + "wires": [ + [ + "ps_dash_station" + ] + ] + }, + { + "id": "lin_proc_setupinflow", + "type": "link in", + "z": "ps_dash_proc", + "name": "setup:to-ps-inflow", + "links": [], + "x": 120, + "y": 600, + "wires": [ + [ + "ps_dash_station" + ] + ] + }, + { + "id": "ps_dash_meas_level", + "type": "measurement", + "z": "ps_dash_proc", + "name": "Basin level sensor", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 0, + "i_offset": 0, + "o_min": 0, + "o_max": 1, + "simulator": true, + "smooth_method": "mean", + "count": 5, + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "example-level-001", + "supplier": "vega", + "category": "sensor", + "assetType": "level", + "model": "VEGAPULS-31", + "unit": "m", + "assetTagNumber": "LT-001", + "enableLog": false, + "logLevel": "error", + "positionVsParent": "atEquipment", + "positionIcon": "", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 600, + "y": 700, + "wires": [ + [], + [], + [ + "ps_dash_station" + ] + ] + }, + { + "id": "ps_dash_inj_level", + "type": "inject", + "z": "ps_dash_proc", + "name": "sim level 1.6 m", + "props": [ + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload", + "v": "1.6", + "vt": "num" + } + ], + "topic": "measurement", + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": "", + "x": 120, + "y": 700, + "wires": [ + [ + "ps_dash_meas_level" + ] + ] + }, + { + "id": "ps_dash_pump_a", + "type": "rotatingMachine", + "z": "ps_dash_proc", + "name": "Pump A", + "speed": "1", + "startup": "2", + "warmup": "1", + "shutdown": "2", + "cooldown": "1", + "movementMode": "staticspeed", + "machineCurve": "", + "uuid": "example-pump-a", + "supplier": "hidrostal", + "category": "pump", + "assetType": "pump-centrifugal", + "model": "hidrostal-H05K-S03R", + "unit": "m3/h", + "curvePressureUnit": "mbar", + "curveFlowUnit": "m3/h", + "curvePowerUnit": "kW", + "curveControlUnit": "%", + "enableLog": false, + "logLevel": "error", + "positionVsParent": "atEquipment", + "positionIcon": "", + "hasDistance": false, + "distance": "", + "distanceUnit": "m", + "distanceDescription": "", + "x": 840, + "y": 320, + "wires": [ + [], + [], + [ + "ps_dash_mgc" + ] + ] + }, + { + "id": "ps_dash_pump_b", + "type": "rotatingMachine", + "z": "ps_dash_proc", + "name": "Pump B", + "speed": "1", + "startup": "2", + "warmup": "1", + "shutdown": "2", + "cooldown": "1", + "movementMode": "staticspeed", + "machineCurve": "", + "uuid": "example-pump-b", + "supplier": "hidrostal", + "category": "pump", + "assetType": "pump-centrifugal", + "model": "hidrostal-H05K-S03R", + "unit": "m3/h", + "curvePressureUnit": "mbar", + "curveFlowUnit": "m3/h", + "curvePowerUnit": "kW", + "curveControlUnit": "%", + "enableLog": false, + "logLevel": "error", + "positionVsParent": "atEquipment", + "positionIcon": "", + "hasDistance": false, + "distance": "", + "distanceUnit": "m", + "distanceDescription": "", + "x": 840, + "y": 400, + "wires": [ + [], + [], + [ + "ps_dash_mgc" + ] + ] + }, + { + "id": "ps_dash_mgc", + "type": "machineGroupControl", + "z": "ps_dash_proc", + "name": "Pump Group", + "enableLog": true, + "logLevel": "info", + "positionVsParent": "atEquipment", + "positionIcon": "", + "hasDistance": false, + "distance": "", + "distanceUnit": "m", + "x": 1080, + "y": 360, + "wires": [ + [], + [], + [ + "ps_dash_station" + ] + ] + }, + { + "id": "ps_dash_station", + "type": "pumpingStation", + "z": "ps_dash_proc", + "name": "Pumping Station", + "simulator": false, + "basinVolume": 50, + "basinHeight": 3.5, + "inflowLevel": 3, + "outflowLevel": 0.2, + "overflowLevel": 3.2, + "defaultFluid": "wastewater", + "inletPipeDiameter": 0.3, + "outletPipeDiameter": 0.3, + "pipelineLength": 80, + "maxDischargeHead": 24, + "staticHead": 12, + "maxInflowRate": 200, + "temperatureReferenceDegC": 15, + "timeleftToFullOrEmptyThresholdSeconds": 0, + "enableDryRunProtection": true, + "enableOverfillProtection": true, + "dryRunThresholdPercent": 2, + "overfillThresholdPercent": 98, + "minHeightBasedOn": "outlet", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "refHeight": "NAP", + "basinBottomRef": 1, + "uuid": "example-ps-001", + "supplier": "WBD-RD", + "category": "station", + "assetType": "pumpingstation", + "model": "demo-50m3", + "unit": "m3/h", + "enableLog": true, + "logLevel": "info", + "positionVsParent": "atEquipment", + "positionIcon": "", + "hasDistance": false, + "distance": "", + "distanceUnit": "m", + "distanceDescription": "", + "controlMode": "levelbased", + "startLevel": 1.2, + "minLevel": 0.4, + "maxLevel": 2.8, + "flowSetpoint": null, + "flowDeadband": null, + "x": 1320, + "y": 520, + "wires": [ + [ + "ps_dash_trend_split" + ], + [], + [] + ] + }, + { + "id": "ps_dash_trend_split", + "type": "function", + "z": "ps_dash_proc", + "name": "Trend split + status", + "func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\nfunction pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\nconst flowIn = pick('flow.predicted.in');\nconst flowOut = pick('flow.predicted.out');\nconst level = pick('level.predicted.atequipment');\nconst volPct = Number(cache.volumePercent);\nconst ts = Date.now();\nconst flowMsgs = [];\nif (flowIn != null) flowMsgs.push({ topic: 'Inflow', payload: flowIn * 3600, timestamp: ts });\nif (flowOut != null) flowMsgs.push({ topic: 'Outflow', payload: flowOut * 3600, timestamp: ts });\nconst flowOut1 = flowMsgs.length ? flowMsgs : null;\nconst levelOut = level != null ? { topic: 'Level', payload: level, timestamp: ts } : null;\nconst volOut = Number.isFinite(volPct) ? { topic: 'Volume%', payload: volPct, timestamp: ts } : null;\nconst stateStr = `state=${cache.state||'?'} | mode=${cache.controlMode||cache.mode||'?'}`;\nconst percStr = cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a';\nconst dirStr = cache.direction || 'n/a';\nconst tEmpty = cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a';\nreturn [\n flowOut1,\n levelOut,\n volOut,\n { payload: stateStr },\n { payload: percStr },\n { payload: dirStr },\n { payload: tEmpty }\n];", + "outputs": 7, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1560, + "y": 520, + "wires": [ + [ + "lout_evt_flow" + ], + [ + "lout_evt_level" + ], + [ + "lout_evt_volpct" + ], + [ + "lout_evt_state" + ], + [ + "lout_evt_perc" + ], + [ + "lout_evt_dir" + ], + [ + "lout_evt_tempty" + ] + ] + }, + { + "id": "lout_evt_flow", + "type": "link out", + "z": "ps_dash_proc", + "name": "evt:flow", + "mode": "link", + "links": [ + "lin_ui_flow" + ], + "x": 1800, + "y": 420, + "wires": [] + }, + { + "id": "lout_evt_level", + "type": "link out", + "z": "ps_dash_proc", + "name": "evt:level", + "mode": "link", + "links": [ + "lin_ui_level" + ], + "x": 1800, + "y": 460, + "wires": [] + }, + { + "id": "lout_evt_volpct", + "type": "link out", + "z": "ps_dash_proc", + "name": "evt:volpct", + "mode": "link", + "links": [ + "lin_ui_volpct" + ], + "x": 1800, + "y": 500, + "wires": [] + }, + { + "id": "lout_evt_state", + "type": "link out", + "z": "ps_dash_proc", + "name": "evt:state", + "mode": "link", + "links": [ + "lin_ui_state" + ], + "x": 1800, + "y": 540, + "wires": [] + }, + { + "id": "lout_evt_perc", + "type": "link out", + "z": "ps_dash_proc", + "name": "evt:perc", + "mode": "link", + "links": [ + "lin_ui_perc" + ], + "x": 1800, + "y": 580, + "wires": [] + }, + { + "id": "lout_evt_dir", + "type": "link out", + "z": "ps_dash_proc", + "name": "evt:dir", + "mode": "link", + "links": [ + "lin_ui_dir" + ], + "x": 1800, + "y": 620, + "wires": [] + }, + { + "id": "lout_evt_tempty", + "type": "link out", + "z": "ps_dash_proc", + "name": "evt:tempty", + "mode": "link", + "links": [ + "lin_ui_tempty" + ], + "x": 1800, + "y": 660, + "wires": [] + }, + { + "id": "ps_dash_grp_station", + "type": "group", + "z": "ps_dash_proc", + "name": "Pumping Station (PC)", + "style": { + "label": true, + "stroke": "#000000", + "fill": "#0c99d9", + "fill-opacity": "0.10" + }, + "nodes": [ + "ps_dash_station", + "ps_dash_trend_split", + "lin_proc_mode", + "lin_proc_demand", + "lin_proc_setupmode", + "lin_proc_setupinflow", + "lout_evt_flow", + "lout_evt_level", + "lout_evt_volpct", + "lout_evt_state", + "lout_evt_perc", + "lout_evt_dir", + "lout_evt_tempty" + ], + "x": 95, + "y": 375, + "w": 1930, + "h": 350 + }, + { + "id": "ps_dash_grp_pa", + "type": "group", + "z": "ps_dash_proc", + "name": "Pump A (EM)", + "style": { + "label": true, + "stroke": "#000000", + "fill": "#86bbdd", + "fill-opacity": "0.10" + }, + "nodes": [ + "ps_dash_pump_a" + ], + "x": 815, + "y": 275, + "w": 250, + "h": 110 + }, + { + "id": "ps_dash_grp_pb", + "type": "group", + "z": "ps_dash_proc", + "name": "Pump B (EM)", + "style": { + "label": true, + "stroke": "#000000", + "fill": "#86bbdd", + "fill-opacity": "0.10" + }, + "nodes": [ + "ps_dash_pump_b" + ], + "x": 815, + "y": 355, + "w": 250, + "h": 110 + }, + { + "id": "ps_dash_grp_mgc", + "type": "group", + "z": "ps_dash_proc", + "name": "Pump Group MGC (UN)", + "style": { + "label": true, + "stroke": "#000000", + "fill": "#50a8d9", + "fill-opacity": "0.10" + }, + "nodes": [ + "ps_dash_mgc" + ], + "x": 1055, + "y": 315, + "w": 250, + "h": 110 + }, + { + "id": "ps_dash_grp_level", + "type": "group", + "z": "ps_dash_proc", + "name": "Level Sensor (CM)", + "style": { + "label": true, + "stroke": "#000000", + "fill": "#a9daee", + "fill-opacity": "0.10" + }, + "nodes": [ + "ps_dash_meas_level", + "ps_dash_inj_level" + ], + "x": 95, + "y": 655, + "w": 730, + "h": 110 + }, + { + "id": "ps_dash_ui_title", + "type": "comment", + "z": "ps_dash_ui", + "name": "Dashboard UI\n━━━━━━━━━━━━━━━\nLink-ins on L0 receive evt:* from Process Plant.\nSliders on L2 emit cmd:* back to Process Plant.\nCharts use the trend-split pattern: one chart per metric, series labelled by msg.topic.", + "info": "", + "x": 600, + "y": 40, + "wires": [] + }, + { + "id": "lin_ui_flow", + "type": "link in", + "z": "ps_dash_ui", + "name": "evt:flow", + "links": [], + "x": 120, + "y": 220, + "wires": [ + [ + "ui_chart_flow" + ] + ] + }, + { + "id": "lin_ui_level", + "type": "link in", + "z": "ps_dash_ui", + "name": "evt:level", + "links": [], + "x": 120, + "y": 320, + "wires": [ + [ + "ui_chart_level" + ] + ] + }, + { + "id": "lin_ui_volpct", + "type": "link in", + "z": "ps_dash_ui", + "name": "evt:volpct", + "links": [], + "x": 120, + "y": 420, + "wires": [ + [ + "ui_chart_volpct" + ] + ] + }, + { + "id": "lin_ui_state", + "type": "link in", + "z": "ps_dash_ui", + "name": "evt:state", + "links": [], + "x": 120, + "y": 520, + "wires": [ + [ + "ui_text_state" + ] + ] + }, + { + "id": "lin_ui_perc", + "type": "link in", + "z": "ps_dash_ui", + "name": "evt:perc", + "links": [], + "x": 120, + "y": 560, + "wires": [ + [ + "ui_text_perc" + ] + ] + }, + { + "id": "lin_ui_dir", + "type": "link in", + "z": "ps_dash_ui", + "name": "evt:dir", + "links": [], + "x": 120, + "y": 600, + "wires": [ + [ + "ui_text_dir" + ] + ] + }, + { + "id": "lin_ui_tempty", + "type": "link in", + "z": "ps_dash_ui", + "name": "evt:tempty", + "links": [], + "x": 120, + "y": 640, + "wires": [ + [ + "ui_text_tempty" + ] + ] + }, + { + "id": "ui_chart_flow", + "type": "ui-chart", + "z": "ps_dash_ui", + "group": "ps_dash_grp_trend", + "name": "Flow trend", + "label": "Flow (m³/h)", + "order": 1, + "width": 12, + "height": 6, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "time", + "xAxisType": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisLabel": "m³/h", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "xmin": "", + "xmax": "", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "interpolation": "linear", + "showLegend": true, + "className": "", + "removeOlder": "15", + "removeOlderUnit": "60", + "removeOlderPoints": "200", + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "x": 1080, + "y": 220, + "wires": [] + }, + { + "id": "ui_chart_level", + "type": "ui-chart", + "z": "ps_dash_ui", + "group": "ps_dash_grp_trend", + "name": "Level trend", + "label": "Level (m)", + "order": 2, + "width": 12, + "height": 6, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "time", + "xAxisType": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisLabel": "m", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "xmin": "", + "xmax": "", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "interpolation": "linear", + "showLegend": true, + "className": "", + "removeOlder": "15", + "removeOlderUnit": "60", + "removeOlderPoints": "200", + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "x": 1080, + "y": 320, + "wires": [] + }, + { + "id": "ui_chart_volpct", + "type": "ui-chart", + "z": "ps_dash_ui", + "group": "ps_dash_grp_trend", + "name": "Volume %", + "label": "Volume (%)", + "order": 3, + "width": 12, + "height": 6, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "time", + "xAxisType": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisLabel": "%", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "xmin": "", + "xmax": "", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "interpolation": "linear", + "showLegend": true, + "className": "", + "removeOlder": "15", + "removeOlderUnit": "60", + "removeOlderPoints": "200", + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "x": 1080, + "y": 420, + "wires": [] + }, + { + "id": "ui_text_state", + "type": "ui-text", + "z": "ps_dash_ui", + "group": "ps_dash_grp_status", + "name": "State", + "label": "Station state", + "order": 1, + "width": 4, + "height": 1, + "format": "{{msg.payload}}", + "layout": "row-spread", + "x": 1080, + "y": 520, + "wires": [] + }, + { + "id": "ui_text_perc", + "type": "ui-text", + "z": "ps_dash_ui", + "group": "ps_dash_grp_status", + "name": "percControl", + "label": "Control %", + "order": 2, + "width": 4, + "height": 1, + "format": "{{msg.payload}}", + "layout": "row-spread", + "x": 1080, + "y": 560, + "wires": [] + }, + { + "id": "ui_text_dir", + "type": "ui-text", + "z": "ps_dash_ui", + "group": "ps_dash_grp_status", + "name": "direction", + "label": "Direction", + "order": 3, + "width": 4, + "height": 1, + "format": "{{msg.payload}}", + "layout": "row-spread", + "x": 1080, + "y": 600, + "wires": [] + }, + { + "id": "ui_text_tempty", + "type": "ui-text", + "z": "ps_dash_ui", + "group": "ps_dash_grp_status", + "name": "timeToEmpty", + "label": "Time to empty", + "order": 4, + "width": 4, + "height": 1, + "format": "{{msg.payload}}", + "layout": "row-spread", + "x": 1080, + "y": 640, + "wires": [] + }, + { + "id": "ui_dd_mode", + "type": "ui-dropdown", + "z": "ps_dash_ui", + "group": "ps_dash_grp_ctrl", + "name": "Mode", + "label": "Control mode", + "order": 1, + "width": 6, + "height": 1, + "passthru": true, + "multiple": false, + "options": [ + { + "label": "manual", + "value": "manual", + "type": "str" + }, + { + "label": "levelbased", + "value": "levelbased", + "type": "str" + }, + { + "label": "flowbased", + "value": "flowbased", + "type": "str" + }, + { + "label": "none", + "value": "none", + "type": "str" + } + ], + "payload": "", + "topic": "set.mode", + "topicType": "str", + "x": 600, + "y": 160, + "wires": [ + [ + "ui_wrap_mode" + ] + ] + }, + { + "id": "ui_sl_demand", + "type": "ui-slider", + "z": "ps_dash_ui", + "group": "ps_dash_grp_ctrl", + "name": "Demand", + "label": "Manual demand (m³/h)", + "order": 2, + "width": 6, + "height": 1, + "passthru": true, + "outs": "end", + "topic": "set.demand", + "topicType": "str", + "min": 0, + "max": 200, + "step": 5, + "icon": "", + "thumbLabel": "always", + "showValue": true, + "className": "", + "x": 600, + "y": 220, + "wires": [ + [ + "ui_wrap_demand" + ] + ] + }, + { + "id": "ui_wrap_mode", + "type": "function", + "z": "ps_dash_ui", + "name": "topic=set.mode", + "func": "msg.topic = 'set.mode';\nmsg.payload = String(msg.payload || 'manual');\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1080, + "y": 160, + "wires": [ + [ + "lout_cmd_mode" + ] + ] + }, + { + "id": "ui_wrap_demand", + "type": "function", + "z": "ps_dash_ui", + "name": "topic=set.demand", + "func": "msg.topic = 'set.demand';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1080, + "y": 220, + "wires": [ + [ + "lout_cmd_demand" + ] + ] + }, + { + "id": "lout_cmd_mode", + "type": "link out", + "z": "ps_dash_ui", + "name": "cmd:ps-mode", + "mode": "link", + "links": [ + "lin_proc_mode" + ], + "x": 1800, + "y": 160, + "wires": [] + }, + { + "id": "lout_cmd_demand", + "type": "link out", + "z": "ps_dash_ui", + "name": "cmd:ps-demand", + "mode": "link", + "links": [ + "lin_proc_demand" + ], + "x": 1800, + "y": 220, + "wires": [] + }, + { + "id": "grp_ui_ctrl", + "type": "group", + "z": "ps_dash_ui", + "name": "Controls (PC)", + "style": { + "label": true, + "stroke": "#000000", + "fill": "#0c99d9", + "fill-opacity": "0.10" + }, + "nodes": [ + "ui_dd_mode", + "ui_sl_demand", + "ui_wrap_mode", + "ui_wrap_demand", + "lout_cmd_mode", + "lout_cmd_demand" + ], + "x": 575, + "y": 115, + "w": 1450, + "h": 170 + }, + { + "id": "grp_ui_status", + "type": "group", + "z": "ps_dash_ui", + "name": "Operator status (PC)", + "style": { + "label": true, + "stroke": "#000000", + "fill": "#0c99d9", + "fill-opacity": "0.10" + }, + "nodes": [ + "ui_text_state", + "ui_text_perc", + "ui_text_dir", + "ui_text_tempty", + "lin_ui_state", + "lin_ui_perc", + "lin_ui_dir", + "lin_ui_tempty" + ], + "x": 95, + "y": 475, + "w": 1210, + "h": 230 + }, + { + "id": "grp_ui_trend", + "type": "group", + "z": "ps_dash_ui", + "name": "Live trends (PC)", + "style": { + "label": true, + "stroke": "#000000", + "fill": "#0c99d9", + "fill-opacity": "0.10" + }, + "nodes": [ + "ui_chart_flow", + "ui_chart_level", + "ui_chart_volpct", + "lin_ui_flow", + "lin_ui_level", + "lin_ui_volpct" + ], + "x": 95, + "y": 175, + "w": 1210, + "h": 310 + }, + { + "id": "ps_dash_setup_title", + "type": "comment", + "z": "ps_dash_setup", + "name": "Deploy-time setup\n━━━━━━━━━━━━━━━━━━━\nInitialises set.mode = levelbased and seeds an inflow at deploy time.", + "info": "", + "x": 600, + "y": 40, + "wires": [] + }, + { + "id": "ps_dash_setup_mode", + "type": "inject", + "z": "ps_dash_setup", + "name": "set.mode = levelbased", + "props": [ + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload", + "v": "levelbased", + "vt": "str" + } + ], + "topic": "set.mode", + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": "0.5", + "x": 120, + "y": 160, + "wires": [ + [ + "ps_dash_lout_setup_mode" + ] + ] + }, + { + "id": "ps_dash_setup_inflow", + "type": "inject", + "z": "ps_dash_setup", + "name": "seed inflow 60 m3/h", + "props": [ + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload", + "v": "60", + "vt": "num" + } + ], + "topic": "set.inflow", + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": "1.0", + "x": 120, + "y": 220, + "wires": [ + [ + "ps_dash_lout_setup_inflow" + ] + ] + }, + { + "id": "ps_dash_lout_setup_mode", + "type": "link out", + "z": "ps_dash_setup", + "name": "setup:to-ps-mode", + "mode": "link", + "links": [ + "lin_proc_setupmode" + ], + "x": 1800, + "y": 160, + "wires": [] + }, + { + "id": "ps_dash_lout_setup_inflow", + "type": "link out", + "z": "ps_dash_setup", + "name": "setup:to-ps-inflow", + "mode": "link", + "links": [ + "lin_proc_setupinflow" + ], + "x": 1800, + "y": 220, + "wires": [] + }, + { + "id": "ps_dash_grp_setup", + "type": "group", + "z": "ps_dash_setup", + "name": "Deploy-time setup", + "style": { + "label": true, + "stroke": "#000000", + "fill": "#dddddd", + "fill-opacity": "0.10" + }, + "nodes": [ + "ps_dash_setup_mode", + "ps_dash_setup_inflow", + "ps_dash_lout_setup_mode", + "ps_dash_lout_setup_inflow" + ], + "x": 95, + "y": 115, + "w": 1930, + "h": 170 + } +] diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..593d26f --- /dev/null +++ b/examples/README.md @@ -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. diff --git a/package.json b/package.json index 844ec69..128c52d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,10 @@ "description": "Control module", "main": "pumpingStation.js", "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": { "type": "git", diff --git a/tools/build-examples.js b/tools/build-examples.js new file mode 100644 index 0000000..74e7ca4 --- /dev/null +++ b/tools/build-examples.js @@ -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(); diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 0000000..1a51217 --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,285 @@ +# pumpingStation + +> **Reflects code as of `d2384b1` · regenerated `` 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
Process Cell]:::pc + meas_lvl[measurement
type=level
position=atequipment]:::ctrl + meas_in[measurement
type=flow
position=upstream]:::ctrl + mgc[machineGroupControl
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()
static DomainClass, commands
static tickInterval = 1000ms"] + end + subgraph domain["specificClass.js — orchestrator (BaseDomain)"] + sc["PumpingStation.configure()
declares ChildRouter rules
tick() → safety → control"] + end + subgraph concerns["src/ concern modules"] + basin["basin/
BasinGeometry + thresholdValidator"] + measurement["measurement/
flowAggregator + router + calibration"] + control["control/
levelbased / flowbased / manual"] + safety["safety/
SafetyController"] + commands["commands/
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`. + + + +| 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. | + + + +## 6. Child registration + +Mirrors the `ChildRouter` declarations in `specificClass.js → configure()`. + +```mermaid +flowchart LR + subgraph kids["accepted children (softwareType)"] + m["measurement"]:::ctrl + mach["machine
(rotatingMachine)"]:::equip + mgc["machinegroup"]:::unit + sub["pumpingstation
(sub-station)"]:::pc + end + m -->|"<type>.measured.<position>"| route1[_subscribeMeasurement
routes to measurementRouter] + mach -->|flow.predicted.<in or out>| route2[_subscribePredictedFlow
+ 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. | `.measured.` 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.` per the child's `positionVsParent`. | +| `machinegroup` | Stored in `this.machineGroups[id]`. | `flow.predicted.`. | +| `pumpingstation` | Stored in `this.stations[id]`. | `flow.predicted.`. | + +## 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`. + + + +| 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` | + + + +The `` 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
basin.height] + c2[basin.inflowLevel
basin.outflowLevel
basin.overflowLevel] + c3[control.mode] + c4[control.levelbased.minLevel
control.levelbased.startLevel
control.levelbased.maxLevel] + c5[safety.dryRunThresholdPercent
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. |