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:
znetsixe
2026-05-17 19:43:55 +02:00
parent 26e92b54f7
commit 472402c62d
26 changed files with 3048 additions and 280 deletions

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;