Compare commits

...

1 Commits

Author SHA1 Message Date
znetsixe
472402c62d feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.

Architecture (src/movement/):
- machineProfile.js   – pure snapshot of a registered child (state,
                        position, velocityPctPerS, ladder timings,
                        flowAt / positionForFlow). Reads timings from
                        child.state.config.time (the actual storage
                        location — previous fallback paths silently
                        produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js   – seconds-to-target per machine; handles
                        idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
                        command is delayed so its move finishes at t*.
                        Startup execsequence fires at 0; its flowmovement
                        is gated by max(ladderS, t* − rampS) so a fast
                        pump waits before ramping rather than landing
                        early. useRendezvous=false collapses to all
                        fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
                        every command whose fireAtTickN ≤ floor(elapsed/tickS).
                        tick() no longer awaits pending fireCommand
                        promises — the synchronous prologue of
                        handleInput claims the latest-wins gate, which
                        is what race-favouring relies on.

Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
  _optimalControl. Builds profiles, calls movementScheduler.plan,
  replans the executor, ticks once. Reads
  config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
  flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
  Same-time landing now applies in BOTH modes.

Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
  config.planner.useRendezvous. Default ON.

Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
  diagnostic — drives a 3-pump mixed-state dispatch and asserts both
  convergence to the demand setpoint AND same-time landing within
  one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
  assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
  multi-startup case proving the fast pumps wait so all three land
  together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.

Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
  helper; every command now checks isValidActionForMode +
  isValidSourceForMode before dispatching. Status-level commands
  (set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
  Contracts,Examples,Limitations}.md split with _Sidebar.md index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:55 +02:00
26 changed files with 3048 additions and 280 deletions

View File

@@ -11,6 +11,30 @@
<script src="/machineGroupControl/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
<script src="/machineGroupControl/configData.js"></script> <!-- Load the config script for node information -->
<!-- Editor JS modules — see nodes/machineGroupControl/src/editor/. Loaded in
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/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;
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;
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; }
</style>
<script>
RED.nodes.registerType('machineGroupControl',{
category: "EVOLV",
@@ -24,6 +48,12 @@
// Control strategy
mode: { value: "optimalControl" }, // optimalControl | priorityControl | maintenance
// Same-time landing (rendezvous planner). When ON the planner
// delays each pump's move so all pumps reach their setpoint at
// the same wall-clock instant t* = max(eta_i). When OFF each
// pump moves at its own pace and lands at its own eta.
useRendezvous: { value: true },
//define asset properties
uuid: { value: "" },
supplier: { value: "" },
@@ -55,10 +85,17 @@
return (this.positionIcon || "") + " machineGroup";
},
oneditprepare: function() {
// Initialize the menu data for the node
const self = this;
// 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.
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.machineGroupControl?.initEditor) {
window.EVOLV.nodes.machineGroupControl.initEditor(this);
window.EVOLV.nodes.machineGroupControl.initEditor(self);
if (window.EVOLV.nodes.machineGroupControl.editor?.initVisuals) {
window.EVOLV.nodes.machineGroupControl.editor.initVisuals(self);
}
} else {
setTimeout(waitForMenuData, 50);
}
@@ -88,14 +125,15 @@
<script type="text/html" data-template-name="machineGroupControl">
<h3>Control strategy</h3>
<div class="form-row">
<label for="node-input-mode"><i class="fa fa-cogs"></i> Mode</label>
<select id="node-input-mode" style="width:60%;">
<option value="optimalControl">optimalControl &mdash; pick the best valid pump combination by BEP-gravitation / NCog</option>
<option value="priorityControl">priorityControl &mdash; sequential equal-flow control by priority list</option>
<option value="maintenance">maintenance &mdash; monitoring only, no dispatch</option>
</select>
<!-- Hidden input is the canonical Node-RED-readable field. The visible
picker is rendered by src/editor/mode-cards.js into the placeholder
below, and clicks on a card write back to this input. -->
<input type="hidden" id="node-input-mode" />
<div id="mgc-mode-cards" class="mgc-mode-cards"
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
@@ -103,6 +141,23 @@
in absolute terms. Negative value stops all pumps.
</p>
<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>
<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>
<h3>Output Formats</h3>
<div class="form-row">
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>

13
mgc.js
View File

@@ -1,4 +1,5 @@
const nameOfNode = 'machineGroupControl'; // this is the name of the node, it should match the file name and the node type in Node-RED
const path = require('path');
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
const { MenuManager, configManager } = require('generalFunctions');
@@ -36,4 +37,16 @@ module.exports = function(RED) {
res.status(500).send(`// Error generating configData: ${err.message}`);
}
});
// Editor JS modules — loaded by mgc.html via <script src="/machineGroupControl/editor/*.js">.
// Files live in src/editor/. Filename restricted to a safe charset to prevent
// path-traversal. Mirrors pumpingStation.js:44-51.
RED.httpAdmin.get(`/${nameOfNode}/editor/:file`, (req, res) => {
const safe = String(req.params.file || '').replace(/[^a-zA-Z0-9._-]/g, '');
if (!safe.endsWith('.js')) return res.status(400).send('// invalid');
res.type('application/javascript');
res.sendFile(path.join(__dirname, 'src', 'editor', safe), (err) => {
if (err && !res.headersSent) res.status(404).send('// editor module not found');
});
});
};

View File

@@ -16,11 +16,31 @@ function _logger(source, ctx) {
return ctx?.logger || source?.logger || null;
}
// Gate one command against the mode-allowed action and source allow-lists.
// Returns true if both gates pass (or if the source lacks the gate methods —
// keeps backward compat with fakes/specifics that haven't adopted the pattern
// yet). When a gate fails the source already warn-logs; we just bail out.
function _gate(source, action, msg) {
if (typeof source?.isValidActionForMode === 'function') {
if (!source.isValidActionForMode(action, source.mode)) return false;
}
if (typeof source?.isValidSourceForMode === 'function') {
const src = (typeof msg?.source === 'string' && msg.source) ? msg.source : 'parent';
if (!source.isValidSourceForMode(src, source.mode)) return false;
}
return true;
}
exports.setMode = (source, msg) => {
// set.mode is a status-level operation — allowed in every mode by the
// default schema (incl. maintenance). The gate still fires so an
// unauthorised source is rejected even for mode switching.
if (!_gate(source, 'statusCheck', msg)) return;
source.setMode(msg.payload);
};
exports.registerChild = (source, msg, ctx) => {
if (!_gate(source, 'statusCheck', msg)) return;
const log = _logger(source, ctx);
const childId = msg.payload;
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
@@ -58,6 +78,16 @@ exports.setDemand = async (source, msg, ctx) => {
log?.error?.(`set.demand: invalid Qd value '${JSON.stringify(msg?.payload)}'`);
return;
}
// Gate the demand against the current mode. Action kind depends on whether
// this is a stop-all (negative) or a dispatch — the schema declares which
// are accepted per mode (maintenance gets neither). Done after numeric
// parse so an unparseable payload is still surfaced as an error, not a
// silent mode-rejection.
let action;
if (value < 0) action = 'emergencyStop';
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 {

View File

@@ -162,18 +162,15 @@ async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = nu
}
mgc.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(totalCog);
await Promise.all(flowDistribution.map(async ({ machineId, flow }) => {
const machine = mgc.machines[machineId];
const currentState = machine.state.getCurrentState();
if (flow > 0) {
await machine.handleInput('parent', 'flowmovement', mgc._canonicalToOutputFlow(flow));
if (currentState === 'idle') {
await machine.handleInput('parent', 'execsequence', 'startup');
}
} else if (currentState === 'operational' || currentState === 'accelerating' || currentState === 'decelerating') {
await machine.handleInput('parent', 'execsequence', 'shutdown');
}
}));
// Route the chosen distribution through the shared planner/executor
// path. With planner.useRendezvous=true (the default) all pumps
// reach their per-pump flow target at the same wall-clock instant;
// with it false, every command fires at tick 0 — same effect as
// the legacy Promise.all dispatch but with correct startup/shutdown
// ordering (the planner emits execsequence BEFORE flowmovement for
// idle pumps, where the legacy code emitted them in the opposite
// order and relied on the pump's delayedMove queue to recover).
await mgc._dispatchFlowDistribution(flowDistribution);
} catch (err) {
mgc.logger?.error?.(err);
}

34
src/editor/index.js Normal file
View File

@@ -0,0 +1,34 @@
// machineGroupControl editor — namespace bootstrap.
//
// Attaches the editor's submodule registry to the shared
// window.EVOLV.nodes.machineGroupControl namespace (same one the menuManager
// and configManager endpoints populate). Each sibling module in this
// directory (mode-cards.js, demand-contract.js, oneditprepare.js) registers
// itself by writing additional members onto this namespace.
//
// Loaded first by mgc.html — must not depend on any other src/editor module.
(function () {
const root = window.EVOLV = window.EVOLV || {};
const nodes = root.nodes = root.nodes || {};
const ns = nodes.machineGroupControl = nodes.machineGroupControl || {};
const editor = ns.editor = ns.editor || {};
// Pub/sub for mode changes — mode-cards.js fires, anything that wants to
// re-render on mode change subscribes. Keep it tiny; no third-party emitter.
const modeListeners = [];
editor.onModeChange = (cb) => { if (typeof cb === 'function') modeListeners.push(cb); };
editor.emitModeChange = (newMode) => {
for (const cb of modeListeners) {
try { cb(newMode); } catch (e) { /* swallow — UI helper */ }
}
};
// Read the currently selected mode from the hidden input that mode-cards.js
// keeps in sync with the active card. Falls back to optimalControl if the
// input isn't on the page yet (race against oneditprepare).
editor.getMode = () => {
const el = document.getElementById('node-input-mode');
return (el && el.value) || 'optimalControl';
};
})();

142
src/editor/mode-cards.js Normal file
View File

@@ -0,0 +1,142 @@
// mode-cards.js — visual radio picker for the three control-strategy modes.
//
// 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.
//
// 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.
(function () {
const editor = window.EVOLV?.nodes?.machineGroupControl?.editor;
if (!editor) return;
const MODES = [
{
value: 'optimalControl',
label: 'optimalControl',
caption: 'Picks the pump combination whose BEP sits closest to current demand.',
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>`,
},
{
value: 'priorityControl',
label: 'priorityControl',
caption: 'Sequential equal-flow ramp — fill pumps one-by-one in priority order.',
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>`,
},
{
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>`,
},
];
// 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.
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-svg">${m.svg}</div>
<div class="mgc-mode-card-label">${m.label}</div>
<div class="mgc-mode-card-caption">${m.caption}</div>
</div>
`).join('');
const cards = Array.from(placeholder.querySelectorAll('.mgc-mode-card'));
function syncHighlight() {
const current = hidden.value || 'optimalControl';
for (const c of cards) {
const on = c.getAttribute('data-mode') === current;
c.classList.toggle('mgc-mode-card-on', on);
c.setAttribute('aria-checked', String(on));
}
}
function pick(mode) {
hidden.value = mode;
// Fire change so any other listener bound to the input (Node-RED's
// dirty-tracker, plus our pub/sub) sees the update.
hidden.dispatchEvent(new Event('change', { bubbles: true }));
syncHighlight();
editor.emitModeChange(mode);
}
for (const c of cards) {
c.addEventListener('click', () => pick(c.getAttribute('data-mode')));
c.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
pick(c.getAttribute('data-mode'));
}
});
}
syncHighlight();
}
editor.modeCards = { init };
})();

View File

@@ -0,0 +1,16 @@
// oneditprepare.js — initialise the editor's visual modules.
//
// Called from mgc.html's oneditprepare alongside the existing menuManager
// initialiser (logger/position dropdowns). Each module is responsible for
// its own placeholder; we just kick them off in dependency order.
(function () {
const ns = window.EVOLV?.nodes?.machineGroupControl;
if (!ns || !ns.editor) return;
ns.editor.initVisuals = function (node) {
if (ns.editor.modeCards && typeof ns.editor.modeCards.init === 'function') {
ns.editor.modeCards.init(node);
}
};
})();

View File

@@ -0,0 +1,90 @@
'use strict';
// Builds a plain-object snapshot of a registered child machine for the
// movement planner. Pure read — no contract changes to the parent/child
// registration handshake, no mutation of the child.
function buildProfile(child) {
if (!child) throw new TypeError('buildProfile: child is required');
const id = child?.config?.general?.id ?? null;
const state = typeof child.state?.getCurrentState === 'function'
? child.state.getCurrentState()
: null;
const position = typeof child.state?.getCurrentPosition === 'function'
? child.state.getCurrentPosition()
: null;
const mm = child.state?.movementManager;
const minPosition = Number(mm?.minPosition);
const maxPosition = Number(mm?.maxPosition);
const velocityPctPerS = (() => {
if (typeof mm?.getNormalizedSpeed === 'function' && Number.isFinite(maxPosition) && Number.isFinite(minPosition)) {
return mm.getNormalizedSpeed() * (maxPosition - minPosition);
}
const s = Number(mm?.speed);
return Number.isFinite(s) ? s : 0;
})();
// Source of truth for ladder durations is the child state's config.time
// (state.js stores the merged stateConfig there). Older fallbacks
// (child.config.stateConfig, child.stateConfig) are kept for callers
// that pre-populate them, but rotatingMachine doesn't — it stores
// timings under state.config.time. Reading the wrong path is silent:
// every duration defaults to 0, the planner thinks startup is
// instantaneous, tStar collapses to the ramp time, and same-time
// landing breaks.
const t = child.state?.config?.time
?? child.config?.stateConfig?.time
?? child.stateConfig?.time
?? {};
const timings = {
startingS: Number(t.starting) || 0,
warmingupS: Number(t.warmingup) || 0,
stoppingS: Number(t.stopping) || 0,
coolingdownS: Number(t.coolingdown) || 0,
};
const remainingTransitionS = typeof child.state?.stateManager?.getRemainingTransitionS === 'function'
? child.state.stateManager.getRemainingTransitionS()
: null;
const flowAt = (pos, pressure) => {
if (typeof child.predictFlow?.evaluate === 'function') {
return child.predictFlow.evaluate(pos, pressure);
}
return null;
};
// Inverse curve: target flow (canonical m³/s, in the child's output unit
// since predictCtrl was built from the same curve units) → control %.
// Mirrors the conversion the pump performs in flowController on a
// `flowmovement` command (rotatingMachine/src/flow/flowController.js:52).
// Returns null when the child has no curve loaded so the scheduler can
// fall back gracefully.
const positionForFlow = (flow) => {
if (!Number.isFinite(flow)) return null;
if (typeof child.predictCtrl?.y !== 'function') return null;
try {
const v = child.predictCtrl.y(flow);
return Number.isFinite(v) ? v : null;
} catch (_) {
return null;
}
};
return {
id,
state,
position,
minPosition,
maxPosition,
velocityPctPerS,
timings,
remainingTransitionS,
flowAt,
positionForFlow,
};
}
module.exports = { buildProfile };

View File

@@ -0,0 +1,86 @@
'use strict';
// Per-machine time-parameterised plan. Pure: given a MachineProfile
// snapshot and a target position, computes how long the move will take.
//
// Cases by profile.state:
// idle / off startup ladder + ramp from min to target
// operational |target position| / velocity
// accelerating |
// decelerating post-abort residue, same as operational
// starting remaining-in-starting + full warmup + ramp from min
// warmingup remaining-in-warmingup + ramp from min
// stopping | coolingdown non-interruptible deload; cannot contribute flow
// in this dispatch — returns null so the scheduler
// can exclude the machine from "up" candidates.
//
// Velocity of 0 returns Infinity (misconfigured speed) so the scheduler
// can demote the machine without crashing.
const ACTIVE_OPERATIONAL = new Set(['operational', 'accelerating', 'decelerating']);
const STARTUP_LADDER = new Set(['starting', 'warmingup']);
const SHUTDOWN_LADDER = new Set(['stopping', 'coolingdown']);
class MoveTrajectory {
constructor(profile, { targetPosition } = {}) {
if (!profile || typeof profile !== 'object') {
throw new TypeError('MoveTrajectory: profile is required');
}
if (!Number.isFinite(targetPosition)) {
throw new TypeError('MoveTrajectory: targetPosition must be a finite number');
}
this.profile = profile;
this.targetPosition = this._clampToBounds(targetPosition);
}
_clampToBounds(p) {
const { minPosition, maxPosition } = this.profile;
if (Number.isFinite(minPosition) && p < minPosition) return minPosition;
if (Number.isFinite(maxPosition) && p > maxPosition) return maxPosition;
return p;
}
// Seconds from "fire" until the machine is delivering flow at
// targetPosition. Null when the machine is in a non-contributing
// (shutting-down) state.
etaToTargetS() {
const p = this.profile;
const v = p.velocityPctPerS;
const target = this.targetPosition;
if (SHUTDOWN_LADDER.has(p.state)) return null;
if (!Number.isFinite(v) || v <= 0) return Infinity;
if (p.state === 'operational' || ACTIVE_OPERATIONAL.has(p.state)) {
const dist = Math.abs(target - p.position);
return dist / v;
}
if (p.state === 'warmingup') {
// Remaining warmup, then ramp from minPosition to target.
// Ramp starts from minPosition because the pump is not moving
// during warmup — position is held at min.
const remW = p.remainingTransitionS ?? p.timings.warmingupS;
const rampDist = Math.max(0, target - p.minPosition);
return remW + rampDist / v;
}
if (p.state === 'starting') {
// Remaining-in-starting + full warmup duration + ramp from min.
const remS = p.remainingTransitionS ?? p.timings.startingS;
const rampDist = Math.max(0, target - p.minPosition);
return remS + p.timings.warmingupS + rampDist / v;
}
// idle / off / emergencystop / maintenance / any non-active state
// not in the ladders: full startup sequence to operational, then ramp.
const rampDist = Math.max(0, target - p.minPosition);
return p.timings.startingS + p.timings.warmingupS + rampDist / v;
}
}
MoveTrajectory.SHUTDOWN_LADDER = SHUTDOWN_LADDER;
MoveTrajectory.STARTUP_LADDER = STARTUP_LADDER;
module.exports = MoveTrajectory;

View File

@@ -0,0 +1,121 @@
'use strict';
// Tick-driven executor for the schedule produced by movementScheduler.plan.
//
// - Holds the current schedule + a cursor that advances one per tick().
// - Fires any unfired command whose fireAtTickN <= cursor.
// - replan(newSchedule) replaces the schedule and resets the cursor —
// already-fired commands stay fired (the pump's FSM is downstream and
// handles their consequences; the executor never tries to "undo" a
// fired startup, which keeps warmup/cooldown safety intact).
// - fireCommand is injected for unit-testability — production wires it to
// `machine.handleInput(...)`.
class MovementExecutor {
constructor({ fireCommand, logger } = {}) {
if (typeof fireCommand !== 'function') {
throw new TypeError('MovementExecutor: fireCommand callback is required');
}
this._fireCommand = fireCommand;
this._logger = logger || null;
this._schedule = null;
this._cursor = 0;
this._firedIdx = new Set();
// Wall-clock anchor for the active schedule. Each tick recomputes
// a "virtual cursor" from elapsed time so the schedule survives a
// blocking first tick (e.g. an awaited startup sequence that takes
// multiple seconds to settle).
this._dispatchT0 = null;
}
// Replace the active schedule. Cursor starts at 0 (new dispatch is
// anchored to "now"). The previous schedule's unfired commands are
// dropped; already-fired commands are not retracted.
replan(schedule) {
this._schedule = schedule || { commands: [] };
this._cursor = 0;
this._firedIdx = new Set();
this._dispatchT0 = Date.now();
if (this._logger?.debug) {
const cmds = this._schedule.commands || [];
this._logger.debug(`MovementExecutor.replan: ${cmds.length} commands, tStar=${this._schedule.tStarS ?? '?'}s`);
}
}
// Advance one tick. Returns a Promise resolving to the list of
// commands fired this tick once their async work settles. Awaiting
// the FIRST tick from within a dispatch is what gives the new move
// priority over an in-flight shutdown sequence — fire-and-forget
// gives the shutdown's for-loop a window to progress through state
// transitions before the new move's residue handler claims the FSM.
async tick() {
// Virtual cursor = max(advanced cursor, elapsed wall-clock ticks).
// If a previous tick blocked on a long await, elapsed time has
// already passed and we should fire every command whose
// fireAtTickN now lies in the past — not wait another N timer
// cycles to catch up. tickS is stamped on the schedule by the
// planner (defaults to 1 s).
const tickS = Number.isFinite(this._schedule?.tickS) && this._schedule.tickS > 0
? this._schedule.tickS
: 1;
const elapsedS = this._dispatchT0 != null ? (Date.now() - this._dispatchT0) / 1000 : 0;
const wallTick = Math.floor(elapsedS / tickS);
const virtCursor = Math.max(this._cursor, wallTick);
const fired = [];
const cmds = this._schedule?.commands || [];
for (let i = 0; i < cmds.length; i++) {
if (this._firedIdx.has(i)) continue;
const c = cmds[i];
if (c.fireAtTickN <= virtCursor) {
this._firedIdx.add(i);
try {
// Fire-and-forget. The synchronous prologue of
// handleInput claims the latest-wins gate before
// returning its promise — that's enough for race
// favouring. AWAITing the returned promise here
// would block the executor for the entire ladder +
// ramp duration of a flowmovement-after-startup
// (because the pump's delayedMove only resolves
// when the ramp completes), preventing the
// wall-clock timer from starting and dragging every
// delayed command in the schedule forward by that
// amount.
const r = this._fireCommand(c);
if (r && typeof r.then === 'function') {
r.catch((e) => {
if (this._logger?.error) {
this._logger.error(`MovementExecutor: fireCommand rejected for ${c.machineId}/${c.action}: ${e?.message || e}`);
}
});
}
fired.push(c);
} catch (e) {
if (this._logger?.error) {
this._logger.error(`MovementExecutor: fireCommand failed for ${c.machineId}/${c.action}: ${e?.message || e}`);
}
}
}
}
this._cursor = virtCursor + 1;
return fired;
}
// Telemetry — number of commands not yet fired.
pending() {
const cmds = this._schedule?.commands || [];
return cmds.length - this._firedIdx.size;
}
// Telemetry — current tick cursor.
cursor() {
return this._cursor;
}
// Telemetry — the live schedule (read-only view).
schedule() {
return this._schedule;
}
}
module.exports = MovementExecutor;

View File

@@ -0,0 +1,245 @@
'use strict';
// Pure movement planner. Given a set of machine profile snapshots and the
// optimizer's chosen flow combination, returns a tick-indexed schedule of
// commands that minimises flow disruption during the transition.
//
// Algorithm — rendezvous-on-demand-at-current-pressure:
//
// 1. For each machine, classify the move it needs (startup, flow-move
// up, flow-move down, shutdown, no-op) based on its current FSM state
// and the optimizer's target flow for it.
// 2. Compute eta_i (seconds-to-target-flow) per machine via
// MoveTrajectory. Machines that can't contribute on this dispatch
// (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.
//
// 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
// (no overshoot from a fast in-flight retarget arriving before the
// startup pumps catch up).
//
// The pump's flow→position conversion (via predictCtrl.y) lives in the
// profile so this module is pure: no Node-RED calls, no live child reads.
const MoveTrajectory = require('./moveTrajectory');
const ACTIVE_STATES = new Set(['operational', 'accelerating', 'decelerating']);
const STARTUP_LADDER = new Set(['starting', 'warmingup']);
const SHUTDOWN_LADDER = new Set(['stopping', 'coolingdown']);
// Tick cadence — MGC main loop is 1 Hz per .claude/rules tick convention.
const DEFAULT_TICK_S = 1;
function isOn(state) {
return ACTIVE_STATES.has(state) || STARTUP_LADDER.has(state);
}
// Classify the action a machine needs. The optimizer's combination is a
// canonical statement of "what flow should this machine deliver now."
// `targetFlow == 0` (or absence from combination) means "this machine is
// not part of the new combination."
function classify(profile, targetFlow) {
const isOff = !isOn(profile.state) && !SHUTDOWN_LADDER.has(profile.state);
if (targetFlow > 0) {
if (isOff) return 'startup';
return 'flowmove'; // up or down depending on current vs target
}
// targetFlow <= 0
if (ACTIVE_STATES.has(profile.state) || STARTUP_LADDER.has(profile.state)) {
return 'shutdown';
}
return 'noop';
}
// Direction in flow-space: increasing, decreasing, or unchanged. Drives
// rendezvous: t* is the max eta over INCREASING moves; DECREASING moves
// get delayed to land at t*.
function directionOf(profile, targetFlow) {
if (!isOn(profile.state)) return targetFlow > 0 ? 'increasing' : 'unchanged';
const currentFlow = Number.isFinite(profile.flowAt?.(profile.position, profile._pressureForClassification))
? profile.flowAt(profile.position, profile._pressureForClassification)
: null;
if (currentFlow == null) {
// Without a current-flow read, assume increasing iff target > 0.
return targetFlow > 0 ? 'increasing' : 'decreasing';
}
if (targetFlow > currentFlow) return 'increasing';
if (targetFlow < currentFlow) return 'decreasing';
return 'unchanged';
}
// Plan the schedule.
//
// profiles — array from buildProfile(child)
// combination — array of {machineId, flow} from optimizer
// currentPressure — Pa, for flow→flow and flow→position conversions
// options — { tickS?: 1, useRendezvous?: true }
//
// useRendezvous=false collapses the schedule to "all commands fire at
// tick 0" — every pump moves at its own speed and lands at its own eta.
// Used when the operator explicitly opts out of same-time landing.
function plan(profiles, combination, currentPressure, options = {}) {
const tickS = Number.isFinite(options.tickS) && options.tickS > 0 ? options.tickS : DEFAULT_TICK_S;
const useRendezvous = options.useRendezvous !== false;
const targets = new Map();
for (const item of combination || []) {
if (item && item.machineId != null) targets.set(String(item.machineId), Number(item.flow) || 0);
}
// First pass: classify + compute eta per machine.
const plans = [];
for (const p of profiles) {
const id = String(p.id);
const targetFlow = targets.get(id) ?? 0;
// Stash pressure on a copy of the profile so directionOf can read it
// without changing the public profile shape. Non-mutating: classify
// only needs the value during this pass.
const probeProfile = Object.assign({}, p, { _pressureForClassification: currentPressure });
const action = classify(p, targetFlow);
const direction = directionOf(probeProfile, targetFlow);
if (action === 'noop') {
plans.push({ machineId: id, action, direction, eta: 0, targetFlow, skip: true });
continue;
}
// Convert target flow to target position using the pump's inverse
// curve (lives on the profile). Fallback: linear interpolation
// across [min,max] using the curve domain we know.
let targetPosition = null;
if (action !== 'shutdown' && typeof p.positionForFlow === 'function') {
targetPosition = p.positionForFlow(targetFlow);
}
if (targetPosition == null) {
// Shutdown: target is the minimum position.
targetPosition = action === 'shutdown' ? (Number.isFinite(p.minPosition) ? p.minPosition : 0) : p.position;
}
let eta;
// Per-pump ladder duration; used to gate the flowmovement so it
// can't fire before warmup completes (the pump won't accept it).
const ladderS = action === 'startup'
? ((Number(p.timings?.startingS) || 0) + (Number(p.timings?.warmingupS) || 0))
: 0;
// Ramp-only portion of the eta. For startup this is eta ladder.
// For flow-move or shutdown the entire eta IS the ramp.
let rampS = 0;
if (action === 'shutdown') {
// Time for flow to reach zero = position ramp from current
// position to minPosition. stoppingS / coolingdownS happen
// AFTER flow is zero; they don't affect rendezvous.
const v = Number(p.velocityPctPerS) > 0 ? p.velocityPctPerS : Infinity;
const dist = Math.max(0, p.position - (p.minPosition ?? 0));
eta = v === Infinity ? 0 : dist / v;
rampS = eta;
} else {
const traj = new MoveTrajectory(p, { targetPosition });
eta = traj.etaToTargetS();
if (eta == null) eta = Infinity; // shouldn't happen for non-shutdown actions, but defensive
rampS = Math.max(0, Number.isFinite(eta) ? eta - ladderS : 0);
}
plans.push({ machineId: id, action, direction, eta, ladderS, rampS, targetFlow, targetPosition, skip: false });
}
// Rendezvous: t* = max eta over ALL non-noop moves. Includes
// increasing AND decreasing flow-moves so the slowest mover sets the
// deadline for everyone. When useRendezvous=false, tStar is forced
// to 0 so every command's delay collapses to 0 (legacy behaviour).
const allEtas = plans
.filter((q) => !q.skip && Number.isFinite(q.eta))
.map((q) => q.eta);
const tStar = useRendezvous && allEtas.length > 0 ? Math.max(...allEtas) : 0;
// Second pass: assign fireAtTickN. Every command is delayed so its
// move finishes at t*; the lone exception is the startup ladder's
// execsequence (the ladder must begin now because eta == ladder + ramp).
const commands = [];
for (const q of plans) {
if (q.skip) continue;
// Delay-to-rendezvous: fire (t* eta) seconds from now so the
// move FINISHES at t*. Clamped to >= 0 (the eta == t* mover fires
// immediately).
const fireAtSDelayed = Math.max(0, tStar - q.eta);
const fireAtTickNDelayed = Math.round(fireAtSDelayed / tickS);
// Unchanged moves are no-ops; fire at 0 for simplicity (the pump
// ignores them and we don't pollute the schedule with delays).
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.
commands.push({
machineId: q.machineId,
action: 'execsequence',
sequence: 'startup',
fireAtTickN: 0,
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)),
eta: q.eta,
});
} else if (q.action === 'flowmove') {
commands.push({
machineId: q.machineId,
action: 'flowmovement',
flow: q.targetFlow,
// Unchanged moves are no-ops; fire immediately so we
// don't park them behind a long startup ladder for no
// reason. Up/down moves both delay so they land at t*.
fireAtTickN: isUnchanged ? 0 : fireAtTickNDelayed,
eta: q.eta,
});
} else if (q.action === 'shutdown') {
commands.push({
machineId: q.machineId,
action: 'execsequence',
sequence: 'shutdown',
fireAtTickN: fireAtTickNDelayed,
eta: q.eta,
});
}
}
return {
tStarS: tStar,
tickS,
commands,
// Debugging telemetry — kept in the output so tests can introspect.
_plans: plans,
};
}
module.exports = { plan, DEFAULT_TICK_S };

View File

@@ -19,6 +19,9 @@ class nodeClass extends BaseNodeAdapter {
const out = {};
if (uiConfig.mode) out.mode = { current: uiConfig.mode };
if (uiConfig.scaling) out.scaling = { current: uiConfig.scaling };
if (uiConfig.useRendezvous !== undefined) {
out.planner = { useRendezvous: uiConfig.useRendezvous };
}
return out;
}
}

View File

@@ -21,9 +21,23 @@ const GroupEfficiency = require('./efficiency/groupEfficiency');
const control = require('./control/strategies');
const io = require('./io/output');
const DemandDispatcher = require('./dispatch/demandDispatcher');
const { buildProfile } = require('./movement/machineProfile');
const movementScheduler = require('./movement/movementScheduler');
const MovementExecutor = require('./movement/movementExecutor');
const ACTIVE_STATES = new Set(['operational', 'accelerating', 'decelerating']);
// 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
// canonical form) honest. Module-level so tests can import without spinning
// up a full MachineGroup instance.
const ALLOWED_MODES = ['optimalControl', 'priorityControl', 'maintenance'];
function _normaliseMode(input) {
const lc = String(input || '').toLowerCase();
return ALLOWED_MODES.find((m) => m.toLowerCase() === lc) || null;
}
class MachineGroup extends BaseDomain {
static name = 'machineGroupControl';
@@ -41,7 +55,12 @@ class MachineGroup extends BaseDomain {
// tests still write directly (matches the pumpingStation pattern).
this.machines = {};
this.mode = this.config.mode.current;
// Persisted flows may have stored the mode in lowercase (legacy editor
// behaviour); normalise at construction so allow-list lookups against
// the schema's camelCase keys work consistently. Fallback to
// optimalControl if the persisted value is missing/garbage so a typo
// doesn't quietly disable dispatch.
this.mode = _normaliseMode(this.config.mode.current) || 'optimalControl';
this.absDistFromPeak = 0;
this.relDistFromPeak = 0;
this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog: 0 };
@@ -56,6 +75,16 @@ class MachineGroup extends BaseDomain {
);
this._shutdownInFlight = new Set();
// Tick-driven executor for the movement schedule produced by the
// planner. MGC owns the wall-clock setInterval that calls tick();
// the executor itself is pure (testable without timers).
this.movementExecutor = new MovementExecutor({
logger: this.logger,
fireCommand: (cmd) => this._fireSchedulerCommand(cmd),
});
this._executorTimer = null;
this._executorIntervalMs = 1000;
this.operatingPoint = new GroupOperatingPoint({
measurements: this.measurements,
machines: this.machines,
@@ -119,7 +148,31 @@ class MachineGroup extends BaseDomain {
}
// ── Surface kept for tests + commands ──────────────────────────────
setMode(mode) { this.mode = mode; this.notifyOutputChanged(); }
// Mirror of rotatingMachine/src/specificClass.js:329-339 — same pattern,
// mode/source allow-lists live in this.config.mode (loaded from the
// schema as Set instances). Anything not declared in the schema is
// dropped silently with a warn-level log.
isValidActionForMode(action, mode) {
const ok = !!this.config?.mode?.allowedActions?.[mode]?.has?.(action);
if (ok) this.logger.debug(`action '${action}' allowed in mode '${mode}'`);
else this.logger.warn(`action '${action}' not allowed in mode '${mode}'`);
return ok;
}
isValidSourceForMode(source, mode) {
const ok = !!this.config?.mode?.allowedSources?.[mode]?.has?.(source);
if (ok) this.logger.debug(`source '${source}' allowed in mode '${mode}'`);
else this.logger.warn(`source '${source}' not allowed in mode '${mode}'`);
return ok;
}
setMode(mode) {
const canonical = _normaliseMode(mode);
if (!canonical) {
this.logger.warn(`Invalid mode '${mode}'. Allowed: ${ALLOWED_MODES.join(', ')}`);
return;
}
this.mode = canonical;
this.notifyOutputChanged();
}
isMachineActive(id) {
const s = this.machines[id]?.state?.getCurrentState?.();
return ACTIVE_STATES.has(s);
@@ -223,20 +276,80 @@ class MachineGroup extends BaseDomain {
}
this.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestCog);
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
const pumpInfo = bestResult.bestCombination.find(it => it.machineId == id);
const flow = pumpInfo ? pumpInfo.flow : 0;
const state = machineStates[id];
// flowmovement BEFORE startup so concurrent retargets update
// delayedMove without a stale chained flowmovement landing
// post-startup — see idle-startup-deadlock Scenario 4.
if (flow > 0) {
await machine.handleInput('parent', 'flowmovement', this._canonicalToOutputFlow(flow));
if (state === 'idle') await machine.handleInput('parent', 'execsequence', 'startup');
} else if (ACTIVE_STATES.has(state)) {
await machine.handleInput('parent', 'execsequence', 'shutdown');
const distribution = bestResult.bestCombination.map((it) => ({ machineId: String(it.machineId), flow: it.flow }));
await this._dispatchFlowDistribution(distribution);
}
// Shared dispatch path used by every control strategy. Takes a flow
// distribution {machineId, flow}[] and routes it through the planner
// and executor. Same-time-landing (rendezvous) is the default and can
// be turned off via config.planner.useRendezvous, in which case every
// command fires at tick 0 (legacy fire-and-forget behaviour, like the
// pre-planner equalFlowControl).
async _dispatchFlowDistribution(distribution) {
const profiles = Object.values(this.machines).map((m) => buildProfile(m));
const headerPa = Number.isFinite(this.operatingPoint.headerDiffPa) ? this.operatingPoint.headerDiffPa : 0;
const useRendezvous = this.config?.planner?.useRendezvous !== false; // default true
const schedule = movementScheduler.plan(profiles, distribution, headerPa, { tickS: 1, useRendezvous });
this.movementExecutor.replan(schedule);
// AWAIT the first tick to preserve the race-favouring behaviour
// of the original code. The new move's full chain (residue
// handler → operational → ramp) settles before _runDispatch
// returns; the in-flight shutdown sequence's for-loop runs on
// other microtasks but its invalid-transition exits truncate it.
await this.movementExecutor.tick();
this._ensureExecutorTimer();
if (this.logger?.debug) {
this.logger.debug(`MGC planner: ${schedule.commands.length} commands queued, tStar=${schedule.tStarS.toFixed(1)}s, rendezvous=${useRendezvous}`);
}
}
// Dispatch one scheduled command to the appropriate child. Returns
// synchronously — the underlying handleInput is fire-and-forget from
// the executor's perspective, mirroring the existing optimal-control
// behaviour where commands are scheduled, not awaited.
_fireSchedulerCommand(cmd) {
const machine = this.machines[cmd.machineId];
if (!machine) {
this.logger?.warn?.(`Scheduler fired ${cmd.action} for unknown machine ${cmd.machineId}`);
return undefined;
}
const handle = typeof machine.handleInput === 'function' ? machine.handleInput.bind(machine) : null;
if (!handle) return undefined;
if (cmd.action === 'execsequence') {
return Promise.resolve(handle('parent', 'execsequence', cmd.sequence))
.catch((e) => this.logger?.error?.(`execsequence ${cmd.sequence} on ${cmd.machineId} failed: ${e?.message || e}`));
}
if (cmd.action === 'flowmovement') {
const outFlow = this._canonicalToOutputFlow(cmd.flow);
return Promise.resolve(handle('parent', 'flowmovement', outFlow))
.catch((e) => this.logger?.error?.(`flowmovement on ${cmd.machineId} failed: ${e?.message || e}`));
}
return undefined;
}
// Wall-clock driver for the executor. Auto-stops when there's nothing
// pending so we don't burn a forever-running setInterval.
_ensureExecutorTimer() {
if (this._executorTimer) return;
this._executorTimer = setInterval(() => {
this.movementExecutor.tick();
if (this.movementExecutor.pending() === 0) {
clearInterval(this._executorTimer);
this._executorTimer = null;
}
}));
}, this._executorIntervalMs);
// Unref so the timer doesn't keep Node-RED alive on shutdown.
if (typeof this._executorTimer.unref === 'function') this._executorTimer.unref();
}
// Stop the executor's wall-clock driver. Called from teardown paths.
_stopExecutorTimer() {
if (this._executorTimer) {
clearInterval(this._executorTimer);
this._executorTimer = null;
}
}
// Returns when THIS call's dispatch settles. If overwritten by a later
@@ -311,3 +424,6 @@ class MachineGroup extends BaseDomain {
}
module.exports = MachineGroup;
// Module-level helpers exposed for unit tests.
module.exports._normaliseMode = _normaliseMode;
module.exports.ALLOWED_MODES = ALLOWED_MODES;

View File

@@ -22,7 +22,7 @@ Built by `src/io/output.js :: getOutput(mgc)`. Delta-compressed by
| Key | Source | Type / Range | Populated test | Degraded test |
|---|---|---|---|---|
| `mode` | `mgc.mode` (set via `set.mode` command) | string ∈ {`optimalcontrol`, `prioritycontrol`, …} | commands.basic.test.js, ncog-distribution.integration.test.js | n/a — always set from constructor default |
| `mode` | `mgc.mode` (set via `set.mode` command; normalised by `specificClass.setMode`) | string ∈ {`optimalControl`, `priorityControl`, `maintenance`} (canonical camelCase) | commands.basic.test.js, ncog-distribution.integration.test.js | n/a — always set from constructor default |
| `scaling` | `mgc.scaling` | string ∈ {`absolute`, `normalized`} or undefined | commands.basic.test.js | dashboard-fanout (undefined → raw-rows shows '—') |
| `absDistFromPeak` | `groupEfficiency.calcDistanceFromPeak` (specificClass.js:132) | number ≥ 0 (η-points) | bep-distance-demand-sweep, group-bep-cascade, groupEfficiency.basic | groupEfficiency.basic test 7 (undefined when current = null) |
| `relDistFromPeak` | `groupEfficiency.calcRelativeDistanceFromPeak` | number ∈ [0,1] **OR `undefined`** for degenerate (homogeneous pumps) | bep-distance-demand-sweep, group-bep-cascade | groupEfficiency.basic tests 5/6/7 (undefined cases), dashboard-fanout test 11 (undefined → '—' display) |

View File

@@ -22,17 +22,44 @@ function makeLogger() {
};
}
function makeSource({ name = 'mgc-1', handleInputResult = undefined, dt = { flow: { min: 0, max: 100 } } } = {}) {
function makeSource({
name = 'mgc-1',
handleInputResult = undefined,
dt = { flow: { min: 0, max: 100 } },
// Initial mode for the fake. Defaults to optimalControl so gates pass for
// the historical tests; per-test override via the returned `source.mode = …`.
mode = 'optimalControl',
// Override the gate decisions. Default-true matches the no-gating world
// tests assumed before this change; negative-path tests pass functions that
// return false for specific actions / sources.
isValidActionForMode = () => true,
isValidSourceForMode = () => true,
} = {}) {
const calls = {
setMode: [],
handleInput: [],
registerChild: [],
turnOffAllMachines: 0,
gateAction: [],
gateSource: [],
};
const source = {
logger: makeLogger(),
config: { general: { name } },
setMode: (m) => calls.setMode.push(m),
mode,
setMode: (m) => { calls.setMode.push(m); /* keep fake.mode unchanged unless test does it */ },
isValidActionForMode: (action, m) => {
const ok = isValidActionForMode(action, m);
calls.gateAction.push({ action, mode: m, ok });
if (!ok) source.logger.warn(`action '${action}' not allowed in mode '${m}'`);
return ok;
},
isValidSourceForMode: (src, m) => {
const ok = isValidSourceForMode(src, m);
calls.gateSource.push({ src, mode: m, ok });
if (!ok) source.logger.warn(`source '${src}' not allowed in mode '${m}'`);
return ok;
},
handleInput: async (src, demand) => {
calls.handleInput.push({ src, demand });
if (handleInputResult instanceof Error) throw handleInputResult;
@@ -192,3 +219,124 @@ test('child.register with unknown child id logs warn and does not throw', async
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});
// --- mode gate tests -------------------------------------------------------
test('gate: set.demand in maintenance mode is dropped (action not allowed)', async () => {
// Mirror schema: maintenance allows only statusCheck. The dispatch action
// for a positive demand under optimalControl/priorityControl is
// execOptimalCombination / execSequentialControl — neither in maintenance.
const { source, calls } = makeSource({
mode: 'maintenance',
isValidActionForMode: (action) => action === 'statusCheck',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 50 }, source, makeCtx());
assert.equal(calls.handleInput.length, 0, 'handleInput must not be invoked');
assert.equal(calls.turnOffAllMachines, 0, 'turnOffAllMachines must not be invoked');
assert.ok(
source.logger.calls.warn.some((m) => m.includes('not allowed')),
`expected warn about action not allowed in maintenance, got: ${JSON.stringify(source.logger.calls.warn)}`
);
});
test("gate: set.demand from msg.source 'physical' in maintenance is dropped (source not allowed)", async () => {
// Maintenance accepts sources ['parent','GUI'] per schema. Physical/HMI is
// rejected by the source gate even before we ask which action to perform.
const { source, calls } = makeSource({
mode: 'maintenance',
isValidActionForMode: () => true, // pretend action is allowed; source gate must still reject
isValidSourceForMode: (src) => src === 'parent' || src === 'GUI',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 50, source: 'physical' }, source, makeCtx());
assert.equal(calls.handleInput.length, 0);
assert.equal(calls.turnOffAllMachines, 0);
assert.ok(
source.logger.calls.warn.some((m) => m.includes("'physical'") && m.includes('not allowed')),
`expected warn about physical source not allowed, got: ${JSON.stringify(source.logger.calls.warn)}`
);
});
test('gate: set.demand from msg.source GUI in optimalControl reaches handleInput', async () => {
const { source, calls } = makeSource({
mode: 'optimalControl',
isValidActionForMode: (action) =>
['statusCheck', 'execOptimalCombination', 'balanceLoad', 'emergencyStop'].includes(action),
isValidSourceForMode: (src) => ['parent', 'GUI', 'physical', 'API'].includes(src),
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 25, source: 'GUI' }, source, makeCtx());
assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 25 });
// Sanity check on the gate plumbing: both gates were consulted with the
// expected (action, source, mode) tuple.
assert.ok(calls.gateAction.some((g) => g.action === 'execOptimalCombination' && g.mode === 'optimalControl' && g.ok));
assert.ok(calls.gateSource.some((g) => g.src === 'GUI' && g.mode === 'optimalControl' && g.ok));
});
test('gate: emergencyStop (negative demand) gated by mode → maintenance blocks the stop-all', async () => {
// A negative demand is the operator stop-all signal. The schema declares
// emergencyStop in optimalControl/priorityControl but NOT in maintenance,
// so this should be rejected too — maintenance is "monitor only", which
// includes "no dispatch decisions, even shutdowns".
const { source, calls } = makeSource({
mode: 'maintenance',
isValidActionForMode: (action) => action === 'statusCheck',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: -1 }, source, makeCtx());
assert.equal(calls.turnOffAllMachines, 0, 'turnOff must be gated');
assert.ok(
source.logger.calls.warn.some((m) => m.includes('emergencyStop') && m.includes('not allowed')),
`expected warn about emergencyStop not allowed, got: ${JSON.stringify(source.logger.calls.warn)}`
);
});
// --- mode-string normalisation (specificClass internals) --------------------
const { _normaliseMode, ALLOWED_MODES } = require('../../src/specificClass');
test('mode normalisation: camelCase pass-through, lowercase accepted, garbage rejected', () => {
assert.equal(_normaliseMode('optimalControl'), 'optimalControl');
assert.equal(_normaliseMode('optimalcontrol'), 'optimalControl');
assert.equal(_normaliseMode('OPTIMALCONTROL'), 'optimalControl');
assert.equal(_normaliseMode('priorityControl'), 'priorityControl');
assert.equal(_normaliseMode('prioritycontrol'), 'priorityControl');
assert.equal(_normaliseMode('maintenance'), 'maintenance');
assert.equal(_normaliseMode('MAINTENANCE'), 'maintenance');
assert.equal(_normaliseMode('wat'), null);
assert.equal(_normaliseMode(''), null);
assert.equal(_normaliseMode(null), null);
assert.equal(_normaliseMode(undefined), null);
assert.deepEqual(ALLOWED_MODES, ['optimalControl', 'priorityControl', 'maintenance']);
});
// --- schema-shape regression -----------------------------------------------
test('schema regression: allowedSources keys are camelCase for all three modes', () => {
// Read the JSON directly — generalFunctions' package.json `exports` map
// doesn't expose the configs subpath, and we don't want to add it just for
// a test. Path is repo-relative from this test file.
const fs = require('node:fs');
const path = require('node:path');
const schemaPath = path.resolve(__dirname, '../../../generalFunctions/src/configs/machineGroupControl.json');
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
const allowedSourcesSchema = schema.mode.allowedSources.rules.schema;
assert.ok(allowedSourcesSchema.optimalControl, 'optimalControl key must exist on allowedSources');
assert.ok(allowedSourcesSchema.priorityControl, 'priorityControl key must exist on allowedSources');
assert.ok(allowedSourcesSchema.maintenance, 'maintenance key must exist on allowedSources');
// Maintenance is monitor-only: parent + GUI permitted, physical/API rejected.
const mDefaults = allowedSourcesSchema.maintenance.default;
assert.ok(mDefaults.includes('parent'), `maintenance default should permit parent, got ${mDefaults}`);
assert.ok(mDefaults.includes('GUI'), `maintenance default should permit GUI, got ${mDefaults}`);
assert.ok(!mDefaults.includes('physical'), 'maintenance must NOT permit physical writes');
assert.ok(!mDefaults.includes('API'), 'maintenance must NOT permit API writes');
// Catch a regression to lowercase keys.
assert.equal(allowedSourcesSchema.optimalcontrol, undefined, 'lowercase optimalcontrol key must NOT exist');
assert.equal(allowedSourcesSchema.prioritycontrol, undefined, 'lowercase prioritycontrol key must NOT exist');
});

View File

@@ -0,0 +1,142 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const MoveTrajectory = require('../../src/movement/moveTrajectory');
// Reusable profile builder — keeps each test focused on the field(s) it cares
// about. Anything not overridden is in a sane "operational at 0%" baseline.
function makeProfile(over = {}) {
return Object.assign({
id: 'P1',
state: 'operational',
position: 0,
minPosition: 0,
maxPosition: 100,
velocityPctPerS: 2,
timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 },
remainingTransitionS: null,
flowAt: () => null,
}, over);
}
// TC1 — idle, full startup ladder + ramp from min.
test('TC1 idle → target = startingS + warmingupS + (targetmin)/velocity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'idle' }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 10 + 20 + 60 / 2); // 60s
});
// TC2 — operational up.
test('TC2 operational up = |targetposition|/velocity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 40 }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 10);
});
// TC3 — operational down. ETA is positive.
test('TC3 operational down = |targetposition|/velocity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 80 }), { targetPosition: 30 });
assert.equal(t.etaToTargetS(), 25);
});
// TC4 — no-op.
test('TC4 operational, target == position → 0s', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 50 }), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 0);
});
// TC5 — accelerating post-abort residue, same formula as operational.
test('TC5 accelerating residue = operational formula', () => {
const t = new MoveTrajectory(makeProfile({ state: 'accelerating', position: 35 }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 12.5);
});
// TC6 — decelerating residue.
test('TC6 decelerating residue = operational formula', () => {
const t = new MoveTrajectory(makeProfile({ state: 'decelerating', position: 70 }), { targetPosition: 40 });
assert.equal(t.etaToTargetS(), 15);
});
// TC7 — warmingup, remaining time from stateManager.
test('TC7 warmingup = remainingWarmupS + (targetmin)/velocity', () => {
const t = new MoveTrajectory(makeProfile({
state: 'warmingup',
position: 0,
remainingTransitionS: 12,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 12 + 50 / 2); // 37s
});
// TC7b — warmingup but no remaining-time observation: falls back to full
// configured warmup (worst-case). Kept for resilience when the state machine
// pre-dates the getter.
test('TC7b warmingup fallback to full warmingupS when no remaining provided', () => {
const t = new MoveTrajectory(makeProfile({
state: 'warmingup',
position: 0,
remainingTransitionS: null,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 20 + 50 / 2); // 45s
});
// TC8 — starting: remaining + full warmup + ramp.
test('TC8 starting = remainingStartingS + warmingupS + (targetmin)/velocity', () => {
const t = new MoveTrajectory(makeProfile({
state: 'starting',
position: 0,
remainingTransitionS: 8,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 8 + 20 + 50 / 2); // 53s
});
// TC8b — boundary: remaining hits 0 just before the setTimeout fires.
test('TC8b starting with remainingTransitionS=0 still yields positive ETA', () => {
const t = new MoveTrajectory(makeProfile({
state: 'starting',
position: 0,
remainingTransitionS: 0,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 0 + 20 + 50 / 2); // 45s
});
// TC9 — shutdown ladder excluded: returns null so scheduler skips it.
test('TC9a stopping → null', () => {
const t = new MoveTrajectory(makeProfile({ state: 'stopping', position: 30 }), { targetPosition: 0 });
assert.equal(t.etaToTargetS(), null);
});
test('TC9b coolingdown → null', () => {
const t = new MoveTrajectory(makeProfile({ state: 'coolingdown', position: 0 }), { targetPosition: 0 });
assert.equal(t.etaToTargetS(), null);
});
// TC10 — target above max clamps; ETA uses clamped value.
test('TC10 target above maxPosition clamps to max', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, maxPosition: 100 }), { targetPosition: 120 });
assert.equal(t.targetPosition, 100);
assert.equal(t.etaToTargetS(), 50);
});
// TC11 — target below min clamps; ETA zero when already at min.
test('TC11 target below min clamps to min; ETA = 0 when at min', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, minPosition: 0 }), { targetPosition: -5 });
assert.equal(t.targetPosition, 0);
assert.equal(t.etaToTargetS(), 0);
});
// TC12 — zero velocity yields Infinity, not NaN or crash.
test('TC12 zero velocity → Infinity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, velocityPctPerS: 0 }), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), Infinity);
});
// TC13 — non-finite target throws at construction (totality of etaToTargetS).
test('TC13 non-finite target throws at construction', () => {
assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: NaN }), TypeError);
assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: undefined }), TypeError);
});
// Extra: minPosition above 0 is honoured in ramp distance for startup cases.
test('TC1b idle with minPosition=10 → ramp from 10, not 0', () => {
const t = new MoveTrajectory(makeProfile({ state: 'idle', minPosition: 10 }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 10 + 20 + (60 - 10) / 2); // 55s
});

View File

@@ -0,0 +1,136 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const MovementExecutor = require('../../src/movement/movementExecutor');
function mkSchedule(commands, tStarS = 0, tickS = 1) {
return { tStarS, tickS, commands };
}
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
test('executor: throws if fireCommand callback missing', () => {
assert.throws(() => new MovementExecutor({}), TypeError);
});
test('executor: fires commands whose fireAtTickN <= cursor', async () => {
const fired = [];
const ex = new MovementExecutor({
fireCommand: (c) => fired.push(c),
logger: noopLogger,
});
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 2, eta: 2 },
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 5, eta: 5 },
]));
let firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 1);
assert.equal(firedThisTick[0].machineId, 'A');
firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 0);
firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 1);
assert.equal(firedThisTick[0].machineId, 'B');
await ex.tick(); await ex.tick();
firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 1);
assert.equal(firedThisTick[0].machineId, 'C');
assert.deepEqual(fired.map((c) => c.machineId), ['A', 'B', 'C']);
assert.equal(ex.pending(), 0);
});
test('executor: replan drops unfired commands and resets cursor', async () => {
const fired = [];
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 10, eta: 10 },
]));
await ex.tick(); // A fires
assert.deepEqual(fired, ['A']);
assert.equal(ex.pending(), 1);
ex.replan(mkSchedule([
{ machineId: 'X', action: 'flowmovement', flow: 80, fireAtTickN: 0, eta: 0 },
{ machineId: 'Y', action: 'flowmovement', flow: 20, fireAtTickN: 3, eta: 3 },
]));
assert.equal(ex.cursor(), 0, 'cursor reset on replan');
await ex.tick(); // X fires
assert.deepEqual(fired, ['A', 'X']);
await ex.tick(); await ex.tick(); await ex.tick();
assert.ok(!fired.includes('B'), 'old B move was dropped by replan');
assert.ok(fired.includes('Y'), 'new Y move fired after delay');
});
test('executor: fires only once per command even across many ticks', async () => {
const fired = [];
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
]));
for (let i = 0; i < 5; i++) await ex.tick();
assert.deepEqual(fired, ['A']);
});
test('executor: catches fireCommand errors and continues', async () => {
const fired = [];
const ex = new MovementExecutor({
fireCommand: (c) => {
if (c.machineId === 'B') throw new Error('boom');
fired.push(c.machineId);
},
logger: noopLogger,
});
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 0, eta: 0 },
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 0, eta: 0 },
]));
await ex.tick();
// B's error must not block A or C.
assert.deepEqual(fired, ['A', 'C']);
});
test('executor: empty / null schedule is safe to tick', async () => {
const ex = new MovementExecutor({ fireCommand: () => {}, logger: noopLogger });
assert.deepEqual(await ex.tick(), []);
ex.replan({ commands: [] });
assert.deepEqual(await ex.tick(), []);
});
test('executor: tick fires commands synchronously and does NOT await their promises', async () => {
// Contract: tick() returns as soon as every due fireCommand has been
// invoked. It does NOT wait for the returned promises to resolve.
// This matters because a flowmovement-after-startup resolves only
// after the pump's entire ramp completes — awaiting it would freeze
// the executor's wall-clock progression and drag every delayed
// command in the schedule forward by that duration.
const order = [];
let resolveFire;
const firePromise = new Promise((r) => { resolveFire = r; });
const ex = new MovementExecutor({
fireCommand: (c) => {
order.push(`fire-start-${c.machineId}`);
return firePromise.then(() => { order.push(`fire-end-${c.machineId}`); });
},
logger: noopLogger,
});
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
]));
const tickPromise = ex.tick().then(() => order.push('tick-resolved'));
// Wait one microtask cycle: tick should already have resolved even
// though fire is still pending.
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(order, ['fire-start-A', 'tick-resolved'],
'tick must resolve immediately after invoking fireCommand — not wait for its promise');
resolveFire();
await tickPromise;
// The fire's tail runs in the background and lands after tick resolved.
assert.deepEqual(order, ['fire-start-A', 'tick-resolved', 'fire-end-A']);
});

View File

@@ -0,0 +1,307 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { plan } = require('../../src/movement/movementScheduler');
// Profile builder — same shape as buildProfile output. positionForFlow
// approximates the inverse curve as a linear mapping over [min,max] for
// flow ∈ [0, maxFlow], which is enough to test scheduler logic without
// dragging real curve math in.
function makeProfile(over = {}) {
const defaults = {
id: 'A',
state: 'operational',
position: 0,
minPosition: 0,
maxPosition: 100,
velocityPctPerS: 2,
timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 },
remainingTransitionS: null,
maxFlow: 100, // synthetic — for the test mapping below
};
const p = Object.assign(defaults, over);
// Linear position-for-flow over [min,max].
p.positionForFlow = (flow) => {
if (!Number.isFinite(flow) || flow <= 0) return p.minPosition;
return p.minPosition + (flow / p.maxFlow) * (p.maxPosition - p.minPosition);
};
// flowAt — inverse of the above.
p.flowAt = (pos /*, pressure */) => {
if (!Number.isFinite(pos)) return 0;
if (p.maxPosition === p.minPosition) return 0;
return ((pos - p.minPosition) / (p.maxPosition - p.minPosition)) * p.maxFlow;
};
return p;
}
// Tick rounding helper — scheduler uses Math.round(eta/tickS).
function tickRound(s, tickS = 1) { return Math.round(s / tickS); }
test('plan: idle → start a single pump (no other pumps online)', () => {
const profiles = [makeProfile({ id: 'A', state: 'idle', position: 0 })];
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
// Two commands: execsequence(startup) + flowmovement(60). Both at tick 0.
assert.equal(out.commands.length, 2);
assert.equal(out.commands[0].action, 'execsequence');
assert.equal(out.commands[0].sequence, 'startup');
assert.equal(out.commands[0].fireAtTickN, 0);
assert.equal(out.commands[1].action, 'flowmovement');
assert.equal(out.commands[1].flow, 60);
assert.equal(out.commands[1].fireAtTickN, 0);
// tStar = full startup ladder + ramp from 0 to position-for-60 (= 60%).
// = 10 + 20 + 60/2 = 60s.
assert.equal(out.tStarS, 60);
});
test('plan: operational up-move (no rendezvous partner)', () => {
const profiles = [makeProfile({ id: 'A', state: 'operational', position: 40 })];
// Currently delivering 40 (at maxFlow=100 → linear), targeting 60.
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
assert.equal(out.commands.length, 1);
assert.equal(out.commands[0].action, 'flowmovement');
assert.equal(out.commands[0].flow, 60);
assert.equal(out.commands[0].fireAtTickN, 0);
// eta = |6040|/2 = 10s
assert.equal(out.tStarS, 10);
});
test('plan: rendezvous — startup pump + running pump that needs to shed load', () => {
// A: starting from idle, target 60. eta = 10 + 20 + 60/2 = 60s.
// B: operational at 80 (flow=80), target 40 (down). eta_B = 40/2 = 20s.
// Expectation: A fires at tick 0; B fires at tick (6020) = 40 so B
// FINISHES at the same time A reaches its target.
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0 }),
makeProfile({ id: 'B', state: 'operational', position: 80 }),
];
const combination = [
{ machineId: 'A', flow: 60 },
{ machineId: 'B', flow: 40 },
];
const out = plan(profiles, combination, 100_000);
const cmdA_startup = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence');
const cmdA_flow = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement');
const cmdB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
assert.ok(cmdA_startup, 'A startup');
assert.ok(cmdA_flow, 'A flowmovement (queued)');
assert.ok(cmdB, 'B flowmovement');
assert.equal(cmdA_startup.fireAtTickN, 0);
assert.equal(cmdA_flow.fireAtTickN, 0);
// B delayed so it finishes at tStar=60 → fires at 6020 = 40.
assert.equal(cmdB.fireAtTickN, 40);
assert.equal(out.tStarS, 60);
});
test('plan: all machines moving down — all land at slowest mover\'s eta', () => {
// Two operational pumps, both reducing flow. tStar = max eta over
// ALL non-noop moves (not just increasing) so the slower pump
// defines the rendezvous and the faster one is delayed to land
// with it. Net effect: same-time landing in pure-down scenarios too,
// sum-of-flows stays at the OLD setpoint until t* then drops cleanly.
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 80, velocityPctPerS: 2 }),
makeProfile({ id: 'B', state: 'operational', position: 70, velocityPctPerS: 2 }),
];
const combination = [
{ machineId: 'A', flow: 40 }, // target position via inverse curve → 40 (identity makeProfile)
{ machineId: 'B', flow: 30 },
];
const out = plan(profiles, combination, 100_000);
// eta_A = |80-40|/2 = 20s, eta_B = |70-30|/2 = 20s → tStar = 20s.
assert.equal(out.tStarS, 20);
// Both pumps have eta == tStar so neither is delayed (fireAtTickN = 0).
for (const c of out.commands) {
assert.equal(c.fireAtTickN, 0, `${c.machineId} should fire at 0 when eta == tStar`);
}
});
test('plan: asymmetric down moves — faster one delayed to land with slower one', () => {
// A and B both reduce flow but A's move is faster. The new
// symmetric-rendezvous semantics delay the faster mover so both land
// at tStar = max eta.
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 60, velocityPctPerS: 4 }), // fast
makeProfile({ id: 'B', state: 'operational', position: 80, velocityPctPerS: 2 }), // slow
];
const combination = [
{ machineId: 'A', flow: 40 },
{ machineId: 'B', flow: 40 },
];
const out = plan(profiles, combination, 100_000);
// eta_A = |60-40|/4 = 5s, eta_B = |80-40|/2 = 20s → tStar = 20s.
assert.equal(out.tStarS, 20);
const cA = out.commands.find((c) => c.machineId === 'A');
const cB = out.commands.find((c) => c.machineId === 'B');
assert.equal(cA.fireAtTickN, 15, 'A (fast) delayed by tStar eta_A = 20 5 = 15');
assert.equal(cB.fireAtTickN, 0, 'B (slow) defines tStar — fires immediately');
});
test('plan: shutdown — removed machine gets execsequence(shutdown)', () => {
// A staying at flow 60, B getting shut down (target 0).
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 60 }),
makeProfile({ id: 'B', state: 'operational', position: 50 }),
];
const combination = [
{ machineId: 'A', flow: 60 }, // unchanged
{ machineId: 'B', flow: 0 },
];
const out = plan(profiles, combination, 100_000);
const shutdownB = out.commands.find((c) => c.machineId === 'B' && c.action === 'execsequence' && c.sequence === 'shutdown');
assert.ok(shutdownB, 'B shutdown command present');
});
test('plan: noop — machine not in combination and already off does nothing', () => {
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 60 }),
makeProfile({ id: 'B', state: 'idle', position: 0 }),
];
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
const bAny = out.commands.find((c) => c.machineId === 'B');
assert.equal(bAny, undefined, 'B should be omitted (no-op)');
});
test('plan: rendezvous with three pumps — slowest startup sets the pace', () => {
// A: idle → 50 (full startup, slow).
// B: operational at 80 → 40 (down).
// C: operational at 30 → 50 (up, fast).
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0 }),
makeProfile({ id: 'B', state: 'operational', position: 80 }),
makeProfile({ id: 'C', state: 'operational', position: 30 }),
];
const combination = [
{ machineId: 'A', flow: 50 },
{ machineId: 'B', flow: 40 },
{ machineId: 'C', flow: 50 },
];
const out = plan(profiles, combination, 100_000);
// eta_A = 10 + 20 + 50/2 = 55s (startup ladder + ramp; defines tStar)
// eta_B = |80-40|/2 = 20s (decreasing)
// eta_C = |50-30|/2 = 10s (increasing)
// tStar = max(55, 20, 10) = 55.
assert.equal(out.tStarS, 55);
const cA = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence');
const cC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement');
const cB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
// A's startup must begin NOW; its delayed flowmovement lands at t*
// by construction.
assert.equal(cA.fireAtTickN, 0);
// Symmetric rendezvous: BOTH B and C are delayed to land at t*.
// C (up, fast) gets delayed by t* eta_C = 45.
// B (down, mid) gets delayed by t* eta_B = 35.
assert.equal(cC.fireAtTickN, 55 - 10, 'C delayed to land at tStar (same-time landing)');
assert.equal(cB.fireAtTickN, 55 - 20, 'B delayed to land at tStar (same-time landing)');
});
test('plan: mixed-speed multi-startup — fast pumps wait so all land at tStar together', () => {
// Three idle pumps starting from min position. Different per-pump
// velocities → different etas. Without the rampStart gating, each
// pump's delayedMove would fire at warmup-end and ramp at its own
// speed, so the FAST pump lands long before the SLOW one — visible
// on the dashboard as staggered landing curves.
//
// Real-world reproducer: pumpingstation-complete-example with the
// editor's Reaction Speed set to A=3 %/s, B=10 %/s, C=1 %/s.
//
// Velocities here mirror that ratio but scaled for unit-test
// readability. Position range is [0,100] so rampDist = 100.
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0, velocityPctPerS: 3 }),
makeProfile({ id: 'B', state: 'idle', position: 0, velocityPctPerS: 10 }),
makeProfile({ id: 'C', state: 'idle', position: 0, velocityPctPerS: 1 }),
];
const combination = [
{ machineId: 'A', flow: 100 },
{ machineId: 'B', flow: 100 },
{ machineId: 'C', flow: 100 },
];
const out = plan(profiles, combination, 100_000);
// Default ladder = starting(10) + warmingup(20) = 30 s.
// ramp_A = 100/3 ≈ 33.33 s → eta_A ≈ 63.33 s
// ramp_B = 100/10 = 10 s → eta_B = 40 s
// ramp_C = 100/1 = 100 s → eta_C = 130 s
// 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).
for (const id of ['A', 'B', 'C']) {
const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence');
assert.ok(exec, `${id} execsequence present`);
assert.equal(exec.fireAtTickN, 0, `${id} execsequence fires immediately`);
}
// 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.
});
test('plan: zero-velocity machine is demoted (infinite eta) but does not crash', () => {
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 0, velocityPctPerS: 0 }),
];
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
// Eta is Infinity → filtered out of tStar computation (only finite etas count).
// Command still scheduled; fireAtTickN remains 0 for increasing move.
const c = out.commands.find((c) => c.action === 'flowmovement');
assert.ok(c);
assert.equal(c.fireAtTickN, 0);
assert.equal(out.tStarS, 0); // no finite increasing eta → tStar collapses to 0
});
test('plan: respects custom tickS option', () => {
// Same as the rendezvous test but with tickS=5 → fireAt should be in
// ticks-of-5-seconds, not seconds.
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0 }),
makeProfile({ id: 'B', state: 'operational', position: 80 }),
];
const combination = [
{ machineId: 'A', flow: 60 },
{ machineId: 'B', flow: 40 },
];
const out = plan(profiles, combination, 100_000, { tickS: 5 });
const cmdB = out.commands.find((c) => c.machineId === 'B');
assert.equal(out.tStarS, 60);
assert.equal(out.tickS, 5);
assert.equal(cmdB.fireAtTickN, tickRound(60 - 20, 5)); // = 8
});

View File

@@ -0,0 +1,254 @@
// MGC planner — real-time CONVERGENCE diagnostic.
//
// Where planner-rendezvous.integration.test.js intercepts _fireCommand to
// only assert schedule SHAPE, this test lets the executor REALLY run on
// real pumps with non-zero startup/warmup times, and asks two questions:
//
// (a) does sum-of-pump-flows converge to the demand setpoint?
// (b) do all pumps reach their individual flow target at roughly the
// same wall-clock instant (the rendezvous)?
//
// Realistic scenario: ONE pump already operational, TWO pumps idle. A new
// demand requires (i) the two idle pumps to start (slow, ~3.5s) AND (ii)
// the running pump to retarget. Per the planner code, only flow-DECREASING
// moves get delayed to land at t*; flow-INCREASING moves on running pumps
// fire at tick 0 and land at their own eta. So the running pump's landing
// time should NOT match the two idle pumps unless its target equals its
// current flow (an unusual coincidence). This test surfaces that.
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
const Machine = require('../../../rotatingMachine/src/specificClass');
const HEAD_MBAR_UP = 0;
const HEAD_MBAR_DOWN = 1100;
const N_PUMPS = 3;
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
const stateConfig = {
general: { logging: logCfg },
state: { current: 'idle' },
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
// REAL ladder times — this is the whole point of the test.
time: { starting: 1, warmingup: 2, stopping: 1, coolingdown: 2 },
};
function machineConfig(id) {
return {
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
allowedSources: { auto: ['parent', 'GUI'] },
},
sequences: {
startup: ['starting', 'warmingup', 'operational'],
shutdown: ['stopping', 'coolingdown', 'idle'],
emergencystop: ['emergencystop', 'off'],
},
};
}
function groupConfig() {
return {
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
mode: { current: 'optimalcontrol' },
};
}
function pctToCanonical(mgc, pct) {
if (pct < 0) return -1;
const dt = mgc.calcDynamicTotals();
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
}
const NON_RUNNING = new Set(['idle', 'off', 'stopping', 'coolingdown', 'emergencystop']);
function pumpFlow_m3h(pump) {
const state = pump.state.getCurrentState();
if (NON_RUNNING.has(state)) return 0;
return Number(pump.predictFlow?.outputY ?? 0) * 3600;
}
function buildGroup() {
const mgc = new MachineGroup(groupConfig());
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
const pumps = ids.map((id) => new Machine(machineConfig(id), stateConfig));
for (const m of pumps) {
m.updateMeasuredPressure(HEAD_MBAR_UP, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
m.updateMeasuredPressure(HEAD_MBAR_DOWN, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
mgc.childRegistrationUtils.registerChild(m, 'downstream');
}
mgc.calcAbsoluteTotals();
mgc.calcDynamicTotals();
return { mgc, pumps };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Sample per-pump flow at fixed intervals and return a trajectory: an array
// of {tMs, perPump:[...], sum}.
async function sampleFlows(pumps, durationMs, intervalMs = 200) {
const t0 = Date.now();
const out = [];
while (Date.now() - t0 < durationMs) {
const perPump = pumps.map(pumpFlow_m3h);
out.push({ tMs: Date.now() - t0, perPump, sum: perPump.reduce((a, b) => a + b, 0) });
await sleep(intervalMs);
}
return out;
}
// Find the wall-clock instant (in ms from t0) at which a given series
// REACHES and STAYS within `tol` of `target` for the rest of the run. If
// never reached, returns null.
function arrivalTimeMs(series, target, tol) {
for (let i = 0; i < series.length; i++) {
const v = series[i];
if (Math.abs(v - target) <= tol) {
// require it to stay close
let stayed = true;
for (let j = i + 1; j < series.length; j++) {
if (Math.abs(series[j] - target) > tol * 1.5) { stayed = false; break; }
}
if (stayed) return i;
}
}
return null;
}
function printTrace(label, traj, demand_m3h) {
console.log(`\n${label} (demand=${demand_m3h.toFixed(1)} m³/h)`);
const head = [' t(s)'.padStart(7), 'pump_a'.padStart(8), 'pump_b'.padStart(8), 'pump_c'.padStart(8), 'Σ m³/h'.padStart(8), 'err'.padStart(7)];
console.log(head.join(' '));
console.log('─'.repeat(head.join(' ').length));
for (const s of traj) {
const err = s.sum - demand_m3h;
console.log([
(s.tMs / 1000).toFixed(2).padStart(7),
s.perPump[0].toFixed(1).padStart(8),
s.perPump[1].toFixed(1).padStart(8),
s.perPump[2].toFixed(1).padStart(8),
s.sum.toFixed(1).padStart(8),
err.toFixed(1).padStart(7),
].join(' '));
}
}
// ── The diagnostic ──────────────────────────────────────────────────────
test('planner-convergence: mixed-state dispatch — sum reaches demand AND lands together', async () => {
const { mgc, pumps } = buildGroup();
const dyn = mgc.calcDynamicTotals();
const flowMin_m3h = dyn.flow.min * 3600;
const flowMax_m3h = dyn.flow.max * 3600;
console.log(`\nStation envelope at head ${HEAD_MBAR_DOWN} mbar (${N_PUMPS} pumps): ${flowMin_m3h.toFixed(1)} .. ${flowMax_m3h.toFixed(1)} m³/h`);
// Phase 1: bring pump_a (only) to operational at a low setpoint via a
// direct child command. This bypasses the optimizer and gives us a
// deterministic mixed state: 1 running, 2 idle. We then drive a global
// demand to ramp up — the planner must coordinate one in-flight retarget
// with two startups.
const pumpA = pumps[0];
await pumpA.handleInput('parent', 'execsequence', 'startup');
// wait for warmup to complete
for (let i = 0; i < 200 && pumpA.state.getCurrentState() !== 'operational'; i++) await sleep(50);
assert.equal(pumpA.state.getCurrentState(), 'operational', 'pre-condition: pump_a should be operational');
// Put pump_a at ~30% of its per-pump flow range. This guarantees the
// optimizer's later combination will want pump_a to MOVE (either up to
// share work with the new pumps, or down to balance them) — either
// direction surfaces a rendezvous concern.
const sample = pumpA.groupPredictFlow ?? pumpA.predictFlow;
const perPumpMin_m3h = sample.currentFxyYMin * 3600;
const perPumpMax_m3h = sample.currentFxyYMax * 3600;
const initialFlow_m3h = perPumpMin_m3h + 0.30 * (perPumpMax_m3h - perPumpMin_m3h);
await pumpA.handleInput('parent', 'flowmovement', initialFlow_m3h);
await sleep(500); // let pump_a settle
const initialSnap = pumps.map((p) => ({ state: p.state.getCurrentState(), q: pumpFlow_m3h(p) }));
console.log('\nInitial state (1 running, 2 idle):');
for (let i = 0; i < pumps.length; i++) {
console.log(` ${pumps[i].config.general.id}: ${initialSnap[i].state.padEnd(13)} Q=${initialSnap[i].q.toFixed(1)} m³/h`);
}
assert.equal(initialSnap[0].state, 'operational', 'pump_a operational at start');
assert.equal(initialSnap[1].state, 'idle', 'pump_b idle at start');
assert.equal(initialSnap[2].state, 'idle', 'pump_c idle at start');
// Phase 2: drive 90% demand — needs all 3 pumps.
const demandPct = 90;
const demand_m3s = pctToCanonical(mgc, demandPct);
const demand_m3h = demand_m3s * 3600;
console.log(`\nDispatching ${demandPct}% → ${demand_m3h.toFixed(1)} m³/h demand…`);
// Fire-and-don't-wait so we can sample DURING the move.
mgc.handleInput('parent', demand_m3s).catch(() => {});
// Give the dispatcher a microtask + tick to plan, then dump the
// schedule so we can see WHAT the planner produced (vs. what the
// executor actually does).
await sleep(60);
const sched = mgc.movementExecutor.schedule();
console.log(`\nPlanner schedule (tStar=${sched?.tStarS?.toFixed(2)}s, ${sched?.commands?.length} cmds):`);
for (const c of (sched?.commands || [])) {
console.log(` ${c.machineId.padEnd(8)} ${c.action.padEnd(13)} ${c.sequence ?? ('flow=' + (c.flow?.toFixed(1) ?? 'n/a')).padEnd(12)} fireAtTickN=${c.fireAtTickN} eta=${c.eta?.toFixed(2)}s`);
}
// Sample for 8 seconds at 200 ms — long enough for tStar ≈ 3.5 s + ramp.
const traj = await sampleFlows(pumps, 8000, 200);
printTrace('Per-pump flow trajectory', traj, demand_m3h);
// ── Question (a): does sum-of-flows converge to demand? ────────────
const finalSum = traj[traj.length - 1].sum;
const tolAbs = demand_m3h * 0.05; // 5% tolerance
console.log(`\nFinal ΣQ = ${finalSum.toFixed(1)} m³/h vs demand ${demand_m3h.toFixed(1)} m³/h (tol ±${tolAbs.toFixed(1)})`);
assert.ok(
Math.abs(finalSum - demand_m3h) <= tolAbs,
`(a) CONVERGENCE FAILED: final ΣQ=${finalSum.toFixed(1)} m³/h, demand=${demand_m3h.toFixed(1)} m³/h, err=${(finalSum - demand_m3h).toFixed(1)} m³/h (>${tolAbs.toFixed(1)})`,
);
// ── Question (b): same-time landing? ───────────────────────────────
//
// For each pump, find when its flow first reached a stable value (its
// own steady-state target). Compare the spread across the three pumps:
// if they "land together", all arrival indices are within ~1 sample.
const sampleTargets = pumps.map((_, i) => {
// Use the LAST sample's flow as that pump's actual landing value.
// We're measuring "when did this pump stop moving" not "did it hit
// some externally-specified target" — that's what same-time-landing
// is about.
return traj[traj.length - 1].perPump[i];
});
const arrivalIdx = pumps.map((_, i) => {
const series = traj.map((s) => s.perPump[i]);
const tgt = sampleTargets[i];
const tol = Math.max(2.0, Math.abs(tgt) * 0.05); // 5% or 2 m³/h, whichever larger
return arrivalTimeMs(series, tgt, tol);
});
console.log('\nArrival index per pump (sample # where flow stabilises within 5%):');
for (let i = 0; i < pumps.length; i++) {
const idx = arrivalIdx[i];
const t = idx == null ? 'NEVER' : `${(traj[idx].tMs / 1000).toFixed(2)} s`;
console.log(` ${pumps[i].config.general.id}: idx=${idx}, t=${t}, finalQ=${sampleTargets[i].toFixed(1)} m³/h`);
}
const validIdx = arrivalIdx.filter((x) => x != null);
assert.equal(validIdx.length, N_PUMPS, '(b) one or more pumps never landed on a stable flow');
const spreadSamples = Math.max(...validIdx) - Math.min(...validIdx);
const spreadMs = spreadSamples * 200;
console.log(`Same-time-landing spread: ${spreadSamples} samples = ${spreadMs} ms`);
// Loose bound: within 1.5 s. A bigger spread means the schedule did
// NOT bring the pumps to their setpoints together.
assert.ok(
spreadMs <= 1500,
`(b) SAME-TIME LANDING FAILED: pumps landed ${spreadMs} ms apart (>1500 ms tolerance). ` +
`This means flow-INCREASING moves on running pumps land BEFORE startup pumps reach operational.`,
);
});

View File

@@ -0,0 +1,210 @@
// MGC + planner end-to-end integration. Proves the timing-aware
// rendezvous schedule actually fires on real rotatingMachine objects
// (not just the abstract scheduler unit tests).
//
// Layout mirrors idle-startup-deadlock.integration.test.js: three real
// pump objects, a real MGC, registration via childRegistrationUtils. The
// difference: instead of asserting end-state, we tap into the executor's
// schedule + intercept fireCommand to record exact ordering.
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
const Machine = require('../../../rotatingMachine/src/specificClass');
const HEAD_MBAR_UP = 0;
const HEAD_MBAR_DOWN = 1100;
const N_PUMPS = 3;
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
const stateConfig = {
general: { logging: logCfg },
state: { current: 'idle' },
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
time: { starting: 1, warmingup: 2, stopping: 1, coolingdown: 2 },
};
function machineConfig(id) {
return {
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
allowedSources: { auto: ['parent', 'GUI'] },
},
sequences: {
startup: ['starting', 'warmingup', 'operational'],
shutdown: ['stopping', 'coolingdown', 'idle'],
emergencystop: ['emergencystop', 'off'],
},
};
}
function groupConfig() {
return {
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
mode: { current: 'optimalcontrol' },
};
}
function pctToCanonical(mgc, pct) {
if (pct < 0) return -1;
const dt = mgc.calcDynamicTotals();
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
}
function buildGroup() {
const mgc = new MachineGroup(groupConfig());
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
const pumps = ids.map((id) => new Machine(machineConfig(id), stateConfig));
for (const m of pumps) {
m.updateMeasuredPressure(HEAD_MBAR_UP, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
m.updateMeasuredPressure(HEAD_MBAR_DOWN, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
mgc.childRegistrationUtils.registerChild(m, 'downstream');
}
mgc.calcAbsoluteTotals();
mgc.calcDynamicTotals();
return { mgc, pumps };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Wrap the MGC's executor.fireCommand so we record every command in
// timing order. Replaces the actual fireCommand so the test stays
// hermetic (pumps don't actually move — we just verify the SCHEDULE).
function tapExecutor(mgc) {
const log = [];
const originalFire = mgc.movementExecutor._fireCommand;
mgc.movementExecutor._fireCommand = (cmd) => {
log.push({ ...cmd, firedAtMs: Date.now() });
// Still call the original so the FSM moves and the test stays realistic.
try { originalFire(cmd); } catch (_) { /* ignore */ }
};
return log;
}
// ── Tests ───────────────────────────────────────────────────────────────
test('planner-integration: idle group → demand brings up all 3 pumps in lockstep', async () => {
const { mgc, pumps } = buildGroup();
const log = tapExecutor(mgc);
// 100% demand from idle → optimizer picks a 3-pump combination.
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
// Wait one tick so the executor's setInterval-driven follow-up ticks
// (if any) have a chance to fire. Three-pump symmetric startup has
// identical etas → tStar = max(eta) = eta itself → all commands at
// fireAtTickN=0 → all fire synchronously.
await sleep(50);
const startupCmds = log.filter((c) => c.action === 'execsequence' && c.sequence === 'startup');
const flowCmds = log.filter((c) => c.action === 'flowmovement');
assert.equal(startupCmds.length, N_PUMPS, 'one startup per pump');
assert.equal(flowCmds.length, N_PUMPS, 'one flowmovement per pump (queued via delayedMove)');
// All startups must be fired in the same tick — i.e. roughly the same
// wall-clock instant (within a few ms).
const spread = Math.max(...startupCmds.map((c) => c.firedAtMs)) - Math.min(...startupCmds.map((c) => c.firedAtMs));
assert.ok(spread < 50, `startup spread too wide: ${spread}ms`);
});
test('planner-integration: rendezvous — startup pump fires immediately, retarget on running pump is delayed', async () => {
// Bring up two pumps first; then change demand so the third pump
// starts AND the two existing pumps shed load. The two running pumps'
// flowmovement should be delayed so they land at the rendezvous time
// matching the third pump's startup completion.
const { mgc, pumps } = buildGroup();
// Phase 1: low demand so optimizer picks a sub-set of pumps and at
// least one stays idle. We try a few decreasing values until we find
// one that leaves an idle pump (optimizer's combination choice is
// sensitive to curve/pressure, hard to predict precisely).
let idlePumpFound = false;
for (const pct of [30, 20, 10, 5, 1]) {
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(() => {});
await sleep(4500);
const states0 = pumps.map((p) => p.state.getCurrentState());
if (states0.includes('idle')) { idlePumpFound = true; break; }
}
if (!idlePumpFound) {
const finalStates = pumps.map((p) => p.state.getCurrentState());
console.log(` (skipping) optimizer always picked all 3 pumps even at low demand: ${finalStates.join(',')}`);
return; // optimizer behaviour denies us the scenario — not a failure of the planner.
}
// Start tapping AFTER the first ramp settles — we only care about
// the schedule from the next dispatch.
const log = tapExecutor(mgc);
// Phase 2: drive to 100%. Now optimizer wants all 3 pumps. The idle
// pump needs full startup; existing pumps adjust their flow.
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
// Wait long enough for the executor's wall-clock ticks to fire
// delayed commands. tStar can be up to startingS + warmingupS + ramp
// = 1 + 2 + 0.5 = 3.5s.
await sleep(5000);
const startupCmds = log.filter((c) => c.action === 'execsequence' && c.sequence === 'startup');
const flowCmds = log.filter((c) => c.action === 'flowmovement');
// We expect: at least one startup (for the idle pump) AND flow
// adjustments on the running pumps. The exact split depends on
// optimizer behaviour, so assert loosely.
assert.ok(startupCmds.length >= 1, 'at least one startup expected for the idle pump');
assert.ok(flowCmds.length >= 1, 'at least one flowmovement expected');
// The schedule snapshot stored on the executor should record a
// positive tStar (rendezvous time).
const lastSchedule = mgc.movementExecutor.schedule();
assert.ok(lastSchedule, 'executor schedule should be set');
// The schedule should have at least one increasing eta (the startup),
// which sets tStar > 0.
assert.ok(lastSchedule.tStarS > 0, `tStar should be > 0 when a startup is in the plan; got ${lastSchedule.tStarS}`);
// If any flowmovement on an EXISTING (then-operational) pump was a
// down-move, its fireAtTickN should be > 0 (delayed). Find any such
// command in the schedule.
const delayedDownMoves = lastSchedule.commands.filter((c) => c.action === 'flowmovement' && c.fireAtTickN > 0);
// Note: this assertion is "expected on most runs" rather than
// "guaranteed every time" — depends on whether the optimizer picks a
// combination that requires existing pumps to reduce. We assert the
// schedule SHAPE (positive tStar) and accept that delayed-down moves
// are common-but-not-mandatory.
if (delayedDownMoves.length === 0) {
// Surface a debug print if the run didn't exercise delayed moves —
// helps when reading test logs to know what happened.
console.log(' (planner-integration) note: no delayed down-moves this run — combination may have been all-up.');
}
});
test('planner-integration: replan drops unfired commands when a new demand arrives', async () => {
const { mgc, pumps } = buildGroup();
const log = tapExecutor(mgc);
// First demand: 100% from idle. tStar will be ~3.5s; all startup
// cmds fire at tick 0 (synchronous), but if there were any delayed
// down-moves, they'd be in the schedule.
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
await sleep(100);
const firstSnapshot = mgc.movementExecutor.schedule().commands.length;
// Immediately fire a second demand: 50%. Replan happens; some unfired
// commands from the first schedule get dropped.
mgc.handleInput('parent', pctToCanonical(mgc, 50)).catch(() => {});
await sleep(100);
// Schedule was replaced.
const secondSnapshot = mgc.movementExecutor.schedule();
assert.ok(secondSnapshot, 'executor schedule replaced after replan');
// Cursor reset to a low value (≤ a couple of ticks from the replan).
assert.ok(mgc.movementExecutor.cursor() <= 2, `cursor should reset on replan; got ${mgc.movementExecutor.cursor()}`);
// Sanity: replan didn't blow up the executor.
assert.ok(firstSnapshot > 0, 'first dispatch should have queued at least one command');
});

View File

@@ -1,18 +1,30 @@
# machineGroupControl
> **Reflects code as of `7d19fc1` · regenerated `2026-05-11` via `npm run wiki:all`**
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
![code-ref](https://img.shields.io/badge/code--ref-26e92b5-blue) ![s88](https://img.shields.io/badge/S88-Unit-50a8d9) ![status](https://img.shields.io/badge/status-trial--ready-brightgreen)
## 1. What this node is
A `machineGroupControl` (MGC) coordinates two or more `rotatingMachine` children that share a common header. It accepts an operator demand setpoint, enumerates the valid pump combinations against the group's live flow/power envelope, picks the best operating point (BEP-Gravitation by default), and schedules per-machine flow setpoints + start/stop commands with **timing-aware rendezvous** so the running aggregate stays close to demand during transitions.
**machineGroupControl (MGC)** is an S88 Unit orchestrator that coordinates multiple `rotatingMachine` children sharing a common header. It receives a demand setpoint, evaluates valid pump combinations against the group's totals and curves, picks the best operating point (BEP-Gravitation or NCog), and dispatches per-machine flow setpoints + start/stop commands.
---
## 2. Position in the platform
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | A pump group sharing one suction + one discharge header |
| S88 level | Unit |
| Use it when | You have 2&nbsp;+ pumps that can substitute for each other on the same header and you want efficient load-sharing |
| Don't use it for | A single pump (wire `rotatingMachine` directly), valves (use `valveGroupControl`), or pumps living behind independent headers |
| Children it accepts | `machine` (rotatingMachine), `measurement` (pressure / others) |
| Parent it talks to | `pumpingStation` (typical), or any node that issues `set.demand` |
---
## How it fits
```mermaid
flowchart LR
parent[pumpingStation<br/>Process Cell]:::pc -->|set.demand| mgc[machineGroupControl<br/>Unit]:::unit
header[measurement<br/>header pressure]:::ctrl -.data.-> mgc
header[measurement<br/>header pressure]:::ctrl -.measured.-> mgc
mgc -->|flowmovement / execsequence| m_a[rotatingMachine A]:::equip
mgc -->|flowmovement / execsequence| m_b[rotatingMachine B]:::equip
mgc -->|flowmovement / execsequence| m_c[rotatingMachine C]:::equip
@@ -26,259 +38,111 @@ flowchart LR
classDef ctrl fill:#a9daee,color:#000
```
S88 colours: Process Cell `#0c99d9`, Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`.
## 3. Capability matrix
---
| Capability | Status | Notes |
|---|---|---|
| Aggregate group flow / power totals | ✅ | `TotalsCalculator` — absolute and dynamic. |
| Valid-combination enumeration | ✅ | `combinatorics/pumpCombinations`. |
| Best-combination optimiser (BEP-Gravitation) | ✅ | Directional or symmetric variant. |
| Best-combination optimiser (NCog) | ✅ | Normalised cost-of-goods score. |
| Priority / equal-flow control | ✅ | `mode='prioritycontrol'`. |
| Priority percentage control | ✅ | Requires `scaling='normalized'`. |
| Optimal control | ✅ | `mode='optimalcontrol'`. |
| Group efficiency + BEP distance | ✅ | `GroupEfficiency`. |
| Header-pressure equalisation | ✅ | `operatingPoint.equalize()`. |
| Demand serialisation (latest-wins) | ✅ | `DemandDispatcher` / `LatestWinsGate.fireAndWait`. |
| Forced shutdown on `Qd ≤ 0` | ✅ | `turnOffAllMachines()`. |
## Try it &mdash; 3-minute demo
## 4. Code map
Import the basic example flow, deploy, and watch three pumps come online together when demand rises.
```mermaid
flowchart TB
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
nc["buildDomainConfig()<br/>static DomainClass, commands"]
end
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
sc["MachineGroup.configure()<br/>ChildRouter rules<br/>handleInput() dispatch gate"]
end
subgraph concerns["src/ concern modules"]
groupOps["groupOps/<br/>GroupOperatingPoint + curves"]
totals["totals/<br/>TotalsCalculator"]
combi["combinatorics/<br/>validPumpCombinations"]
opt["optimizer/<br/>BEP-Grav / NCog selectors"]
efficiency["efficiency/<br/>GroupEfficiency + BEP dist"]
ctrl["control/<br/>strategies (equalFlow / prioPct)"]
dispatch["dispatch/<br/>DemandDispatcher (LatestWinsGate)"]
io["io/<br/>output + status"]
commands["commands/<br/>topic registry + handlers"]
end
nc --> sc
sc --> groupOps
sc --> totals
sc --> combi
sc --> opt
sc --> efficiency
sc --> ctrl
sc --> dispatch
sc --> io
nc --> commands
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/machineGroupControl/examples/01-Basic.json \
http://localhost:1880/flow
```
| Module | Owns | Read first if you're changing… |
|---|---|---|
| `groupOps/` | Group operating point + child read helpers | Header pressure handling, child measurement plumbing. |
| `totals/` | Absolute + dynamic flow/power totals | Demand clamping, totals math. |
| `combinatorics/` | Enumeration of valid pump subsets | Which combinations are considered eligible. |
| `optimizer/` | Best-combination selectors | Optimiser selection method, scoring math. |
| `efficiency/` | Group efficiency, BEP distance | BEP gravitation tuning, peak math. |
| `control/strategies.js` | Per-mode dispatch (priority, prioPct) | Mode behaviour, priorityList usage. |
| `dispatch/` | `DemandDispatcher` wrapping `LatestWinsGate.fireAndWait` | Demand serialisation, mid-flight overrides. |
| `commands/` | Input-topic registry and handlers | New input topics, payload validation. |
| `io/` | `getOutput`, `getStatusBadge` | Output shape, dashboard badge. |
What to click in the dashboard after deploy:
## 5. Topic contract
1. The Setup group auto-fires `virtualControl` + `cmd.startup` on each child pump after ~1.5&nbsp;s.
2. `set.demand = 50` (bare number = percent of group capacity) &rarr; MGC picks the best 1- or 2-pump combination by BEP-Gravitation.
3. `set.demand = { value: 80, unit: "m3/h" }` &rarr; absolute-flow setpoint.
4. `set.mode = priorityControl` &rarr; equal-flow distribution by priority order.
5. `set.demand = -1` &rarr; operator stop-all; `turnOffAllMachines` cancels any pending dispatch and shuts every active pump down.
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
> [!IMPORTANT]
> **GIF needed.** Demo recording of demand 50 % &rarr; 100 % &rarr; -1 with the live status panel. Save as `wiki/_partial-gifs/machineGroupControl/01-basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
<!-- BEGIN AUTOGEN: topic-contract -->
---
| Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---|---|
| `set.mode` | `setMode` | `string` | — | Switch the machine group between auto / manual modes. |
| `set.scaling` | `setScaling` | `string` | — | Select the group scaling strategy. |
| `child.register` | `registerChild` | `string` | — | Register a child machine with this group. |
| `set.demand` | `Qd` | `any` | `volumeFlowRate` (default `m3/h`) | Operator demand setpoint dispatched to the child machines. |
## The three things you'll send
<!-- END AUTOGEN: topic-contract -->
`set.demand` is **unit-self-describing** &mdash; the payload itself decides how the value is interpreted. There is no persistent `scaling` state on the orchestrator.
## 6. Child registration
| Topic | Aliases | Payload | What it does |
|:---|:---|:---|:---|
| `set.mode` | `setMode` | `"optimalControl"` \| `"priorityControl"` \| `"maintenance"` | Switches dispatch strategy. `maintenance` is monitoring-only. |
| `set.demand` | `Qd` | bare number = %; `{value, unit}` for absolute units (`m3/h`, `l/s`, `m3/s`, &hellip;); negative = stop all | Operator demand setpoint. Resolves to canonical m³/s before dispatch. |
| `child.register` | `registerChild` | child node id (string) | Manually register a child (Port 2 wiring does this automatically in most flows). |
`ChildRouter` declarations in `specificClass.js → configure()`.
---
## What you'll see come out
Sample Port 0 message (delta-compressed &mdash; only changed fields each tick):
```json
{
"topic": "machineGroupControl#MGC1",
"payload": {
"mode": "optimalControl",
"atEquipment_predicted_flow": 42.5,
"downstream_predicted_flow": 42.5,
"atEquipment_predicted_power": 18.0,
"headerDiffPa": 145000,
"headerDiffMbar": 1450,
"flowCapacityMax": 90,
"flowCapacityMin": 6,
"machineCount": 3,
"machineCountActive": 2,
"absDistFromPeak": 0.02,
"relDistFromPeak": 0.10
}
}
```
| Field | Meaning |
|:---|:---|
| `mode` | Current dispatch mode. |
| `atEquipment_predicted_flow` / `_power` | Group aggregate at the pump shafts. The optimizer writes intent here; `handlePressureChange` keeps it in sync with the live totals. |
| `downstream_predicted_flow` | Live aggregate mirrored onto DOWNSTREAM &mdash; pumpingStation parents subscribe here. |
| `headerDiffPa` / `headerDiffMbar` | Last header differential the equalizer resolved. Dashboards use it for Q-H plots without re-reading every child. |
| `flowCapacityMax` / `flowCapacityMin` | The group's dynamic envelope at the current header pressure. Defines where `set.demand` (as %) maps to. |
| `machineCount` / `machineCountActive` | All registered children, and how many are in a state other than `off` / `maintenance`. |
| `absDistFromPeak` / `relDistFromPeak` | Distance from group BEP. `relDistFromPeak` is `undefined` when the η spread collapses (homogeneous pump group). |
The key shape is `<position>_<variant>_<type>` &mdash; the inverse of `rotatingMachine`'s `<type>.<variant>.<position>.<childId>` key shape, because MGC's output is the group aggregate, not a per-child snapshot.
---
## The new bit &mdash; the movement planner
When MGC computes a new optimal combination it doesn't fan the commands out instantly. It builds a **schedule** that times each command so the running aggregate stays close to demand during the transition.
```mermaid
flowchart LR
subgraph kids["accepted children (softwareType)"]
mach["machine<br/>(rotatingMachine)"]:::equip
m["measurement<br/>(header pressure)"]:::ctrl
end
mach -->|"pressure.measured.downstream<br/>pressure.measured.differential<br/>flow.predicted.downstream"| eq[operatingPoint.equalize<br/>+ totals refresh]
m -->|"&lt;type&gt;.measured.&lt;position&gt;"| mirror[mirror into own<br/>MeasurementContainer]
mirror -->|"if type === 'pressure'"| eq
eq --> emit[notifyOutputChanged]
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
demand[set.demand] --> dispatch[_runDispatch<br/>latest-wins]
dispatch --> abort[abortActiveMovements]
abort --> opt[optimizer.calcBestCombination*]
opt --> profiles[buildProfile<br/>x children]
profiles --> plan[movementScheduler.plan<br/>rendezvous t* = max&#40;eta_i&#41;]
plan --> exec[movementExecutor.replan<br/>+ await tick&#40;&#41;]
exec --> kids[rotatingMachine x N<br/>flowmovement / execsequence]
```
| softwareType | filter / subscribed events | Side-effect |
|---|---|---|
| `machine` | onRegister stores in `this.machines[id]`; subscribes to `pressure.measured.downstream`, `pressure.measured.differential`, `flow.predicted.downstream` | `handlePressureChange()` — equalise + recompute totals + recompute group efficiency. |
| `measurement` | onRegister attaches listener for `<asset.type>.measured.<positionVsParent>` | Mirror value into MGC's own MeasurementContainer; pressure also triggers `handlePressureChange()`. |
The planner classifies each pump's required move (`startup` / `flowmove` / `shutdown` / `noop`), computes an ETA per move via `MoveTrajectory`, sets the rendezvous time `t* = max(eta_i)` over flow-INCREASING moves, and delays flow-DECREASING moves so they FINISH at `t*`. Net effect: the sum of flows tracks the demand smoothly during the transition; on overshoot the header pressure rises and self-corrects.
## 7. Lifecycle — what one event does
This path is exercised in `optimalControl` mode. `priorityControl` mode still uses the legacy direct-dispatch path (`control.equalFlowControl`) &mdash; the planner has not been wired through there yet.
```mermaid
sequenceDiagram
participant parent as pumpingStation
participant mgc as MGC
participant op as GroupOperatingPoint
participant tot as TotalsCalculator
participant opt as optimizer
participant kids as rotatingMachine[]
---
parent->>mgc: set.demand (Qd)
Note over mgc: dispatch gate — latest-wins
mgc->>mgc: abortActiveMovements('new demand')
mgc->>tot: calcDynamicTotals()
mgc->>mgc: clamp Qd to [minFlow, maxFlow]
alt mode=optimalcontrol
mgc->>mgc: validPumpCombinations(Qd)
mgc->>opt: pick best (BEP-Grav | NCog)
opt-->>mgc: bestCombination + bestFlow/Power
mgc->>kids: flowmovement (per-pump flow)
mgc->>kids: execsequence (startup / shutdown)
else mode=prioritycontrol
mgc->>mgc: equalFlowControl(Qd, powerCap, priorityList)
end
mgc->>op: writeOwn flow/power predicted (AT_EQUIPMENT + DOWNSTREAM)
mgc->>mgc: notifyOutputChanged()
```
## Need more?
## 8. Data model — `getOutput()`
| Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Topic registry, config schema, child registration filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals, output ports |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows, debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | When not to use, known issues, open questions |
What lands on Port 0. Composed in `io/output.js → getOutput(this)` and delta-compressed by `outputUtils.formatMsg`.
<!-- BEGIN AUTOGEN: data-model -->
| Key | Type | Unit | Sample |
|---|---|---|---|
| `absDistFromPeak` | number | — | `0` |
| `mode` | string | — | `"optimalcontrol"` |
| `relDistFromPeak` | number | — | `0` |
| `scaling` | string | — | `"normalized"` |
<!-- END AUTOGEN: data-model -->
**Concrete sample** (excerpt — see live test output for the canonical shape):
~~~json
{
"mode": "optimalcontrol",
"scaling": "normalized",
"atEquipment_predicted_flow": 42.5,
"downstream_predicted_flow": 42.5,
"atEquipment_predicted_power": 18.0,
"atEquipment_predicted_efficiency": 0.65,
"atEquipment_predicted_Ncog": 1.23,
"absDistFromPeak": 0.02,
"relDistFromPeak": 0.10
}
~~~
Key format from `io/output.js`: `<position>_<variant>_<type>` (e.g. `atEquipment_predicted_flow`). Output units: flow in `m3/h`, power in `kW`, pressure in `mbar`.
## 9. Configuration — editor form ↔ config keys
```mermaid
flowchart TB
subgraph editor["Node-RED editor form"]
f1[Control mode dropdown]
f2[Scaling dropdown]
f3[Optimisation method]
f4[Output unit (flow)]
f5[Position vs parent]
f6[Allowed sources / actions per mode]
end
subgraph cfg["Domain config slice"]
c1[mode.current]
c2[scaling.current]
c3[optimization.method]
c4[general.unit]
c5[functionality.positionVsParent]
c6[mode.allowedSources<br/>mode.allowedActions]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
f6 --> c6
```
| Form field | Config key | Default | Range | Where used |
|---|---|---|---|---|
| Control mode | `mode.current` | `optimalControl` | enum (`prioritycontrol`, `prioritypercentagecontrol`, `optimalcontrol`) | dispatch switch in `_runDispatch` |
| Scaling | `scaling.current` | `normalized` | enum (`absolute`, `normalized`) | demand mapping in `_runDispatch` |
| Optimisation method | `optimization.method` | `BEP-Gravitation-Directional` | enum (`NCog`, `BEP-Gravitation`, `BEP-Gravitation-Directional`) | `_optimalControl` selector |
| Output unit (flow) | `general.unit` | `m3/h` | unit string | unit policy `output.flow` |
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | enum | event suffix for parent subscription |
## 10. State chart
MGC is **event-driven and stateless** with respect to operating modes — there is no FSM. The closest thing to "state" is the dispatch gate. Diagram for that single state vector:
```mermaid
stateDiagram-v2
[*] --> idle_disp: configure()
idle_disp --> dispatching: handleInput(Qd)
dispatching --> idle_disp: dispatch complete
dispatching --> dispatching: handleInput(Qd) — deferred and re-fired on completion
dispatching --> turning_off: Qd <= 0
turning_off --> idle_disp: all machines acknowledged shutdown
```
While `dispatching`, additional `handleInput` calls are absorbed by `DemandDispatcher` (latest-wins). A superseded call resolves with `{ superseded: true }`. `turnOffAllMachines()` calls `cancelPending()` so turn-off is always the final intent.
## 11. Examples
| Tier | File | What it shows |
|---|---|---|
| 1 | `examples/01-Basic.json` | One MGC + three `rotatingMachine` pumps driven by inject buttons. Setup auto-fires `virtualControl` + `cmd.startup` on all three pumps; numbered driver groups for mode / scaling / demand. |
| 2 | `examples/02-Dashboard.json` | Same command surface driven by a FlowFuse Dashboard 2.0 page — Mode + Scaling buttons, Demand slider, live Status rows (mode / scaling / total flow / total power / capacity / active machines / BEP %), three trend charts, and a raw-output table. |
See [`examples/README.md`](https://gitea.wbd-rd.nl/RnD/machineGroupControl/src/branch/development/examples/README.md) for the canonical command surface table and step-by-step "what to try" recipes.
> [!IMPORTANT]
> **Screenshots needed.** Capture both flows in the editor + the rendered dashboard. Save under `wiki/_partial-screenshots/machineGroupControl/` as `01-basic-flow.png`, `02-dashboard-editor.png`, `03-dashboard-rendered.png`. Replace this callout with the image links.
## 12. Debug recipes
| Symptom | First thing to check | Where to look |
|---|---|---|
| No combination selected | Demand outside `[dynamicTotals.flow.min, max]` — clamped on entry; `_optimalControl` returns early if combinations empty. | `validPumpCombinations` + warn log. |
| Group flow stuck at zero | Machines never reach an `ACTIVE_STATE` — check per-pump startup logs. | `isMachineActive`. |
| Priority-percentage mode warns and exits | Mode requires `scaling='normalized'`. Set both. | `_runDispatch` switch. |
| Stale flow setpoints on chained calls | Dispatch gate may have superseded intermediate calls — callers should check `result.superseded`. | `DemandDispatcher` / `LatestWinsGate`. |
| Header pressure not equalising | Pressure children must register with `asset.type='pressure'` and a matching position. | `operatingPoint.equalize`. |
| Optimiser picks unexpected combo | Verify `optimization.method` and per-method scoring (NCog vs BEP-Grav). | `optimizer/`. |
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors.
## 13. When you would NOT use this node
- Don't use MGC for a **single pump** — wire `rotatingMachine` directly. MGC's combinatorics + totals add no value below N=2.
- Don't use MGC for **valves** — use `valveGroupControl`. MGC's optimiser assumes a flow-vs-pressure characteristic curve.
- Don't use MGC when the pumps live behind **independent headers** — combinations assume a shared discharge / suction pressure.
## 14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | `optimalControl` requires every machine to expose a curve — null-curve members silently exclude themselves from combinations. | `combinatorics/pumpCombinations`. |
| 2 | Mid-flight setpoint overrides on `accelerating` / `decelerating` rely on `abortActiveMovements` per dispatch — a sequence with no awaitable `abortMovement` will warn but proceed. | `abortActiveMovements`. |
| 3 | Power-cap parameter exposed but not surfaced as a topic input — only programmatic via `handleInput(source, demand, powerCap)`. | `commands/index.js` — no canonical topic. |
| 4 | Per-pump fan-out for dashboard charts (per-machine flow / power series) not surfaced from MGC's Port 0 — only group aggregates appear. Subscribe to each rotatingMachine's Port 0 if you need per-pump trends. | `io/output.js` aggregates only. |
| 5 | **`maxEfficiency` naming bug** — `GroupEfficiency.calcGroupEfficiency` returns `{ maxEfficiency, lowestEfficiency }` but `maxEfficiency` is actually the **mean cog** across all machines (not the maximum). The name is deliberately preserved for behavioural parity; callers using it as "the peak" will over-estimate the BEP target. | `efficiency/groupEfficiency.js` comment + `OPEN_QUESTIONS.md` 2026-05-10. |
| 6 | **`calcAbsoluteTotals` implicit pressure-key coupling** — iterates `machine.predictFlow.inputCurve` and re-uses the same pressure key to index `machine.predictPower.inputCurve[pressure]`. If the two curves were sampled at different pressures the lookup is `undefined` and the call throws. Enforcement or defensive skip deferred to P5 (rotatingMachine curveLoader). | `totals/totalsCalculator.js` + `OPEN_QUESTIONS.md` 2026-05-10. |
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)

View File

@@ -0,0 +1,261 @@
# Reference &mdash; Architecture
![code-ref](https://img.shields.io/badge/code--ref-26e92b5-blue)
> [!NOTE]
> Code structure for `machineGroupControl`: the three-tier sandwich, the `src/` layout, the dispatch lifecycle, the movement planner that fans commands out, and the output-port pipeline. Everything here is reproducible from `src/`. For an intuitive overview, return to [Home](Home).
---
## Three-tier code layout
```
nodes/machineGroupControl/
|
+-- mgc.js entry: RED.nodes.registerType('machineGroupControl', NodeClass)
|
+-- src/
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
| specificClass.js extends BaseDomain (orchestration only)
| |
| +-- commands/
| | index.js topic descriptors
| | handlers.js pure handler functions (unit-self-describing set.demand)
| |
| +-- groupOps/
| | groupOperatingPoint.js header equalisation + child read helpers
| | groupCurves.js per-machine curve adapters used by optimizer + strategies
| |
| +-- totals/
| | totalsCalculator.js absolute, dynamic, and active envelopes
| |
| +-- combinatorics/
| | pumpCombinations.js enumerate valid pump subsets that can deliver Qd
| |
| +-- optimizer/
| | index.js selector (CoG vs BEP-Gravitation variants)
| | bestCombination.js N-CoG optimizer
| | bepGravitation.js BEP-Gravitation (+ Directional variant)
| |
| +-- efficiency/
| | groupEfficiency.js group η, BEP distance (abs + relative)
| |
| +-- control/
| | strategies.js equalFlowControl (priority mode legacy direct dispatch)
| |
| +-- dispatch/
| | demandDispatcher.js thin wrapper over LatestWinsGate.fireAndWait
| |
| +-- movement/
| | machineProfile.js pure snapshot of a registered child for the planner
| | moveTrajectory.js per-pump ETA-to-target math
| | movementScheduler.js rendezvous planner (pure)
| | movementExecutor.js tick-driven, async-aware command firer
| |
| +-- io/
| output.js getOutput() shape + status badge
```
### Tier responsibilities
| Tier | File | What it owns | Touches `RED.*` |
|:---|:---|:---|:---:|
| entry | `mgc.js` | Type registration | Yes |
| nodeClass | `src/nodeClass.js` | Input routing, output ports, status badge polling (`statusInterval=1000`). No tick loop &mdash; event-driven. | Yes |
| specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; route demand through `DemandDispatcher`; pick mode in `_runDispatch`; own the planner's wall-clock driver. | No |
`specificClass` is stitching. All real work lives in the concern modules: pure math in `combinatorics/`, `optimizer/`, `efficiency/`, `movement/{moveTrajectory,movementScheduler}`; live-state-touching in `groupOps/`, `totals/`, `control/`, `dispatch/`, `movement/movementExecutor`.
---
## The dispatch lifecycle
```mermaid
sequenceDiagram
autonumber
participant parent as pumpingStation / UI
participant gate as DemandDispatcher (LatestWinsGate)
participant disp as _runDispatch
participant abort as abortActiveMovements
participant opt as optimizer
participant plan as movementScheduler
participant exec as movementExecutor
participant kids as rotatingMachine[]
parent->>gate: handleInput(Qd)
Note over gate: latest-wins:<br/>parked demand is dropped if a fresher one arrives
gate->>disp: payload.demand = canonical m³/s
disp->>abort: abortActiveMovements('new demand')
disp->>disp: calcDynamicTotals + clamp Qd to envelope
alt mode = optimalControl
disp->>opt: pickOptimizer(method).calcBestCombination*
opt-->>disp: bestCombination + bestFlow / bestPower / bestCog
disp->>plan: plan(profiles, combination, headerDiffPa)
plan-->>disp: schedule {tStarS, tickS, commands[]}
disp->>exec: replan(schedule)
disp->>exec: await tick() (FIRST tick, synchronous race-favouring)
Note over exec: setInterval(1000ms) drives further ticks<br/>auto-stops when pending() == 0
else mode = priorityControl
disp->>disp: control.equalFlowControl(ctx, Qd, powerCap, priorityList)
Note over disp: Legacy direct fan-out:<br/>await Promise.all(...handleInput...)
end
exec->>kids: flowmovement / execsequence (per scheduled tick)
disp->>disp: handlePressureChange-style refresh<br/>notifyOutputChanged
```
Key facts the diagram pins down:
| Fact | Why it matters |
|:---|:---|
| Demand serialisation is **latest-wins**, not FIFO | A burst of demand updates collapses to a single dispatch. Parked demands resolve with `{ superseded: true }` so callers can branch on it. |
| `abortActiveMovements` only aborts pumps in `accelerating` / `decelerating` | Warmup / cooldown are protected at the pump's FSM; aborting them is silently ignored there. |
| `_runDispatch` **awaits the first executor tick** | Synchronous first-tick fire gives the new move's residue-handler priority over an in-flight shutdown sequence's for-loop. Fire-and-forget would lose the race in real wall-clock conditions. |
| The 1&nbsp;Hz `setInterval` only runs while `executor.pending() > 0` | Idle MGCs don't burn a forever-on timer. |
| Negative demand goes straight to `turnOffAllMachines` | And `turnOffAllMachines` calls `dispatcher.cancelPending` so a parked positive demand can't re-engage pumps post-shutdown. |
| `priorityControl` uses the legacy direct-dispatch path | The planner is not (yet) wired through `equalFlowControl`. See [Reference &mdash; Limitations](Reference-Limitations). |
---
## The movement planner
The planner is the new architectural layer between the optimizer and the children. It exists so that when MGC re-balances during transitions, the running aggregate flow stays close to demand instead of dipping while one pump warms up and another keeps spinning.
### 1. `buildProfile(child)` &mdash; pure read
A plain-object snapshot of a registered child machine. Returns:
| Field | Source | Notes |
|:---|:---|:---|
| `id` | `child.config.general.id` | |
| `state` | `child.state.getCurrentState()` | One of `idle`, `starting`, `warmingup`, `operational`, `accelerating`, `decelerating`, `stopping`, `coolingdown`, `off`, `emergencystop`, `maintenance`. |
| `position` | `child.state.getCurrentPosition()` | Control % (`0..100`). |
| `minPosition` / `maxPosition` | `child.state.movementManager` | |
| `velocityPctPerS` | `movementManager.getNormalizedSpeed() × range` | Movement ramp rate in position-units / second. |
| `timings` | `child.config.stateConfig.time` | `{startingS, warmingupS, stoppingS, coolingdownS}` &mdash; the configured durations the FSM spends in each timed state. |
| `remainingTransitionS` | `child.state.stateManager.getRemainingTransitionS()` | Wall-clock-aware remaining seconds in the current timed state. 0 for untimed states. |
| `flowAt(pos, pressure)` | `child.predictFlow.evaluate` | Forward curve (position → flow). |
| `positionForFlow(flow)` | `child.predictCtrl.y` | Inverse curve (flow → control %); mirrors what `flowController` does on a `flowmovement` command. |
No contract changes &mdash; MGC already holds the live child reference (`this.machines[id]`); the profile is just a read of that.
### 2. `MoveTrajectory` &mdash; per-pump ETA math
Given a profile and a `targetPosition`, `etaToTargetS()` returns seconds-to-target-flow:
| Current state | ETA |
|:---|:---|
| `idle` / `off` / `emergencystop` / `maintenance` | `startingS + warmingupS + (target minPosition) / velocity` |
| `operational` / `accelerating` / `decelerating` (post-abort residue) | `\|target position\| / velocity` |
| `warmingup` | `remainingTransitionS + (target minPosition) / velocity` |
| `starting` | `remainingTransitionS + warmingupS + (target minPosition) / velocity` |
| `stopping` / `coolingdown` | `null` &mdash; pump cannot contribute on this dispatch |
Velocity of 0 returns `Infinity` so the scheduler can demote the machine without crashing. Targets are clamped to `[minPosition, maxPosition]` at construction.
### 3. `movementScheduler.plan` &mdash; rendezvous
Pure function. Inputs: `(profiles[], combination, currentPressurePa, { tickS = 1 })`. Output:
```js
{
tStarS: 60, // rendezvous time in seconds
tickS: 1, // tick cadence
commands: [
{ machineId: 'A', action: 'execsequence', sequence: 'startup', fireAtTickN: 0, eta: 60 },
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 60 },
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 40, eta: 20 },
{ machineId: 'C', action: 'execsequence', sequence: 'shutdown', fireAtTickN: 55, eta: 5 }
],
_plans: [...] // per-machine classification + eta + direction; useful in tests
}
```
Algorithm:
1. **Classify** each machine's move against the optimizer's target flow:
- `targetFlow > 0` and pump off &rarr; `startup`
- `targetFlow > 0` and pump on (any active or startup-ladder state) &rarr; `flowmove`
- `targetFlow <= 0` and pump on &rarr; `shutdown`
- Otherwise &rarr; `noop`
2. **Direction**: compare target flow against the pump's current flow (via `profile.flowAt`). Increasing, decreasing, or unchanged.
3. **ETA**: `MoveTrajectory.etaToTargetS()` (or, for shutdowns, the position-ramp time to `minPosition`).
4. **Rendezvous**: `t* = max(eta_i)` over flow-INCREASING moves.
5. **Schedule**: increasing / unchanged moves fire at `fireAtTickN = 0`; decreasing moves fire at `fireAtTickN = round((t* eta_j) / tickS)` so they finish at `t*`.
Net behaviour: during a transition the flow sum tracks demand smoothly. On overshoot, header pressure rises and individual pumps deliver less &mdash; a self-correcting undershoot. On undershoot, demand simply lands a few ticks later than ideal.
### 4. `MovementExecutor` &mdash; tick-driven, async-aware
Holds the active schedule plus a cursor (`_cursor`) that advances one per `tick()`. Each tick fires every unfired command whose `fireAtTickN <= cursor` via an injected `fireCommand` callback. The callback returns a Promise (in production, the `machine.handleInput(...)` promise); `tick()` awaits all of those before resolving.
`replan(newSchedule)` replaces the schedule and resets the cursor to 0. Already-fired commands stay fired &mdash; the pump's FSM downstream owns their consequences; the executor never tries to "undo" a fired startup (which keeps warmup / cooldown safety intact).
Wall-clock driver lives on the MGC itself (`_ensureExecutorTimer`): a `setInterval(1000)` that calls `tick()` and clears itself when `pending() === 0`. `unref()` keeps the timer from blocking Node-RED shutdown.
### 5. The cooperating FSM change (in `rotatingMachine`)
For the planner to be robust, the pump's `executeSequence` honours a **sequence-abort token** that MGC's external aborts advance. Without this, an in-flight shutdown's for-loop would race against the new dispatch's residue handler and could win &mdash; transitioning `operational → stopping → coolingdown → idle` even after the new move took the FSM operational.
See the rotatingMachine wiki's [Architecture &mdash; FSM section](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Reference-Architecture#fsm) for the full mechanism. Summary:
- `state.abortCurrentMovement(reason, { returnToOperational: false })` &mdash; the default form, used by MGC's `abortActiveMovements` &mdash; increments `state.sequenceAbortToken`.
- `executeSequence` captures the token at entry and re-checks it before every state transition in its for-loop. A mismatch exits the loop early with a `Sequence '<name>' interrupted ... by external abort` warning.
- Sequence-internal aborts (`returnToOperational: true`, used when a fresher shutdown pre-empts its own setpoint ramp) do NOT advance the token. So the shutdown's own ramp-down to zero is interruptible without terminating the shutdown sequence itself.
---
## Output ports
| Port | Carries | Sample shape |
|:---|:---|:---|
| 0 (process) | Delta-compressed state snapshot &mdash; group aggregates, header diff, BEP distance, machine counts | `{topic, payload: {mode, atEquipment_predicted_flow, headerDiffPa, machineCountActive, ...}}` |
| 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | `machineGroupControl,id=MGC1 atEquipment_predicted_flow=42.5,... ` |
| 2 (register / control) | `child.register` upward at init | `{topic: 'child.register', payload: {ref, softwareType, config}}` |
Port-0 key shape is **`<position>_<variant>_<type>`** &mdash; group aggregates only. Per-pump series live on each `rotatingMachine`'s Port 0 (with the inverted `<type>.<variant>.<position>.<childId>` shape). Subscribe per-child if you need per-pump trends on a dashboard.
See [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the full InfluxDB layout.
---
## Event sources
| Source | Where it fires | What it triggers |
|:---|:---|:---|
| `setInterval(_executorIntervalMs = 1000)` | Driven by `_ensureExecutorTimer` after a successful `optimalControl` plan | `movementExecutor.tick()` |
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render |
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch to `set.mode` / `set.demand` / `child.register` |
| Child measurement event | `child.measurements.emitter` after a measurement landed | `handlePressureChange()` (for pressure) or value mirror (for everything else) |
| Child prediction event | `child.emitter` "flow.predicted.downstream" | `handlePressureChange()` |
| `child.register` from a pump | Port 2 of the pump | `onRegister('machine', ...)` &mdash; stores ref in `this.machines[id]` |
MGC has **no per-second tick of its own**. It's purely event-driven plus the planner's optional wall-clock executor.
---
## Where to start reading
| If you're changing... | Read first |
|:---|:---|
| The dispatch flow, latest-wins semantics, mode switch | `src/specificClass.js` `_runDispatch` (lines 318&ndash;349) |
| Topic registration, payload validation | `src/commands/index.js` + `src/commands/handlers.js` |
| Optimizer selection / scoring | `src/optimizer/index.js`, `bepGravitation.js`, `bestCombination.js` |
| Header-pressure equalisation | `src/groupOps/groupOperatingPoint.js` `equalize()` |
| Combination enumeration | `src/combinatorics/pumpCombinations.js` |
| Per-pump ETA, rendezvous math | `src/movement/moveTrajectory.js`, `movementScheduler.js` |
| Wall-clock tick wiring | `src/specificClass.js` `_ensureExecutorTimer` (lines 290&ndash;301) |
| Output shape, status badge | `src/io/output.js` |
| Priority-mode equal-flow distribution | `src/control/strategies.js` |
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home) | The child node: FSM, prediction, drift |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |

196
wiki/Reference-Contracts.md Normal file
View File

@@ -0,0 +1,196 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-26e92b5-blue)
> [!NOTE]
> Full topic contract, configuration schema, and child-registration filters for `machineGroupControl`. Source of truth: `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/machineGroupControl.json`.
>
> For an intuitive overview, return to the [Home](Home).
---
## Topic contract
The MGC accepts three canonical topics. `set.demand` is the only one with semantic content; the other two are simple state changes.
| Canonical topic | Aliases | Payload | Unit handling | Effect |
|:---|:---|:---|:---|:---|
| `set.mode` | `setMode` | `string` (`"optimalControl"` \| `"priorityControl"` \| `"maintenance"`) | &mdash; | Switch the dispatch strategy. `maintenance` is monitoring-only &mdash; the dispatch switch warns and skips. |
| `set.demand` | `Qd` | bare number, OR `{value: number, unit: string}` | self-describing (see below) | Operator demand setpoint. Resolves to canonical m³/s, then enters the latest-wins gate. Negative value = stop all (any unit). |
| `child.register` | `registerChild` | `string` (Node-RED node id) | &mdash; | Register a child machine manually. Port 2 wiring does this automatically in normal flows. |
### `set.demand` &mdash; unit-self-describing semantics
`src/commands/handlers.js` `setDemand`. The payload itself decides the meaning:
| Payload form | Interpretation |
|:---|:---|
| `42` (bare number) | 42&nbsp;%. Mapped through `interpolation.interpolate_lin_single_point(value, 0, 100, dt.flow.min, dt.flow.max)` to a canonical m³/s, clamped to the dynamic envelope. |
| `{value: 42, unit: '%'}` | Same as above &mdash; explicit-percent form. |
| `{value: 80, unit: 'm3/h'}` (or `l/s` / `m3/s` / &hellip;) | Absolute flow. Converted via `convert(value).from(unit).to('m3/s')`. |
| `42` or `{value: …, unit: 'm3/h'}` with `value < 0` | Triggers `turnOffAllMachines()` regardless of unit. |
| Anything else (`NaN`, missing) | Logged at error level; dispatch is skipped. |
There is **no persistent `scaling` state** on the orchestrator. Each `set.demand` carries its own unit context; callers can switch between absolute and percent at will.
After a successful dispatch the handler replies on the input port with `{topic: <node.name>, payload: 'done'}` &mdash; the legacy "done" handshake some downstream flows still rely on.
---
## Data model &mdash; `getOutput()` shape
Composed each tick by `src/io/output.js` `getOutput()` and emitted via `outputUtils.formatMsg` on Port 0. Delta-compressed: consumers see only the keys that changed.
### Per-measurement keys
For every `(type, variant)` MeasurementContainer pair, the formatter emits **up to four keys** &mdash; one per position plus a differential when both upstream and downstream are present:
```
<position>_<variant>_<type>
```
Examples (with `variant=predicted`, `type=flow`):
| Key | Source |
|:---|:---|
| `downstream_predicted_flow` | Group aggregate at the discharge side. |
| `atEquipment_predicted_flow` | Optimizer intent (what the controller's solving for). |
| `upstream_predicted_flow` | Group suction-side aggregate (when populated). |
| `differential_predicted_flow` | `downstream upstream` when both legs read. |
Same shape for `pressure`, `power`, `temperature`, `efficiency`, `Ncog`. Output units are taken from the unit policy (`flow=m3/h`, `pressure=mbar`, `power=kW`, `temperature=°C`).
### Scalar group keys
| Key | Type | Source | Notes |
|:---|:---|:---|:---|
| `mode` | string | `mgc.mode` | Current dispatch mode. |
| `scaling` | (legacy) | `mgc.scaling` | Always `undefined` in the current code &mdash; the orchestrator no longer carries a scaling field. Kept in the formatter for now; will be removed. |
| `absDistFromPeak` | number | `mgc.efficiency.calcDistanceBEP` | Absolute η distance to the group "peak" (mean of per-pump cogs). |
| `relDistFromPeak` | number \| undefined | same | Normalised 0..1; `undefined` when the η spread collapses (homogeneous pump group). |
| `headerDiffPa` | number | `mgc.operatingPoint.headerDiffPa` | Last header differential the equaliser resolved. Pa. |
| `headerDiffMbar` | number | derived | Only emitted when `output.pressure === 'mbar'`. |
| `flowCapacityMax` / `flowCapacityMin` | number | `mgc.dynamicTotals.flow.{max,min}` | The group's current envelope at the active header pressure. |
| `machineCount` | number | `Object.keys(mgc.machines).length` | All registered children. |
| `machineCountActive` | number | derived | Children whose state ≠ `off` / `maintenance` and currentMode ≠ `maintenance`. |
### Status badge
`src/io/output.js` `getStatusBadge()` composes:
```
<mode> · <scaling-abbrev> · Q=<flow>/<capacity> m³/h · P=<power> kW · <active>/<count>x
```
Fill colour: `green` when any pump is available, `yellow` when machines are registered but all are off/maintenance, `grey` when no pumps are registered.
---
## Configuration schema &mdash; editor form to config keys
Source of truth: `generalFunctions/src/configs/machineGroupControl.json` plus `nodeClass.buildDomainConfig`.
### General (`config.general`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Name | `general.name` | `Machine Group Configuration` | Human-readable label. |
| (auto-assigned) | `general.id` | `null` | Node-RED node id; assigned at deploy. |
| Default unit | `general.unit` | `m3/h` | Surfaces as the unit-policy output for `flow`. |
| Enable logging | `general.logging.enabled` | `true` | Master logger switch. |
| Log level | `general.logging.logLevel` | `info` | `debug` / `info` / `warn` / `error`. |
### Functionality (`config.functionality`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | One of `atEquipment` / `upstream` / `downstream`. Used in the child-register payload. |
| (hidden) | `functionality.softwareType` | `machinegroupcontrol` | Constant. |
| (hidden) | `functionality.role` | `GroupController` | Constant. |
| Distance offset | `functionality.distance` | `null` | Optional spatial offset; populated from the editor when `hasDistance` is enabled. |
| Distance unit | `functionality.distanceUnit` | `m` | |
| Distance description | `functionality.distanceDescription` | `""` | Free-text. |
### Output (`config.output`)
| Form field | Config key | Default | Range | Notes |
|:---|:---|:---|:---|:---|
| Process Output | `output.process` | `process` | `process` / `json` / `csv` | Port-0 formatter. |
| Database Output | `output.dbase` | `influxdb` | `influxdb` / `json` / `csv` | Port-1 formatter. |
### Mode (`config.mode`)
| Form field | Config key | Default | Range | Where used |
|:---|:---|:---|:---|:---|
| Control mode | `mode.current` | `optimalControl` | `optimalControl` / `priorityControl` / `maintenance` | dispatch switch in `_runDispatch`; mode-source/-action gates in `commands/handlers.js`. |
| (defaults) | `mode.allowedActions.optimalControl` | `[statusCheck, execOptimalCombination, balanceLoad, emergencyStop]` | &mdash; | Enforced at command-handler entry via `specificClass.isValidActionForMode`. |
| (defaults) | `mode.allowedActions.priorityControl` | `[statusCheck, execSequentialControl, balanceLoad, emergencyStop]` | &mdash; | Same. |
| (defaults) | `mode.allowedActions.maintenance` | `[statusCheck]` | &mdash; | Same &mdash; dispatch/emergencyStop are dropped with a warn log. |
| (defaults) | `mode.allowedSources.optimalControl` | `["parent","GUI","physical","API"]` | &mdash; | Enforced via `specificClass.isValidSourceForMode`. |
| (defaults) | `mode.allowedSources.priorityControl` | `["parent","GUI","physical","API"]` | &mdash; | Same. |
| (defaults) | `mode.allowedSources.maintenance` | `["parent","GUI"]` | &mdash; | Physical/HMI and API writes dropped in maintenance &mdash; monitoring only. |
> [!NOTE]
> `mode.current` is normalised at write time by `specificClass.setMode`: legacy lowercase inputs (`optimalcontrol`, `prioritycontrol`) are accepted and stored as the canonical camelCase. The `_runDispatch` switch then lowercases for its comparison &mdash; both forms reach the correct branch. Garbage modes (e.g. `'wat'`) are rejected with a warn log and the previous mode is preserved.
>
> Selecting `maintenance` no longer reaches `_runDispatch` at all in normal operation: the mode-action gate at `commands/handlers.js` drops the incoming `set.demand` before the dispatcher sees it. Status messages (`set.mode`, `child.register`) continue to flow.
### Unit policy
Source: `src/specificClass.js` lines 33&ndash;37.
| Quantity | Canonical (internal) | Output (rendered) | Required-unit |
|:---|:---|:---|:---:|
| Flow | `m3/s` | `m3/h` | ✓ |
| Pressure | `Pa` | `mbar` | ✓ |
| Power | `W` | `kW` | ✓ |
| Temperature | `K` | `°C` | ✓ |
`requireUnitForTypes` means MeasurementContainer rejects writes without an explicit unit for these types &mdash; protects against accidentally writing raw numbers in the wrong scale.
---
## Child registration
Source: `src/specificClass.js` `configure()` lines 92&ndash;118.
| softwareType | Filter / subscribed events | Side-effect |
|:---|:---|:---|
| `machine` | `onRegister` stores the child in `this.machines[id]`. Subscribes to `pressure.measured.downstream`, `pressure.measured.differential`, and `flow.predicted.downstream` from the child's emitter. | Every event calls `handlePressureChange()` &mdash; equalises the header, recomputes dynamic totals, refreshes group η, fires `notifyOutputChanged()`. |
| `measurement` | `onRegister` reads `asset.type` and `positionVsParent`, subscribes to `<type>.measured.<position>` on the child's measurement emitter. | Mirrors the value into MGC's own MeasurementContainer; pressure values additionally trigger `handlePressureChange()`. |
A child whose `asset.type` or `positionVsParent` is missing is logged at warn and skipped (not registered).
There is **no filter on `machinegroup` / `pumpingstation` children** &mdash; MGC is a leaf controller; it parents pumps but doesn't accept fellow aggregators.
---
## Header-pressure equalisation
Source: `src/groupOps/groupOperatingPoint.js` `equalize()`.
MGC ensures every registered child uses the **same** header differential pressure when computing predicted flow / power. Algorithm:
1. Read MGC's own group-scope pressure (downstream and upstream) from its MeasurementContainer.
2. Read each child's measured pressure (downstream / upstream).
3. Pick:
- `headerDownstream` = group reading if positive, else `max` across children.
- `headerUpstream` = group reading if positive, else `min` across children.
4. If the differential is non-positive, skip the equalisation (debug log).
5. Stash the diff on `this.headerDiffPa` (used by `getOutput` and by every η computation).
6. Push the diff onto each child's `predictFlow.fDimension` / `predictPower.fDimension` / `predictCtrl.fDimension` &mdash; preferred path is `child.setGroupOperatingPoint(downstream, upstream)`, which lets the child re-build its `groupPredict*` interpolators. Older children fall back to a direct `fDimension` write.
The equaliser is called from `handlePressureChange` (on every child pressure / predicted-flow event) and from the start of `_optimalControl`.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [EVOLV &mdash; Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
| [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |

155
wiki/Reference-Examples.md Normal file
View File

@@ -0,0 +1,155 @@
# Reference &mdash; Examples
![code-ref](https://img.shields.io/badge/code--ref-26e92b5-blue)
> [!NOTE]
> Every example flow shipped under `nodes/machineGroupControl/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/machineGroupControl/examples/`.
---
## Shipped examples
| File | Tier | What it shows |
|:---|:---:|:---|
| `examples/01-Basic.json` | 1 | One MGC + three `rotatingMachine` pumps driven by inject buttons. A Setup group once-fires `virtualControl` + `cmd.startup` on all three pumps; mode / demand are then driven by buttons. |
| `examples/02-Dashboard.json` | 2 | Same command surface driven by a FlowFuse Dashboard 2.0 page &mdash; mode buttons, demand slider, live status rows (mode / total flow / total power / capacity / active machines / BEP %), trend charts, and a raw-output table. |
MGC is not a standalone node &mdash; it needs at least one `rotatingMachine` child to dispatch to. Both flows ship three child pumps.
---
## Loading a flow
### Via the editor
1. Open the Node-RED editor at `http://localhost:1880`.
2. Menu &rarr; Import.
3. Drag-and-drop the JSON file, or paste its contents.
4. Click Deploy.
### Via the Admin API
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/machineGroupControl/examples/01-Basic.json \
http://localhost:1880/flows
```
---
## Example 01 &mdash; Basic standalone
> [!IMPORTANT]
> **Screenshot needed.** Capture of the basic flow in the editor. Save as `wiki/_partial-screenshots/machineGroupControl/01-basic-flow.png`. Replace this callout with the image link.
### Nodes on the tab
| Type | Purpose |
|:---|:---|
| `comment` | Tab header / instructions / driver-group labels |
| `inject` | Setup auto-injects (virtualControl + cmd.startup per pump), mode buttons, demand-by-percent buttons, demand-by-absolute-unit buttons, stop-all button |
| `machineGroupControl` | The unit under test |
| `rotatingMachine` &times; 3 | Children A / B / C (each with its own simulated pressure pair) |
| `debug` | Port 0 (process), Port 1 (telemetry), Port 2 (registration) per node |
### What to do after deploy
1. Wait ~1.5&nbsp;s. The Setup group auto-fires `virtualControl` + `cmd.startup` on all three pumps.
2. Click `set.demand = 50` (bare number = percent). MGC selects the best combination via BEP-Gravitation, plans a rendezvous, and dispatches `flowmovement` to the selected pumps.
3. Click `set.demand = 100`. The optimizer probably engages a third pump; the planner schedules its `execsequence(startup)` at tick 0 and delays the running pumps' down-moves so they all hit their new targets together at `t*`.
4. Click `set.mode = priorityControl`. Subsequent demands route through `equalFlowControl` &mdash; equal-flow per active pump in priority order. (Planner is bypassed in this mode &mdash; see [Limitations](Reference-Limitations).)
5. Click `set.demand = {value: 80, unit: 'm3/h'}` (or use the absolute-unit button). Same path, but the percent-mapping step is skipped &mdash; the value lands on the gate as canonical m³/s directly.
6. Click `set.demand = -1`. `turnOffAllMachines` runs: cancels any parked demand, sends `execsequence: 'shutdown'` to every active pump.
> [!IMPORTANT]
> **GIF needed.** Demo of steps 1&ndash;6 with the live status panel. Save as `wiki/_partial-gifs/machineGroupControl/01-basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
---
## Example 02 &mdash; Dashboard
> [!IMPORTANT]
> **Screenshots needed.** Two captures from `02-Dashboard.json`:
> 1. The editor tab (left controls column + MGC + 3 pumps + dashboard widget cluster on the right).
> 2. The rendered dashboard at `http://localhost:1880/dashboard/mgc-basic`.
>
> Save as `wiki/_partial-screenshots/machineGroupControl/02-dashboard-editor.png` and `03-dashboard-rendered.png`. Replace this callout with both image links.
### What it adds vs Example 01
| Addition | Why |
|:---|:---|
| FlowFuse `ui-base` + `ui-theme` + `ui-page` setup | One dashboard page hosting four widget groups |
| `ui-button` cluster (Controls) | Mode buttons, `Initialize pumps`, `Stop all` |
| `ui-slider` (Demand) | Drag-to-set demand; passes through the same canonical `set.demand` topic the injects use |
| `ui-text` cluster (Status) | Mode / total flow / total power / capacity / active machines / BEP % rows |
| `ui-chart` &times; N (Trends) | Flow, power, BEP trends over time |
| `ui-template` (Raw output) | Full key/value table of the latest Port 0 payload |
| Fan-out function | Caches last-known values so delta-only Port 0 updates never blank a status row, and forwards numeric values to charts |
The dashboard buttons fire the **same canonical `msg.topic`** as the inject nodes in Example 01 &mdash; there is no separate dashboard command surface to learn.
Required: `@flowfuse/node-red-dashboard` (Dashboard 2.0) installed in the Node-RED instance.
### What to do after deploy
1. Open `http://localhost:1880/dashboard/mgc-basic`.
2. The page auto-initialises the pumps; the `Initialize pumps` button re-runs the setup manually.
3. Drag the **Demand** slider. The Status row's `total flow` and `BEP %` react; the trend charts plot the transition.
4. Switch modes. The mode row in Status reflects the change immediately.
5. Inspect the **Raw output** table for the full Port-0 surface &mdash; `headerDiffPa`, `flowCapacityMax`, `machineCountActive`, `relDistFromPeak`, &hellip;
> [!IMPORTANT]
> **GIF needed.** Capture clicking through demand 30 % &rarr; 80 % &rarr; -1 with the trends reacting. 30&ndash;45&nbsp;s is enough.
>
> Save as `wiki/_partial-gifs/machineGroupControl/02-dashboard-demo.gif`. Replace this callout with the image link.
---
## Docker compose snippet
To bring up Node-RED + InfluxDB with EVOLV nodes pre-loaded:
```yaml
# docker-compose.yml (extract)
services:
nodered:
build: ./docker/nodered
ports: ['1880:1880']
volumes:
- ./docker/nodered/data:/data/evolv
influxdb:
image: influxdb:2.7
ports: ['8086:8086']
```
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
---
## Debug recipes
| Symptom | First thing to check | Where to look |
|:---|:---|:---|
| `mode is not a valid mode` warns every dispatch | `mode.current` is `maintenance` (or a typo). Reset to `optimalControl` or `priorityControl`. | `_runDispatch` switch. |
| `No valid combination found (empty set)` | Demand outside the dynamic envelope, OR every child filtered out (state in `off / coolingdown / stopping / emergencystop` or `auto`-mode rejects the action). | `validPumpCombinations` + state of each child. |
| Group flow stuck at zero after `set.demand` | Pumps never reached an active state &mdash; check per-pump startup logs. | Each pump's `state` on its Port 0. |
| Pump warmingup, but then drops back to idle when demand keeps changing | Pre-2026-05-15 race condition: shutdown's for-loop barged through after a residue-handler operational transition. The fix is the `sequenceAbortToken` mechanism in rotatingMachine's FSM. Verify the rotatingMachine submodule is at `394a972` or newer. | rotatingMachine `state/sequenceController.js`. |
| Header pressure not equalising | Pressure children must register with `asset.type='pressure'` and a matching `positionVsParent`. Pure-numeric pressures with no unit are rejected by MeasurementContainer. | `operatingPoint.equalize`. |
| Optimiser picks unexpected combination | Verify `optimization.method` &mdash; default is `BEP-Gravitation-Directional`. Per-method scoring lives in `optimizer/`. | `optimizer/{bestCombination, bepGravitation}.js`. |
| Status badge shows `scaling=norm` even after a unit-tagged demand | Badge cosmetic only &mdash; the `scaling` field is a legacy artifact and currently always reads `norm`. The dispatch path is unit-self-describing. | `io/output.js` `getStatusBadge`. |
| Per-pump flow / power trends missing | MGC only emits group aggregates on Port 0. Subscribe to each `rotatingMachine`'s Port 0 if you need per-pump series. | `io/output.js` `getOutput`. |
> Never ship `enableLog: 'debug'` in a demo &mdash; fills the container log within seconds and obscures real errors.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [EVOLV &mdash; Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where this node fits in a larger plant |

View File

@@ -0,0 +1,128 @@
# Reference &mdash; Limitations
![code-ref](https://img.shields.io/badge/code--ref-26e92b5-blue)
> [!NOTE]
> What `machineGroupControl` does not do, current rough edges, and open questions. The planner-decline question is tracked as Gitea issue `RnD/machineGroupControl#1`; other open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` in the superproject.
---
## When you would not use this node
| Scenario | Use instead |
|:---|:---|
| A single pump | Wire `rotatingMachine` directly under your parent. MGC's combinatorics + totals add no value below N=2. |
| Valves (no curve, no FSM-driven motor) | `valveGroupControl`. MGC's optimizer assumes a flow-vs-pressure characteristic. |
| Pumps behind independent headers | Multiple MGCs (one per header), each parented to its own logical aggregator. The equaliser assumes a shared discharge / suction pressure. |
| Curve-less assets | Without a curve, `optimalControl` excludes the machine from every combination; the dispatch loop falls into the empty-set branch and warns each tick. |
| Mixed compressor + pump groups | The optimizer is curve-agnostic in principle, but the η = (Q·ΔP)/P_shaft identity used in `_optimalControl` assumes an incompressible-flow head. Use separate MGCs per phase. |
---
## Known limitations
### `maintenance` mode is in the schema but not in the dispatch switch
`config.mode.current` accepts `maintenance` as a valid value (per the schema enum), but `_runDispatch`'s mode switch only handles `optimalcontrol` and `prioritycontrol`. Picking `maintenance` will log `'maintenance' is not a valid mode.` on every demand. Treated as schema-vs-code drift, not a runtime bug.
### `priorityControl` bypasses the movement planner
`equalFlowControl` (the priority-mode strategy) still uses the legacy direct-dispatch path:
```js
await Promise.all(flowDistribution.map(async ({ machineId, flow }) => {
if (flow > 0) {
await machine.handleInput('parent', 'flowmovement', ...);
if (currentState === 'idle') await machine.handleInput('parent', 'execsequence', 'startup');
} else { ... shutdown ... }
}));
```
The planner is only wired through `optimalControl`. Consequence: priority-mode transitions can show a flow dip while one pump warms up and another keeps spinning. Tracked for a future pass; the planner's API is mode-agnostic so the surgery is straightforward when priorities allow.
### `mgc.scaling` is undefined
The orchestrator no longer carries a `scaling` field &mdash; `set.demand` is unit-self-describing per message. The `io/output.js` formatter still references `mgc.scaling`, which always reads `undefined`. The status-badge cosmetically displays `norm`. This is a leftover artifact of the pre-refactor design; harmless, scheduled for removal.
### Group efficiency naming &mdash; `maxEfficiency` is the **mean**, not the peak
`GroupEfficiency.calcGroupEfficiency` returns `{ maxEfficiency, lowestEfficiency }`. `maxEfficiency` is the **mean cog** across all machines, not the maximum. The name is preserved for behavioural parity with the pre-refactor code; callers using it as "the peak" will over-estimate the BEP target. Tracked &mdash; rename is a follow-up.
### `calcAbsoluteTotals` implicit pressure coupling
`TotalsCalculator.calcAbsoluteTotals` iterates a machine's `predictFlow.inputCurve` and re-indexes the SAME pressure key into `predictPower.inputCurve`. If the two curves were sampled at different pressures the lookup is `undefined` and the call throws. Mitigation deferred to the rotatingMachine curveLoader pass (P5).
### Power-cap parameter has no canonical topic
`handleInput(source, demand, powerCap)` accepts a `powerCap` argument and threads it to `validPumpCombinations`, but there is no `set.power-cap` topic in `commands/index.js`. Only programmatic callers can set it. Tracked.
### Per-pump fan-out not on Port 0
MGC's Port 0 carries the group aggregate only (`atEquipment_predicted_flow`, `headerDiffPa`, etc.). If you want per-pump trends on a dashboard you must wire each `rotatingMachine`'s Port 0 separately. By design &mdash; the alternative would put N × M fields on the MGC payload.
### Curve-less members silently drop out
`combinatorics/pumpCombinations.validPumpCombinations` filters by FSM state and mode but not by curve presence. A machine with `predictFlow === null` (because its curve loader failed at startup) has `currentFxyYMin / Max = 0`, so its contribution to subset envelopes is zero. It can still appear in subsets &mdash; the optimizer just gives it zero flow. The drop-out is silent; the only signal is the curve-loader's error log at startup.
---
## Open questions (tracked)
| Question | Where it lives |
|:---|:---|
| Should the planner ever decline a combination when the slowest startup exceeds an SLA on demand spikes? | [machineGroupControl#1](https://gitea.wbd-rd.nl/RnD/machineGroupControl/issues/1) |
| Wire the movement planner through `priorityControl` | Internal &mdash; not yet ticketed |
| Remove the `mgc.scaling` artifact + the `scaling` badge field | Internal |
| Rename `maxEfficiency``meanGroupCog` in `GroupEfficiency` | Internal |
| Decline-and-fall-back vs always-commit on planner level | Same as the Gitea issue above |
---
## Migration notes
### From pre-planner
The MGC's `_optimalControl` used to fan commands out inline (lines 226&ndash;239 in `26e92b5^`):
```js
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
if (flow > 0) {
await machine.handleInput('parent', 'flowmovement', ...);
if (state === 'idle') await machine.handleInput('parent', 'execsequence', 'startup');
} else if (ACTIVE_STATES.has(state)) {
await machine.handleInput('parent', 'execsequence', 'shutdown');
}
}));
```
That code is gone. The new path: build profiles &rarr; `scheduler.plan` &rarr; `executor.replan` &rarr; `await executor.tick()` (synchronous first tick) &rarr; `setInterval(1000)` for the rest. The flow / power numbers and the optimizer's pick are unchanged; only the **timing** of the per-pump commands changed.
If your test fixture relied on commands firing inline during `_runDispatch`, the new behaviour fires `fireAtTickN=0` commands synchronously inside the first `await executor.tick()` and later ones on the wall-clock interval. Tests that asserted exact timing should use the `executor.schedule()` introspection getter.
### From pre-unit-self-describing demand
The old `set.scaling` topic and its persistent `scaling.current` config field have been removed. Each `set.demand` now carries its own unit context:
| Pre | Post |
|:---|:---|
| `set.scaling = "absolute"`; `set.demand = 80` | `set.demand = {value: 80, unit: "m3/h"}` |
| `set.scaling = "normalized"`; `set.demand = 50` | `set.demand = 50` (bare number = %) |
| `set.scaling = "absolute"`; `set.demand = 0.022` (m³/s) | `set.demand = {value: 0.022, unit: "m3/s"}` |
Old flows that still send `set.scaling` will silently ignore it; the topic is no longer registered.
### From `prioritypercentagecontrol`
The mode `prioritypercentagecontrol` was retired with the unit-self-describing refactor. Use `priorityControl` with absolute-unit `set.demand` payloads, or `optimalControl` with the same.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [rotatingMachine &mdash; Limitations](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Reference-Limitations) | The child's own limitations (drift, multi-parent, virtual-child stale data) |

19
wiki/_Sidebar.md Normal file
View File

@@ -0,0 +1,19 @@
### machineGroupControl
- [Home](Home)
**Reference**
- [Contracts](Reference-Contracts)
- [Architecture](Reference-Architecture)
- [Examples](Reference-Examples)
- [Limitations](Reference-Limitations)
**Related**
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
- [pumpingStation wiki](https://gitea.wbd-rd.nl/RnD/pumpingStation/wiki/Home)
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)