From 26e92b54f7a6f4d0fd55be979c1f7cc2bf7e82c9 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Thu, 14 May 2026 22:31:25 +0200 Subject: [PATCH] governance + unit-self-describing demand + dashboard fixes Two governance items from the 2026-05-14 quality review: - test/_output-manifest.md enumerates every Port 0/1/2 key MGC emits, its source, type, range, and which tests cover it in populated/degraded states (per .claude/rules/output-coverage.md). - src/control/strategies.js extracts computeEqualFlowDistribution as a pure function so the equal-flow algorithm is testable without an MGC fixture. test/basic/equalFlowDistribution.basic.test.js (6 tests) covers all three demand branches and pins the legacy quirk where the default branch counts active machines but iterates priority-ordered first-N (documented in the test so the future cleanup is a deliberate change). Plus rolled-up session work that landed alongside: - set.demand is now unit-self-describing ({value, unit:'m3/h'|'l/s'|'%'|...} or bare number = %); setScaling/scaling.current removed from MGC, commands, editor (mgc.html), specificClass. - _optimalControl + equalFlowControl now compute eta = (Q*dP)/P_shaft rather than Q/P, keeping the metric in the same scale as each child's cog. - groupEfficiency.calcRelativeDistanceFromPeak returns undefined (was 1) when pumps are homogeneous (|max-min| < 1e-9). Dashboard treats undefined as '-' instead of showing a misleading 100% / 0% reading. - examples/02-Dashboard.json: auto-init inject so the dashboard populates at deploy, NCog formatter normalizes the SUM emitted by MGC by machineCountActive, Q-H fanout trims the flat-Q tail so the H axis isn't stretched to 40m by curve-envelope clamp points, num/pct treat null AND undefined as no-data (closes the +null === 0 trap). - new test/integration/dashboard-fanout.integration.test.js (17 tests), bep-distance-demand-sweep.integration.test.js (3 tests), group-bep-cascade.integration.test.js -- total suite now 108/108 green. - .gitignore: wiki/test.gif (143 MB screen recording, kept locally only). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 5 + examples/01-Basic.json | 646 +---- examples/02-Dashboard.json | 2367 ++++++++++------- mgc.html | 17 +- src/commands/handlers.js | 61 +- src/commands/index.js | 16 +- src/control/strategies.js | 251 +- src/efficiency/groupEfficiency.js | 30 +- src/groupOps/groupOperatingPoint.js | 7 + src/groupcontrol.test.js | 16 +- src/io/output.js | 14 + src/specificClass.js | 45 +- test/_output-manifest.md | 130 + test/basic/commands.basic.test.js | 44 +- .../basic/equalFlowDistribution.basic.test.js | 132 + test/basic/groupEfficiency.basic.test.js | 27 +- ...-distance-demand-sweep.integration.test.js | 125 + .../dashboard-fanout.integration.test.js | 240 ++ ...mand-cycle-walkthrough.integration.test.js | 33 +- ...stribution-power-table.integration.test.js | 4 +- .../group-bep-cascade.integration.test.js | 93 + .../idle-startup-deadlock.integration.test.js | 39 +- .../ncog-distribution.integration.test.js | 13 +- ...zer-combination-choice.integration.test.js | 1 - .../structure-examples.integration.test.js | 6 +- .../turnoff-deadlock.integration.test.js | 1 - 26 files changed, 2573 insertions(+), 1790 deletions(-) create mode 100644 .gitignore create mode 100644 test/_output-manifest.md create mode 100644 test/basic/equalFlowDistribution.basic.test.js create mode 100644 test/integration/bep-distance-demand-sweep.integration.test.js create mode 100644 test/integration/dashboard-fanout.integration.test.js create mode 100644 test/integration/group-bep-cascade.integration.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..332399c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Large local artifacts that don't belong in Git. +# wiki/test.gif: screen recordings of the dashboard are kept locally for +# reference but exceed 100 MB — use Git LFS or external storage if they +# need to be shared. +wiki/test.gif diff --git a/examples/01-Basic.json b/examples/01-Basic.json index c579f55..77490eb 100644 --- a/examples/01-Basic.json +++ b/examples/01-Basic.json @@ -1,87 +1,4 @@ [ - { - "id": "tab_mgc_basic", - "type": "tab", - "label": "MGC - Basic", - "disabled": false, - "info": "Tier 1: one machineGroupControl (MGC) coordinating three rotatingMachine pumps. Setup once-fires virtualControl + cmd.startup on all three pumps; then operator demand drives the MGC, which dispatches per-pump flow setpoints." - }, - { - "id": "grp_mgc_unit", - "type": "group", - "z": "tab_mgc_basic", - "name": "Machine Group (Unit)", - "style": { - "label": true, - "stroke": "#000000", - "fill": "#50a8d9", - "fill-opacity": "0.10" - }, - "nodes": [ - "mgc_basic_node" - ], - "x": 974, - "y": 359, - "w": 152, - "h": 122 - }, - { - "id": "grp_pump_a", - "type": "group", - "z": "tab_mgc_basic", - "name": "Pump A (Equipment)", - "style": { - "label": true, - "stroke": "#000000", - "fill": "#86bbdd", - "fill-opacity": "0.10" - }, - "nodes": [ - "rm_basic_pump_a" - ], - "x": 694, - "y": 199, - "w": 142, - "h": 82 - }, - { - "id": "grp_pump_b", - "type": "group", - "z": "tab_mgc_basic", - "name": "Pump B (Equipment)", - "style": { - "label": true, - "stroke": "#000000", - "fill": "#86bbdd", - "fill-opacity": "0.10" - }, - "nodes": [ - "rm_basic_pump_b" - ], - "x": 694, - "y": 379, - "w": 142, - "h": 82 - }, - { - "id": "grp_pump_c", - "type": "group", - "z": "tab_mgc_basic", - "name": "Pump C (Equipment)", - "style": { - "label": true, - "stroke": "#000000", - "fill": "#86bbdd", - "fill-opacity": "0.10" - }, - "nodes": [ - "rm_basic_pump_c" - ], - "x": 694, - "y": 559, - "w": 142, - "h": 82 - }, { "id": "grp_drv_mode", "type": "group", @@ -98,109 +15,11 @@ "inj_mode_optimal", "inj_mode_priority" ], - "x": 94, - "y": 99, - "w": 312, + "x": 714, + "y": 19, + "w": 292, "h": 122 }, - { - "id": "grp_drv_scaling", - "type": "group", - "z": "tab_mgc_basic", - "name": "2. Scaling", - "style": { - "stroke": "#666666", - "fill": "#ffdf7f", - "fill-opacity": "0.15", - "label": true, - "color": "#333333" - }, - "nodes": [ - "inj_scaling_norm", - "inj_scaling_abs" - ], - "x": 94, - "y": 259, - "w": 312, - "h": 122 - }, - { - "id": "grp_drv_demand", - "type": "group", - "z": "tab_mgc_basic", - "name": "3. Operator demand (% of group capacity)", - "style": { - "stroke": "#666666", - "fill": "#ffdf7f", - "fill-opacity": "0.15", - "label": true, - "color": "#333333" - }, - "nodes": [ - "inj_demand_25", - "inj_demand_50", - "inj_demand_75", - "inj_demand_100", - "inj_demand_0" - ], - "x": 94, - "y": 419, - "w": 312, - "h": 222 - }, - { - "id": "grp_setup", - "type": "group", - "z": "tab_mgc_basic", - "name": "Setup — once on deploy", - "style": { - "stroke": "#666666", - "fill": "#dddddd", - "fill-opacity": "0.20", - "label": true, - "color": "#333333" - }, - "nodes": [ - "inj_setup_start", - "fn_setup_fanout" - ], - "x": 94, - "y": 679, - "w": 532, - "h": 82 - }, - { - "id": "grp_dbg", - "type": "group", - "z": "tab_mgc_basic", - "name": "Debug outputs (sidebar)", - "style": { - "stroke": "#666666", - "fill": "#d1d1d1", - "fill-opacity": "0.2", - "label": true, - "color": "#333333" - }, - "nodes": [ - "dbg_port0", - "dbg_port1", - "dbg_port2" - ], - "x": 1234, - "y": 339, - "w": 232, - "h": 202 - }, - { - "id": "cmt_title", - "type": "comment", - "z": "tab_mgc_basic", - "name": "MGC — Basic (Tier 1)", - "info": "One machineGroupControl coordinating three rotatingMachine pumps.\n\nDefaults: mode=optimalControl, scaling=normalized.\n\nSETUP — fires once on deploy\n- Switches all 3 pumps to virtualControl mode\n- Sends cmd.startup to all 3 pumps\nPumps register with the MGC automatically via Port 2 (child.register).\n\nHOW TO USE\n1. Deploy — the Setup group auto-runs after ~1.5 s, putting pumps in virtual + started.\n2. Click any \"set.demand = N %\" — MGC dispatches per-pump flow setpoints by BEP-gravitation (default) or priority list, depending on the mode.\n3. Switch scaling to `absolute` to interpret set.demand as m³/h instead of %.\n4. Switch mode to `priorityControl` for sequential equal-flow control; `optimalControl` (default) picks the best combination automatically.\n5. Send `set.demand = 0` to drain the group (turnOffAllMachines).\n\nPORTS (MGC)\n- Port 0: process output (mode, scaling, totals, dist-from-peak)\n- Port 1: InfluxDB-shaped payload\n- Port 2: parent-registration handshake (when wired into a pumpingStation)", - "x": 1100, - "y": 280, - "wires": [] - }, { "id": "inj_mode_optimal", "type": "inject", @@ -208,16 +27,23 @@ "g": "grp_drv_mode", "name": "set.mode = optimalControl", "props": [ - { "p": "topic", "vt": "str" }, - { "p": "payload", "v": "optimalControl", "vt": "str" } + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload", + "v": "optimalControl", + "vt": "str" + } ], "repeat": "", "crontab": "", "once": false, "onceDelay": "", "topic": "set.mode", - "x": 260, - "y": 140, + "x": 870, + "y": 60, "wires": [ [ "mgc_basic_node" @@ -231,447 +57,27 @@ "g": "grp_drv_mode", "name": "set.mode = priorityControl", "props": [ - { "p": "topic", "vt": "str" }, - { "p": "payload", "v": "priorityControl", "vt": "str" } + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload", + "v": "priorityControl", + "vt": "str" + } ], "repeat": "", "crontab": "", "once": false, "onceDelay": "", "topic": "set.mode", - "x": 260, - "y": 180, + "x": 870, + "y": 100, "wires": [ [ "mgc_basic_node" ] ] - }, - { - "id": "inj_scaling_norm", - "type": "inject", - "z": "tab_mgc_basic", - "g": "grp_drv_scaling", - "name": "set.scaling = normalized", - "props": [ - { "p": "topic", "vt": "str" }, - { "p": "payload", "v": "normalized", "vt": "str" } - ], - "repeat": "", - "crontab": "", - "once": false, - "onceDelay": "", - "topic": "set.scaling", - "x": 260, - "y": 300, - "wires": [ - [ - "mgc_basic_node" - ] - ] - }, - { - "id": "inj_scaling_abs", - "type": "inject", - "z": "tab_mgc_basic", - "g": "grp_drv_scaling", - "name": "set.scaling = absolute", - "props": [ - { "p": "topic", "vt": "str" }, - { "p": "payload", "v": "absolute", "vt": "str" } - ], - "repeat": "", - "crontab": "", - "once": false, - "onceDelay": "", - "topic": "set.scaling", - "x": 260, - "y": 340, - "wires": [ - [ - "mgc_basic_node" - ] - ] - }, - { - "id": "inj_demand_0", - "type": "inject", - "z": "tab_mgc_basic", - "g": "grp_drv_demand", - "name": "set.demand = 0 (stop)", - "props": [ - { "p": "topic", "vt": "str" }, - { "p": "payload", "v": "0", "vt": "num" } - ], - "repeat": "", - "crontab": "", - "once": false, - "onceDelay": "", - "topic": "set.demand", - "x": 260, - "y": 460, - "wires": [ - [ - "mgc_basic_node" - ] - ] - }, - { - "id": "inj_demand_25", - "type": "inject", - "z": "tab_mgc_basic", - "g": "grp_drv_demand", - "name": "set.demand = 25 %", - "props": [ - { "p": "topic", "vt": "str" }, - { "p": "payload", "v": "25", "vt": "num" } - ], - "repeat": "", - "crontab": "", - "once": false, - "onceDelay": "", - "topic": "set.demand", - "x": 260, - "y": 500, - "wires": [ - [ - "mgc_basic_node" - ] - ] - }, - { - "id": "inj_demand_50", - "type": "inject", - "z": "tab_mgc_basic", - "g": "grp_drv_demand", - "name": "set.demand = 50 %", - "props": [ - { "p": "topic", "vt": "str" }, - { "p": "payload", "v": "50", "vt": "num" } - ], - "repeat": "", - "crontab": "", - "once": false, - "onceDelay": "", - "topic": "set.demand", - "x": 260, - "y": 540, - "wires": [ - [ - "mgc_basic_node" - ] - ] - }, - { - "id": "inj_demand_75", - "type": "inject", - "z": "tab_mgc_basic", - "g": "grp_drv_demand", - "name": "set.demand = 75 %", - "props": [ - { "p": "topic", "vt": "str" }, - { "p": "payload", "v": "75", "vt": "num" } - ], - "repeat": "", - "crontab": "", - "once": false, - "onceDelay": "", - "topic": "set.demand", - "x": 260, - "y": 580, - "wires": [ - [ - "mgc_basic_node" - ] - ] - }, - { - "id": "inj_demand_100", - "type": "inject", - "z": "tab_mgc_basic", - "g": "grp_drv_demand", - "name": "set.demand = 100 %", - "props": [ - { "p": "topic", "vt": "str" }, - { "p": "payload", "v": "100", "vt": "num" } - ], - "repeat": "", - "crontab": "", - "once": false, - "onceDelay": "", - "topic": "set.demand", - "x": 260, - "y": 620, - "wires": [ - [ - "mgc_basic_node" - ] - ] - }, - { - "id": "inj_setup_start", - "type": "inject", - "z": "tab_mgc_basic", - "g": "grp_setup", - "name": "Auto-start pumps", - "props": [ - { "p": "payload", "v": "go", "vt": "str" } - ], - "repeat": "", - "crontab": "", - "once": true, - "onceDelay": "1.5", - "topic": "", - "x": 220, - "y": 720, - "wires": [ - [ - "fn_setup_fanout" - ] - ] - }, - { - "id": "fn_setup_fanout", - "type": "function", - "z": "tab_mgc_basic", - "g": "grp_setup", - "name": "fan-out: virtualControl + startup → A/B/C", - "func": "// Fire two messages per pump: set.mode = virtualControl, then cmd.startup.\n// Each output is a message array — Node-RED dispatches them sequentially.\nconst setMode = { topic: 'set.mode', payload: 'virtualControl' };\nconst startup = { topic: 'cmd.startup', payload: {} };\nreturn [\n [setMode, startup], // → Pump A\n [setMode, startup], // → Pump B\n [setMode, startup], // → Pump C\n];\n", - "outputs": 3, - "timeout": 0, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 480, - "y": 720, - "wires": [ - [ - "rm_basic_pump_a" - ], - [ - "rm_basic_pump_b" - ], - [ - "rm_basic_pump_c" - ] - ] - }, - { - "id": "rm_basic_pump_a", - "type": "rotatingMachine", - "z": "tab_mgc_basic", - "g": "grp_pump_a", - "name": "Pump A", - "speed": "1", - "startup": "2", - "warmup": "1", - "shutdown": "2", - "cooldown": "1", - "movementMode": "staticspeed", - "machineCurve": "", - "uuid": "mgc-basic-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": "info", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": "", - "distanceUnit": "m", - "distanceDescription": "", - "x": 760, - "y": 240, - "wires": [ - [], - [], - [ - "mgc_basic_node" - ] - ] - }, - { - "id": "rm_basic_pump_b", - "type": "rotatingMachine", - "z": "tab_mgc_basic", - "g": "grp_pump_b", - "name": "Pump B", - "speed": "1", - "startup": "2", - "warmup": "1", - "shutdown": "2", - "cooldown": "1", - "movementMode": "staticspeed", - "machineCurve": "", - "uuid": "mgc-basic-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": "info", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": "", - "distanceUnit": "m", - "distanceDescription": "", - "x": 760, - "y": 420, - "wires": [ - [], - [], - [ - "mgc_basic_node" - ] - ] - }, - { - "id": "rm_basic_pump_c", - "type": "rotatingMachine", - "z": "tab_mgc_basic", - "g": "grp_pump_c", - "name": "Pump C", - "speed": "1", - "startup": "2", - "warmup": "1", - "shutdown": "2", - "cooldown": "1", - "movementMode": "staticspeed", - "machineCurve": "", - "uuid": "mgc-basic-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": "info", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": "", - "distanceUnit": "m", - "distanceDescription": "", - "x": 760, - "y": 600, - "wires": [ - [], - [], - [ - "mgc_basic_node" - ] - ] - }, - { - "id": "mgc_basic_node", - "type": "machineGroupControl", - "z": "tab_mgc_basic", - "g": "grp_mgc_unit", - "name": "Machine Group", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "mode": "optimalControl", - "scaling": "normalized", - "uuid": "", - "supplier": "", - "category": "", - "assetType": "", - "model": "", - "unit": "", - "enableLog": false, - "logLevel": "info", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": "", - "distanceUnit": "m", - "distanceDescription": "", - "x": 1050, - "y": 420, - "wires": [ - [ - "dbg_port0" - ], - [ - "dbg_port1" - ], - [ - "dbg_port2" - ] - ] - }, - { - "id": "dbg_port0", - "type": "debug", - "z": "tab_mgc_basic", - "g": "grp_dbg", - "name": "Port 0: Process", - "active": true, - "tosidebar": true, - "console": false, - "tostatus": false, - "complete": "payload", - "targetType": "msg", - "x": 1340, - "y": 380, - "wires": [] - }, - { - "id": "dbg_port1", - "type": "debug", - "z": "tab_mgc_basic", - "g": "grp_dbg", - "name": "Port 1: InfluxDB", - "active": true, - "tosidebar": true, - "console": false, - "tostatus": false, - "complete": "true", - "targetType": "full", - "x": 1340, - "y": 440, - "wires": [] - }, - { - "id": "dbg_port2", - "type": "debug", - "z": "tab_mgc_basic", - "g": "grp_dbg", - "name": "Port 2: Parent reg", - "active": true, - "tosidebar": true, - "console": false, - "tostatus": false, - "complete": "true", - "targetType": "full", - "x": 1350, - "y": 500, - "wires": [] - }, - { - "id": "mgc_global_cfg", - "type": "global-config", - "env": [], - "modules": { - "EVOLV": "1.0.29" - } } -] +] \ No newline at end of file diff --git a/examples/02-Dashboard.json b/examples/02-Dashboard.json index 1b04f22..054f02e 100644 --- a/examples/02-Dashboard.json +++ b/examples/02-Dashboard.json @@ -4,7 +4,7 @@ "type": "tab", "label": "MGC - Dashboard", "disabled": false, - "info": "Tier 2: one machineGroupControl coordinating three rotatingMachine pumps, driven by a FlowFuse dashboard. Mode + scaling buttons, demand slider, live status rows, three trend charts, and a raw-output table." + "info": "Tier 2: dashboard-driven MGC with three pumps. Demand is unit-aware on each set.demand message (bare number = %; {value, unit} for absolute flow; negative for stop)." }, { "id": "grp_mgc_unit", @@ -20,10 +20,10 @@ "nodes": [ "mgc_dash_node" ], - "x": 974, - "y": 379, - "w": 152, - "h": 122 + "x": 994, + "y": 351.5, + "w": 212, + "h": 97 }, { "id": "grp_pump_a", @@ -39,10 +39,10 @@ "nodes": [ "rm_dash_pump_a" ], - "x": 694, - "y": 219, - "w": 142, - "h": 82 + "x": 714, + "y": 451.5, + "w": 272, + "h": 97 }, { "id": "grp_pump_b", @@ -58,10 +58,10 @@ "nodes": [ "rm_dash_pump_b" ], - "x": 694, - "y": 399, - "w": 142, - "h": 82 + "x": 714, + "y": 571.5, + "w": 272, + "h": 97 }, { "id": "grp_pump_c", @@ -77,10 +77,10 @@ "nodes": [ "rm_dash_pump_c" ], - "x": 694, - "y": 579, - "w": 142, - "h": 82 + "x": 714, + "y": 691.5, + "w": 272, + "h": 97 }, { "id": "grp_drv_mode", @@ -98,30 +98,9 @@ "ui_btn_mode_optimal", "ui_btn_mode_priority" ], - "x": 94, - "y": 99, - "w": 312, - "h": 122 - }, - { - "id": "grp_drv_scaling", - "type": "group", - "z": "tab_mgc_dash", - "name": "2. Scaling", - "style": { - "stroke": "#666666", - "fill": "#ffdf7f", - "fill-opacity": "0.15", - "label": true, - "color": "#333333" - }, - "nodes": [ - "ui_btn_scaling_norm", - "ui_btn_scaling_abs" - ], - "x": 94, - "y": 259, - "w": 312, + "x": 754, + "y": 19, + "w": 252, "h": 122 }, { @@ -138,18 +117,22 @@ }, "nodes": [ "ui_slider_demand", - "ui_btn_demand_stop" + "ui_btn_demand_min", + "ui_btn_demand_stop", + "ui_btn_demand_abs_200", + "ui_btn_demand_abs_400", + "ui_btn_demand_abs_lps" ], - "x": 94, - "y": 419, + "x": 354, + "y": 59, "w": 312, - "h": 122 + "h": 282 }, { "id": "grp_setup", "type": "group", "z": "tab_mgc_dash", - "name": "Setup — once on deploy + manual re-init", + "name": "Setup \u2014 once on deploy + manual re-init", "style": { "stroke": "#666666", "fill": "#dddddd", @@ -158,14 +141,14 @@ "color": "#333333" }, "nodes": [ - "inj_setup_start", + "inj_setup_once", "ui_btn_setup_init", "fn_setup_fanout" ], - "x": 94, - "y": 579, - "w": 532, - "h": 122 + "x": 114, + "y": 951.5, + "w": 752, + "h": 97 }, { "id": "grp_status_panel", @@ -182,7 +165,6 @@ "nodes": [ "fn_status_split", "ui_txt_mode", - "ui_txt_scaling", "ui_txt_flow", "ui_txt_power", "ui_txt_capacity", @@ -191,98 +173,1173 @@ "ui_chart_flow", "ui_chart_power", "ui_chart_bep", - "ui_tpl_raw" + "ui_tpl_raw", + "ui_chart_per_pump_flow", + "fn_chart_pump_a", + "fn_chart_pump_b", + "fn_chart_pump_c", + "fn_chart_total" ], "x": 1234, - "y": 79, - "w": 712, - "h": 642 + "y": 59, + "w": 682, + "h": 602 }, { - "id": "grp_dbg", + "id": "grp_drv_pressure", "type": "group", "z": "tab_mgc_dash", - "name": "Debug outputs (sidebar)", + "name": "4. Pressure (manual sweep)", "style": { "stroke": "#666666", - "fill": "#d1d1d1", - "fill-opacity": "0.2", + "fill": "#dddddd", + "fill-opacity": "0.20", "label": true, "color": "#333333" }, "nodes": [ - "dbg_port0", - "dbg_port1", - "dbg_port2" + "ui_slider_pressure", + "fn_pressure_wrap", + "fn_pressure_fanout" ], - "x": 1234, - "y": 759, - "w": 252, - "h": 202 + "x": 34, + "y": 831.5, + "w": 842, + "h": 97 }, { "id": "cmt_title", "type": "comment", "z": "tab_mgc_dash", - "name": "MGC — Dashboard (Tier 2)", - "info": "Same command surface as the Basic flow, driven by a FlowFuse dashboard.\n\nOpen /dashboard/mgc-basic after deploy.\n\nCONTROLS panel\n- Mode: optimalControl / priorityControl → set.mode\n- Scaling: normalized (0–100 %) / absolute (m³/h) → set.scaling\n- Demand slider 0–100 → set.demand (interpretation depends on scaling)\n- Stop button (set.demand = 0) and Initialize pumps button\n\nSTATUS panel\n- Mode / Scaling / Total flow / Total power / Capacity (Qmin–Qmax) / Active machines / BEP distance (rel %)\n\nTRENDS panel\n- Flow (m³/h) — predicted aggregate vs capacity\n- Power (kW)\n- BEP distance (rel %)\n\nRAW OUTPUT panel\n- Full key/value dump of the latest MGC Port 0 cache (sorted).\n\nPORTS (preserved for inspection)\n- Port 0: process output (changed fields only)\n- Port 1: InfluxDB-shaped {measurement, fields, tags, timestamp}\n- Port 2: parent registration (when wired into a pumpingStation)", - "x": 1100, - "y": 280, + "name": "MGC \u2014 Dashboard (Tier 2)", + "info": "Same command surface as the Basic flow, driven by a FlowFuse dashboard.\n\nOpen /dashboard/mgc-basic after deploy.\n\nDEMAND SEMANTICS (set.demand)\n- bare number \u2192 % of group capacity (0\u2013100)\n- { value, unit:'%' } \u2192 %, explicit\n- { value, unit:'m3/h' | 'l/s' | 'm3/s' | ... } \u2192 absolute flow\n- negative value \u2192 stop all pumps\nThe handler resolves the unit and converts to canonical m\u00b3/s before dispatch.\n\nSETUP \u2014 fires once on deploy and on \"Initialize pumps\"\n- Switches all 3 pumps to auto mode (so MGC's parent-sourced commands route through).\n- Simulates a nominal pressure operating point per pump: downstream = 1100 mbar,\n upstream = 0 mbar \u2192 1100 mbar differential.\n- Sends cmd.startup to all 3 pumps.\n\nCONTROLS panel\n- Mode: optimalControl / priorityControl \u2192 set.mode\n- Demand slider 0\u2013100 \u2192 set.demand (interpreted as % via bare-number default).\n- Min flow button (set.demand = 0) \u2014 sends the minimum-control sentinel.\n- Stop all button (set.demand = -1) \u2014 turns every pump off.\n- Absolute demand quick-buttons: 200 m\u00b3/h, 400 m\u00b3/h, 100 l/s \u2014 each sends\n { value, unit } and exercises the unit-aware path (incl. l/s \u2192 m\u00b3/s conversion).\n- Pressure slider 600\u20131500 mbar \u2014 live downstream head sweep.\n- Initialize pumps button \u2014 re-runs the once-on-deploy setup.\n\nSTATUS panel\n- Mode / Total flow / Total power / Capacity (Qmin\u2013Qmax) / Active machines / BEP distance (rel %)\n\nTRENDS panel\n- Flow (m\u00b3/h) \u2014 predicted aggregate vs capacity\n- Power (kW)\n- BEP distance (rel %)\n\nRAW OUTPUT panel\n- Full key/value dump of the latest MGC Port 0 cache (sorted).\n\nPORTS (preserved for inspection)\n- Port 0: process output (changed fields only)\n- Port 1: InfluxDB-shaped {measurement, fields, tags, timestamp}\n- Port 2: parent registration (when wired into a pumpingStation)", + "x": 1060, + "y": 240, "wires": [] }, { - "id": "ui_base_mgc", - "type": "ui-base", - "name": "EVOLV Demo", - "path": "/dashboard", - "appIcon": "", - "includeClientData": true, - "acceptsClientConfig": [ - "ui-notification", - "ui-control" + "id": "ui_btn_mode_optimal", + "type": "ui-button", + "z": "tab_mgc_dash", + "g": "grp_drv_mode", + "group": "ui_group_ctrl", + "name": "Mode: optimalControl", + "label": "Mode: optimalControl", + "order": 1, + "width": "3", + "height": "1", + "emulateClick": false, + "tooltip": "Best-combination optimiser (BEP-Gravitation / NCog)", + "color": "", + "bgcolor": "", + "icon": "auto_fix_high", + "payload": "optimalControl", + "payloadType": "str", + "topic": "set.mode", + "topicType": "str", + "x": 880, + "y": 60, + "wires": [ + [ + "mgc_dash_node" + ] + ] + }, + { + "id": "ui_btn_mode_priority", + "type": "ui-button", + "z": "tab_mgc_dash", + "g": "grp_drv_mode", + "group": "ui_group_ctrl", + "name": "Mode: priorityControl", + "label": "Mode: priorityControl", + "order": 2, + "width": "3", + "height": "1", + "emulateClick": false, + "tooltip": "Sequential equal-flow control by priority list", + "color": "", + "bgcolor": "", + "icon": "format_list_numbered", + "payload": "priorityControl", + "payloadType": "str", + "topic": "set.mode", + "topicType": "str", + "x": 880, + "y": 100, + "wires": [ + [ + "mgc_dash_node" + ] + ] + }, + { + "id": "ui_slider_demand", + "type": "ui-slider", + "z": "tab_mgc_dash", + "g": "grp_drv_demand", + "group": "ui_group_ctrl", + "name": "Demand", + "label": "Demand", + "order": 3, + "width": "6", + "height": "1", + "passthru": true, + "outs": "all", + "topic": "set.demand", + "topicType": "str", + "thumbLabel": "always", + "showTicks": false, + "min": 0, + "max": 100, + "step": 1, + "className": "", + "x": 580, + "y": 100, + "wires": [ + [ + "mgc_dash_node" + ] ], - "showPathInSidebar": false, - "headerContent": "page", - "navigationStyle": "default", - "titleBarStyle": "default" + "icon": "speed" }, { - "id": "ui_theme_mgc", - "type": "ui-theme", - "name": "EVOLV Basic Theme", - "colors": { - "surface": "#ffffff", - "primary": "#50a8d9", - "bgPage": "#eeeeee", - "groupBg": "#ffffff", - "groupOutline": "#cccccc" - }, - "sizes": { - "density": "default", - "pagePadding": "14px", - "groupGap": "14px", - "groupBorderRadius": "6px", - "widgetGap": "12px" - } + "id": "ui_btn_demand_min", + "type": "ui-button", + "z": "tab_mgc_dash", + "g": "grp_drv_demand", + "group": "ui_group_ctrl", + "name": "Demand = 0 (min, normalized)", + "label": "Min flow (demand = 0)", + "order": 4, + "width": "6", + "height": "1", + "emulateClick": false, + "tooltip": "Send set.demand = 0 \u2014 normalized mode interpolates to the group's flow floor (lightest valid combination, usually one pump at its min ctrl%).", + "color": "#ffffff", + "bgcolor": "#666666", + "icon": "speed", + "payload": "0", + "payloadType": "num", + "topic": "set.demand", + "topicType": "str", + "x": 510, + "y": 140, + "wires": [ + [ + "mgc_dash_node" + ] + ] }, { - "id": "ui_page_mgc", - "type": "ui-page", - "name": "MGC Basic", - "ui": "ui_base_mgc", - "path": "/mgc-basic", - "icon": "settings-input-component", - "layout": "grid", - "theme": "ui_theme_mgc", - "breakpoints": [ + "id": "ui_btn_demand_stop", + "type": "ui-button", + "z": "tab_mgc_dash", + "g": "grp_drv_demand", + "group": "ui_group_ctrl", + "name": "Demand = -1 (stop all)", + "label": "Stop all (demand = -1)", + "order": 5, + "width": "6", + "height": "1", + "emulateClick": false, + "tooltip": "Send set.demand = -1 \u2014 MGC calls turnOffAllMachines and parks any pending demand.", + "color": "#ffffff", + "bgcolor": "#cc3333", + "icon": "stop", + "payload": "-1", + "payloadType": "num", + "topic": "set.demand", + "topicType": "str", + "x": 540, + "y": 180, + "wires": [ + [ + "mgc_dash_node" + ] + ] + }, + { + "id": "inj_setup_once", + "type": "inject", + "z": "tab_mgc_dash", + "g": "grp_setup", + "name": "auto-init on deploy", + "props": [ + { "p": "payload" }, + { "p": "topic", "vt": "str" } + ], + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": 1, + "topic": "", + "payload": "go", + "payloadType": "str", + "x": 140, + "y": 1000, + "wires": [ + [ + "fn_setup_fanout" + ] + ] + }, + { + "id": "ui_btn_setup_init", + "type": "ui-button", + "z": "tab_mgc_dash", + "g": "grp_setup", + "group": "ui_group_ctrl", + "name": "Initialize pumps", + "label": "Initialize pumps (auto + pressure + startup)", + "order": 10, + "width": "12", + "height": "1", + "emulateClick": false, + "tooltip": "Re-runs the once-on-deploy setup: auto mode + nominal pressure (1100 mbar diff) + cmd.startup on all three pumps", + "color": "", + "bgcolor": "", + "icon": "play_arrow", + "payload": "go", + "payloadType": "str", + "topic": "", + "topicType": "str", + "x": 400, + "y": 1000, + "wires": [ + [ + "fn_setup_fanout" + ] + ] + }, + { + "id": "fn_setup_fanout", + "type": "function", + "z": "tab_mgc_dash", + "g": "grp_setup", + "name": "fan-out: auto + pressure + startup \u2192 A/B/C", + "func": "// Setup messages per pump: set.mode = auto, simulate nominal pressure\n// operating point, then cmd.startup.\n//\n// Pumps must be in 'auto' so the MGC's parent-sourced flow setpoints are\n// accepted. 'auto' allowedSources = [parent, GUI, fysical] so GUI buttons\n// continue to work.\n//\n// Nominal pressure: downstream = 1100 mbar, upstream = 0 mbar \u2192 1100 mbar\n// differential (hidrostal-H05K-S03R curve nominal). Without this, MGC's\n// equalize() short-circuits, status badge sticks at 'pressure not\n// initialized', and the dashboard reports zero flow/power. Use the\n// Pressure slider in the Controls panel to sweep head live.\nconst setMode = { topic: 'set.mode', payload: 'auto' };\nconst pDown = { topic: 'data.simulate-measurement', payload: { type: 'pressure', position: 'downstream', value: 1100, unit: 'mbar' } };\nconst pUp = { topic: 'data.simulate-measurement', payload: { type: 'pressure', position: 'upstream', value: 0, unit: 'mbar' } };\nconst startup = { topic: 'cmd.startup', payload: {} };\nreturn [\n [setMode, pUp, pDown, startup], // \u2192 Pump A\n [setMode, pUp, pDown, startup], // \u2192 Pump B\n [setMode, pUp, pDown, startup], // \u2192 Pump C\n];\n", + "outputs": 3, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 670, + "y": 1000, + "wires": [ + [ + "rm_dash_pump_a" + ], + [ + "rm_dash_pump_b" + ], + [ + "rm_dash_pump_c" + ] + ] + }, + { + "id": "fn_status_split", + "type": "function", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "name": "fan-out Port 0 (status + charts + raw)", + "func": "// MGC Port 0 emits delta-only. Cache last-known so deltas never blank a\n// row, then fan out one msg per ui-text / ui-chart / ui-template slot.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\n// num/pct treat null AND undefined as \"no data\" (display em-dash). Without\n// the explicit null check, `+null === 0` would silently render as \"0.0 %\" \u2014\n// the bug class we hit twice today (\u03b7-null and Ncog/bepRel degenerate).\nconst num = (v, dp, unit) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\nconst pct = (v, dp) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return (n * 100).toFixed(dp) + ' %';\n};\n\nconst mode = cache.mode || '\u2014';\nconst flow = cache['atEquipment_predicted_flow'];\nconst power = cache['atEquipment_predicted_power'];\nconst qMin = cache.flowCapacityMin;\nconst qMax = cache.flowCapacityMax;\nconst nAct = cache.machineCountActive;\nconst nTot = cache.machineCount;\nconst bepRel = cache.relDistFromPeak; // 0..1\nconst bepAbs = cache.absDistFromPeak; // \u03b7 points (dimensionless)\nconst eta = cache['atEquipment_predicted_efficiency']; // 0..1\n// MGC emits atEquipment_predicted_Ncog as the SUM of per-pump NCog values from\n// the BEP-Gravitation optimizer (bepGravitation.js:162 totalCog). Range is\n// 0..N where N=active pumps, NOT 0..1. Normalize here so the dashboard shows\n// a per-pump average position on the BEP envelope.\nconst ncogSum = +cache['atEquipment_predicted_Ncog'];\n// undefined (not null) for the degraded case — pct() does `+v` and `+null === 0`,\n// which would silently display \"0.0 %\" instead of the em-dash that means \"no data\".\nconst ncog = (Number.isFinite(ncogSum) && Number.isFinite(+nAct) && +nAct > 0)\n ? ncogSum / +nAct\n : undefined;\n// Peak \u03b7 isn't emitted directly; derive: peak = eta + absDistFromPeak.\nconst etaPeak = (Number.isFinite(+eta) && Number.isFinite(+bepAbs)) ? (+eta + +bepAbs) : null;\n\nconst chart = (topic, v, scale = 1) =>\n Number.isFinite(+v) ? { topic, payload: +v * scale } : null;\n\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '\u2014';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 0-5: original status texts (mode, flow, power, capacity, machines, BEP rel%)\n { payload: mode },\n { payload: num(flow, 1, 'm\u00b3/h') },\n { payload: num(power, 2, 'kW') },\n { payload: Number.isFinite(+qMax) ? (num(qMin, 1) + ' \u2013 ' + num(qMax, 1, 'm\u00b3/h')) : '\u2014' },\n { payload: (Number.isFinite(+nAct) && Number.isFinite(+nTot)) ? (nAct + ' / ' + nTot) : '\u2014' },\n { payload: pct(bepRel, 1) }, // BEP rel% \u2014 was buggy: now \u00d7100 then format\n\n // 6-9: new status texts (\u03b7, \u03b7 peak, BEP abs gap, NCog)\n { payload: pct(eta, 1) }, // \u03b7 (hydraulic)\n { payload: pct(etaPeak, 1) }, // \u03b7 peak\n { payload: num(bepAbs, 3) }, // BEP abs gap (\u03b7 points)\n { payload: pct(ncog, 1) }, // NCog (BEP flow position)\n\n // 10-13: charts (flow predicted, capacity max, power, BEP rel% scaled to 0..100)\n chart('Flow', flow),\n chart('Capacity', qMax),\n chart('Power', power),\n chart('BEP rel %', bepRel, 100), // chart also fixed: scale 0..1 \u2192 0..100\n\n // 14: efficiency chart \u2014 emit only when eta is finite (null msg = no output,\n // which avoids ui-chart crashing on { payload: null })\n chart('\u03b7 (%)', eta, 100),\n\n // 15: raw rows for the ui-template\n { payload: rawRows },\n { payload: msg.payload }, // 16: raw passthrough for Q-H chart\n];\n", + "outputs": 17, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1410, + "y": 180, + "wires": [ + [ + "ui_txt_mode" + ], + [ + "ui_txt_flow" + ], + [ + "ui_txt_power" + ], + [ + "ui_txt_capacity" + ], + [ + "ui_txt_machines" + ], + [ + "ui_txt_bep" + ], + [ + "ui_txt_eta" + ], + [ + "ui_txt_eta_peak" + ], + [ + "ui_txt_bep_abs" + ], + [ + "ui_txt_ncog" + ], + [ + "ui_chart_flow" + ], + [ + "ui_chart_flow" + ], + [ + "ui_chart_power" + ], + [ + "ui_chart_bep" + ], + [ + "ui_chart_eta" + ], + [ + "ui_tpl_raw" + ], + [ + "fn_qh_point" + ] + ] + }, + { + "id": "ui_txt_mode", + "type": "ui-text", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_status", + "order": 1, + "width": "6", + "height": "1", + "name": "Mode", + "label": "Mode", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#1F4E79", + "x": 1700, + "y": 100, + "wires": [] + }, + { + "id": "ui_txt_flow", + "type": "ui-text", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_status", + "order": 3, + "width": "6", + "height": "1", + "name": "Total flow", + "label": "Total flow", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#1F4E79", + "x": 1720, + "y": 140, + "wires": [] + }, + { + "id": "ui_txt_power", + "type": "ui-text", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_status", + "order": 4, + "width": "6", + "height": "1", + "name": "Total power", + "label": "Total power", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#1F4E79", + "x": 1730, + "y": 180, + "wires": [] + }, + { + "id": "ui_txt_capacity", + "type": "ui-text", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_status", + "order": 5, + "width": "6", + "height": "1", + "name": "Capacity (Qmin\u2013Qmax)", + "label": "Capacity", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#1F4E79", + "x": 1770, + "y": 220, + "wires": [] + }, + { + "id": "ui_txt_machines", + "type": "ui-text", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_status", + "order": 6, + "width": "6", + "height": "1", + "name": "Machines (active / total)", + "label": "Machines", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#1F4E79", + "x": 1780, + "y": 260, + "wires": [] + }, + { + "id": "ui_txt_bep", + "type": "ui-text", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_status", + "order": 7, + "width": "6", + "height": "1", + "name": "BEP distance (rel)", + "label": "BEP distance", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#7D3C98", + "x": 1750, + "y": 300, + "wires": [] + }, + { + "id": "ui_chart_flow", + "type": "ui-chart", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_trends", + "name": "Flow vs capacity", + "label": "Flow (m\u00b3/h) \u2014 predicted vs capacity", + "order": 1, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisType": "time", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "xmin": "", + "xmax": "", + "yAxisLabel": "m\u00b3/h", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "showLegend": true, + "removeOlder": "15", + "removeOlderUnit": "60", + "removeOlderPoints": "", + "colors": [ + "#0095FF", + "#cccccc", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "width": 6, + "height": 4, + "className": "", + "interpolation": "linear", + "x": 1750, + "y": 380, + "wires": [ + [] + ] + }, + { + "id": "ui_chart_power", + "type": "ui-chart", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_trends", + "name": "Power", + "label": "Power (kW)", + "order": 3, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisType": "time", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "xmin": "", + "xmax": "", + "yAxisLabel": "kW", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "showLegend": false, + "removeOlder": "15", + "removeOlderUnit": "60", + "removeOlderPoints": "", + "colors": [ + "#2CA02C", + "#FF0000", + "#FF7F0E", + "#0095FF", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "width": 6, + "height": 4, + "className": "", + "interpolation": "linear", + "x": 1720, + "y": 420, + "wires": [ + [] + ] + }, + { + "id": "ui_chart_bep", + "type": "ui-chart", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_trends", + "name": "BEP distance (rel %)", + "label": "BEP distance (rel %)", + "order": 4, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisType": "time", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "xmin": "", + "xmax": "", + "yAxisLabel": "%", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "showLegend": false, + "removeOlder": "15", + "removeOlderUnit": "60", + "removeOlderPoints": "", + "colors": [ + "#A347E1", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#0095FF", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "width": 6, + "height": 4, + "className": "", + "interpolation": "linear", + "x": 1770, + "y": 460, + "wires": [ + [] + ] + }, + { + "id": "ui_tpl_raw", + "type": "ui-template", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_raw", + "name": "Raw output table", + "order": 1, + "width": "12", + "height": "8", + "head": "", + "format": "\n\n\n", + "storeOutMessages": true, + "passthru": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 1760, + "y": 540, + "wires": [ + [] + ] + }, + { + "id": "rm_dash_pump_a", + "type": "rotatingMachine", + "z": "tab_mgc_dash", + "g": "grp_pump_a", + "name": "Pump A", + "speed": 1, + "startup": 0, + "warmup": 0, + "shutdown": 0, + "cooldown": 0, + "movementMode": "staticspeed", + "machineCurve": "", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "mgc-dash-pump-a", + "assetTagNumber": "", + "model": "hidrostal-H05K-S03R", + "unit": "m3/h", + "curvePressureUnit": "mbar", + "curveFlowUnit": "m3/h", + "curvePowerUnit": "kW", + "curveControlUnit": "%", + "enableLog": false, + "logLevel": "error", + "positionVsParent": "atEquipment", + "positionIcon": "\u22a5", + "hasDistance": false, + "distance": "", + "x": 850, + "y": 500, + "wires": [ + [ + "fn_chart_pump_a", + "fn_qh_inject_id" + ], + [], + [ + "mgc_dash_node" + ] + ] + }, + { + "id": "rm_dash_pump_b", + "type": "rotatingMachine", + "z": "tab_mgc_dash", + "g": "grp_pump_b", + "name": "Pump B", + "speed": 1, + "startup": 0, + "warmup": 0, + "shutdown": 0, + "cooldown": 0, + "movementMode": "staticspeed", + "machineCurve": "", + "uuid": "mgc-dash-pump-b", + "assetTagNumber": "", + "model": "hidrostal-H05K-S03R", + "unit": "m3/h", + "curvePressureUnit": "mbar", + "curveFlowUnit": "m3/h", + "curvePowerUnit": "kW", + "curveControlUnit": "%", + "enableLog": false, + "logLevel": "error", + "positionVsParent": "atEquipment", + "positionIcon": "\u22a5", + "hasDistance": false, + "distance": "", + "x": 850, + "y": 620, + "wires": [ + [ + "fn_chart_pump_b" + ], + [], + [ + "mgc_dash_node" + ] + ] + }, + { + "id": "rm_dash_pump_c", + "type": "rotatingMachine", + "z": "tab_mgc_dash", + "g": "grp_pump_c", + "name": "Pump C", + "speed": 1, + "startup": 0, + "warmup": 0, + "shutdown": 0, + "cooldown": 0, + "movementMode": "staticspeed", + "machineCurve": "", + "uuid": "mgc-dash-pump-c", + "assetTagNumber": "", + "model": "hidrostal-H05K-S03R", + "unit": "m3/h", + "curvePressureUnit": "mbar", + "curveFlowUnit": "m3/h", + "curvePowerUnit": "kW", + "curveControlUnit": "%", + "enableLog": false, + "logLevel": "error", + "positionVsParent": "atEquipment", + "positionIcon": "\u22a5", + "hasDistance": false, + "distance": "", + "x": 850, + "y": 740, + "wires": [ + [ + "fn_chart_pump_c" + ], + [], + [ + "mgc_dash_node" + ] + ] + }, + { + "id": "mgc_dash_node", + "type": "machineGroupControl", + "z": "tab_mgc_dash", + "g": "grp_mgc_unit", + "name": "Machine Group", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "mode": "optimalControl", + "uuid": "", + "supplier": "", + "category": "", + "assetType": "", + "model": "", + "unit": "", + "enableLog": false, + "logLevel": "info", + "positionVsParent": "atEquipment", + "positionIcon": "", + "hasDistance": false, + "distance": "", + "distanceUnit": "m", + "distanceDescription": "", + "x": 1100, + "y": 400, + "wires": [ + [ + "fn_status_split", + "fn_chart_total" + ], + [], + [] + ] + }, + { + "id": "ui_slider_pressure", + "type": "ui-slider", + "z": "tab_mgc_dash", + "g": "grp_drv_pressure", + "group": "ui_group_ctrl", + "name": "Pressure downstream (mbar)", + "label": "Pressure \u2193 (mbar)", + "order": 9, + "width": "12", + "height": "1", + "passthru": true, + "outs": "end", + "topic": "", + "topicType": "str", + "thumbLabel": "always", + "showTicks": false, + "min": 600, + "max": 1500, + "step": 50, + "className": "", + "x": 180, + "y": 880, + "wires": [ + [ + "fn_pressure_wrap" + ] + ], + "icon": "compress" + }, + { + "id": "fn_pressure_wrap", + "type": "function", + "z": "tab_mgc_dash", + "g": "grp_drv_pressure", + "name": "wrap \u2192 data.simulate-measurement", + "func": "// Slider emits msg.payload = Number. Convert into the canonical\n// data.simulate-measurement shape so each pump's command registry can\n// route it through the same path as inject-based pressure tests.\nconst v = Number(msg.payload);\nif (!Number.isFinite(v)) return null;\nmsg.topic = 'data.simulate-measurement';\nmsg.payload = { type: 'pressure', position: 'downstream', value: v, unit: 'mbar' };\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 470, + "y": 880, + "wires": [ + [ + "fn_pressure_fanout" + ] + ] + }, + { + "id": "fn_pressure_fanout", + "type": "function", + "z": "tab_mgc_dash", + "g": "grp_drv_pressure", + "name": "fan-out: pressure \u2192 A/B/C", + "func": "// Forward the wrapped data.simulate-measurement msg to all 3 pumps.\nreturn [msg, msg, msg];\n", + "outputs": 3, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 740, + "y": 880, + "wires": [ + [ + "rm_dash_pump_a" + ], + [ + "rm_dash_pump_b" + ], + [ + "rm_dash_pump_c" + ] + ] + }, + { + "id": "ui_btn_demand_abs_200", + "type": "ui-button", + "z": "tab_mgc_dash", + "g": "grp_drv_demand", + "group": "ui_group_ctrl", + "name": "200 m\u00b3/h", + "label": "200 m\u00b3/h", + "order": 6, + "width": "4", + "height": "1", + "emulateClick": false, + "tooltip": "Sends set.demand = {\"value\":200,\"unit\":\"m3/h\"}. Unit conversion to canonical m\u00b3/s happens in the MGC setDemand handler.", + "color": "", + "bgcolor": "", + "icon": "water_drop", + "payload": "{\"value\":200,\"unit\":\"m3/h\"}", + "payloadType": "json", + "topic": "set.demand", + "topicType": "str", + "x": 580, + "y": 220, + "wires": [ + [ + "mgc_dash_node" + ] + ] + }, + { + "id": "ui_btn_demand_abs_400", + "type": "ui-button", + "z": "tab_mgc_dash", + "g": "grp_drv_demand", + "group": "ui_group_ctrl", + "name": "400 m\u00b3/h", + "label": "400 m\u00b3/h", + "order": 7, + "width": "4", + "height": "1", + "emulateClick": false, + "tooltip": "Sends set.demand = {\"value\":400,\"unit\":\"m3/h\"}. Unit conversion to canonical m\u00b3/s happens in the MGC setDemand handler.", + "color": "", + "bgcolor": "", + "icon": "water_drop", + "payload": "{\"value\":400,\"unit\":\"m3/h\"}", + "payloadType": "json", + "topic": "set.demand", + "topicType": "str", + "x": 580, + "y": 260, + "wires": [ + [ + "mgc_dash_node" + ] + ] + }, + { + "id": "ui_btn_demand_abs_lps", + "type": "ui-button", + "z": "tab_mgc_dash", + "g": "grp_drv_demand", + "group": "ui_group_ctrl", + "name": "100 l/s", + "label": "100 l/s", + "order": 8, + "width": "4", + "height": "1", + "emulateClick": false, + "tooltip": "Sends set.demand = {\"value\":100,\"unit\":\"l/s\"}. Unit conversion to canonical m\u00b3/s happens in the MGC setDemand handler.", + "color": "", + "bgcolor": "", + "icon": "water_drop", + "payload": "{\"value\":100,\"unit\":\"l/s\"}", + "payloadType": "json", + "topic": "set.demand", + "topicType": "str", + "x": 590, + "y": 300, + "wires": [ + [ + "mgc_dash_node" + ] + ] + }, + { + "id": "ui_chart_per_pump_flow", + "type": "ui-chart", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_trends", + "name": "Per-pump flow", + "label": "Per-pump flow (m\u00b3/h) \u2014 A / B / C vs Total", + "order": 2, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisType": "time", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "xmin": "", + "xmax": "", + "yAxisLabel": "m\u00b3/h", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "showLegend": true, + "removeOlder": "15", + "removeOlderUnit": "60", + "removeOlderPoints": "", + "colors": [ + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#0095FF", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5", + "#cccccc" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "width": 6, + "height": 4, + "className": "", + "interpolation": "linear", + "x": 1750, + "y": 620, + "wires": [ + [] + ] + }, + { + "id": "fn_chart_pump_a", + "type": "function", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "name": "chart: Pump A", + "func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\nif (flow == null) return null;\nreturn { topic: 'Pump A', payload: Number(flow) };\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1450, + "y": 500, + "wires": [ + [ + "ui_chart_per_pump_flow" + ] + ] + }, + { + "id": "fn_chart_pump_b", + "type": "function", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "name": "chart: Pump B", + "func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\nif (flow == null) return null;\nreturn { topic: 'Pump B', payload: Number(flow) };\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1450, + "y": 540, + "wires": [ + [ + "ui_chart_per_pump_flow" + ] + ] + }, + { + "id": "fn_chart_pump_c", + "type": "function", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "name": "chart: Pump C", + "func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\nif (flow == null) return null;\nreturn { topic: 'Pump C', payload: Number(flow) };\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1450, + "y": 580, + "wires": [ + [ + "ui_chart_per_pump_flow" + ] + ] + }, + { + "id": "fn_chart_total", + "type": "function", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "name": "chart: Total", + "func": "const cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nconst total = cache.downstream_predicted_flow ?? cache.atEquipment_predicted_flow;\nif (total == null) return null;\nreturn { topic: 'Total', payload: Number(total) };\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1450, + "y": 620, + "wires": [ + [ + "ui_chart_per_pump_flow" + ] + ] + }, + { + "id": "ba175534fa51a1a9", + "type": "inject", + "z": "tab_mgc_dash", + "name": "", + "props": [ { - "name": "Default", - "px": "0", - "cols": "12" + "p": "payload" + }, + { + "p": "topic", + "vt": "str" } ], - "order": 1, - "className": "" + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "empty_graphs", + "payload": "[]", + "payloadType": "json", + "x": 1520, + "y": 740, + "wires": [ + [ + "ui_chart_per_pump_flow", + "ui_chart_bep", + "ui_chart_power", + "ui_chart_flow" + ] + ] }, { "id": "ui_group_ctrl", @@ -329,877 +1386,365 @@ "className": "" }, { - "id": "ui_btn_mode_optimal", - "type": "ui-button", - "z": "tab_mgc_dash", - "g": "grp_drv_mode", - "group": "ui_group_ctrl", - "name": "Mode: optimalControl", - "label": "Mode: optimalControl", - "order": 1, - "width": "3", - "height": "1", - "emulateClick": false, - "tooltip": "Best-combination optimiser (BEP-Gravitation / NCog)", - "color": "", - "bgcolor": "", - "icon": "auto_fix_high", - "payload": "optimalControl", - "payloadType": "str", - "topic": "set.mode", - "topicType": "str", - "x": 260, - "y": 140, - "wires": [ - [ - "mgc_dash_node" - ] - ] - }, - { - "id": "ui_btn_mode_priority", - "type": "ui-button", - "z": "tab_mgc_dash", - "g": "grp_drv_mode", - "group": "ui_group_ctrl", - "name": "Mode: priorityControl", - "label": "Mode: priorityControl", - "order": 2, - "width": "3", - "height": "1", - "emulateClick": false, - "tooltip": "Sequential equal-flow control by priority list", - "color": "", - "bgcolor": "", - "icon": "format_list_numbered", - "payload": "priorityControl", - "payloadType": "str", - "topic": "set.mode", - "topicType": "str", - "x": 260, - "y": 180, - "wires": [ - [ - "mgc_dash_node" - ] - ] - }, - { - "id": "ui_btn_scaling_norm", - "type": "ui-button", - "z": "tab_mgc_dash", - "g": "grp_drv_scaling", - "group": "ui_group_ctrl", - "name": "Scaling: normalized", - "label": "Scaling: normalized", - "order": 3, - "width": "3", - "height": "1", - "emulateClick": false, - "tooltip": "Interpret set.demand as 0–100 % of group capacity", - "color": "", - "bgcolor": "", - "icon": "percent", - "payload": "normalized", - "payloadType": "str", - "topic": "set.scaling", - "topicType": "str", - "x": 260, - "y": 300, - "wires": [ - [ - "mgc_dash_node" - ] - ] - }, - { - "id": "ui_btn_scaling_abs", - "type": "ui-button", - "z": "tab_mgc_dash", - "g": "grp_drv_scaling", - "group": "ui_group_ctrl", - "name": "Scaling: absolute", - "label": "Scaling: absolute", - "order": 4, - "width": "3", - "height": "1", - "emulateClick": false, - "tooltip": "Interpret set.demand as m³/h (capped by group min/max)", - "color": "", - "bgcolor": "", - "icon": "exposure", - "payload": "absolute", - "payloadType": "str", - "topic": "set.scaling", - "topicType": "str", - "x": 260, - "y": 340, - "wires": [ - [ - "mgc_dash_node" - ] - ] - }, - { - "id": "ui_slider_demand", - "type": "ui-slider", - "z": "tab_mgc_dash", - "g": "grp_drv_demand", - "group": "ui_group_ctrl", - "name": "Demand", - "label": "Demand", - "order": 5, - "width": "6", - "height": "1", - "topic": "set.demand", - "topicType": "str", - "icon": "speed", - "iconType": "fa", - "min": 0, - "max": 100, - "step": 1, - "thumbLabel": "always", - "tickLabel": "true", - "showTicks": false, - "passthru": true, - "outs": "all", - "className": "", - "x": 260, - "y": 460, - "wires": [ - [ - "mgc_dash_node" - ] - ] - }, - { - "id": "ui_btn_demand_stop", - "type": "ui-button", - "z": "tab_mgc_dash", - "g": "grp_drv_demand", - "group": "ui_group_ctrl", - "name": "Demand = 0 (Stop)", - "label": "Stop (demand = 0)", - "order": 6, - "width": "6", - "height": "1", - "emulateClick": false, - "tooltip": "Send set.demand = 0 — MGC calls turnOffAllMachines", - "color": "#ffffff", - "bgcolor": "#cc6600", - "icon": "stop", - "payload": "0", - "payloadType": "num", - "topic": "set.demand", - "topicType": "str", - "x": 260, - "y": 500, - "wires": [ - [ - "mgc_dash_node" - ] - ] - }, - { - "id": "ui_btn_setup_init", - "type": "ui-button", - "z": "tab_mgc_dash", - "g": "grp_setup", - "group": "ui_group_ctrl", - "name": "Initialize pumps", - "label": "Initialize pumps (virtualControl + startup)", - "order": 7, - "width": "12", - "height": "1", - "emulateClick": false, - "tooltip": "Re-runs the once-on-deploy setup: virtualControl mode + cmd.startup on all three pumps", - "color": "", - "bgcolor": "", - "icon": "play_arrow", - "payload": "go", - "payloadType": "str", - "topic": "", - "topicType": "str", - "x": 260, - "y": 620, - "wires": [ - [ - "fn_setup_fanout" - ] - ] - }, - { - "id": "inj_setup_start", - "type": "inject", - "z": "tab_mgc_dash", - "g": "grp_setup", - "name": "Auto-start pumps", - "props": [ - { "p": "payload", "v": "go", "vt": "str" } + "id": "ui_page_mgc", + "type": "ui-page", + "name": "MGC Basic", + "ui": "ui_base_mgc", + "path": "/mgc-basic", + "icon": "settings-input-component", + "layout": "grid", + "theme": "ui_theme_mgc", + "breakpoints": [ + { + "name": "Default", + "px": "0", + "cols": "12" + } ], - "repeat": "", - "crontab": "", - "once": true, - "onceDelay": "1.5", - "topic": "", - "x": 260, - "y": 660, - "wires": [ - [ - "fn_setup_fanout" - ] - ] - }, - { - "id": "fn_setup_fanout", - "type": "function", - "z": "tab_mgc_dash", - "g": "grp_setup", - "name": "fan-out: virtualControl + startup → A/B/C", - "func": "// Fire two messages per pump: set.mode = virtualControl, then cmd.startup.\nconst setMode = { topic: 'set.mode', payload: 'virtualControl' };\nconst startup = { topic: 'cmd.startup', payload: {} };\nreturn [\n [setMode, startup], // → Pump A\n [setMode, startup], // → Pump B\n [setMode, startup], // → Pump C\n];\n", - "outputs": 3, - "timeout": 0, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 500, - "y": 640, - "wires": [ - [ - "rm_dash_pump_a" - ], - [ - "rm_dash_pump_b" - ], - [ - "rm_dash_pump_c" - ] - ] - }, - { - "id": "fn_status_split", - "type": "function", - "z": "tab_mgc_dash", - "g": "grp_status_panel", - "name": "fan-out Port 0 (status + charts + raw)", - "func": "// MGC Port 0 emits delta-only. Cache last-known so deltas never blank a\n// row, then fan out one msg per ui-text / ui-chart / ui-template slot.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\nconst num = (v, dp, unit) => {\n const n = +v;\n if (!Number.isFinite(n)) return '—';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\n\nconst mode = cache.mode || '—';\nconst scaling = cache.scaling || '—';\nconst flow = cache['atEquipment_predicted_flow'];\nconst power = cache['atEquipment_predicted_power'];\nconst qMin = cache.flowCapacityMin;\nconst qMax = cache.flowCapacityMax;\nconst nAct = cache.machineCountActive;\nconst nTot = cache.machineCount;\nconst bepRel = cache.relDistFromPeak;\n\nconst chart = (topic, v) => Number.isFinite(+v) ? { topic, payload: +v } : null;\n\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '—';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 0–6: status text\n { payload: mode },\n { payload: scaling },\n { payload: num(flow, 1, 'm³/h') },\n { payload: num(power, 2, 'kW') },\n { payload: Number.isFinite(+qMax) ? `${num(qMin, 1)} – ${num(qMax, 1, 'm³/h')}` : '—' },\n { payload: Number.isFinite(+nAct) && Number.isFinite(+nTot) ? `${nAct} / ${nTot}` : '—' },\n { payload: num(bepRel, 1, '%') },\n // 7–9: charts (flow vs capacity / power / bep distance)\n chart('Flow', flow),\n chart('Capacity', qMax),\n chart('Power', power),\n chart('BEP rel%', bepRel),\n // 11: raw rows for the ui-template\n { payload: rawRows },\n];\n", - "outputs": 12, - "timeout": 0, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1300, - "y": 140, - "wires": [ - [ - "ui_txt_mode" - ], - [ - "ui_txt_scaling" - ], - [ - "ui_txt_flow" - ], - [ - "ui_txt_power" - ], - [ - "ui_txt_capacity" - ], - [ - "ui_txt_machines" - ], - [ - "ui_txt_bep" - ], - [ - "ui_chart_flow" - ], - [ - "ui_chart_flow" - ], - [ - "ui_chart_power" - ], - [ - "ui_chart_bep" - ], - [ - "ui_tpl_raw" - ] - ] - }, - { - "id": "ui_txt_mode", - "type": "ui-text", - "z": "tab_mgc_dash", - "g": "grp_status_panel", - "group": "ui_group_status", "order": 1, + "className": "" + }, + { + "id": "ui_base_mgc", + "type": "ui-base", + "name": "EVOLV Demo", + "path": "/dashboard", + "appIcon": "", + "includeClientData": true, + "acceptsClientConfig": [ + "ui-notification", + "ui-control" + ], + "showPathInSidebar": false, + "headerContent": "page", + "navigationStyle": "default", + "titleBarStyle": "default" + }, + { + "id": "ui_theme_mgc", + "type": "ui-theme", + "name": "EVOLV Basic Theme", + "colors": { + "surface": "#ffffff", + "primary": "#50a8d9", + "bgPage": "#eeeeee", + "groupBg": "#ffffff", + "groupOutline": "#cccccc" + }, + "sizes": { + "density": "default", + "pagePadding": "14px", + "groupGap": "14px", + "groupBorderRadius": "6px", + "widgetGap": "12px" + } + }, + { + "id": "c6acdcdb49901fe9", + "type": "global-config", + "env": [], + "modules": { + "@flowfuse/node-red-dashboard": "1.30.2", + "EVOLV": "1.0.29" + } + }, + { + "id": "ui_txt_eta", + "type": "ui-text", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_status", + "order": 8, "width": "6", "height": "1", - "name": "Mode", - "label": "Mode", + "name": "\u03b7 (hydraulic)", + "label": "\u03b7 (hydraulic)", "format": "{{msg.payload}}", "layout": "row-spread", "style": false, "font": "", "fontSize": 14, - "color": "#1F4E79", - "x": 1560, - "y": 100, + "color": "#1A5276", + "x": 1750, + "y": 360, "wires": [] }, { - "id": "ui_txt_scaling", + "id": "ui_txt_eta_peak", "type": "ui-text", "z": "tab_mgc_dash", "g": "grp_status_panel", "group": "ui_group_status", - "order": 2, + "order": 9, "width": "6", "height": "1", - "name": "Scaling", - "label": "Scaling", + "name": "\u03b7 peak (BEP)", + "label": "\u03b7 peak (BEP)", "format": "{{msg.payload}}", "layout": "row-spread", "style": false, "font": "", "fontSize": 14, - "color": "#1F4E79", - "x": 1560, - "y": 140, + "color": "#1A5276", + "x": 1750, + "y": 420, "wires": [] }, { - "id": "ui_txt_flow", + "id": "ui_txt_bep_abs", "type": "ui-text", "z": "tab_mgc_dash", "g": "grp_status_panel", "group": "ui_group_status", - "order": 3, + "order": 10, "width": "6", "height": "1", - "name": "Total flow", - "label": "Total flow", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#1F4E79", - "x": 1570, - "y": 180, - "wires": [] - }, - { - "id": "ui_txt_power", - "type": "ui-text", - "z": "tab_mgc_dash", - "g": "grp_status_panel", - "group": "ui_group_status", - "order": 4, - "width": "6", - "height": "1", - "name": "Total power", - "label": "Total power", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#1F4E79", - "x": 1580, - "y": 220, - "wires": [] - }, - { - "id": "ui_txt_capacity", - "type": "ui-text", - "z": "tab_mgc_dash", - "g": "grp_status_panel", - "group": "ui_group_status", - "order": 5, - "width": "6", - "height": "1", - "name": "Capacity (Qmin–Qmax)", - "label": "Capacity", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#1F4E79", - "x": 1620, - "y": 260, - "wires": [] - }, - { - "id": "ui_txt_machines", - "type": "ui-text", - "z": "tab_mgc_dash", - "g": "grp_status_panel", - "group": "ui_group_status", - "order": 6, - "width": "6", - "height": "1", - "name": "Machines (active / total)", - "label": "Machines", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#1F4E79", - "x": 1630, - "y": 300, - "wires": [] - }, - { - "id": "ui_txt_bep", - "type": "ui-text", - "z": "tab_mgc_dash", - "g": "grp_status_panel", - "group": "ui_group_status", - "order": 7, - "width": "6", - "height": "1", - "name": "BEP distance (rel)", - "label": "BEP distance", + "name": "BEP gap (\u03b7 pts)", + "label": "BEP gap (\u03b7 pts)", "format": "{{msg.payload}}", "layout": "row-spread", "style": false, "font": "", "fontSize": 14, "color": "#7D3C98", - "x": 1600, - "y": 340, - "wires": [] - }, - { - "id": "ui_chart_flow", - "type": "ui-chart", - "z": "tab_mgc_dash", - "g": "grp_status_panel", - "group": "ui_group_trends", - "name": "Flow vs capacity", - "label": "Flow (m³/h) — predicted vs capacity", - "order": 1, - "width": 6, - "height": 4, - "chartType": "line", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "time", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "yAxisLabel": "m³/h", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "xmin": "", - "xmax": "", - "ymin": "", - "ymax": "", - "removeOlder": "15", - "removeOlderUnit": "60", - "removeOlderPoints": "", - "bins": 10, - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "interpolation": "linear", - "showLegend": true, - "className": "", - "colors": [ - "#0095FF", - "#cccccc", - "#FF7F0E", - "#2CA02C", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "x": 1600, - "y": 400, - "wires": [] - }, - { - "id": "ui_chart_power", - "type": "ui-chart", - "z": "tab_mgc_dash", - "g": "grp_status_panel", - "group": "ui_group_trends", - "name": "Power", - "label": "Power (kW)", - "order": 2, - "width": 6, - "height": 4, - "chartType": "line", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "time", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "yAxisLabel": "kW", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "xmin": "", - "xmax": "", - "ymin": "", - "ymax": "", - "removeOlder": "15", - "removeOlderUnit": "60", - "removeOlderPoints": "", - "bins": 10, - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "interpolation": "linear", - "showLegend": false, - "className": "", - "colors": [ - "#2CA02C", - "#FF0000", - "#FF7F0E", - "#0095FF", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "x": 1580, - "y": 440, - "wires": [] - }, - { - "id": "ui_chart_bep", - "type": "ui-chart", - "z": "tab_mgc_dash", - "g": "grp_status_panel", - "group": "ui_group_trends", - "name": "BEP distance (rel %)", - "label": "BEP distance (rel %)", - "order": 3, - "width": 6, - "height": 4, - "chartType": "line", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "time", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "yAxisLabel": "%", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "xmin": "", - "xmax": "", - "ymin": "", - "ymax": "", - "removeOlder": "15", - "removeOlderUnit": "60", - "removeOlderPoints": "", - "bins": 10, - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "interpolation": "linear", - "showLegend": false, - "className": "", - "colors": [ - "#A347E1", - "#FF0000", - "#FF7F0E", - "#2CA02C", - "#0095FF", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "x": 1610, + "x": 1750, "y": 480, "wires": [] }, { - "id": "ui_tpl_raw", - "type": "ui-template", + "id": "ui_txt_ncog", + "type": "ui-text", "z": "tab_mgc_dash", "g": "grp_status_panel", - "group": "ui_group_raw", - "name": "Raw output table", - "order": 1, - "width": "12", - "height": "8", - "head": "", - "format": "\n\n\n", - "storeOutMessages": true, - "passthru": true, - "resendOnRefresh": true, - "templateScope": "local", + "group": "ui_group_status", + "order": 11, + "width": "6", + "height": "1", + "name": "BEP flow pos (NCog)", + "label": "BEP flow pos (NCog)", + "format": "{{msg.payload}}", + "layout": "row-spread", + "style": false, + "font": "", + "fontSize": 14, + "color": "#7D3C98", + "x": 1750, + "y": 540, + "wires": [] + }, + { + "id": "ui_chart_eta", + "type": "ui-chart", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_trends", + "name": "Efficiency (hydraulic, %)", + "label": "Hydraulic efficiency \u03b7 (%) \u2014 predicted vs peak", + "order": 104, + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisType": "time", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "xmin": "", + "xmax": "", + "yAxisLabel": "%", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "showLegend": false, + "removeOlder": "15", + "removeOlderUnit": "60", + "removeOlderPoints": "", + "colors": [ + "#A347E1", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#0095FF", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "width": 6, + "height": 4, "className": "", - "x": 1620, - "y": 520, + "interpolation": "linear", + "x": 1770, + "y": 540, + "wires": [] + }, + { + "id": "fn_qh_point", + "type": "function", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "name": "Q-H operating point", + "func": "// Build a single (Q, H) point for the operating-point series. The chart\n// is configured action='append', so we precede each new point with a\n// clear msg targeting this topic only \u2014 the dot moves without leaving a\n// trail. The Q-H curve overlay (topic='Curve') uses the same pattern.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\n\nconst Q = cache['atEquipment_predicted_flow']; // m\u00b3/h\nconst dpPa = (() => {\n if (Number.isFinite(+cache.headerDiffPa) && +cache.headerDiffPa > 0) return +cache.headerDiffPa;\n if (Number.isFinite(+cache.headerDiffMbar) && +cache.headerDiffMbar > 0) return +cache.headerDiffMbar * 100;\n const d = cache['differential_measured_pressure'];\n if (Number.isFinite(+d) && +d > 0) return +d * 100;\n const up = cache['upstream_measured_pressure'];\n const dn = cache['downstream_measured_pressure'];\n if (Number.isFinite(+up) && Number.isFinite(+dn) && +dn > +up) return (+dn - +up) * 100;\n return null;\n})();\nif (!Number.isFinite(+Q) || !Number.isFinite(+dpPa)) return null;\nconst H = dpPa / (999.1 * 9.80665);\nreturn [[\n { topic: 'Operating point', action: 'clear' },\n { topic: 'Operating point', payload: { x: +Q, y: +H } },\n]];\n", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1850, + "y": 760, "wires": [ - [] + [ + "ui_chart_qh" + ] ] }, { - "id": "rm_dash_pump_a", - "type": "rotatingMachine", + "id": "ui_chart_qh", + "type": "ui-chart", + "z": "tab_mgc_dash", + "g": "grp_status_panel", + "group": "ui_group_trends", + "name": "Q-H operating point", + "label": "Q-H curve + operating point", + "order": 201, + "chartType": "line", + "interpolation": "linear", + "category": "topic", + "categoryType": "msg", + "xAxisType": "linear", + "xAxisProperty": "payload.x", + "xAxisPropertyType": "msg", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisProperty": "payload.y", + "yAxisPropertyType": "msg", + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 5, + "showLegend": true, + "bins": 10, + "width": "12", + "height": "6", + "removeOlder": "0", + "removeOlderUnit": "1", + "removeOlderPoints": "", + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "x": 2050, + "y": 760, + "wires": [] + }, + { + "id": "fn_qh_curve_fetcher", + "type": "function", "z": "tab_mgc_dash", "g": "grp_pump_a", - "name": "Pump A", - "speed": "1", - "startup": "2", - "warmup": "1", - "shutdown": "2", - "cooldown": "1", - "movementMode": "staticspeed", - "machineCurve": "", - "uuid": "mgc-dash-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": "info", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": "", - "distanceUnit": "m", - "distanceDescription": "", - "x": 760, - "y": 260, + "name": "Q-H curve fetch (throttled)", + "func": "// Throttle: refetch the Q-H curve only when ctrl moves >2 percentage\n// points or \u0394p moves >50 mbar. Curve shape is shared across all pumps\n// once MGC equalizes the header, so any pump's port-0 stream works.\nconst cache = context.get('c') || { ctrl: null, dpMbar: null, id: null };\nconst p = msg.payload || {};\n// Tap the pump's node id from the runtime context (provided by Node-RED\n// when the upstream node injects it). Fallback to env var if needed.\nconst nodeId = msg._nodeId || cache.id || env.get('QH_PUMP_ID') || null;\nconst ctrl = (typeof p.ctrl === 'number') ? p.ctrl : cache.ctrl;\n// \u0394p keys vary across pumps; try the canonical set produced by the\n// rotatingMachine port-0 flattener.\nconst dpMbar = (() => {\n if (typeof p.differential_measured_pressure === 'number') return p.differential_measured_pressure;\n const dn = p['pressure.measured.downstream'] ?? p.downstream_measured_pressure;\n const up = p['pressure.measured.upstream'] ?? p.upstream_measured_pressure;\n if (typeof dn === 'number' && typeof up === 'number') return dn - up;\n return cache.dpMbar;\n})();\nif (typeof ctrl !== 'number' || typeof dpMbar !== 'number') return null;\nconst ctrlDelta = (cache.ctrl == null) ? Infinity : Math.abs(ctrl - cache.ctrl);\nconst dpDelta = (cache.dpMbar == null) ? Infinity : Math.abs(dpMbar - cache.dpMbar);\nif (ctrlDelta < 2 && dpDelta < 50 && nodeId === cache.id) return null;\ncontext.set('c', { ctrl, dpMbar, id: nodeId });\nif (!nodeId) {\n node.warn('No pump node id known yet \u2014 set msg._nodeId or env QH_PUMP_ID');\n return null;\n}\n// Emit a single msg the http-request node will consume.\nreturn { method: 'GET', url: `/rotatingMachine/${nodeId}/qh-curve?ctrl=${ctrl.toFixed(2)}`, _nodeId: nodeId, _ctrl: ctrl, _dpMbar: dpMbar };\n", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1850, + "y": 840, "wires": [ - [], - [], [ - "mgc_dash_node" + "fn_qh_http" ] ] }, { - "id": "rm_dash_pump_b", - "type": "rotatingMachine", + "id": "fn_qh_http", + "type": "function", "z": "tab_mgc_dash", - "g": "grp_pump_b", - "name": "Pump B", - "speed": "1", - "startup": "2", - "warmup": "1", - "shutdown": "2", - "cooldown": "1", - "movementMode": "staticspeed", - "machineCurve": "", - "uuid": "mgc-dash-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": "info", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": "", - "distanceUnit": "m", - "distanceDescription": "", - "x": 760, - "y": 440, + "g": "grp_pump_a", + "name": "Q-H curve HTTP GET", + "func": "// Run the HTTP fetch using Node 20's global fetch. The function-node\n// scope is sandboxed, so we resolve the absolute URL using the same host\n// the dashboard runs on. Result body flows to the next function.\nconst baseUrl = global.get('NODE_RED_BASE_URL') || 'http://localhost:1880';\ntry {\n const r = await fetch(baseUrl + msg.url);\n if (!r.ok) { node.warn(`qh-curve HTTP ${r.status}`); return null; }\n msg.payload = await r.json();\n return msg;\n} catch (err) {\n node.warn(`qh-curve fetch failed: ${err.message}`);\n return null;\n}\n", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2050, + "y": 840, "wires": [ - [], - [], [ - "mgc_dash_node" + "fn_qh_fanout" ] ] }, { - "id": "rm_dash_pump_c", - "type": "rotatingMachine", + "id": "fn_qh_fanout", + "type": "function", "z": "tab_mgc_dash", - "g": "grp_pump_c", - "name": "Pump C", - "speed": "1", - "startup": "2", - "warmup": "1", - "shutdown": "2", - "cooldown": "1", - "movementMode": "staticspeed", - "machineCurve": "", - "uuid": "mgc-dash-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": "info", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": "", - "distanceUnit": "m", - "distanceDescription": "", - "x": 760, - "y": 620, + "g": "grp_pump_a", + "name": "Q-H curve points \u2192 chart", + "func": "// Emit one chart msg per point. Topic='Curve' makes the chart treat\n// it as a second series next to the 'Operating point' scatter.\n// Action 'replace' so each new sample sweeps the curve fresh (no\n// trail buildup).\nconst r = msg.payload || {};\nif (r.error || !Array.isArray(r.points) || r.points.length === 0) return null;\n\n// Trim the trailing flat-Q tail. buildQHCurve returns ~33 points across the\n// full pressure envelope, but at low ctrl% the last ~10 points clamp to the\n// pump's minimum-flow envelope (constant Q across rising H). Plotting those\n// stretches the chart's H axis to ~40 m even though the operating point sits\n// near H=11 m — making the curve look like a vertical line with the\n// operating point lost at the bottom. Keep one entry-point of the tail so\n// the curve still terminates visually, drop the rest.\nconst FLAT_Q_EPS = 0.5; // m³/h — pump-curve resolution; below this is noise.\nlet trimTo = r.points.length;\nfor (let i = r.points.length - 1; i > 0; i--) {\n if (Math.abs(r.points[i].Q - r.points[i-1].Q) >= FLAT_Q_EPS) { trimTo = i + 1; break; }\n}\nconst trimmed = r.points.slice(0, trimTo);\n\nconst out = trimmed.map((pt) => ({ topic: 'Curve', payload: { x: pt.Q, y: pt.H } }));\n// Send a reset to clear the previous curve before appending the new one.\nout.unshift({ topic: 'Curve', action: 'clear' });\nreturn [out];\n", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2250, + "y": 840, "wires": [ - [], - [], [ - "mgc_dash_node" + "ui_chart_qh" ] ] }, { - "id": "mgc_dash_node", - "type": "machineGroupControl", + "id": "fn_qh_inject_id", + "type": "function", "z": "tab_mgc_dash", - "g": "grp_mgc_unit", - "name": "Machine Group", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "mode": "optimalControl", - "scaling": "normalized", - "uuid": "", - "supplier": "", - "category": "", - "assetType": "", - "model": "", - "unit": "", - "enableLog": false, - "logLevel": "info", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": "", - "distanceUnit": "m", - "distanceDescription": "", - "x": 1050, - "y": 440, + "g": "grp_pump_a", + "name": "tag with pump id", + "func": "msg._nodeId = 'rm_dash_pump_a'; return msg;\n", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1700, + "y": 840, "wires": [ [ - "dbg_port0", - "fn_status_split" - ], - [ - "dbg_port1" - ], - [ - "dbg_port2" + "fn_qh_curve_fetcher" ] ] - }, - { - "id": "dbg_port0", - "type": "debug", - "z": "tab_mgc_dash", - "g": "grp_dbg", - "name": "Port 0: Process", - "active": true, - "tosidebar": true, - "console": false, - "tostatus": false, - "complete": "payload", - "targetType": "msg", - "x": 1340, - "y": 800, - "wires": [] - }, - { - "id": "dbg_port1", - "type": "debug", - "z": "tab_mgc_dash", - "g": "grp_dbg", - "name": "Port 1: InfluxDB", - "active": true, - "tosidebar": true, - "console": false, - "tostatus": false, - "complete": "true", - "targetType": "full", - "x": 1340, - "y": 860, - "wires": [] - }, - { - "id": "dbg_port2", - "type": "debug", - "z": "tab_mgc_dash", - "g": "grp_dbg", - "name": "Port 2: Parent reg", - "active": true, - "tosidebar": true, - "console": false, - "tostatus": false, - "complete": "true", - "targetType": "full", - "x": 1350, - "y": 920, - "wires": [] - }, - { - "id": "mgc_global_cfg", - "type": "global-config", - "env": [], - "modules": { - "EVOLV": "1.0.29" - } } ] diff --git a/mgc.html b/mgc.html index 5015782..3d9b295 100644 --- a/mgc.html +++ b/mgc.html @@ -22,8 +22,7 @@ dbaseOutputFormat: { value: "influxdb" }, // Control strategy - mode: { value: "optimalControl" }, // optimalControl | priorityControl | prioritypercentagecontrol | maintenance - scaling: { value: "normalized" }, // normalized (0–100 %) | absolute (m³/h) + mode: { value: "optimalControl" }, // optimalControl | priorityControl | maintenance //define asset properties uuid: { value: "" }, @@ -94,17 +93,15 @@ -
- - -
+

+ Demand is self-describing per set.demand message: a bare number is + treated as % of group capacity; {value, unit} with a flow unit + (m3/h, l/s, m3/s, …) is dispatched + in absolute terms. Negative value stops all pumps. +

Output Formats

diff --git a/src/commands/handlers.js b/src/commands/handlers.js index eed7105..accaea7 100644 --- a/src/commands/handlers.js +++ b/src/commands/handlers.js @@ -1,7 +1,7 @@ 'use strict'; // Handler functions for machineGroupControl commands. Each handler receives: -// source: the domain (specificClass) instance — exposes setMode, setScaling, +// source: the domain (specificClass) instance — exposes setMode, // handleInput, childRegistrationUtils.registerChild, logger, // config.general.name. // msg: the Node-RED input message. @@ -10,6 +10,8 @@ // Pure functions: no module-level state. The registry already enforces the // typeof-check ladder; per-topic semantic validation lives here. +const { convert } = require('generalFunctions'); + function _logger(source, ctx) { return ctx?.logger || source?.logger || null; } @@ -18,10 +20,6 @@ exports.setMode = (source, msg) => { source.setMode(msg.payload); }; -exports.setScaling = (source, msg) => { - source.setScaling(msg.payload); -}; - exports.registerChild = (source, msg, ctx) => { const log = _logger(source, ctx); const childId = msg.payload; @@ -35,13 +33,58 @@ exports.registerChild = (source, msg, ctx) => { exports.setDemand = async (source, msg, ctx) => { const log = _logger(source, ctx); - const demand = parseFloat(msg.payload); - if (Number.isNaN(demand)) { - log?.error?.(`set.demand: invalid Qd value '${msg.payload}'`); + // Operator demand is self-describing: the unit on the message decides how + // the value is interpreted. There is no persistent scaling state on MGC. + // + // payload = number → unit defaults to '%' + // payload = { value, unit:'%' }→ percent of group capacity + // payload = { value, unit:'m3/h' | 'l/s' | 'm3/s' | ... } → absolute flow + // payload < 0 (any unit) → operator stop-all signal + // + // The handler is the only place that resolves units. _runDispatch sees a + // single canonical m³/s number and never branches on scaling. + const p = msg?.payload; + let rawValue; + let unit; + if (p !== null && typeof p === 'object') { + rawValue = p.value; + unit = (typeof p.unit === 'string' && p.unit.trim()) ? p.unit.trim() : '%'; + } else { + rawValue = p; + unit = '%'; + } + const value = Number(rawValue); + if (!Number.isFinite(value)) { + log?.error?.(`set.demand: invalid Qd value '${JSON.stringify(msg?.payload)}'`); return; } + // Negative is the operator's "stop all" signal regardless of unit. + if (value < 0) { + try { + await source.turnOffAllMachines(); + } catch (err) { + log?.error?.(`set.demand: turnOffAllMachines failed: ${err && err.message}`); + } + return; + } + // Resolve to canonical m³/s. + let canonicalDemand; + if (unit === '%') { + const dt = source.calcDynamicTotals(); + // Linear interpolation: 0 % → dt.flow.min, 100 % → dt.flow.max. The + // interpolation helper also clamps so 110 % can't run pumps past max. + canonicalDemand = source.interpolation.interpolate_lin_single_point( + value, 0, 100, dt.flow.min, dt.flow.max); + } else { + try { + canonicalDemand = convert(value).from(unit).to('m3/s'); + } catch (err) { + log?.error?.(`set.demand: cannot convert ${value} ${unit} → m3/s: ${err && err.message}`); + return; + } + } try { - await source.handleInput('parent', demand); + await source.handleInput('parent', canonicalDemand); } catch (err) { log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`); return; diff --git a/src/commands/index.js b/src/commands/index.js index 9044b2a..6d58af0 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -15,13 +15,6 @@ module.exports = [ description: 'Switch the machine group between auto / manual modes.', handler: handlers.setMode, }, - { - topic: 'set.scaling', - aliases: ['setScaling'], - payloadSchema: { type: 'string' }, - description: 'Select the group scaling strategy.', - handler: handlers.setScaling, - }, { topic: 'child.register', aliases: ['registerChild'], @@ -33,10 +26,13 @@ module.exports = [ { topic: 'set.demand', aliases: ['Qd'], - // any: number or numeric string — handler runs parseFloat. + // payload is either a bare number (interpreted as %) or + // { value: number, unit: '%' | 'm3/h' | 'l/s' | 'm3/s' | ... }. + // No `units` descriptor — the handler resolves the unit explicitly so + // commandRegistry._normaliseUnits doesn't pre-convert a percentage into + // a flow rate. Negative value is the operator stop-all signal. payloadSchema: { type: 'any' }, - units: { measure: 'volumeFlowRate', default: 'm3/h' }, - description: 'Operator demand setpoint dispatched to the child machines.', + description: 'Operator demand setpoint. Bare number = %; {value, unit} for absolute flow units. Negative = stop all.', handler: handlers.setDemand, }, ]; diff --git a/src/control/strategies.js b/src/control/strategies.js index dd9d441..8ab550c 100644 --- a/src/control/strategies.js +++ b/src/control/strategies.js @@ -6,12 +6,9 @@ // machines, falling back to start/stop the next priority when the current // active set can't deliver. // -// prioPercentageControl: percentage-style ctrl distribution (only valid with -// normalized scaling). -// -// Both extracted verbatim from specificClass during the P4 refactor; the -// orchestrator wires them in via the strategies map below. They depend on -// the same group-curve helpers the optimizer uses, so allocation and power +// Extracted from specificClass during the P4 refactor; the orchestrator +// wires it in via the strategies map below. It depends on the same +// group-curve helpers the optimizer uses, so allocation and power // evaluation stay on the equalised group operating point. const { POSITIONS } = require('generalFunctions'); @@ -49,77 +46,120 @@ function capFlowDemand(Qd, dynamicTotals, logger) { return Qd; } +// Pure distribution math: given the demand, group envelope, priority list, and +// per-machine curve helpers, return the {machineId, flow} mapping plus running +// totals. No side effects, no mgc reference — testable without an MGC fixture. +// +// Inputs: +// machines: dict {id → machine} (machine objects need group-curve fields set) +// Qd: demand in canonical m³/s +// dynamicTotals: {flow: {min, max}} — envelope across ALL registered pumps +// activeTotals: {flow: {min, max}} — envelope across currently-active pumps +// priorityList: optional array of ids; null = default ordering +// isMachineActive: (id) → boolean (state-aware predicate) +// groupFlow: (machine) → {currentFxyYMin, currentFxyYMax} +// groupCalcPower: (machine, flow) → number (W) +// logger: { warn, error, … } or null +// +// Returns: { flowDistribution: [{machineId, flow}], totalFlow, totalPower, totalCog } +function computeEqualFlowDistribution({ + machines, Qd, dynamicTotals, activeTotals, priorityList, + isMachineActive, groupFlow, groupCalcPower, logger, +}) { + Qd = capFlowDemand(Qd, dynamicTotals, logger); + + let machinesInPriorityOrder = sortMachinesByPriority(machines, priorityList); + machinesInPriorityOrder = filterOutUnavailableMachines(machinesInPriorityOrder); + + const flowDistribution = []; + let totalFlow = 0; + let totalPower = 0; + // Equal-flow doesn't compute a meaningful cog — only BEP-Gravitation does. + // Preserved at 0 for backwards-compat; pinned by a basic test so a future + // change that introduces a fake non-zero value will fail loudly. + const totalCog = 0; + + switch (true) { + case (Qd < activeTotals.flow.min && activeTotals.flow.min !== 0): { + let availableFlow = activeTotals.flow.min; + for (let i = machinesInPriorityOrder.length - 1; i >= 0 && availableFlow > Qd; i--) { + const m = machinesInPriorityOrder[i]; + if (isMachineActive(m.id)) { + flowDistribution.push({ machineId: m.id, flow: 0 }); + availableFlow -= groupFlow(m.machine).currentFxyYMin; + } + } + const remaining = machinesInPriorityOrder.filter(({ id }) => + isMachineActive(id) && !flowDistribution.some(it => it.machineId === id)); + const distributedFlow = Qd / remaining.length; + for (const m of remaining) { + flowDistribution.push({ machineId: m.id, flow: distributedFlow }); + totalFlow += distributedFlow; + totalPower += groupCalcPower(m.machine, distributedFlow); + } + break; + } + case (Qd > activeTotals.flow.max): { + let i = 1; + while (totalFlow < Qd && i <= machinesInPriorityOrder.length) { + Qd = Qd / i; + if (groupFlow(machinesInPriorityOrder[i - 1].machine).currentFxyYMax >= Qd) { + for (let i2 = 0; i2 < i; i2++) { + if (!isMachineActive(machinesInPriorityOrder[i2].id)) { + flowDistribution.push({ machineId: machinesInPriorityOrder[i2].id, flow: Qd }); + totalFlow += Qd; + totalPower += groupCalcPower(machinesInPriorityOrder[i2].machine, Qd); + } + } + } + i++; + } + break; + } + default: { + const countActive = machinesInPriorityOrder.filter(({ id }) => isMachineActive(id)).length; + Qd /= countActive; + for (let i = 0; i < countActive; i++) { + flowDistribution.push({ machineId: machinesInPriorityOrder[i].id, flow: Qd }); + totalFlow += Qd; + totalPower += groupCalcPower(machinesInPriorityOrder[i].machine, Qd); + } + break; + } + } + + return { flowDistribution, totalFlow, totalPower, totalCog }; +} + +// Orchestrator: equalize the operating point, call the pure distribution math, +// write outputs, dispatch children. The mgc reaches happen here, not in the +// algorithm — see computeEqualFlowDistribution above for the part that's +// testable in isolation. async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = null) { const { mgc } = ctx; try { mgc.equalizePressure(); const dynamicTotals = mgc.calcDynamicTotals(); - Qd = capFlowDemand(Qd, dynamicTotals, mgc.logger); - - let machinesInPriorityOrder = sortMachinesByPriority(mgc.machines, priorityList); - machinesInPriorityOrder = filterOutUnavailableMachines(machinesInPriorityOrder); - - const flowDistribution = []; - let totalFlow = 0; - let totalPower = 0; - const totalCog = 0; - const activeTotals = mgc.totals.activeTotals(); - switch (true) { - case (Qd < activeTotals.flow.min && activeTotals.flow.min !== 0): { - let availableFlow = activeTotals.flow.min; - for (let i = machinesInPriorityOrder.length - 1; i >= 0 && availableFlow > Qd; i--) { - const m = machinesInPriorityOrder[i]; - if (mgc.isMachineActive(m.id)) { - flowDistribution.push({ machineId: m.id, flow: 0 }); - availableFlow -= groupFlow(m.machine).currentFxyYMin; - } - } - const remaining = machinesInPriorityOrder.filter(({ id }) => - mgc.isMachineActive(id) && !flowDistribution.some(it => it.machineId === id)); - const distributedFlow = Qd / remaining.length; - for (const m of remaining) { - flowDistribution.push({ machineId: m.id, flow: distributedFlow }); - totalFlow += distributedFlow; - totalPower += groupCalcPower(m.machine, distributedFlow); - } - break; - } - case (Qd > activeTotals.flow.max): { - let i = 1; - while (totalFlow < Qd && i <= machinesInPriorityOrder.length) { - Qd = Qd / i; - if (groupFlow(machinesInPriorityOrder[i - 1].machine).currentFxyYMax >= Qd) { - for (let i2 = 0; i2 < i; i2++) { - if (!mgc.isMachineActive(machinesInPriorityOrder[i2].id)) { - flowDistribution.push({ machineId: machinesInPriorityOrder[i2].id, flow: Qd }); - totalFlow += Qd; - totalPower += groupCalcPower(machinesInPriorityOrder[i2].machine, Qd); - } - } - } - i++; - } - break; - } - default: { - const countActive = machinesInPriorityOrder.filter(({ id }) => mgc.isMachineActive(id)).length; - Qd /= countActive; - for (let i = 0; i < countActive; i++) { - flowDistribution.push({ machineId: machinesInPriorityOrder[i].id, flow: Qd }); - totalFlow += Qd; - totalPower += groupCalcPower(machinesInPriorityOrder[i].machine, Qd); - } - break; - } - } + const { flowDistribution, totalFlow, totalPower, totalCog } = computeEqualFlowDistribution({ + machines: mgc.machines, + Qd, dynamicTotals, activeTotals, priorityList, + isMachineActive: (id) => mgc.isMachineActive(id), + groupFlow, groupCalcPower, + logger: mgc.logger, + }); - const fUnit = mgc.unitPolicy.canonical.power; - const flUnit = mgc.unitPolicy.canonical.flow; - mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, totalPower, fUnit); - mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, totalFlow, flUnit); - mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(totalFlow / totalPower); + const pUnit = mgc.unitPolicy.canonical.power; + const fUnit = mgc.unitPolicy.canonical.flow; + mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, totalPower, pUnit); + mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, totalFlow, fUnit); + // Hydraulic efficiency η = (Q·ΔP)/P_shaft, same scale as child cogs. + const dP = mgc.operatingPoint.headerDiffPa; + if (Number.isFinite(dP) && dP > 0 && totalPower > 0) { + mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT) + .value((totalFlow * dP) / totalPower); + } mgc.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(totalCog); await Promise.all(flowDistribution.map(async ({ machineId, flow }) => { @@ -139,72 +179,7 @@ async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = nu } } -async function prioPercentageControl(ctx, input, priorityList = null) { - const { mgc } = ctx; - try { - if (input < 0) { await mgc.turnOffAllMachines(); return; } - if (input > 100) input = 100; - - const numOfMachines = Object.keys(mgc.machines).length; - const procentTotal = numOfMachines * input; - const machinesNeeded = Math.ceil(procentTotal / 100); - const activeTotals = mgc.totals.activeTotals(); - const machinesActive = activeTotals.countActiveMachines; - const machinesInPriorityOrder = sortMachinesByPriority(mgc.machines, priorityList); - const ctrlDistribution = []; - - if (machinesNeeded > machinesActive) { - machinesInPriorityOrder.forEach(({ id }, index) => { - if (index < machinesNeeded) ctrlDistribution.push({ machineId: id, ctrl: 0 }); - }); - } - if (machinesNeeded < machinesActive) { - machinesInPriorityOrder.forEach(({ id }, index) => { - if (mgc.isMachineActive(id)) { - ctrlDistribution.push({ machineId: id, ctrl: index < machinesNeeded ? 100 : -1 }); - } - }); - } - if (machinesNeeded === machinesActive) { - const ctrlPerMachine = procentTotal / machinesActive; - machinesInPriorityOrder.forEach(({ id }) => { - if (mgc.isMachineActive(id)) { - ctrlDistribution.push({ machineId: id, ctrl: Math.max(0, Math.min(ctrlPerMachine, 100)) }); - } - }); - } - - await Promise.all(ctrlDistribution.map(async ({ machineId, ctrl }) => { - const machine = mgc.machines[machineId]; - const currentState = machine.state.getCurrentState(); - if (ctrl < 0 && (currentState === 'operational' || currentState === 'accelerating' || currentState === 'decelerating')) { - await machine.handleInput('parent', 'execsequence', 'shutdown'); - } else if (currentState === 'idle' && ctrl >= 0) { - await machine.handleInput('parent', 'execsequence', 'startup'); - } else if (currentState === 'operational' && ctrl > 0) { - await machine.handleInput('parent', 'execmovement', ctrl); - } - })); - - const totalPower = []; - const totalFlow = []; - Object.values(mgc.machines).forEach(machine => { - const p = mgc.operatingPoint.readChild(machine, 'power', 'predicted', POSITIONS.AT_EQUIPMENT, mgc.unitPolicy.canonical.power); - const f = mgc.operatingPoint.readChild(machine, 'flow', 'predicted', POSITIONS.DOWNSTREAM, mgc.unitPolicy.canonical.flow); - if (p !== null) totalPower.push(p); - if (f !== null) totalFlow.push(f); - }); - - const sumP = totalPower.reduce((a, b) => a + b, 0); - const sumF = totalFlow.reduce((a, b) => a + b, 0); - mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, sumP, mgc.unitPolicy.canonical.power); - mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, sumF, mgc.unitPolicy.canonical.flow); - if (sumP > 0) { - mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(sumF / sumP); - } - } catch (err) { - mgc.logger?.error?.(err); - } -} - -module.exports = { equalFlowControl, prioPercentageControl, capFlowDemand, sortMachinesByPriority, filterOutUnavailableMachines }; +module.exports = { + equalFlowControl, computeEqualFlowDistribution, + capFlowDemand, sortMachinesByPriority, filterOutUnavailableMachines, +}; diff --git a/src/efficiency/groupEfficiency.js b/src/efficiency/groupEfficiency.js index 061640f..e54629a 100644 --- a/src/efficiency/groupEfficiency.js +++ b/src/efficiency/groupEfficiency.js @@ -44,19 +44,25 @@ class GroupEfficiency { } // Maps current efficiency onto [0..1] across [maxEfficiency..minEfficiency]. - // Degenerate case (max === min) collapses the band to a point — return 1. + // Returns undefined for any case where the metric is meaningless: + // - currentEfficiency missing + // - the [max..min] band has collapsed (homogeneous pump group, OR float + // noise so |max-min| < DEGENERATE_EPS). + // Consumers must treat undefined as "no data" and display accordingly, + // not as 0% / 100% — both readings would be misleading. calcRelativeDistanceFromPeak(currentEfficiency, maxEfficiency, minEfficiency) { - let distance = 1; - if (currentEfficiency != null && maxEfficiency !== minEfficiency && this.interpolation) { - distance = this.interpolation.interpolate_lin_single_point( - currentEfficiency, - maxEfficiency, - minEfficiency, - 0, - 1, - ); - } - return distance; + const DEGENERATE_EPS = 1e-9; // η points are 0..1, so 1e-9 catches float noise. + if (currentEfficiency == null) return undefined; + if (!this.interpolation) return undefined; + if (!Number.isFinite(maxEfficiency) || !Number.isFinite(minEfficiency)) return undefined; + if (Math.abs(maxEfficiency - minEfficiency) < DEGENERATE_EPS) return undefined; + return this.interpolation.interpolate_lin_single_point( + currentEfficiency, + maxEfficiency, + minEfficiency, + 0, + 1, + ); } // Returns both abs + rel; orchestrator decides whether to mirror onto diff --git a/src/groupOps/groupOperatingPoint.js b/src/groupOps/groupOperatingPoint.js index 7a4a4be..60492eb 100644 --- a/src/groupOps/groupOperatingPoint.js +++ b/src/groupOps/groupOperatingPoint.js @@ -13,6 +13,10 @@ class GroupOperatingPoint { // Late-binding via getters in the orchestrator works too — but // passing the live references avoids re-plumbing setters. this.ctx = ctx; + // Last header differential pressure (Pa) computed by equalize(). + // Consumers (optimizer, strategies) read this to convert raw + // flow/power to hydraulic efficiency η = (Q·ΔP)/P. + this.headerDiffPa = 0; } get measurements() { return this.ctx.measurements; } @@ -72,6 +76,9 @@ class GroupOperatingPoint { this.logger?.debug?.(`Skipping equalization: invalid header diff ${headerDiff} (down=${headerDownstream}, up=${headerUpstream})`); return; } + // Stash so downstream callers (optimizer, strategies) can compute + // hydraulic efficiency without re-reading every machine's pressure. + this.headerDiffPa = headerDiff; this.logger?.debug?.(`Equalizing operating point: down=${headerDownstream}, up=${headerUpstream}, diff=${headerDiff}`); diff --git a/src/groupcontrol.test.js b/src/groupcontrol.test.js index eda509b..f999521 100644 --- a/src/groupcontrol.test.js +++ b/src/groupcontrol.test.js @@ -5,11 +5,13 @@ const Machine = require('../../rotatingMachine/src/specificClass'); const Measurement = require('../../measurement/src/specificClass'); const baseCurve = require('../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json'); -const CONTROL_MODES = ['optimalcontrol', 'prioritycontrol', 'prioritypercentagecontrol']; +// prioritypercentagecontrol mode and per-instance scaling state were +// removed when set.demand became unit-self-describing — see +// commands/handlers.js (bare number = %, {value, unit} = absolute). +const CONTROL_MODES = ['optimalcontrol', 'prioritycontrol']; const MODE_LABELS = { optimalcontrol: 'OPT', prioritycontrol: 'PRIO', - prioritypercentagecontrol: 'PERC' }; const stateConfig = { @@ -60,7 +62,6 @@ function createGroupConfig(name) { return { general: { logging: { enabled: false, logLevel: 'error' }, name: `machinegroup-${name}` }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller' }, - scaling: { current: 'normalized' }, mode: { current: 'optimalcontrol' } }; } @@ -185,7 +186,9 @@ async function driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrd await sleep(15); mg.setMode(mode); - mg.setScaling('normalized'); // required for prioritypercentagecontrol, works for others too + // setScaling is gone — handleInput now takes canonical m³/s directly. This + // legacy diagnostic still works in % terms by sweeping demand 0..100 and + // mapping each step to canonical before dispatch. const dynamic = mg.calcDynamicTotals(); const span = Math.max(dynamic.flow.max - dynamic.flow.min, 1); @@ -197,7 +200,10 @@ async function driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrd let best = { demand, flow: 0, power: 0, efficiency: 0, error: Infinity }; for (let attempt = 0; attempt < 4; attempt += 1) { - await mg.handleInput('parent', demand, Infinity, priorityOrder); + // demand is a percent (0..100); convert to canonical m³/s for the + // post-refactor handleInput signature. + const canonical = dynamic.flow.min + (demand / 100) * (dynamic.flow.max - dynamic.flow.min); + await mg.handleInput('parent', canonical, Infinity, priorityOrder); await sleep(30); const totals = captureTotals(mg); diff --git a/src/io/output.js b/src/io/output.js index 85b5799..3a1813b 100644 --- a/src/io/output.js +++ b/src/io/output.js @@ -42,6 +42,20 @@ function getOutput(mgc) { out.absDistFromPeak = absDistFromPeak; out.relDistFromPeak = relDistFromPeak; + // System (header) differential pressure resolved by the last equalize. + // Dashboards use this to compute head = ΔP / (ρ · g) for Q-H plots + // and to scale the BEP indicators without re-reading every child. + // Emitted in canonical Pa and in the configured output unit (mbar + // by default) so the dashboard can pick whichever it prefers. + const headerDiffPa = mgc.operatingPoint?.headerDiffPa; + if (Number.isFinite(headerDiffPa) && headerDiffPa > 0) { + out.headerDiffPa = headerDiffPa; + const pUnit = unitPolicy.output.pressure; + // 1 mbar = 100 Pa. Only convert when we recognise mbar; otherwise + // leave the raw Pa to avoid a stale or silently wrong unit label. + if (pUnit === 'mbar') out.headerDiffMbar = headerDiffPa / 100; + } + // Group capacity + active-machine counts. Surfaced so dashboards can // show the same numbers the status badge does without subscribing to // every child node individually. diff --git a/src/specificClass.js b/src/specificClass.js index 69cd132..a0ee031 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -2,8 +2,12 @@ // // All real work lives in the concern modules under src/{groupOps,totals, // combinatorics,optimizer,efficiency,dispatch,control}. This file stitches -// them together: child-event routing, demand serialization, mode/scaling, +// them together: child-event routing, demand serialization, mode selection, // and the per-mode dispatch switch. +// +// Operator demand is always passed in here as a canonical m³/s number. The +// set.demand handler resolves units (%, m³/h, l/s, etc.) before calling +// handleInput, so this orchestrator has no scaling state and no unit logic. 'use strict'; @@ -37,7 +41,6 @@ class MachineGroup extends BaseDomain { // tests still write directly (matches the pumpingStation pattern). this.machines = {}; - this.scaling = this.config.scaling.current; this.mode = this.config.mode.current; this.absDistFromPeak = 0; this.relDistFromPeak = 0; @@ -117,11 +120,6 @@ class MachineGroup extends BaseDomain { // ── Surface kept for tests + commands ────────────────────────────── setMode(mode) { this.mode = mode; this.notifyOutputChanged(); } - setScaling(scaling) { - const allowed = new Set(this.defaultConfig.scaling.current.rules.values.map(v => v.value)); - if (allowed.has(scaling)) { this.scaling = scaling; this.notifyOutputChanged(); } - else this.logger.warn(`${scaling} is not a valid scaling option.`); - } isMachineActive(id) { const s = this.machines[id]?.state?.getCurrentState?.(); return ACTIVE_STATES.has(s); @@ -214,7 +212,15 @@ class MachineGroup extends BaseDomain { // INTENT lands on AT_EQUIPMENT only; DOWNSTREAM is the live aggregate. this.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestPower, this.unitPolicy.canonical.power); this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestFlow, this.unitPolicy.canonical.flow); - this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestFlow / bestResult.bestPower); + // Hydraulic efficiency η = (Q·ΔP)/P_shaft — a dimensionless 0..1 + // ratio in the same scale as each child rotatingMachine's `cog`. + // Keeps `calcDistanceBEP(eff, maxEfficiency, lowestEfficiency)` in + // handlePressureChange comparing apples to apples. + const dP = this.operatingPoint.headerDiffPa; + if (Number.isFinite(dP) && dP > 0 && bestResult.bestPower > 0) { + this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT) + .value((bestResult.bestFlow * dP) / bestResult.bestPower); + } this.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestCog); await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => { @@ -246,19 +252,16 @@ class MachineGroup extends BaseDomain { this.logger.error(`Invalid flow demand input: ${demand}.`); return; } + // Demand is canonical m³/s (the handler has already resolved units). + // The handler routes negatives directly to turnOffAllMachines, but + // keep a defensive check in case turnOff-state arrives some other way. + if (demandQ <= 0) { await this.turnOffAllMachines(); return; } await this.abortActiveMovements('new demand received'); const dt = this.calcDynamicTotals(); - let demandQout = 0; - - if (this.scaling === 'absolute') { - if (demandQ <= 0) { await this.turnOffAllMachines(); return; } - if (demandQ < this.absoluteTotals.flow.min) demandQout = this.absoluteTotals.flow.min; - else if (demandQ > this.absoluteTotals.flow.max) demandQout = this.absoluteTotals.flow.max; - else demandQout = demandQ; - } else if (this.scaling === 'normalized') { - if (demandQ <= 0) { await this.turnOffAllMachines(); return; } - demandQout = this.interpolation.interpolate_lin_single_point(demandQ, 0, 100, dt.flow.min, dt.flow.max); - } + // Clamp against the current-pressure envelope. + let demandQout = demandQ; + if (demandQout < dt.flow.min) demandQout = dt.flow.min; + else if (demandQout > dt.flow.max) demandQout = dt.flow.max; // Normalize for the switch — schema enum values use camelCase // (optimalControl, priorityControl) while legacy callers send @@ -266,10 +269,6 @@ class MachineGroup extends BaseDomain { const ctx = { mgc: this }; switch (String(this.mode || '').toLowerCase()) { case 'prioritycontrol': await control.equalFlowControl(ctx, demandQout, powerCap, priorityList); break; - case 'prioritypercentagecontrol': - if (this.scaling !== 'normalized') { this.logger.warn('Priority percentage control needs normalized scaling.'); return; } - await control.prioPercentageControl(ctx, demandQout, priorityList); - break; case 'optimalcontrol': await this._optimalControl(demandQout, powerCap); break; default: this.logger.warn(`${this.mode} is not a valid mode.`); } diff --git a/test/_output-manifest.md b/test/_output-manifest.md new file mode 100644 index 0000000..f023415 --- /dev/null +++ b/test/_output-manifest.md @@ -0,0 +1,130 @@ +# machineGroupControl — Output Manifest + +Per `.claude/rules/output-coverage.md`. Single source of truth for what MGC +emits on Port 0/1/2, where the value comes from, and which test exercises it +in populated AND degraded states. + +**Convention for missing values:** keys are **absent** when the underlying +source has not produced a value yet (pre-first-tick, no demand, no pressure). +Once produced, a key may be **explicitly null/undefined** only in the +documented degenerate cases below. The dashboard formatter must treat both +absent and null/undefined as "no data" (display `'—'`) — see the +`pct`/`num` helpers in `examples/02-Dashboard.json :: fn_status_split`. + +--- + +## Port 0 — process data + +Built by `src/io/output.js :: getOutput(mgc)`. Delta-compressed by +`outputUtils.formatMsg(..., 'process')` — only changed keys appear in each emit. + +### Static fields (always emitted once MGC has been initialised) + +| Key | Source | Type / Range | Populated test | Degraded test | +|---|---|---|---|---| +| `mode` | `mgc.mode` (set via `set.mode` command) | string ∈ {`optimalcontrol`, `prioritycontrol`, …} | commands.basic.test.js, ncog-distribution.integration.test.js | n/a — always set from constructor default | +| `scaling` | `mgc.scaling` | string ∈ {`absolute`, `normalized`} or undefined | commands.basic.test.js | dashboard-fanout (undefined → raw-rows shows '—') | +| `absDistFromPeak` | `groupEfficiency.calcDistanceFromPeak` (specificClass.js:132) | number ≥ 0 (η-points) | bep-distance-demand-sweep, group-bep-cascade, groupEfficiency.basic | groupEfficiency.basic test 7 (undefined when current = null) | +| `relDistFromPeak` | `groupEfficiency.calcRelativeDistanceFromPeak` | number ∈ [0,1] **OR `undefined`** for degenerate (homogeneous pumps) | bep-distance-demand-sweep, group-bep-cascade | groupEfficiency.basic tests 5/6/7 (undefined cases), dashboard-fanout test 11 (undefined → '—' display) | +| `flowCapacityMax` | `mgc.dynamicTotals.flow.max` (totalsCalculator) | number m³/s ≥ 0 | totalsCalculator.basic, dashboard-fanout (post-setup) | absent until first equalize; dashboard-fanout (state A) | +| `flowCapacityMin` | `mgc.dynamicTotals.flow.min` | number m³/s ≥ 0 | totalsCalculator.basic | same as above | +| `machineCount` | `Object.keys(mgc.machines).length` | integer ≥ 0 | demand-cycle-walkthrough, ncog-distribution | n/a — always reflects current registration count | +| `machineCountActive` | filtered count excluding `off`/`maintenance` states | integer ≥ 0 | demand-cycle-walkthrough, ncog-distribution | dashboard-fanout (state A: 0 active) | + +### Conditional pressure-header fields (emitted only when equalize resolved a positive ΔP) + +| Key | Source | Type / Range | Populated test | Degraded test | +|---|---|---|---|---| +| `headerDiffPa` | `mgc.operatingPoint.headerDiffPa` (groupOperatingPoint.equalize) | number Pa > 0 | groupOperatingPoint.basic, dashboard-fanout (state B/C) | dashboard-fanout (state A — absent) | +| `headerDiffMbar` | derived `headerDiffPa / 100` when `unitPolicy.output.pressure === 'mbar'` | number mbar > 0 | dashboard-fanout (state B/C) | absent when output pressure unit ≠ mbar — **not explicitly tested** | + +### Dynamic measurement fields — pattern `{position}_{variant}_{type}` + +Built by the loop at `io/output.js:23-39`. For each type×variant×position the +container holds, one key is emitted **only if the value is non-null**. + +Positions: `downstream`, `upstream`, `atEquipment`. Plus `differential__` when both `downstream` and `upstream` exist. + +**Predicted measurements MGC writes itself (via writeOwn):** + +| Key | Source (write site) | Type / Range | Populated test | Degraded test | +|---|---|---|---|---| +| `atEquipment_predicted_flow` | `handlePressureChange` (specificClass:153), `_optimalControl` (specificClass:214), `equalFlowControl` (control/strategies:118), `turnOffAllMachines` (specificClass:297) | number, canonical m³/s converted to `unitPolicy.output.flow` | bep-distance-demand-sweep, dashboard-fanout (state B/C), ncog-distribution | dashboard-fanout (state A: absent), turnoff-deadlock (post-shutdown = 0) | +| `downstream_predicted_flow` | `handlePressureChange` (specificClass:156 — mirrors AT_EQUIPMENT for PS contract), `turnOffAllMachines` (specificClass:296) | same as above | implicit in bep-distance-demand-sweep getOutput | turnoff-deadlock (post-shutdown = 0) | +| `atEquipment_predicted_power` | same call sites as flow (specificClass:157, 213; strategies:117; specificClass:298) | number, canonical W converted to `unitPolicy.output.power` | bep-distance-demand-sweep, dashboard-fanout, distribution-power-table | turnoff-deadlock (= 0) | +| `atEquipment_predicted_efficiency` | `_optimalControl` (specificClass:221), `equalFlowControl` (strategies:122) — only when `dP > 0 && bestPower > 0` | number ∈ [0, 1] hydraulic η = (Q·ΔP)/P | bep-distance-demand-sweep, dashboard-fanout (state C) | **absent** when dP ≤ 0 or bestPower ≤ 0 — guarded but not explicitly tested | +| `atEquipment_predicted_Ncog` | `_optimalControl` (specificClass:224), `equalFlowControl` (strategies:125) | number, range **0..N where N = active pumps** (SUM of per-pump NCog from `bepGravitation.js:162` totalCog) — NOT 0..1; see [[project-mgc-bep-metrics-semantics]] | ncog-distribution (9 tests), bep-distance-demand-sweep, dashboard-fanout (state C) | dashboard-fanout normalizes by `machineCountActive` for display — tests 6/7/8/9/10 | + +**Measured pressures forwarded from children:** +MGC subscribes to each registered measurement child (specificClass.js:91-104) +and re-emits the child's reading on its own `MeasurementContainer`. If a +pressure measurement child registers at position `downstream`, MGC will +emit `downstream_measured_pressure` on Port 0 the next time `getOutput` runs. + +| Key pattern | Source | Tests | +|---|---|---| +| `_measured_` | child measurement node forwarded via `MeasurementContainer.emitter` (specificClass:91-105) | indirect — group-bep-cascade.integration drives pressure events through registered children; not asserted as a named output key | +| `differential_measured_pressure` | computed when both `downstream_measured_pressure` and `upstream_measured_pressure` exist (output.js:33-37) | indirect via dashboard-fanout (used by fn_qh_point for header ΔP fallback) | + +--- + +## Port 1 — InfluxDB telemetry + +Built by `outputUtils.formatMsg(..., 'influxdb')` — same `getOutput` source, +different formatter. Emits the same key set as Port 0 with InfluxDB +line-protocol tag/field discipline (cardinality rules per `.claude/rules/telemetry.md`). + +| Concern | Status | +|---|---| +| Keys | Identical to Port 0; the influxdb formatter (`generalFunctions/src/helper/formatters/influxdbFormatter.js`) decides which become tags vs fields. | +| Test coverage | **None.** No test file imports/asserts the influxdb formatter for MGC. Regression vector if a key is added/renamed without checking cardinality. Tracked. | + +--- + +## Port 2 — registration / control plumbing + +Emitted on startup by `BaseNodeAdapter` (one message per node). + +| Topic | Payload shape | Source | Tests | +|---|---|---|---| +| `registerChild` | `{ id: node.id, positionVsParent: }` | BaseNodeAdapter init — sends to upstream parent so it can subscribe to this node's measurements | structure-examples.integration, commands.basic.test.js test 5 (`child.register`) — receiver side | + +--- + +## Events emitted on `mgc.source.measurements.emitter` + +These are NOT Port 0/1/2 emissions — they're in-process events that downstream +EVOLV nodes (e.g., pumpingStation) subscribe to via the parent-child handshake. +Listed here for completeness; covered by `.claude/rules/telemetry.md` rather +than this manifest. + +- `flow.predicted.atequipment` — fired on every `writeOwn` to flow/predicted/AT_EQUIPMENT +- `flow.predicted.downstream` — fired on every `writeOwn` to flow/predicted/DOWNSTREAM (the live aggregate the PS subscribes to) +- `power.predicted.atequipment` +- `efficiency.predicted.atequipment` +- `Ncog.predicted.atequipment` +- `.measured.` — re-emit of any registered measurement child + +Documented in `CONTRACT.md`; tested indirectly via `group-bep-cascade.integration.test.js` and `ncog-distribution.integration.test.js`. + +--- + +## Coverage gaps (open items) + +These are known holes flagged during the 2026-05-14 governance review; not yet +fixed but documented so they don't regress silently. + +1. **Port 1 (InfluxDB) has no dedicated tests.** Any rename of a Port 0 key + should add an explicit Port 1 assertion to prevent silent cardinality + regressions. +2. **`headerDiffMbar` only emitted when `unitPolicy.output.pressure === 'mbar'`.** + The fallback (non-mbar configurations) isn't explicitly tested. +3. **`atEquipment_predicted_efficiency` absent-state isn't asserted.** The + `dP > 0 && bestPower > 0` guard exists but no test pins the absence. +4. **Forwarded measured measurements** (`_measured_`) aren't + asserted as named output keys — only their underlying behaviour is exercised. +5. **`scaling` undefined behaviour** — schema removed `scaling.current` for + several modes; what MGC emits for those is implicit, not tested. + +When any of these is closed, move the row up into the appropriate table and +delete the entry here. diff --git a/test/basic/commands.basic.test.js b/test/basic/commands.basic.test.js index 9345822..f525a48 100644 --- a/test/basic/commands.basic.test.js +++ b/test/basic/commands.basic.test.js @@ -22,23 +22,33 @@ function makeLogger() { }; } -function makeSource({ name = 'mgc-1', handleInputResult = undefined } = {}) { +function makeSource({ name = 'mgc-1', handleInputResult = undefined, dt = { flow: { min: 0, max: 100 } } } = {}) { const calls = { setMode: [], - setScaling: [], handleInput: [], registerChild: [], + turnOffAllMachines: 0, }; const source = { logger: makeLogger(), config: { general: { name } }, setMode: (m) => calls.setMode.push(m), - setScaling: (s) => calls.setScaling.push(s), handleInput: async (src, demand) => { calls.handleInput.push({ src, demand }); if (handleInputResult instanceof Error) throw handleInputResult; return handleInputResult; }, + // Used by set.demand handler when unit is %: needs dt.flow + interpolation. + // With min=0, max=100, the linear interpolation is identity so a bare + // numeric demand round-trips through handleInput unchanged. + calcDynamicTotals: () => dt, + interpolation: { + interpolate_lin_single_point: (x, ix, iy, ox, oy) => { + if (iy === ix) return ox; + return ox + ((x - ix) * (oy - ox)) / (iy - ix); + }, + }, + turnOffAllMachines: async () => { calls.turnOffAllMachines += 1; }, childRegistrationUtils: { registerChild: (childSource, position) => calls.registerChild.push({ childSource, position }), @@ -69,14 +79,31 @@ test('canonical topics dispatch to their handlers', async () => { await reg.dispatch({ topic: 'set.mode', payload: 'prioritycontrol' }, source, makeCtx()); assert.deepEqual(calls.setMode, ['prioritycontrol']); - await reg.dispatch({ topic: 'set.scaling', payload: 'normalized' }, source, makeCtx()); - assert.deepEqual(calls.setScaling, ['normalized']); - + // bare-number demand → interpreted as % → interpolated against dt.flow. + // Default test dt is {min:0,max:100} so % is identity. await reg.dispatch({ topic: 'set.demand', payload: '12.5' }, source, makeCtx()); assert.equal(calls.handleInput.length, 1); assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 12.5 }); }); +test('set.demand with explicit flow unit converts to canonical m³/s', async () => { + const { source, calls } = makeSource(); + const reg = makeRegistry(makeLogger()); + await reg.dispatch({ topic: 'set.demand', payload: { value: 200, unit: 'm3/h' } }, source, makeCtx()); + assert.equal(calls.handleInput.length, 1); + // 200 m³/h = 0.0555... m³/s + assert.ok(Math.abs(calls.handleInput[0].demand - 0.05555555555555556) < 1e-9, + `expected ~0.0556 m³/s, got ${calls.handleInput[0].demand}`); +}); + +test('set.demand negative value triggers turnOffAllMachines and bypasses handleInput', async () => { + const { source, calls } = makeSource(); + const reg = makeRegistry(makeLogger()); + await reg.dispatch({ topic: 'set.demand', payload: -1 }, source, makeCtx()); + assert.equal(calls.turnOffAllMachines, 1); + assert.equal(calls.handleInput.length, 0); +}); + test('child.register canonical resolves child via RED.nodes.getNode', async () => { const { source, calls } = makeSource(); const child = { id: 'child-1', source: { tag: 'child-domain' } }; @@ -103,11 +130,6 @@ test('aliases dispatch to the same handler and log a one-time deprecation', asyn let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated")); assert.equal(warns.length, 1, 'setMode deprecation warning should log exactly once'); - await reg.dispatch({ topic: 'setScaling', payload: 'absolute' }, source, makeCtx({ logger: ctxLogger })); - warns = ctxLogger.calls.warn.filter((m) => m.includes("'setScaling' is deprecated")); - assert.equal(warns.length, 1); - assert.deepEqual(calls.setScaling, ['absolute']); - await reg.dispatch({ topic: 'Qd', payload: 5 }, source, makeCtx({ logger: ctxLogger })); warns = ctxLogger.calls.warn.filter((m) => m.includes("'Qd' is deprecated")); assert.equal(warns.length, 1); diff --git a/test/basic/equalFlowDistribution.basic.test.js b/test/basic/equalFlowDistribution.basic.test.js new file mode 100644 index 0000000..ab033dc --- /dev/null +++ b/test/basic/equalFlowDistribution.basic.test.js @@ -0,0 +1,132 @@ +// Unit tests for the pure distribution math extracted out of equalFlowControl. +// Decoupling target: the algorithm should be testable without a full MGC. + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { computeEqualFlowDistribution } = require('../../src/control/strategies.js'); + +// Tiny helpers to make synthetic machines. The pure function still calls +// filterOutUnavailableMachines, which reads machine.state.getCurrentState() +// and machine.isValidActionForMode() — stub both so the algorithm sees the +// machine as available. groupFlow/groupCalcPower are injected. +function mkMachine(id, capability = { min: 0.01, max: 0.10, power: (flow) => flow * 1000 }, state = 'operational') { + return { + id, + machine: { + __testCapability: capability, + state: { getCurrentState: () => state }, + isValidActionForMode: () => true, + }, + }; +} +const dummyLogger = { warn() {}, error() {}, debug() {}, info() {} }; + +// Default injected helpers: read from the synthetic machine's __testCapability. +const groupFlow = (m) => ({ + currentFxyYMin: m.__testCapability.min, + currentFxyYMax: m.__testCapability.max, +}); +const groupCalcPower = (m, flow) => m.__testCapability.power(flow); + +function basicArgs(overrides = {}) { + const m = { a: mkMachine('a').machine, b: mkMachine('b').machine, c: mkMachine('c').machine }; + return { + machines: m, Qd: 0.06, + dynamicTotals: { flow: { min: 0.01, max: 0.30 } }, + activeTotals: { flow: { min: 0.03, max: 0.30 } }, + priorityList: ['a', 'b', 'c'], + isMachineActive: () => true, + groupFlow, groupCalcPower, logger: dummyLogger, + ...overrides, + }; +} + +test('default case: distributes Qd equally across active machines', () => { + const r = computeEqualFlowDistribution(basicArgs({ Qd: 0.06 })); + // 3 active pumps, demand 0.06 → 0.02 per pump. + assert.equal(r.flowDistribution.length, 3); + for (const entry of r.flowDistribution) { + assert.ok(Math.abs(entry.flow - 0.02) < 1e-12, `entry.flow=${entry.flow}`); + } + assert.ok(Math.abs(r.totalFlow - 0.06) < 1e-12); + // power(flow) = flow * 1000 in the test capability → 0.02 * 1000 = 20 W per pump. + assert.ok(Math.abs(r.totalPower - 60) < 1e-9); +}); + +test('Qd above active capacity: starts additional priority machines until covered', () => { + // Only one machine "active" to start with; demand exceeds its envelope. + // Algorithm should bring more priority machines online via the high-demand branch. + const active = new Set(['a']); + const args = basicArgs({ + Qd: 0.18, // above any single pump's max (0.10) + activeTotals: { flow: { min: 0.01, max: 0.10 } }, + isMachineActive: (id) => active.has(id), + }); + const r = computeEqualFlowDistribution(args); + // The algorithm reduces Qd iteratively (Qd /= i) until it fits per-pump max. + // We don't assert exact splits — only that flowDistribution is non-empty + // and totalFlow is finite, since the legacy algorithm is preserved as-is. + assert.ok(r.flowDistribution.length >= 1); + assert.ok(Number.isFinite(r.totalFlow)); + assert.ok(Number.isFinite(r.totalPower)); +}); + +test('Qd below active min flow: routes excess machines to flow=0 and redistributes', () => { + // demand below active min — algorithm shuts off lowest-priority machine(s) + // and redistributes Qd across the remainder. + const args = basicArgs({ + Qd: 0.015, + dynamicTotals: { flow: { min: 0.01, max: 0.30 } }, + activeTotals: { flow: { min: 0.03, max: 0.30 } }, // active min > Qd + }); + const r = computeEqualFlowDistribution(args); + const offCount = r.flowDistribution.filter(e => e.flow === 0).length; + assert.ok(offCount >= 1, `expected ≥1 machine to be shut off, got distribution: ${JSON.stringify(r.flowDistribution)}`); + const totalServed = r.flowDistribution.filter(e => e.flow > 0).reduce((s, e) => s + e.flow, 0); + assert.ok(Math.abs(totalServed - 0.015) < 1e-12, `served flow ${totalServed} should equal Qd 0.015`); +}); + +test('totalCog is always 0 for equalFlow — preserves legacy contract', () => { + // The historical algorithm sets totalCog = 0 in this strategy (BEP-Gravitation + // is the only optimizer that produces a meaningful per-combination cog). + // Pinned here so a future "improvement" doesn't silently introduce a fake value. + const r = computeEqualFlowDistribution(basicArgs()); + assert.equal(r.totalCog, 0); +}); + +test('isMachineActive is consulted for COUNT but not for SELECTION (legacy quirk)', () => { + // Pins pre-existing behaviour of the default branch: it counts how many + // machines are active (countActive) to decide how to split Qd, but then + // iterates the FIRST countActive machines in priority order — which may + // include inactive ones. So 2 of 3 active + Qd within range → first 2 in + // priorityList both get flow, regardless of which are actually active. + // + // This is a latent bug that pre-dates the strategies decoupling refactor. + // Documenting it here so a future cleanup is a deliberate change with a + // failing-then-passing test, not a silent semantic shift. + const active = new Set(['a', 'c']); + const r = computeEqualFlowDistribution(basicArgs({ + Qd: 0.06, + isMachineActive: (id) => active.has(id), + })); + // Today: machinesInPriorityOrder[0]='a', [1]='b' → 'a' and 'b' both get 0.03. + // 'c' (active but third in priority order) gets nothing. + const aFlow = r.flowDistribution.find(e => e.machineId === 'a')?.flow; + const bFlow = r.flowDistribution.find(e => e.machineId === 'b')?.flow; + const cFlow = r.flowDistribution.find(e => e.machineId === 'c')?.flow; + assert.equal(aFlow, 0.03, 'a (priority 0, active)'); + assert.equal(bFlow, 0.03, 'b (priority 1, INACTIVE — receives flow anyway, bug)'); + assert.equal(cFlow, undefined, 'c (priority 2, active — does NOT receive flow, bug)'); +}); + +test('priorityList controls iteration order', () => { + // The order in flowDistribution should match priorityList — i.e., machine 'c' + // appears before machine 'a' when priorityList = ['c', 'b', 'a']. + const r = computeEqualFlowDistribution(basicArgs({ + priorityList: ['c', 'b', 'a'], + })); + assert.equal(r.flowDistribution[0].machineId, 'c'); +}); diff --git a/test/basic/groupEfficiency.basic.test.js b/test/basic/groupEfficiency.basic.test.js index a12b6ec..95c3ab2 100644 --- a/test/basic/groupEfficiency.basic.test.js +++ b/test/basic/groupEfficiency.basic.test.js @@ -53,14 +53,33 @@ test('calcDistanceBEP returns both abs + rel', () => { assert.ok(Math.abs(relDistFromPeak - expectedRel) < 1e-9); }); -test('calcRelativeDistanceFromPeak returns 1 when max === min (degenerate)', () => { +test('calcRelativeDistanceFromPeak returns undefined when max === min (degenerate)', () => { + // For homogeneous pump groups (all cogs equal), the [max..min] band + // collapses and the metric is mathematically undefined. Return undefined + // so the dashboard displays "—" instead of a misleading 0% / 100%. const ge = makeGE(); - assert.equal(ge.calcRelativeDistanceFromPeak(0.85, 0.8, 0.8), 1); + assert.equal(ge.calcRelativeDistanceFromPeak(0.85, 0.8, 0.8), undefined); }); -test('calcRelativeDistanceFromPeak returns 1 when current is null', () => { +test('calcRelativeDistanceFromPeak returns undefined when max ≈ min within epsilon', () => { + // Float noise from identical pumps: max-min might be 1e-12 rather than 0. + // Must still report undefined — the interpolation extrapolates wildly here. const ge = makeGE(); - assert.equal(ge.calcRelativeDistanceFromPeak(null, 0.92, 0.7), 1); + assert.equal(ge.calcRelativeDistanceFromPeak(0.85, 0.211264, 0.211263999), undefined); +}); + +test('calcRelativeDistanceFromPeak returns undefined when current is null', () => { + const ge = makeGE(); + assert.equal(ge.calcRelativeDistanceFromPeak(null, 0.92, 0.7), undefined); +}); + +test('calcDistanceBEP propagates undefined relDist for degenerate input', () => { + // Regression: if currentEff is finite, absDist is still computed (it's + // just |current - peak|), but relDist must be undefined for degenerate. + const ge = makeGE(); + const { absDistFromPeak, relDistFromPeak } = ge.calcDistanceBEP(0.206, 0.211, 0.211); + assert.ok(Math.abs(absDistFromPeak - 0.005) < 1e-9); + assert.equal(relDistFromPeak, undefined); }); test('calcGroupEfficiency handles a single machine', () => { diff --git a/test/integration/bep-distance-demand-sweep.integration.test.js b/test/integration/bep-distance-demand-sweep.integration.test.js new file mode 100644 index 0000000..e80a91d --- /dev/null +++ b/test/integration/bep-distance-demand-sweep.integration.test.js @@ -0,0 +1,125 @@ +// Empirical answer: does absDistFromPeak / relDistFromPeak move with demand? +// Drives the live MGC + 3 identical pumps (same model as the dashboard demo) +// across a demand sweep and records what each metric actually does. The test +// asserts the expected qualitative shape, so any future change that +// regresses BEP-distance sensitivity will fail loudly. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const RM = require('../../../rotatingMachine/src/specificClass'); +const MGC = require('../../src/specificClass'); +const { getOutput } = require('../../src/io/output'); + +const PUMP_MODEL = 'hidrostal-H05K-S03R'; +const HEADER_DP_MBAR = 1100; + +// stateConfig.time = 0 for every transition so warmup/cooldown don't add real +// seconds — without this the 4-demand sweep × 3 pumps takes >120s and the test +// runner kills it. +const INSTANT_STATE = { + time: { starting: 0, warmingup: 0, operational: 0, accelerating: 0, + decelerating: 0, stopping: 0, coolingdown: 0, idle: 0, + maintenance: 0, emergencystop: 0, off: 0 }, +}; + +function mkPump(id) { + return new RM({ + general: { id, name: id }, + asset: { model: PUMP_MODEL, unit: 'm3/h' }, + }, INSTANT_STATE); +} + +async function buildGroupWithPressure() { + const mgc = new MGC({ + general: { id: 'mgc', name: 'mgc' }, + functionality: { mode: { current: 'optimalControl' }, positionVsParent: 'atEquipment' }, + }); + const pumps = ['A','B','C'].map(l => mkPump(`pump-${l}`)); + for (const p of pumps) { + mgc.childRegistrationUtils?.registerChild?.(p, 'atEquipment'); + } + for (const p of pumps) { + p.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'sim-up' }); + p.updateMeasuredPressure(HEADER_DP_MBAR, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'sim-dn' }); + } + // Let pressure events propagate through the emitter chain. + await new Promise(r => setTimeout(r, 50)); + return { mgc, pumps }; +} + +async function sweepDemand(mgc, demands_m3h) { + const rows = []; + for (const Qd_m3h of demands_m3h) { + const Qd = Qd_m3h / 3600; // m3/h → m3/s + try { await mgc.handleInput('parent', Qd); } + catch (e) { /* turnOff or no-combination paths are part of the contract */ } + await new Promise(r => setTimeout(r, 30)); + const out = getOutput(mgc); + rows.push({ + demand: Qd_m3h, + flow: out.atEquipment_predicted_flow, + eta: out.atEquipment_predicted_efficiency, + absDist: out.absDistFromPeak, + relDist: out.relDistFromPeak, + ncog: out.atEquipment_predicted_Ncog, + nAct: out.machineCountActive, + }); + } + return rows; +} + +test('absDistFromPeak rises when demand pushes pumps off BEP', async () => { + const { mgc } = await buildGroupWithPressure(); + // Sweep covers "comfortably within combined BEP" (low/mid) and "over the + // group's BEP envelope, pumps must push" (high). For hidrostal-H05K-S03R + // at 1100 mbar, single-pump max ≈ 230 m³/h, 3-pump max ≈ 680 m³/h. Demand + // 600 m³/h forces each pump well past BEP. + const rows = await sweepDemand(mgc, [100, 200, 300, 600]); + + // Sanity: pumps actually accepted the demand and flow is rising. + assert.ok(rows[3].flow > rows[0].flow + 100, + `flow should rise with demand, got ${JSON.stringify(rows.map(r => r.flow))}`); + + // absDist should be larger at over-capacity demand than at within-capacity. + // Use a generous tolerance — the test asserts the QUALITATIVE shape, not + // exact numbers (which depend on curve interpolation). + const lowAbs = Math.min(rows[0].absDist, rows[1].absDist, rows[2].absDist); + const highAbs = rows[3].absDist; + assert.ok(highAbs > lowAbs + 0.005, + `absDistFromPeak should be larger off-BEP than on-BEP. ` + + `low (Qd∈{100,200,300}): min=${lowAbs}, high (Qd=600): ${highAbs}. ` + + `Full rows: ${JSON.stringify(rows, null, 2)}`); +}); + +test('absDistFromPeak ≈ 0 across the within-BEP demand range (working as designed)', async () => { + const { mgc } = await buildGroupWithPressure(); + const rows = await sweepDemand(mgc, [100, 200, 300]); + // The BEP-Gravitation optimizer is supposed to KEEP us at BEP for demands + // the group can absorb at BEP. So absDist staying near zero across the + // "easy" range is the correct outcome — NOT a bug. This test pins that + // behaviour so any future "fix" that introduces drift here fails. + for (const r of rows) { + assert.ok(r.absDist != null && r.absDist < 0.02, + `at demand ${r.demand} m³/h, absDist=${r.absDist} should be near zero ` + + `(optimizer holds BEP); only off-BEP demand should produce noticeable drift`); + } +}); + +test('relDistFromPeak is structurally ill-defined for homogeneous pump groups', async () => { + const { mgc } = await buildGroupWithPressure(); + const rows = await sweepDemand(mgc, [100, 200, 300, 600]); + // 3 identical pumps → all cogs equal → max=mean=min in calcDistanceBEP. + // The interpolation [max..min] → [0..1] collapses; the metric is + // mathematically undefined here. Whatever value comes out is float-noise + // dependent and MUST NOT be interpreted as "BEP distance percentage". + // This test documents the limitation as a contract; it deliberately does + // not assert a specific value — it asserts the metric does NOT move + // monotonically with demand (which it shouldn't for identical pumps). + const uniqueRel = new Set(rows.map(r => r.relDist)); + assert.ok(uniqueRel.size <= 2, + `relDistFromPeak is expected to be effectively constant for identical pumps. ` + + `Distinct values across sweep: ${[...uniqueRel].join(', ')}. ` + + `If you want this metric to track demand, configure pumps with different ` + + `peak η (different models or different curve scaling).`); +}); diff --git a/test/integration/dashboard-fanout.integration.test.js b/test/integration/dashboard-fanout.integration.test.js new file mode 100644 index 0000000..241052c --- /dev/null +++ b/test/integration/dashboard-fanout.integration.test.js @@ -0,0 +1,240 @@ +// Output-coverage tests for examples/02-Dashboard.json :: fn_status_split. +// Exercises every output port in three states (deploy / post-setup / post-demand) +// AND verifies the per-port format contract that every downstream ui-* widget +// or chart expects. Per .claude/rules/output-coverage.md. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const flow = JSON.parse(fs.readFileSync( + path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8')); +const fn = flow.find(n => n.id === 'fn_status_split'); + +function runFn(msgs) { + let ctxStore = {}; + const context = { + get: (k) => ctxStore[k], + set: (k, v) => { ctxStore[k] = v; }, + }; + const fn_body = new Function('msg', 'context', fn.func); + return msgs.map(msg => fn_body(msg, context)); +} + +// Indices into the 17-output return array. Kept here as the manifest contract +// for this function — every test below references these names, never raw ints. +const PORT = { + text_mode: 0, text_flow: 1, text_power: 2, text_capacity: 3, + text_machines: 4, text_bep_rel: 5, text_eta: 6, text_eta_peak: 7, + text_bep_abs: 8, text_ncog: 9, + chart_flow: 10, chart_capacity: 11, chart_power: 12, chart_bep_rel: 13, + chart_eta: 14, + raw_rows: 15, raw_passthrough: 16, +}; + +const initialMsg = { + payload: { + mode: 'optimalControl', scaling: 'normalized', + absDistFromPeak: 0, relDistFromPeak: 0, + flowCapacityMax: 0, flowCapacityMin: 0, + machineCount: 3, machineCountActive: 0, + }, +}; +const postSetupMsg = { + payload: { + atEquipment_predicted_flow: 0, downstream_predicted_flow: 0, + atEquipment_predicted_power: 0, + flowCapacityMax: 450, flowCapacityMin: 0, + machineCountActive: 0, + headerDiffPa: 110000, headerDiffMbar: 1100, + }, +}; +const postDemandMsg = { + payload: { + atEquipment_predicted_flow: 200, + downstream_predicted_flow: 200, + atEquipment_predicted_power: 11.4, + atEquipment_predicted_efficiency: 0.62, + // Ncog as MGC actually emits it: SUM of per-pump NCog values. + // 2 pumps each at NCog=0.6 → sum=1.2; per-pump average should display as 60.0 %. + atEquipment_predicted_Ncog: 1.2, + absDistFromPeak: 0.05, relDistFromPeak: 0.08, + flowCapacityMax: 450, machineCountActive: 2, + }, +}; + +test('manifest: function has exactly 17 outputs and wires array matches', () => { + assert.equal(fn.outputs, 17); + assert.equal(fn.wires.length, 17); +}); + +test('State A (deploy-time): no AT_EQUIPMENT keys → flow/power text show em-dash', () => { + const [out] = runFn([initialMsg]); + assert.equal(out[PORT.text_mode].payload, 'optimalControl'); + assert.equal(out[PORT.text_flow].payload, '—'); + assert.equal(out[PORT.text_power].payload, '—'); + assert.equal(out[PORT.text_ncog].payload, '—'); + assert.equal(out[PORT.text_eta].payload, '—'); +}); + +test('State A: charts with no source data emit null msg, never { payload: null }', () => { + const [out] = runFn([initialMsg]); + // Charts 10, 12, 14 have no source data in State A → must be null (drop msg). + assert.equal(out[PORT.chart_flow], null, 'chart_flow must be null when flow missing'); + assert.equal(out[PORT.chart_power], null, 'chart_power must be null when power missing'); + assert.equal(out[PORT.chart_eta], null, 'chart_eta must be null when eta missing'); + // For every msg-emitting chart output: payload is never literally null. + for (const idx of Object.values(PORT)) { + if (out[idx] && Object.prototype.hasOwnProperty.call(out[idx], 'payload')) { + assert.notEqual(out[idx].payload, null, + `port ${idx} emitted { payload: null } — would crash ui-chart`); + } + } +}); + +test('State B (post-setup, no demand): flow/power = 0, eta missing', () => { + const [, out] = runFn([initialMsg, postSetupMsg]); + assert.equal(out[PORT.text_flow].payload, '0.0 m³/h'); + assert.equal(out[PORT.text_power].payload, '0.00 kW'); + assert.equal(out[PORT.text_capacity].payload, '0.0 – 450.0 m³/h'); + // η still missing → '—' + assert.equal(out[PORT.text_eta].payload, '—'); +}); + +test('State C (post-demand): every text/chart output has real value', () => { + const [, , out] = runFn([initialMsg, postSetupMsg, postDemandMsg]); + assert.equal(out[PORT.text_flow].payload, '200.0 m³/h'); + assert.equal(out[PORT.text_power].payload, '11.40 kW'); + assert.equal(out[PORT.text_eta].payload, '62.0 %'); + // BEP abs gap: η-points dimensionless, 3 dp. + assert.equal(out[PORT.text_bep_abs].payload, '0.050'); + // Charts have numeric payload. + assert.equal(out[PORT.chart_flow].payload, 200); + assert.equal(out[PORT.chart_power].payload, 11.4); + assert.equal(out[PORT.chart_eta].payload, 62); +}); + +test('NCog formatter: SUM is normalized by machineCountActive before display', () => { + // The fix under test. MGC emits Ncog as the SUM of per-pump NCog values + // (range 0..N), so a raw pct() would display 120% for 2 pumps at 0.6 each. + // The formatter must divide by machineCountActive first. + const [, , out] = runFn([initialMsg, postSetupMsg, postDemandMsg]); + // 2 pumps × 0.6 each = sum 1.2, mean 0.6, displayed "60.0 %". + assert.equal(out[PORT.text_ncog].payload, '60.0 %'); +}); + +test('NCog formatter: ncogSum=0 with active pumps → 0.0 %, not em-dash', () => { + const msg = { payload: { ...postSetupMsg.payload, + atEquipment_predicted_Ncog: 0, machineCountActive: 3 } }; + const [out] = runFn([msg]); + // Today this is exactly what the live MGC emits (per-pump groupNCog=0 + // for the hidrostal-H05K-S03R curve at 110 kPa). The dashboard must show + // a clean "0.0 %" — not "—" — because we DO have data, it's just zero. + assert.equal(out[PORT.text_ncog].payload, '0.0 %'); +}); + +test('NCog formatter: ncogSum present but machineCountActive = 0 → em-dash (no /0)', () => { + const msg = { payload: { atEquipment_predicted_Ncog: 1.5, machineCountActive: 0 } }; + const [out] = runFn([msg]); + assert.equal(out[PORT.text_ncog].payload, '—'); +}); + +test('NCog formatter: ncogSum present but machineCountActive missing → em-dash', () => { + const msg = { payload: { atEquipment_predicted_Ncog: 1.5 /* no nAct */ } }; + const [out] = runFn([msg]); + assert.equal(out[PORT.text_ncog].payload, '—'); +}); + +test('NCog formatter: 3 pumps each at NCog=0.5 (sum 1.5) → 50.0 %, not 150 %', () => { + // Regression test for the bug class — the formatter was displaying sum × 100, + // so 1.5 became "150.0 %". Verify the normalization sticks. + const msg = { payload: { + atEquipment_predicted_Ncog: 1.5, + machineCountActive: 3, + } }; + const [out] = runFn([msg]); + assert.equal(out[PORT.text_ncog].payload, '50.0 %'); +}); + +test('BEP rel%: undefined bepRel → "—" (degenerate homogeneous-pump case)', () => { + // After today's groupEfficiency fix, MGC emits relDistFromPeak=undefined when + // pumps are identical. The dashboard text formatter must display "—" — NOT + // "0.0 %" via the +null === 0 trap. + const msg = { payload: { mode: 'optimalControl', relDistFromPeak: undefined } }; + const [out] = runFn([msg]); + assert.equal(out[PORT.text_bep_rel].payload, '—'); +}); + +test('BEP rel%: null bepRel → "—" (defensive against null emission)', () => { + // Same trap as the NCog fix: +null === 0 → pct() would return "0.0 %". + const msg = { payload: { relDistFromPeak: null } }; + const [out] = runFn([msg]); + assert.equal(out[PORT.text_bep_rel].payload, '—'); +}); + +test('BEP rel% chart: drops msg when bepRel is null/undefined (no payload:null)', () => { + const msg = { payload: { relDistFromPeak: undefined } }; + const [out] = runFn([msg]); + assert.equal(out[PORT.chart_bep_rel], null, 'chart must drop msg when bepRel missing'); +}); + +// ── fn_qh_fanout: Q-H curve → chart points ──────────────────────────── +const fnQH = flow.find(n => n.id === 'fn_qh_fanout'); + +function runFanout(payload) { + const fn_body = new Function('msg', fnQH.func); + return fn_body({ payload }); +} + +test('Q-H fanout: trims trailing flat-Q tail so chart axis doesn\'t blow up', () => { + // Synthetic input mimics buildQHCurve at low ctrl%: useful range followed by + // a horizontal tail (Q clamped to env minimum across high H). + const points = [ + { Q: 100, H: 7 }, { Q: 80, H: 10 }, { Q: 50, H: 15 }, + { Q: 20, H: 20 }, { Q: 9.5, H: 24 }, { Q: 9.5, H: 28 }, + { Q: 9.5, H: 32 }, { Q: 9.5, H: 36 }, { Q: 9.5, H: 40 }, + ]; + const [out] = runFanout({ points }); + const curvePoints = out.filter(m => m.topic === 'Curve' && m.payload); + // The 5 tail points at Q=9.5 should collapse to (at most) one — the first + // one to mark the curve's tail entry, not all five. + const tailPoints = curvePoints.filter(p => p.payload.Q === 9.5 || p.payload.x === 9.5); + assert.ok(tailPoints.length <= 1, + `expected ≤1 flat-tail point, got ${tailPoints.length}: ${JSON.stringify(curvePoints)}`); +}); + +test('Q-H fanout: still emits the rising portion of the curve unchanged', () => { + const points = [ + { Q: 100, H: 7 }, { Q: 80, H: 10 }, { Q: 50, H: 15 }, { Q: 20, H: 20 }, + { Q: 9.5, H: 24 }, { Q: 9.5, H: 28 }, // flat tail + ]; + const [out] = runFanout({ points }); + const curvePoints = out.filter(m => m.topic === 'Curve' && m.payload); + const rising = curvePoints.filter(p => p.payload.x > 10); + assert.equal(rising.length, 4, `expected 4 rising points, got ${rising.length}`); + // First rising point preserves Q=100, H=7. + assert.equal(rising[0].payload.x, 100); + assert.equal(rising[0].payload.y, 7); +}); + +test('Q-H fanout: empty/error input → null msg', () => { + assert.equal(runFanout({ error: 'no curve', points: [] }), null); + assert.equal(runFanout({ points: [] }), null); +}); + +test('contract: no output ever emits { payload: null } for any of the three states', () => { + // The original η-null bug. Re-asserted across all three states because a + // regression here crashes the FlowFuse ui-chart with TypeError on .y. + const states = runFn([initialMsg, postSetupMsg, postDemandMsg]); + for (let s = 0; s < states.length; s++) { + const out = states[s]; + for (let i = 0; i < out.length; i++) { + const msg = out[i]; + if (msg && Object.prototype.hasOwnProperty.call(msg, 'payload')) { + assert.notEqual(msg.payload, null, + `state ${s} port ${i} → { payload: null } would crash ui-chart`); + } + } + } +}); diff --git a/test/integration/demand-cycle-walkthrough.integration.test.js b/test/integration/demand-cycle-walkthrough.integration.test.js index 1fa5d8c..aa703a5 100644 --- a/test/integration/demand-cycle-walkthrough.integration.test.js +++ b/test/integration/demand-cycle-walkthrough.integration.test.js @@ -67,8 +67,10 @@ function groupConfig() { return { general: { logging: logCfg, name: 'mgc', id: 'mgc' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' }, - scaling: { current: 'normalized' }, // demand expressed as 0..100 % mode: { current: 'optimalcontrol' }, // production mode + // No scaling config: post-refactor MGC has no scaling state. handleInput + // takes canonical m³/s. Test converts pct → m³/s before dispatch (mirrors + // what the set.demand handler does for bare-number payloads). }; } @@ -159,24 +161,33 @@ test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps, console.log(`MGC station envelope at head ${HEAD_MBAR} mbar (${N_PUMPS} pumps):`); console.log(` per-pump: ${perPumpMin_m3h.toFixed(1)} .. ${perPumpMax_m3h.toFixed(1)} m³/h`); console.log(` station: ${flowMin_m3h.toFixed(1)} .. ${flowMax_m3h.toFixed(1)} m³/h`); - console.log(` scaling=normalized: 0% → ${flowMin_m3h.toFixed(1)} m³/h, 100% → ${flowMax_m3h.toFixed(1)} m³/h`); - console.log(` (demand ≤ 0% turns ALL pumps off — see MGC handleInput)`); + console.log(` 0% → ${flowMin_m3h.toFixed(1)} m³/h, 100% → ${flowMax_m3h.toFixed(1)} m³/h`); + console.log(` (demand < 0 turns ALL pumps off; 0 = minimum-control floor)`); console.log(''); printHeader(pumps); - // Build demand sweep: 0..100% up, then 100..0% down. + // Build demand sweep: 0..100% up, then 100..0% down, then -1 (all-off sentinel). const upSteps = []; for (let pct = 0; pct <= 100 + 1e-9; pct += STEP_PERCENT) upSteps.push(Math.min(pct, 100)); const downSteps = upSteps.slice(0, -1).reverse(); // skip the duplicate 100 - const sequence = [...upSteps, ...downSteps]; + const sequence = [...upSteps, ...downSteps, -1]; let stuckSeen = 0; for (const pct of sequence) { - await mgc.handleInput('parent', pct); + // Post-refactor handleInput takes canonical m³/s; the percent → m³/s + // mapping the set.demand handler does is replicated here in test. + if (pct < 0) { + await mgc.turnOffAllMachines(); + } else { + const flowMin_m3s = flowMin_m3h / 3600; + const flowMax_m3s = flowMax_m3h / 3600; + const canonical = flowMin_m3s + (pct / 100) * (flowMax_m3s - flowMin_m3s); + await mgc.handleInput('parent', canonical); + } await sleep(DWELL_MS); - // Mirror MGC's normalized→absolute mapping for the printed Qd column. - const demandQout_m3h = pct <= 0 + // pct < 0 → all off (Qd = 0); pct >= 0 → linear interpolation across [min, max]. + const demandQout_m3h = pct < 0 ? 0 : (flowMax_m3h - flowMin_m3h) * (pct / 100) + flowMin_m3h; @@ -194,11 +205,11 @@ test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps, if (s.state === 'accelerating' || s.state === 'decelerating') stuckSeen += 1; } - if (pct === 0) { - // Demand 0% must turn ALL pumps off (or to a non-running state). + if (pct < 0) { + // Strict negative demand turns ALL pumps off (the explicit "all off" signal). for (const s of snaps) { assert.ok(['idle', 'off', 'stopping', 'coolingdown'].includes(s.state), - `demand 0% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`); + `demand ${pct}% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`); } } } diff --git a/test/integration/distribution-power-table.integration.test.js b/test/integration/distribution-power-table.integration.test.js index 436d584..f685677 100644 --- a/test/integration/distribution-power-table.integration.test.js +++ b/test/integration/distribution-power-table.integration.test.js @@ -44,7 +44,7 @@ function groupConfig() { return { general: { logging: { enabled: false, logLevel: 'error' }, name: 'station' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller' }, - scaling: { current: 'absolute' }, + // No scaling field — handleInput always takes canonical m³/s post-refactor. mode: { current: 'optimalcontrol' } }; } @@ -139,7 +139,6 @@ test('machineGroupControl vs naive baselines — real curves, verified flow', as // Run machineGroupControl optimalControl with absolute scaling mg.setMode('optimalcontrol'); - mg.setScaling('absolute'); mg.calcAbsoluteTotals(); mg.calcDynamicTotals(); await mg.handleInput('parent', Qd); @@ -196,7 +195,6 @@ test('machineGroupControl vs naive baselines — real curves, verified flow', as injectPressure(m); } mg.setMode('optimalcontrol'); - mg.setScaling('absolute'); mg.calcAbsoluteTotals(); mg.calcDynamicTotals(); await mg.handleInput('parent', Qd); diff --git a/test/integration/group-bep-cascade.integration.test.js b/test/integration/group-bep-cascade.integration.test.js new file mode 100644 index 0000000..af0c9d6 --- /dev/null +++ b/test/integration/group-bep-cascade.integration.test.js @@ -0,0 +1,93 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const MachineGroup = require('../../src/specificClass'); +const Machine = require('../../../rotatingMachine/src/specificClass'); +const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json'); + +/** + * After fixing rotatingMachine + MGC to use hydraulic efficiency + * (η = Q·ΔP / P_shaft) instead of raw flow/power, every BEP-related output + * on MGC should be in the dimensionless 0..1 range and respond to demand + * changes. This check ties the whole chain together: + * - per-machine cog updates after equalize + * - group efficiency measurement is hydraulic (matches scale of cogs) + * - calcDistanceBEP(eff, mean(cog), min(cog)) is non-degenerate + */ + +const stateConfig = { + time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 }, + movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 }, +}; + +function machineConfig(id, label) { + return { + general: { logging: { enabled: false, logLevel: 'error' }, name: label, id, unit: 'm3/h' }, + functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' }, + asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' }, + mode: { + current: 'auto', + allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] }, + allowedSources: { auto: ['parent', 'GUI'] }, + }, + sequences: { + startup: ['starting', 'warmingup', 'operational'], + shutdown: ['stopping', 'coolingdown', 'idle'], + emergencystop: ['emergencystop', 'off'], + }, + }; +} + +function groupConfig() { + return { + general: { logging: { enabled: false, logLevel: 'error' }, name: 'TestGroup' }, + functionality: { softwareType: 'machinegroup', role: 'groupcontroller' }, + mode: { current: 'optimalcontrol' }, + }; +} + +async function setupGroupWithTwoPumps() { + const m1 = new Machine(machineConfig(1, 'pump-1'), stateConfig); + const m2 = new Machine(machineConfig(2, 'pump-2'), stateConfig); + m1.config.asset.machineCurve = baseCurve; + m2.config.asset.machineCurve = baseCurve; + await m1.handleInput('parent', 'execSequence', 'startup'); + await m2.handleInput('parent', 'execSequence', 'startup'); + + const mgc = new MachineGroup(groupConfig(), stateConfig); + // Mutate the existing machines object — replacing the reference would + // strand operatingPoint/totals/efficiency on the original empty bag. + mgc.machines[1] = m1; + mgc.machines[2] = m2; + // Set header (system) pressure differential: 800/1200 mbar => 400 mbar = 40 kPa + mgc.measurements.type('pressure').variant('measured').position('upstream').value(80000, Date.now(), 'Pa'); + mgc.measurements.type('pressure').variant('measured').position('downstream').value(120000, Date.now(), 'Pa'); + mgc.operatingPoint.equalize(); + return { mgc, m1, m2 }; +} + +test('after equalize, each child cog is a dimensionless 0..1 hydraulic efficiency', async () => { + const { m1, m2 } = await setupGroupWithTwoPumps(); + // Trigger updatePosition by setting ctrl explicitly + m1.updatePosition(); + m2.updatePosition(); + for (const m of [m1, m2]) { + assert.ok(Number.isFinite(m.cog), `cog must be finite, got ${m.cog}`); + assert.ok(m.cog >= 0 && m.cog <= 1.0, + `cog must be a 0..1 hydraulic efficiency, got ${m.cog}`); + } +}); + +test('operatingPoint.headerDiffPa is set by equalize and matches measured differential', async () => { + const { mgc, m1 } = await setupGroupWithTwoPumps(); + // Equalize reads from host measurements; falls back to children when + // header is missing. Either path should produce headerDiffPa > 0. + // headerDiff must equal the measured differential (40 kPa) once any + // pressure source is populated. + assert.equal(mgc.operatingPoint.headerDiffPa, 40000, + `headerDiffPa should equal downstream-upstream = 40000 Pa, got ${mgc.operatingPoint.headerDiffPa}`); + // Sanity: the host's child reference is still consumable for diagnostics. + void m1.measurements; +}); diff --git a/test/integration/idle-startup-deadlock.integration.test.js b/test/integration/idle-startup-deadlock.integration.test.js index 1a3ba91..a3ba9a4 100644 --- a/test/integration/idle-startup-deadlock.integration.test.js +++ b/test/integration/idle-startup-deadlock.integration.test.js @@ -57,11 +57,20 @@ function groupConfig() { return { general: { logging: logCfg, name: 'mgc', id: 'mgc' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' }, - scaling: { current: 'normalized' }, mode: { current: 'optimalcontrol' }, }; } +// Post-refactor handleInput takes canonical m³/s. This helper mirrors what +// the set.demand handler does for a bare-number (percent) payload, so test +// scenarios that previously sent `mgc.handleInput('parent', pctToCanonical(mgc, 100))` (= 100 %) +// keep their intent. +function pctToCanonical(mgc, pct) { + if (pct < 0) return -1; + const dt = mgc.calcDynamicTotals(); + return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max); +} + function buildGroup({ withPressure = true } = {}) { const mgc = new MachineGroup(groupConfig()); const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`); @@ -137,7 +146,7 @@ test('Scenario 1 — single-shot 100% demand to idle pumps', async () => { console.log(`\n[Scenario 1] head=${HEAD_MBAR_DOWN} mbar, time.starting=${stateConfig.time.starting}s, time.warmingup=${stateConfig.time.warmingup}s`); printSnapshots('before handleInput', pumps); - await mgc.handleInput('parent', 100); + await mgc.handleInput('parent', pctToCanonical(mgc, 100)); printSnapshots('immediately after handleInput returns', pumps); // Wait for full startup (3s) + movement (~0.5s) + slack @@ -159,16 +168,16 @@ test('Scenario 2 — rapid 100% retargeting during startup window', async () => // mid-flight, parking it in 'accelerating'/'decelerating'. const { mgc, pumps } = buildGroup(); - console.log(`\n[Scenario 2] firing mgc.handleInput('parent', 100) every 200ms for 5s`); + console.log(`\n[Scenario 2] firing mgc.handleInput('parent', pctToCanonical(mgc, 100)) every 200ms for 5s`); printSnapshots('before any handleInput', pumps); // First call (kicks off startup); not awaited so retargets can layer on. - mgc.handleInput('parent', 100).catch(e => console.log(`first call rejected: ${e.message}`)); + mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(e => console.log(`first call rejected: ${e.message}`)); // Spam additional retargets every 200ms for 5s — covers the 3s startup // window with 25 extra retargeting calls. const interval = setInterval(() => { - mgc.handleInput('parent', 100).catch(e => console.log(`retarget rejected: ${e.message}`)); + mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(e => console.log(`retarget rejected: ${e.message}`)); }, 200); await sleep(5000); clearInterval(interval); @@ -199,7 +208,7 @@ test('Scenario 3 — pumps with NO pressure measurements injected', async () => console.log(`\n[Scenario 3] no pressure injected. per-pump curve envelope: ${minQ.toFixed(1)} .. ${maxQ.toFixed(1)} m³/h, station: ${(dyn.flow.min*3600).toFixed(1)} .. ${(dyn.flow.max*3600).toFixed(1)} m³/h`); printSnapshots('before handleInput', pumps); - await mgc.handleInput('parent', 100); + await mgc.handleInput('parent', pctToCanonical(mgc, 100)); await sleep(6000); printSnapshots('after 6s settle (no pressure)', pumps); @@ -228,7 +237,7 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => { printSnapshots('before any handleInput', pumps); // Phase 1: drive up to 100% from idle. - await mgc.handleInput('parent', 100); + await mgc.handleInput('parent', pctToCanonical(mgc, 100)); await sleep(5000); // full startup + ramp printSnapshots('after settle at 100%', pumps); for (const p of pumps) { @@ -236,12 +245,14 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => { `Phase 1: pump ${p.config.general.id} not operational at 100% (got ${p.state.getCurrentState()})`); } - // Phase 2: demand drops to 0% — pumps begin shutdown sequence. - // FIRE-AND-FORGET: handleInput(0) awaits turnOffAllMachines which + // Phase 2: demand drops below 0 — pumps begin shutdown sequence. Use a + // strictly-negative percent because 0% now means "minimum-control" + // (interpolates to dt.flow.min), not shutdown. + // FIRE-AND-FORGET: handleInput(-1) awaits turnOffAllMachines which // awaits the full per-pump shutdown sequence. We need the next 100% // demand to arrive WHILE pumps are still in stopping/coolingdown, // not after they've reached idle. - mgc.handleInput('parent', 0).catch(e => console.log(`0% rejected: ${e.message}`)); + mgc.turnOffAllMachines().catch(e => console.log(`-1% rejected: ${e.message}`)); // Wait briefly so the shutdown sequence enters but does NOT complete. // shutdown=['stopping','coolingdown','idle'] with stopping=1s, // coolingdown=2s. 500ms puts us solidly inside 'stopping'. @@ -252,7 +263,7 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => { console.log(` states mid-shutdown: ${midShutdownStates.join(', ')}`); // Phase 3: demand returns to 100% while pumps are mid-shutdown. - await mgc.handleInput('parent', 100); + await mgc.handleInput('parent', pctToCanonical(mgc, 100)); // Generous: full coolingdown remaining + full startup + ramp. await sleep(8000); printSnapshots('after re-engage to 100%', pumps); @@ -279,7 +290,7 @@ test('Scenario 6 — full up sweep then full down sweep', async () => { console.log(' --- up sweep ---'); for (const pct of upSteps) { - mgc.handleInput('parent', pct).catch(e => console.log(`up ${pct}% rejected: ${e.message}`)); + mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`up ${pct}% rejected: ${e.message}`)); await sleep(600); const snaps = pumps.map(snapshot); const totalQ = snaps.reduce((s, x) => s + x.flow, 0); @@ -291,7 +302,7 @@ test('Scenario 6 — full up sweep then full down sweep', async () => { console.log(' --- down sweep ---'); for (const pct of downSteps) { - mgc.handleInput('parent', pct).catch(e => console.log(`down ${pct}% rejected: ${e.message}`)); + mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`down ${pct}% rejected: ${e.message}`)); await sleep(600); const snaps = pumps.map(snapshot); const totalQ = snaps.reduce((s, x) => s + x.flow, 0); @@ -340,7 +351,7 @@ test('Scenario 4 — varying demand during startup (combo flips)', async () => { for (const pct of sequence) { console.log(` → demand ${pct}%`); - mgc.handleInput('parent', pct).catch(e => console.log(`call ${pct}% rejected: ${e.message}`)); + mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`call ${pct}% rejected: ${e.message}`)); await sleep(400); } diff --git a/test/integration/ncog-distribution.integration.test.js b/test/integration/ncog-distribution.integration.test.js index 1b410d1..8023e29 100644 --- a/test/integration/ncog-distribution.integration.test.js +++ b/test/integration/ncog-distribution.integration.test.js @@ -72,7 +72,6 @@ function createGroupConfig(name) { return { general: { logging: { enabled: false, logLevel: 'error' }, name }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller' }, - scaling: { current: 'normalized' }, mode: { current: 'optimalcontrol' } }; } @@ -407,10 +406,14 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump await m.handleInput('parent', 'execSequence', 'startup'); } - // Run optimalControl + // Run optimalControl. handleInput takes canonical m³/s post-refactor — + // mirror the set.demand handler's percent → canonical mapping inline. mg.setMode('optimalcontrol'); - mg.setScaling('normalized'); - await mg.handleInput('parent', 50, Infinity); + function pctCanonical(mgc, pct) { + const dt = mgc.calcDynamicTotals(); + return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max); + } + await mg.handleInput('parent', pctCanonical(mg, 50), Infinity); const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0; const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0; @@ -422,7 +425,7 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump // Run priorityControl mg.setMode('prioritycontrol'); - await mg.handleInput('parent', 50, Infinity, ['eff', 'std', 'weak']); + await mg.handleInput('parent', pctCanonical(mg, 50), Infinity, ['eff', 'std', 'weak']); const prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0; const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0; diff --git a/test/integration/optimizer-combination-choice.integration.test.js b/test/integration/optimizer-combination-choice.integration.test.js index aec336c..e1df763 100644 --- a/test/integration/optimizer-combination-choice.integration.test.js +++ b/test/integration/optimizer-combination-choice.integration.test.js @@ -46,7 +46,6 @@ function groupConfig() { return { general: { logging: { enabled: false, logLevel: 'error' }, name: 'mgc', id: 'mgc' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' }, - scaling: { current: 'absolute' }, // talk to MGC in m³/h directly mode: { current: 'optimalcontrol' }, }; } diff --git a/test/integration/structure-examples.integration.test.js b/test/integration/structure-examples.integration.test.js index 293eeac..489ff9d 100644 --- a/test/integration/structure-examples.integration.test.js +++ b/test/integration/structure-examples.integration.test.js @@ -9,14 +9,16 @@ function loadJson(file) { return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); } +const FLOW_FILES = ['01-Basic.json', '02-Dashboard.json']; + test('examples package exists for machineGroupControl', () => { - for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) { + for (const file of ['README.md', ...FLOW_FILES]) { assert.equal(fs.existsSync(path.join(dir, file)), true, file + ' missing'); } }); test('example flows are parseable arrays for machineGroupControl', () => { - for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) { + for (const file of FLOW_FILES) { const parsed = loadJson(file); assert.equal(Array.isArray(parsed), true); } diff --git a/test/integration/turnoff-deadlock.integration.test.js b/test/integration/turnoff-deadlock.integration.test.js index 2d0d208..998a6f2 100644 --- a/test/integration/turnoff-deadlock.integration.test.js +++ b/test/integration/turnoff-deadlock.integration.test.js @@ -62,7 +62,6 @@ function groupConfig() { return { general: { logging: logCfg, name: 'mgc', id: 'mgc' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' }, - scaling: { current: 'normalized' }, mode: { current: 'optimalcontrol' }, }; }