fix: stopLevel hysteresis works — bump rotatingMachine + MGC
Some checks failed
CI / lint-and-test (push) Has been cancelled

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>
This commit is contained in:
Rene De Ren
2026-05-09 18:18:11 +02:00
parent 6fef002da1
commit aec90cc8e7
4 changed files with 87 additions and 85 deletions

View File

@@ -211,6 +211,7 @@ def dashboard_scaffold():
"layout": "grid", "theme": "ui_theme", "layout": "grid", "theme": "ui_theme",
"breakpoints": [{"name": "Default", "px": "0", "cols": "12"}], "breakpoints": [{"name": "Default", "px": "0", "cols": "12"}],
"order": 2, "className": "", "order": 2, "className": "",
"d": True,
} }
return [base, theme, page_realtime, page_trends] return [base, theme, page_realtime, page_trends]

View File

@@ -2,15 +2,15 @@
{ {
"id": "tab_process", "id": "tab_process",
"type": "tab", "type": "tab",
"label": "\ud83c\udfed Process Plant", "label": "🏭 Process Plant",
"disabled": false, "disabled": false,
"info": "EVOLV plant model: 3 rotatingMachines (each with 4 measurement nodes \u2014 upstream P, downstream P, flow, power), MGC, PS.\n\nPer pump there is a 'physics' function node that consumes the pump's own port-0 stream PLUS PS port-0 (basin level) and drives all 4 measurement nodes with physically-coupled values (upstream P from basin head; downstream P from pump state + flow; flow/power mirror predicted with Gaussian noise). This lives on this tab so the plant model is self-contained.\n\nAll cross-tab wires use named link-in / link-out channels." "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", "id": "c_process_title",
"type": "comment", "type": "comment",
"z": "tab_process", "z": "tab_process",
"name": "\ud83c\udfed PROCESS PLANT \u2014 EVOLV nodes + per-pump physics feeders", "name": "🏭 PROCESS PLANT EVOLV nodes + per-pump physics feeders",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 20, "y": 20,
@@ -20,7 +20,7 @@
"id": "c_pump_a", "id": "c_pump_a",
"type": "comment", "type": "comment",
"z": "tab_process", "z": "tab_process",
"name": "\u2500\u2500 Pump A \u2500\u2500 (pump + 4 sensors + physics feeder)", "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.", "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, "x": 640,
"y": 80, "y": 80,
@@ -55,7 +55,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "upstream", "positionVsParent": "upstream",
"positionIcon": "\u2192", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -114,7 +114,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "downstream", "positionVsParent": "downstream",
"positionIcon": "\u2190", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -173,7 +173,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "downstream", "positionVsParent": "downstream",
"positionIcon": "\u2190", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -232,7 +232,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "atEquipment", "positionVsParent": "atEquipment",
"positionIcon": "\u22a5", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -288,7 +288,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "atEquipment", "positionVsParent": "atEquipment",
"positionIcon": "\u22a5", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -326,7 +326,7 @@
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "format Pump A port 0", "name": "format Pump A port 0",
"func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle dashboard fan-out to \u2264 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;", "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, "outputs": 1,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -357,8 +357,8 @@
"id": "physics_pump_a", "id": "physics_pump_a",
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "physics Pump A \u2192 4 sensors", "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 \u2014 flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') \u2014 `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_a', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_a_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure \u2014 same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure \u2014 driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m\u00b3/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n", "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, "outputs": 4,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -436,7 +436,7 @@
"id": "c_pump_b", "id": "c_pump_b",
"type": "comment", "type": "comment",
"z": "tab_process", "z": "tab_process",
"name": "\u2500\u2500 Pump B \u2500\u2500 (pump + 4 sensors + physics feeder)", "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.", "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, "x": 640,
"y": 360, "y": 360,
@@ -471,7 +471,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "upstream", "positionVsParent": "upstream",
"positionIcon": "\u2192", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -530,7 +530,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "downstream", "positionVsParent": "downstream",
"positionIcon": "\u2190", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -589,7 +589,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "downstream", "positionVsParent": "downstream",
"positionIcon": "\u2190", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -648,7 +648,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "atEquipment", "positionVsParent": "atEquipment",
"positionIcon": "\u22a5", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -704,7 +704,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "atEquipment", "positionVsParent": "atEquipment",
"positionIcon": "\u22a5", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -742,7 +742,7 @@
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "format Pump B port 0", "name": "format Pump B port 0",
"func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle dashboard fan-out to \u2264 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;", "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, "outputs": 1,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -773,8 +773,8 @@
"id": "physics_pump_b", "id": "physics_pump_b",
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "physics Pump B \u2192 4 sensors", "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 \u2014 flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') \u2014 `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_b', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_b_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure \u2014 same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure \u2014 driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m\u00b3/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n", "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, "outputs": 4,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -852,7 +852,7 @@
"id": "c_pump_c", "id": "c_pump_c",
"type": "comment", "type": "comment",
"z": "tab_process", "z": "tab_process",
"name": "\u2500\u2500 Pump C \u2500\u2500 (pump + 4 sensors + physics feeder)", "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.", "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, "x": 640,
"y": 640, "y": 640,
@@ -887,7 +887,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "upstream", "positionVsParent": "upstream",
"positionIcon": "\u2192", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -946,7 +946,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "downstream", "positionVsParent": "downstream",
"positionIcon": "\u2190", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -1005,7 +1005,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "downstream", "positionVsParent": "downstream",
"positionIcon": "\u2190", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -1064,7 +1064,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "atEquipment", "positionVsParent": "atEquipment",
"positionIcon": "\u22a5", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -1120,7 +1120,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "atEquipment", "positionVsParent": "atEquipment",
"positionIcon": "\u22a5", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -1158,7 +1158,7 @@
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "format Pump C port 0", "name": "format Pump C port 0",
"func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle dashboard fan-out to \u2264 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;", "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, "outputs": 1,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -1189,8 +1189,8 @@
"id": "physics_pump_c", "id": "physics_pump_c",
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "physics Pump C \u2192 4 sensors", "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 \u2014 flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') \u2014 `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_c', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_c_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure \u2014 same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure \u2014 driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m\u00b3/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n", "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, "outputs": 4,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -1268,7 +1268,7 @@
"id": "c_mgc", "id": "c_mgc",
"type": "comment", "type": "comment",
"z": "tab_process", "z": "tab_process",
"name": "\u2500\u2500 MGC \u2500\u2500 (orchestrates the 3 pumps via optimalcontrol)", "name": "── MGC ── (orchestrates the 3 pumps via optimalcontrol)",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 920, "y": 920,
@@ -1278,7 +1278,7 @@
"id": "mgc_pumps", "id": "mgc_pumps",
"type": "machineGroupControl", "type": "machineGroupControl",
"z": "tab_process", "z": "tab_process",
"name": "MGC \u2014 Pump Group", "name": "MGC Pump Group",
"uuid": "mgc-pump-group", "uuid": "mgc-pump-group",
"category": "controller", "category": "controller",
"assetType": "machinegroupcontrol", "assetType": "machinegroupcontrol",
@@ -1289,7 +1289,7 @@
"logLevel": "debug", "logLevel": "debug",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "atEquipment", "positionVsParent": "atEquipment",
"positionIcon": "\u22a5", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -1328,7 +1328,7 @@
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "format MGC port 0", "name": "format MGC port 0",
"func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle: MGC fires on every distribution change.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst totalFlow = find('flow.predicted.atequipment.') ?? find('downstream_predicted_flow');\nconst totalPower = find('power.predicted.atequipment.') ?? find('atEquipment_predicted_power');\nconst eff = find('efficiency.predicted.atequipment.');\nmsg.payload = {\n totalFlow: totalFlow != null ? Number(totalFlow ).toFixed(1) + ' m\u00b3/h' : 'n/a',\n totalPower: totalPower != null ? Number(totalPower).toFixed(2) + ' kW' : 'n/a',\n efficiency: eff != null ? Number(eff).toFixed(3) : 'n/a',\n totalFlowNum: totalFlow != null ? Number(totalFlow ) : null,\n totalPowerNum: totalPower != null ? Number(totalPower) : null,\n efficiencyNum: eff != null ? Number(eff) : null,\n};\nreturn msg;", "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, "outputs": 1,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -1359,7 +1359,7 @@
"id": "c_ps", "id": "c_ps",
"type": "comment", "type": "comment",
"z": "tab_process", "z": "tab_process",
"name": "\u2500\u2500 Pumping Station \u2500\u2500 (basin model, levelbased control)", "name": "── Pumping Station ── (basin model, levelbased control)",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 1200, "y": 1200,
@@ -1401,7 +1401,7 @@
"id": "qd_to_ps_wrap", "id": "qd_to_ps_wrap",
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "wrap slider \u2192 PS Qd", "name": "wrap slider PS Qd",
"func": "msg.topic = 'Qd';\nreturn msg;", "func": "msg.topic = 'Qd';\nreturn msg;",
"outputs": 1, "outputs": 1,
"noerr": 0, "noerr": 0,
@@ -1447,7 +1447,7 @@
"logLevel": "warn", "logLevel": "warn",
"tickIntervalMs": 2000, "tickIntervalMs": 2000,
"positionVsParent": "atEquipment", "positionVsParent": "atEquipment",
"positionIcon": "\u22a5", "positionIcon": "",
"hasDistance": false, "hasDistance": false,
"distance": 0, "distance": 0,
"distanceUnit": "m", "distanceUnit": "m",
@@ -1510,7 +1510,7 @@
"id": "ps_to_physics", "id": "ps_to_physics",
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "ps \u2192 fan basin level to 3 physics feeders", "name": "ps fan basin level to 3 physics feeders",
"func": "const out = { from: 'ps', payload: msg.payload };\nreturn [out, out, out];", "func": "const out = { from: 'ps', payload: msg.payload };\nreturn [out, out, out];",
"outputs": 3, "outputs": 3,
"noerr": 0, "noerr": 0,
@@ -1536,7 +1536,7 @@
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "format PS port 0", "name": "format PS port 0",
"func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\n// Throttle: PS emits frequently in levelbased mode.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst MAX_VOL = 50.0;\nconst lvl = find('level.predicted.');\nconst vol = find('volume.predicted.');\nconst qIn = find('flow.predicted.in.');\nconst qOut = find('flow.predicted.out.');\nconst netFlowRate = find('netFlowRate.predicted.');\nconst fillPct = vol != null\n ? Math.min(100, Math.max(0, Math.round(Number(vol) / MAX_VOL * 100)))\n : null;\nconst netM3h = netFlowRate != null ? Number(netFlowRate) * 3600 : null;\nconst seconds = (c.timeleft != null && Number.isFinite(Number(c.timeleft)))\n ? Number(c.timeleft) : null;\nconst timeStr = seconds != null\n ? (seconds > 60 ? Math.round(seconds/60) + ' min'\n : Math.round(seconds) + ' s')\n : 'n/a';\nmsg.payload = {\n direction: c.direction || 'steady',\n level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n volume: vol != null ? Number(vol).toFixed(1) + ' m\u00b3' : 'n/a',\n fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n netFlow: netM3h != null ? netM3h.toFixed(0) + ' m\u00b3/h' : 'n/a',\n timeLeft: timeStr,\n qIn: qIn != null ? (Number(qIn ) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n levelNum: lvl != null ? Number(lvl) : null,\n volumeNum: vol != null ? Number(vol) : null,\n fillPctNum: fillPct,\n netFlowNum: netM3h,\n percControl: c.percControl != null ? Number(c.percControl) : null,\n qInNum: qIn != null ? Number(qIn ) * 3600 : null,\n qOutNum: qOut != null ? Number(qOut) * 3600 : null,\n safetyState: c.safetyState || 'normal',\n};\nreturn msg;", "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, "outputs": 1,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -1567,7 +1567,7 @@
"id": "c_mode_bcast", "id": "c_mode_bcast",
"type": "comment", "type": "comment",
"z": "tab_process", "z": "tab_process",
"name": "\u2500\u2500 Mode broadcast \u2500\u2500", "name": "── Mode broadcast ──",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 1420, "y": 1420,
@@ -1593,7 +1593,7 @@
"id": "fanout_mode", "id": "fanout_mode",
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "fan setMode \u2192 3 pumps", "name": "fan setMode 3 pumps",
"func": "msg.topic = 'setMode';\nreturn [msg, msg, msg];", "func": "msg.topic = 'setMode';\nreturn [msg, msg, msg];",
"outputs": 3, "outputs": 3,
"noerr": 0, "noerr": 0,
@@ -1618,7 +1618,7 @@
"id": "c_station_cmds", "id": "c_station_cmds",
"type": "comment", "type": "comment",
"z": "tab_process", "z": "tab_process",
"name": "\u2500\u2500 Station-wide commands \u2500\u2500", "name": "── Station-wide commands ──",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 1620, "y": 1620,
@@ -1644,7 +1644,7 @@
"id": "fan_station_start", "id": "fan_station_start",
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "fan startup \u2192 3 pumps", "name": "fan startup 3 pumps",
"func": "return [msg, msg, msg];", "func": "return [msg, msg, msg];",
"outputs": 3, "outputs": 3,
"noerr": 0, "noerr": 0,
@@ -1685,7 +1685,7 @@
"id": "fan_station_stop", "id": "fan_station_stop",
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "fan shutdown \u2192 3 pumps", "name": "fan shutdown 3 pumps",
"func": "return [msg, msg, msg];", "func": "return [msg, msg, msg];",
"outputs": 3, "outputs": 3,
"noerr": 0, "noerr": 0,
@@ -1726,7 +1726,7 @@
"id": "fan_station_estop", "id": "fan_station_estop",
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "fan emergency stop \u2192 3 pumps", "name": "fan emergency stop 3 pumps",
"func": "return [msg, msg, msg];", "func": "return [msg, msg, msg];",
"outputs": 3, "outputs": 3,
"noerr": 0, "noerr": 0,
@@ -1751,7 +1751,7 @@
"id": "c_setup_at_mgc", "id": "c_setup_at_mgc",
"type": "comment", "type": "comment",
"z": "tab_process", "z": "tab_process",
"name": "\u2500\u2500 Setup feeders \u2500\u2500", "name": "── Setup feeders ──",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 1900, "y": 1900,
@@ -1792,9 +1792,9 @@
{ {
"id": "tab_ui", "id": "tab_ui",
"type": "tab", "type": "tab",
"label": "\ud83d\udcca Dashboard UI", "label": "📊 Dashboard UI",
"disabled": false, "disabled": false,
"info": "All FlowFuse ui-* widgets. Two pages:\n /dashboard/realtime \u2014 gauges + per-pump status (no time history)\n /dashboard/trends \u2014 line charts, 1 hour rolling window\n\nAll inputs leave via link-out; all process state arrives via link-in." "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", "id": "ui_base",
@@ -1853,7 +1853,7 @@
{ {
"id": "ui_page_trends", "id": "ui_page_trends",
"type": "ui-page", "type": "ui-page",
"name": "Trends \u2014 1 hour", "name": "Trends 1 hour",
"ui": "ui_base", "ui": "ui_base",
"path": "/trends", "path": "/trends",
"icon": "show_chart", "icon": "show_chart",
@@ -1867,7 +1867,8 @@
} }
], ],
"order": 2, "order": 2,
"className": "" "className": "",
"d": true
}, },
{ {
"id": "ui_grp_inflow", "id": "ui_grp_inflow",
@@ -1984,7 +1985,7 @@
{ {
"id": "ui_grp_tr_demand", "id": "ui_grp_tr_demand",
"type": "ui-group", "type": "ui-group",
"name": "Process demand \u2014 PS percControl (1h)", "name": "Process demand PS percControl (1h)",
"page": "ui_page_trends", "page": "ui_page_trends",
"width": "12", "width": "12",
"height": "1", "height": "1",
@@ -1998,7 +1999,7 @@
{ {
"id": "ui_grp_tr_dq", "id": "ui_grp_tr_dq",
"type": "ui-group", "type": "ui-group",
"name": "\u0394Q = inflow \u2212 outflow (m\u00b3/h, +fill / \u2212drain)", "name": "ΔQ = inflow outflow (m³/h, +fill / drain)",
"page": "ui_page_trends", "page": "ui_page_trends",
"width": "12", "width": "12",
"height": "1", "height": "1",
@@ -2069,7 +2070,7 @@
"id": "c_ui_title", "id": "c_ui_title",
"type": "comment", "type": "comment",
"z": "tab_ui", "z": "tab_ui",
"name": "\ud83d\udcca DASHBOARD UI \u2014 only ui-* widgets here", "name": "📊 DASHBOARD UI only ui-* widgets here",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 20, "y": 20,
@@ -2079,7 +2080,7 @@
"id": "c_ui_inflow", "id": "c_ui_inflow",
"type": "comment", "type": "comment",
"z": "tab_ui", "z": "tab_ui",
"name": "\u2500\u2500 Operator inflow input \u2500\u2500", "name": "── Operator inflow input ──",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 80, "y": 80,
@@ -2091,7 +2092,7 @@
"z": "tab_ui", "z": "tab_ui",
"group": "ui_grp_inflow", "group": "ui_grp_inflow",
"name": "Inflow baseline", "name": "Inflow baseline",
"label": "Inflow baseline (m\u00b3/h) \u2014 scenarios modulate around this value", "label": "Inflow baseline (m³/h) — scenarios modulate around this value",
"tooltip": "", "tooltip": "",
"order": 1, "order": 1,
"width": "0", "width": "0",
@@ -2357,7 +2358,7 @@
"type": "function", "type": "function",
"z": "tab_ui", "z": "tab_ui",
"name": "dispatch inflow", "name": "dispatch inflow",
"func": "const p = msg.payload || {};\nconst ts = Date.now();\nreturn [\n { payload: (p.scenario || 'constant').toUpperCase() },\n { payload: p.q_h != null ? Number(p.q_h).toFixed(1) + ' m\u00b3/h' : 'n/a' },\n p.q_h != null ? { topic: 'Inflow', payload: Number(p.q_h), timestamp: ts } : null,\n];", "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, "outputs": 3,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -2421,7 +2422,7 @@
"id": "c_ui_station", "id": "c_ui_station",
"type": "comment", "type": "comment",
"z": "tab_ui", "z": "tab_ui",
"name": "\u2500\u2500 Mode + Station-wide buttons \u2500\u2500", "name": "── Mode + Station-wide buttons ──",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 380, "y": 380,
@@ -2433,7 +2434,7 @@
"z": "tab_ui", "z": "tab_ui",
"group": "ui_grp_station", "group": "ui_grp_station",
"name": "Station mode", "name": "Station mode",
"label": "Station mode (Auto = level-based \u00b7 Manual = slider Qd)", "label": "Station mode (Auto = level-based · Manual = slider Qd)",
"tooltip": "", "tooltip": "",
"order": 1, "order": 1,
"width": "0", "width": "0",
@@ -2480,7 +2481,7 @@
"z": "tab_ui", "z": "tab_ui",
"group": "ui_grp_station", "group": "ui_grp_station",
"name": "Manual Qd", "name": "Manual Qd",
"label": "Manual Qd (m\u00b3/h, manual mode only)", "label": "Manual Qd (m³/h, manual mode only)",
"tooltip": "", "tooltip": "",
"order": 1, "order": 1,
"width": "0", "width": "0",
@@ -2707,7 +2708,7 @@
"id": "c_ui_basin", "id": "c_ui_basin",
"type": "comment", "type": "comment",
"z": "tab_ui", "z": "tab_ui",
"name": "\u2500\u2500 Basin realtime (gauges + text) \u2500\u2500", "name": "── Basin realtime (gauges + text) ──",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 700, "y": 700,
@@ -2734,7 +2735,7 @@
"type": "function", "type": "function",
"z": "tab_ui", "z": "tab_ui",
"name": "dispatch PS", "name": "dispatch PS",
"func": "const p = msg.payload || {};\nconst ts = Date.now();\n// \u0394Q = inflow \u2212 outflow in m\u00b3/h (positive = filling).\nconst dQ = (p.qInNum != null && p.qOutNum != null)\n ? p.qInNum - p.qOutNum : null;\n// Demand text formatting.\nconst demandStr = p.percControl != null\n ? Number(p.percControl).toFixed(0) + '%' : 'n/a';\nreturn [\n { payload: String(p.direction || 'steady') },\n { payload: String(p.level || 'n/a') },\n { payload: String(p.volume || 'n/a') },\n { payload: String(p.fillPct || 'n/a') },\n { payload: String(p.netFlow || 'n/a') },\n { payload: String(p.timeLeft || 'n/a') },\n { payload: String(p.qIn || 'n/a') },\n { payload: String(p.qOut || 'n/a') },\n { payload: String(p.safetyState || 'normal') },\n { payload: demandStr },\n p.levelNum != null ? { payload: p.levelNum } : null,\n p.fillPctNum != null ? { payload: p.fillPctNum } : null,\n p.percControl != null ? { payload: p.percControl } : null,\n p.levelNum != null ? { topic: 'Basin level', payload: p.levelNum, timestamp: ts } : null,\n p.fillPctNum != null ? { topic: 'Fill %', payload: p.fillPctNum, timestamp: ts } : null,\n p.qOutNum != null ? { topic: 'Outflow', payload: p.qOutNum, timestamp: ts } : null,\n p.percControl != null ? { topic: 'PS demand', payload: p.percControl, timestamp: ts } : null,\n dQ != null ? { topic: '\u0394Q', payload: dQ, timestamp: ts } : null,\n];", "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, "outputs": 18,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -3144,7 +3145,7 @@
"id": "c_ui_mgc", "id": "c_ui_mgc",
"type": "comment", "type": "comment",
"z": "tab_ui", "z": "tab_ui",
"name": "\u2500\u2500 MGC realtime \u2500\u2500", "name": "── MGC realtime ──",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 1080, "y": 1080,
@@ -3266,9 +3267,9 @@
"gtype": "gauge-34", "gtype": "gauge-34",
"gstyle": "Rounded", "gstyle": "Rounded",
"title": "Total flow", "title": "Total flow",
"units": "m\u00b3/h", "units": "m³/h",
"prefix": "", "prefix": "",
"suffix": " m\u00b3/h", "suffix": " m³/h",
"min": 0, "min": 0,
"max": 600, "max": 600,
"segments": [ "segments": [
@@ -3347,7 +3348,7 @@
"id": "c_ui_pump_a", "id": "c_ui_pump_a",
"type": "comment", "type": "comment",
"z": "tab_ui", "z": "tab_ui",
"name": "\u2500\u2500 Pump A \u2500\u2500", "name": "── Pump A ──",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 1340, "y": 1340,
@@ -3720,7 +3721,7 @@
"id": "c_ui_pump_b", "id": "c_ui_pump_b",
"type": "comment", "type": "comment",
"z": "tab_ui", "z": "tab_ui",
"name": "\u2500\u2500 Pump B \u2500\u2500", "name": "── Pump B ──",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 1820, "y": 1820,
@@ -4093,7 +4094,7 @@
"id": "c_ui_pump_c", "id": "c_ui_pump_c",
"type": "comment", "type": "comment",
"z": "tab_ui", "z": "tab_ui",
"name": "\u2500\u2500 Pump C \u2500\u2500", "name": "── Pump C ──",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 2300, "y": 2300,
@@ -4466,7 +4467,7 @@
"id": "c_ui_trends", "id": "c_ui_trends",
"type": "comment", "type": "comment",
"z": "tab_ui", "z": "tab_ui",
"name": "\u2500\u2500 Trend charts (1h rolling) \u2500\u2500", "name": "── Trend charts (1h rolling) ──",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 2840, "y": 2840,
@@ -4601,8 +4602,8 @@
"type": "ui-chart", "type": "ui-chart",
"z": "tab_ui", "z": "tab_ui",
"group": "ui_grp_tr_dq", "group": "ui_grp_tr_dq",
"name": "\u0394Q \u2014 inflow \u2212 outflow", "name": "ΔQ — inflow outflow",
"label": "\u0394Q", "label": "ΔQ",
"order": 1, "order": 1,
"chartType": "line", "chartType": "line",
"interpolation": "linear", "interpolation": "linear",
@@ -4616,7 +4617,7 @@
"xAxisFormatType": "auto", "xAxisFormatType": "auto",
"xmin": "", "xmin": "",
"xmax": "", "xmax": "",
"yAxisLabel": "m\u00b3/h", "yAxisLabel": "m³/h",
"yAxisProperty": "payload", "yAxisProperty": "payload",
"yAxisPropertyType": "msg", "yAxisPropertyType": "msg",
"ymin": "", "ymin": "",
@@ -4740,7 +4741,7 @@
"xAxisFormatType": "auto", "xAxisFormatType": "auto",
"xmin": "", "xmin": "",
"xmax": "", "xmax": "",
"yAxisLabel": "m\u00b3/h", "yAxisLabel": "m³/h",
"yAxisProperty": "payload", "yAxisProperty": "payload",
"yAxisPropertyType": "msg", "yAxisPropertyType": "msg",
"ymin": "", "ymin": "",
@@ -4909,15 +4910,15 @@
{ {
"id": "tab_drivers", "id": "tab_drivers",
"type": "tab", "type": "tab",
"label": "\ud83c\udf9b\ufe0f Demo Drivers", "label": "🎛️ Demo Drivers",
"disabled": false, "disabled": false,
"info": "Inflow generator. The operator picks a SCENARIO (Constant / Sine / Diurnal / Storm) on the dashboard and sets a BASELINE m\u00b3/h value. Every second this generator emits q_in to the PS based on the active scenario + baseline.\n\nOutflow is implicit: the pumps drain the basin via MGC." "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", "id": "c_drv_title",
"type": "comment", "type": "comment",
"z": "tab_drivers", "z": "tab_drivers",
"name": "\ud83c\udf9b\ufe0f DEMO DRIVERS \u2014 operator-driven inflow generator", "name": "🎛️ DEMO DRIVERS operator-driven inflow generator",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 20, "y": 20,
@@ -5039,15 +5040,15 @@
{ {
"id": "tab_setup", "id": "tab_setup",
"type": "tab", "type": "tab",
"label": "\u2699\ufe0f Setup & Init", "label": "⚙️ Setup & Init",
"disabled": false, "disabled": false,
"info": "One-shot deploy-time injects:\n \u2022 MGC scaling = normalized + mode = optimalcontrol\n \u2022 all pumps mode = auto\n \u2022 initial inflow baseline + scenario\n\nDisable this tab in production." "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", "id": "c_setup_title",
"type": "comment", "type": "comment",
"z": "tab_setup", "z": "tab_setup",
"name": "\u2699\ufe0f SETUP & INIT \u2014 one-shot deploy-time injects", "name": "⚙️ SETUP & INIT one-shot deploy-time injects",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 20, "y": 20,
@@ -5176,7 +5177,7 @@
"id": "setup_inflow_baseline", "id": "setup_inflow_baseline",
"type": "inject", "type": "inject",
"z": "tab_setup", "z": "tab_setup",
"name": "inflow baseline = 25 m\u00b3/h (nominal)", "name": "inflow baseline = 25 m³/h (nominal)",
"props": [ "props": [
{ {
"p": "topic", "p": "topic",
@@ -5307,7 +5308,7 @@
{ {
"id": "tab_telemetry", "id": "tab_telemetry",
"type": "tab", "type": "tab",
"label": "\ud83d\udcc8 Telemetry", "label": "📈 Telemetry",
"disabled": false, "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." "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."
}, },
@@ -5315,7 +5316,7 @@
"id": "c_tlm_title", "id": "c_tlm_title",
"type": "comment", "type": "comment",
"z": "tab_telemetry", "z": "tab_telemetry",
"name": "\ud83d\udcc8 TELEMETRY \u2014 InfluxDB writer", "name": "📈 TELEMETRY InfluxDB writer",
"info": "", "info": "",
"x": 640, "x": 640,
"y": 20, "y": 20,
@@ -5357,7 +5358,7 @@
"id": "fn_tlm_to_lp", "id": "fn_tlm_to_lp",
"type": "function", "type": "function",
"z": "tab_telemetry", "z": "tab_telemetry",
"name": "\u2192 InfluxDB line protocol", "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;", "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, "outputs": 1,
"noerr": 0, "noerr": 0,
@@ -5446,7 +5447,7 @@
"type": "function", "type": "function",
"z": "tab_telemetry", "z": "tab_telemetry",
"name": "Count writes", "name": "Count writes",
"func": "const lines = Number(msg.lineCount) || 0;\nconst writes = (global.get('influx_writes') || 0) + 1;\nconst totalLines = (global.get('influx_lines') || 0) + lines;\nglobal.set('influx_writes', writes);\nglobal.set('influx_lines', totalLines);\nconst errors = global.get('influx_errors') || 0;\nif (msg.statusCode && msg.statusCode >= 400) {\n global.set('influx_errors', errors + 1);\n node.status({fill:'red', shape:'ring',\n text:`ERR ${errors+1}: ${msg.statusCode}`});\n} else {\n node.status({fill:'green', shape:'dot',\n text:`${writes} POSTs \u00b7 ${totalLines} lines (${errors} err)`});\n}\nreturn null;", "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, "outputs": 1,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",