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>
132 lines
5.1 KiB
JavaScript
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);
|
|
}
|
|
};
|