Files
machineGroupControl/wiki/Reference-Architecture.md
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

15 KiB
Raw Permalink Blame History

Reference — Architecture

code-ref

Note

Code structure for machineGroupControl: the three-tier sandwich, the src/ layout, the dispatch lifecycle, the movement planner that fans commands out, and the output-port pipeline. Everything here is reproducible from src/. 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:

  1. Classify each machine's move against the optimizer's target flow:
    • targetFlow > 0 and pump off → startup
    • targetFlow > 0 and pump on (any active or startup-ladder state) → flowmove
    • targetFlow <= 0 and pump on → shutdown
    • Otherwise → noop
  2. Direction: compare target flow against the pump's current flow (via profile.flowAt). Increasing, decreasing, or unchanged.
  3. ETA: MoveTrajectory.etaToTargetS() (or, for shutdowns, the position-ramp time to minPosition).
  4. Rendezvous: t* = max(eta_i) over flow-INCREASING moves.
  5. Schedule: increasing / unchanged moves fire at fireAtTickN = 0; decreasing moves fire at fireAtTickN = round((t* eta_j) / tickS) so they finish at t*.

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's abortActiveMovements — increments state.sequenceAbortToken.
  • executeSequence captures the token at entry and re-checks it before every state transition in its for-loop. A mismatch exits the loop early with a Sequence '<name>' interrupted ... by external abort warning.
  • 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 318349)
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 290301)
Output shape, status badge src/io/output.js
Priority-mode equal-flow distribution src/control/strategies.js

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