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>
This commit is contained in:
34
src/editor/index.js
Normal file
34
src/editor/index.js
Normal 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
142
src/editor/mode-cards.js
Normal 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 };
|
||||
})();
|
||||
16
src/editor/oneditprepare.js
Normal file
16
src/editor/oneditprepare.js
Normal 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);
|
||||
}
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user