Files
EVOLV/examples/pumpingstation-complete-example/flow.json
Rene De Ren 0cab98c196
Some checks failed
CI / lint-and-test (push) Has been cancelled
Pumping-station demo overhaul + cross-node test harness + bumps
Submodule bumps land the deadlock fix (state.js residue unpark + MGC
optimalControl dispatch reorder) and pumpingStation stopLevel hysteresis.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:21:21 +02:00

5462 lines
134 KiB
JSON

[
{
"id": "tab_process",
"type": "tab",
"label": "\ud83c\udfed Process Plant",
"disabled": false,
"info": "EVOLV plant model: 3 rotatingMachines (each with 4 measurement nodes \u2014 upstream P, downstream P, flow, power), MGC, PS.\n\nPer pump there is a 'physics' function node that consumes the pump's own port-0 stream PLUS PS port-0 (basin level) and drives all 4 measurement nodes with physically-coupled values (upstream P from basin head; downstream P from pump state + flow; flow/power mirror predicted with Gaussian noise). This lives on this tab so the plant model is self-contained.\n\nAll cross-tab wires use named link-in / link-out channels."
},
{
"id": "c_process_title",
"type": "comment",
"z": "tab_process",
"name": "\ud83c\udfed PROCESS PLANT \u2014 EVOLV nodes + per-pump physics feeders",
"info": "",
"x": 640,
"y": 20,
"wires": []
},
{
"id": "c_pump_a",
"type": "comment",
"z": "tab_process",
"name": "\u2500\u2500 Pump A \u2500\u2500 (pump + 4 sensors + physics feeder)",
"info": "Up/Dn pressure + flow + power sensors register as children of the pump. The physics_<pump> function takes the pump's own port-0 stream and PS port-0 (basin level) and drives all 4 sensors with physically-coupled values.",
"x": 640,
"y": 80,
"wires": []
},
{
"id": "meas_pump_a_u",
"type": "measurement",
"z": "tab_process",
"name": "A-Up",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 4000,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_a-u",
"supplier": "vega",
"category": "sensor",
"assetType": "pressure",
"model": "vega-pressure-10",
"unit": "mbar",
"assetTagNumber": "A-U",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "upstream",
"positionIcon": "\u2192",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 120,
"wires": [
[],
[
"lout_tlm_meas_pump_a_u"
],
[
"pump_a"
]
]
},
{
"id": "lout_tlm_meas_pump_a_u",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 120,
"wires": []
},
{
"id": "meas_pump_a_d",
"type": "measurement",
"z": "tab_process",
"name": "A-Dn",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 4000,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_a-d",
"supplier": "vega",
"category": "sensor",
"assetType": "pressure",
"model": "vega-pressure-10",
"unit": "mbar",
"assetTagNumber": "A-D",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "downstream",
"positionIcon": "\u2190",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 155,
"wires": [
[],
[
"lout_tlm_meas_pump_a_d"
],
[
"pump_a"
]
]
},
{
"id": "lout_tlm_meas_pump_a_d",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 155,
"wires": []
},
{
"id": "meas_pump_a_f",
"type": "measurement",
"z": "tab_process",
"name": "A-Flow",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 250,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_a-f",
"supplier": "endress",
"category": "sensor",
"assetType": "flow",
"model": "endress-promag-50",
"unit": "m3/h",
"assetTagNumber": "A-F",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "downstream",
"positionIcon": "\u2190",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 190,
"wires": [
[],
[
"lout_tlm_meas_pump_a_f"
],
[
"pump_a"
]
]
},
{
"id": "lout_tlm_meas_pump_a_f",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 190,
"wires": []
},
{
"id": "meas_pump_a_p",
"type": "measurement",
"z": "tab_process",
"name": "A-Pwr",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 30,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_a-p",
"supplier": "siemens",
"category": "sensor",
"assetType": "power",
"model": "siemens-sentron-pac4200",
"unit": "kW",
"assetTagNumber": "A-P",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "atEquipment",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 225,
"wires": [
[],
[
"lout_tlm_meas_pump_a_p"
],
[
"pump_a"
]
]
},
{
"id": "lout_tlm_meas_pump_a_p",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 225,
"wires": []
},
{
"id": "pump_a",
"type": "rotatingMachine",
"z": "tab_process",
"name": "Pump A",
"speed": "200",
"startup": "2",
"warmup": "1",
"shutdown": "2",
"cooldown": "1",
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": "pump-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": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "atEquipment",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 900,
"y": 170,
"wires": [
[
"format_pump_a",
"physics_pump_a"
],
[
"lout_tlm_pump_a"
],
[
"mgc_pumps"
]
]
},
{
"id": "lout_tlm_pump_a",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 900,
"y": 210,
"wires": []
},
{
"id": "format_pump_a",
"type": "function",
"z": "tab_process",
"name": "format Pump A port 0",
"func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle dashboard fan-out to \u2264 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1160,
"y": 170,
"wires": [
[
"lout_evt_pump_a"
]
]
},
{
"id": "lout_evt_pump_a",
"type": "link out",
"z": "tab_process",
"name": "evt:pump-A",
"mode": "link",
"links": [
"lin_evt_pump_a_dash"
],
"x": 1420,
"y": 170,
"wires": []
},
{
"id": "physics_pump_a",
"type": "function",
"z": "tab_process",
"name": "physics Pump A \u2192 4 sensors",
"func": "const c = context.get('c') || {};\nfunction find(o, prefix) {\n for (const k in o) { if (k.indexOf(prefix) === 0) return o[k]; }\n return null;\n}\nfunction gauss(sigma) {\n let s = 0;\n for (let i = 0; i < 12; i++) s += Math.random();\n return (s - 6) * sigma;\n}\n\nif (msg.from === 'ps') {\n const psSnap = c.ps || {};\n Object.assign(psSnap, msg.payload || {});\n c.ps = psSnap;\n const lvl = find(psSnap, 'level.predicted.atequipment.')\n ?? find(psSnap, 'level.measured.atequipment.');\n if (lvl != null) c.basinLevel = Number(lvl);\n context.set('c', c);\n return null;\n}\n\nconst pumpSnap = c.pump || {};\nObject.assign(pumpSnap, msg.payload || {});\nc.pump = pumpSnap;\ncontext.set('c', c);\n// Throttle: 1 Hz sensor updates are plenty for the demo; the\n// pump emits on every state change (5+/sec while cycling).\nconst _now = Date.now();\nconst _last = context.get('_lastEmit') || 0;\nif (_now - _last < 1000) return null;\ncontext.set('_lastEmit', _now);\n\nconst state = pumpSnap.state || 'idle';\n// 'isRunning' = the rotor is spinning (any non-idle, non-cooled state).\n// MGC retargets flow on every tick, so the pump spends most of its\n// time in 'accelerating' or 'decelerating', not 'operational'. Those\n// transient states are still moving water \u2014 flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') \u2014 `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_a', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_a_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure \u2014 same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure \u2014 driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m\u00b3/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n",
"outputs": 4,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1160,
"y": 240,
"wires": [
[
"meas_pump_a_u"
],
[
"meas_pump_a_d"
],
[
"meas_pump_a_f"
],
[
"meas_pump_a_p"
]
]
},
{
"id": "lin_setpoint_pump_a",
"type": "link in",
"z": "tab_process",
"name": "cmd:setpoint-A",
"links": [
"lout_setpoint_pump_a_dash"
],
"x": 120,
"y": 140,
"wires": [
[
"build_setpoint_pump_a"
]
]
},
{
"id": "build_setpoint_pump_a",
"type": "function",
"z": "tab_process",
"name": "build setpoint cmd (Pump A)",
"func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 600,
"y": 140,
"wires": [
[
"pump_a"
]
]
},
{
"id": "lin_seq_pump_a",
"type": "link in",
"z": "tab_process",
"name": "cmd:pump-A-seq",
"links": [
"lout_seq_pump_a_dash"
],
"x": 120,
"y": 190,
"wires": [
[
"pump_a"
]
]
},
{
"id": "c_pump_b",
"type": "comment",
"z": "tab_process",
"name": "\u2500\u2500 Pump B \u2500\u2500 (pump + 4 sensors + physics feeder)",
"info": "Up/Dn pressure + flow + power sensors register as children of the pump. The physics_<pump> function takes the pump's own port-0 stream and PS port-0 (basin level) and drives all 4 sensors with physically-coupled values.",
"x": 640,
"y": 360,
"wires": []
},
{
"id": "meas_pump_b_u",
"type": "measurement",
"z": "tab_process",
"name": "B-Up",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 4000,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_b-u",
"supplier": "vega",
"category": "sensor",
"assetType": "pressure",
"model": "vega-pressure-10",
"unit": "mbar",
"assetTagNumber": "B-U",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "upstream",
"positionIcon": "\u2192",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 400,
"wires": [
[],
[
"lout_tlm_meas_pump_b_u"
],
[
"pump_b"
]
]
},
{
"id": "lout_tlm_meas_pump_b_u",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 400,
"wires": []
},
{
"id": "meas_pump_b_d",
"type": "measurement",
"z": "tab_process",
"name": "B-Dn",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 4000,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_b-d",
"supplier": "vega",
"category": "sensor",
"assetType": "pressure",
"model": "vega-pressure-10",
"unit": "mbar",
"assetTagNumber": "B-D",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "downstream",
"positionIcon": "\u2190",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 435,
"wires": [
[],
[
"lout_tlm_meas_pump_b_d"
],
[
"pump_b"
]
]
},
{
"id": "lout_tlm_meas_pump_b_d",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 435,
"wires": []
},
{
"id": "meas_pump_b_f",
"type": "measurement",
"z": "tab_process",
"name": "B-Flow",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 250,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_b-f",
"supplier": "endress",
"category": "sensor",
"assetType": "flow",
"model": "endress-promag-50",
"unit": "m3/h",
"assetTagNumber": "B-F",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "downstream",
"positionIcon": "\u2190",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 470,
"wires": [
[],
[
"lout_tlm_meas_pump_b_f"
],
[
"pump_b"
]
]
},
{
"id": "lout_tlm_meas_pump_b_f",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 470,
"wires": []
},
{
"id": "meas_pump_b_p",
"type": "measurement",
"z": "tab_process",
"name": "B-Pwr",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 30,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_b-p",
"supplier": "siemens",
"category": "sensor",
"assetType": "power",
"model": "siemens-sentron-pac4200",
"unit": "kW",
"assetTagNumber": "B-P",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "atEquipment",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 505,
"wires": [
[],
[
"lout_tlm_meas_pump_b_p"
],
[
"pump_b"
]
]
},
{
"id": "lout_tlm_meas_pump_b_p",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 505,
"wires": []
},
{
"id": "pump_b",
"type": "rotatingMachine",
"z": "tab_process",
"name": "Pump B",
"speed": "200",
"startup": "2",
"warmup": "1",
"shutdown": "2",
"cooldown": "1",
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": "pump-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": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "atEquipment",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 900,
"y": 450,
"wires": [
[
"format_pump_b",
"physics_pump_b"
],
[
"lout_tlm_pump_b"
],
[
"mgc_pumps"
]
]
},
{
"id": "lout_tlm_pump_b",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 900,
"y": 490,
"wires": []
},
{
"id": "format_pump_b",
"type": "function",
"z": "tab_process",
"name": "format Pump B port 0",
"func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle dashboard fan-out to \u2264 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1160,
"y": 450,
"wires": [
[
"lout_evt_pump_b"
]
]
},
{
"id": "lout_evt_pump_b",
"type": "link out",
"z": "tab_process",
"name": "evt:pump-B",
"mode": "link",
"links": [
"lin_evt_pump_b_dash"
],
"x": 1420,
"y": 450,
"wires": []
},
{
"id": "physics_pump_b",
"type": "function",
"z": "tab_process",
"name": "physics Pump B \u2192 4 sensors",
"func": "const c = context.get('c') || {};\nfunction find(o, prefix) {\n for (const k in o) { if (k.indexOf(prefix) === 0) return o[k]; }\n return null;\n}\nfunction gauss(sigma) {\n let s = 0;\n for (let i = 0; i < 12; i++) s += Math.random();\n return (s - 6) * sigma;\n}\n\nif (msg.from === 'ps') {\n const psSnap = c.ps || {};\n Object.assign(psSnap, msg.payload || {});\n c.ps = psSnap;\n const lvl = find(psSnap, 'level.predicted.atequipment.')\n ?? find(psSnap, 'level.measured.atequipment.');\n if (lvl != null) c.basinLevel = Number(lvl);\n context.set('c', c);\n return null;\n}\n\nconst pumpSnap = c.pump || {};\nObject.assign(pumpSnap, msg.payload || {});\nc.pump = pumpSnap;\ncontext.set('c', c);\n// Throttle: 1 Hz sensor updates are plenty for the demo; the\n// pump emits on every state change (5+/sec while cycling).\nconst _now = Date.now();\nconst _last = context.get('_lastEmit') || 0;\nif (_now - _last < 1000) return null;\ncontext.set('_lastEmit', _now);\n\nconst state = pumpSnap.state || 'idle';\n// 'isRunning' = the rotor is spinning (any non-idle, non-cooled state).\n// MGC retargets flow on every tick, so the pump spends most of its\n// time in 'accelerating' or 'decelerating', not 'operational'. Those\n// transient states are still moving water \u2014 flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') \u2014 `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_b', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_b_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure \u2014 same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure \u2014 driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m\u00b3/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n",
"outputs": 4,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1160,
"y": 520,
"wires": [
[
"meas_pump_b_u"
],
[
"meas_pump_b_d"
],
[
"meas_pump_b_f"
],
[
"meas_pump_b_p"
]
]
},
{
"id": "lin_setpoint_pump_b",
"type": "link in",
"z": "tab_process",
"name": "cmd:setpoint-B",
"links": [
"lout_setpoint_pump_b_dash"
],
"x": 120,
"y": 420,
"wires": [
[
"build_setpoint_pump_b"
]
]
},
{
"id": "build_setpoint_pump_b",
"type": "function",
"z": "tab_process",
"name": "build setpoint cmd (Pump B)",
"func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 600,
"y": 420,
"wires": [
[
"pump_b"
]
]
},
{
"id": "lin_seq_pump_b",
"type": "link in",
"z": "tab_process",
"name": "cmd:pump-B-seq",
"links": [
"lout_seq_pump_b_dash"
],
"x": 120,
"y": 470,
"wires": [
[
"pump_b"
]
]
},
{
"id": "c_pump_c",
"type": "comment",
"z": "tab_process",
"name": "\u2500\u2500 Pump C \u2500\u2500 (pump + 4 sensors + physics feeder)",
"info": "Up/Dn pressure + flow + power sensors register as children of the pump. The physics_<pump> function takes the pump's own port-0 stream and PS port-0 (basin level) and drives all 4 sensors with physically-coupled values.",
"x": 640,
"y": 640,
"wires": []
},
{
"id": "meas_pump_c_u",
"type": "measurement",
"z": "tab_process",
"name": "C-Up",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 4000,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_c-u",
"supplier": "vega",
"category": "sensor",
"assetType": "pressure",
"model": "vega-pressure-10",
"unit": "mbar",
"assetTagNumber": "C-U",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "upstream",
"positionIcon": "\u2192",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 680,
"wires": [
[],
[
"lout_tlm_meas_pump_c_u"
],
[
"pump_c"
]
]
},
{
"id": "lout_tlm_meas_pump_c_u",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 680,
"wires": []
},
{
"id": "meas_pump_c_d",
"type": "measurement",
"z": "tab_process",
"name": "C-Dn",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 4000,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_c-d",
"supplier": "vega",
"category": "sensor",
"assetType": "pressure",
"model": "vega-pressure-10",
"unit": "mbar",
"assetTagNumber": "C-D",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "downstream",
"positionIcon": "\u2190",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 715,
"wires": [
[],
[
"lout_tlm_meas_pump_c_d"
],
[
"pump_c"
]
]
},
{
"id": "lout_tlm_meas_pump_c_d",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 715,
"wires": []
},
{
"id": "meas_pump_c_f",
"type": "measurement",
"z": "tab_process",
"name": "C-Flow",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 250,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_c-f",
"supplier": "endress",
"category": "sensor",
"assetType": "flow",
"model": "endress-promag-50",
"unit": "m3/h",
"assetTagNumber": "C-F",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "downstream",
"positionIcon": "\u2190",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 750,
"wires": [
[],
[
"lout_tlm_meas_pump_c_f"
],
[
"pump_c"
]
]
},
{
"id": "lout_tlm_meas_pump_c_f",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 750,
"wires": []
},
{
"id": "meas_pump_c_p",
"type": "measurement",
"z": "tab_process",
"name": "C-Pwr",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 1,
"i_offset": 0,
"o_min": 0,
"o_max": 30,
"simulator": false,
"smooth_method": "mean",
"count": "3",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "sensor-pump_c-p",
"supplier": "siemens",
"category": "sensor",
"assetType": "power",
"model": "siemens-sentron-pac4200",
"unit": "kW",
"assetTagNumber": "C-P",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "atEquipment",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 380,
"y": 785,
"wires": [
[],
[
"lout_tlm_meas_pump_c_p"
],
[
"pump_c"
]
]
},
{
"id": "lout_tlm_meas_pump_c_p",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 580,
"y": 785,
"wires": []
},
{
"id": "pump_c",
"type": "rotatingMachine",
"z": "tab_process",
"name": "Pump C",
"speed": "200",
"startup": "2",
"warmup": "1",
"shutdown": "2",
"cooldown": "1",
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": "pump-pump_c",
"supplier": "hidrostal",
"category": "pump",
"assetType": "pump-centrifugal",
"model": "hidrostal-H05K-S03R",
"unit": "m3/h",
"curvePressureUnit": "mbar",
"curveFlowUnit": "m3/h",
"curvePowerUnit": "kW",
"curveControlUnit": "%",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "atEquipment",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 900,
"y": 730,
"wires": [
[
"format_pump_c",
"physics_pump_c"
],
[
"lout_tlm_pump_c"
],
[
"mgc_pumps"
]
]
},
{
"id": "lout_tlm_pump_c",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 900,
"y": 770,
"wires": []
},
{
"id": "format_pump_c",
"type": "function",
"z": "tab_process",
"name": "format Pump C port 0",
"func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle dashboard fan-out to \u2264 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1160,
"y": 730,
"wires": [
[
"lout_evt_pump_c"
]
]
},
{
"id": "lout_evt_pump_c",
"type": "link out",
"z": "tab_process",
"name": "evt:pump-C",
"mode": "link",
"links": [
"lin_evt_pump_c_dash"
],
"x": 1420,
"y": 730,
"wires": []
},
{
"id": "physics_pump_c",
"type": "function",
"z": "tab_process",
"name": "physics Pump C \u2192 4 sensors",
"func": "const c = context.get('c') || {};\nfunction find(o, prefix) {\n for (const k in o) { if (k.indexOf(prefix) === 0) return o[k]; }\n return null;\n}\nfunction gauss(sigma) {\n let s = 0;\n for (let i = 0; i < 12; i++) s += Math.random();\n return (s - 6) * sigma;\n}\n\nif (msg.from === 'ps') {\n const psSnap = c.ps || {};\n Object.assign(psSnap, msg.payload || {});\n c.ps = psSnap;\n const lvl = find(psSnap, 'level.predicted.atequipment.')\n ?? find(psSnap, 'level.measured.atequipment.');\n if (lvl != null) c.basinLevel = Number(lvl);\n context.set('c', c);\n return null;\n}\n\nconst pumpSnap = c.pump || {};\nObject.assign(pumpSnap, msg.payload || {});\nc.pump = pumpSnap;\ncontext.set('c', c);\n// Throttle: 1 Hz sensor updates are plenty for the demo; the\n// pump emits on every state change (5+/sec while cycling).\nconst _now = Date.now();\nconst _last = context.get('_lastEmit') || 0;\nif (_now - _last < 1000) return null;\ncontext.set('_lastEmit', _now);\n\nconst state = pumpSnap.state || 'idle';\n// 'isRunning' = the rotor is spinning (any non-idle, non-cooled state).\n// MGC retargets flow on every tick, so the pump spends most of its\n// time in 'accelerating' or 'decelerating', not 'operational'. Those\n// transient states are still moving water \u2014 flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') \u2014 `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_c', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_c_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure \u2014 same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure \u2014 driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m\u00b3/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n",
"outputs": 4,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1160,
"y": 800,
"wires": [
[
"meas_pump_c_u"
],
[
"meas_pump_c_d"
],
[
"meas_pump_c_f"
],
[
"meas_pump_c_p"
]
]
},
{
"id": "lin_setpoint_pump_c",
"type": "link in",
"z": "tab_process",
"name": "cmd:setpoint-C",
"links": [
"lout_setpoint_pump_c_dash"
],
"x": 120,
"y": 700,
"wires": [
[
"build_setpoint_pump_c"
]
]
},
{
"id": "build_setpoint_pump_c",
"type": "function",
"z": "tab_process",
"name": "build setpoint cmd (Pump C)",
"func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 600,
"y": 700,
"wires": [
[
"pump_c"
]
]
},
{
"id": "lin_seq_pump_c",
"type": "link in",
"z": "tab_process",
"name": "cmd:pump-C-seq",
"links": [
"lout_seq_pump_c_dash"
],
"x": 120,
"y": 750,
"wires": [
[
"pump_c"
]
]
},
{
"id": "c_mgc",
"type": "comment",
"z": "tab_process",
"name": "\u2500\u2500 MGC \u2500\u2500 (orchestrates the 3 pumps via optimalcontrol)",
"info": "",
"x": 640,
"y": 920,
"wires": []
},
{
"id": "mgc_pumps",
"type": "machineGroupControl",
"z": "tab_process",
"name": "MGC \u2014 Pump Group",
"uuid": "mgc-pump-group",
"category": "controller",
"assetType": "machinegroupcontrol",
"model": "default",
"unit": "m3/h",
"supplier": "evolv",
"enableLog": true,
"logLevel": "debug",
"tickIntervalMs": 2000,
"positionVsParent": "atEquipment",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"x": 900,
"y": 1000,
"wires": [
[
"format_mgc"
],
[
"lout_tlm_mgc"
],
[
"ps_basin"
]
]
},
{
"id": "lout_tlm_mgc",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 900,
"y": 1040,
"wires": []
},
{
"id": "format_mgc",
"type": "function",
"z": "tab_process",
"name": "format MGC port 0",
"func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle: MGC fires on every distribution change.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst totalFlow = find('flow.predicted.atequipment.') ?? find('downstream_predicted_flow');\nconst totalPower = find('power.predicted.atequipment.') ?? find('atEquipment_predicted_power');\nconst eff = find('efficiency.predicted.atequipment.');\nmsg.payload = {\n totalFlow: totalFlow != null ? Number(totalFlow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n totalPower: totalPower != null ? Number(totalPower).toFixed(2) + ' kW' : 'n/a',\n efficiency: eff != null ? Number(eff).toFixed(3) : 'n/a',\n totalFlowNum: totalFlow != null ? Number(totalFlow ) : null,\n totalPowerNum: totalPower != null ? Number(totalPower) : null,\n efficiencyNum: eff != null ? Number(eff) : null,\n};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1160,
"y": 1000,
"wires": [
[
"lout_evt_mgc"
]
]
},
{
"id": "lout_evt_mgc",
"type": "link out",
"z": "tab_process",
"name": "evt:mgc",
"mode": "link",
"links": [
"lin_evt_mgc_dash"
],
"x": 1420,
"y": 1000,
"wires": []
},
{
"id": "c_ps",
"type": "comment",
"z": "tab_process",
"name": "\u2500\u2500 Pumping Station \u2500\u2500 (basin model, levelbased control)",
"info": "",
"x": 640,
"y": 1200,
"wires": []
},
{
"id": "lin_qin_at_ps",
"type": "link in",
"z": "tab_process",
"name": "cmd:q_in",
"links": [
"lout_qin_drivers"
],
"x": 120,
"y": 1240,
"wires": [
[
"ps_basin"
]
]
},
{
"id": "lin_qd_at_ps",
"type": "link in",
"z": "tab_process",
"name": "cmd:Qd",
"links": [
"lout_qd_dash"
],
"x": 120,
"y": 1280,
"wires": [
[
"qd_to_ps_wrap"
]
]
},
{
"id": "qd_to_ps_wrap",
"type": "function",
"z": "tab_process",
"name": "wrap slider \u2192 PS Qd",
"func": "msg.topic = 'Qd';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 380,
"y": 1280,
"wires": [
[
"ps_basin"
]
]
},
{
"id": "lin_ps_mode_at_ps",
"type": "link in",
"z": "tab_process",
"name": "cmd:ps-mode",
"links": [
"lout_ps_mode_dash"
],
"x": 120,
"y": 1320,
"wires": [
[
"ps_basin"
]
]
},
{
"id": "ps_basin",
"type": "pumpingStation",
"z": "tab_process",
"name": "Pumping Station",
"uuid": "ps-basin-1",
"category": "station",
"assetType": "pumpingstation",
"model": "default",
"unit": "m3/s",
"supplier": "evolv",
"enableLog": false,
"logLevel": "warn",
"tickIntervalMs": 2000,
"positionVsParent": "atEquipment",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"controlMode": "levelbased",
"basinVolume": 50.0,
"basinHeight": 4.0,
"inflowLevel": 2.5,
"outflowLevel": 0.3,
"overflowLevel": 3.8,
"inletPipeDiameter": 0.3,
"outletPipeDiameter": 0.3,
"minLevel": 0.5,
"startLevel": 2.5,
"stopLevel": 2.0,
"deadZoneKeepAlivePercent": 1,
"maxLevel": 3.5,
"refHeight": "NAP",
"minHeightBasedOn": "outlet",
"basinBottomRef": 0,
"staticHead": 12,
"maxDischargeHead": 24,
"pipelineLength": 80,
"defaultFluid": "wastewater",
"temperatureReferenceDegC": 15,
"maxInflowRate": 200,
"enableDryRunProtection": true,
"enableOverfillProtection": true,
"dryRunThresholdPercent": 5,
"overfillThresholdPercent": 95,
"timeleftToFullOrEmptyThresholdSeconds": 0,
"x": 900,
"y": 1280,
"wires": [
[
"format_ps",
"ps_to_physics"
],
[
"lout_tlm_ps"
]
]
},
{
"id": "lout_tlm_ps",
"type": "link out",
"z": "tab_process",
"name": "evt:tlm",
"mode": "link",
"links": [
"lin_tlm"
],
"x": 900,
"y": 1320,
"wires": []
},
{
"id": "ps_to_physics",
"type": "function",
"z": "tab_process",
"name": "ps \u2192 fan basin level to 3 physics feeders",
"func": "const out = { from: 'ps', payload: msg.payload };\nreturn [out, out, out];",
"outputs": 3,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1160,
"y": 1330,
"wires": [
[
"physics_pump_a"
],
[
"physics_pump_b"
],
[
"physics_pump_c"
]
]
},
{
"id": "format_ps",
"type": "function",
"z": "tab_process",
"name": "format PS port 0",
"func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle: PS emits frequently in levelbased mode.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst MAX_VOL = 50.0;\nconst lvl = find('level.predicted.');\nconst vol = find('volume.predicted.');\nconst qIn = find('flow.predicted.in.');\nconst qOut = find('flow.predicted.out.');\nconst netFlowRate = find('netFlowRate.predicted.');\nconst fillPct = vol != null\n ? Math.min(100, Math.max(0, Math.round(Number(vol) / MAX_VOL * 100)))\n : null;\nconst netM3h = netFlowRate != null ? Number(netFlowRate) * 3600 : null;\nconst seconds = (c.timeleft != null && Number.isFinite(Number(c.timeleft)))\n ? Number(c.timeleft) : null;\nconst timeStr = seconds != null\n ? (seconds > 60 ? Math.round(seconds/60) + ' min'\n : Math.round(seconds) + ' s')\n : 'n/a';\nmsg.payload = {\n direction: c.direction || 'steady',\n level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n volume: vol != null ? Number(vol).toFixed(1) + ' m\u00b3' : 'n/a',\n fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n netFlow: netM3h != null ? netM3h.toFixed(0) + ' m\u00b3/h' : 'n/a',\n timeLeft: timeStr,\n qIn: qIn != null ? (Number(qIn ) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n levelNum: lvl != null ? Number(lvl) : null,\n volumeNum: vol != null ? Number(vol) : null,\n fillPctNum: fillPct,\n netFlowNum: netM3h,\n percControl: c.percControl != null ? Number(c.percControl) : null,\n qInNum: qIn != null ? Number(qIn ) * 3600 : null,\n qOutNum: qOut != null ? Number(qOut) * 3600 : null,\n safetyState: c.safetyState || 'normal',\n};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1160,
"y": 1280,
"wires": [
[
"lout_evt_ps"
]
]
},
{
"id": "lout_evt_ps",
"type": "link out",
"z": "tab_process",
"name": "evt:ps",
"mode": "link",
"links": [
"lin_evt_ps_dash"
],
"x": 1420,
"y": 1280,
"wires": []
},
{
"id": "c_mode_bcast",
"type": "comment",
"z": "tab_process",
"name": "\u2500\u2500 Mode broadcast \u2500\u2500",
"info": "",
"x": 640,
"y": 1420,
"wires": []
},
{
"id": "lin_mode",
"type": "link in",
"z": "tab_process",
"name": "cmd:mode",
"links": [
"lout_mode_setup"
],
"x": 120,
"y": 1480,
"wires": [
[
"fanout_mode"
]
]
},
{
"id": "fanout_mode",
"type": "function",
"z": "tab_process",
"name": "fan setMode \u2192 3 pumps",
"func": "msg.topic = 'setMode';\nreturn [msg, msg, msg];",
"outputs": 3,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 600,
"y": 1480,
"wires": [
[
"pump_a"
],
[
"pump_b"
],
[
"pump_c"
]
]
},
{
"id": "c_station_cmds",
"type": "comment",
"z": "tab_process",
"name": "\u2500\u2500 Station-wide commands \u2500\u2500",
"info": "",
"x": 640,
"y": 1620,
"wires": []
},
{
"id": "lin_station_start",
"type": "link in",
"z": "tab_process",
"name": "cmd:station-startup",
"links": [
"lout_cmd_station_startup_dash"
],
"x": 120,
"y": 1680,
"wires": [
[
"fan_station_start"
]
]
},
{
"id": "fan_station_start",
"type": "function",
"z": "tab_process",
"name": "fan startup \u2192 3 pumps",
"func": "return [msg, msg, msg];",
"outputs": 3,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 600,
"y": 1680,
"wires": [
[
"pump_a"
],
[
"pump_b"
],
[
"pump_c"
]
]
},
{
"id": "lin_station_stop",
"type": "link in",
"z": "tab_process",
"name": "cmd:station-shutdown",
"links": [
"lout_cmd_station_shutdown_dash"
],
"x": 120,
"y": 1740,
"wires": [
[
"fan_station_stop"
]
]
},
{
"id": "fan_station_stop",
"type": "function",
"z": "tab_process",
"name": "fan shutdown \u2192 3 pumps",
"func": "return [msg, msg, msg];",
"outputs": 3,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 600,
"y": 1740,
"wires": [
[
"pump_a"
],
[
"pump_b"
],
[
"pump_c"
]
]
},
{
"id": "lin_station_estop",
"type": "link in",
"z": "tab_process",
"name": "cmd:station-estop",
"links": [
"lout_cmd_station_estop_dash"
],
"x": 120,
"y": 1800,
"wires": [
[
"fan_station_estop"
]
]
},
{
"id": "fan_station_estop",
"type": "function",
"z": "tab_process",
"name": "fan emergency stop \u2192 3 pumps",
"func": "return [msg, msg, msg];",
"outputs": 3,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 600,
"y": 1800,
"wires": [
[
"pump_a"
],
[
"pump_b"
],
[
"pump_c"
]
]
},
{
"id": "c_setup_at_mgc",
"type": "comment",
"z": "tab_process",
"name": "\u2500\u2500 Setup feeders \u2500\u2500",
"info": "",
"x": 640,
"y": 1900,
"wires": []
},
{
"id": "lin_setup_at_mgc",
"type": "link in",
"z": "tab_process",
"name": "setup:to-mgc",
"links": [
"lout_setup_to_mgc"
],
"x": 120,
"y": 1960,
"wires": [
[
"mgc_pumps"
]
]
},
{
"id": "lin_setup_calibrate_ps",
"type": "link in",
"z": "tab_process",
"name": "setup:calibrate-ps",
"links": [
"lout_setup_calibrate"
],
"x": 120,
"y": 2020,
"wires": [
[
"ps_basin"
]
]
},
{
"id": "tab_ui",
"type": "tab",
"label": "\ud83d\udcca Dashboard UI",
"disabled": false,
"info": "All FlowFuse ui-* widgets. Two pages:\n /dashboard/realtime \u2014 gauges + per-pump status (no time history)\n /dashboard/trends \u2014 line charts, 1 hour rolling window\n\nAll inputs leave via link-out; all process state arrives via link-in."
},
{
"id": "ui_base",
"type": "ui-base",
"name": "EVOLV Pumping",
"path": "/dashboard",
"appIcon": "",
"includeClientData": true,
"acceptsClientConfig": [
"ui-notification",
"ui-control"
],
"showPathInSidebar": true,
"headerContent": "page",
"navigationStyle": "default",
"titleBarStyle": "default"
},
{
"id": "ui_theme",
"type": "ui-theme",
"name": "EVOLV Theme",
"colors": {
"surface": "#ffffff",
"primary": "#0c99d9",
"bgPage": "#f4f6fa",
"groupBg": "#ffffff",
"groupOutline": "#cccccc"
},
"sizes": {
"density": "default",
"pagePadding": "12px",
"groupGap": "12px",
"groupBorderRadius": "6px",
"widgetGap": "8px"
}
},
{
"id": "ui_page_realtime",
"type": "ui-page",
"name": "Realtime",
"ui": "ui_base",
"path": "/realtime",
"icon": "speed",
"layout": "grid",
"theme": "ui_theme",
"breakpoints": [
{
"name": "Default",
"px": "0",
"cols": "12"
}
],
"order": 1,
"className": ""
},
{
"id": "ui_page_trends",
"type": "ui-page",
"name": "Trends \u2014 1 hour",
"ui": "ui_base",
"path": "/trends",
"icon": "show_chart",
"layout": "grid",
"theme": "ui_theme",
"breakpoints": [
{
"name": "Default",
"px": "0",
"cols": "12"
}
],
"order": 2,
"className": ""
},
{
"id": "ui_grp_inflow",
"type": "ui-group",
"name": "1. Inflow (operator input)",
"page": "ui_page_realtime",
"width": "12",
"height": "1",
"order": 1,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_station",
"type": "ui-group",
"name": "2. Station Mode + Commands",
"page": "ui_page_realtime",
"width": "12",
"height": "1",
"order": 2,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_basin",
"type": "ui-group",
"name": "3. Basin Realtime",
"page": "ui_page_realtime",
"width": "6",
"height": "1",
"order": 3,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_mgc",
"type": "ui-group",
"name": "4. Pump Group (MGC)",
"page": "ui_page_realtime",
"width": "6",
"height": "1",
"order": 4,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_pump_a",
"type": "ui-group",
"name": "5a. Pump A",
"page": "ui_page_realtime",
"width": "4",
"height": "1",
"order": 5,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_pump_b",
"type": "ui-group",
"name": "5b. Pump B",
"page": "ui_page_realtime",
"width": "4",
"height": "1",
"order": 6,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_pump_c",
"type": "ui-group",
"name": "5c. Pump C",
"page": "ui_page_realtime",
"width": "4",
"height": "1",
"order": 7,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_tr_basin",
"type": "ui-group",
"name": "Basin level + fill (1h)",
"page": "ui_page_trends",
"width": "12",
"height": "1",
"order": 1,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_tr_demand",
"type": "ui-group",
"name": "Process demand \u2014 PS percControl (1h)",
"page": "ui_page_trends",
"width": "12",
"height": "1",
"order": 2,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_tr_dq",
"type": "ui-group",
"name": "\u0394Q = inflow \u2212 outflow (m\u00b3/h, +fill / \u2212drain)",
"page": "ui_page_trends",
"width": "12",
"height": "1",
"order": 3,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_tr_states",
"type": "ui-group",
"name": "Pump state timeline (gantt)",
"page": "ui_page_trends",
"width": "12",
"height": "1",
"order": 4,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_tr_flow",
"type": "ui-group",
"name": "Inflow / Outflow / Per-pump flow (1h)",
"page": "ui_page_trends",
"width": "12",
"height": "1",
"order": 5,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_tr_power",
"type": "ui-group",
"name": "Per-pump power (1h)",
"page": "ui_page_trends",
"width": "12",
"height": "1",
"order": 6,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "ui_grp_tr_press",
"type": "ui-group",
"name": "Per-pump pressures (1h)",
"page": "ui_page_trends",
"width": "12",
"height": "1",
"order": 7,
"showTitle": true,
"className": "",
"groupType": "default",
"disabled": false,
"visible": true
},
{
"id": "c_ui_title",
"type": "comment",
"z": "tab_ui",
"name": "\ud83d\udcca DASHBOARD UI \u2014 only ui-* widgets here",
"info": "",
"x": 640,
"y": 20,
"wires": []
},
{
"id": "c_ui_inflow",
"type": "comment",
"z": "tab_ui",
"name": "\u2500\u2500 Operator inflow input \u2500\u2500",
"info": "",
"x": 640,
"y": 80,
"wires": []
},
{
"id": "ui_inflow_slider",
"type": "ui-slider",
"z": "tab_ui",
"group": "ui_grp_inflow",
"name": "Inflow baseline",
"label": "Inflow baseline (m\u00b3/h) \u2014 scenarios modulate around this value",
"tooltip": "",
"order": 1,
"width": "0",
"height": "0",
"passthru": true,
"outs": "end",
"topic": "inflowBaseline",
"topicType": "str",
"min": "0",
"max": "250",
"step": "5.0",
"showLabel": true,
"showValue": true,
"labelPosition": "top",
"valuePosition": "left",
"thumbLabel": false,
"iconStart": "",
"iconEnd": "",
"x": 120,
"y": 120,
"wires": [
[
"lout_inflow_baseline"
]
]
},
{
"id": "lout_inflow_baseline",
"type": "link out",
"z": "tab_ui",
"name": "cmd:inflow-baseline",
"mode": "link",
"links": [
"lin_inflow_baseline"
],
"x": 380,
"y": 120,
"wires": []
},
{
"id": "btn_scn_constant",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_inflow",
"name": "Scenario Constant",
"label": "Constant",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#0c99d9",
"className": "",
"icon": "horizontal_rule",
"iconPosition": "left",
"payload": "constant",
"payloadType": "str",
"topic": "scenario",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 180,
"wires": [
[
"wrap_scn_constant"
]
]
},
{
"id": "wrap_scn_constant",
"type": "function",
"z": "tab_ui",
"name": "build scenario constant",
"func": "msg.payload = 'constant';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 180,
"wires": [
[
"lout_inflow_scenario"
]
]
},
{
"id": "btn_scn_sine",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_inflow",
"name": "Scenario Sine wave",
"label": "Sine wave",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#16a34a",
"className": "",
"icon": "show_chart",
"iconPosition": "left",
"payload": "sine",
"payloadType": "str",
"topic": "scenario",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 230,
"wires": [
[
"wrap_scn_sine"
]
]
},
{
"id": "wrap_scn_sine",
"type": "function",
"z": "tab_ui",
"name": "build scenario sine",
"func": "msg.payload = 'sine';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 230,
"wires": [
[
"lout_inflow_scenario"
]
]
},
{
"id": "btn_scn_diurnal",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_inflow",
"name": "Scenario Diurnal",
"label": "Diurnal",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#f59e0b",
"className": "",
"icon": "schedule",
"iconPosition": "left",
"payload": "diurnal",
"payloadType": "str",
"topic": "scenario",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 280,
"wires": [
[
"wrap_scn_diurnal"
]
]
},
{
"id": "wrap_scn_diurnal",
"type": "function",
"z": "tab_ui",
"name": "build scenario diurnal",
"func": "msg.payload = 'diurnal';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 280,
"wires": [
[
"lout_inflow_scenario"
]
]
},
{
"id": "btn_scn_storm",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_inflow",
"name": "Scenario Storm",
"label": "Storm",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#dc2626",
"className": "",
"icon": "thunderstorm",
"iconPosition": "left",
"payload": "storm",
"payloadType": "str",
"topic": "scenario",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 330,
"wires": [
[
"wrap_scn_storm"
]
]
},
{
"id": "wrap_scn_storm",
"type": "function",
"z": "tab_ui",
"name": "build scenario storm",
"func": "msg.payload = 'storm';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 330,
"wires": [
[
"lout_inflow_scenario"
]
]
},
{
"id": "lout_inflow_scenario",
"type": "link out",
"z": "tab_ui",
"name": "cmd:inflow-scenario",
"mode": "link",
"links": [
"lin_inflow_scenario"
],
"x": 640,
"y": 180,
"wires": []
},
{
"id": "lin_evt_inflow",
"type": "link in",
"z": "tab_ui",
"name": "evt:inflow",
"links": [
"lout_evt_inflow"
],
"x": 900,
"y": 120,
"wires": [
[
"dispatch_inflow"
]
]
},
{
"id": "dispatch_inflow",
"type": "function",
"z": "tab_ui",
"name": "dispatch inflow",
"func": "const p = msg.payload || {};\nconst ts = Date.now();\nreturn [\n { payload: (p.scenario || 'constant').toUpperCase() },\n { payload: p.q_h != null ? Number(p.q_h).toFixed(1) + ' m\u00b3/h' : 'n/a' },\n p.q_h != null ? { topic: 'Inflow', payload: Number(p.q_h), timestamp: ts } : null,\n];",
"outputs": 3,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1160,
"y": 120,
"wires": [
[
"ui_inflow_scn_text"
],
[
"ui_inflow_value_text"
],
[
"chart_trend_flow"
]
]
},
{
"id": "ui_inflow_scn_text",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_inflow",
"order": 1,
"width": "0",
"height": "0",
"name": "Active scenario",
"label": "Active scenario",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 1420,
"y": 120,
"wires": []
},
{
"id": "ui_inflow_value_text",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_inflow",
"order": 1,
"width": "0",
"height": "0",
"name": "Live inflow",
"label": "Live inflow",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 1420,
"y": 160,
"wires": []
},
{
"id": "c_ui_station",
"type": "comment",
"z": "tab_ui",
"name": "\u2500\u2500 Mode + Station-wide buttons \u2500\u2500",
"info": "",
"x": 640,
"y": 380,
"wires": []
},
{
"id": "ui_mode_toggle",
"type": "ui-switch",
"z": "tab_ui",
"group": "ui_grp_station",
"name": "Station mode",
"label": "Station mode (Auto = level-based \u00b7 Manual = slider Qd)",
"tooltip": "",
"order": 1,
"width": "0",
"height": "0",
"passthru": true,
"decouple": "false",
"topic": "changemode",
"topicType": "str",
"style": "",
"className": "",
"evaluate": "true",
"onvalue": "levelbased",
"onvalueType": "str",
"onicon": "auto_mode",
"oncolor": "#0c99d9",
"offvalue": "manual",
"offvalueType": "str",
"officon": "back_hand",
"offcolor": "#888888",
"x": 120,
"y": 420,
"wires": [
[
"lout_ps_mode_dash"
]
]
},
{
"id": "lout_ps_mode_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:ps-mode",
"mode": "link",
"links": [
"lin_ps_mode_at_ps"
],
"x": 380,
"y": 420,
"wires": []
},
{
"id": "ui_qd_slider",
"type": "ui-slider",
"z": "tab_ui",
"group": "ui_grp_station",
"name": "Manual Qd",
"label": "Manual Qd (m\u00b3/h, manual mode only)",
"tooltip": "",
"order": 1,
"width": "0",
"height": "0",
"passthru": true,
"outs": "end",
"topic": "manualDemand",
"topicType": "str",
"min": "0",
"max": "600",
"step": "5.0",
"showLabel": true,
"showValue": true,
"labelPosition": "top",
"valuePosition": "left",
"thumbLabel": false,
"iconStart": "",
"iconEnd": "",
"x": 120,
"y": 470,
"wires": [
[
"lout_qd_dash"
]
]
},
{
"id": "lout_qd_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:Qd",
"mode": "link",
"links": [
"lin_qd_at_ps"
],
"x": 380,
"y": 470,
"wires": []
},
{
"id": "btn_station_0",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_station",
"name": "Start all pumps",
"label": "Start all pumps",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#16a34a",
"className": "",
"icon": "play_arrow",
"iconPosition": "left",
"payload": "fired",
"payloadType": "str",
"topic": "station_0",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 530,
"wires": [
[
"wrap_station_0"
]
]
},
{
"id": "wrap_station_0",
"type": "function",
"z": "tab_ui",
"name": "build cmd (Start all pumps)",
"func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 530,
"wires": [
[
"lout_cmd_station_startup_dash"
]
]
},
{
"id": "lout_cmd_station_startup_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:station-startup",
"mode": "link",
"links": [
"lin_station_start"
],
"x": 640,
"y": 530,
"wires": []
},
{
"id": "btn_station_1",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_station",
"name": "Stop all pumps",
"label": "Stop all pumps",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#ea580c",
"className": "",
"icon": "stop",
"iconPosition": "left",
"payload": "fired",
"payloadType": "str",
"topic": "station_1",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 580,
"wires": [
[
"wrap_station_1"
]
]
},
{
"id": "wrap_station_1",
"type": "function",
"z": "tab_ui",
"name": "build cmd (Stop all pumps)",
"func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 580,
"wires": [
[
"lout_cmd_station_shutdown_dash"
]
]
},
{
"id": "lout_cmd_station_shutdown_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:station-shutdown",
"mode": "link",
"links": [
"lin_station_stop"
],
"x": 640,
"y": 580,
"wires": []
},
{
"id": "btn_station_2",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_station",
"name": "EMERGENCY STOP",
"label": "EMERGENCY STOP",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#dc2626",
"className": "",
"icon": "stop_circle",
"iconPosition": "left",
"payload": "fired",
"payloadType": "str",
"topic": "station_2",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 630,
"wires": [
[
"wrap_station_2"
]
]
},
{
"id": "wrap_station_2",
"type": "function",
"z": "tab_ui",
"name": "build cmd (EMERGENCY STOP)",
"func": "msg.topic = 'emergencystop';\nmsg.payload = { source:'GUI', action:'emergencystop' };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 630,
"wires": [
[
"lout_cmd_station_estop_dash"
]
]
},
{
"id": "lout_cmd_station_estop_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:station-estop",
"mode": "link",
"links": [
"lin_station_estop"
],
"x": 640,
"y": 630,
"wires": []
},
{
"id": "c_ui_basin",
"type": "comment",
"z": "tab_ui",
"name": "\u2500\u2500 Basin realtime (gauges + text) \u2500\u2500",
"info": "",
"x": 640,
"y": 700,
"wires": []
},
{
"id": "lin_evt_ps_dash",
"type": "link in",
"z": "tab_ui",
"name": "evt:ps",
"links": [
"lout_evt_ps"
],
"x": 120,
"y": 740,
"wires": [
[
"dispatch_ps"
]
]
},
{
"id": "dispatch_ps",
"type": "function",
"z": "tab_ui",
"name": "dispatch PS",
"func": "const p = msg.payload || {};\nconst ts = Date.now();\n// \u0394Q = inflow \u2212 outflow in m\u00b3/h (positive = filling).\nconst dQ = (p.qInNum != null && p.qOutNum != null)\n ? p.qInNum - p.qOutNum : null;\n// Demand text formatting.\nconst demandStr = p.percControl != null\n ? Number(p.percControl).toFixed(0) + '%' : 'n/a';\nreturn [\n { payload: String(p.direction || 'steady') },\n { payload: String(p.level || 'n/a') },\n { payload: String(p.volume || 'n/a') },\n { payload: String(p.fillPct || 'n/a') },\n { payload: String(p.netFlow || 'n/a') },\n { payload: String(p.timeLeft || 'n/a') },\n { payload: String(p.qIn || 'n/a') },\n { payload: String(p.qOut || 'n/a') },\n { payload: String(p.safetyState || 'normal') },\n { payload: demandStr },\n p.levelNum != null ? { payload: p.levelNum } : null,\n p.fillPctNum != null ? { payload: p.fillPctNum } : null,\n p.percControl != null ? { payload: p.percControl } : null,\n p.levelNum != null ? { topic: 'Basin level', payload: p.levelNum, timestamp: ts } : null,\n p.fillPctNum != null ? { topic: 'Fill %', payload: p.fillPctNum, timestamp: ts } : null,\n p.qOutNum != null ? { topic: 'Outflow', payload: p.qOutNum, timestamp: ts } : null,\n p.percControl != null ? { topic: 'PS demand', payload: p.percControl, timestamp: ts } : null,\n dQ != null ? { topic: '\u0394Q', payload: dQ, timestamp: ts } : null,\n];",
"outputs": 18,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 380,
"y": 740,
"wires": [
[
"ui_ps_direction"
],
[
"ui_ps_level"
],
[
"ui_ps_volume"
],
[
"ui_ps_fill"
],
[
"ui_ps_netflow"
],
[
"ui_ps_timeleft"
],
[
"ui_ps_qin"
],
[
"ui_ps_qout"
],
[
"ui_ps_safety"
],
[
"ui_ps_demand"
],
[
"gauge_basin_level"
],
[
"gauge_basin_fill"
],
[
"gauge_ps_demand"
],
[
"chart_trend_basin"
],
[
"chart_trend_basin"
],
[
"chart_trend_flow"
],
[
"chart_trend_demand"
],
[
"chart_trend_dq"
]
]
},
{
"id": "ui_ps_direction",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_basin",
"order": 1,
"width": "0",
"height": "0",
"name": "Direction",
"label": "Direction",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 740,
"wires": []
},
{
"id": "ui_ps_level",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_basin",
"order": 1,
"width": "0",
"height": "0",
"name": "Basin level",
"label": "Basin level",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 770,
"wires": []
},
{
"id": "ui_ps_volume",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_basin",
"order": 1,
"width": "0",
"height": "0",
"name": "Basin volume",
"label": "Basin volume",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 800,
"wires": []
},
{
"id": "ui_ps_fill",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_basin",
"order": 1,
"width": "0",
"height": "0",
"name": "Fill %",
"label": "Fill %",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 830,
"wires": []
},
{
"id": "ui_ps_netflow",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_basin",
"order": 1,
"width": "0",
"height": "0",
"name": "Net flow",
"label": "Net flow",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 860,
"wires": []
},
{
"id": "ui_ps_timeleft",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_basin",
"order": 1,
"width": "0",
"height": "0",
"name": "Time left",
"label": "Time to full/empty",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 890,
"wires": []
},
{
"id": "ui_ps_qin",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_basin",
"order": 1,
"width": "0",
"height": "0",
"name": "Inflow",
"label": "Inflow",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 920,
"wires": []
},
{
"id": "ui_ps_qout",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_basin",
"order": 1,
"width": "0",
"height": "0",
"name": "Outflow",
"label": "Outflow",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 950,
"wires": []
},
{
"id": "ui_ps_safety",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_basin",
"order": 1,
"width": "0",
"height": "0",
"name": "Safety",
"label": "Safety state",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 980,
"wires": []
},
{
"id": "ui_ps_demand",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_basin",
"order": 1,
"width": "0",
"height": "0",
"name": "PS demand",
"label": "Process demand",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1010,
"wires": []
},
{
"id": "gauge_basin_level",
"type": "ui-gauge",
"z": "tab_ui",
"group": "ui_grp_basin",
"name": "Basin level gauge",
"gtype": "gauge-tank",
"gstyle": "Rounded",
"title": "Level",
"units": "m",
"prefix": "",
"suffix": " m",
"min": 0,
"max": 4.0,
"segments": [
{
"color": "#f44336",
"from": 0
},
{
"color": "#ff9800",
"from": 1.0
},
{
"color": "#2196f3",
"from": 2.0
},
{
"color": "#ff9800",
"from": 3.5
},
{
"color": "#f44336",
"from": 3.8
}
],
"width": 3,
"height": 4,
"order": 10,
"icon": "",
"sizeGauge": 20,
"sizeGap": 2,
"sizeSegments": 10,
"x": 900,
"y": 740,
"wires": []
},
{
"id": "gauge_basin_fill",
"type": "ui-gauge",
"z": "tab_ui",
"group": "ui_grp_basin",
"name": "Basin fill gauge",
"gtype": "gauge-34",
"gstyle": "Rounded",
"title": "Fill",
"units": "%",
"prefix": "",
"suffix": "%",
"min": 0,
"max": 100,
"segments": [
{
"color": "#f44336",
"from": 0
},
{
"color": "#ff9800",
"from": 10
},
{
"color": "#4caf50",
"from": 30
},
{
"color": "#ff9800",
"from": 80
},
{
"color": "#f44336",
"from": 95
}
],
"width": 3,
"height": 4,
"order": 11,
"icon": "water_drop",
"sizeGauge": 20,
"sizeGap": 2,
"sizeSegments": 10,
"x": 900,
"y": 800,
"wires": []
},
{
"id": "gauge_ps_demand",
"type": "ui-gauge",
"z": "tab_ui",
"group": "ui_grp_basin",
"name": "PS demand gauge",
"gtype": "gauge-34",
"gstyle": "Rounded",
"title": "PS demand",
"units": "%",
"prefix": "",
"suffix": "%",
"min": 0,
"max": 100,
"segments": [
{
"color": "#cccccc",
"from": 0
},
{
"color": "#0c99d9",
"from": 5
},
{
"color": "#16a34a",
"from": 30
},
{
"color": "#f59e0b",
"from": 70
},
{
"color": "#dc2626",
"from": 95
}
],
"width": 3,
"height": 4,
"order": 12,
"icon": "speed",
"sizeGauge": 20,
"sizeGap": 2,
"sizeSegments": 10,
"x": 900,
"y": 860,
"wires": []
},
{
"id": "c_ui_mgc",
"type": "comment",
"z": "tab_ui",
"name": "\u2500\u2500 MGC realtime \u2500\u2500",
"info": "",
"x": 640,
"y": 1080,
"wires": []
},
{
"id": "lin_evt_mgc_dash",
"type": "link in",
"z": "tab_ui",
"name": "evt:mgc",
"links": [
"lout_evt_mgc"
],
"x": 120,
"y": 1120,
"wires": [
[
"dispatch_mgc"
]
]
},
{
"id": "dispatch_mgc",
"type": "function",
"z": "tab_ui",
"name": "dispatch MGC",
"func": "const p = msg.payload || {};\nreturn [\n { payload: String(p.totalFlow || 'n/a') },\n { payload: String(p.totalPower || 'n/a') },\n { payload: String(p.efficiency || 'n/a') },\n p.totalFlowNum != null ? { payload: p.totalFlowNum } : null,\n p.totalPowerNum != null ? { payload: p.totalPowerNum } : null,\n];",
"outputs": 5,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 380,
"y": 1120,
"wires": [
[
"ui_mgc_total_flow"
],
[
"ui_mgc_total_power"
],
[
"ui_mgc_eff"
],
[
"gauge_mgc_flow"
],
[
"gauge_mgc_power"
]
]
},
{
"id": "ui_mgc_total_flow",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_mgc",
"order": 1,
"width": "0",
"height": "0",
"name": "MGC total flow",
"label": "Total flow",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1120,
"wires": []
},
{
"id": "ui_mgc_total_power",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_mgc",
"order": 1,
"width": "0",
"height": "0",
"name": "MGC total power",
"label": "Total power",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1150,
"wires": []
},
{
"id": "ui_mgc_eff",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_mgc",
"order": 1,
"width": "0",
"height": "0",
"name": "MGC efficiency",
"label": "Group efficiency",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1180,
"wires": []
},
{
"id": "gauge_mgc_flow",
"type": "ui-gauge",
"z": "tab_ui",
"group": "ui_grp_mgc",
"name": "MGC total flow gauge",
"gtype": "gauge-34",
"gstyle": "Rounded",
"title": "Total flow",
"units": "m\u00b3/h",
"prefix": "",
"suffix": " m\u00b3/h",
"min": 0,
"max": 600,
"segments": [
{
"color": "#cccccc",
"from": 0
},
{
"color": "#0c99d9",
"from": 50
},
{
"color": "#16a34a",
"from": 200
},
{
"color": "#f59e0b",
"from": 500
}
],
"width": 3,
"height": 4,
"order": 10,
"icon": "",
"sizeGauge": 20,
"sizeGap": 2,
"sizeSegments": 10,
"x": 900,
"y": 1120,
"wires": []
},
{
"id": "gauge_mgc_power",
"type": "ui-gauge",
"z": "tab_ui",
"group": "ui_grp_mgc",
"name": "MGC total power gauge",
"gtype": "gauge-34",
"gstyle": "Rounded",
"title": "Total power",
"units": "kW",
"prefix": "",
"suffix": " kW",
"min": 0,
"max": 30,
"segments": [
{
"color": "#cccccc",
"from": 0
},
{
"color": "#0c99d9",
"from": 1
},
{
"color": "#16a34a",
"from": 5
},
{
"color": "#f59e0b",
"from": 20
}
],
"width": 3,
"height": 4,
"order": 11,
"icon": "",
"sizeGauge": 20,
"sizeGap": 2,
"sizeSegments": 10,
"x": 900,
"y": 1180,
"wires": []
},
{
"id": "c_ui_pump_a",
"type": "comment",
"z": "tab_ui",
"name": "\u2500\u2500 Pump A \u2500\u2500",
"info": "",
"x": 640,
"y": 1340,
"wires": []
},
{
"id": "lin_evt_pump_a_dash",
"type": "link in",
"z": "tab_ui",
"name": "evt:pump-A",
"links": [
"lout_evt_pump_a"
],
"x": 120,
"y": 1380,
"wires": [
[
"dispatch_pump_a"
]
]
},
{
"id": "dispatch_pump_a",
"type": "function",
"z": "tab_ui",
"name": "dispatch Pump A",
"func": "const p = msg.payload || {};\nconst ts = Date.now();\nconst OFF = 0;\nfunction stateNum(s) {\n switch (s) {\n case 'operational': return OFF + 2;\n case 'starting':\n case 'warmingup': return OFF + 1;\n case 'stopping': return OFF + 1.5;\n case 'coolingdown': return OFF + 0.5;\n default: return OFF;\n }\n}\nconst sNum = p.state ? stateNum(p.state) : null;\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump A', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump A', payload: p.powerNum, timestamp: ts} : null,\n p.pUpNum != null ? {topic: 'Pump A up', payload: p.pUpNum, timestamp: ts} : null,\n p.pDnNum != null ? {topic: 'Pump A dn', payload: p.pDnNum, timestamp: ts} : null,\n sNum != null ? {topic: 'Pump A state', payload: sNum, timestamp: ts} : null,\n];",
"outputs": 12,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 380,
"y": 1380,
"wires": [
[
"ui_pump_a_state"
],
[
"ui_pump_a_mode"
],
[
"ui_pump_a_ctrl"
],
[
"ui_pump_a_flow"
],
[
"ui_pump_a_power"
],
[
"ui_pump_a_pUp"
],
[
"ui_pump_a_pDn"
],
[
"chart_trend_flow"
],
[
"chart_trend_power"
],
[
"chart_trend_pressure"
],
[
"chart_trend_pressure"
],
[
"chart_trend_states"
]
]
},
{
"id": "ui_pump_a_state",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_a",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump A State",
"label": "State",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1380,
"wires": []
},
{
"id": "ui_pump_a_mode",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_a",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump A Mode",
"label": "Mode",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1410,
"wires": []
},
{
"id": "ui_pump_a_ctrl",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_a",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump A Controller %",
"label": "Controller %",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1440,
"wires": []
},
{
"id": "ui_pump_a_flow",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_a",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump A Flow",
"label": "Flow",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1470,
"wires": []
},
{
"id": "ui_pump_a_power",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_a",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump A Power",
"label": "Power",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1500,
"wires": []
},
{
"id": "ui_pump_a_pUp",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_a",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump A p Upstream",
"label": "p Upstream",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1530,
"wires": []
},
{
"id": "ui_pump_a_pDn",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_a",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump A p Downstream",
"label": "p Downstream",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1560,
"wires": []
},
{
"id": "ui_pump_a_setpoint",
"type": "ui-slider",
"z": "tab_ui",
"group": "ui_grp_pump_a",
"name": "Pump A setpoint",
"label": "Setpoint % (manual mode)",
"tooltip": "",
"order": 1,
"width": "0",
"height": "0",
"passthru": true,
"outs": "end",
"topic": "setpoint_pump_a",
"topicType": "str",
"min": "0",
"max": "100",
"step": "5.0",
"showLabel": true,
"showValue": true,
"labelPosition": "top",
"valuePosition": "left",
"thumbLabel": false,
"iconStart": "",
"iconEnd": "",
"x": 120,
"y": 1620,
"wires": [
[
"lout_setpoint_pump_a_dash"
]
]
},
{
"id": "lout_setpoint_pump_a_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:setpoint-A",
"mode": "link",
"links": [
"lin_setpoint_pump_a"
],
"x": 380,
"y": 1620,
"wires": []
},
{
"id": "btn_pump_a_start",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_pump_a",
"name": "Pump A startup",
"label": "Startup",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#16a34a",
"className": "",
"icon": "play_arrow",
"iconPosition": "left",
"payload": "fired",
"payloadType": "str",
"topic": "start_pump_a",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 1670,
"wires": [
[
"wrap_pump_a_start"
]
]
},
{
"id": "wrap_pump_a_start",
"type": "function",
"z": "tab_ui",
"name": "build start (Pump A)",
"func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 1670,
"wires": [
[
"lout_seq_pump_a_dash"
]
]
},
{
"id": "btn_pump_a_stop",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_pump_a",
"name": "Pump A shutdown",
"label": "Shutdown",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#ea580c",
"className": "",
"icon": "stop",
"iconPosition": "left",
"payload": "fired",
"payloadType": "str",
"topic": "stop_pump_a",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 1720,
"wires": [
[
"wrap_pump_a_stop"
]
]
},
{
"id": "wrap_pump_a_stop",
"type": "function",
"z": "tab_ui",
"name": "build stop (Pump A)",
"func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 1720,
"wires": [
[
"lout_seq_pump_a_dash"
]
]
},
{
"id": "lout_seq_pump_a_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:pump-A-seq",
"mode": "link",
"links": [
"lin_seq_pump_a"
],
"x": 640,
"y": 1695,
"wires": []
},
{
"id": "c_ui_pump_b",
"type": "comment",
"z": "tab_ui",
"name": "\u2500\u2500 Pump B \u2500\u2500",
"info": "",
"x": 640,
"y": 1820,
"wires": []
},
{
"id": "lin_evt_pump_b_dash",
"type": "link in",
"z": "tab_ui",
"name": "evt:pump-B",
"links": [
"lout_evt_pump_b"
],
"x": 120,
"y": 1860,
"wires": [
[
"dispatch_pump_b"
]
]
},
{
"id": "dispatch_pump_b",
"type": "function",
"z": "tab_ui",
"name": "dispatch Pump B",
"func": "const p = msg.payload || {};\nconst ts = Date.now();\nconst OFF = 3;\nfunction stateNum(s) {\n switch (s) {\n case 'operational': return OFF + 2;\n case 'starting':\n case 'warmingup': return OFF + 1;\n case 'stopping': return OFF + 1.5;\n case 'coolingdown': return OFF + 0.5;\n default: return OFF;\n }\n}\nconst sNum = p.state ? stateNum(p.state) : null;\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump B', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump B', payload: p.powerNum, timestamp: ts} : null,\n p.pUpNum != null ? {topic: 'Pump B up', payload: p.pUpNum, timestamp: ts} : null,\n p.pDnNum != null ? {topic: 'Pump B dn', payload: p.pDnNum, timestamp: ts} : null,\n sNum != null ? {topic: 'Pump B state', payload: sNum, timestamp: ts} : null,\n];",
"outputs": 12,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 380,
"y": 1860,
"wires": [
[
"ui_pump_b_state"
],
[
"ui_pump_b_mode"
],
[
"ui_pump_b_ctrl"
],
[
"ui_pump_b_flow"
],
[
"ui_pump_b_power"
],
[
"ui_pump_b_pUp"
],
[
"ui_pump_b_pDn"
],
[
"chart_trend_flow"
],
[
"chart_trend_power"
],
[
"chart_trend_pressure"
],
[
"chart_trend_pressure"
],
[
"chart_trend_states"
]
]
},
{
"id": "ui_pump_b_state",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_b",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump B State",
"label": "State",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1860,
"wires": []
},
{
"id": "ui_pump_b_mode",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_b",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump B Mode",
"label": "Mode",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1890,
"wires": []
},
{
"id": "ui_pump_b_ctrl",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_b",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump B Controller %",
"label": "Controller %",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1920,
"wires": []
},
{
"id": "ui_pump_b_flow",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_b",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump B Flow",
"label": "Flow",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1950,
"wires": []
},
{
"id": "ui_pump_b_power",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_b",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump B Power",
"label": "Power",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 1980,
"wires": []
},
{
"id": "ui_pump_b_pUp",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_b",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump B p Upstream",
"label": "p Upstream",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 2010,
"wires": []
},
{
"id": "ui_pump_b_pDn",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_b",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump B p Downstream",
"label": "p Downstream",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 2040,
"wires": []
},
{
"id": "ui_pump_b_setpoint",
"type": "ui-slider",
"z": "tab_ui",
"group": "ui_grp_pump_b",
"name": "Pump B setpoint",
"label": "Setpoint % (manual mode)",
"tooltip": "",
"order": 1,
"width": "0",
"height": "0",
"passthru": true,
"outs": "end",
"topic": "setpoint_pump_b",
"topicType": "str",
"min": "0",
"max": "100",
"step": "5.0",
"showLabel": true,
"showValue": true,
"labelPosition": "top",
"valuePosition": "left",
"thumbLabel": false,
"iconStart": "",
"iconEnd": "",
"x": 120,
"y": 2100,
"wires": [
[
"lout_setpoint_pump_b_dash"
]
]
},
{
"id": "lout_setpoint_pump_b_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:setpoint-B",
"mode": "link",
"links": [
"lin_setpoint_pump_b"
],
"x": 380,
"y": 2100,
"wires": []
},
{
"id": "btn_pump_b_start",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_pump_b",
"name": "Pump B startup",
"label": "Startup",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#16a34a",
"className": "",
"icon": "play_arrow",
"iconPosition": "left",
"payload": "fired",
"payloadType": "str",
"topic": "start_pump_b",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 2150,
"wires": [
[
"wrap_pump_b_start"
]
]
},
{
"id": "wrap_pump_b_start",
"type": "function",
"z": "tab_ui",
"name": "build start (Pump B)",
"func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 2150,
"wires": [
[
"lout_seq_pump_b_dash"
]
]
},
{
"id": "btn_pump_b_stop",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_pump_b",
"name": "Pump B shutdown",
"label": "Shutdown",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#ea580c",
"className": "",
"icon": "stop",
"iconPosition": "left",
"payload": "fired",
"payloadType": "str",
"topic": "stop_pump_b",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 2200,
"wires": [
[
"wrap_pump_b_stop"
]
]
},
{
"id": "wrap_pump_b_stop",
"type": "function",
"z": "tab_ui",
"name": "build stop (Pump B)",
"func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 2200,
"wires": [
[
"lout_seq_pump_b_dash"
]
]
},
{
"id": "lout_seq_pump_b_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:pump-B-seq",
"mode": "link",
"links": [
"lin_seq_pump_b"
],
"x": 640,
"y": 2175,
"wires": []
},
{
"id": "c_ui_pump_c",
"type": "comment",
"z": "tab_ui",
"name": "\u2500\u2500 Pump C \u2500\u2500",
"info": "",
"x": 640,
"y": 2300,
"wires": []
},
{
"id": "lin_evt_pump_c_dash",
"type": "link in",
"z": "tab_ui",
"name": "evt:pump-C",
"links": [
"lout_evt_pump_c"
],
"x": 120,
"y": 2340,
"wires": [
[
"dispatch_pump_c"
]
]
},
{
"id": "dispatch_pump_c",
"type": "function",
"z": "tab_ui",
"name": "dispatch Pump C",
"func": "const p = msg.payload || {};\nconst ts = Date.now();\nconst OFF = 6;\nfunction stateNum(s) {\n switch (s) {\n case 'operational': return OFF + 2;\n case 'starting':\n case 'warmingup': return OFF + 1;\n case 'stopping': return OFF + 1.5;\n case 'coolingdown': return OFF + 0.5;\n default: return OFF;\n }\n}\nconst sNum = p.state ? stateNum(p.state) : null;\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump C', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump C', payload: p.powerNum, timestamp: ts} : null,\n p.pUpNum != null ? {topic: 'Pump C up', payload: p.pUpNum, timestamp: ts} : null,\n p.pDnNum != null ? {topic: 'Pump C dn', payload: p.pDnNum, timestamp: ts} : null,\n sNum != null ? {topic: 'Pump C state', payload: sNum, timestamp: ts} : null,\n];",
"outputs": 12,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 380,
"y": 2340,
"wires": [
[
"ui_pump_c_state"
],
[
"ui_pump_c_mode"
],
[
"ui_pump_c_ctrl"
],
[
"ui_pump_c_flow"
],
[
"ui_pump_c_power"
],
[
"ui_pump_c_pUp"
],
[
"ui_pump_c_pDn"
],
[
"chart_trend_flow"
],
[
"chart_trend_power"
],
[
"chart_trend_pressure"
],
[
"chart_trend_pressure"
],
[
"chart_trend_states"
]
]
},
{
"id": "ui_pump_c_state",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_c",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump C State",
"label": "State",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 2340,
"wires": []
},
{
"id": "ui_pump_c_mode",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_c",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump C Mode",
"label": "Mode",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 2370,
"wires": []
},
{
"id": "ui_pump_c_ctrl",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_c",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump C Controller %",
"label": "Controller %",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 2400,
"wires": []
},
{
"id": "ui_pump_c_flow",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_c",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump C Flow",
"label": "Flow",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 2430,
"wires": []
},
{
"id": "ui_pump_c_power",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_c",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump C Power",
"label": "Power",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 2460,
"wires": []
},
{
"id": "ui_pump_c_pUp",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_c",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump C p Upstream",
"label": "p Upstream",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 2490,
"wires": []
},
{
"id": "ui_pump_c_pDn",
"type": "ui-text",
"z": "tab_ui",
"group": "ui_grp_pump_c",
"order": 1,
"width": "0",
"height": "0",
"name": "Pump C p Downstream",
"label": "p Downstream",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#000000",
"x": 640,
"y": 2520,
"wires": []
},
{
"id": "ui_pump_c_setpoint",
"type": "ui-slider",
"z": "tab_ui",
"group": "ui_grp_pump_c",
"name": "Pump C setpoint",
"label": "Setpoint % (manual mode)",
"tooltip": "",
"order": 1,
"width": "0",
"height": "0",
"passthru": true,
"outs": "end",
"topic": "setpoint_pump_c",
"topicType": "str",
"min": "0",
"max": "100",
"step": "5.0",
"showLabel": true,
"showValue": true,
"labelPosition": "top",
"valuePosition": "left",
"thumbLabel": false,
"iconStart": "",
"iconEnd": "",
"x": 120,
"y": 2580,
"wires": [
[
"lout_setpoint_pump_c_dash"
]
]
},
{
"id": "lout_setpoint_pump_c_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:setpoint-C",
"mode": "link",
"links": [
"lin_setpoint_pump_c"
],
"x": 380,
"y": 2580,
"wires": []
},
{
"id": "btn_pump_c_start",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_pump_c",
"name": "Pump C startup",
"label": "Startup",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#16a34a",
"className": "",
"icon": "play_arrow",
"iconPosition": "left",
"payload": "fired",
"payloadType": "str",
"topic": "start_pump_c",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 2630,
"wires": [
[
"wrap_pump_c_start"
]
]
},
{
"id": "wrap_pump_c_start",
"type": "function",
"z": "tab_ui",
"name": "build start (Pump C)",
"func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 2630,
"wires": [
[
"lout_seq_pump_c_dash"
]
]
},
{
"id": "btn_pump_c_stop",
"type": "ui-button",
"z": "tab_ui",
"group": "ui_grp_pump_c",
"name": "Pump C shutdown",
"label": "Shutdown",
"order": 1,
"width": "0",
"height": "0",
"tooltip": "",
"color": "#ffffff",
"bgcolor": "#ea580c",
"className": "",
"icon": "stop",
"iconPosition": "left",
"payload": "fired",
"payloadType": "str",
"topic": "stop_pump_c",
"topicType": "str",
"buttonType": "default",
"x": 120,
"y": 2680,
"wires": [
[
"wrap_pump_c_stop"
]
]
},
{
"id": "wrap_pump_c_stop",
"type": "function",
"z": "tab_ui",
"name": "build stop (Pump C)",
"func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 2680,
"wires": [
[
"lout_seq_pump_c_dash"
]
]
},
{
"id": "lout_seq_pump_c_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:pump-C-seq",
"mode": "link",
"links": [
"lin_seq_pump_c"
],
"x": 640,
"y": 2655,
"wires": []
},
{
"id": "c_ui_trends",
"type": "comment",
"z": "tab_ui",
"name": "\u2500\u2500 Trend charts (1h rolling) \u2500\u2500",
"info": "",
"x": 640,
"y": 2840,
"wires": []
},
{
"id": "chart_trend_basin",
"type": "ui-chart",
"z": "tab_ui",
"group": "ui_grp_tr_basin",
"name": "Basin level + fill %",
"label": "Basin level + fill",
"order": 1,
"chartType": "line",
"interpolation": "linear",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "",
"xAxisType": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "m / %",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "",
"ymax": "",
"removeOlder": "60",
"removeOlderUnit": "60",
"removeOlderPoints": "3600",
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"bins": 10,
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 12,
"height": 8,
"className": "",
"x": 900,
"y": 2880,
"wires": [
[]
]
},
{
"id": "chart_trend_demand",
"type": "ui-chart",
"z": "tab_ui",
"group": "ui_grp_tr_demand",
"name": "PS process demand %",
"label": "PS demand",
"order": 1,
"chartType": "line",
"interpolation": "linear",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "",
"xAxisType": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "%",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "0",
"ymax": "110",
"removeOlder": "60",
"removeOlderUnit": "60",
"removeOlderPoints": "3600",
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"bins": 10,
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 12,
"height": 6,
"className": "",
"x": 900,
"y": 2920,
"wires": [
[]
]
},
{
"id": "chart_trend_dq",
"type": "ui-chart",
"z": "tab_ui",
"group": "ui_grp_tr_dq",
"name": "\u0394Q \u2014 inflow \u2212 outflow",
"label": "\u0394Q",
"order": 1,
"chartType": "line",
"interpolation": "linear",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "",
"xAxisType": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "m\u00b3/h",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "",
"ymax": "",
"removeOlder": "60",
"removeOlderUnit": "60",
"removeOlderPoints": "3600",
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"bins": 10,
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 12,
"height": 6,
"className": "",
"x": 900,
"y": 2940,
"wires": [
[]
]
},
{
"id": "chart_trend_states",
"type": "ui-chart",
"z": "tab_ui",
"group": "ui_grp_tr_states",
"name": "Pump state timeline",
"label": "Pump states (A=0-2, B=3-5, C=6-8)",
"order": 1,
"chartType": "line",
"interpolation": "step",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "",
"xAxisType": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "A B C tracks",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "-0.5",
"ymax": "8.5",
"removeOlder": "60",
"removeOlderUnit": "60",
"removeOlderPoints": "3600",
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"bins": 10,
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 12,
"height": 6,
"className": "",
"x": 900,
"y": 2960,
"wires": [
[]
]
},
{
"id": "chart_trend_flow",
"type": "ui-chart",
"z": "tab_ui",
"group": "ui_grp_tr_flow",
"name": "Inflow / Outflow / Per-pump flow",
"label": "Flows",
"order": 1,
"chartType": "line",
"interpolation": "linear",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "",
"xAxisType": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "m\u00b3/h",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "",
"ymax": "",
"removeOlder": "60",
"removeOlderUnit": "60",
"removeOlderPoints": "3600",
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"bins": 10,
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 12,
"height": 8,
"className": "",
"x": 900,
"y": 2960,
"wires": [
[]
]
},
{
"id": "chart_trend_power",
"type": "ui-chart",
"z": "tab_ui",
"group": "ui_grp_tr_power",
"name": "Per-pump power",
"label": "Power",
"order": 1,
"chartType": "line",
"interpolation": "linear",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "",
"xAxisType": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "kW",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "",
"ymax": "",
"removeOlder": "60",
"removeOlderUnit": "60",
"removeOlderPoints": "3600",
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"bins": 10,
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 12,
"height": 8,
"className": "",
"x": 900,
"y": 3040,
"wires": [
[]
]
},
{
"id": "chart_trend_pressure",
"type": "ui-chart",
"z": "tab_ui",
"group": "ui_grp_tr_press",
"name": "Per-pump up/dn pressure",
"label": "Pressure",
"order": 1,
"chartType": "line",
"interpolation": "linear",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "",
"xAxisType": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "mbar",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "",
"ymax": "",
"removeOlder": "60",
"removeOlderUnit": "60",
"removeOlderPoints": "3600",
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"bins": 10,
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 12,
"height": 8,
"className": "",
"x": 900,
"y": 3120,
"wires": [
[]
]
},
{
"id": "tab_drivers",
"type": "tab",
"label": "\ud83c\udf9b\ufe0f Demo Drivers",
"disabled": false,
"info": "Inflow generator. The operator picks a SCENARIO (Constant / Sine / Diurnal / Storm) on the dashboard and sets a BASELINE m\u00b3/h value. Every second this generator emits q_in to the PS based on the active scenario + baseline.\n\nOutflow is implicit: the pumps drain the basin via MGC."
},
{
"id": "c_drv_title",
"type": "comment",
"z": "tab_drivers",
"name": "\ud83c\udf9b\ufe0f DEMO DRIVERS \u2014 operator-driven inflow generator",
"info": "",
"x": 640,
"y": 20,
"wires": []
},
{
"id": "lin_inflow_scenario",
"type": "link in",
"z": "tab_drivers",
"name": "cmd:inflow-scenario",
"links": [
"lout_inflow_scenario",
"lout_setup_inflow_scn"
],
"x": 120,
"y": 100,
"wires": [
[
"inflow_state"
]
]
},
{
"id": "lin_inflow_baseline",
"type": "link in",
"z": "tab_drivers",
"name": "cmd:inflow-baseline",
"links": [
"lout_inflow_baseline",
"lout_setup_inflow_baseline"
],
"x": 120,
"y": 140,
"wires": [
[
"inflow_state"
]
]
},
{
"id": "inflow_tick",
"type": "inject",
"z": "tab_drivers",
"name": "tick (1 Hz)",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "",
"vt": "date"
}
],
"topic": "tick",
"payload": "",
"payloadType": "date",
"repeat": "1",
"crontab": "",
"once": false,
"onceDelay": "0.5",
"x": 120,
"y": 200,
"wires": [
[
"inflow_state"
]
]
},
{
"id": "inflow_state",
"type": "function",
"z": "tab_drivers",
"name": "inflow scenario engine",
"func": "let scenario = context.get('scenario') || 'constant';\nlet baseline = context.get('baseline');\nif (baseline == null) baseline = 60;\n\nif (msg.topic === 'inflowBaseline') {\n const v = Number(msg.payload);\n if (Number.isFinite(v) && v >= 0) {\n baseline = v;\n context.set('baseline', baseline);\n }\n return null;\n}\nif (msg.topic === 'scenario') {\n const s = String(msg.payload || '').toLowerCase();\n if (['constant','sine','diurnal','storm'].includes(s)) {\n scenario = s;\n context.set('scenario', scenario);\n }\n return null;\n}\nconst t = Date.now() / 1000;\nlet q_h;\nswitch (scenario) {\n case 'sine': {\n q_h = baseline * (1 + 0.5 * Math.sin(2 * Math.PI * t / 240));\n break;\n }\n case 'diurnal': {\n q_h = baseline * (1 + 0.6 * Math.sin(2 * Math.PI * t / 480 - Math.PI/2));\n break;\n }\n case 'storm': {\n const phase = (t % 240) / 240;\n let factor;\n if (phase < 0.15) factor = 1 + (4 / 0.15) * phase;\n else factor = Math.max(1, 5 - (4 / 0.85) * (phase - 0.15));\n q_h = baseline * factor;\n break;\n }\n case 'constant':\n default:\n q_h = baseline;\n}\nq_h = Math.max(0, q_h);\nconst q_s = q_h / 3600;\nreturn [\n { topic: 'q_in', payload: q_s, unit: 'm3/s', timestamp: Date.now() },\n { payload: { scenario, baseline, q_h, q_s, ts: Date.now() } },\n];",
"outputs": 2,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 640,
"y": 160,
"wires": [
[
"lout_qin_drivers"
],
[
"lout_evt_inflow"
]
]
},
{
"id": "lout_qin_drivers",
"type": "link out",
"z": "tab_drivers",
"name": "cmd:q_in",
"mode": "link",
"links": [
"lin_qin_at_ps"
],
"x": 900,
"y": 140,
"wires": []
},
{
"id": "lout_evt_inflow",
"type": "link out",
"z": "tab_drivers",
"name": "evt:inflow",
"mode": "link",
"links": [
"lin_evt_inflow"
],
"x": 900,
"y": 180,
"wires": []
},
{
"id": "tab_setup",
"type": "tab",
"label": "\u2699\ufe0f Setup & Init",
"disabled": false,
"info": "One-shot deploy-time injects:\n \u2022 MGC scaling = normalized + mode = optimalcontrol\n \u2022 all pumps mode = auto\n \u2022 initial inflow baseline + scenario\n\nDisable this tab in production."
},
{
"id": "c_setup_title",
"type": "comment",
"z": "tab_setup",
"name": "\u2699\ufe0f SETUP & INIT \u2014 one-shot deploy-time injects",
"info": "",
"x": 640,
"y": 20,
"wires": []
},
{
"id": "setup_mgc_scaling",
"type": "inject",
"z": "tab_setup",
"name": "MGC scaling = normalized",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "normalized",
"vt": "str"
}
],
"topic": "setScaling",
"payload": "normalized",
"payloadType": "str",
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "1.5",
"x": 120,
"y": 100,
"wires": [
[
"lout_setup_to_mgc"
]
]
},
{
"id": "setup_mgc_mode",
"type": "inject",
"z": "tab_setup",
"name": "MGC mode = optimalcontrol",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "optimalcontrol",
"vt": "str"
}
],
"topic": "setMode",
"payload": "optimalcontrol",
"payloadType": "str",
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "1.7",
"x": 120,
"y": 160,
"wires": [
[
"lout_setup_to_mgc"
]
]
},
{
"id": "lout_setup_to_mgc",
"type": "link out",
"z": "tab_setup",
"name": "setup:to-mgc",
"mode": "link",
"links": [
"lin_setup_at_mgc"
],
"x": 380,
"y": 130,
"wires": []
},
{
"id": "setup_pumps_mode",
"type": "inject",
"z": "tab_setup",
"name": "pumps mode = auto",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "auto",
"vt": "str"
}
],
"topic": "setMode",
"payload": "auto",
"payloadType": "str",
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "2.0",
"x": 120,
"y": 240,
"wires": [
[
"lout_mode_setup"
]
]
},
{
"id": "lout_mode_setup",
"type": "link out",
"z": "tab_setup",
"name": "cmd:mode",
"mode": "link",
"links": [
"lin_mode"
],
"x": 380,
"y": 240,
"wires": []
},
{
"id": "setup_inflow_baseline",
"type": "inject",
"z": "tab_setup",
"name": "inflow baseline = 25 m\u00b3/h (nominal)",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "25",
"vt": "num"
}
],
"topic": "inflowBaseline",
"payload": "25",
"payloadType": "num",
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "2.5",
"x": 120,
"y": 320,
"wires": [
[
"lout_setup_inflow_baseline"
]
]
},
{
"id": "lout_setup_inflow_baseline",
"type": "link out",
"z": "tab_setup",
"name": "cmd:inflow-baseline",
"mode": "link",
"links": [
"lin_inflow_baseline"
],
"x": 380,
"y": 320,
"wires": []
},
{
"id": "setup_inflow_scenario",
"type": "inject",
"z": "tab_setup",
"name": "inflow scenario = sine",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "sine",
"vt": "str"
}
],
"topic": "scenario",
"payload": "sine",
"payloadType": "str",
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "2.7",
"x": 120,
"y": 380,
"wires": [
[
"lout_setup_inflow_scn"
]
]
},
{
"id": "lout_setup_inflow_scn",
"type": "link out",
"z": "tab_setup",
"name": "cmd:inflow-scenario",
"mode": "link",
"links": [
"lin_inflow_scenario"
],
"x": 380,
"y": 380,
"wires": []
},
{
"id": "setup_calibrate_level",
"type": "inject",
"z": "tab_setup",
"name": "[manual] calibrate basin = 1.0 m (click to reset)",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "1.0",
"vt": "num"
}
],
"topic": "calibratePredictedLevel",
"payload": "1.0",
"payloadType": "num",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "0.5",
"x": 120,
"y": 460,
"wires": [
[
"lout_setup_calibrate"
]
]
},
{
"id": "lout_setup_calibrate",
"type": "link out",
"z": "tab_setup",
"name": "setup:calibrate-ps",
"mode": "link",
"links": [
"lin_setup_calibrate_ps"
],
"x": 380,
"y": 460,
"wires": []
},
{
"id": "tab_telemetry",
"type": "tab",
"label": "\ud83d\udcc8 Telemetry",
"disabled": false,
"info": "InfluxDB writer: every EVOLV node's port-1 telemetry is fanned in via the evt:tlm link channel, converted to line protocol, and POSTed to InfluxDB v2 (org=evolv, bucket=telemetry).\n\nPattern adapted from docker/demo-flow.json."
},
{
"id": "c_tlm_title",
"type": "comment",
"z": "tab_telemetry",
"name": "\ud83d\udcc8 TELEMETRY \u2014 InfluxDB writer",
"info": "",
"x": 640,
"y": 20,
"wires": []
},
{
"id": "lin_tlm",
"type": "link in",
"z": "tab_telemetry",
"name": "evt:tlm",
"links": [
"lout_tlm_pump_a",
"lout_tlm_meas_pump_a_u",
"lout_tlm_meas_pump_a_d",
"lout_tlm_meas_pump_a_f",
"lout_tlm_meas_pump_a_p",
"lout_tlm_pump_b",
"lout_tlm_meas_pump_b_u",
"lout_tlm_meas_pump_b_d",
"lout_tlm_meas_pump_b_f",
"lout_tlm_meas_pump_b_p",
"lout_tlm_pump_c",
"lout_tlm_meas_pump_c_u",
"lout_tlm_meas_pump_c_d",
"lout_tlm_meas_pump_c_f",
"lout_tlm_meas_pump_c_p",
"lout_tlm_mgc",
"lout_tlm_ps"
],
"x": 120,
"y": 100,
"wires": [
[
"fn_tlm_to_lp"
]
]
},
{
"id": "fn_tlm_to_lp",
"type": "function",
"z": "tab_telemetry",
"name": "\u2192 InfluxDB line protocol",
"func": "const p = msg.payload;\nif (!p || !p.measurement || !p.fields) return null;\nconst esc = (s) => String(s)\n .replace(/,/g, '\\\\,').replace(/ /g, '\\\\ ').replace(/=/g, '\\\\=');\nconst tags = Object.entries(p.tags || {})\n .filter(([k, v]) => v !== undefined && v !== null && v !== '')\n .map(([k, v]) => `${esc(k)}=${esc(v)}`).join(',');\nconst fieldPairs = Object.entries(p.fields)\n .filter(([k, v]) => v !== undefined && v !== null)\n .map(([k, v]) => {\n if (typeof v === 'number' && Number.isFinite(v)) return `${esc(k)}=${v}`;\n if (typeof v === 'boolean') return `${esc(k)}=${v}`;\n return `${esc(k)}=\"${String(v).replace(/\"/g, '\\\\\"')}\"`;\n });\nif (fieldPairs.length === 0) return null;\nconst ts = Date.now() * 1000000;\nmsg.payload = `${esc(p.measurement)}${tags ? ',' + tags : ''} `\n + `${fieldPairs.join(',')} ${ts}`;\n// Hint the join node to fire on size or timeout.\nmsg.topic = 'tlm';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 640,
"y": 100,
"wires": [
[
"join_tlm"
]
]
},
{
"id": "join_tlm",
"type": "join",
"z": "tab_telemetry",
"name": "batch (200 lines / 2 s)",
"mode": "custom",
"build": "string",
"property": "payload",
"propertyType": "msg",
"key": "topic",
"joiner": "\\n",
"joinerType": "str",
"accumulate": false,
"timeout": "2",
"count": "200",
"reduceRight": false,
"reduceExp": "",
"reduceInit": "",
"reduceInitType": "",
"reduceFixup": "",
"x": 900,
"y": 100,
"wires": [
[
"fn_tlm_post"
]
]
},
{
"id": "fn_tlm_post",
"type": "function",
"z": "tab_telemetry",
"name": "wrap as InfluxDB POST",
"func": "// Count lines for status reporting.\nconst body = String(msg.payload || '');\nconst lineCount = body ? body.split('\\n').length : 0;\nif (lineCount === 0) return null;\nmsg.lineCount = lineCount;\nmsg.headers = {\n 'Authorization': 'Token evolv-dev-token',\n 'Content-Type': 'text/plain'\n};\nmsg.url = 'http://influxdb:8086/api/v2/write?org=evolv&bucket=telemetry&precision=ns';\nmsg.method = 'POST';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1100,
"y": 100,
"wires": [
[
"http_tlm"
]
]
},
{
"id": "http_tlm",
"type": "http request",
"z": "tab_telemetry",
"name": "Write InfluxDB",
"method": "use",
"ret": "txt",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"x": 1240,
"y": 100,
"wires": [
[
"fn_tlm_count"
]
]
},
{
"id": "fn_tlm_count",
"type": "function",
"z": "tab_telemetry",
"name": "Count writes",
"func": "const lines = Number(msg.lineCount) || 0;\nconst writes = (global.get('influx_writes') || 0) + 1;\nconst totalLines = (global.get('influx_lines') || 0) + lines;\nglobal.set('influx_writes', writes);\nglobal.set('influx_lines', totalLines);\nconst errors = global.get('influx_errors') || 0;\nif (msg.statusCode && msg.statusCode >= 400) {\n global.set('influx_errors', errors + 1);\n node.status({fill:'red', shape:'ring',\n text:`ERR ${errors+1}: ${msg.statusCode}`});\n} else {\n node.status({fill:'green', shape:'dot',\n text:`${writes} POSTs \u00b7 ${totalLines} lines (${errors} err)`});\n}\nreturn null;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1420,
"y": 100,
"wires": [
[]
]
}
]