Table of Contents
- machineGroupControl
- 1. What this node is
- 2. Position in the platform
- 3. Capability matrix
- 4. Code map
- 5. Topic contract
- 6. Child registration
- 7. Lifecycle — what one event does
- 8. Data model — getOutput()
- 9. Configuration — editor form ↔ config keys
- 10. State chart
- 11. Examples
- 12. Debug recipes
- 13. When you would NOT use this node
- 14. Known limitations / current issues
machineGroupControl
Reflects code as of
7d19fc1· regenerated2026-05-11vianpm run wiki:allIf this banner is stale, the page may be out of date. Treat as informative, not authoritative.
1. What this node is
machineGroupControl (MGC) is an S88 Unit orchestrator that coordinates multiple rotatingMachine children sharing a common header. It receives a demand setpoint, evaluates valid pump combinations against the group's totals and curves, picks the best operating point (BEP-Gravitation or NCog), and dispatches per-machine flow setpoints + start/stop commands.
2. Position in the platform
flowchart LR
parent[pumpingStation<br/>Process Cell]:::pc -->|set.demand| mgc[machineGroupControl<br/>Unit]:::unit
header[measurement<br/>header pressure]:::ctrl -.data.-> 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: Process Cell #0c99d9, Unit #50a8d9, Equipment #86bbdd, Control Module #a9daee. Source of truth: .claude/rules/node-red-flow-layout.md.
3. Capability matrix
| Capability | Status | Notes |
|---|---|---|
| Aggregate group flow / power totals | ✅ | TotalsCalculator — absolute and dynamic. |
| Valid-combination enumeration | ✅ | combinatorics/pumpCombinations. |
| Best-combination optimiser (BEP-Gravitation) | ✅ | Directional or symmetric variant. |
| Best-combination optimiser (NCog) | ✅ | Normalised cost-of-goods score. |
| Priority / equal-flow control | ✅ | mode='prioritycontrol'. |
| Priority percentage control | ✅ | Requires scaling='normalized'. |
| Optimal control | ✅ | mode='optimalcontrol'. |
| Group efficiency + BEP distance | ✅ | GroupEfficiency. |
| Header-pressure equalisation | ✅ | operatingPoint.equalize(). |
| Demand serialisation (latest-wins) | ✅ | DemandDispatcher / LatestWinsGate.fireAndWait. |
Forced shutdown on Qd ≤ 0 |
✅ | turnOffAllMachines(). |
4. Code map
flowchart TB
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
nc["buildDomainConfig()<br/>static DomainClass, commands"]
end
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
sc["MachineGroup.configure()<br/>ChildRouter rules<br/>handleInput() dispatch gate"]
end
subgraph concerns["src/ concern modules"]
groupOps["groupOps/<br/>GroupOperatingPoint + curves"]
totals["totals/<br/>TotalsCalculator"]
combi["combinatorics/<br/>validPumpCombinations"]
opt["optimizer/<br/>BEP-Grav / NCog selectors"]
efficiency["efficiency/<br/>GroupEfficiency + BEP dist"]
ctrl["control/<br/>strategies (equalFlow / prioPct)"]
dispatch["dispatch/<br/>DemandDispatcher (LatestWinsGate)"]
io["io/<br/>output + status"]
commands["commands/<br/>topic registry + handlers"]
end
nc --> sc
sc --> groupOps
sc --> totals
sc --> combi
sc --> opt
sc --> efficiency
sc --> ctrl
sc --> dispatch
sc --> io
nc --> commands
| Module | Owns | Read first if you're changing… |
|---|---|---|
groupOps/ |
Group operating point + child read helpers | Header pressure handling, child measurement plumbing. |
totals/ |
Absolute + dynamic flow/power totals | Demand clamping, totals math. |
combinatorics/ |
Enumeration of valid pump subsets | Which combinations are considered eligible. |
optimizer/ |
Best-combination selectors | Optimiser selection method, scoring math. |
efficiency/ |
Group efficiency, BEP distance | BEP gravitation tuning, peak math. |
control/strategies.js |
Per-mode dispatch (priority, prioPct) | Mode behaviour, priorityList usage. |
dispatch/ |
DemandDispatcher wrapping LatestWinsGate.fireAndWait |
Demand serialisation, mid-flight overrides. |
commands/ |
Input-topic registry and handlers | New input topics, payload validation. |
io/ |
getOutput, getStatusBadge |
Output shape, dashboard badge. |
5. Topic contract
Auto-generated from
src/commands/index.js. Do NOT hand-edit between the markers. Re-runnpm run wiki:contract.
| Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---|---|
set.mode |
setMode |
string |
— | Switch the machine group between auto / manual modes. |
set.scaling |
setScaling |
string |
— | Select the group scaling strategy. |
child.register |
registerChild |
string |
— | Register a child machine with this group. |
set.demand |
Qd |
any |
volumeFlowRate (default m3/h) |
Operator demand setpoint dispatched to the child machines. |
6. Child registration
ChildRouter declarations in specificClass.js → configure().
flowchart LR
subgraph kids["accepted children (softwareType)"]
mach["machine<br/>(rotatingMachine)"]:::equip
m["measurement<br/>(header pressure)"]:::ctrl
end
mach -->|"pressure.measured.downstream<br/>pressure.measured.differential<br/>flow.predicted.downstream"| eq[operatingPoint.equalize<br/>+ totals refresh]
m -->|"<type>.measured.<position>"| mirror[mirror into own<br/>MeasurementContainer]
mirror -->|"if type === 'pressure'"| eq
eq --> emit[notifyOutputChanged]
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
| softwareType | filter / subscribed events | Side-effect |
|---|---|---|
machine |
onRegister stores in this.machines[id]; subscribes to pressure.measured.downstream, pressure.measured.differential, flow.predicted.downstream |
handlePressureChange() — equalise + recompute totals + recompute group efficiency. |
measurement |
onRegister attaches listener for <asset.type>.measured.<positionVsParent> |
Mirror value into MGC's own MeasurementContainer; pressure also triggers handlePressureChange(). |
7. Lifecycle — what one event does
sequenceDiagram
participant parent as pumpingStation
participant mgc as MGC
participant op as GroupOperatingPoint
participant tot as TotalsCalculator
participant opt as optimizer
participant kids as rotatingMachine[]
parent->>mgc: set.demand (Qd)
Note over mgc: dispatch gate — latest-wins
mgc->>mgc: abortActiveMovements('new demand')
mgc->>tot: calcDynamicTotals()
mgc->>mgc: clamp Qd to [minFlow, maxFlow]
alt mode=optimalcontrol
mgc->>mgc: validPumpCombinations(Qd)
mgc->>opt: pick best (BEP-Grav | NCog)
opt-->>mgc: bestCombination + bestFlow/Power
mgc->>kids: flowmovement (per-pump flow)
mgc->>kids: execsequence (startup / shutdown)
else mode=prioritycontrol
mgc->>mgc: equalFlowControl(Qd, powerCap, priorityList)
end
mgc->>op: writeOwn flow/power predicted (AT_EQUIPMENT + DOWNSTREAM)
mgc->>mgc: notifyOutputChanged()
8. Data model — getOutput()
What lands on Port 0. Composed in io/output.js → getOutput(this) and delta-compressed by outputUtils.formatMsg.
| Key | Type | Unit | Sample |
|---|---|---|---|
absDistFromPeak |
number | — | 0 |
mode |
string | — | "optimalcontrol" |
relDistFromPeak |
number | — | 0 |
scaling |
string | — | "normalized" |
Concrete sample (excerpt — see live test output for the canonical shape):
{
"mode": "optimalcontrol",
"scaling": "normalized",
"atEquipment_predicted_flow": 42.5,
"downstream_predicted_flow": 42.5,
"atEquipment_predicted_power": 18.0,
"atEquipment_predicted_efficiency": 0.65,
"atEquipment_predicted_Ncog": 1.23,
"absDistFromPeak": 0.02,
"relDistFromPeak": 0.10
}
Key format from io/output.js: <position>_<variant>_<type> (e.g. atEquipment_predicted_flow). Output units: flow in m3/h, power in kW, pressure in mbar.
9. Configuration — editor form ↔ config keys
flowchart TB
subgraph editor["Node-RED editor form"]
f1[Control mode dropdown]
f2[Scaling dropdown]
f3[Optimisation method]
f4[Output unit (flow)]
f5[Position vs parent]
f6[Allowed sources / actions per mode]
end
subgraph cfg["Domain config slice"]
c1[mode.current]
c2[scaling.current]
c3[optimization.method]
c4[general.unit]
c5[functionality.positionVsParent]
c6[mode.allowedSources<br/>mode.allowedActions]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
f6 --> c6
| Form field | Config key | Default | Range | Where used |
|---|---|---|---|---|
| Control mode | mode.current |
optimalControl |
enum (prioritycontrol, prioritypercentagecontrol, optimalcontrol) |
dispatch switch in _runDispatch |
| Scaling | scaling.current |
normalized |
enum (absolute, normalized) |
demand mapping in _runDispatch |
| Optimisation method | optimization.method |
BEP-Gravitation-Directional |
enum (NCog, BEP-Gravitation, BEP-Gravitation-Directional) |
_optimalControl selector |
| Output unit (flow) | general.unit |
m3/h |
unit string | unit policy output.flow |
| Position vs parent | functionality.positionVsParent |
atEquipment |
enum | event suffix for parent subscription |
10. State chart
MGC is event-driven and stateless with respect to operating modes — there is no FSM. The closest thing to "state" is the dispatch gate. Diagram for that single state vector:
stateDiagram-v2
[*] --> idle_disp: configure()
idle_disp --> dispatching: handleInput(Qd)
dispatching --> idle_disp: dispatch complete
dispatching --> dispatching: handleInput(Qd) — deferred and re-fired on completion
dispatching --> turning_off: Qd <= 0
turning_off --> idle_disp: all machines acknowledged shutdown
While dispatching, additional handleInput calls are absorbed by DemandDispatcher (latest-wins). A superseded call resolves with { superseded: true }. turnOffAllMachines() calls cancelPending() so turn-off is always the final intent.
11. Examples
| Tier | File | What it shows |
|---|---|---|
| 1 | examples/01-Basic.json |
One MGC + three rotatingMachine pumps driven by inject buttons. Setup auto-fires virtualControl + cmd.startup on all three pumps; numbered driver groups for mode / scaling / demand. |
| 2 | examples/02-Dashboard.json |
Same command surface driven by a FlowFuse Dashboard 2.0 page — Mode + Scaling buttons, Demand slider, live Status rows (mode / scaling / total flow / total power / capacity / active machines / BEP %), three trend charts, and a raw-output table. |
See examples/README.md for the canonical command surface table and step-by-step "what to try" recipes.
Important
Screenshots needed. Capture both flows in the editor + the rendered dashboard. Save under
wiki/_partial-screenshots/machineGroupControl/as01-basic-flow.png,02-dashboard-editor.png,03-dashboard-rendered.png. Replace this callout with the image links.
12. Debug recipes
| Symptom | First thing to check | Where to look |
|---|---|---|
| No combination selected | Demand outside [dynamicTotals.flow.min, max] — clamped on entry; _optimalControl returns early if combinations empty. |
validPumpCombinations + warn log. |
| Group flow stuck at zero | Machines never reach an ACTIVE_STATE — check per-pump startup logs. |
isMachineActive. |
| Priority-percentage mode warns and exits | Mode requires scaling='normalized'. Set both. |
_runDispatch switch. |
| Stale flow setpoints on chained calls | Dispatch gate may have superseded intermediate calls — callers should check result.superseded. |
DemandDispatcher / LatestWinsGate. |
| Header pressure not equalising | Pressure children must register with asset.type='pressure' and a matching position. |
operatingPoint.equalize. |
| Optimiser picks unexpected combo | Verify optimization.method and per-method scoring (NCog vs BEP-Grav). |
optimizer/. |
Never ship
enableLog: 'debug'in a demo — fills the container log within seconds and obscures real errors.
13. When you would NOT use this node
- Don't use MGC for a single pump — wire
rotatingMachinedirectly. MGC's combinatorics + totals add no value below N=2. - Don't use MGC for valves — use
valveGroupControl. MGC's optimiser assumes a flow-vs-pressure characteristic curve. - Don't use MGC when the pumps live behind independent headers — combinations assume a shared discharge / suction pressure.
14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | optimalControl requires every machine to expose a curve — null-curve members silently exclude themselves from combinations. |
combinatorics/pumpCombinations. |
| 2 | Mid-flight setpoint overrides on accelerating / decelerating rely on abortActiveMovements per dispatch — a sequence with no awaitable abortMovement will warn but proceed. |
abortActiveMovements. |
| 3 | Power-cap parameter exposed but not surfaced as a topic input — only programmatic via handleInput(source, demand, powerCap). |
commands/index.js — no canonical topic. |
| 4 | Per-pump fan-out for dashboard charts (per-machine flow / power series) not surfaced from MGC's Port 0 — only group aggregates appear. Subscribe to each rotatingMachine's Port 0 if you need per-pump trends. | io/output.js aggregates only. |
| 5 | maxEfficiency naming bug — GroupEfficiency.calcGroupEfficiency returns { maxEfficiency, lowestEfficiency } but maxEfficiency is actually the mean cog across all machines (not the maximum). The name is deliberately preserved for behavioural parity; callers using it as "the peak" will over-estimate the BEP target. |
efficiency/groupEfficiency.js comment + OPEN_QUESTIONS.md 2026-05-10. |
| 6 | calcAbsoluteTotals implicit pressure-key coupling — iterates machine.predictFlow.inputCurve and re-uses the same pressure key to index machine.predictPower.inputCurve[pressure]. If the two curves were sampled at different pressures the lookup is undefined and the call throws. Enforcement or defensive skip deferred to P5 (rotatingMachine curveLoader). |
totals/totalsCalculator.js + OPEN_QUESTIONS.md 2026-05-10. |