Files
machineGroupControl/wiki/Reference-Contracts.md
znetsixe 472402c62d 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

197 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |