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>
12 KiB
Reference — Contracts
Note
Full topic contract, configuration schema, and child-registration filters for
machineGroupControl. Source of truth:src/commands/index.js,src/specificClass.jsconfigure(), and the schema atgeneralFunctions/src/configs/machineGroupControl.json.For an intuitive overview, return to the 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.currentis normalised at write time byspecificClass.setMode: legacy lowercase inputs (optimalcontrol,prioritycontrol) are accepted and stored as the canonical camelCase. The_runDispatchswitch 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
maintenanceno longer reaches_runDispatchat all in normal operation: the mode-action gate atcommands/handlers.jsdrops the incomingset.demandbefore 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:
- Read MGC's own group-scope pressure (downstream and upstream) from its MeasurementContainer.
- Read each child's measured pressure (downstream / upstream).
- Pick:
headerDownstream= group reading if positive, elsemaxacross children.headerUpstream= group reading if positive, elseminacross children.
- If the differential is non-positive, skip the equalisation (debug log).
- Stash the diff on
this.headerDiffPa(used bygetOutputand by every η computation). - Push the diff onto each child's
predictFlow.fDimension/predictPower.fDimension/predictCtrl.fDimension— preferred path ischild.setGroupOperatingPoint(downstream, upstream), which lets the child re-build itsgroupPredict*interpolators. Older children fall back to a directfDimensionwrite.
The equaliser is called from handlePressureChange (on every child pressure / predicted-flow event) and from the start of _optimalControl.
Related pages
| Page | Why |
|---|---|
| Home | Intuitive overview |
| Reference — Architecture | Code map, dispatch lifecycle, planner internals |
| Reference — Examples | Shipped flows |
| Reference — Limitations | Known issues and open questions |
| EVOLV — Topic Conventions | Platform-wide topic rules |
| EVOLV — Telemetry | Port 0 / 1 / 2 InfluxDB layout |