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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user