Files
machineGroupControl/src/commands/handlers.js
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

132 lines
5.1 KiB
JavaScript

'use strict';
// Handler functions for machineGroupControl commands. Each handler receives:
// source: the domain (specificClass) instance — exposes setMode,
// handleInput, childRegistrationUtils.registerChild, logger,
// config.general.name.
// msg: the Node-RED input message.
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
//
// Pure functions: no module-level state. The registry already enforces the
// typeof-check ladder; per-topic semantic validation lives here.
const { convert } = require('generalFunctions');
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);
if (!childObj || !childObj.source) {
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
return;
}
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
};
exports.setDemand = async (source, msg, ctx) => {
const log = _logger(source, ctx);
// Operator demand is self-describing: the unit on the message decides how
// the value is interpreted. There is no persistent scaling state on MGC.
//
// payload = number → unit defaults to '%'
// payload = { value, unit:'%' }→ percent of group capacity
// payload = { value, unit:'m3/h' | 'l/s' | 'm3/s' | ... } → absolute flow
// payload < 0 (any unit) → operator stop-all signal
//
// The handler is the only place that resolves units. _runDispatch sees a
// single canonical m³/s number and never branches on scaling.
const p = msg?.payload;
let rawValue;
let unit;
if (p !== null && typeof p === 'object') {
rawValue = p.value;
unit = (typeof p.unit === 'string' && p.unit.trim()) ? p.unit.trim() : '%';
} else {
rawValue = p;
unit = '%';
}
const value = Number(rawValue);
if (!Number.isFinite(value)) {
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 {
await source.turnOffAllMachines();
} catch (err) {
log?.error?.(`set.demand: turnOffAllMachines failed: ${err && err.message}`);
}
return;
}
// Resolve to canonical m³/s.
let canonicalDemand;
if (unit === '%') {
const dt = source.calcDynamicTotals();
// Linear interpolation: 0 % → dt.flow.min, 100 % → dt.flow.max. The
// interpolation helper also clamps so 110 % can't run pumps past max.
canonicalDemand = source.interpolation.interpolate_lin_single_point(
value, 0, 100, dt.flow.min, dt.flow.max);
} else {
try {
canonicalDemand = convert(value).from(unit).to('m3/s');
} catch (err) {
log?.error?.(`set.demand: cannot convert ${value} ${unit} → m3/s: ${err && err.message}`);
return;
}
}
try {
await source.handleInput('parent', canonicalDemand);
} catch (err) {
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
return;
}
// Reply on Port 0 with the configured node name as topic — preserves the
// legacy "done" handshake some downstream flows rely on.
if (typeof ctx?.send === 'function') {
const reply = Object.assign({}, msg, {
topic: source?.config?.general?.name,
payload: 'done',
});
ctx.send(reply);
}
};