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

262 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Reference &mdash; 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 &mdash; 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:<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&nbsp;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 &mdash; 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)` &mdash; 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}` &mdash; 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 &mdash; MGC already holds the live child reference (`this.machines[id]`); the profile is just a read of that.
### 2. `MoveTrajectory` &mdash; 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` &mdash; 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` &mdash; 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 &rarr; `startup`
- `targetFlow > 0` and pump on (any active or startup-ladder state) &rarr; `flowmove`
- `targetFlow <= 0` and pump on &rarr; `shutdown`
- Otherwise &rarr; `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 &mdash; a self-correcting undershoot. On undershoot, demand simply lands a few ticks later than ideal.
### 4. `MovementExecutor` &mdash; 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 &mdash; 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 &mdash; transitioning `operational → stopping → coolingdown → idle` even after the new move took the FSM operational.
See the rotatingMachine wiki's [Architecture &mdash; FSM section](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Reference-Architecture#fsm) for the full mechanism. Summary:
- `state.abortCurrentMovement(reason, { returnToOperational: false })` &mdash; the default form, used by MGC's `abortActiveMovements` &mdash; 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 &mdash; 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>`** &mdash; 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 &mdash; 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', ...)` &mdash; 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&ndash;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&ndash;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 &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; 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 &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |