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) <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -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
|
||||||
@@ -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",
|
"id": "grp_drv_mode",
|
||||||
"type": "group",
|
"type": "group",
|
||||||
@@ -98,109 +15,11 @@
|
|||||||
"inj_mode_optimal",
|
"inj_mode_optimal",
|
||||||
"inj_mode_priority"
|
"inj_mode_priority"
|
||||||
],
|
],
|
||||||
"x": 94,
|
"x": 714,
|
||||||
"y": 99,
|
"y": 19,
|
||||||
"w": 312,
|
"w": 292,
|
||||||
"h": 122
|
"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",
|
"id": "inj_mode_optimal",
|
||||||
"type": "inject",
|
"type": "inject",
|
||||||
@@ -208,16 +27,23 @@
|
|||||||
"g": "grp_drv_mode",
|
"g": "grp_drv_mode",
|
||||||
"name": "set.mode = optimalControl",
|
"name": "set.mode = optimalControl",
|
||||||
"props": [
|
"props": [
|
||||||
{ "p": "topic", "vt": "str" },
|
{
|
||||||
{ "p": "payload", "v": "optimalControl", "vt": "str" }
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "optimalControl",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"repeat": "",
|
"repeat": "",
|
||||||
"crontab": "",
|
"crontab": "",
|
||||||
"once": false,
|
"once": false,
|
||||||
"onceDelay": "",
|
"onceDelay": "",
|
||||||
"topic": "set.mode",
|
"topic": "set.mode",
|
||||||
"x": 260,
|
"x": 870,
|
||||||
"y": 140,
|
"y": 60,
|
||||||
"wires": [
|
"wires": [
|
||||||
[
|
[
|
||||||
"mgc_basic_node"
|
"mgc_basic_node"
|
||||||
@@ -231,447 +57,27 @@
|
|||||||
"g": "grp_drv_mode",
|
"g": "grp_drv_mode",
|
||||||
"name": "set.mode = priorityControl",
|
"name": "set.mode = priorityControl",
|
||||||
"props": [
|
"props": [
|
||||||
{ "p": "topic", "vt": "str" },
|
{
|
||||||
{ "p": "payload", "v": "priorityControl", "vt": "str" }
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "priorityControl",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"repeat": "",
|
"repeat": "",
|
||||||
"crontab": "",
|
"crontab": "",
|
||||||
"once": false,
|
"once": false,
|
||||||
"onceDelay": "",
|
"onceDelay": "",
|
||||||
"topic": "set.mode",
|
"topic": "set.mode",
|
||||||
"x": 260,
|
"x": 870,
|
||||||
"y": 180,
|
"y": 100,
|
||||||
"wires": [
|
"wires": [
|
||||||
[
|
[
|
||||||
"mgc_basic_node"
|
"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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
17
mgc.html
17
mgc.html
@@ -22,8 +22,7 @@
|
|||||||
dbaseOutputFormat: { value: "influxdb" },
|
dbaseOutputFormat: { value: "influxdb" },
|
||||||
|
|
||||||
// Control strategy
|
// Control strategy
|
||||||
mode: { value: "optimalControl" }, // optimalControl | priorityControl | prioritypercentagecontrol | maintenance
|
mode: { value: "optimalControl" }, // optimalControl | priorityControl | maintenance
|
||||||
scaling: { value: "normalized" }, // normalized (0–100 %) | absolute (m³/h)
|
|
||||||
|
|
||||||
//define asset properties
|
//define asset properties
|
||||||
uuid: { value: "" },
|
uuid: { value: "" },
|
||||||
@@ -94,17 +93,15 @@
|
|||||||
<select id="node-input-mode" style="width:60%;">
|
<select id="node-input-mode" style="width:60%;">
|
||||||
<option value="optimalControl">optimalControl — pick the best valid pump combination by BEP-gravitation / NCog</option>
|
<option value="optimalControl">optimalControl — pick the best valid pump combination by BEP-gravitation / NCog</option>
|
||||||
<option value="priorityControl">priorityControl — sequential equal-flow control by priority list</option>
|
<option value="priorityControl">priorityControl — sequential equal-flow control by priority list</option>
|
||||||
<option value="prioritypercentagecontrol">prioritypercentagecontrol — sequential percentage control (requires normalized scaling)</option>
|
|
||||||
<option value="maintenance">maintenance — monitoring only, no dispatch</option>
|
<option value="maintenance">maintenance — monitoring only, no dispatch</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<p style="margin-top:8px;color:#666;font-size:11px;">
|
||||||
<label for="node-input-scaling"><i class="fa fa-arrows-h"></i> Scaling</label>
|
Demand is self-describing per <code>set.demand</code> message: a bare number is
|
||||||
<select id="node-input-scaling" style="width:60%;">
|
treated as % of group capacity; <code>{value, unit}</code> with a flow unit
|
||||||
<option value="normalized">normalized — demand interpreted as 0–100 % of group capacity</option>
|
(<code>m3/h</code>, <code>l/s</code>, <code>m3/s</code>, …) is dispatched
|
||||||
<option value="absolute">absolute — demand interpreted as m³/h, capped by group min/max</option>
|
in absolute terms. Negative value stops all pumps.
|
||||||
</select>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Output Formats</h3>
|
<h3>Output Formats</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Handler functions for machineGroupControl commands. Each handler receives:
|
// 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,
|
// handleInput, childRegistrationUtils.registerChild, logger,
|
||||||
// config.general.name.
|
// config.general.name.
|
||||||
// msg: the Node-RED input message.
|
// msg: the Node-RED input message.
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
// Pure functions: no module-level state. The registry already enforces the
|
// Pure functions: no module-level state. The registry already enforces the
|
||||||
// typeof-check ladder; per-topic semantic validation lives here.
|
// typeof-check ladder; per-topic semantic validation lives here.
|
||||||
|
|
||||||
|
const { convert } = require('generalFunctions');
|
||||||
|
|
||||||
function _logger(source, ctx) {
|
function _logger(source, ctx) {
|
||||||
return ctx?.logger || source?.logger || null;
|
return ctx?.logger || source?.logger || null;
|
||||||
}
|
}
|
||||||
@@ -18,10 +20,6 @@ exports.setMode = (source, msg) => {
|
|||||||
source.setMode(msg.payload);
|
source.setMode(msg.payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.setScaling = (source, msg) => {
|
|
||||||
source.setScaling(msg.payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.registerChild = (source, msg, ctx) => {
|
exports.registerChild = (source, msg, ctx) => {
|
||||||
const log = _logger(source, ctx);
|
const log = _logger(source, ctx);
|
||||||
const childId = msg.payload;
|
const childId = msg.payload;
|
||||||
@@ -35,13 +33,58 @@ exports.registerChild = (source, msg, ctx) => {
|
|||||||
|
|
||||||
exports.setDemand = async (source, msg, ctx) => {
|
exports.setDemand = async (source, msg, ctx) => {
|
||||||
const log = _logger(source, ctx);
|
const log = _logger(source, ctx);
|
||||||
const demand = parseFloat(msg.payload);
|
// Operator demand is self-describing: the unit on the message decides how
|
||||||
if (Number.isNaN(demand)) {
|
// the value is interpreted. There is no persistent scaling state on MGC.
|
||||||
log?.error?.(`set.demand: invalid Qd value '${msg.payload}'`);
|
//
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
// Negative is the operator's "stop all" signal regardless of unit.
|
||||||
|
if (value < 0) {
|
||||||
try {
|
try {
|
||||||
await source.handleInput('parent', demand);
|
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', canonicalDemand);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
|
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -15,13 +15,6 @@ module.exports = [
|
|||||||
description: 'Switch the machine group between auto / manual modes.',
|
description: 'Switch the machine group between auto / manual modes.',
|
||||||
handler: handlers.setMode,
|
handler: handlers.setMode,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
topic: 'set.scaling',
|
|
||||||
aliases: ['setScaling'],
|
|
||||||
payloadSchema: { type: 'string' },
|
|
||||||
description: 'Select the group scaling strategy.',
|
|
||||||
handler: handlers.setScaling,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
topic: 'child.register',
|
topic: 'child.register',
|
||||||
aliases: ['registerChild'],
|
aliases: ['registerChild'],
|
||||||
@@ -33,10 +26,13 @@ module.exports = [
|
|||||||
{
|
{
|
||||||
topic: 'set.demand',
|
topic: 'set.demand',
|
||||||
aliases: ['Qd'],
|
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' },
|
payloadSchema: { type: 'any' },
|
||||||
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
description: 'Operator demand setpoint. Bare number = %; {value, unit} for absolute flow units. Negative = stop all.',
|
||||||
description: 'Operator demand setpoint dispatched to the child machines.',
|
|
||||||
handler: handlers.setDemand,
|
handler: handlers.setDemand,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,12 +6,9 @@
|
|||||||
// machines, falling back to start/stop the next priority when the current
|
// machines, falling back to start/stop the next priority when the current
|
||||||
// active set can't deliver.
|
// active set can't deliver.
|
||||||
//
|
//
|
||||||
// prioPercentageControl: percentage-style ctrl distribution (only valid with
|
// Extracted from specificClass during the P4 refactor; the orchestrator
|
||||||
// normalized scaling).
|
// wires it in via the strategies map below. It depends on the same
|
||||||
//
|
// group-curve helpers the optimizer uses, so allocation and power
|
||||||
// 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
|
|
||||||
// evaluation stay on the equalised group operating point.
|
// evaluation stay on the equalised group operating point.
|
||||||
|
|
||||||
const { POSITIONS } = require('generalFunctions');
|
const { POSITIONS } = require('generalFunctions');
|
||||||
@@ -49,35 +46,51 @@ function capFlowDemand(Qd, dynamicTotals, logger) {
|
|||||||
return Qd;
|
return Qd;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = null) {
|
// Pure distribution math: given the demand, group envelope, priority list, and
|
||||||
const { mgc } = ctx;
|
// per-machine curve helpers, return the {machineId, flow} mapping plus running
|
||||||
try {
|
// totals. No side effects, no mgc reference — testable without an MGC fixture.
|
||||||
mgc.equalizePressure();
|
//
|
||||||
const dynamicTotals = mgc.calcDynamicTotals();
|
// Inputs:
|
||||||
Qd = capFlowDemand(Qd, dynamicTotals, mgc.logger);
|
// 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(mgc.machines, priorityList);
|
let machinesInPriorityOrder = sortMachinesByPriority(machines, priorityList);
|
||||||
machinesInPriorityOrder = filterOutUnavailableMachines(machinesInPriorityOrder);
|
machinesInPriorityOrder = filterOutUnavailableMachines(machinesInPriorityOrder);
|
||||||
|
|
||||||
const flowDistribution = [];
|
const flowDistribution = [];
|
||||||
let totalFlow = 0;
|
let totalFlow = 0;
|
||||||
let totalPower = 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;
|
const totalCog = 0;
|
||||||
|
|
||||||
const activeTotals = mgc.totals.activeTotals();
|
|
||||||
|
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case (Qd < activeTotals.flow.min && activeTotals.flow.min !== 0): {
|
case (Qd < activeTotals.flow.min && activeTotals.flow.min !== 0): {
|
||||||
let availableFlow = activeTotals.flow.min;
|
let availableFlow = activeTotals.flow.min;
|
||||||
for (let i = machinesInPriorityOrder.length - 1; i >= 0 && availableFlow > Qd; i--) {
|
for (let i = machinesInPriorityOrder.length - 1; i >= 0 && availableFlow > Qd; i--) {
|
||||||
const m = machinesInPriorityOrder[i];
|
const m = machinesInPriorityOrder[i];
|
||||||
if (mgc.isMachineActive(m.id)) {
|
if (isMachineActive(m.id)) {
|
||||||
flowDistribution.push({ machineId: m.id, flow: 0 });
|
flowDistribution.push({ machineId: m.id, flow: 0 });
|
||||||
availableFlow -= groupFlow(m.machine).currentFxyYMin;
|
availableFlow -= groupFlow(m.machine).currentFxyYMin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const remaining = machinesInPriorityOrder.filter(({ id }) =>
|
const remaining = machinesInPriorityOrder.filter(({ id }) =>
|
||||||
mgc.isMachineActive(id) && !flowDistribution.some(it => it.machineId === id));
|
isMachineActive(id) && !flowDistribution.some(it => it.machineId === id));
|
||||||
const distributedFlow = Qd / remaining.length;
|
const distributedFlow = Qd / remaining.length;
|
||||||
for (const m of remaining) {
|
for (const m of remaining) {
|
||||||
flowDistribution.push({ machineId: m.id, flow: distributedFlow });
|
flowDistribution.push({ machineId: m.id, flow: distributedFlow });
|
||||||
@@ -92,7 +105,7 @@ async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = nu
|
|||||||
Qd = Qd / i;
|
Qd = Qd / i;
|
||||||
if (groupFlow(machinesInPriorityOrder[i - 1].machine).currentFxyYMax >= Qd) {
|
if (groupFlow(machinesInPriorityOrder[i - 1].machine).currentFxyYMax >= Qd) {
|
||||||
for (let i2 = 0; i2 < i; i2++) {
|
for (let i2 = 0; i2 < i; i2++) {
|
||||||
if (!mgc.isMachineActive(machinesInPriorityOrder[i2].id)) {
|
if (!isMachineActive(machinesInPriorityOrder[i2].id)) {
|
||||||
flowDistribution.push({ machineId: machinesInPriorityOrder[i2].id, flow: Qd });
|
flowDistribution.push({ machineId: machinesInPriorityOrder[i2].id, flow: Qd });
|
||||||
totalFlow += Qd;
|
totalFlow += Qd;
|
||||||
totalPower += groupCalcPower(machinesInPriorityOrder[i2].machine, Qd);
|
totalPower += groupCalcPower(machinesInPriorityOrder[i2].machine, Qd);
|
||||||
@@ -104,7 +117,7 @@ async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = nu
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
const countActive = machinesInPriorityOrder.filter(({ id }) => mgc.isMachineActive(id)).length;
|
const countActive = machinesInPriorityOrder.filter(({ id }) => isMachineActive(id)).length;
|
||||||
Qd /= countActive;
|
Qd /= countActive;
|
||||||
for (let i = 0; i < countActive; i++) {
|
for (let i = 0; i < countActive; i++) {
|
||||||
flowDistribution.push({ machineId: machinesInPriorityOrder[i].id, flow: Qd });
|
flowDistribution.push({ machineId: machinesInPriorityOrder[i].id, flow: Qd });
|
||||||
@@ -115,11 +128,38 @@ async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = nu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fUnit = mgc.unitPolicy.canonical.power;
|
return { flowDistribution, totalFlow, totalPower, totalCog };
|
||||||
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);
|
// Orchestrator: equalize the operating point, call the pure distribution math,
|
||||||
mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(totalFlow / totalPower);
|
// 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();
|
||||||
|
const activeTotals = mgc.totals.activeTotals();
|
||||||
|
|
||||||
|
const { flowDistribution, totalFlow, totalPower, totalCog } = computeEqualFlowDistribution({
|
||||||
|
machines: mgc.machines,
|
||||||
|
Qd, dynamicTotals, activeTotals, priorityList,
|
||||||
|
isMachineActive: (id) => mgc.isMachineActive(id),
|
||||||
|
groupFlow, groupCalcPower,
|
||||||
|
logger: mgc.logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
mgc.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(totalCog);
|
||||||
|
|
||||||
await Promise.all(flowDistribution.map(async ({ machineId, flow }) => {
|
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) {
|
module.exports = {
|
||||||
const { mgc } = ctx;
|
equalFlowControl, computeEqualFlowDistribution,
|
||||||
try {
|
capFlowDemand, sortMachinesByPriority, filterOutUnavailableMachines,
|
||||||
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 };
|
|
||||||
|
|||||||
@@ -44,11 +44,19 @@ class GroupEfficiency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Maps current efficiency onto [0..1] across [maxEfficiency..minEfficiency].
|
// 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) {
|
calcRelativeDistanceFromPeak(currentEfficiency, maxEfficiency, minEfficiency) {
|
||||||
let distance = 1;
|
const DEGENERATE_EPS = 1e-9; // η points are 0..1, so 1e-9 catches float noise.
|
||||||
if (currentEfficiency != null && maxEfficiency !== minEfficiency && this.interpolation) {
|
if (currentEfficiency == null) return undefined;
|
||||||
distance = this.interpolation.interpolate_lin_single_point(
|
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,
|
currentEfficiency,
|
||||||
maxEfficiency,
|
maxEfficiency,
|
||||||
minEfficiency,
|
minEfficiency,
|
||||||
@@ -56,8 +64,6 @@ class GroupEfficiency {
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return distance;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns both abs + rel; orchestrator decides whether to mirror onto
|
// Returns both abs + rel; orchestrator decides whether to mirror onto
|
||||||
// its own this.absDistFromPeak / this.relDistFromPeak fields.
|
// its own this.absDistFromPeak / this.relDistFromPeak fields.
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ class GroupOperatingPoint {
|
|||||||
// Late-binding via getters in the orchestrator works too — but
|
// Late-binding via getters in the orchestrator works too — but
|
||||||
// passing the live references avoids re-plumbing setters.
|
// passing the live references avoids re-plumbing setters.
|
||||||
this.ctx = ctx;
|
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; }
|
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})`);
|
this.logger?.debug?.(`Skipping equalization: invalid header diff ${headerDiff} (down=${headerDownstream}, up=${headerUpstream})`);
|
||||||
return;
|
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}`);
|
this.logger?.debug?.(`Equalizing operating point: down=${headerDownstream}, up=${headerUpstream}, diff=${headerDiff}`);
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ const Machine = require('../../rotatingMachine/src/specificClass');
|
|||||||
const Measurement = require('../../measurement/src/specificClass');
|
const Measurement = require('../../measurement/src/specificClass');
|
||||||
const baseCurve = require('../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json');
|
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 = {
|
const MODE_LABELS = {
|
||||||
optimalcontrol: 'OPT',
|
optimalcontrol: 'OPT',
|
||||||
prioritycontrol: 'PRIO',
|
prioritycontrol: 'PRIO',
|
||||||
prioritypercentagecontrol: 'PERC'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateConfig = {
|
const stateConfig = {
|
||||||
@@ -60,7 +62,6 @@ function createGroupConfig(name) {
|
|||||||
return {
|
return {
|
||||||
general: { logging: { enabled: false, logLevel: 'error' }, name: `machinegroup-${name}` },
|
general: { logging: { enabled: false, logLevel: 'error' }, name: `machinegroup-${name}` },
|
||||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||||
scaling: { current: 'normalized' },
|
|
||||||
mode: { current: 'optimalcontrol' }
|
mode: { current: 'optimalcontrol' }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -185,7 +186,9 @@ async function driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrd
|
|||||||
await sleep(15);
|
await sleep(15);
|
||||||
|
|
||||||
mg.setMode(mode);
|
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 dynamic = mg.calcDynamicTotals();
|
||||||
const span = Math.max(dynamic.flow.max - dynamic.flow.min, 1);
|
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 };
|
let best = { demand, flow: 0, power: 0, efficiency: 0, error: Infinity };
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
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);
|
await sleep(30);
|
||||||
|
|
||||||
const totals = captureTotals(mg);
|
const totals = captureTotals(mg);
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ function getOutput(mgc) {
|
|||||||
out.absDistFromPeak = absDistFromPeak;
|
out.absDistFromPeak = absDistFromPeak;
|
||||||
out.relDistFromPeak = relDistFromPeak;
|
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
|
// Group capacity + active-machine counts. Surfaced so dashboards can
|
||||||
// show the same numbers the status badge does without subscribing to
|
// show the same numbers the status badge does without subscribing to
|
||||||
// every child node individually.
|
// every child node individually.
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
//
|
//
|
||||||
// All real work lives in the concern modules under src/{groupOps,totals,
|
// All real work lives in the concern modules under src/{groupOps,totals,
|
||||||
// combinatorics,optimizer,efficiency,dispatch,control}. This file stitches
|
// 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.
|
// 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';
|
'use strict';
|
||||||
|
|
||||||
@@ -37,7 +41,6 @@ class MachineGroup extends BaseDomain {
|
|||||||
// tests still write directly (matches the pumpingStation pattern).
|
// tests still write directly (matches the pumpingStation pattern).
|
||||||
this.machines = {};
|
this.machines = {};
|
||||||
|
|
||||||
this.scaling = this.config.scaling.current;
|
|
||||||
this.mode = this.config.mode.current;
|
this.mode = this.config.mode.current;
|
||||||
this.absDistFromPeak = 0;
|
this.absDistFromPeak = 0;
|
||||||
this.relDistFromPeak = 0;
|
this.relDistFromPeak = 0;
|
||||||
@@ -117,11 +120,6 @@ class MachineGroup extends BaseDomain {
|
|||||||
|
|
||||||
// ── Surface kept for tests + commands ──────────────────────────────
|
// ── Surface kept for tests + commands ──────────────────────────────
|
||||||
setMode(mode) { this.mode = mode; this.notifyOutputChanged(); }
|
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) {
|
isMachineActive(id) {
|
||||||
const s = this.machines[id]?.state?.getCurrentState?.();
|
const s = this.machines[id]?.state?.getCurrentState?.();
|
||||||
return ACTIVE_STATES.has(s);
|
return ACTIVE_STATES.has(s);
|
||||||
@@ -214,7 +212,15 @@ class MachineGroup extends BaseDomain {
|
|||||||
// INTENT lands on AT_EQUIPMENT only; DOWNSTREAM is the live aggregate.
|
// 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('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.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);
|
this.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestCog);
|
||||||
|
|
||||||
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
|
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}.`);
|
this.logger.error(`Invalid flow demand input: ${demand}.`);
|
||||||
return;
|
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');
|
await this.abortActiveMovements('new demand received');
|
||||||
const dt = this.calcDynamicTotals();
|
const dt = this.calcDynamicTotals();
|
||||||
let demandQout = 0;
|
// Clamp against the current-pressure envelope.
|
||||||
|
let demandQout = demandQ;
|
||||||
if (this.scaling === 'absolute') {
|
if (demandQout < dt.flow.min) demandQout = dt.flow.min;
|
||||||
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
|
else if (demandQout > dt.flow.max) demandQout = dt.flow.max;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize for the switch — schema enum values use camelCase
|
// Normalize for the switch — schema enum values use camelCase
|
||||||
// (optimalControl, priorityControl) while legacy callers send
|
// (optimalControl, priorityControl) while legacy callers send
|
||||||
@@ -266,10 +269,6 @@ class MachineGroup extends BaseDomain {
|
|||||||
const ctx = { mgc: this };
|
const ctx = { mgc: this };
|
||||||
switch (String(this.mode || '').toLowerCase()) {
|
switch (String(this.mode || '').toLowerCase()) {
|
||||||
case 'prioritycontrol': await control.equalFlowControl(ctx, demandQout, powerCap, priorityList); break;
|
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;
|
case 'optimalcontrol': await this._optimalControl(demandQout, powerCap); break;
|
||||||
default: this.logger.warn(`${this.mode} is not a valid mode.`);
|
default: this.logger.warn(`${this.mode} is not a valid mode.`);
|
||||||
}
|
}
|
||||||
|
|||||||
130
test/_output-manifest.md
Normal file
130
test/_output-manifest.md
Normal file
@@ -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_<variant>_<type>` 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 |
|
||||||
|
|---|---|---|
|
||||||
|
| `<position>_measured_<type>` | 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: <string> }` | 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`
|
||||||
|
- `<type>.measured.<position>` — 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** (`<position>_measured_<type>`) 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.
|
||||||
@@ -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 = {
|
const calls = {
|
||||||
setMode: [],
|
setMode: [],
|
||||||
setScaling: [],
|
|
||||||
handleInput: [],
|
handleInput: [],
|
||||||
registerChild: [],
|
registerChild: [],
|
||||||
|
turnOffAllMachines: 0,
|
||||||
};
|
};
|
||||||
const source = {
|
const source = {
|
||||||
logger: makeLogger(),
|
logger: makeLogger(),
|
||||||
config: { general: { name } },
|
config: { general: { name } },
|
||||||
setMode: (m) => calls.setMode.push(m),
|
setMode: (m) => calls.setMode.push(m),
|
||||||
setScaling: (s) => calls.setScaling.push(s),
|
|
||||||
handleInput: async (src, demand) => {
|
handleInput: async (src, demand) => {
|
||||||
calls.handleInput.push({ src, demand });
|
calls.handleInput.push({ src, demand });
|
||||||
if (handleInputResult instanceof Error) throw handleInputResult;
|
if (handleInputResult instanceof Error) throw handleInputResult;
|
||||||
return 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: {
|
childRegistrationUtils: {
|
||||||
registerChild: (childSource, position) =>
|
registerChild: (childSource, position) =>
|
||||||
calls.registerChild.push({ 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());
|
await reg.dispatch({ topic: 'set.mode', payload: 'prioritycontrol' }, source, makeCtx());
|
||||||
assert.deepEqual(calls.setMode, ['prioritycontrol']);
|
assert.deepEqual(calls.setMode, ['prioritycontrol']);
|
||||||
|
|
||||||
await reg.dispatch({ topic: 'set.scaling', payload: 'normalized' }, source, makeCtx());
|
// bare-number demand → interpreted as % → interpolated against dt.flow.
|
||||||
assert.deepEqual(calls.setScaling, ['normalized']);
|
// Default test dt is {min:0,max:100} so % is identity.
|
||||||
|
|
||||||
await reg.dispatch({ topic: 'set.demand', payload: '12.5' }, source, makeCtx());
|
await reg.dispatch({ topic: 'set.demand', payload: '12.5' }, source, makeCtx());
|
||||||
assert.equal(calls.handleInput.length, 1);
|
assert.equal(calls.handleInput.length, 1);
|
||||||
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 12.5 });
|
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 () => {
|
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
|
||||||
const { source, calls } = makeSource();
|
const { source, calls } = makeSource();
|
||||||
const child = { id: 'child-1', source: { tag: 'child-domain' } };
|
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"));
|
let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated"));
|
||||||
assert.equal(warns.length, 1, 'setMode deprecation warning should log exactly once');
|
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 }));
|
await reg.dispatch({ topic: 'Qd', payload: 5 }, source, makeCtx({ logger: ctxLogger }));
|
||||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'Qd' is deprecated"));
|
warns = ctxLogger.calls.warn.filter((m) => m.includes("'Qd' is deprecated"));
|
||||||
assert.equal(warns.length, 1);
|
assert.equal(warns.length, 1);
|
||||||
|
|||||||
132
test/basic/equalFlowDistribution.basic.test.js
Normal file
132
test/basic/equalFlowDistribution.basic.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
@@ -53,14 +53,33 @@ test('calcDistanceBEP returns both abs + rel', () => {
|
|||||||
assert.ok(Math.abs(relDistFromPeak - expectedRel) < 1e-9);
|
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();
|
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();
|
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', () => {
|
test('calcGroupEfficiency handles a single machine', () => {
|
||||||
|
|||||||
125
test/integration/bep-distance-demand-sweep.integration.test.js
Normal file
125
test/integration/bep-distance-demand-sweep.integration.test.js
Normal file
@@ -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).`);
|
||||||
|
});
|
||||||
240
test/integration/dashboard-fanout.integration.test.js
Normal file
240
test/integration/dashboard-fanout.integration.test.js
Normal file
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -67,8 +67,10 @@ function groupConfig() {
|
|||||||
return {
|
return {
|
||||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||||
scaling: { current: 'normalized' }, // demand expressed as 0..100 %
|
|
||||||
mode: { current: 'optimalcontrol' }, // production mode
|
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(`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(` 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(` 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(` 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(` (demand < 0 turns ALL pumps off; 0 = minimum-control floor)`);
|
||||||
console.log('');
|
console.log('');
|
||||||
printHeader(pumps);
|
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 = [];
|
const upSteps = [];
|
||||||
for (let pct = 0; pct <= 100 + 1e-9; pct += STEP_PERCENT) upSteps.push(Math.min(pct, 100));
|
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 downSteps = upSteps.slice(0, -1).reverse(); // skip the duplicate 100
|
||||||
const sequence = [...upSteps, ...downSteps];
|
const sequence = [...upSteps, ...downSteps, -1];
|
||||||
|
|
||||||
let stuckSeen = 0;
|
let stuckSeen = 0;
|
||||||
for (const pct of sequence) {
|
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);
|
await sleep(DWELL_MS);
|
||||||
|
|
||||||
// Mirror MGC's normalized→absolute mapping for the printed Qd column.
|
// pct < 0 → all off (Qd = 0); pct >= 0 → linear interpolation across [min, max].
|
||||||
const demandQout_m3h = pct <= 0
|
const demandQout_m3h = pct < 0
|
||||||
? 0
|
? 0
|
||||||
: (flowMax_m3h - flowMin_m3h) * (pct / 100) + flowMin_m3h;
|
: (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 (s.state === 'accelerating' || s.state === 'decelerating') stuckSeen += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pct === 0) {
|
if (pct < 0) {
|
||||||
// Demand 0% must turn ALL pumps off (or to a non-running state).
|
// Strict negative demand turns ALL pumps off (the explicit "all off" signal).
|
||||||
for (const s of snaps) {
|
for (const s of snaps) {
|
||||||
assert.ok(['idle', 'off', 'stopping', 'coolingdown'].includes(s.state),
|
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)})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function groupConfig() {
|
|||||||
return {
|
return {
|
||||||
general: { logging: { enabled: false, logLevel: 'error' }, name: 'station' },
|
general: { logging: { enabled: false, logLevel: 'error' }, name: 'station' },
|
||||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||||
scaling: { current: 'absolute' },
|
// No scaling field — handleInput always takes canonical m³/s post-refactor.
|
||||||
mode: { current: 'optimalcontrol' }
|
mode: { current: 'optimalcontrol' }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,6 @@ test('machineGroupControl vs naive baselines — real curves, verified flow', as
|
|||||||
|
|
||||||
// Run machineGroupControl optimalControl with absolute scaling
|
// Run machineGroupControl optimalControl with absolute scaling
|
||||||
mg.setMode('optimalcontrol');
|
mg.setMode('optimalcontrol');
|
||||||
mg.setScaling('absolute');
|
|
||||||
mg.calcAbsoluteTotals();
|
mg.calcAbsoluteTotals();
|
||||||
mg.calcDynamicTotals();
|
mg.calcDynamicTotals();
|
||||||
await mg.handleInput('parent', Qd);
|
await mg.handleInput('parent', Qd);
|
||||||
@@ -196,7 +195,6 @@ test('machineGroupControl vs naive baselines — real curves, verified flow', as
|
|||||||
injectPressure(m);
|
injectPressure(m);
|
||||||
}
|
}
|
||||||
mg.setMode('optimalcontrol');
|
mg.setMode('optimalcontrol');
|
||||||
mg.setScaling('absolute');
|
|
||||||
mg.calcAbsoluteTotals();
|
mg.calcAbsoluteTotals();
|
||||||
mg.calcDynamicTotals();
|
mg.calcDynamicTotals();
|
||||||
await mg.handleInput('parent', Qd);
|
await mg.handleInput('parent', Qd);
|
||||||
|
|||||||
93
test/integration/group-bep-cascade.integration.test.js
Normal file
93
test/integration/group-bep-cascade.integration.test.js
Normal file
@@ -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;
|
||||||
|
});
|
||||||
@@ -57,11 +57,20 @@ function groupConfig() {
|
|||||||
return {
|
return {
|
||||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||||
scaling: { current: 'normalized' },
|
|
||||||
mode: { current: 'optimalcontrol' },
|
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 } = {}) {
|
function buildGroup({ withPressure = true } = {}) {
|
||||||
const mgc = new MachineGroup(groupConfig());
|
const mgc = new MachineGroup(groupConfig());
|
||||||
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
|
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`);
|
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);
|
printSnapshots('before handleInput', pumps);
|
||||||
|
|
||||||
await mgc.handleInput('parent', 100);
|
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
|
||||||
printSnapshots('immediately after handleInput returns', pumps);
|
printSnapshots('immediately after handleInput returns', pumps);
|
||||||
|
|
||||||
// Wait for full startup (3s) + movement (~0.5s) + slack
|
// 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'.
|
// mid-flight, parking it in 'accelerating'/'decelerating'.
|
||||||
|
|
||||||
const { mgc, pumps } = buildGroup();
|
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);
|
printSnapshots('before any handleInput', pumps);
|
||||||
|
|
||||||
// First call (kicks off startup); not awaited so retargets can layer on.
|
// 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
|
// Spam additional retargets every 200ms for 5s — covers the 3s startup
|
||||||
// window with 25 extra retargeting calls.
|
// window with 25 extra retargeting calls.
|
||||||
const interval = setInterval(() => {
|
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);
|
}, 200);
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
clearInterval(interval);
|
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`);
|
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);
|
printSnapshots('before handleInput', pumps);
|
||||||
|
|
||||||
await mgc.handleInput('parent', 100);
|
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
|
||||||
await sleep(6000);
|
await sleep(6000);
|
||||||
printSnapshots('after 6s settle (no pressure)', pumps);
|
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);
|
printSnapshots('before any handleInput', pumps);
|
||||||
|
|
||||||
// Phase 1: drive up to 100% from idle.
|
// 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
|
await sleep(5000); // full startup + ramp
|
||||||
printSnapshots('after settle at 100%', pumps);
|
printSnapshots('after settle at 100%', pumps);
|
||||||
for (const p of 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 1: pump ${p.config.general.id} not operational at 100% (got ${p.state.getCurrentState()})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: demand drops to 0% — pumps begin shutdown sequence.
|
// Phase 2: demand drops below 0 — pumps begin shutdown sequence. Use a
|
||||||
// FIRE-AND-FORGET: handleInput(0) awaits turnOffAllMachines which
|
// 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%
|
// awaits the full per-pump shutdown sequence. We need the next 100%
|
||||||
// demand to arrive WHILE pumps are still in stopping/coolingdown,
|
// demand to arrive WHILE pumps are still in stopping/coolingdown,
|
||||||
// not after they've reached idle.
|
// 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.
|
// Wait briefly so the shutdown sequence enters but does NOT complete.
|
||||||
// shutdown=['stopping','coolingdown','idle'] with stopping=1s,
|
// shutdown=['stopping','coolingdown','idle'] with stopping=1s,
|
||||||
// coolingdown=2s. 500ms puts us solidly inside 'stopping'.
|
// 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(', ')}`);
|
console.log(` states mid-shutdown: ${midShutdownStates.join(', ')}`);
|
||||||
|
|
||||||
// Phase 3: demand returns to 100% while pumps are mid-shutdown.
|
// 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.
|
// Generous: full coolingdown remaining + full startup + ramp.
|
||||||
await sleep(8000);
|
await sleep(8000);
|
||||||
printSnapshots('after re-engage to 100%', pumps);
|
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 ---');
|
console.log(' --- up sweep ---');
|
||||||
for (const pct of upSteps) {
|
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);
|
await sleep(600);
|
||||||
const snaps = pumps.map(snapshot);
|
const snaps = pumps.map(snapshot);
|
||||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
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 ---');
|
console.log(' --- down sweep ---');
|
||||||
for (const pct of downSteps) {
|
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);
|
await sleep(600);
|
||||||
const snaps = pumps.map(snapshot);
|
const snaps = pumps.map(snapshot);
|
||||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
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) {
|
for (const pct of sequence) {
|
||||||
console.log(` → demand ${pct}%`);
|
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);
|
await sleep(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ function createGroupConfig(name) {
|
|||||||
return {
|
return {
|
||||||
general: { logging: { enabled: false, logLevel: 'error' }, name },
|
general: { logging: { enabled: false, logLevel: 'error' }, name },
|
||||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||||
scaling: { current: 'normalized' },
|
|
||||||
mode: { current: 'optimalcontrol' }
|
mode: { current: 'optimalcontrol' }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -407,10 +406,14 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
|
|||||||
await m.handleInput('parent', 'execSequence', 'startup');
|
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.setMode('optimalcontrol');
|
||||||
mg.setScaling('normalized');
|
function pctCanonical(mgc, pct) {
|
||||||
await mg.handleInput('parent', 50, Infinity);
|
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 optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||||
const optFlow = mg.measurements.type('flow').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
|
// Run priorityControl
|
||||||
mg.setMode('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 prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||||
const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ function groupConfig() {
|
|||||||
return {
|
return {
|
||||||
general: { logging: { enabled: false, logLevel: 'error' }, name: 'mgc', id: 'mgc' },
|
general: { logging: { enabled: false, logLevel: 'error' }, name: 'mgc', id: 'mgc' },
|
||||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||||
scaling: { current: 'absolute' }, // talk to MGC in m³/h directly
|
|
||||||
mode: { current: 'optimalcontrol' },
|
mode: { current: 'optimalcontrol' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ function loadJson(file) {
|
|||||||
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
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', () => {
|
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');
|
assert.equal(fs.existsSync(path.join(dir, file)), true, file + ' missing');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('example flows are parseable arrays for machineGroupControl', () => {
|
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);
|
const parsed = loadJson(file);
|
||||||
assert.equal(Array.isArray(parsed), true);
|
assert.equal(Array.isArray(parsed), true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ function groupConfig() {
|
|||||||
return {
|
return {
|
||||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||||
scaling: { current: 'normalized' },
|
|
||||||
mode: { current: 'optimalcontrol' },
|
mode: { current: 'optimalcontrol' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user