Files
EVOLV/examples/pumpingstation-complete-example/flow.json
Rene De Ren aec90cc8e7
Some checks failed
CI / lint-and-test (push) Has been cancelled
fix: stopLevel hysteresis works — bump rotatingMachine + MGC
Pump-shutdown deadlock fix split across two submodules:

- rotatingMachine@8f9150e: shutdown sequence clears state.delayedMove
  so the abort-and-return-to-operational path doesn't auto-pickup the
  queued setpoint and re-engage the pump.
- machineGroupControl@ea2857f: turnOffAllMachines clears MGC's
  _delayedCall and serializes per-pump shutdown so PS's 2 s tick loop
  can't interrupt an in-flight shutdown.

Live verification on pumpingstation-complete-example demo: basin now
shuts pumps off at stopLevel cleanly, reverses to fill, completes the
hysteresis cycle.

Also disable the trends page in the demo flow (build_flow.py + regen
flow.json). FlowFuse ui-chart's per-series server-side history buffer
(7 charts × ~20 series × 3600-point retention) was saturating the
Node-RED event loop at 129% CPU, making the dashboard freeze on every
click. Trends remain available — just disabled by default; flip the
ui_page_trends "d" key to false to re-enable.

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

5463 lines
133 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[
{
"id": "tab_process",
"type": "tab",
"label": "🏭 Process Plant",
"disabled": false,
"info": "EVOLV plant model: 3 rotatingMachines (each with 4 measurement nodes — 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": "🏭 PROCESS PLANT — EVOLV nodes + per-pump physics feeders",
"info": "",
"x": 640,
"y": 20,
"wires": []
},
{
"id": "c_pump_a",
"type": "comment",
"z": "tab_process",
"name": "── Pump A ── (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": "→",
"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": "←",
"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": "←",
"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": "⊥",
"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": "⊥",
"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 ≤ 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³/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 → 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 — 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') — `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 — 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 — 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³/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": "── Pump B ── (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": "→",
"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": "←",
"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": "←",
"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": "⊥",
"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": "⊥",
"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 ≤ 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³/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 → 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 — 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') — `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 — 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 — 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³/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": "── Pump C ── (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": "→",
"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": "←",
"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": "←",
"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": "⊥",
"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": "⊥",
"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 ≤ 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³/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 → 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 — 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') — `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 — 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 — 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³/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": "── MGC ── (orchestrates the 3 pumps via optimalcontrol)",
"info": "",
"x": 640,
"y": 920,
"wires": []
},
{
"id": "mgc_pumps",
"type": "machineGroupControl",
"z": "tab_process",
"name": "MGC — 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": "⊥",
"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³/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": "── Pumping Station ── (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 → 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": "⊥",
"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 → 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³' : 'n/a',\n fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n netFlow: netM3h != null ? netM3h.toFixed(0) + ' m³/h' : 'n/a',\n timeLeft: timeStr,\n qIn: qIn != null ? (Number(qIn ) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m³/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": "── Mode broadcast ──",
"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 → 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": "── Station-wide commands ──",
"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 → 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 → 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 → 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": "── Setup feeders ──",
"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": "📊 Dashboard UI",
"disabled": false,
"info": "All FlowFuse ui-* widgets. Two pages:\n /dashboard/realtime — gauges + per-pump status (no time history)\n /dashboard/trends — 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 — 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": "",
"d": true
},
{
"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 — 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": "ΔQ = inflow outflow (m³/h, +fill / drain)",
"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": "📊 DASHBOARD UI — only ui-* widgets here",
"info": "",
"x": 640,
"y": 20,
"wires": []
},
{
"id": "c_ui_inflow",
"type": "comment",
"z": "tab_ui",
"name": "── Operator inflow input ──",
"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³/h) — 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³/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": "── Mode + Station-wide buttons ──",
"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 · 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³/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": "── Basin realtime (gauges + text) ──",
"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// ΔQ = inflow outflow in m³/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: 'ΔQ', 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": "── MGC realtime ──",
"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³/h",
"prefix": "",
"suffix": " m³/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": "── Pump A ──",
"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": "── Pump B ──",
"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": "── Pump C ──",
"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": "── Trend charts (1h rolling) ──",
"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": "ΔQ — inflow outflow",
"label": "ΔQ",
"order": 1,
"chartType": "line",
"interpolation": "linear",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "",
"xAxisType": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "m³/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³/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": "🎛️ Demo Drivers",
"disabled": false,
"info": "Inflow generator. The operator picks a SCENARIO (Constant / Sine / Diurnal / Storm) on the dashboard and sets a BASELINE m³/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": "🎛️ DEMO DRIVERS — 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": "⚙️ Setup & Init",
"disabled": false,
"info": "One-shot deploy-time injects:\n • MGC scaling = normalized + mode = optimalcontrol\n • all pumps mode = auto\n • initial inflow baseline + scenario\n\nDisable this tab in production."
},
{
"id": "c_setup_title",
"type": "comment",
"z": "tab_setup",
"name": "⚙️ SETUP & INIT — 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³/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": "📈 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": "📈 TELEMETRY — 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": "→ 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 · ${totalLines} lines (${errors} err)`});\n}\nreturn null;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1420,
"y": 100,
"wires": [
[]
]
}
]