# machineGroupControl ![code-ref](https://img.shields.io/badge/code--ref-26e92b5-blue) ![s88](https://img.shields.io/badge/S88-Unit-50a8d9) ![status](https://img.shields.io/badge/status-trial--ready-brightgreen) A `machineGroupControl` (MGC) coordinates two or more `rotatingMachine` children that share a common header. It accepts an operator demand setpoint, enumerates the valid pump combinations against the group's live flow/power envelope, picks the best operating point (BEP-Gravitation by default), and schedules per-machine flow setpoints + start/stop commands with **timing-aware rendezvous** so the running aggregate stays close to demand during transitions. --- ## At a glance | Thing | Value | |:---|:---| | What it represents | A pump group sharing one suction + one discharge header | | S88 level | Unit | | Use it when | You have 2 + pumps that can substitute for each other on the same header and you want efficient load-sharing | | Don't use it for | A single pump (wire `rotatingMachine` directly), valves (use `valveGroupControl`), or pumps living behind independent headers | | Children it accepts | `machine` (rotatingMachine), `measurement` (pressure / others) | | Parent it talks to | `pumpingStation` (typical), or any node that issues `set.demand` | --- ## How it fits ```mermaid flowchart LR parent[pumpingStation
Process Cell]:::pc -->|set.demand| mgc[machineGroupControl
Unit]:::unit header[measurement
header pressure]:::ctrl -.measured.-> mgc mgc -->|flowmovement / execsequence| m_a[rotatingMachine A]:::equip mgc -->|flowmovement / execsequence| m_b[rotatingMachine B]:::equip mgc -->|flowmovement / execsequence| m_c[rotatingMachine C]:::equip mgc -->|child.register| parent m_a -->|child.register| mgc m_b -->|child.register| mgc m_c -->|child.register| mgc classDef pc fill:#0c99d9,color:#fff classDef unit fill:#50a8d9,color:#000 classDef equip fill:#86bbdd,color:#000 classDef ctrl fill:#a9daee,color:#000 ``` S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`. --- ## Try it — 3-minute demo Import the basic example flow, deploy, and watch three pumps come online together when demand rises. ```bash curl -X POST -H 'Content-Type: application/json' \ --data @nodes/machineGroupControl/examples/01-Basic.json \ http://localhost:1880/flow ``` What to click in the dashboard after deploy: 1. The Setup group auto-fires `virtualControl` + `cmd.startup` on each child pump after ~1.5 s. 2. `set.demand = 50` (bare number = percent of group capacity) → MGC picks the best 1- or 2-pump combination by BEP-Gravitation. 3. `set.demand = { value: 80, unit: "m3/h" }` → absolute-flow setpoint. 4. `set.mode = priorityControl` → equal-flow distribution by priority order. 5. `set.demand = -1` → operator stop-all; `turnOffAllMachines` cancels any pending dispatch and shuts every active pump down. > [!IMPORTANT] > **GIF needed.** Demo recording of demand 50 % → 100 % → -1 with the live status panel. Save as `wiki/_partial-gifs/machineGroupControl/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`. --- ## The three things you'll send `set.demand` is **unit-self-describing** — the payload itself decides how the value is interpreted. There is no persistent `scaling` state on the orchestrator. | Topic | Aliases | Payload | What it does | |:---|:---|:---|:---| | `set.mode` | `setMode` | `"optimalControl"` \| `"priorityControl"` \| `"maintenance"` | Switches dispatch strategy. `maintenance` is monitoring-only. | | `set.demand` | `Qd` | bare number = %; `{value, unit}` for absolute units (`m3/h`, `l/s`, `m3/s`, …); negative = stop all | Operator demand setpoint. Resolves to canonical m³/s before dispatch. | | `child.register` | `registerChild` | child node id (string) | Manually register a child (Port 2 wiring does this automatically in most flows). | --- ## What you'll see come out Sample Port 0 message (delta-compressed — only changed fields each tick): ```json { "topic": "machineGroupControl#MGC1", "payload": { "mode": "optimalControl", "atEquipment_predicted_flow": 42.5, "downstream_predicted_flow": 42.5, "atEquipment_predicted_power": 18.0, "headerDiffPa": 145000, "headerDiffMbar": 1450, "flowCapacityMax": 90, "flowCapacityMin": 6, "machineCount": 3, "machineCountActive": 2, "absDistFromPeak": 0.02, "relDistFromPeak": 0.10 } } ``` | Field | Meaning | |:---|:---| | `mode` | Current dispatch mode. | | `atEquipment_predicted_flow` / `_power` | Group aggregate at the pump shafts. The optimizer writes intent here; `handlePressureChange` keeps it in sync with the live totals. | | `downstream_predicted_flow` | Live aggregate mirrored onto DOWNSTREAM — pumpingStation parents subscribe here. | | `headerDiffPa` / `headerDiffMbar` | Last header differential the equalizer resolved. Dashboards use it for Q-H plots without re-reading every child. | | `flowCapacityMax` / `flowCapacityMin` | The group's dynamic envelope at the current header pressure. Defines where `set.demand` (as %) maps to. | | `machineCount` / `machineCountActive` | All registered children, and how many are in a state other than `off` / `maintenance`. | | `absDistFromPeak` / `relDistFromPeak` | Distance from group BEP. `relDistFromPeak` is `undefined` when the η spread collapses (homogeneous pump group). | The key shape is `__` — the inverse of `rotatingMachine`'s `...` key shape, because MGC's output is the group aggregate, not a per-child snapshot. --- ## The new bit — the movement planner When MGC computes a new optimal combination it doesn't fan the commands out instantly. It builds a **schedule** that times each command so the running aggregate stays close to demand during the transition. ```mermaid flowchart LR demand[set.demand] --> dispatch[_runDispatch
latest-wins] dispatch --> abort[abortActiveMovements] abort --> opt[optimizer.calcBestCombination*] opt --> profiles[buildProfile
x children] profiles --> plan[movementScheduler.plan
rendezvous t* = max(eta_i)] plan --> exec[movementExecutor.replan
+ await tick()] exec --> kids[rotatingMachine x N
flowmovement / execsequence] ``` The planner classifies each pump's required move (`startup` / `flowmove` / `shutdown` / `noop`), computes an ETA per move via `MoveTrajectory`, sets the rendezvous time `t* = max(eta_i)` over flow-INCREASING moves, and delays flow-DECREASING moves so they FINISH at `t*`. Net effect: the sum of flows tracks the demand smoothly during the transition; on overshoot the header pressure rises and self-corrects. This path is exercised in `optimalControl` mode. `priorityControl` mode still uses the legacy direct-dispatch path (`control.equalFlowControl`) — the planner has not been wired through there yet. --- ## Need more? | Page | What you'll find | |:---|:---| | [Reference — Contracts](Reference-Contracts) | Topic registry, config schema, child registration filters | | [Reference — Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals, output ports | | [Reference — Examples](Reference-Examples) | Shipped flows, debug recipes | | [Reference — Limitations](Reference-Limitations) | When not to use, known issues, open questions | [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) · [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) · [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)