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:
znetsixe
2026-05-17 19:43:55 +02:00
parent 26e92b54f7
commit 472402c62d
26 changed files with 3048 additions and 280 deletions

View File

@@ -0,0 +1,128 @@
# Reference &mdash; Limitations
![code-ref](https://img.shields.io/badge/code--ref-26e92b5-blue)
> [!NOTE]
> What `machineGroupControl` does not do, current rough edges, and open questions. The planner-decline question is tracked as Gitea issue `RnD/machineGroupControl#1`; other open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` in the superproject.
---
## When you would not use this node
| Scenario | Use instead |
|:---|:---|
| A single pump | Wire `rotatingMachine` directly under your parent. MGC's combinatorics + totals add no value below N=2. |
| Valves (no curve, no FSM-driven motor) | `valveGroupControl`. MGC's optimizer assumes a flow-vs-pressure characteristic. |
| Pumps behind independent headers | Multiple MGCs (one per header), each parented to its own logical aggregator. The equaliser assumes a shared discharge / suction pressure. |
| Curve-less assets | Without a curve, `optimalControl` excludes the machine from every combination; the dispatch loop falls into the empty-set branch and warns each tick. |
| Mixed compressor + pump groups | The optimizer is curve-agnostic in principle, but the η = (Q·ΔP)/P_shaft identity used in `_optimalControl` assumes an incompressible-flow head. Use separate MGCs per phase. |
---
## Known limitations
### `maintenance` mode is in the schema but not in the dispatch switch
`config.mode.current` accepts `maintenance` as a valid value (per the schema enum), but `_runDispatch`'s mode switch only handles `optimalcontrol` and `prioritycontrol`. Picking `maintenance` will log `'maintenance' is not a valid mode.` on every demand. Treated as schema-vs-code drift, not a runtime bug.
### `priorityControl` bypasses the movement planner
`equalFlowControl` (the priority-mode strategy) still uses the legacy direct-dispatch path:
```js
await Promise.all(flowDistribution.map(async ({ machineId, flow }) => {
if (flow > 0) {
await machine.handleInput('parent', 'flowmovement', ...);
if (currentState === 'idle') await machine.handleInput('parent', 'execsequence', 'startup');
} else { ... shutdown ... }
}));
```
The planner is only wired through `optimalControl`. Consequence: priority-mode transitions can show a flow dip while one pump warms up and another keeps spinning. Tracked for a future pass; the planner's API is mode-agnostic so the surgery is straightforward when priorities allow.
### `mgc.scaling` is undefined
The orchestrator no longer carries a `scaling` field &mdash; `set.demand` is unit-self-describing per message. The `io/output.js` formatter still references `mgc.scaling`, which always reads `undefined`. The status-badge cosmetically displays `norm`. This is a leftover artifact of the pre-refactor design; harmless, scheduled for removal.
### Group efficiency naming &mdash; `maxEfficiency` is the **mean**, not the peak
`GroupEfficiency.calcGroupEfficiency` returns `{ maxEfficiency, lowestEfficiency }`. `maxEfficiency` is the **mean cog** across all machines, not the maximum. The name is preserved for behavioural parity with the pre-refactor code; callers using it as "the peak" will over-estimate the BEP target. Tracked &mdash; rename is a follow-up.
### `calcAbsoluteTotals` implicit pressure coupling
`TotalsCalculator.calcAbsoluteTotals` iterates a machine's `predictFlow.inputCurve` and re-indexes the SAME pressure key into `predictPower.inputCurve`. If the two curves were sampled at different pressures the lookup is `undefined` and the call throws. Mitigation deferred to the rotatingMachine curveLoader pass (P5).
### Power-cap parameter has no canonical topic
`handleInput(source, demand, powerCap)` accepts a `powerCap` argument and threads it to `validPumpCombinations`, but there is no `set.power-cap` topic in `commands/index.js`. Only programmatic callers can set it. Tracked.
### Per-pump fan-out not on Port 0
MGC's Port 0 carries the group aggregate only (`atEquipment_predicted_flow`, `headerDiffPa`, etc.). If you want per-pump trends on a dashboard you must wire each `rotatingMachine`'s Port 0 separately. By design &mdash; the alternative would put N × M fields on the MGC payload.
### Curve-less members silently drop out
`combinatorics/pumpCombinations.validPumpCombinations` filters by FSM state and mode but not by curve presence. A machine with `predictFlow === null` (because its curve loader failed at startup) has `currentFxyYMin / Max = 0`, so its contribution to subset envelopes is zero. It can still appear in subsets &mdash; the optimizer just gives it zero flow. The drop-out is silent; the only signal is the curve-loader's error log at startup.
---
## Open questions (tracked)
| Question | Where it lives |
|:---|:---|
| Should the planner ever decline a combination when the slowest startup exceeds an SLA on demand spikes? | [machineGroupControl#1](https://gitea.wbd-rd.nl/RnD/machineGroupControl/issues/1) |
| Wire the movement planner through `priorityControl` | Internal &mdash; not yet ticketed |
| Remove the `mgc.scaling` artifact + the `scaling` badge field | Internal |
| Rename `maxEfficiency``meanGroupCog` in `GroupEfficiency` | Internal |
| Decline-and-fall-back vs always-commit on planner level | Same as the Gitea issue above |
---
## Migration notes
### From pre-planner
The MGC's `_optimalControl` used to fan commands out inline (lines 226&ndash;239 in `26e92b5^`):
```js
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
if (flow > 0) {
await machine.handleInput('parent', 'flowmovement', ...);
if (state === 'idle') await machine.handleInput('parent', 'execsequence', 'startup');
} else if (ACTIVE_STATES.has(state)) {
await machine.handleInput('parent', 'execsequence', 'shutdown');
}
}));
```
That code is gone. The new path: build profiles &rarr; `scheduler.plan` &rarr; `executor.replan` &rarr; `await executor.tick()` (synchronous first tick) &rarr; `setInterval(1000)` for the rest. The flow / power numbers and the optimizer's pick are unchanged; only the **timing** of the per-pump commands changed.
If your test fixture relied on commands firing inline during `_runDispatch`, the new behaviour fires `fireAtTickN=0` commands synchronously inside the first `await executor.tick()` and later ones on the wall-clock interval. Tests that asserted exact timing should use the `executor.schedule()` introspection getter.
### From pre-unit-self-describing demand
The old `set.scaling` topic and its persistent `scaling.current` config field have been removed. Each `set.demand` now carries its own unit context:
| Pre | Post |
|:---|:---|
| `set.scaling = "absolute"`; `set.demand = 80` | `set.demand = {value: 80, unit: "m3/h"}` |
| `set.scaling = "normalized"`; `set.demand = 50` | `set.demand = 50` (bare number = %) |
| `set.scaling = "absolute"`; `set.demand = 0.022` (m³/s) | `set.demand = {value: 0.022, unit: "m3/s"}` |
Old flows that still send `set.scaling` will silently ignore it; the topic is no longer registered.
### From `prioritypercentagecontrol`
The mode `prioritypercentagecontrol` was retired with the unit-self-describing refactor. Use `priorityControl` with absolute-unit `set.demand` payloads, or `optimalControl` with the same.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [rotatingMachine &mdash; Limitations](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Reference-Limitations) | The child's own limitations (drift, multi-parent, virtual-child stale data) |