Files
machineGroupControl/wiki/Reference-Architecture.md

262 lines
15 KiB
Markdown
Raw Normal View History

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
# 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 |