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>
15 KiB
Reference — Architecture
Note
Code structure for
machineGroupControl: the three-tier sandwich, thesrc/layout, the dispatch lifecycle, the movement planner that fans commands out, and the output-port pipeline. Everything here is reproducible fromsrc/. For an intuitive overview, return to Home.
Three-tier code layout
nodes/machineGroupControl/
|
+-- mgc.js entry: RED.nodes.registerType('machineGroupControl', NodeClass)
|
+-- src/
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
| specificClass.js extends BaseDomain (orchestration only)
| |
| +-- commands/
| | index.js topic descriptors
| | handlers.js pure handler functions (unit-self-describing set.demand)
| |
| +-- groupOps/
| | groupOperatingPoint.js header equalisation + child read helpers
| | groupCurves.js per-machine curve adapters used by optimizer + strategies
| |
| +-- totals/
| | totalsCalculator.js absolute, dynamic, and active envelopes
| |
| +-- combinatorics/
| | pumpCombinations.js enumerate valid pump subsets that can deliver Qd
| |
| +-- optimizer/
| | index.js selector (CoG vs BEP-Gravitation variants)
| | bestCombination.js N-CoG optimizer
| | bepGravitation.js BEP-Gravitation (+ Directional variant)
| |
| +-- efficiency/
| | groupEfficiency.js group η, BEP distance (abs + relative)
| |
| +-- control/
| | strategies.js equalFlowControl (priority mode legacy direct dispatch)
| |
| +-- dispatch/
| | demandDispatcher.js thin wrapper over LatestWinsGate.fireAndWait
| |
| +-- movement/
| | machineProfile.js pure snapshot of a registered child for the planner
| | moveTrajectory.js per-pump ETA-to-target math
| | movementScheduler.js rendezvous planner (pure)
| | movementExecutor.js tick-driven, async-aware command firer
| |
| +-- io/
| output.js getOutput() shape + status badge
Tier responsibilities
| Tier | File | What it owns | Touches RED.* |
|---|---|---|---|
| entry | mgc.js |
Type registration | Yes |
| nodeClass | src/nodeClass.js |
Input routing, output ports, status badge polling (statusInterval=1000). No tick loop — event-driven. |
Yes |
| specificClass | src/specificClass.js |
Wire concern modules in configure(); route demand through DemandDispatcher; pick mode in _runDispatch; own the planner's wall-clock driver. |
No |
specificClass is stitching. All real work lives in the concern modules: pure math in combinatorics/, optimizer/, efficiency/, movement/{moveTrajectory,movementScheduler}; live-state-touching in groupOps/, totals/, control/, dispatch/, movement/movementExecutor.
The dispatch lifecycle
sequenceDiagram
autonumber
participant parent as pumpingStation / UI
participant gate as DemandDispatcher (LatestWinsGate)
participant disp as _runDispatch
participant abort as abortActiveMovements
participant opt as optimizer
participant plan as movementScheduler
participant exec as movementExecutor
participant kids as rotatingMachine[]
parent->>gate: handleInput(Qd)
Note over gate: latest-wins:<br/>parked demand is dropped if a fresher one arrives
gate->>disp: payload.demand = canonical m³/s
disp->>abort: abortActiveMovements('new demand')
disp->>disp: calcDynamicTotals + clamp Qd to envelope
alt mode = optimalControl
disp->>opt: pickOptimizer(method).calcBestCombination*
opt-->>disp: bestCombination + bestFlow / bestPower / bestCog
disp->>plan: plan(profiles, combination, headerDiffPa)
plan-->>disp: schedule {tStarS, tickS, commands[]}
disp->>exec: replan(schedule)
disp->>exec: await tick() (FIRST tick, synchronous race-favouring)
Note over exec: setInterval(1000ms) drives further ticks<br/>auto-stops when pending() == 0
else mode = priorityControl
disp->>disp: control.equalFlowControl(ctx, Qd, powerCap, priorityList)
Note over disp: Legacy direct fan-out:<br/>await Promise.all(...handleInput...)
end
exec->>kids: flowmovement / execsequence (per scheduled tick)
disp->>disp: handlePressureChange-style refresh<br/>notifyOutputChanged
Key facts the diagram pins down:
| Fact | Why it matters |
|---|---|
| Demand serialisation is latest-wins, not FIFO | A burst of demand updates collapses to a single dispatch. Parked demands resolve with { superseded: true } so callers can branch on it. |
abortActiveMovements only aborts pumps in accelerating / decelerating |
Warmup / cooldown are protected at the pump's FSM; aborting them is silently ignored there. |
_runDispatch awaits the first executor tick |
Synchronous first-tick fire gives the new move's residue-handler priority over an in-flight shutdown sequence's for-loop. Fire-and-forget would lose the race in real wall-clock conditions. |
The 1 Hz setInterval only runs while executor.pending() > 0 |
Idle MGCs don't burn a forever-on timer. |
Negative demand goes straight to turnOffAllMachines |
And turnOffAllMachines calls dispatcher.cancelPending so a parked positive demand can't re-engage pumps post-shutdown. |
priorityControl uses the legacy direct-dispatch path |
The planner is not (yet) wired through equalFlowControl. See Reference — Limitations. |
The movement planner
The planner is the new architectural layer between the optimizer and the children. It exists so that when MGC re-balances during transitions, the running aggregate flow stays close to demand instead of dipping while one pump warms up and another keeps spinning.
1. buildProfile(child) — pure read
A plain-object snapshot of a registered child machine. Returns:
| Field | Source | Notes |
|---|---|---|
id |
child.config.general.id |
|
state |
child.state.getCurrentState() |
One of idle, starting, warmingup, operational, accelerating, decelerating, stopping, coolingdown, off, emergencystop, maintenance. |
position |
child.state.getCurrentPosition() |
Control % (0..100). |
minPosition / maxPosition |
child.state.movementManager |
|
velocityPctPerS |
movementManager.getNormalizedSpeed() × range |
Movement ramp rate in position-units / second. |
timings |
child.config.stateConfig.time |
{startingS, warmingupS, stoppingS, coolingdownS} — the configured durations the FSM spends in each timed state. |
remainingTransitionS |
child.state.stateManager.getRemainingTransitionS() |
Wall-clock-aware remaining seconds in the current timed state. 0 for untimed states. |
flowAt(pos, pressure) |
child.predictFlow.evaluate |
Forward curve (position → flow). |
positionForFlow(flow) |
child.predictCtrl.y |
Inverse curve (flow → control %); mirrors what flowController does on a flowmovement command. |
No contract changes — MGC already holds the live child reference (this.machines[id]); the profile is just a read of that.
2. MoveTrajectory — per-pump ETA math
Given a profile and a targetPosition, etaToTargetS() returns seconds-to-target-flow:
| Current state | ETA |
|---|---|
idle / off / emergencystop / maintenance |
startingS + warmingupS + (target − minPosition) / velocity |
operational / accelerating / decelerating (post-abort residue) |
|target − position| / velocity |
warmingup |
remainingTransitionS + (target − minPosition) / velocity |
starting |
remainingTransitionS + warmingupS + (target − minPosition) / velocity |
stopping / coolingdown |
null — pump cannot contribute on this dispatch |
Velocity of 0 returns Infinity so the scheduler can demote the machine without crashing. Targets are clamped to [minPosition, maxPosition] at construction.
3. movementScheduler.plan — rendezvous
Pure function. Inputs: (profiles[], combination, currentPressurePa, { tickS = 1 }). Output:
{
tStarS: 60, // rendezvous time in seconds
tickS: 1, // tick cadence
commands: [
{ machineId: 'A', action: 'execsequence', sequence: 'startup', fireAtTickN: 0, eta: 60 },
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 60 },
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 40, eta: 20 },
{ machineId: 'C', action: 'execsequence', sequence: 'shutdown', fireAtTickN: 55, eta: 5 }
],
_plans: [...] // per-machine classification + eta + direction; useful in tests
}
Algorithm:
- Classify each machine's move against the optimizer's target flow:
targetFlow > 0and pump off →startuptargetFlow > 0and pump on (any active or startup-ladder state) →flowmovetargetFlow <= 0and pump on →shutdown- Otherwise →
noop
- Direction: compare target flow against the pump's current flow (via
profile.flowAt). Increasing, decreasing, or unchanged. - ETA:
MoveTrajectory.etaToTargetS()(or, for shutdowns, the position-ramp time tominPosition). - Rendezvous:
t* = max(eta_i)over flow-INCREASING moves. - Schedule: increasing / unchanged moves fire at
fireAtTickN = 0; decreasing moves fire atfireAtTickN = round((t* − eta_j) / tickS)so they finish att*.
Net behaviour: during a transition the flow sum tracks demand smoothly. On overshoot, header pressure rises and individual pumps deliver less — a self-correcting undershoot. On undershoot, demand simply lands a few ticks later than ideal.
4. MovementExecutor — tick-driven, async-aware
Holds the active schedule plus a cursor (_cursor) that advances one per tick(). Each tick fires every unfired command whose fireAtTickN <= cursor via an injected fireCommand callback. The callback returns a Promise (in production, the machine.handleInput(...) promise); tick() awaits all of those before resolving.
replan(newSchedule) replaces the schedule and resets the cursor to 0. Already-fired commands stay fired — the pump's FSM downstream owns their consequences; the executor never tries to "undo" a fired startup (which keeps warmup / cooldown safety intact).
Wall-clock driver lives on the MGC itself (_ensureExecutorTimer): a setInterval(1000) that calls tick() and clears itself when pending() === 0. unref() keeps the timer from blocking Node-RED shutdown.
5. The cooperating FSM change (in rotatingMachine)
For the planner to be robust, the pump's executeSequence honours a sequence-abort token that MGC's external aborts advance. Without this, an in-flight shutdown's for-loop would race against the new dispatch's residue handler and could win — transitioning operational → stopping → coolingdown → idle even after the new move took the FSM operational.
See the rotatingMachine wiki's Architecture — FSM section for the full mechanism. Summary:
state.abortCurrentMovement(reason, { returnToOperational: false })— the default form, used by MGC'sabortActiveMovements— incrementsstate.sequenceAbortToken.executeSequencecaptures the token at entry and re-checks it before every state transition in its for-loop. A mismatch exits the loop early with aSequence '<name>' interrupted ... by external abortwarning.- Sequence-internal aborts (
returnToOperational: true, used when a fresher shutdown pre-empts its own setpoint ramp) do NOT advance the token. So the shutdown's own ramp-down to zero is interruptible without terminating the shutdown sequence itself.
Output ports
| Port | Carries | Sample shape |
|---|---|---|
| 0 (process) | Delta-compressed state snapshot — group aggregates, header diff, BEP distance, machine counts | {topic, payload: {mode, atEquipment_predicted_flow, headerDiffPa, machineCountActive, ...}} |
| 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | machineGroupControl,id=MGC1 atEquipment_predicted_flow=42.5,... |
| 2 (register / control) | child.register upward at init |
{topic: 'child.register', payload: {ref, softwareType, config}} |
Port-0 key shape is <position>_<variant>_<type> — group aggregates only. Per-pump series live on each rotatingMachine's Port 0 (with the inverted <type>.<variant>.<position>.<childId> shape). Subscribe per-child if you need per-pump trends on a dashboard.
See EVOLV — Telemetry for the full InfluxDB layout.
Event sources
| Source | Where it fires | What it triggers |
|---|---|---|
setInterval(_executorIntervalMs = 1000) |
Driven by _ensureExecutorTimer after a successful optimalControl plan |
movementExecutor.tick() |
setInterval(statusInterval = 1000) |
BaseNodeAdapter |
Status badge re-render |
Inbound msg.topic |
Node-RED input wire | commandRegistry dispatch to set.mode / set.demand / child.register |
| Child measurement event | child.measurements.emitter after a measurement landed |
handlePressureChange() (for pressure) or value mirror (for everything else) |
| Child prediction event | child.emitter "flow.predicted.downstream" |
handlePressureChange() |
child.register from a pump |
Port 2 of the pump | onRegister('machine', ...) — stores ref in this.machines[id] |
MGC has no per-second tick of its own. It's purely event-driven plus the planner's optional wall-clock executor.
Where to start reading
| If you're changing... | Read first |
|---|---|
| The dispatch flow, latest-wins semantics, mode switch | src/specificClass.js _runDispatch (lines 318–349) |
| Topic registration, payload validation | src/commands/index.js + src/commands/handlers.js |
| Optimizer selection / scoring | src/optimizer/index.js, bepGravitation.js, bestCombination.js |
| Header-pressure equalisation | src/groupOps/groupOperatingPoint.js equalize() |
| Combination enumeration | src/combinatorics/pumpCombinations.js |
| Per-pump ETA, rendezvous math | src/movement/moveTrajectory.js, movementScheduler.js |
| Wall-clock tick wiring | src/specificClass.js _ensureExecutorTimer (lines 290–301) |
| Output shape, status badge | src/io/output.js |
| Priority-mode equal-flow distribution | src/control/strategies.js |
Related pages
| Page | Why |
|---|---|
| Home | Intuitive overview |
| Reference — Contracts | Topic + config + child filters |
| Reference — Examples | Shipped flows + debug recipes |
| Reference — Limitations | Known issues and open questions |
| rotatingMachine wiki | The child node: FSM, prediction, drift |
| EVOLV — Architecture | Platform-wide three-tier pattern |