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

196
wiki/Reference-Contracts.md Normal file
View File

@@ -0,0 +1,196 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-26e92b5-blue)
> [!NOTE]
> Full topic contract, configuration schema, and child-registration filters for `machineGroupControl`. Source of truth: `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/machineGroupControl.json`.
>
> For an intuitive overview, return to the [Home](Home).
---
## Topic contract
The MGC accepts three canonical topics. `set.demand` is the only one with semantic content; the other two are simple state changes.
| Canonical topic | Aliases | Payload | Unit handling | Effect |
|:---|:---|:---|:---|:---|
| `set.mode` | `setMode` | `string` (`"optimalControl"` \| `"priorityControl"` \| `"maintenance"`) | &mdash; | Switch the dispatch strategy. `maintenance` is monitoring-only &mdash; the dispatch switch warns and skips. |
| `set.demand` | `Qd` | bare number, OR `{value: number, unit: string}` | self-describing (see below) | Operator demand setpoint. Resolves to canonical m³/s, then enters the latest-wins gate. Negative value = stop all (any unit). |
| `child.register` | `registerChild` | `string` (Node-RED node id) | &mdash; | Register a child machine manually. Port 2 wiring does this automatically in normal flows. |
### `set.demand` &mdash; unit-self-describing semantics
`src/commands/handlers.js` `setDemand`. The payload itself decides the meaning:
| Payload form | Interpretation |
|:---|:---|
| `42` (bare number) | 42&nbsp;%. Mapped through `interpolation.interpolate_lin_single_point(value, 0, 100, dt.flow.min, dt.flow.max)` to a canonical m³/s, clamped to the dynamic envelope. |
| `{value: 42, unit: '%'}` | Same as above &mdash; explicit-percent form. |
| `{value: 80, unit: 'm3/h'}` (or `l/s` / `m3/s` / &hellip;) | Absolute flow. Converted via `convert(value).from(unit).to('m3/s')`. |
| `42` or `{value: …, unit: 'm3/h'}` with `value < 0` | Triggers `turnOffAllMachines()` regardless of unit. |
| Anything else (`NaN`, missing) | Logged at error level; dispatch is skipped. |
There is **no persistent `scaling` state** on the orchestrator. Each `set.demand` carries its own unit context; callers can switch between absolute and percent at will.
After a successful dispatch the handler replies on the input port with `{topic: <node.name>, payload: 'done'}` &mdash; the legacy "done" handshake some downstream flows still rely on.
---
## Data model &mdash; `getOutput()` shape
Composed each tick by `src/io/output.js` `getOutput()` and emitted via `outputUtils.formatMsg` on Port 0. Delta-compressed: consumers see only the keys that changed.
### Per-measurement keys
For every `(type, variant)` MeasurementContainer pair, the formatter emits **up to four keys** &mdash; one per position plus a differential when both upstream and downstream are present:
```
<position>_<variant>_<type>
```
Examples (with `variant=predicted`, `type=flow`):
| Key | Source |
|:---|:---|
| `downstream_predicted_flow` | Group aggregate at the discharge side. |
| `atEquipment_predicted_flow` | Optimizer intent (what the controller's solving for). |
| `upstream_predicted_flow` | Group suction-side aggregate (when populated). |
| `differential_predicted_flow` | `downstream upstream` when both legs read. |
Same shape for `pressure`, `power`, `temperature`, `efficiency`, `Ncog`. Output units are taken from the unit policy (`flow=m3/h`, `pressure=mbar`, `power=kW`, `temperature=°C`).
### Scalar group keys
| Key | Type | Source | Notes |
|:---|:---|:---|:---|
| `mode` | string | `mgc.mode` | Current dispatch mode. |
| `scaling` | (legacy) | `mgc.scaling` | Always `undefined` in the current code &mdash; the orchestrator no longer carries a scaling field. Kept in the formatter for now; will be removed. |
| `absDistFromPeak` | number | `mgc.efficiency.calcDistanceBEP` | Absolute η distance to the group "peak" (mean of per-pump cogs). |
| `relDistFromPeak` | number \| undefined | same | Normalised 0..1; `undefined` when the η spread collapses (homogeneous pump group). |
| `headerDiffPa` | number | `mgc.operatingPoint.headerDiffPa` | Last header differential the equaliser resolved. Pa. |
| `headerDiffMbar` | number | derived | Only emitted when `output.pressure === 'mbar'`. |
| `flowCapacityMax` / `flowCapacityMin` | number | `mgc.dynamicTotals.flow.{max,min}` | The group's current envelope at the active header pressure. |
| `machineCount` | number | `Object.keys(mgc.machines).length` | All registered children. |
| `machineCountActive` | number | derived | Children whose state ≠ `off` / `maintenance` and currentMode ≠ `maintenance`. |
### Status badge
`src/io/output.js` `getStatusBadge()` composes:
```
<mode> · <scaling-abbrev> · Q=<flow>/<capacity> m³/h · P=<power> kW · <active>/<count>x
```
Fill colour: `green` when any pump is available, `yellow` when machines are registered but all are off/maintenance, `grey` when no pumps are registered.
---
## Configuration schema &mdash; editor form to config keys
Source of truth: `generalFunctions/src/configs/machineGroupControl.json` plus `nodeClass.buildDomainConfig`.
### General (`config.general`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Name | `general.name` | `Machine Group Configuration` | Human-readable label. |
| (auto-assigned) | `general.id` | `null` | Node-RED node id; assigned at deploy. |
| Default unit | `general.unit` | `m3/h` | Surfaces as the unit-policy output for `flow`. |
| Enable logging | `general.logging.enabled` | `true` | Master logger switch. |
| Log level | `general.logging.logLevel` | `info` | `debug` / `info` / `warn` / `error`. |
### Functionality (`config.functionality`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | One of `atEquipment` / `upstream` / `downstream`. Used in the child-register payload. |
| (hidden) | `functionality.softwareType` | `machinegroupcontrol` | Constant. |
| (hidden) | `functionality.role` | `GroupController` | Constant. |
| Distance offset | `functionality.distance` | `null` | Optional spatial offset; populated from the editor when `hasDistance` is enabled. |
| Distance unit | `functionality.distanceUnit` | `m` | |
| Distance description | `functionality.distanceDescription` | `""` | Free-text. |
### Output (`config.output`)
| Form field | Config key | Default | Range | Notes |
|:---|:---|:---|:---|:---|
| Process Output | `output.process` | `process` | `process` / `json` / `csv` | Port-0 formatter. |
| Database Output | `output.dbase` | `influxdb` | `influxdb` / `json` / `csv` | Port-1 formatter. |
### Mode (`config.mode`)
| Form field | Config key | Default | Range | Where used |
|:---|:---|:---|:---|:---|
| Control mode | `mode.current` | `optimalControl` | `optimalControl` / `priorityControl` / `maintenance` | dispatch switch in `_runDispatch`; mode-source/-action gates in `commands/handlers.js`. |
| (defaults) | `mode.allowedActions.optimalControl` | `[statusCheck, execOptimalCombination, balanceLoad, emergencyStop]` | &mdash; | Enforced at command-handler entry via `specificClass.isValidActionForMode`. |
| (defaults) | `mode.allowedActions.priorityControl` | `[statusCheck, execSequentialControl, balanceLoad, emergencyStop]` | &mdash; | Same. |
| (defaults) | `mode.allowedActions.maintenance` | `[statusCheck]` | &mdash; | Same &mdash; dispatch/emergencyStop are dropped with a warn log. |
| (defaults) | `mode.allowedSources.optimalControl` | `["parent","GUI","physical","API"]` | &mdash; | Enforced via `specificClass.isValidSourceForMode`. |
| (defaults) | `mode.allowedSources.priorityControl` | `["parent","GUI","physical","API"]` | &mdash; | Same. |
| (defaults) | `mode.allowedSources.maintenance` | `["parent","GUI"]` | &mdash; | Physical/HMI and API writes dropped in maintenance &mdash; monitoring only. |
> [!NOTE]
> `mode.current` is normalised at write time by `specificClass.setMode`: legacy lowercase inputs (`optimalcontrol`, `prioritycontrol`) are accepted and stored as the canonical camelCase. The `_runDispatch` switch then lowercases for its comparison &mdash; both forms reach the correct branch. Garbage modes (e.g. `'wat'`) are rejected with a warn log and the previous mode is preserved.
>
> Selecting `maintenance` no longer reaches `_runDispatch` at all in normal operation: the mode-action gate at `commands/handlers.js` drops the incoming `set.demand` before the dispatcher sees it. Status messages (`set.mode`, `child.register`) continue to flow.
### Unit policy
Source: `src/specificClass.js` lines 33&ndash;37.
| Quantity | Canonical (internal) | Output (rendered) | Required-unit |
|:---|:---|:---|:---:|
| Flow | `m3/s` | `m3/h` | ✓ |
| Pressure | `Pa` | `mbar` | ✓ |
| Power | `W` | `kW` | ✓ |
| Temperature | `K` | `°C` | ✓ |
`requireUnitForTypes` means MeasurementContainer rejects writes without an explicit unit for these types &mdash; protects against accidentally writing raw numbers in the wrong scale.
---
## Child registration
Source: `src/specificClass.js` `configure()` lines 92&ndash;118.
| softwareType | Filter / subscribed events | Side-effect |
|:---|:---|:---|
| `machine` | `onRegister` stores the child in `this.machines[id]`. Subscribes to `pressure.measured.downstream`, `pressure.measured.differential`, and `flow.predicted.downstream` from the child's emitter. | Every event calls `handlePressureChange()` &mdash; equalises the header, recomputes dynamic totals, refreshes group η, fires `notifyOutputChanged()`. |
| `measurement` | `onRegister` reads `asset.type` and `positionVsParent`, subscribes to `<type>.measured.<position>` on the child's measurement emitter. | Mirrors the value into MGC's own MeasurementContainer; pressure values additionally trigger `handlePressureChange()`. |
A child whose `asset.type` or `positionVsParent` is missing is logged at warn and skipped (not registered).
There is **no filter on `machinegroup` / `pumpingstation` children** &mdash; MGC is a leaf controller; it parents pumps but doesn't accept fellow aggregators.
---
## Header-pressure equalisation
Source: `src/groupOps/groupOperatingPoint.js` `equalize()`.
MGC ensures every registered child uses the **same** header differential pressure when computing predicted flow / power. Algorithm:
1. Read MGC's own group-scope pressure (downstream and upstream) from its MeasurementContainer.
2. Read each child's measured pressure (downstream / upstream).
3. Pick:
- `headerDownstream` = group reading if positive, else `max` across children.
- `headerUpstream` = group reading if positive, else `min` across children.
4. If the differential is non-positive, skip the equalisation (debug log).
5. Stash the diff on `this.headerDiffPa` (used by `getOutput` and by every η computation).
6. Push the diff onto each child's `predictFlow.fDimension` / `predictPower.fDimension` / `predictCtrl.fDimension` &mdash; preferred path is `child.setGroupOperatingPoint(downstream, upstream)`, which lets the child re-build its `groupPredict*` interpolators. Older children fall back to a direct `fDimension` write.
The equaliser is called from `handlePressureChange` (on every child pressure / predicted-flow event) and from the start of `_optimalControl`.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [EVOLV &mdash; Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
| [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |