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:
196
wiki/Reference-Contracts.md
Normal file
196
wiki/Reference-Contracts.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Reference — Contracts
|
||||
|
||||

|
||||
|
||||
> [!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"`) | — | Switch the dispatch strategy. `maintenance` is monitoring-only — 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) | — | Register a child machine manually. Port 2 wiring does this automatically in normal flows. |
|
||||
|
||||
### `set.demand` — unit-self-describing semantics
|
||||
|
||||
`src/commands/handlers.js` `setDemand`. The payload itself decides the meaning:
|
||||
|
||||
| Payload form | Interpretation |
|
||||
|:---|:---|
|
||||
| `42` (bare number) | 42 %. 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 — explicit-percent form. |
|
||||
| `{value: 80, unit: 'm3/h'}` (or `l/s` / `m3/s` / …) | 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'}` — the legacy "done" handshake some downstream flows still rely on.
|
||||
|
||||
---
|
||||
|
||||
## Data model — `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** — 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 — 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 — 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]` | — | Enforced at command-handler entry via `specificClass.isValidActionForMode`. |
|
||||
| (defaults) | `mode.allowedActions.priorityControl` | `[statusCheck, execSequentialControl, balanceLoad, emergencyStop]` | — | Same. |
|
||||
| (defaults) | `mode.allowedActions.maintenance` | `[statusCheck]` | — | Same — dispatch/emergencyStop are dropped with a warn log. |
|
||||
| (defaults) | `mode.allowedSources.optimalControl` | `["parent","GUI","physical","API"]` | — | Enforced via `specificClass.isValidSourceForMode`. |
|
||||
| (defaults) | `mode.allowedSources.priorityControl` | `["parent","GUI","physical","API"]` | — | Same. |
|
||||
| (defaults) | `mode.allowedSources.maintenance` | `["parent","GUI"]` | — | Physical/HMI and API writes dropped in maintenance — 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 — 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–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 — protects against accidentally writing raw numbers in the wrong scale.
|
||||
|
||||
---
|
||||
|
||||
## Child registration
|
||||
|
||||
Source: `src/specificClass.js` `configure()` lines 92–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()` — 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** — 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` — 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 — Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
|
||||
| [Reference — Examples](Reference-Examples) | Shipped flows |
|
||||
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
||||
| [EVOLV — Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
|
||||
| [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |
|
||||
Reference in New Issue
Block a user