Compare commits
14 Commits
472402c62d
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f18f3cc673 | ||
|
|
2af6c904da | ||
|
|
f41e319b30 | ||
|
|
551ee6d70e | ||
|
|
b59d8e60f7 | ||
| e1e1977139 | |||
|
|
ddf2b07424 | ||
|
|
c982c9bef7 | ||
|
|
a47aa53d17 | ||
|
|
aeb938c205 | ||
|
|
a57e0095a3 | ||
|
|
047229c514 | ||
|
|
998e9bd758 | ||
|
|
6833e9f3a8 |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,3 +1,14 @@
|
||||
# Repo dev artifacts. Mirrors the deny list in .npmignore so the two stay
|
||||
# in sync — anything that shouldn't be committed AND shouldn't ship in the
|
||||
# npm tarball goes in both files.
|
||||
node_modules/
|
||||
package-lock.json
|
||||
*.tgz
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
|
||||
# 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
|
||||
|
||||
28
.npmignore
Normal file
28
.npmignore
Normal file
@@ -0,0 +1,28 @@
|
||||
# === Mirrors .gitignore — items below this block are also excluded from
|
||||
# the npm tarball. Kept here verbatim so npm pack doesn't fall back to
|
||||
# the .gitignore inheritance (silent + surprising). ===
|
||||
node_modules/
|
||||
package-lock.json
|
||||
*.tgz
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
|
||||
# Large local screen recording (>100 MB) — kept out of both repo and pack.
|
||||
wiki/test.gif
|
||||
|
||||
# === Dev-only content the npm tarball doesn't need ===
|
||||
# Tests + their harness — Node-RED loads the entry .js, not the test tree.
|
||||
test/
|
||||
*.test.js
|
||||
|
||||
# Wiki / docs — useful in the repo, big in the pack.
|
||||
wiki/
|
||||
|
||||
# Project memory + IDE configs.
|
||||
.claude/
|
||||
.codex/
|
||||
.repo-mem/
|
||||
CLAUDE.md
|
||||
CLAUDE.local.md
|
||||
25
CLAUDE.md
25
CLAUDE.md
@@ -21,3 +21,28 @@ Key points for this node:
|
||||
- Stack same-level siblings vertically.
|
||||
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||
- Wrap in a Node-RED group box coloured `#50a8d9` (Unit).
|
||||
|
||||
## Folder & File Layout
|
||||
|
||||
Every per-node file MUST use the folder name (`machineGroupControl`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
|
||||
|
||||
| Path | Required name |
|
||||
|---|---|
|
||||
| Entry file | `machineGroupControl.js` |
|
||||
| Editor HTML | `machineGroupControl.html` |
|
||||
| Node adapter | `src/nodeClass.js` |
|
||||
| Domain logic | `src/specificClass.js` |
|
||||
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
|
||||
| Tests | `test/{basic,integration,edge}/*.test.js` |
|
||||
| Example flows | `examples/*.flow.json` |
|
||||
|
||||
> ⚠️ **Legacy naming drift in this repo** — to be renamed when the file is next touched:
|
||||
>
|
||||
> | Path | Currently | Should be |
|
||||
> |---|---|---|
|
||||
> | Entry file | `mgc.js` | `machineGroupControl.js` |
|
||||
> | Editor HTML | `mgc.html` | `machineGroupControl.html` |
|
||||
>
|
||||
> Renames require updating: the file itself, `package.json#node-red.nodes`, any `require()` / `import` paths, and superproject submodule references in one commit.
|
||||
|
||||
When adding new files, read the rule above first to avoid drift.
|
||||
|
||||
@@ -7,8 +7,7 @@ Hand-maintained for Phase 4; the `## Inputs` table is generated from
|
||||
|
||||
| Canonical | Aliases (deprecated) | Payload | Effect |
|
||||
|---|---|---|---|
|
||||
| `set.mode` | `setMode` | `string` — one of `prioritycontrol`, `optimalcontrol`, `dynamiccontrol`, … | Switches the control strategy via `source.setMode(payload)`. |
|
||||
| `set.scaling` | `setScaling` | `string` — one of `absolute`, `normalized` | Sets the demand-scaling convention via `source.setScaling(payload)`. |
|
||||
| `set.mode` | `setMode` | `string` — one of `optimalControl`, `priorityControl`, `maintenance` (schema-validated) | Switches the control strategy via `source.setMode(payload)`. |
|
||||
| `child.register` | `registerChild` | `string` — the child node's Node-RED id | Resolves the child via `RED.nodes.getNode` and registers it through `childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent)`. |
|
||||
| `set.demand` | `Qd` | numeric (number or numeric string) | Calls `source.handleInput('parent', parseFloat(payload))`. On success, replies on Port 0 with `topic = source.config.general.name`, `payload = 'done'`. Non-numeric payloads log `error` and are skipped. |
|
||||
|
||||
|
||||
@@ -366,8 +366,13 @@
|
||||
"g": "grp_setup",
|
||||
"name": "auto-init on deploy",
|
||||
"props": [
|
||||
{ "p": "payload" },
|
||||
{ "p": "topic", "vt": "str" }
|
||||
{
|
||||
"p": "payload"
|
||||
},
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
@@ -445,8 +450,8 @@
|
||||
"z": "tab_mgc_dash",
|
||||
"g": "grp_status_panel",
|
||||
"name": "fan-out Port 0 (status + charts + raw)",
|
||||
"func": "// MGC Port 0 emits delta-only. Cache last-known so deltas never blank a\n// row, then fan out one msg per ui-text / ui-chart / ui-template slot.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\n// num/pct treat null AND undefined as \"no data\" (display em-dash). Without\n// the explicit null check, `+null === 0` would silently render as \"0.0 %\" \u2014\n// the bug class we hit twice today (\u03b7-null and Ncog/bepRel degenerate).\nconst num = (v, dp, unit) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\nconst pct = (v, dp) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return (n * 100).toFixed(dp) + ' %';\n};\n\nconst mode = cache.mode || '\u2014';\nconst flow = cache['atEquipment_predicted_flow'];\nconst power = cache['atEquipment_predicted_power'];\nconst qMin = cache.flowCapacityMin;\nconst qMax = cache.flowCapacityMax;\nconst nAct = cache.machineCountActive;\nconst nTot = cache.machineCount;\nconst bepRel = cache.relDistFromPeak; // 0..1\nconst bepAbs = cache.absDistFromPeak; // \u03b7 points (dimensionless)\nconst eta = cache['atEquipment_predicted_efficiency']; // 0..1\n// MGC emits atEquipment_predicted_Ncog as the SUM of per-pump NCog values from\n// the BEP-Gravitation optimizer (bepGravitation.js:162 totalCog). Range is\n// 0..N where N=active pumps, NOT 0..1. Normalize here so the dashboard shows\n// a per-pump average position on the BEP envelope.\nconst ncogSum = +cache['atEquipment_predicted_Ncog'];\n// undefined (not null) for the degraded case — pct() does `+v` and `+null === 0`,\n// which would silently display \"0.0 %\" instead of the em-dash that means \"no data\".\nconst ncog = (Number.isFinite(ncogSum) && Number.isFinite(+nAct) && +nAct > 0)\n ? ncogSum / +nAct\n : undefined;\n// Peak \u03b7 isn't emitted directly; derive: peak = eta + absDistFromPeak.\nconst etaPeak = (Number.isFinite(+eta) && Number.isFinite(+bepAbs)) ? (+eta + +bepAbs) : null;\n\nconst chart = (topic, v, scale = 1) =>\n Number.isFinite(+v) ? { topic, payload: +v * scale } : null;\n\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '\u2014';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 0-5: original status texts (mode, flow, power, capacity, machines, BEP rel%)\n { payload: mode },\n { payload: num(flow, 1, 'm\u00b3/h') },\n { payload: num(power, 2, 'kW') },\n { payload: Number.isFinite(+qMax) ? (num(qMin, 1) + ' \u2013 ' + num(qMax, 1, 'm\u00b3/h')) : '\u2014' },\n { payload: (Number.isFinite(+nAct) && Number.isFinite(+nTot)) ? (nAct + ' / ' + nTot) : '\u2014' },\n { payload: pct(bepRel, 1) }, // BEP rel% \u2014 was buggy: now \u00d7100 then format\n\n // 6-9: new status texts (\u03b7, \u03b7 peak, BEP abs gap, NCog)\n { payload: pct(eta, 1) }, // \u03b7 (hydraulic)\n { payload: pct(etaPeak, 1) }, // \u03b7 peak\n { payload: num(bepAbs, 3) }, // BEP abs gap (\u03b7 points)\n { payload: pct(ncog, 1) }, // NCog (BEP flow position)\n\n // 10-13: charts (flow predicted, capacity max, power, BEP rel% scaled to 0..100)\n chart('Flow', flow),\n chart('Capacity', qMax),\n chart('Power', power),\n chart('BEP rel %', bepRel, 100), // chart also fixed: scale 0..1 \u2192 0..100\n\n // 14: efficiency chart \u2014 emit only when eta is finite (null msg = no output,\n // which avoids ui-chart crashing on { payload: null })\n chart('\u03b7 (%)', eta, 100),\n\n // 15: raw rows for the ui-template\n { payload: rawRows },\n { payload: msg.payload }, // 16: raw passthrough for Q-H chart\n];\n",
|
||||
"outputs": 17,
|
||||
"func": "// MGC Port 0 emits delta-only. Cache last-known so deltas never blank a\n// row, then fan out one msg per ui-text / ui-chart / ui-template slot.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\n// num/pct treat null AND undefined as \"no data\" (display em-dash). Without\n// the explicit null check, `+null === 0` would silently render as \"0.0 %\" \u2014\n// the bug class we hit twice today (\u03b7-null and Ncog/bepRel degenerate).\nconst num = (v, dp, unit) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\nconst pct = (v, dp) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return (n * 100).toFixed(dp) + ' %';\n};\n\nconst mode = cache.mode || '\u2014';\nconst flow = cache['atEquipment_predicted_flow'];\nconst power = cache['atEquipment_predicted_power'];\nconst qMin = cache.flowCapacityMin;\nconst qMax = cache.flowCapacityMax;\nconst nAct = cache.machineCountActive;\nconst nTot = cache.machineCount;\nconst bepRel = cache.relDistFromPeak; // 0..1\nconst bepAbs = cache.absDistFromPeak; // \u03b7 points (dimensionless)\nconst eta = cache['atEquipment_predicted_efficiency']; // 0..1\n// MGC emits atEquipment_predicted_Ncog as the SUM of per-pump NCog values from\n// the BEP-Gravitation optimizer (bepGravitation.js:162 totalCog). Range is\n// 0..N where N=active pumps, NOT 0..1. Normalize here so the dashboard shows\n// a per-pump average position on the BEP envelope.\nconst ncogSum = +cache['atEquipment_predicted_Ncog'];\n// undefined (not null) for the degraded case \u2014 pct() does `+v` and `+null === 0`,\n// which would silently display \"0.0 %\" instead of the em-dash that means \"no data\".\nconst ncog = (Number.isFinite(ncogSum) && Number.isFinite(+nAct) && +nAct > 0)\n ? ncogSum / +nAct\n : undefined;\n// Peak \u03b7 isn't emitted directly; derive: peak = eta + absDistFromPeak.\nconst etaPeak = (Number.isFinite(+eta) && Number.isFinite(+bepAbs)) ? (+eta + +bepAbs) : null;\n\n// % of capacity \u2014 realized predicted flow / max capacity. Both already coerced safely above.\nconst pctCap = (Number.isFinite(+flow) && Number.isFinite(+qMax) && +qMax > 0)\n ? (+flow / +qMax) * 100\n : undefined;\n\nconst chart = (topic, v, scale = 1) =>\n Number.isFinite(+v) ? { topic, payload: +v * scale } : null;\n\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '\u2014';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 0-5: original status texts (mode, flow, power, capacity, machines, BEP rel%)\n { payload: mode },\n { payload: num(flow, 1, 'm\u00b3/h') },\n { payload: num(power, 2, 'kW') },\n { payload: Number.isFinite(+qMax) ? (num(qMin, 1) + ' \u2013 ' + num(qMax, 1, 'm\u00b3/h')) : '\u2014' },\n { payload: (Number.isFinite(+nAct) && Number.isFinite(+nTot)) ? (nAct + ' / ' + nTot) : '\u2014' },\n { payload: pct(bepRel, 1) }, // BEP rel% \u2014 was buggy: now \u00d7100 then format\n\n // 6-9: new status texts (\u03b7, \u03b7 peak, BEP abs gap, NCog)\n { payload: pct(eta, 1) }, // \u03b7 (hydraulic)\n { payload: pct(etaPeak, 1) }, // \u03b7 peak\n { payload: num(bepAbs, 3) }, // BEP abs gap (\u03b7 points)\n { payload: pct(ncog, 1) }, // NCog (BEP flow position)\n\n // 10-13: charts (flow predicted, capacity max, power, BEP rel% scaled to 0..100)\n chart('Flow', flow),\n chart('Capacity', qMax),\n chart('Power', power),\n chart('BEP rel %', bepRel, 100), // chart also fixed: scale 0..1 \u2192 0..100\n\n // 14: efficiency chart \u2014 emit only when eta is finite (null msg = no output,\n // which avoids ui-chart crashing on { payload: null })\n chart('\u03b7 (%)', eta, 100),\n\n // 15: raw rows for the ui-template\n { payload: rawRows },\n { payload: msg.payload }, // 16: raw passthrough for Q-H chart\n // 17: % of capacity chart\n chart('% of capacity', pctCap),\n];\n",
|
||||
"outputs": 18,
|
||||
"timeout": 0,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
@@ -505,6 +510,9 @@
|
||||
],
|
||||
[
|
||||
"fn_qh_point"
|
||||
],
|
||||
[
|
||||
"ui_chart_mgc_pctcap"
|
||||
]
|
||||
]
|
||||
},
|
||||
@@ -1231,8 +1239,8 @@
|
||||
"z": "tab_mgc_dash",
|
||||
"g": "grp_status_panel",
|
||||
"name": "chart: Pump A",
|
||||
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\nif (flow == null) return null;\nreturn { topic: 'Pump A', payload: Number(flow) };\n",
|
||||
"outputs": 1,
|
||||
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump A', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump A', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump A', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n",
|
||||
"outputs": 2,
|
||||
"timeout": 0,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
@@ -1243,6 +1251,9 @@
|
||||
"wires": [
|
||||
[
|
||||
"ui_chart_per_pump_flow"
|
||||
],
|
||||
[
|
||||
"ui_chart_pumps_ctrl"
|
||||
]
|
||||
]
|
||||
},
|
||||
@@ -1252,8 +1263,8 @@
|
||||
"z": "tab_mgc_dash",
|
||||
"g": "grp_status_panel",
|
||||
"name": "chart: Pump B",
|
||||
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\nif (flow == null) return null;\nreturn { topic: 'Pump B', payload: Number(flow) };\n",
|
||||
"outputs": 1,
|
||||
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump B', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump B', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump B', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n",
|
||||
"outputs": 2,
|
||||
"timeout": 0,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
@@ -1264,6 +1275,9 @@
|
||||
"wires": [
|
||||
[
|
||||
"ui_chart_per_pump_flow"
|
||||
],
|
||||
[
|
||||
"ui_chart_pumps_ctrl"
|
||||
]
|
||||
]
|
||||
},
|
||||
@@ -1273,8 +1287,8 @@
|
||||
"z": "tab_mgc_dash",
|
||||
"g": "grp_status_panel",
|
||||
"name": "chart: Pump C",
|
||||
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\nif (flow == null) return null;\nreturn { topic: 'Pump C', payload: Number(flow) };\n",
|
||||
"outputs": 1,
|
||||
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump C', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump C', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump C', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n",
|
||||
"outputs": 2,
|
||||
"timeout": 0,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
@@ -1285,6 +1299,9 @@
|
||||
"wires": [
|
||||
[
|
||||
"ui_chart_per_pump_flow"
|
||||
],
|
||||
[
|
||||
"ui_chart_pumps_ctrl"
|
||||
]
|
||||
]
|
||||
},
|
||||
@@ -1713,7 +1730,7 @@
|
||||
"z": "tab_mgc_dash",
|
||||
"g": "grp_pump_a",
|
||||
"name": "Q-H curve points \u2192 chart",
|
||||
"func": "// Emit one chart msg per point. Topic='Curve' makes the chart treat\n// it as a second series next to the 'Operating point' scatter.\n// Action 'replace' so each new sample sweeps the curve fresh (no\n// trail buildup).\nconst r = msg.payload || {};\nif (r.error || !Array.isArray(r.points) || r.points.length === 0) return null;\n\n// Trim the trailing flat-Q tail. buildQHCurve returns ~33 points across the\n// full pressure envelope, but at low ctrl% the last ~10 points clamp to the\n// pump's minimum-flow envelope (constant Q across rising H). Plotting those\n// stretches the chart's H axis to ~40 m even though the operating point sits\n// near H=11 m — making the curve look like a vertical line with the\n// operating point lost at the bottom. Keep one entry-point of the tail so\n// the curve still terminates visually, drop the rest.\nconst FLAT_Q_EPS = 0.5; // m³/h — pump-curve resolution; below this is noise.\nlet trimTo = r.points.length;\nfor (let i = r.points.length - 1; i > 0; i--) {\n if (Math.abs(r.points[i].Q - r.points[i-1].Q) >= FLAT_Q_EPS) { trimTo = i + 1; break; }\n}\nconst trimmed = r.points.slice(0, trimTo);\n\nconst out = trimmed.map((pt) => ({ topic: 'Curve', payload: { x: pt.Q, y: pt.H } }));\n// Send a reset to clear the previous curve before appending the new one.\nout.unshift({ topic: 'Curve', action: 'clear' });\nreturn [out];\n",
|
||||
"func": "// Emit one chart msg per point. Topic='Curve' makes the chart treat\n// it as a second series next to the 'Operating point' scatter.\n// Action 'replace' so each new sample sweeps the curve fresh (no\n// trail buildup).\nconst r = msg.payload || {};\nif (r.error || !Array.isArray(r.points) || r.points.length === 0) return null;\n\n// Trim the trailing flat-Q tail. buildQHCurve returns ~33 points across the\n// full pressure envelope, but at low ctrl% the last ~10 points clamp to the\n// pump's minimum-flow envelope (constant Q across rising H). Plotting those\n// stretches the chart's H axis to ~40 m even though the operating point sits\n// near H=11 m \u2014 making the curve look like a vertical line with the\n// operating point lost at the bottom. Keep one entry-point of the tail so\n// the curve still terminates visually, drop the rest.\nconst FLAT_Q_EPS = 0.5; // m\u00b3/h \u2014 pump-curve resolution; below this is noise.\nlet trimTo = r.points.length;\nfor (let i = r.points.length - 1; i > 0; i--) {\n if (Math.abs(r.points[i].Q - r.points[i-1].Q) >= FLAT_Q_EPS) { trimTo = i + 1; break; }\n}\nconst trimmed = r.points.slice(0, trimTo);\n\nconst out = trimmed.map((pt) => ({ topic: 'Curve', payload: { x: pt.Q, y: pt.H } }));\n// Send a reset to clear the previous curve before appending the new one.\nout.unshift({ topic: 'Curve', action: 'clear' });\nreturn [out];\n",
|
||||
"outputs": 1,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
@@ -1746,5 +1763,131 @@
|
||||
"fn_qh_curve_fetcher"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ui_chart_mgc_pctcap",
|
||||
"type": "ui-chart",
|
||||
"z": "tab_mgc_dash",
|
||||
"g": "grp_status_panel",
|
||||
"group": "ui_group_trends",
|
||||
"name": "% of capacity",
|
||||
"label": "Group % of capacity \u2014 flow \u00f7 Qmax",
|
||||
"order": 5,
|
||||
"chartType": "line",
|
||||
"category": "topic",
|
||||
"categoryType": "msg",
|
||||
"xAxisLabel": "time",
|
||||
"xAxisProperty": "",
|
||||
"xAxisPropertyType": "timestamp",
|
||||
"xAxisType": "time",
|
||||
"xAxisFormat": "",
|
||||
"xAxisFormatType": "auto",
|
||||
"xmin": "",
|
||||
"xmax": "",
|
||||
"yAxisLabel": "%",
|
||||
"yAxisProperty": "payload",
|
||||
"yAxisPropertyType": "msg",
|
||||
"ymin": "0",
|
||||
"ymax": "120",
|
||||
"bins": 10,
|
||||
"action": "append",
|
||||
"stackSeries": false,
|
||||
"pointShape": "circle",
|
||||
"pointRadius": 4,
|
||||
"showLegend": false,
|
||||
"removeOlder": "15",
|
||||
"removeOlderUnit": "60",
|
||||
"removeOlderPoints": "",
|
||||
"colors": [
|
||||
"#A347E1",
|
||||
"#FF0000",
|
||||
"#FF7F0E",
|
||||
"#2CA02C",
|
||||
"#0095FF",
|
||||
"#D62728",
|
||||
"#FF9896",
|
||||
"#9467BD",
|
||||
"#C5B0D5"
|
||||
],
|
||||
"textColor": [
|
||||
"#666666"
|
||||
],
|
||||
"textColorDefault": true,
|
||||
"gridColor": [
|
||||
"#e5e5e5"
|
||||
],
|
||||
"gridColorDefault": true,
|
||||
"width": 6,
|
||||
"height": 4,
|
||||
"className": "",
|
||||
"interpolation": "linear",
|
||||
"x": 1770,
|
||||
"y": 500,
|
||||
"wires": [
|
||||
[]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ui_chart_pumps_ctrl",
|
||||
"type": "ui-chart",
|
||||
"z": "tab_mgc_dash",
|
||||
"g": "grp_status_panel",
|
||||
"group": "ui_group_trends",
|
||||
"name": "Per-pump % control",
|
||||
"label": "Per-pump % control \u2014 A / B / C",
|
||||
"order": 6,
|
||||
"chartType": "line",
|
||||
"category": "topic",
|
||||
"categoryType": "msg",
|
||||
"xAxisLabel": "time",
|
||||
"xAxisProperty": "",
|
||||
"xAxisPropertyType": "timestamp",
|
||||
"xAxisType": "time",
|
||||
"xAxisFormat": "",
|
||||
"xAxisFormatType": "auto",
|
||||
"xmin": "",
|
||||
"xmax": "",
|
||||
"yAxisLabel": "%",
|
||||
"yAxisProperty": "payload",
|
||||
"yAxisPropertyType": "msg",
|
||||
"ymin": "-5",
|
||||
"ymax": "100",
|
||||
"bins": 10,
|
||||
"action": "append",
|
||||
"stackSeries": false,
|
||||
"pointShape": "circle",
|
||||
"pointRadius": 4,
|
||||
"showLegend": true,
|
||||
"removeOlder": "15",
|
||||
"removeOlderUnit": "60",
|
||||
"removeOlderPoints": "",
|
||||
"colors": [
|
||||
"#FF7F0E",
|
||||
"#2CA02C",
|
||||
"#A347E1",
|
||||
"#0095FF",
|
||||
"#D62728",
|
||||
"#FF9896",
|
||||
"#9467BD",
|
||||
"#C5B0D5",
|
||||
"#cccccc"
|
||||
],
|
||||
"textColor": [
|
||||
"#666666"
|
||||
],
|
||||
"textColorDefault": true,
|
||||
"gridColor": [
|
||||
"#e5e5e5"
|
||||
],
|
||||
"gridColorDefault": true,
|
||||
"width": 6,
|
||||
"height": 4,
|
||||
"className": "",
|
||||
"interpolation": "linear",
|
||||
"x": 1750,
|
||||
"y": 700,
|
||||
"wires": [
|
||||
[]
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
87
mgc.html
87
mgc.html
@@ -15,30 +15,47 @@
|
||||
dependency order: index.js (namespace + helpers) → modules → oneditprepare. -->
|
||||
<script src="/machineGroupControl/editor/index.js"></script>
|
||||
<script src="/machineGroupControl/editor/mode-cards.js"></script>
|
||||
<script src="/machineGroupControl/editor/compact-fields.js"></script>
|
||||
<script src="/machineGroupControl/editor/oneditprepare.js"></script>
|
||||
|
||||
<style>
|
||||
/* Mode-card picker. Three cards stack horizontally; on a narrow editor pane
|
||||
they wrap. Selected card gets a thick #50a8d9 (Unit-colour) border. */
|
||||
.mgc-mode-cards { display:flex; gap:8px; flex-wrap:wrap; margin:6px 0 4px 0; }
|
||||
.mgc-mode-card {
|
||||
flex:1 1 0; min-width:140px; box-sizing:border-box;
|
||||
/* MGC-specific UI: strategy mode cards + rendezvous toggle.
|
||||
Generic .evolv-icon-picker / .evolv-icon-option styles for the
|
||||
output-format pickers come from generalFunctions' iconHelpers (auto-
|
||||
injected by /menu.js). */
|
||||
.mgc-mode-cards,
|
||||
.mgc-toggle-row { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 4px 0; }
|
||||
.mgc-mode-card,
|
||||
.mgc-toggle-card {
|
||||
width:94px; height:86px; box-sizing:border-box;
|
||||
border:2px solid #d0d0d0; border-radius:4px; background:#fafafa;
|
||||
padding:6px 8px 8px 8px; cursor:pointer; user-select:none;
|
||||
display:flex; flex-direction:column; gap:4px;
|
||||
padding:4px; cursor:pointer; user-select:none;
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px;
|
||||
transition:border-color 80ms ease-out, background 80ms ease-out;
|
||||
}
|
||||
.mgc-mode-card:hover { border-color:#86bbdd; background:#f5fafd; }
|
||||
.mgc-mode-card-on { border-color:#50a8d9; background:#eaf4fb; }
|
||||
.mgc-mode-card-svg svg { width:100%; height:auto; max-height:90px; display:block; }
|
||||
.mgc-mode-card-label { font-weight:600; font-size:12px; color:#333; }
|
||||
.mgc-mode-card-caption { font-size:10px; color:#666; line-height:1.3; }
|
||||
.mgc-mode-card:hover,
|
||||
.mgc-toggle-card:hover { border-color:#86bbdd; background:#f5fafd; }
|
||||
.mgc-mode-card:focus,
|
||||
.mgc-toggle-card:focus { outline:2px solid #1F4E79; outline-offset:2px; }
|
||||
.mgc-mode-card-on,
|
||||
.mgc-toggle-card-on { border-color:#50a8d9; background:#eaf4fb; }
|
||||
.mgc-mode-card-svg,
|
||||
.mgc-toggle-card-svg { width:100%; height:54px; display:flex; align-items:center; justify-content:center; }
|
||||
.mgc-mode-card-svg svg,
|
||||
.mgc-toggle-card-svg svg { width:100%; height:100%; display:block; }
|
||||
.mgc-mode-card-label,
|
||||
.mgc-toggle-card-label { font-size:10px; line-height:1; font-weight:600; color:#333; white-space:nowrap; letter-spacing:0; }
|
||||
.mgc-toggle-card:not(.mgc-toggle-card-on) .mgc-toggle-card-svg { opacity:0.45; filter:grayscale(1); }
|
||||
.mgc-toggle-card:not(.mgc-toggle-card-on) .mgc-toggle-card-label { color:#888; }
|
||||
.mgc-hidden-checkbox { position:absolute; opacity:0; width:1px; height:1px; pointer-events:none; }
|
||||
.mgc-section-divider { border:0; border-top:1px solid #d6d6d6; margin:12px 0; }
|
||||
.mgc-output-row > label { white-space:nowrap; width:130px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
RED.nodes.registerType('machineGroupControl',{
|
||||
category: "EVOLV",
|
||||
color: "#50a8d9",
|
||||
color: "#B5651D",
|
||||
defaults: {
|
||||
// Define default properties
|
||||
name: { value: "" },
|
||||
@@ -89,7 +106,7 @@
|
||||
// Initialize the menu data for the node, then the visual modules.
|
||||
// Both attach to window.EVOLV.nodes.machineGroupControl.* — the
|
||||
// menu endpoint populates loggerMenu/positionMenu/initEditor; the
|
||||
// editor scripts populate editor.modeCards/demandContract.
|
||||
// editor scripts populate editor.modeCards/rendezvousToggle/compactFields.
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.machineGroupControl?.initEditor) {
|
||||
window.EVOLV.nodes.machineGroupControl.initEditor(self);
|
||||
@@ -133,48 +150,40 @@
|
||||
role="radiogroup" aria-label="Control strategy mode">
|
||||
<!-- mode-cards.js renders three card divs here -->
|
||||
</div>
|
||||
|
||||
<p style="margin-top:8px;color:#666;font-size:11px;">
|
||||
Demand is self-describing per <code>set.demand</code> message: a bare number is
|
||||
treated as % of group capacity; <code>{value, unit}</code> with a flow unit
|
||||
(<code>m3/h</code>, <code>l/s</code>, <code>m3/s</code>, …) is dispatched
|
||||
in absolute terms. Negative value stops all pumps.
|
||||
</p>
|
||||
<hr class="mgc-section-divider" />
|
||||
|
||||
<h3>Rendezvous planner</h3>
|
||||
<div class="form-row" style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="checkbox" id="node-input-useRendezvous"
|
||||
style="width:auto;margin:0;vertical-align:middle;" />
|
||||
<label for="node-input-useRendezvous" style="width:auto;margin:0;cursor:pointer;">
|
||||
Same-time landing
|
||||
</label>
|
||||
<div class="form-row mgc-toggle-row">
|
||||
<input type="checkbox" id="node-input-useRendezvous" class="mgc-hidden-checkbox" />
|
||||
<div id="mgc-rendezvous-toggle" class="mgc-toggle-card"
|
||||
role="switch" tabindex="0" aria-label="Same-time landing"
|
||||
aria-checked="false" title="Same-time landing"></div>
|
||||
</div>
|
||||
<p style="margin-top:4px;color:#666;font-size:11px;">
|
||||
When enabled (default), every dispatch is routed through the rendezvous
|
||||
planner regardless of control strategy: per-pump moves are delayed so all
|
||||
pumps reach their setpoint at the same wall-clock instant
|
||||
<code>t* = max(eta<sub>i</sub>)</code>. When disabled, every
|
||||
<code>flowmovement</code> fires immediately and each pump ramps at its
|
||||
own configured reaction speed (legacy behaviour).
|
||||
</p>
|
||||
<hr class="mgc-section-divider" />
|
||||
|
||||
<h3>Output Formats</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-row mgc-output-row">
|
||||
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||
<select id="node-input-processOutputFormat" style="width:60%;">
|
||||
<select id="node-input-processOutputFormat" class="evolv-native-hidden" style="width:60%;">
|
||||
<option value="process">process</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
<div id="mgc-process-output-picker" class="evolv-icon-picker"
|
||||
role="radiogroup" aria-label="Process output format"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-row mgc-output-row">
|
||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||
<select id="node-input-dbaseOutputFormat" class="evolv-native-hidden" style="width:60%;">
|
||||
<option value="influxdb">influxdb</option>
|
||||
<option value="frost">frost</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
<div id="mgc-dbase-output-picker" class="evolv-icon-picker"
|
||||
role="radiogroup" aria-label="Database output format"></div>
|
||||
</div>
|
||||
<hr class="mgc-section-divider" />
|
||||
|
||||
<!-- Logger fields injected here -->
|
||||
<div id="logger-fields-placeholder"></div>
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
// Pure functions: no module-level state. The registry already enforces the
|
||||
// typeof-check ladder; per-topic semantic validation lives here.
|
||||
|
||||
const { convert } = require('generalFunctions');
|
||||
|
||||
function _logger(source, ctx) {
|
||||
return ctx?.logger || source?.logger || null;
|
||||
}
|
||||
@@ -61,8 +59,8 @@ exports.setDemand = async (source, msg, ctx) => {
|
||||
// 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.
|
||||
// Unit resolution + canonical dispatch lives in source.setDemand. The
|
||||
// handler's job is payload parsing, mode gating, and the "done" reply.
|
||||
const p = msg?.payload;
|
||||
let rawValue;
|
||||
let unit;
|
||||
@@ -88,33 +86,8 @@ exports.setDemand = async (source, msg, ctx) => {
|
||||
else if (source?.mode === 'priorityControl') action = 'execSequentialControl';
|
||||
else action = 'execOptimalCombination';
|
||||
if (!_gate(source, action, msg)) return;
|
||||
// Negative is the operator's "stop all" signal regardless of unit.
|
||||
if (value < 0) {
|
||||
try {
|
||||
await source.turnOffAllMachines();
|
||||
} catch (err) {
|
||||
log?.error?.(`set.demand: turnOffAllMachines failed: ${err && err.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Resolve to canonical m³/s.
|
||||
let canonicalDemand;
|
||||
if (unit === '%') {
|
||||
const dt = source.calcDynamicTotals();
|
||||
// Linear interpolation: 0 % → dt.flow.min, 100 % → dt.flow.max. The
|
||||
// interpolation helper also clamps so 110 % can't run pumps past max.
|
||||
canonicalDemand = source.interpolation.interpolate_lin_single_point(
|
||||
value, 0, 100, dt.flow.min, dt.flow.max);
|
||||
} else {
|
||||
try {
|
||||
canonicalDemand = convert(value).from(unit).to('m3/s');
|
||||
} catch (err) {
|
||||
log?.error?.(`set.demand: cannot convert ${value} ${unit} → m3/s: ${err && err.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await source.handleInput('parent', canonicalDemand);
|
||||
await source.setDemand(value, unit);
|
||||
} catch (err) {
|
||||
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
|
||||
return;
|
||||
|
||||
@@ -12,7 +12,7 @@ module.exports = [
|
||||
topic: 'set.mode',
|
||||
aliases: ['setMode'],
|
||||
payloadSchema: { type: 'string' },
|
||||
description: 'Switch the machine group between auto / manual modes.',
|
||||
description: 'Switch the operating mode. Allowed: `optimalControl`, `priorityControl`, `maintenance` (schema-validated in `machineGroupControl.json` → `mode.current`).',
|
||||
handler: handlers.setMode,
|
||||
},
|
||||
{
|
||||
|
||||
94
src/editor/compact-fields.js
Normal file
94
src/editor/compact-fields.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// compact-fields.js — MGC-only output-format icon picker.
|
||||
//
|
||||
// Logger toggle/level and physical-position visuals now live in the shared
|
||||
// generalFunctions/src/menu/iconHelpers.js (auto-injected by MenuManager), so
|
||||
// the only MGC-local visuals left are the two output-format dropdowns
|
||||
// (processOutputFormat, dbaseOutputFormat) — those fields aren't part of any
|
||||
// shared menu.
|
||||
|
||||
(function () {
|
||||
const editor = window.EVOLV?.nodes?.machineGroupControl?.editor;
|
||||
if (!editor) return;
|
||||
|
||||
const BLUE = '#1F4E79';
|
||||
const STEEL = '#607484';
|
||||
|
||||
// MGC-only SVGs (output formats only — logger/position SVGs come from
|
||||
// window.EVOLV.iconHelpers.SVG).
|
||||
const SVG = {
|
||||
process: `
|
||||
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect x="10" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
|
||||
<rect x="50" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
|
||||
<line x1="30" y1="29" x2="46" y2="29" stroke="${BLUE}" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M42 24 L48 29 L42 34" fill="none" stroke="${BLUE}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`,
|
||||
json: `
|
||||
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<g fill="none" stroke="${BLUE}" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M30 14 C22 16 22 26 27 29 C22 32 22 42 30 44"/>
|
||||
<path d="M50 14 C58 16 58 26 53 29 C58 32 58 42 50 44"/>
|
||||
</g>
|
||||
<g fill="${STEEL}">
|
||||
<circle cx="36" cy="29" r="2.2"/>
|
||||
<circle cx="44" cy="29" r="2.2"/>
|
||||
</g>
|
||||
</svg>`,
|
||||
csv: `
|
||||
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect x="12" y="12" width="56" height="34" rx="2" fill="#fff" stroke="${STEEL}" stroke-width="2.4"/>
|
||||
<line x1="12" y1="22" x2="68" y2="22" stroke="${STEEL}" stroke-width="2"/>
|
||||
<g stroke="${STEEL}" stroke-width="1.6">
|
||||
<line x1="12" y1="34" x2="68" y2="34"/>
|
||||
<line x1="31" y1="12" x2="31" y2="46"/>
|
||||
<line x1="49" y1="12" x2="49" y2="46"/>
|
||||
</g>
|
||||
</svg>`,
|
||||
influxdb: `
|
||||
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<ellipse cx="40" cy="15" rx="22" ry="6" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
|
||||
<path d="M18 15 V42 C18 46 28 49 40 49 C52 49 62 46 62 42 V15" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
|
||||
<path d="M18 28 C26 32 54 32 62 28" fill="none" stroke="${STEEL}" stroke-width="1.6" opacity="0.6"/>
|
||||
<path d="M22 39 L30 32 L38 41 L46 34 L54 38" fill="none" stroke="${BLUE}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
const outputIcons = {
|
||||
process: SVG.process,
|
||||
json: SVG.json,
|
||||
csv: SVG.csv,
|
||||
influxdb: SVG.influxdb,
|
||||
frost: SVG.influxdb,
|
||||
};
|
||||
|
||||
const outputLabels = {
|
||||
process: 'Process',
|
||||
json: 'JSON',
|
||||
csv: 'CSV',
|
||||
influxdb: 'Influx',
|
||||
frost: 'FROST',
|
||||
};
|
||||
|
||||
function initOutputFormats() {
|
||||
const helpers = window.EVOLV?.iconHelpers;
|
||||
if (!helpers) return;
|
||||
|
||||
const processSelect = document.getElementById('node-input-processOutputFormat');
|
||||
const processHolder = document.getElementById('mgc-process-output-picker');
|
||||
if (processSelect && processHolder) {
|
||||
helpers.renderSelectPicker(processSelect, processHolder, outputIcons, outputLabels);
|
||||
}
|
||||
|
||||
const dbaseSelect = document.getElementById('node-input-dbaseOutputFormat');
|
||||
const dbaseHolder = document.getElementById('mgc-dbase-output-picker');
|
||||
if (dbaseSelect && dbaseHolder) {
|
||||
helpers.renderSelectPicker(dbaseSelect, dbaseHolder, outputIcons, outputLabels);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
initOutputFormats();
|
||||
}
|
||||
|
||||
editor.compactFields = { init };
|
||||
})();
|
||||
@@ -1,14 +1,10 @@
|
||||
// mode-cards.js — visual radio picker for the three control-strategy modes.
|
||||
// mode-cards.js — visual pickers for control-strategy modes and planner flags.
|
||||
//
|
||||
// Replaces the plain <select id="node-input-mode"> with three illustrated
|
||||
// cards. The original <input> stays in the DOM but is hidden — Node-RED reads
|
||||
// its value on save, exactly as before. Clicking a card sets that value and
|
||||
// fires editor.emitModeChange so downstream UI (none today, future widgets
|
||||
// such as a parameter panel) can re-render.
|
||||
// Replaces the plain mode field with compact illustrated controls. The
|
||||
// original inputs stay in the DOM but are hidden — Node-RED reads their values
|
||||
// on save, exactly as before.
|
||||
//
|
||||
// Three cards: optimalControl (BEP-curve), priorityControl (flow ladder),
|
||||
// maintenance (status-only badge). SVGs are inline so the editor doesn't
|
||||
// need to fetch additional assets.
|
||||
// SVGs are inline so the editor doesn't need to fetch additional assets.
|
||||
|
||||
(function () {
|
||||
const editor = window.EVOLV?.nodes?.machineGroupControl?.editor;
|
||||
@@ -17,95 +13,61 @@
|
||||
const MODES = [
|
||||
{
|
||||
value: 'optimalControl',
|
||||
label: 'optimalControl',
|
||||
caption: 'Picks the pump combination whose BEP sits closest to current demand.',
|
||||
ariaLabel: 'Optimal control',
|
||||
label: 'Most-efficient',
|
||||
svg: `
|
||||
<svg viewBox="0 0 160 90" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<text x="6" y="14" font-size="9" fill="#444">η</text>
|
||||
<line x1="14" y1="78" x2="154" y2="78" stroke="#444" stroke-width="1"/>
|
||||
<line x1="14" y1="78" x2="14" y2="14" stroke="#444" stroke-width="1"/>
|
||||
<text x="118" y="88" font-size="8" fill="#666">demand →</text>
|
||||
|
||||
<!-- Three pump-combination efficiency humps. Each combination has
|
||||
its own BEP (peak). The optimizer "gravitates" toward whichever
|
||||
peak sits closest to the current demand. Quadratic-Bezier peak
|
||||
formula: peak_y = (y0 + 2*cy + y1)/4 — so for y0=y1=78 (foot on
|
||||
x-axis), cy=22 → peak_y=50, cy=-26 → peak_y=26, cy=10 → peak_y=44. -->
|
||||
<path d="M 16 78 Q 32 22 50 78" fill="none" stroke="#888" stroke-width="1.1"/>
|
||||
<path d="M 44 78 Q 72 -26 100 78" fill="none" stroke="#1E8449" stroke-width="2"/>
|
||||
<path d="M 92 78 Q 122 10 152 78" fill="none" stroke="#888" stroke-width="1.1"/>
|
||||
|
||||
<!-- BEP markers sit ON each hump's apex — small grey for unpicked
|
||||
combos, large red for the selected (winner) combination. -->
|
||||
<circle cx="33" cy="50" r="2" fill="#888"/>
|
||||
<circle cx="72" cy="26" r="3.2" fill="#C0392B" stroke="#fff" stroke-width="1"/>
|
||||
<circle cx="122" cy="44" r="2" fill="#888"/>
|
||||
|
||||
<!-- Current demand (dashed line) lines up with combo #2's BEP, so
|
||||
combo #2 wins — drawn thicker/green above. -->
|
||||
<line x1="72" y1="14" x2="72" y2="78" stroke="#1F4E79" stroke-dasharray="2 2" stroke-width="0.9"/>
|
||||
<text x="46" y="20" font-size="7" fill="#1F4E79">demand</text>
|
||||
<text x="80" y="22" font-size="7" fill="#C0392B" font-weight="bold">BEP</text>
|
||||
|
||||
<!-- Combination labels under each curve. -->
|
||||
<text x="33" y="86" font-size="6" fill="#666" text-anchor="middle">P1</text>
|
||||
<text x="72" y="86" font-size="6" fill="#1E8449" text-anchor="middle" font-weight="bold">P1+P2</text>
|
||||
<text x="122" y="86" font-size="6" fill="#666" text-anchor="middle">P1+P2+P3</text>
|
||||
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<path d="M 16 60 Q 62 -30 108 60" fill="none" stroke="#1E8449" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="62" y1="15" x2="62" y2="60" stroke="#1F4E79" stroke-dasharray="3 3" stroke-width="1.2"/>
|
||||
<circle cx="62" cy="15" r="5.5" fill="#1E8449" stroke="#fff" stroke-width="1.6"/>
|
||||
</svg>`,
|
||||
},
|
||||
{
|
||||
value: 'priorityControl',
|
||||
label: 'priorityControl',
|
||||
caption: 'Sequential equal-flow ramp — fill pumps one-by-one in priority order.',
|
||||
ariaLabel: 'Priority control',
|
||||
label: 'Priority',
|
||||
svg: `
|
||||
<svg viewBox="0 0 160 90" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<text x="6" y="14" font-size="9" fill="#444">flow</text>
|
||||
<line x1="14" y1="78" x2="154" y2="78" stroke="#444" stroke-width="1"/>
|
||||
<line x1="14" y1="78" x2="14" y2="14" stroke="#444" stroke-width="1"/>
|
||||
<text x="118" y="88" font-size="8" fill="#666">demand →</text>
|
||||
<polyline points="14,72 50,72 50,52 86,52 86,32 122,32 122,16 154,16"
|
||||
fill="none" stroke="#1F4E79" stroke-width="2"/>
|
||||
<text x="28" y="86" font-size="7" fill="#666">P1</text>
|
||||
<text x="64" y="86" font-size="7" fill="#666">P2</text>
|
||||
<text x="100" y="86" font-size="7" fill="#666">P3</text>
|
||||
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<polyline points="14,54 38,54 38,40 62,40 62,26 86,26 86,14 110,14"
|
||||
fill="none" stroke="#1F4E79" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>`,
|
||||
},
|
||||
{
|
||||
value: 'maintenance',
|
||||
label: 'maintenance',
|
||||
caption: 'Monitor only. Dispatch and stop-all commands are rejected; status messages still flow.',
|
||||
svg: `
|
||||
<svg viewBox="0 0 160 90" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="80" cy="42" r="22" fill="none" stroke="#888" stroke-width="2"/>
|
||||
<circle cx="80" cy="42" r="8" fill="#888"/>
|
||||
<g stroke="#888" stroke-width="3" stroke-linecap="round">
|
||||
<line x1="80" y1="14" x2="80" y2="24"/>
|
||||
<line x1="80" y1="60" x2="80" y2="70"/>
|
||||
<line x1="52" y1="42" x2="62" y2="42"/>
|
||||
<line x1="98" y1="42" x2="108" y2="42"/>
|
||||
<line x1="60" y1="22" x2="67" y2="29"/>
|
||||
<line x1="93" y1="55" x2="100" y2="62"/>
|
||||
<line x1="60" y1="62" x2="67" y2="55"/>
|
||||
<line x1="93" y1="29" x2="100" y2="22"/>
|
||||
</g>
|
||||
<text x="80" y="84" text-anchor="middle" font-size="8" fill="#888">monitor only</text>
|
||||
</svg>`,
|
||||
ariaLabel: 'Maintenance',
|
||||
label: 'Maintenance',
|
||||
svg: `<i class="fa fa-wrench" style="font-size:40px;color:#607484;" aria-hidden="true"></i>`,
|
||||
},
|
||||
];
|
||||
|
||||
// Render the three cards into the placeholder div. The hidden <select> stays
|
||||
// intact — the card click handler writes its value back to that <select> so
|
||||
// Node-RED's save path is unchanged.
|
||||
const RENDEZVOUS_SVG = `
|
||||
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<line x1="96" y1="12" x2="96" y2="60" stroke="#1F4E79" stroke-dasharray="3 3" stroke-width="1.4"/>
|
||||
<path d="M 18 52 C 38 50, 64 38, 96 20" fill="none" stroke="#1E8449" stroke-width="2.6" stroke-linecap="round"/>
|
||||
<path d="M 18 58 C 40 56, 64 42, 96 20" fill="none" stroke="#50a8d9" stroke-width="2.6" stroke-linecap="round"/>
|
||||
<path d="M 18 44 C 42 44, 66 34, 96 20" fill="none" stroke="#C0392B" stroke-width="2.6" stroke-linecap="round"/>
|
||||
<circle cx="96" cy="20" r="6" fill="#1F4E79" stroke="#fff" stroke-width="1.6"/>
|
||||
</svg>`;
|
||||
|
||||
// Render the three cards into the placeholder div. The hidden input stays
|
||||
// intact; the card click handler writes its value back so Node-RED's save
|
||||
// path is unchanged.
|
||||
function init(/* node */) {
|
||||
const placeholder = document.getElementById('mgc-mode-cards');
|
||||
const hidden = document.getElementById('node-input-mode');
|
||||
if (!placeholder || !hidden) return;
|
||||
|
||||
placeholder.innerHTML = MODES.map((m) => `
|
||||
<div class="mgc-mode-card" data-mode="${m.value}" role="radio" tabindex="0" aria-checked="false">
|
||||
<div class="mgc-mode-card" data-mode="${m.value}" role="radio" tabindex="0"
|
||||
aria-label="${m.ariaLabel}" aria-checked="false" title="${m.ariaLabel}">
|
||||
<div class="mgc-mode-card-svg">${m.svg}</div>
|
||||
<div class="mgc-mode-card-label">${m.label}</div>
|
||||
<div class="mgc-mode-card-caption">${m.caption}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
@@ -138,5 +100,40 @@
|
||||
syncHighlight();
|
||||
}
|
||||
|
||||
function initRendezvousToggle(/* node */) {
|
||||
const placeholder = document.getElementById('mgc-rendezvous-toggle');
|
||||
const checkbox = document.getElementById('node-input-useRendezvous');
|
||||
if (!placeholder || !checkbox) return;
|
||||
|
||||
placeholder.innerHTML = `
|
||||
<div class="mgc-toggle-card-svg">${RENDEZVOUS_SVG}</div>
|
||||
<div class="mgc-toggle-card-label">Inactive</div>
|
||||
`;
|
||||
const labelEl = placeholder.querySelector('.mgc-toggle-card-label');
|
||||
|
||||
function syncHighlight() {
|
||||
const on = checkbox.checked;
|
||||
placeholder.classList.toggle('mgc-toggle-card-on', on);
|
||||
placeholder.setAttribute('aria-checked', String(on));
|
||||
if (labelEl) labelEl.textContent = on ? 'Active' : 'Inactive';
|
||||
}
|
||||
function toggle() {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
syncHighlight();
|
||||
}
|
||||
|
||||
placeholder.addEventListener('click', toggle);
|
||||
placeholder.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
checkbox.addEventListener('change', syncHighlight);
|
||||
syncHighlight();
|
||||
}
|
||||
|
||||
editor.modeCards = { init };
|
||||
editor.rendezvousToggle = { init: initRendezvousToggle };
|
||||
})();
|
||||
|
||||
@@ -12,5 +12,11 @@
|
||||
if (ns.editor.modeCards && typeof ns.editor.modeCards.init === 'function') {
|
||||
ns.editor.modeCards.init(node);
|
||||
}
|
||||
if (ns.editor.rendezvousToggle && typeof ns.editor.rendezvousToggle.init === 'function') {
|
||||
ns.editor.rendezvousToggle.init(node);
|
||||
}
|
||||
if (ns.editor.compactFields && typeof ns.editor.compactFields.init === 'function') {
|
||||
ns.editor.compactFields.init(node);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -58,15 +58,48 @@ function getOutput(mgc) {
|
||||
|
||||
// Group capacity + active-machine counts. Surfaced so dashboards can
|
||||
// show the same numbers the status badge does without subscribing to
|
||||
// every child node individually.
|
||||
out.flowCapacityMax = mgc.dynamicTotals?.flow?.max ?? 0;
|
||||
out.flowCapacityMin = mgc.dynamicTotals?.flow?.min ?? 0;
|
||||
// every child node individually. Emitted in the output flow unit (m³/h)
|
||||
// so the dashed capacity envelope lands on the SAME axis as the predicted-
|
||||
// flow series — dynamicTotals is canonical m³/s, so convert here. (Both
|
||||
// telemetry consumers — the Grafana flow panel and the FlowFuse fanout —
|
||||
// assume m³/h; emitting raw m³/s made the capacity lines render as ~0.)
|
||||
const fUnit = unitPolicy.output.flow;
|
||||
const capMax = mgc.dynamicTotals?.flow?.max;
|
||||
const capMin = mgc.dynamicTotals?.flow?.min;
|
||||
out.flowCapacityMax = Number.isFinite(capMax)
|
||||
? unitPolicy.convert(capMax, 'm3/s', fUnit, 'MGC flow capacity max') : 0;
|
||||
out.flowCapacityMin = Number.isFinite(capMin)
|
||||
? unitPolicy.convert(capMin, 'm3/s', fUnit, 'MGC flow capacity min') : 0;
|
||||
|
||||
// Operator demand resolved by the last dispatch. Surfaced so the dashboard
|
||||
// can overlay "what was asked" against the achieved total flow:
|
||||
// - demandFlow: resolved flow setpoint (post-envelope-clamp) in the output
|
||||
// flow unit (m³/h), same scale as the total-flow series.
|
||||
// - demandPct: that setpoint as 0..100 % of the live capacity envelope
|
||||
// (flow.min..flow.max), so a % demand entered by the operator round-trips
|
||||
// regardless of whether they asked in % or absolute flow.
|
||||
// Omitted entirely before the first demand arrives (degraded state).
|
||||
if (mgc._lastDemand) {
|
||||
const clampedCanonical = mgc._lastDemand.clamped;
|
||||
out.demandFlow = unitPolicy.convert(clampedCanonical, 'm3/s', fUnit, 'MGC demand setpoint');
|
||||
const span = Number.isFinite(capMin) && Number.isFinite(capMax) ? capMax - capMin : 0;
|
||||
out.demandPct = span > 0
|
||||
? Math.max(0, Math.min(100, ((clampedCanonical - capMin) / span) * 100))
|
||||
: 0;
|
||||
}
|
||||
|
||||
out.machineCount = Object.keys(mgc.machines || {}).length;
|
||||
out.machineCountActive = Object.values(mgc.machines || {}).filter((m) => {
|
||||
const s = m?.state?.getCurrentState?.();
|
||||
const md = m?.currentMode;
|
||||
return s && s !== 'off' && s !== 'maintenance' && md !== 'maintenance';
|
||||
}).length;
|
||||
|
||||
// Group movement status: 'working' while any child is still ramping /
|
||||
// sequencing toward its dispatched setpoint, 'ready' once all have settled.
|
||||
// The dispatch gate holds non-urgent demand until 'ready'; surfacing it lets
|
||||
// a dashboard show why a fresh setpoint hasn't been applied yet.
|
||||
out.movementState = typeof mgc.getMovementState === 'function' ? mgc.getMovementState() : 'ready';
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,16 @@
|
||||
// (stopping / coolingdown / unknown) are skipped.
|
||||
// 3. Rendezvous time t* = max(eta_i over ALL non-noop moves). The
|
||||
// slowest move (typically a startup ladder + ramp) sets the deadline.
|
||||
// 4. Every command is delayed by (t* − eta_j) so it FINISHES at t*.
|
||||
// Exception: a startup's `execsequence` command must fire NOW so the
|
||||
// ladder can begin — its own duration is what defines eta and thus
|
||||
// t* — but the startup's queued flowmovement (held in the pump's
|
||||
// delayedMove) lands at t* by construction.
|
||||
// 4. Every command — including a startup's `execsequence` — is delayed by
|
||||
// (t* − eta_j) so its move FINISHES at t*. A startup is delayed as a
|
||||
// whole: its ladder begins at (t* − eta) and completes at (t* − rampS),
|
||||
// then the queued flowmovement (held in the pump's delayedMove) ramps to
|
||||
// finish at t*. The slowest mover (t* − eta == 0) fires immediately.
|
||||
// Delaying the ladder — rather than firing it at tick 0 — is what keeps a
|
||||
// faster-than-slowest startup from reaching `operational` early and
|
||||
// sitting at its MINIMUM flow before t* (calcFlow at min position is not
|
||||
// zero), which otherwise leaks ~min-flow into the group total ahead of
|
||||
// the rendezvous (the staging bump).
|
||||
//
|
||||
// Net effect: ALL pumps reach their per-pump flow target at the same
|
||||
// wall-clock instant t*. Sum-of-flows is monotonic during the transition
|
||||
@@ -177,38 +182,31 @@ function plan(profiles, combination, currentPressure, options = {}) {
|
||||
const isUnchanged = q.direction === 'unchanged';
|
||||
|
||||
if (q.action === 'startup') {
|
||||
// execsequence MUST begin NOW — the ladder duration is
|
||||
// baked into eta and can't be compressed.
|
||||
// Just-in-time start. Delay the ENTIRE startup — ladder AND ramp —
|
||||
// by (t* − eta), so the warmup ladder finishes (and the ramp
|
||||
// begins) at (t* − rampS) and the flow lands exactly at t*.
|
||||
//
|
||||
// The ladder duration can't be compressed, but it CAN be delayed.
|
||||
// Firing the execsequence at tick 0 (the old behaviour) made a
|
||||
// faster-than-slowest startup reach `operational` early and sit at
|
||||
// its minimum flow from warmup-end until its delayed ramp — leaking
|
||||
// ~min-flow into the group total before t* (the staging bump). For
|
||||
// the slowest pump (eta == t*) fireAtTickNDelayed is 0, so it still
|
||||
// fires immediately. The flowmovement fires on the same tick; the
|
||||
// pump holds it in delayedMove through the ladder, then ramps over
|
||||
// rampS to finish at t*.
|
||||
commands.push({
|
||||
machineId: q.machineId,
|
||||
action: 'execsequence',
|
||||
sequence: 'startup',
|
||||
fireAtTickN: 0,
|
||||
fireAtTickN: fireAtTickNDelayed,
|
||||
eta: q.eta,
|
||||
});
|
||||
// flowmovement timing.
|
||||
//
|
||||
// Default behaviour: queue it at tick 0; the pump's
|
||||
// delayedMove holds it until warmup completes, after which
|
||||
// the pump ramps at its own velocity. That ramp finishes at
|
||||
// ladderS + rampS = eta. For a single pump (eta == tStar)
|
||||
// this naturally lands at tStar — no extra delay needed.
|
||||
//
|
||||
// Mixed-speed multi-startup: if this pump is FASTER than
|
||||
// the slowest one, its natural landing (at its own eta)
|
||||
// is EARLIER than tStar. Delay the flowmovement so the
|
||||
// ramp starts at (tStar − rampS), making the ramp finish
|
||||
// at tStar regardless of per-pump speed.
|
||||
const naturalRampStartS = q.ladderS;
|
||||
const rendezvousRampStartS = tStar - q.rampS;
|
||||
const flowMoveFireAtS = rendezvousRampStartS > naturalRampStartS
|
||||
? rendezvousRampStartS
|
||||
: 0;
|
||||
commands.push({
|
||||
machineId: q.machineId,
|
||||
action: 'flowmovement',
|
||||
flow: q.targetFlow,
|
||||
fireAtTickN: Math.max(0, Math.round(flowMoveFireAtS / tickS)),
|
||||
fireAtTickN: fireAtTickNDelayed,
|
||||
eta: q.eta,
|
||||
});
|
||||
} else if (q.action === 'flowmove') {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
const { BaseDomain, UnitPolicy, POSITIONS, interpolation, convert } = require('generalFunctions');
|
||||
const { BaseDomain, UnitPolicy, POSITIONS, interpolation } = require('generalFunctions');
|
||||
const GroupOperatingPoint = require('./groupOps/groupOperatingPoint');
|
||||
const groupCurves = require('./groupOps/groupCurves');
|
||||
const TotalsCalculator = require('./totals/totalsCalculator');
|
||||
@@ -27,6 +27,13 @@ const MovementExecutor = require('./movement/movementExecutor');
|
||||
|
||||
const ACTIVE_STATES = new Set(['operational', 'accelerating', 'decelerating']);
|
||||
|
||||
// A machine in one of these states has settled — it is not mid-ramp and is
|
||||
// not stepping through a start/stop sequence. Anything else (starting,
|
||||
// warmingup, accelerating, decelerating, stopping, coolingdown) means the
|
||||
// group is still converging on its last dispatched intent. Drives
|
||||
// getMovementState(): 'ready' when every machine is settled, else 'working'.
|
||||
const SETTLED_STATES = new Set(['operational', 'idle', 'off', 'maintenance', 'emergencystop']);
|
||||
|
||||
// Canonical mode names (camelCase). The dispatcher already lowercases for its
|
||||
// switch, but we normalise at setMode so this.mode is always in the canonical
|
||||
// form — keeps allowedActions/allowedSources lookups (which key on the
|
||||
@@ -63,6 +70,24 @@ class MachineGroup extends BaseDomain {
|
||||
this.mode = _normaliseMode(this.config.mode.current) || 'optimalControl';
|
||||
this.absDistFromPeak = 0;
|
||||
this.relDistFromPeak = 0;
|
||||
// Last operator demand resolved by _runDispatch. `null` until the first
|
||||
// demand arrives so getOutput can omit the demand telemetry (the
|
||||
// degraded / pre-first-demand state) rather than emit a misleading 0.
|
||||
// { canonical: m³/s requested, clamped: m³/s after envelope clamp }.
|
||||
this._lastDemand = null;
|
||||
// Demand held by the movement gate while the group is 'working'. Latest
|
||||
// wins; flushed by _maybeFlushPendingDemand once the group is 'ready'.
|
||||
this._pendingDemand = null;
|
||||
// Intent of the last dispatch that actually proceeded — recorded so a
|
||||
// pressure-emergency re-dispatch can re-plan the SAME intent against
|
||||
// the new envelope without inventing a setpoint.
|
||||
this._lastDispatchedMode = null;
|
||||
this._lastPriorityKey = JSON.stringify(null);
|
||||
this._lastPriorityList = null;
|
||||
// Pressure-emergency latch. Set when handlePressureChange fires a
|
||||
// bypass dispatch; cleared once pressure falls back below threshold,
|
||||
// so the (several-times-a-second) handler doesn't re-fire every tick.
|
||||
this._emergencyLatched = false;
|
||||
this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog: 0 };
|
||||
this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
|
||||
|
||||
@@ -71,7 +96,7 @@ class MachineGroup extends BaseDomain {
|
||||
// call that is later superseded resolves with { superseded: true }.
|
||||
this._demandDispatcher = new DemandDispatcher(
|
||||
{ logger: this.logger },
|
||||
(payload) => this._runDispatch(payload.source, payload.demand, payload.powerCap, payload.priorityList),
|
||||
(payload) => this._runDispatch(payload.source, payload.demand, payload.powerCap, payload.priorityList, { emergency: payload.emergency === true }),
|
||||
);
|
||||
this._shutdownInFlight = new Set();
|
||||
|
||||
@@ -213,6 +238,97 @@ class MachineGroup extends BaseDomain {
|
||||
const eff = this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue() ?? null;
|
||||
this.calcDistanceBEP(eff, maxEfficiency, lowestEfficiency);
|
||||
this.notifyOutputChanged();
|
||||
// Emergency bypass: a pressure excursion pre-empts the rendezvous lock
|
||||
// and re-plans the last intent against the new envelope immediately.
|
||||
// Inert until planner.emergencyPressurePa is configured (see
|
||||
// _pressureEmergency). Latched so we fire once per excursion, not every
|
||||
// tick; the latch clears when pressure falls back below threshold.
|
||||
if (this._pressureEmergency()) {
|
||||
if (!this._emergencyLatched && Number.isFinite(this._lastDemand?.canonical)) {
|
||||
this._emergencyLatched = true;
|
||||
this.logger.warn(`Pressure emergency — pre-empting rendezvous, re-planning last demand ${this._lastDemand.canonical.toFixed(3)}.`);
|
||||
Promise.resolve(this._demandDispatcher.fireAndWait({
|
||||
source: 'pressure-emergency',
|
||||
demand: this._lastDemand.canonical,
|
||||
powerCap: Infinity,
|
||||
priorityList: this._lastPriorityList,
|
||||
emergency: true,
|
||||
})).catch((e) => this.logger?.error?.(`emergency dispatch failed: ${e?.message || e}`));
|
||||
}
|
||||
} else {
|
||||
this._emergencyLatched = false;
|
||||
}
|
||||
// Group may have just settled — release any demand the lock is holding.
|
||||
this._maybeFlushPendingDemand();
|
||||
}
|
||||
|
||||
// Aggregate movement status of the group:
|
||||
// 'working' — at least one machine is mid-ramp, has a queued setpoint
|
||||
// (delayedMove), still has move time left, OR the executor
|
||||
// has scheduled commands that haven't fired yet.
|
||||
// 'ready' — every machine has settled; a fresh demand can be dispatched
|
||||
// cleanly without interrupting an in-flight move.
|
||||
// Surfaced as telemetry (out.movementState) and used by the dispatch gate
|
||||
// to hold non-urgent demand until the group is ready, instead of aborting
|
||||
// ramps on every incoming demand (which froze pumps at 0 — connected
|
||||
// devices must never be able to do that). Urgent demand still pre-empts.
|
||||
getMovementState() {
|
||||
const machines = Object.values(this.machines);
|
||||
if (machines.length === 0) return 'ready';
|
||||
if (typeof this.movementExecutor?.pending === 'function' && this.movementExecutor.pending() > 0) {
|
||||
return 'working';
|
||||
}
|
||||
for (const m of machines) {
|
||||
const st = m?.state?.getCurrentState?.();
|
||||
if (st && !SETTLED_STATES.has(st)) return 'working';
|
||||
if (m?.state?.delayedMove != null) return 'working';
|
||||
if ((m?.state?.getMoveTimeLeft?.() ?? 0) > 0) return 'working';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
// May this demand pre-empt an in-flight rendezvous? Only an EMERGENCY may —
|
||||
// a committed rendezvous is otherwise locked, and ordinary new setpoints
|
||||
// (any size, mode/priority changes included) are deferred and dispatched
|
||||
// sequentially once the group is 'ready' (_maybeFlushPendingDemand). This
|
||||
// is what stops a re-plan from re-deferring a pump that's mid-sequence
|
||||
// (which parked starting pumps at minimum flow → the staging bump).
|
||||
// • a stop (≤0) is always an emergency — never make the operator wait;
|
||||
// • the first demand (no prior intent) must proceed or nothing ever runs;
|
||||
// • a pressure excursion (opts.emergency, raised by handlePressureChange)
|
||||
// pre-empts so rising discharge pressure is actioned immediately.
|
||||
// Everything else returns false → defer.
|
||||
_isEmergencyDemand(demandQ, opts = {}) {
|
||||
if (!(demandQ > 0)) return true;
|
||||
if (this._lastDemand?.canonical == null) return true;
|
||||
return opts.emergency === true;
|
||||
}
|
||||
|
||||
// Pressure-excursion detector for the emergency bypass. Returns true when
|
||||
// the resolved header pressure breaches a configured safety threshold.
|
||||
// INERT BY DEFAULT: with no `planner.emergencyPressurePa` set, this always
|
||||
// returns false — the bypass mechanism is wired and tested but never fires
|
||||
// until a real threshold is configured. (Rate-of-rise can be added here
|
||||
// later behind its own config key without touching the call sites.)
|
||||
_pressureEmergency() {
|
||||
const absPa = Number(this.config?.planner?.emergencyPressurePa);
|
||||
if (!Number.isFinite(absPa) || absPa <= 0) return false;
|
||||
const p = this.operatingPoint?.headerDiffPa;
|
||||
return Number.isFinite(p) && p >= absPa;
|
||||
}
|
||||
|
||||
// Dispatch a demand held by the movement gate, once the group has settled.
|
||||
// Driven off handlePressureChange (fires several times/s), so a held demand
|
||||
// is applied promptly when the last ramp completes. Routed back through the
|
||||
// latest-wins dispatcher so a demand arriving in the same window still wins.
|
||||
_maybeFlushPendingDemand() {
|
||||
if (!this._pendingDemand) return;
|
||||
if (this.getMovementState() !== 'ready') return;
|
||||
const p = this._pendingDemand;
|
||||
this._pendingDemand = null;
|
||||
this.logger.debug(`Group 'ready' — dispatching held demand ${Number(p.demand).toFixed(3)}.`);
|
||||
Promise.resolve(this._demandDispatcher.fireAndWait(p))
|
||||
.catch((e) => this.logger?.error?.(`deferred dispatch failed: ${e?.message || e}`));
|
||||
}
|
||||
|
||||
async abortActiveMovements(reason = 'new demand') {
|
||||
@@ -359,7 +475,40 @@ class MachineGroup extends BaseDomain {
|
||||
return this._demandDispatcher.fireAndWait({ source, demand, powerCap, priorityList });
|
||||
}
|
||||
|
||||
async _runDispatch(source, demand, powerCap, priorityList) {
|
||||
// Operator-style entry point: accepts a (value, unit) pair and resolves
|
||||
// to canonical m³/s before delegating to handleInput. Single source of
|
||||
// truth for the unit math shared by the set.demand command handler and
|
||||
// by parent nodes (e.g. pumpingStation level-based control) that hold a
|
||||
// direct reference to this specificClass and need to push a % demand
|
||||
// without re-implementing the interpolation. Negative value is the
|
||||
// stop-all signal regardless of unit.
|
||||
async setDemand(value, unit = '%') {
|
||||
const v = Number(value);
|
||||
if (!Number.isFinite(v)) {
|
||||
this.logger?.error?.(`setDemand: invalid value '${value}'`);
|
||||
return undefined;
|
||||
}
|
||||
if (v < 0) {
|
||||
await this.turnOffAllMachines();
|
||||
return undefined;
|
||||
}
|
||||
let canonical;
|
||||
if (unit === '%') {
|
||||
const dt = this.calcDynamicTotals();
|
||||
canonical = this.interpolation.interpolate_lin_single_point(
|
||||
v, 0, 100, dt.flow.min, dt.flow.max);
|
||||
} else {
|
||||
try {
|
||||
canonical = this.unitPolicy.convert(v, unit, 'm3/s', 'setDemand absolute flow');
|
||||
} catch (err) {
|
||||
this.logger?.error?.(`setDemand: cannot convert ${v} ${unit} -> m3/s: ${err?.message || err}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return this.handleInput('parent', canonical);
|
||||
}
|
||||
|
||||
async _runDispatch(source, demand, powerCap, priorityList, opts = {}) {
|
||||
const demandQ = parseFloat(demand);
|
||||
if (!Number.isFinite(demandQ)) {
|
||||
this.logger.error(`Invalid flow demand input: ${demand}.`);
|
||||
@@ -369,6 +518,27 @@ class MachineGroup extends BaseDomain {
|
||||
// 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; }
|
||||
|
||||
// Rendezvous lock. While the group is still converging on its committed
|
||||
// plan ('working'), an ordinary new setpoint is NOT applied — it is
|
||||
// remembered (latest wins) and dispatched sequentially once the group
|
||||
// reports 'ready' (_maybeFlushPendingDemand, off handlePressureChange).
|
||||
// This keeps a re-plan from dropping the in-flight schedule and
|
||||
// re-deferring a pump that's mid-sequence — which parked starting pumps
|
||||
// at minimum flow (the staging bump). Only an EMERGENCY (stop, or a
|
||||
// pressure excursion flagged via opts.emergency) pre-empts.
|
||||
if (this.getMovementState() === 'working' && !this._isEmergencyDemand(demandQ, opts)) {
|
||||
this._pendingDemand = { source, demand: demandQ, powerCap, priorityList };
|
||||
this.logger.debug(`Demand ${demandQ.toFixed(3)} held — rendezvous locked ('working'); will dispatch when 'ready'.`);
|
||||
return;
|
||||
}
|
||||
this._pendingDemand = null;
|
||||
// Record the intent now driving the group, so a pressure-emergency
|
||||
// re-dispatch can re-plan the same intent against the new envelope.
|
||||
this._lastDispatchedMode = this.mode;
|
||||
this._lastPriorityKey = JSON.stringify(priorityList ?? null);
|
||||
this._lastPriorityList = priorityList ?? null;
|
||||
|
||||
await this.abortActiveMovements('new demand received');
|
||||
const dt = this.calcDynamicTotals();
|
||||
// Clamp against the current-pressure envelope.
|
||||
@@ -376,6 +546,12 @@ class MachineGroup extends BaseDomain {
|
||||
if (demandQout < dt.flow.min) demandQout = dt.flow.min;
|
||||
else if (demandQout > dt.flow.max) demandQout = dt.flow.max;
|
||||
|
||||
// Record what the operator asked for (canonical) and the setpoint we
|
||||
// actually drive after the current-pressure envelope clamp. getOutput
|
||||
// turns this into the demand telemetry the dashboard overlays on the
|
||||
// total-flow graph (resolved flow setpoint + % of group capacity).
|
||||
this._lastDemand = { canonical: demandQ, clamped: demandQout };
|
||||
|
||||
// Normalize for the switch — schema enum values use camelCase
|
||||
// (optimalControl, priorityControl) while legacy callers send
|
||||
// lowercase. Accept both rather than silently falling through.
|
||||
@@ -396,6 +572,8 @@ class MachineGroup extends BaseDomain {
|
||||
// Cancel any parked demand — turnOff is latest user intent so a
|
||||
// pending fireAndWait must not re-engage pumps post-shutdown.
|
||||
this._demandDispatcher.cancelPending();
|
||||
// Demand resolved to "stop": reflect 0 setpoint in the telemetry.
|
||||
this._lastDemand = { canonical: 0, clamped: 0 };
|
||||
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
|
||||
if (this._shutdownInFlight.has(id)) return;
|
||||
if (this.isMachineActive(id)) {
|
||||
@@ -413,10 +591,12 @@ class MachineGroup extends BaseDomain {
|
||||
}
|
||||
|
||||
_canonicalToOutputFlow(value) {
|
||||
const from = this.unitPolicy.canonical.flow;
|
||||
const to = this.unitPolicy.output.flow;
|
||||
if (!from || !to || from === to) return value;
|
||||
return convert(value).from(from).to(to);
|
||||
return this.unitPolicy.convert(
|
||||
value,
|
||||
this.unitPolicy.canonical.flow,
|
||||
this.unitPolicy.output.flow,
|
||||
'canonical->output flow',
|
||||
);
|
||||
}
|
||||
|
||||
getOutput() { return io.getOutput(this); }
|
||||
|
||||
@@ -26,10 +26,13 @@ Built by `src/io/output.js :: getOutput(mgc)`. Delta-compressed by
|
||||
| `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 |
|
||||
| `flowCapacityMax` | `mgc.dynamicTotals.flow.max` (totalsCalculator), **converted to `unitPolicy.output.flow` (m³/h)** in output.js:62 | number m³/h ≥ 0; `0` when envelope unresolved (Infinity/NaN) | totalsCalculator.basic, dashboard-fanout (post-setup), demand-telemetry.basic | absent until first equalize; dashboard-fanout (state A); demand-telemetry (Infinity → 0) |
|
||||
| `flowCapacityMin` | `mgc.dynamicTotals.flow.min`, **converted to output flow unit (m³/h)** | number m³/h ≥ 0; `0` when unresolved | totalsCalculator.basic, demand-telemetry.basic | same as above |
|
||||
| `demandFlow` | `mgc._lastDemand.clamped` (set in `_runDispatch`, output.js:62) | number, canonical m³/s clamped to envelope, converted to `unitPolicy.output.flow` | demand-telemetry.basic (populated) | demand-telemetry.basic (absent before first demand); turnOff → 0 |
|
||||
| `demandPct` | derived `(clamped − flow.min)/(flow.max − flow.min)·100` (output.js:62) | number ∈ [0,100], `0` when capacity span ≤ 0 | demand-telemetry.basic (populated) | demand-telemetry.basic (absent before first demand) |
|
||||
| `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) |
|
||||
| `movementState` | `mgc.getMovementState()` (specificClass) — `'working'` while any child is ramping/sequencing or the executor has pending commands, else `'ready'` | string `'working'`\|`'ready'`, never null | movement-gate.basic (working: accelerating/warmingup/delayedMove/moveTimeLeft/executor-pending) | movement-gate.basic (ready: no machines, all settled) |
|
||||
|
||||
### Conditional pressure-header fields (emitted only when equalize resolved a positive ΔP)
|
||||
|
||||
@@ -109,6 +112,53 @@ Documented in `CONTRACT.md`; tested indirectly via `group-bep-cascade.integratio
|
||||
|
||||
---
|
||||
|
||||
## Example flow fan-out — `examples/02-Dashboard.json :: fn_status_split` (outputs: 18)
|
||||
|
||||
Delta-caches Port 0 then fans one msg per dashboard widget. Charts return the
|
||||
whole msg as `null` (drop the output) when their source is missing — never
|
||||
`{ payload: null }`. All ports covered by `test/integration/dashboard-fanout.integration.test.js`.
|
||||
|
||||
| # | Target widget | Topic / payload | Populated | Degraded (missing source) |
|
||||
|---|---|---|---|---|
|
||||
| 0 | ui_txt_mode | string | ✔ State C | ✔ State A → mode string |
|
||||
| 1 | ui_txt_flow | `'… m³/h'` | ✔ | ✔ State A → `—` |
|
||||
| 2 | ui_txt_power | `'… kW'` | ✔ | ✔ → `—` |
|
||||
| 3 | ui_txt_capacity | `'min – max m³/h'` | ✔ State B | ✔ → `—` |
|
||||
| 4 | ui_txt_machines | `'nAct / nTot'` | ✔ | ✔ → `—` |
|
||||
| 5 | ui_txt_bep (rel%) | `'… %'` | ✔ | ✔ null/undefined → `—` |
|
||||
| 6 | ui_txt_eta | `'… %'` | ✔ | ✔ → `—` |
|
||||
| 7 | ui_txt_eta_peak | `'… %'` | ✔ | ✔ → `—` |
|
||||
| 8 | ui_txt_bep_abs | `'…'` (η pts, 3dp) | ✔ | ✔ → `—` |
|
||||
| 9 | ui_txt_ncog | `'… %'` (sum/nAct) | ✔ | ✔ nAct=0/missing → `—` |
|
||||
| 10 | ui_chart_flow | `{topic:'Flow', payload:number}` | ✔ | ✔ → null (drop) |
|
||||
| 11 | ui_chart_flow (capacity) | `{topic:'Capacity', …}` | ✔ | ✔ → null |
|
||||
| 12 | ui_chart_power | `{topic:'Power', …}` | ✔ | ✔ → null |
|
||||
| 13 | ui_chart_bep | `{topic:'BEP rel %', ×100}` | ✔ | ✔ → null |
|
||||
| 14 | ui_chart_eta | `{topic:'η (%)', ×100}` | ✔ | ✔ → null |
|
||||
| 15 | ui_tpl_raw | `[{key,value}]` rows | ✔ | ✔ |
|
||||
| 16 | ui_chart_qh (passthrough) | raw `msg.payload` | ✔ | ✔ |
|
||||
| 17 | ui_chart_mgc_pctcap | `{topic:'% of capacity', payload:flow/capMax×100}` | ✔ State C | ✔ State A → null (drop) |
|
||||
|
||||
## Example flow fan-out — `examples/02-Dashboard.json :: fn_chart_pump_a/b/c` (outputs: 2 each)
|
||||
|
||||
Each per-pump fan-out delta-caches the pump's Port 0 then emits two chart msgs.
|
||||
The ctrl output carries a **-1 OFF sentinel**: when the cached pump `state` is
|
||||
`off` / `idle` / `maintenance` the pump is not running, so it plots `-1` (below
|
||||
the 0–100 band) — a clear OFF rail distinct from a pump genuinely running at 0%.
|
||||
`ui_chart_pumps_ctrl` has `ymin: "-5"` so the sentinel is visible. Charts return
|
||||
the whole msg as `null` (drop the output) when their source is missing — never
|
||||
`{ payload: null }`. All ports covered by
|
||||
`test/integration/per-pump-ctrl-fanout.integration.test.js`.
|
||||
|
||||
| # | Target chart | Topic / payload | Populated | Degraded |
|
||||
|---|---|---|---|---|
|
||||
| 0 | ui_chart_per_pump_flow | `{topic:'Pump A/B/C', payload:flow m³/h}` | ✔ running state | ✔ no `flow.predicted.downstream.*` key → null (drop) |
|
||||
| 1 | ui_chart_pumps_ctrl | `{topic:'Pump A/B/C', payload:ctrl%}`, or `payload:-1` when state ∈ {off,idle,maintenance} | ✔ running → +ctrl; ✔ off/idle/maintenance → -1 | ✔ no state + ctrl missing/NaN/null → null (drop); ✔ ctrl-only delta keeps cached OFF state |
|
||||
|
||||
`fn_chart_total` (outputs: 1) feeds the same flow chart with the group total
|
||||
(`downstream_predicted_flow ?? atEquipment_predicted_flow`); returns `null` when
|
||||
both are absent.
|
||||
|
||||
## Coverage gaps (open items)
|
||||
|
||||
These are known holes flagged during the 2026-05-14 governance review; not yet
|
||||
|
||||
@@ -65,9 +65,25 @@ function makeSource({
|
||||
if (handleInputResult instanceof Error) throw handleInputResult;
|
||||
return handleInputResult;
|
||||
},
|
||||
// Used by set.demand handler when unit is %: needs dt.flow + interpolation.
|
||||
// With min=0, max=100, the linear interpolation is identity so a bare
|
||||
// numeric demand round-trips through handleInput unchanged.
|
||||
// Mirror of the real specificClass.setDemand: resolves unit -> canonical
|
||||
// m³/s and forwards to handleInput. With dt.flow {min:0,max:100} the %
|
||||
// interpolation is identity, so a bare numeric demand round-trips through
|
||||
// handleInput unchanged — keeping the existing assertions stable.
|
||||
setDemand: async (value, unit = '%') => {
|
||||
const v = Number(value);
|
||||
if (!Number.isFinite(v)) return undefined;
|
||||
if (v < 0) { await source.turnOffAllMachines(); return undefined; }
|
||||
let canonical;
|
||||
if (unit === '%') {
|
||||
canonical = source.interpolation.interpolate_lin_single_point(
|
||||
v, 0, 100, dt.flow.min, dt.flow.max);
|
||||
} else {
|
||||
const { convert } = require('generalFunctions');
|
||||
canonical = convert(v).from(unit).to('m3/s');
|
||||
}
|
||||
return source.handleInput('parent', canonical);
|
||||
},
|
||||
// Retained for completeness — the mock setDemand uses these internally.
|
||||
calcDynamicTotals: () => dt,
|
||||
interpolation: {
|
||||
interpolate_lin_single_point: (x, ix, iy, ox, oy) => {
|
||||
|
||||
83
test/basic/demand-telemetry.basic.test.js
Normal file
83
test/basic/demand-telemetry.basic.test.js
Normal file
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { getOutput } = require('../../src/io/output.js');
|
||||
const MachineGroup = require('../../src/specificClass.js');
|
||||
|
||||
// Real declared unit policy so the m³/s → m³/h conversion is the production one.
|
||||
const unitPolicy = MachineGroup.unitPolicy;
|
||||
|
||||
// Minimal MGC stand-in exposing exactly the surface getOutput reads. The
|
||||
// measurement loop is short-circuited with an empty type list so the test
|
||||
// isolates the demand telemetry without needing curves / CoolProp.
|
||||
function mockMgc(overrides = {}) {
|
||||
return {
|
||||
measurements: { getTypes: () => [] },
|
||||
unitPolicy,
|
||||
mode: 'optimalControl',
|
||||
scaling: 'absolute',
|
||||
absDistFromPeak: 0,
|
||||
relDistFromPeak: 0,
|
||||
dynamicTotals: { flow: { min: 0.05, max: 0.25 } }, // m³/s
|
||||
machines: {},
|
||||
operatingPoint: {},
|
||||
_lastDemand: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('demandFlow + demandPct emitted once a demand is resolved', () => {
|
||||
// Demand resolved to 0.15 m³/s inside a 0.05..0.25 envelope → midpoint = 50%.
|
||||
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0.15, clamped: 0.15 } }));
|
||||
|
||||
// m³/s → m³/h is ×3600. 0.15 m³/s = 540 m³/h.
|
||||
assert.equal(out.demandFlow, 540);
|
||||
assert.ok(Math.abs(out.demandPct - 50) < 1e-9, `expected ~50%, got ${out.demandPct}`);
|
||||
});
|
||||
|
||||
test('demandPct reflects the clamped setpoint, not the raw request', () => {
|
||||
// Operator asked for 0.40 m³/s but the envelope caps at 0.25 → 100%.
|
||||
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0.40, clamped: 0.25 } }));
|
||||
assert.equal(out.demandFlow, 900); // 0.25 m³/s = 900 m³/h
|
||||
assert.equal(out.demandPct, 100);
|
||||
});
|
||||
|
||||
test('demandPct is 0 (never NaN) when the capacity span is zero', () => {
|
||||
const out = getOutput(mockMgc({
|
||||
dynamicTotals: { flow: { min: 0.1, max: 0.1 } },
|
||||
_lastDemand: { canonical: 0.1, clamped: 0.1 },
|
||||
}));
|
||||
assert.equal(out.demandPct, 0);
|
||||
assert.ok(Number.isFinite(out.demandFlow));
|
||||
});
|
||||
|
||||
test('turnOff demand (0) emits a zero setpoint, not absent', () => {
|
||||
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0, clamped: 0 } }));
|
||||
assert.equal(out.demandFlow, 0);
|
||||
assert.equal(out.demandPct, 0);
|
||||
});
|
||||
|
||||
test('demand telemetry is absent before the first demand (degraded state)', () => {
|
||||
const out = getOutput(mockMgc({ _lastDemand: null }));
|
||||
assert.ok(!('demandFlow' in out), 'demandFlow must be absent pre-first-demand');
|
||||
assert.ok(!('demandPct' in out), 'demandPct must be absent pre-first-demand');
|
||||
// The always-on capacity fields are still present, converted to the output
|
||||
// flow unit (m³/h): 0.05 m³/s → 180, 0.25 m³/s → 900.
|
||||
assert.equal(out.flowCapacityMin, 180);
|
||||
assert.equal(out.flowCapacityMax, 900);
|
||||
});
|
||||
|
||||
test('flow capacity is emitted in the output unit (m³/h), matching the flow series', () => {
|
||||
const out = getOutput(mockMgc({ dynamicTotals: { flow: { min: 0.1, max: 0.3 } } }));
|
||||
assert.equal(out.flowCapacityMin, 360); // 0.1 m³/s × 3600
|
||||
assert.equal(out.flowCapacityMax, 1080); // 0.3 m³/s × 3600
|
||||
});
|
||||
|
||||
test('flow capacity falls back to 0 when the envelope is unresolved (Infinity)', () => {
|
||||
// Pre-first-equalize: dynamicTotals seeds min=Infinity, max=0.
|
||||
const out = getOutput(mockMgc({ dynamicTotals: { flow: { min: Infinity, max: 0 } } }));
|
||||
assert.equal(out.flowCapacityMin, 0);
|
||||
assert.equal(out.flowCapacityMax, 0);
|
||||
});
|
||||
86
test/basic/movement-gate.basic.test.js
Normal file
86
test/basic/movement-gate.basic.test.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// Unit tests for the MGC movement state + rendezvous-lock helpers
|
||||
// (getMovementState / _isEmergencyDemand / _pressureEmergency). Exercised via
|
||||
// prototype.call with a
|
||||
// minimal fake `this` so no Node-RED runtime or full MachineGroup boot is
|
||||
// needed. See project rule .claude/rules/testing.md (basic = pure logic).
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
|
||||
function machine(state, { delayedMove = null, moveTimeLeft = 0 } = {}) {
|
||||
return { state: { getCurrentState: () => state, delayedMove, getMoveTimeLeft: () => moveTimeLeft } };
|
||||
}
|
||||
function movementStateOf(machines, pending = 0) {
|
||||
return MachineGroup.prototype.getMovementState.call({
|
||||
machines,
|
||||
movementExecutor: { pending: () => pending },
|
||||
});
|
||||
}
|
||||
|
||||
test('movementState: ready when no machines are registered', () => {
|
||||
assert.equal(movementStateOf({}), 'ready');
|
||||
});
|
||||
test('movementState: ready when every machine is settled and nothing is pending', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational'), b: machine('idle') }), 'ready');
|
||||
});
|
||||
test('movementState: working while a machine is mid-ramp', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational'), b: machine('accelerating') }), 'working');
|
||||
});
|
||||
test('movementState: working during a start/stop sequence step', () => {
|
||||
assert.equal(movementStateOf({ a: machine('warmingup') }), 'working');
|
||||
});
|
||||
test('movementState: working when a setpoint is queued (delayedMove)', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational', { delayedMove: 50 }) }), 'working');
|
||||
});
|
||||
test('movementState: working while move time remains', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational', { moveTimeLeft: 1.2 }) }), 'working');
|
||||
});
|
||||
test('movementState: working when the executor still has scheduled commands', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational') }, 2), 'working');
|
||||
});
|
||||
|
||||
// Rendezvous lock: only an EMERGENCY pre-empts an in-flight rendezvous; every
|
||||
// ordinary setpoint (any size, mode/priority change included) defers.
|
||||
function emergency(demandQ, { last = 10, emergency = false } = {}) {
|
||||
return MachineGroup.prototype._isEmergencyDemand.call({
|
||||
_lastDemand: last == null ? null : { canonical: last },
|
||||
}, demandQ, { emergency });
|
||||
}
|
||||
|
||||
test('emergency: a stop (≤0) always pre-empts', () => {
|
||||
assert.equal(emergency(0), true);
|
||||
assert.equal(emergency(-5), true);
|
||||
});
|
||||
test('emergency: the first demand (no prior) dispatches immediately', () => {
|
||||
assert.equal(emergency(50, { last: null }), true);
|
||||
});
|
||||
test('emergency: an explicit emergency flag pre-empts', () => {
|
||||
assert.equal(emergency(60, { last: 10, emergency: true }), true);
|
||||
});
|
||||
test('emergency: an ordinary same-mode step defers (large or small)', () => {
|
||||
assert.equal(emergency(12, { last: 10 }), false); // small nudge — defer
|
||||
assert.equal(emergency(60, { last: 10 }), false); // large step — also defers now
|
||||
});
|
||||
|
||||
// Pressure-excursion detector — inert until planner.emergencyPressurePa is set.
|
||||
function pressureEmergency({ thr, headerPa } = {}) {
|
||||
return MachineGroup.prototype._pressureEmergency.call({
|
||||
config: { planner: thr == null ? {} : { emergencyPressurePa: thr } },
|
||||
operatingPoint: { headerDiffPa: headerPa },
|
||||
});
|
||||
}
|
||||
|
||||
test('pressureEmergency: inert (false) when no threshold is configured', () => {
|
||||
assert.equal(pressureEmergency({ headerPa: 999999 }), false);
|
||||
});
|
||||
test('pressureEmergency: false when header is below the configured threshold', () => {
|
||||
assert.equal(pressureEmergency({ thr: 200000, headerPa: 150000 }), false);
|
||||
});
|
||||
test('pressureEmergency: true when header breaches the configured threshold', () => {
|
||||
assert.equal(pressureEmergency({ thr: 200000, headerPa: 210000 }), true);
|
||||
});
|
||||
test('pressureEmergency: false when header pressure is unknown', () => {
|
||||
assert.equal(pressureEmergency({ thr: 200000, headerPa: undefined }), false);
|
||||
});
|
||||
@@ -242,34 +242,29 @@ test('plan: mixed-speed multi-startup — fast pumps wait so all land at tStar t
|
||||
// tStar = max(eta_A, eta_B, eta_C) = 130 s.
|
||||
assert.ok(Math.abs(out.tStarS - 130) < 0.01, `tStar should be 130; got ${out.tStarS}`);
|
||||
|
||||
// execsequence fires at 0 for ALL idle pumps (the ladder must start now).
|
||||
// Just-in-time: the WHOLE startup (ladder + ramp) is delayed by (tStar −
|
||||
// eta), so both execsequence and flowmovement fire at the same delayed
|
||||
// tick. eta_A = 30 + 33.33 ≈ 63.33, eta_B = 40, eta_C = 130.
|
||||
// A: round(130 − 63.33) = 67
|
||||
// B: round(130 − 40) = 90
|
||||
// C: round(130 − 130) = 0 (slowest — defines tStar, fires now)
|
||||
const delays = { A: Math.round(130 - (30 + 100 / 3)), B: 90, C: 0 };
|
||||
for (const id of ['A', 'B', 'C']) {
|
||||
const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence');
|
||||
const flow = out.commands.find((c) => c.machineId === id && c.action === 'flowmovement');
|
||||
assert.ok(exec, `${id} execsequence present`);
|
||||
assert.equal(exec.fireAtTickN, 0, `${id} execsequence fires immediately`);
|
||||
assert.ok(flow, `${id} flowmovement present`);
|
||||
assert.equal(exec.fireAtTickN, delays[id], `${id} ladder delayed to land at tStar`);
|
||||
assert.equal(flow.fireAtTickN, delays[id], `${id} flowmovement fires with the ladder`);
|
||||
}
|
||||
|
||||
// flowmovement gating — each pump's ramp must FINISH at tStar=130.
|
||||
const flowA = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement');
|
||||
const flowB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
|
||||
const flowC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement');
|
||||
|
||||
// A (medium): rampStart = 130 − 33.33 ≈ 96.67 → fireAtTickN = 97.
|
||||
assert.equal(flowA.fireAtTickN, Math.round(130 - 100 / 3));
|
||||
// B (fast): rampStart = 130 − 10 = 120 → fireAtTickN = 120.
|
||||
assert.equal(flowB.fireAtTickN, 120);
|
||||
// C (slow, defines tStar): rendezvousRampStart = 130 − 100 = 30 == ladderS,
|
||||
// so no extra delay needed — fall back to fireAtTickN=0 and let
|
||||
// the pump's delayedMove fire it naturally at warmup-end.
|
||||
assert.equal(flowC.fireAtTickN, 0);
|
||||
|
||||
// Sanity: with these schedules, all three pumps' ramps end at the
|
||||
// same wall-clock instant (within rounding).
|
||||
// A: 97 + 100/3 ≈ 130.33
|
||||
// B: 120 + 10 = 130
|
||||
// C: 30 (delayedMove) + 100 = 130
|
||||
// Max spread ≈ 0.33 s — far better than the per-eta spread of
|
||||
// 130 − 40 = 90 s the planner would produce without this gating.
|
||||
// Sanity: with the ladder delayed, each pump reaches `operational` only at
|
||||
// (delay + ladderS) and its ramp ends at the same wall-clock instant ≈ 130.
|
||||
// A: 67 + 30 (op) + 33.33 ≈ 130.33
|
||||
// B: 90 + 30 (op) + 10 = 130
|
||||
// C: 0 + 30 (op) + 100 = 130
|
||||
// No pump sits at `operational` (and minimum flow) before its ramp — that
|
||||
// early min-flow was the staging bump this just-in-time start removes.
|
||||
});
|
||||
|
||||
test('plan: zero-velocity machine is demoted (infinite eta) but does not crash', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ 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/basic.flow.json'), 'utf8'));
|
||||
const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8'));
|
||||
|
||||
test('basic example includes node type machineGroupControl', () => {
|
||||
const count = flow.filter((n) => n && n.type === 'machineGroupControl').length;
|
||||
|
||||
@@ -48,13 +48,26 @@ async function buildGroupWithPressure() {
|
||||
return { mgc, pumps };
|
||||
}
|
||||
|
||||
// Settle to 'ready' between demands. The rendezvous lock defers a new setpoint
|
||||
// that arrives while the group is still 'working', so each sweep step must wait
|
||||
// for the previous move to land before issuing (and reading) the next.
|
||||
async function waitReady(mgc, timeoutMs = 6000) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (mgc.getMovementState?.() === 'ready') return true;
|
||||
try { await mgc.movementExecutor?.tick?.(); } catch { /* ignore */ }
|
||||
await new Promise(r => setTimeout(r, 40));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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));
|
||||
await waitReady(mgc);
|
||||
const out = getOutput(mgc);
|
||||
rows.push({
|
||||
demand: Qd_m3h,
|
||||
|
||||
@@ -22,7 +22,7 @@ function runFn(msgs) {
|
||||
return msgs.map(msg => fn_body(msg, context));
|
||||
}
|
||||
|
||||
// Indices into the 17-output return array. Kept here as the manifest contract
|
||||
// Indices into the 18-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,
|
||||
@@ -31,6 +31,7 @@ const PORT = {
|
||||
chart_flow: 10, chart_capacity: 11, chart_power: 12, chart_bep_rel: 13,
|
||||
chart_eta: 14,
|
||||
raw_rows: 15, raw_passthrough: 16,
|
||||
chart_pctcap: 17,
|
||||
};
|
||||
|
||||
const initialMsg = {
|
||||
@@ -64,9 +65,9 @@ const postDemandMsg = {
|
||||
},
|
||||
};
|
||||
|
||||
test('manifest: function has exactly 17 outputs and wires array matches', () => {
|
||||
assert.equal(fn.outputs, 17);
|
||||
assert.equal(fn.wires.length, 17);
|
||||
test('manifest: function has exactly 18 outputs and wires array matches', () => {
|
||||
assert.equal(fn.outputs, 18);
|
||||
assert.equal(fn.wires.length, 18);
|
||||
});
|
||||
|
||||
test('State A (deploy-time): no AT_EQUIPMENT keys → flow/power text show em-dash', () => {
|
||||
@@ -113,6 +114,16 @@ test('State C (post-demand): every text/chart output has real value', () => {
|
||||
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);
|
||||
// % of capacity = flow / flowCapacityMax × 100 = 200 / 450 × 100 ≈ 44.44.
|
||||
assert.equal(out[PORT.chart_pctcap].topic, '% of capacity');
|
||||
assert.ok(Math.abs(out[PORT.chart_pctcap].payload - (200 / 450) * 100) < 1e-6);
|
||||
});
|
||||
|
||||
test('% of capacity chart: drops msg when flow or capacity missing (no payload:null)', () => {
|
||||
// State A: no flow + flowCapacityMax=0 → pctCap undefined → chart() returns
|
||||
// null so the function node skips the output, never { payload: null }.
|
||||
const [out] = runFn([initialMsg]);
|
||||
assert.equal(out[PORT.chart_pctcap], null, 'chart_pctcap must drop msg when source missing');
|
||||
});
|
||||
|
||||
test('NCog formatter: SUM is normalized by machineCountActive before display', () => {
|
||||
|
||||
@@ -27,6 +27,19 @@ const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/h
|
||||
|
||||
/* ---- helpers ---- */
|
||||
|
||||
// Settle the group to 'ready'. The rendezvous lock defers a setpoint arriving
|
||||
// while the group is still 'working', so a full-MGC test must wait for each
|
||||
// move to land before reading steady state or issuing the next demand.
|
||||
async function waitReady(mgc, timeoutMs = 6000) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (mgc.getMovementState?.() === 'ready') return true;
|
||||
try { await mgc.movementExecutor?.tick?.(); } catch { /* ignore */ }
|
||||
await new Promise(r => setTimeout(r, 40));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
||||
|
||||
function distortSeries(series, scale = 1, tilt = 0) {
|
||||
@@ -414,6 +427,7 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
|
||||
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
|
||||
}
|
||||
await mg.handleInput('parent', pctCanonical(mg, 50), Infinity);
|
||||
await waitReady(mg); // rendezvous lock — let the move land before reading steady state
|
||||
const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
|
||||
@@ -422,10 +436,12 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
|
||||
await m.handleInput('parent', 'execSequence', 'shutdown');
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
}
|
||||
await waitReady(mg); // ensure the group is settled so the next demand isn't deferred
|
||||
|
||||
// Run priorityControl
|
||||
mg.setMode('prioritycontrol');
|
||||
await mg.handleInput('parent', pctCanonical(mg, 50), Infinity, ['eff', 'std', 'weak']);
|
||||
await waitReady(mg);
|
||||
const prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
|
||||
|
||||
117
test/integration/per-pump-ctrl-fanout.integration.test.js
Normal file
117
test/integration/per-pump-ctrl-fanout.integration.test.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// Output-coverage tests for examples/02-Dashboard.json :: fn_chart_pump_a/b/c.
|
||||
// These per-pump fan-out functions feed two charts:
|
||||
// output 0 → ui_chart_per_pump_flow (topic = 'Pump A/B/C', payload = flow m³/h)
|
||||
// output 1 → ui_chart_pumps_ctrl (topic = 'Pump A/B/C', payload = ctrl %)
|
||||
// The ctrl output carries a -1 OFF sentinel: when the pump is off / idle /
|
||||
// maintenance it is not running, so we plot -1 (below the 0–100 band) to give
|
||||
// the chart a clear OFF rail distinct from a pump genuinely running at 0%.
|
||||
// Every output is exercised in populated AND degraded states 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 PUMPS = [
|
||||
{ id: 'fn_chart_pump_a', topic: 'Pump A' },
|
||||
{ id: 'fn_chart_pump_b', topic: 'Pump B' },
|
||||
{ id: 'fn_chart_pump_c', topic: 'Pump C' },
|
||||
];
|
||||
|
||||
const FLOW = 0; // output index → ui_chart_per_pump_flow
|
||||
const CTRL = 1; // output index → ui_chart_pumps_ctrl
|
||||
|
||||
// Each fan-out caches Port 0 deltas in context('c'). Build a fresh runner per
|
||||
// test so state never leaks between cases.
|
||||
function makeRunner(node) {
|
||||
let store = {};
|
||||
const context = { get: (k) => store[k], set: (k, v) => { store[k] = v; } };
|
||||
const body = new Function('msg', 'context', node.func);
|
||||
return (payload) => body({ payload }, context);
|
||||
}
|
||||
|
||||
// A populated downstream-flow key uses the 4-segment MeasurementContainer
|
||||
// convention the function matches with find('flow.predicted.downstream.').
|
||||
const flowKey = (id) => `flow.predicted.downstream.${id}`;
|
||||
|
||||
test('every per-pump fan-out has exactly 2 outputs wired to flow + ctrl charts', () => {
|
||||
for (const { id } of PUMPS) {
|
||||
const node = flow.find(n => n.id === id);
|
||||
assert.ok(node, `${id} present in flow`);
|
||||
assert.equal(node.outputs, 2, `${id} outputs`);
|
||||
assert.equal(node.wires.length, 2, `${id} wires`);
|
||||
assert.deepEqual(node.wires[FLOW], ['ui_chart_per_pump_flow'], `${id} flow wire`);
|
||||
assert.deepEqual(node.wires[CTRL], ['ui_chart_pumps_ctrl'], `${id} ctrl wire`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ui_chart_pumps_ctrl ymin is -5 so the OFF sentinel (-1) is visible', () => {
|
||||
const chart = flow.find(n => n.id === 'ui_chart_pumps_ctrl');
|
||||
assert.ok(chart, 'ui_chart_pumps_ctrl present');
|
||||
assert.equal(chart.ymin, '-5');
|
||||
assert.equal(chart.ymax, '100');
|
||||
});
|
||||
|
||||
for (const { id, topic } of PUMPS) {
|
||||
test(`${id}: populated running state → flow + ctrl carry real numbers`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
const out = run({ [flowKey(id)]: 478 / 3, ctrl: 72, state: 'operational' });
|
||||
assert.deepEqual(out[FLOW], { topic, payload: 478 / 3 });
|
||||
assert.deepEqual(out[CTRL], { topic, payload: 72 });
|
||||
});
|
||||
|
||||
for (const offState of ['off', 'idle', 'maintenance']) {
|
||||
test(`${id}: state '${offState}' → ctrl emits -1 sentinel (even if ctrl% is 0/stale)`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
// ctrl stale at 0 (or any residual) must be overridden by the sentinel.
|
||||
const out = run({ [flowKey(id)]: 0, ctrl: 0, state: offState });
|
||||
assert.deepEqual(out[CTRL], { topic, payload: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
test(`${id}: degraded — no state, ctrl missing → ctrl output is null (drop, never payload:null)`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
const out = run({ [flowKey(id)]: 50 });
|
||||
assert.equal(out[CTRL], null, 'ctrl must drop when no state and no ctrl');
|
||||
// flow still present.
|
||||
assert.deepEqual(out[FLOW], { topic, payload: 50 });
|
||||
});
|
||||
|
||||
test(`${id}: degraded — no flow key → flow output is null (drop)`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
const out = run({ ctrl: 40, state: 'operational' });
|
||||
assert.equal(out[FLOW], null, 'flow must drop when source key missing');
|
||||
assert.deepEqual(out[CTRL], { topic, payload: 40 });
|
||||
});
|
||||
|
||||
test(`${id}: pre-first-tick — empty payload → both outputs null, no payload:null`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
const out = run({});
|
||||
assert.equal(out[FLOW], null);
|
||||
assert.equal(out[CTRL], null);
|
||||
for (const m of out) {
|
||||
if (m && Object.prototype.hasOwnProperty.call(m, 'payload')) {
|
||||
assert.notEqual(m.payload, null, `${id} emitted { payload: null }`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`${id}: running ctrl with NaN/null ctrl value → ctrl drops (no payload:null)`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
assert.equal(run({ [flowKey(id)]: 10, ctrl: null, state: 'operational' })[CTRL], null);
|
||||
assert.equal(run({ [flowKey(id)]: 10, ctrl: NaN, state: 'operational' })[CTRL], null);
|
||||
});
|
||||
|
||||
test(`${id}: delta-cache holds last state so a ctrl-only delta still rails OFF`, () => {
|
||||
// Realistic: pump first reports state:'off', then a later tick carries only
|
||||
// a ctrl delta (no state). The cached 'off' must keep the sentinel engaged.
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
run({ state: 'off', ctrl: 0 });
|
||||
const out = run({ ctrl: 5 }); // ctrl-only delta; cached state still 'off'
|
||||
assert.deepEqual(out[CTRL], { topic, payload: -1 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user