# Reference — Architecture ![code-ref](https://img.shields.io/badge/code--ref-26e92b5-blue) > [!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](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 ```mermaid 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:
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
auto-stops when pending() == 0 else mode = priorityControl disp->>disp: control.equalFlowControl(ctx, Qd, powerCap, priorityList) Note over disp: Legacy direct fan-out:
await Promise.all(...handleInput...) end exec->>kids: flowmovement / execsequence (per scheduled tick) disp->>disp: handlePressureChange-style refresh
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](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: ```js { 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](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Reference-Architecture#fsm) 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 '' 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 **`__`** — group aggregates only. Per-pump series live on each `rotatingMachine`'s Port 0 (with the inverted `...` shape). Subscribe per-child if you need per-pump trends on a dashboard. See [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/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](Home) | Intuitive overview | | [Reference — Contracts](Reference-Contracts) | Topic + config + child filters | | [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes | | [Reference — Limitations](Reference-Limitations) | Known issues and open questions | | [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home) | The child node: FSM, prediction, drift | | [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |