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>
This commit is contained in:
261
wiki/Reference-Architecture.md
Normal file
261
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Reference — Architecture
|
||||
|
||||

|
||||
|
||||
> [!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:<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](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 '<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](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 |
|
||||
Reference in New Issue
Block a user