Compare commits

...

9 Commits

Author SHA1 Message Date
znetsixe
f18f3cc673 feat(mgc-dashboard): -1 OFF sentinel on per-pump % control chart
fn_chart_pump_a/b/c now emit -1 on the ctrl output when the cached pump
state is off/idle/maintenance, instead of the residual ctrl% (which would
sit at 0 and be indistinguishable from a pump genuinely running at 0%).
ui_chart_pumps_ctrl ymin set to -5 so the OFF rail is visible below the
0-100 band.

Adds test/integration/per-pump-ctrl-fanout.integration.test.js covering
both chart outputs of all three pumps in populated (running), OFF
(off/idle/maintenance), and degraded (missing state/ctrl/flow, pre-tick,
NaN, ctrl-only delta) states per .claude/rules/output-coverage.md. Updates
test/_output-manifest.md to document the previously-undocumented per-pump
fan-out functions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:16:50 +02:00
znetsixe
2af6c904da feat(mgc): rendezvous lock + emergency bypass (no re-plan mid-rendezvous)
Once a rendezvous plan is committed it now runs to completion untouched: an
ordinary new setpoint arriving while the group is 'working' is remembered
(latest wins) and dispatched sequentially when the group reaches 'ready',
instead of aborting + re-planning. A re-plan mid-flight dropped the in-flight
schedule and re-deferred a pump that was mid-sequence, parking starting pumps
at minimum flow.

Only an EMERGENCY pre-empts the lock: a stop (≤0) or a pressure excursion.
_isUrgentDemand (which pre-empted on any large step) is replaced by
_isEmergencyDemand; the large-step pre-emption is gone — large operator steps
now defer like any other setpoint. _pressureEmergency() reads
planner.emergencyPressurePa and is INERT until that threshold is configured;
handlePressureChange fires a latched bypass dispatch when it breaches.

Verified live on the E2E Isolated MGC rig: a 1→2 pump staging transition ramps
the added pump straight through (no wait-at-minimum, no start-then-stop) and the
group total climbs monotonically. (The Pump-tab node's hunting is a separate
demand-feedback-loop issue in that flow's wiring, not the rendezvous.)

Integration tests now settle to 'ready' between demands (waitReady) since the
lock defers setpoints arriving mid-move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:47:50 +02:00
znetsixe
f41e319b30 test(mgc): cover fn_status_split output 17 (% of capacity); fix stale 17→18 count
The dashboard fan-out grew to 18 outputs (output 17 = '% of capacity' chart)
but dashboard-fanout.integration.test.js still asserted 17 and had no PORT
entry or coverage for output 17. Add chart_pctcap (17) with populated (State C,
flow/capMax×100) and degraded (State A → null-drop) assertions, fix the count
assertion, and add the fan-out enumeration table to _output-manifest.md per
.claude/rules/output-coverage.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:24:22 +02:00
znetsixe
551ee6d70e fix(mgc): just-in-time startup in rendezvous planner (kill staging flow bump)
Delay a startup's execsequence by (t* − eta) instead of firing it at tick 0.
Previously the ladder fired immediately for every starting pump; a
faster-than-slowest startup then reached `operational` early and sat at its
minimum flow (calcFlow at min position is non-zero) from warmup-end until its
delayed ramp — leaking ~one pump's minimum flow into the group total before
the rendezvous instant t* (the 207→309 staging bump observed live).

Now the whole startup (ladder + ramp) is delayed: the ladder begins at
(t* − eta), completes at (t* − rampS), then the queued flowmovement ramps to
finish exactly at t*. The slowest pump (eta == t*) still fires at tick 0.
Sum-of-flows is monotonic through the transition.

Updated movementScheduler.basic.test.js mixed-speed multi-startup assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:22:32 +02:00
znetsixe
b59d8e60f7 feat(mgc): demand telemetry + movement gate (demand debounce)
- Movement gate: hold non-urgent demand while the group is 'working'
  (mid-ramp/sequencing) and flush it once 'ready', instead of aborting
  in-flight ramps on every incoming demand — which could freeze pumps at 0.
  Urgent demand (stop, mode/priority change, large step) still pre-empts.
- getMovementState()/_isUrgentDemand()/_maybeFlushPendingDemand() helpers.
- Demand telemetry: emit demandFlow (m³/h) and demandPct (0..100 of envelope)
  resolved by the last dispatch; omitted before the first demand (degraded).
- Capacity envelope now emitted in output flow unit (m³/h) not raw m³/s.
- Manifest + populated/degraded tests for the new outputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:09:18 +02:00
e1e1977139 wip: pre-ship-it state — example dashboard tweaks 2026-05-26 17:31:43 +02:00
znetsixe
ddf2b07424 test: point structure check at renamed 02-Dashboard.json
Example flows were renamed to the numbered-tier convention
(01-Basic.json / 02-Dashboard.json). The structure test still pointed
at the old basic.flow.json path. Rewire to the current filename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:29:49 +02:00
znetsixe
c982c9bef7 refactor(units): route _canonicalToOutputFlow + setDemand through UnitPolicy.convert
Drop the direct convert() import — both call sites now go through
this.unitPolicy.convert. setDemand keeps its try/catch around the
absolute-flow branch (legitimate Bucket-2 case: % vs flow demux
prevents declaring `units:` on the dispatcher). Matches the
contract direction in .claude/refactor/CONTRACTS.md §6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:43:44 +02:00
znetsixe
a47aa53d17 style + ui(editor): palette swatch #B5651D + compact-fields tweak
mgc.html: sidebar swatch → #B5651D (mid-orange, rotating-machinery family) as part of the EVOLV palette redesign 2026-05-21. See superproject .claude/rules/node-red-flow-layout.md §10.0 and .claude/refactor/OPEN_QUESTIONS.md.

src/editor/compact-fields.js: minor field tweak (separate, in-progress work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:06:35 +02:00
15 changed files with 777 additions and 82 deletions

View File

@@ -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": [
[]
]
}
]
]

View File

@@ -55,7 +55,7 @@
<script>
RED.nodes.registerType('machineGroupControl',{
category: "EVOLV",
color: "#50a8d9",
color: "#B5651D",
defaults: {
// Define default properties
name: { value: "" },
@@ -176,6 +176,7 @@
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<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>

View File

@@ -58,6 +58,7 @@
json: SVG.json,
csv: SVG.csv,
influxdb: SVG.influxdb,
frost: SVG.influxdb,
};
const outputLabels = {
@@ -65,6 +66,7 @@
json: 'JSON',
csv: 'CSV',
influxdb: 'Influx',
frost: 'FROST',
};
function initOutputFormats() {

View File

@@ -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;
}

View File

@@ -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') {

View File

@@ -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') {
@@ -383,7 +499,7 @@ class MachineGroup extends BaseDomain {
v, 0, 100, dt.flow.min, dt.flow.max);
} else {
try {
canonical = convert(v).from(unit).to('m3/s');
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;
@@ -392,7 +508,7 @@ class MachineGroup extends BaseDomain {
return this.handleInput('parent', canonical);
}
async _runDispatch(source, demand, powerCap, priorityList) {
async _runDispatch(source, demand, powerCap, priorityList, opts = {}) {
const demandQ = parseFloat(demand);
if (!Number.isFinite(demandQ)) {
this.logger.error(`Invalid flow demand input: ${demand}.`);
@@ -402,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.
@@ -409,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.
@@ -429,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)) {
@@ -446,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); }

View File

@@ -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 0100 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

View 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);
});

View 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);
});

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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;

View 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 0100 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 });
});
}