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:
342
wiki/Home.md
342
wiki/Home.md
@@ -1,18 +1,30 @@
|
||||
# machineGroupControl
|
||||
|
||||
> **Reflects code as of `7d19fc1` · regenerated `2026-05-11` via `npm run wiki:all`**
|
||||
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
|
||||
  
|
||||
|
||||
## 1. What this node is
|
||||
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.
|
||||
|
||||
**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
|
||||
## 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<br/>Process Cell]:::pc -->|set.demand| mgc[machineGroupControl<br/>Unit]:::unit
|
||||
header[measurement<br/>header pressure]:::ctrl -.data.-> mgc
|
||||
header[measurement<br/>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
|
||||
@@ -26,259 +38,111 @@ flowchart LR
|
||||
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`.
|
||||
S88 colours are anchored in `.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()`. |
|
||||
## Try it — 3-minute demo
|
||||
|
||||
## 4. Code map
|
||||
Import the basic example flow, deploy, and watch three pumps come online together when demand rises.
|
||||
|
||||
```mermaid
|
||||
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
|
||||
```bash
|
||||
curl -X POST -H 'Content-Type: application/json' \
|
||||
--data @nodes/machineGroupControl/examples/01-Basic.json \
|
||||
http://localhost:1880/flow
|
||||
```
|
||||
|
||||
| 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. |
|
||||
What to click in the dashboard after deploy:
|
||||
|
||||
## 5. Topic contract
|
||||
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.
|
||||
|
||||
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
|
||||
> [!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`.
|
||||
|
||||
<!-- BEGIN AUTOGEN: topic-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. |
|
||||
## The three things you'll send
|
||||
|
||||
<!-- END AUTOGEN: topic-contract -->
|
||||
`set.demand` is **unit-self-describing** — the payload itself decides how the value is interpreted. There is no persistent `scaling` state on the orchestrator.
|
||||
|
||||
## 6. Child registration
|
||||
| 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). |
|
||||
|
||||
`ChildRouter` declarations in `specificClass.js → configure()`.
|
||||
---
|
||||
|
||||
## 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 `<position>_<variant>_<type>` — the inverse of `rotatingMachine`'s `<type>.<variant>.<position>.<childId>` 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
|
||||
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
|
||||
demand[set.demand] --> dispatch[_runDispatch<br/>latest-wins]
|
||||
dispatch --> abort[abortActiveMovements]
|
||||
abort --> opt[optimizer.calcBestCombination*]
|
||||
opt --> profiles[buildProfile<br/>x children]
|
||||
profiles --> plan[movementScheduler.plan<br/>rendezvous t* = max(eta_i)]
|
||||
plan --> exec[movementExecutor.replan<br/>+ await tick()]
|
||||
exec --> kids[rotatingMachine x N<br/>flowmovement / execsequence]
|
||||
```
|
||||
|
||||
| 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()`. |
|
||||
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.
|
||||
|
||||
## 7. Lifecycle — what one event does
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
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()
|
||||
```
|
||||
## Need more?
|
||||
|
||||
## 8. Data model — `getOutput()`
|
||||
| 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 |
|
||||
|
||||
What lands on Port 0. Composed in `io/output.js → getOutput(this)` and delta-compressed by `outputUtils.formatMsg`.
|
||||
|
||||
<!-- BEGIN AUTOGEN: data-model -->
|
||||
|
||||
| Key | Type | Unit | Sample |
|
||||
|---|---|---|---|
|
||||
| `absDistFromPeak` | number | — | `0` |
|
||||
| `mode` | string | — | `"optimalcontrol"` |
|
||||
| `relDistFromPeak` | number | — | `0` |
|
||||
| `scaling` | string | — | `"normalized"` |
|
||||
|
||||
<!-- END AUTOGEN: data-model -->
|
||||
|
||||
**Concrete sample** (excerpt — see live test output for the canonical shape):
|
||||
|
||||
~~~json
|
||||
{
|
||||
"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
|
||||
|
||||
```mermaid
|
||||
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:
|
||||
|
||||
```mermaid
|
||||
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`](https://gitea.wbd-rd.nl/RnD/machineGroupControl/src/branch/development/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/` as `01-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 `rotatingMachine` directly. 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. |
|
||||
[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)
|
||||
|
||||
261
wiki/Reference-Architecture.md
Normal file
261
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Reference — Architecture
|
||||
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> Code structure for `machineGroupControl`: the three-tier sandwich, the `src/` layout, the dispatch lifecycle, the movement planner that fans commands out, and the output-port pipeline. Everything here is reproducible from `src/`. For an intuitive overview, return to [Home](Home).
|
||||
|
||||
---
|
||||
|
||||
## Three-tier code layout
|
||||
|
||||
```
|
||||
nodes/machineGroupControl/
|
||||
|
|
||||
+-- mgc.js entry: RED.nodes.registerType('machineGroupControl', NodeClass)
|
||||
|
|
||||
+-- src/
|
||||
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
|
||||
| specificClass.js extends BaseDomain (orchestration only)
|
||||
| |
|
||||
| +-- commands/
|
||||
| | index.js topic descriptors
|
||||
| | handlers.js pure handler functions (unit-self-describing set.demand)
|
||||
| |
|
||||
| +-- groupOps/
|
||||
| | groupOperatingPoint.js header equalisation + child read helpers
|
||||
| | groupCurves.js per-machine curve adapters used by optimizer + strategies
|
||||
| |
|
||||
| +-- totals/
|
||||
| | totalsCalculator.js absolute, dynamic, and active envelopes
|
||||
| |
|
||||
| +-- combinatorics/
|
||||
| | pumpCombinations.js enumerate valid pump subsets that can deliver Qd
|
||||
| |
|
||||
| +-- optimizer/
|
||||
| | index.js selector (CoG vs BEP-Gravitation variants)
|
||||
| | bestCombination.js N-CoG optimizer
|
||||
| | bepGravitation.js BEP-Gravitation (+ Directional variant)
|
||||
| |
|
||||
| +-- efficiency/
|
||||
| | groupEfficiency.js group η, BEP distance (abs + relative)
|
||||
| |
|
||||
| +-- control/
|
||||
| | strategies.js equalFlowControl (priority mode legacy direct dispatch)
|
||||
| |
|
||||
| +-- dispatch/
|
||||
| | demandDispatcher.js thin wrapper over LatestWinsGate.fireAndWait
|
||||
| |
|
||||
| +-- movement/
|
||||
| | machineProfile.js pure snapshot of a registered child for the planner
|
||||
| | moveTrajectory.js per-pump ETA-to-target math
|
||||
| | movementScheduler.js rendezvous planner (pure)
|
||||
| | movementExecutor.js tick-driven, async-aware command firer
|
||||
| |
|
||||
| +-- io/
|
||||
| output.js getOutput() shape + status badge
|
||||
```
|
||||
|
||||
### Tier responsibilities
|
||||
|
||||
| Tier | File | What it owns | Touches `RED.*` |
|
||||
|:---|:---|:---|:---:|
|
||||
| entry | `mgc.js` | Type registration | Yes |
|
||||
| nodeClass | `src/nodeClass.js` | Input routing, output ports, status badge polling (`statusInterval=1000`). No tick loop — event-driven. | Yes |
|
||||
| specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; route demand through `DemandDispatcher`; pick mode in `_runDispatch`; own the planner's wall-clock driver. | No |
|
||||
|
||||
`specificClass` is stitching. All real work lives in the concern modules: pure math in `combinatorics/`, `optimizer/`, `efficiency/`, `movement/{moveTrajectory,movementScheduler}`; live-state-touching in `groupOps/`, `totals/`, `control/`, `dispatch/`, `movement/movementExecutor`.
|
||||
|
||||
---
|
||||
|
||||
## The dispatch lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant parent as pumpingStation / UI
|
||||
participant gate as DemandDispatcher (LatestWinsGate)
|
||||
participant disp as _runDispatch
|
||||
participant abort as abortActiveMovements
|
||||
participant opt as optimizer
|
||||
participant plan as movementScheduler
|
||||
participant exec as movementExecutor
|
||||
participant kids as rotatingMachine[]
|
||||
|
||||
parent->>gate: handleInput(Qd)
|
||||
Note over gate: latest-wins:<br/>parked demand is dropped if a fresher one arrives
|
||||
gate->>disp: payload.demand = canonical m³/s
|
||||
disp->>abort: abortActiveMovements('new demand')
|
||||
disp->>disp: calcDynamicTotals + clamp Qd to envelope
|
||||
alt mode = optimalControl
|
||||
disp->>opt: pickOptimizer(method).calcBestCombination*
|
||||
opt-->>disp: bestCombination + bestFlow / bestPower / bestCog
|
||||
disp->>plan: plan(profiles, combination, headerDiffPa)
|
||||
plan-->>disp: schedule {tStarS, tickS, commands[]}
|
||||
disp->>exec: replan(schedule)
|
||||
disp->>exec: await tick() (FIRST tick, synchronous race-favouring)
|
||||
Note over exec: setInterval(1000ms) drives further ticks<br/>auto-stops when pending() == 0
|
||||
else mode = priorityControl
|
||||
disp->>disp: control.equalFlowControl(ctx, Qd, powerCap, priorityList)
|
||||
Note over disp: Legacy direct fan-out:<br/>await Promise.all(...handleInput...)
|
||||
end
|
||||
exec->>kids: flowmovement / execsequence (per scheduled tick)
|
||||
disp->>disp: handlePressureChange-style refresh<br/>notifyOutputChanged
|
||||
```
|
||||
|
||||
Key facts the diagram pins down:
|
||||
|
||||
| Fact | Why it matters |
|
||||
|:---|:---|
|
||||
| Demand serialisation is **latest-wins**, not FIFO | A burst of demand updates collapses to a single dispatch. Parked demands resolve with `{ superseded: true }` so callers can branch on it. |
|
||||
| `abortActiveMovements` only aborts pumps in `accelerating` / `decelerating` | Warmup / cooldown are protected at the pump's FSM; aborting them is silently ignored there. |
|
||||
| `_runDispatch` **awaits the first executor tick** | Synchronous first-tick fire gives the new move's residue-handler priority over an in-flight shutdown sequence's for-loop. Fire-and-forget would lose the race in real wall-clock conditions. |
|
||||
| The 1 Hz `setInterval` only runs while `executor.pending() > 0` | Idle MGCs don't burn a forever-on timer. |
|
||||
| Negative demand goes straight to `turnOffAllMachines` | And `turnOffAllMachines` calls `dispatcher.cancelPending` so a parked positive demand can't re-engage pumps post-shutdown. |
|
||||
| `priorityControl` uses the legacy direct-dispatch path | The planner is not (yet) wired through `equalFlowControl`. See [Reference — Limitations](Reference-Limitations). |
|
||||
|
||||
---
|
||||
|
||||
## The movement planner
|
||||
|
||||
The planner is the new architectural layer between the optimizer and the children. It exists so that when MGC re-balances during transitions, the running aggregate flow stays close to demand instead of dipping while one pump warms up and another keeps spinning.
|
||||
|
||||
### 1. `buildProfile(child)` — pure read
|
||||
|
||||
A plain-object snapshot of a registered child machine. Returns:
|
||||
|
||||
| Field | Source | Notes |
|
||||
|:---|:---|:---|
|
||||
| `id` | `child.config.general.id` | |
|
||||
| `state` | `child.state.getCurrentState()` | One of `idle`, `starting`, `warmingup`, `operational`, `accelerating`, `decelerating`, `stopping`, `coolingdown`, `off`, `emergencystop`, `maintenance`. |
|
||||
| `position` | `child.state.getCurrentPosition()` | Control % (`0..100`). |
|
||||
| `minPosition` / `maxPosition` | `child.state.movementManager` | |
|
||||
| `velocityPctPerS` | `movementManager.getNormalizedSpeed() × range` | Movement ramp rate in position-units / second. |
|
||||
| `timings` | `child.config.stateConfig.time` | `{startingS, warmingupS, stoppingS, coolingdownS}` — the configured durations the FSM spends in each timed state. |
|
||||
| `remainingTransitionS` | `child.state.stateManager.getRemainingTransitionS()` | Wall-clock-aware remaining seconds in the current timed state. 0 for untimed states. |
|
||||
| `flowAt(pos, pressure)` | `child.predictFlow.evaluate` | Forward curve (position → flow). |
|
||||
| `positionForFlow(flow)` | `child.predictCtrl.y` | Inverse curve (flow → control %); mirrors what `flowController` does on a `flowmovement` command. |
|
||||
|
||||
No contract changes — MGC already holds the live child reference (`this.machines[id]`); the profile is just a read of that.
|
||||
|
||||
### 2. `MoveTrajectory` — per-pump ETA math
|
||||
|
||||
Given a profile and a `targetPosition`, `etaToTargetS()` returns seconds-to-target-flow:
|
||||
|
||||
| Current state | ETA |
|
||||
|:---|:---|
|
||||
| `idle` / `off` / `emergencystop` / `maintenance` | `startingS + warmingupS + (target − minPosition) / velocity` |
|
||||
| `operational` / `accelerating` / `decelerating` (post-abort residue) | `\|target − position\| / velocity` |
|
||||
| `warmingup` | `remainingTransitionS + (target − minPosition) / velocity` |
|
||||
| `starting` | `remainingTransitionS + warmingupS + (target − minPosition) / velocity` |
|
||||
| `stopping` / `coolingdown` | `null` — pump cannot contribute on this dispatch |
|
||||
|
||||
Velocity of 0 returns `Infinity` so the scheduler can demote the machine without crashing. Targets are clamped to `[minPosition, maxPosition]` at construction.
|
||||
|
||||
### 3. `movementScheduler.plan` — rendezvous
|
||||
|
||||
Pure function. Inputs: `(profiles[], combination, currentPressurePa, { tickS = 1 })`. Output:
|
||||
|
||||
```js
|
||||
{
|
||||
tStarS: 60, // rendezvous time in seconds
|
||||
tickS: 1, // tick cadence
|
||||
commands: [
|
||||
{ machineId: 'A', action: 'execsequence', sequence: 'startup', fireAtTickN: 0, eta: 60 },
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 60 },
|
||||
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 40, eta: 20 },
|
||||
{ machineId: 'C', action: 'execsequence', sequence: 'shutdown', fireAtTickN: 55, eta: 5 }
|
||||
],
|
||||
_plans: [...] // per-machine classification + eta + direction; useful in tests
|
||||
}
|
||||
```
|
||||
|
||||
Algorithm:
|
||||
|
||||
1. **Classify** each machine's move against the optimizer's target flow:
|
||||
- `targetFlow > 0` and pump off → `startup`
|
||||
- `targetFlow > 0` and pump on (any active or startup-ladder state) → `flowmove`
|
||||
- `targetFlow <= 0` and pump on → `shutdown`
|
||||
- Otherwise → `noop`
|
||||
2. **Direction**: compare target flow against the pump's current flow (via `profile.flowAt`). Increasing, decreasing, or unchanged.
|
||||
3. **ETA**: `MoveTrajectory.etaToTargetS()` (or, for shutdowns, the position-ramp time to `minPosition`).
|
||||
4. **Rendezvous**: `t* = max(eta_i)` over flow-INCREASING moves.
|
||||
5. **Schedule**: increasing / unchanged moves fire at `fireAtTickN = 0`; decreasing moves fire at `fireAtTickN = round((t* − eta_j) / tickS)` so they finish at `t*`.
|
||||
|
||||
Net behaviour: during a transition the flow sum tracks demand smoothly. On overshoot, header pressure rises and individual pumps deliver less — a self-correcting undershoot. On undershoot, demand simply lands a few ticks later than ideal.
|
||||
|
||||
### 4. `MovementExecutor` — tick-driven, async-aware
|
||||
|
||||
Holds the active schedule plus a cursor (`_cursor`) that advances one per `tick()`. Each tick fires every unfired command whose `fireAtTickN <= cursor` via an injected `fireCommand` callback. The callback returns a Promise (in production, the `machine.handleInput(...)` promise); `tick()` awaits all of those before resolving.
|
||||
|
||||
`replan(newSchedule)` replaces the schedule and resets the cursor to 0. Already-fired commands stay fired — the pump's FSM downstream owns their consequences; the executor never tries to "undo" a fired startup (which keeps warmup / cooldown safety intact).
|
||||
|
||||
Wall-clock driver lives on the MGC itself (`_ensureExecutorTimer`): a `setInterval(1000)` that calls `tick()` and clears itself when `pending() === 0`. `unref()` keeps the timer from blocking Node-RED shutdown.
|
||||
|
||||
### 5. The cooperating FSM change (in `rotatingMachine`)
|
||||
|
||||
For the planner to be robust, the pump's `executeSequence` honours a **sequence-abort token** that MGC's external aborts advance. Without this, an in-flight shutdown's for-loop would race against the new dispatch's residue handler and could win — transitioning `operational → stopping → coolingdown → idle` even after the new move took the FSM operational.
|
||||
|
||||
See the rotatingMachine wiki's [Architecture — FSM section](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Reference-Architecture#fsm) for the full mechanism. Summary:
|
||||
|
||||
- `state.abortCurrentMovement(reason, { returnToOperational: false })` — the default form, used by MGC's `abortActiveMovements` — increments `state.sequenceAbortToken`.
|
||||
- `executeSequence` captures the token at entry and re-checks it before every state transition in its for-loop. A mismatch exits the loop early with a `Sequence '<name>' interrupted ... by external abort` warning.
|
||||
- Sequence-internal aborts (`returnToOperational: true`, used when a fresher shutdown pre-empts its own setpoint ramp) do NOT advance the token. So the shutdown's own ramp-down to zero is interruptible without terminating the shutdown sequence itself.
|
||||
|
||||
---
|
||||
|
||||
## Output ports
|
||||
|
||||
| Port | Carries | Sample shape |
|
||||
|:---|:---|:---|
|
||||
| 0 (process) | Delta-compressed state snapshot — group aggregates, header diff, BEP distance, machine counts | `{topic, payload: {mode, atEquipment_predicted_flow, headerDiffPa, machineCountActive, ...}}` |
|
||||
| 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | `machineGroupControl,id=MGC1 atEquipment_predicted_flow=42.5,... ` |
|
||||
| 2 (register / control) | `child.register` upward at init | `{topic: 'child.register', payload: {ref, softwareType, config}}` |
|
||||
|
||||
Port-0 key shape is **`<position>_<variant>_<type>`** — group aggregates only. Per-pump series live on each `rotatingMachine`'s Port 0 (with the inverted `<type>.<variant>.<position>.<childId>` shape). Subscribe per-child if you need per-pump trends on a dashboard.
|
||||
|
||||
See [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the full InfluxDB layout.
|
||||
|
||||
---
|
||||
|
||||
## Event sources
|
||||
|
||||
| Source | Where it fires | What it triggers |
|
||||
|:---|:---|:---|
|
||||
| `setInterval(_executorIntervalMs = 1000)` | Driven by `_ensureExecutorTimer` after a successful `optimalControl` plan | `movementExecutor.tick()` |
|
||||
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render |
|
||||
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch to `set.mode` / `set.demand` / `child.register` |
|
||||
| Child measurement event | `child.measurements.emitter` after a measurement landed | `handlePressureChange()` (for pressure) or value mirror (for everything else) |
|
||||
| Child prediction event | `child.emitter` "flow.predicted.downstream" | `handlePressureChange()` |
|
||||
| `child.register` from a pump | Port 2 of the pump | `onRegister('machine', ...)` — stores ref in `this.machines[id]` |
|
||||
|
||||
MGC has **no per-second tick of its own**. It's purely event-driven plus the planner's optional wall-clock executor.
|
||||
|
||||
---
|
||||
|
||||
## Where to start reading
|
||||
|
||||
| If you're changing... | Read first |
|
||||
|:---|:---|
|
||||
| The dispatch flow, latest-wins semantics, mode switch | `src/specificClass.js` `_runDispatch` (lines 318–349) |
|
||||
| Topic registration, payload validation | `src/commands/index.js` + `src/commands/handlers.js` |
|
||||
| Optimizer selection / scoring | `src/optimizer/index.js`, `bepGravitation.js`, `bestCombination.js` |
|
||||
| Header-pressure equalisation | `src/groupOps/groupOperatingPoint.js` `equalize()` |
|
||||
| Combination enumeration | `src/combinatorics/pumpCombinations.js` |
|
||||
| Per-pump ETA, rendezvous math | `src/movement/moveTrajectory.js`, `movementScheduler.js` |
|
||||
| Wall-clock tick wiring | `src/specificClass.js` `_ensureExecutorTimer` (lines 290–301) |
|
||||
| Output shape, status badge | `src/io/output.js` |
|
||||
| Priority-mode equal-flow distribution | `src/control/strategies.js` |
|
||||
|
||||
---
|
||||
|
||||
## Related pages
|
||||
|
||||
| Page | Why |
|
||||
|:---|:---|
|
||||
| [Home](Home) | Intuitive overview |
|
||||
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
||||
| [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home) | The child node: FSM, prediction, drift |
|
||||
| [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|
||||
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 |
|
||||
155
wiki/Reference-Examples.md
Normal file
155
wiki/Reference-Examples.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Reference — Examples
|
||||
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> Every example flow shipped under `nodes/machineGroupControl/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/machineGroupControl/examples/`.
|
||||
|
||||
---
|
||||
|
||||
## Shipped examples
|
||||
|
||||
| File | Tier | What it shows |
|
||||
|:---|:---:|:---|
|
||||
| `examples/01-Basic.json` | 1 | One MGC + three `rotatingMachine` pumps driven by inject buttons. A Setup group once-fires `virtualControl` + `cmd.startup` on all three pumps; mode / demand are then driven by buttons. |
|
||||
| `examples/02-Dashboard.json` | 2 | Same command surface driven by a FlowFuse Dashboard 2.0 page — mode buttons, demand slider, live status rows (mode / total flow / total power / capacity / active machines / BEP %), trend charts, and a raw-output table. |
|
||||
|
||||
MGC is not a standalone node — it needs at least one `rotatingMachine` child to dispatch to. Both flows ship three child pumps.
|
||||
|
||||
---
|
||||
|
||||
## Loading a flow
|
||||
|
||||
### Via the editor
|
||||
|
||||
1. Open the Node-RED editor at `http://localhost:1880`.
|
||||
2. Menu → Import.
|
||||
3. Drag-and-drop the JSON file, or paste its contents.
|
||||
4. Click Deploy.
|
||||
|
||||
### Via the Admin API
|
||||
|
||||
```bash
|
||||
curl -X POST -H 'Content-Type: application/json' \
|
||||
--data @nodes/machineGroupControl/examples/01-Basic.json \
|
||||
http://localhost:1880/flows
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 01 — Basic standalone
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Screenshot needed.** Capture of the basic flow in the editor. Save as `wiki/_partial-screenshots/machineGroupControl/01-basic-flow.png`. Replace this callout with the image link.
|
||||
|
||||
### Nodes on the tab
|
||||
|
||||
| Type | Purpose |
|
||||
|:---|:---|
|
||||
| `comment` | Tab header / instructions / driver-group labels |
|
||||
| `inject` | Setup auto-injects (virtualControl + cmd.startup per pump), mode buttons, demand-by-percent buttons, demand-by-absolute-unit buttons, stop-all button |
|
||||
| `machineGroupControl` | The unit under test |
|
||||
| `rotatingMachine` × 3 | Children A / B / C (each with its own simulated pressure pair) |
|
||||
| `debug` | Port 0 (process), Port 1 (telemetry), Port 2 (registration) per node |
|
||||
|
||||
### What to do after deploy
|
||||
|
||||
1. Wait ~1.5 s. The Setup group auto-fires `virtualControl` + `cmd.startup` on all three pumps.
|
||||
2. Click `set.demand = 50` (bare number = percent). MGC selects the best combination via BEP-Gravitation, plans a rendezvous, and dispatches `flowmovement` to the selected pumps.
|
||||
3. Click `set.demand = 100`. The optimizer probably engages a third pump; the planner schedules its `execsequence(startup)` at tick 0 and delays the running pumps' down-moves so they all hit their new targets together at `t*`.
|
||||
4. Click `set.mode = priorityControl`. Subsequent demands route through `equalFlowControl` — equal-flow per active pump in priority order. (Planner is bypassed in this mode — see [Limitations](Reference-Limitations).)
|
||||
5. Click `set.demand = {value: 80, unit: 'm3/h'}` (or use the absolute-unit button). Same path, but the percent-mapping step is skipped — the value lands on the gate as canonical m³/s directly.
|
||||
6. Click `set.demand = -1`. `turnOffAllMachines` runs: cancels any parked demand, sends `execsequence: 'shutdown'` to every active pump.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **GIF needed.** Demo of steps 1–6 with the live status panel. Save as `wiki/_partial-gifs/machineGroupControl/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
|
||||
|
||||
---
|
||||
|
||||
## Example 02 — Dashboard
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Screenshots needed.** Two captures from `02-Dashboard.json`:
|
||||
> 1. The editor tab (left controls column + MGC + 3 pumps + dashboard widget cluster on the right).
|
||||
> 2. The rendered dashboard at `http://localhost:1880/dashboard/mgc-basic`.
|
||||
>
|
||||
> Save as `wiki/_partial-screenshots/machineGroupControl/02-dashboard-editor.png` and `03-dashboard-rendered.png`. Replace this callout with both image links.
|
||||
|
||||
### What it adds vs Example 01
|
||||
|
||||
| Addition | Why |
|
||||
|:---|:---|
|
||||
| FlowFuse `ui-base` + `ui-theme` + `ui-page` setup | One dashboard page hosting four widget groups |
|
||||
| `ui-button` cluster (Controls) | Mode buttons, `Initialize pumps`, `Stop all` |
|
||||
| `ui-slider` (Demand) | Drag-to-set demand; passes through the same canonical `set.demand` topic the injects use |
|
||||
| `ui-text` cluster (Status) | Mode / total flow / total power / capacity / active machines / BEP % rows |
|
||||
| `ui-chart` × N (Trends) | Flow, power, BEP trends over time |
|
||||
| `ui-template` (Raw output) | Full key/value table of the latest Port 0 payload |
|
||||
| Fan-out function | Caches last-known values so delta-only Port 0 updates never blank a status row, and forwards numeric values to charts |
|
||||
|
||||
The dashboard buttons fire the **same canonical `msg.topic`** as the inject nodes in Example 01 — there is no separate dashboard command surface to learn.
|
||||
|
||||
Required: `@flowfuse/node-red-dashboard` (Dashboard 2.0) installed in the Node-RED instance.
|
||||
|
||||
### What to do after deploy
|
||||
|
||||
1. Open `http://localhost:1880/dashboard/mgc-basic`.
|
||||
2. The page auto-initialises the pumps; the `Initialize pumps` button re-runs the setup manually.
|
||||
3. Drag the **Demand** slider. The Status row's `total flow` and `BEP %` react; the trend charts plot the transition.
|
||||
4. Switch modes. The mode row in Status reflects the change immediately.
|
||||
5. Inspect the **Raw output** table for the full Port-0 surface — `headerDiffPa`, `flowCapacityMax`, `machineCountActive`, `relDistFromPeak`, …
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **GIF needed.** Capture clicking through demand 30 % → 80 % → -1 with the trends reacting. 30–45 s is enough.
|
||||
>
|
||||
> Save as `wiki/_partial-gifs/machineGroupControl/02-dashboard-demo.gif`. Replace this callout with the image link.
|
||||
|
||||
---
|
||||
|
||||
## Docker compose snippet
|
||||
|
||||
To bring up Node-RED + InfluxDB with EVOLV nodes pre-loaded:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (extract)
|
||||
services:
|
||||
nodered:
|
||||
build: ./docker/nodered
|
||||
ports: ['1880:1880']
|
||||
volumes:
|
||||
- ./docker/nodered/data:/data/evolv
|
||||
influxdb:
|
||||
image: influxdb:2.7
|
||||
ports: ['8086:8086']
|
||||
```
|
||||
|
||||
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
|
||||
|
||||
---
|
||||
|
||||
## Debug recipes
|
||||
|
||||
| Symptom | First thing to check | Where to look |
|
||||
|:---|:---|:---|
|
||||
| `mode is not a valid mode` warns every dispatch | `mode.current` is `maintenance` (or a typo). Reset to `optimalControl` or `priorityControl`. | `_runDispatch` switch. |
|
||||
| `No valid combination found (empty set)` | Demand outside the dynamic envelope, OR every child filtered out (state in `off / coolingdown / stopping / emergencystop` or `auto`-mode rejects the action). | `validPumpCombinations` + state of each child. |
|
||||
| Group flow stuck at zero after `set.demand` | Pumps never reached an active state — check per-pump startup logs. | Each pump's `state` on its Port 0. |
|
||||
| Pump warmingup, but then drops back to idle when demand keeps changing | Pre-2026-05-15 race condition: shutdown's for-loop barged through after a residue-handler operational transition. The fix is the `sequenceAbortToken` mechanism in rotatingMachine's FSM. Verify the rotatingMachine submodule is at `394a972` or newer. | rotatingMachine `state/sequenceController.js`. |
|
||||
| Header pressure not equalising | Pressure children must register with `asset.type='pressure'` and a matching `positionVsParent`. Pure-numeric pressures with no unit are rejected by MeasurementContainer. | `operatingPoint.equalize`. |
|
||||
| Optimiser picks unexpected combination | Verify `optimization.method` — default is `BEP-Gravitation-Directional`. Per-method scoring lives in `optimizer/`. | `optimizer/{bestCombination, bepGravitation}.js`. |
|
||||
| Status badge shows `scaling=norm` even after a unit-tagged demand | Badge cosmetic only — the `scaling` field is a legacy artifact and currently always reads `norm`. The dispatch path is unit-self-describing. | `io/output.js` `getStatusBadge`. |
|
||||
| Per-pump flow / power trends missing | MGC only emits group aggregates on Port 0. Subscribe to each `rotatingMachine`'s Port 0 if you need per-pump series. | `io/output.js` `getOutput`. |
|
||||
|
||||
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors.
|
||||
|
||||
---
|
||||
|
||||
## Related pages
|
||||
|
||||
| Page | Why |
|
||||
|:---|:---|
|
||||
| [Home](Home) | Intuitive overview |
|
||||
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||
| [Reference — Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
|
||||
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
||||
| [EVOLV — Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where this node fits in a larger plant |
|
||||
128
wiki/Reference-Limitations.md
Normal file
128
wiki/Reference-Limitations.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Reference — Limitations
|
||||
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> What `machineGroupControl` does not do, current rough edges, and open questions. The planner-decline question is tracked as Gitea issue `RnD/machineGroupControl#1`; other open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` in the superproject.
|
||||
|
||||
---
|
||||
|
||||
## When you would not use this node
|
||||
|
||||
| Scenario | Use instead |
|
||||
|:---|:---|
|
||||
| A single pump | Wire `rotatingMachine` directly under your parent. MGC's combinatorics + totals add no value below N=2. |
|
||||
| Valves (no curve, no FSM-driven motor) | `valveGroupControl`. MGC's optimizer assumes a flow-vs-pressure characteristic. |
|
||||
| Pumps behind independent headers | Multiple MGCs (one per header), each parented to its own logical aggregator. The equaliser assumes a shared discharge / suction pressure. |
|
||||
| Curve-less assets | Without a curve, `optimalControl` excludes the machine from every combination; the dispatch loop falls into the empty-set branch and warns each tick. |
|
||||
| Mixed compressor + pump groups | The optimizer is curve-agnostic in principle, but the η = (Q·ΔP)/P_shaft identity used in `_optimalControl` assumes an incompressible-flow head. Use separate MGCs per phase. |
|
||||
|
||||
---
|
||||
|
||||
## Known limitations
|
||||
|
||||
### `maintenance` mode is in the schema but not in the dispatch switch
|
||||
|
||||
`config.mode.current` accepts `maintenance` as a valid value (per the schema enum), but `_runDispatch`'s mode switch only handles `optimalcontrol` and `prioritycontrol`. Picking `maintenance` will log `'maintenance' is not a valid mode.` on every demand. Treated as schema-vs-code drift, not a runtime bug.
|
||||
|
||||
### `priorityControl` bypasses the movement planner
|
||||
|
||||
`equalFlowControl` (the priority-mode strategy) still uses the legacy direct-dispatch path:
|
||||
|
||||
```js
|
||||
await Promise.all(flowDistribution.map(async ({ machineId, flow }) => {
|
||||
if (flow > 0) {
|
||||
await machine.handleInput('parent', 'flowmovement', ...);
|
||||
if (currentState === 'idle') await machine.handleInput('parent', 'execsequence', 'startup');
|
||||
} else { ... shutdown ... }
|
||||
}));
|
||||
```
|
||||
|
||||
The planner is only wired through `optimalControl`. Consequence: priority-mode transitions can show a flow dip while one pump warms up and another keeps spinning. Tracked for a future pass; the planner's API is mode-agnostic so the surgery is straightforward when priorities allow.
|
||||
|
||||
### `mgc.scaling` is undefined
|
||||
|
||||
The orchestrator no longer carries a `scaling` field — `set.demand` is unit-self-describing per message. The `io/output.js` formatter still references `mgc.scaling`, which always reads `undefined`. The status-badge cosmetically displays `norm`. This is a leftover artifact of the pre-refactor design; harmless, scheduled for removal.
|
||||
|
||||
### Group efficiency naming — `maxEfficiency` is the **mean**, not the peak
|
||||
|
||||
`GroupEfficiency.calcGroupEfficiency` returns `{ maxEfficiency, lowestEfficiency }`. `maxEfficiency` is the **mean cog** across all machines, not the maximum. The name is preserved for behavioural parity with the pre-refactor code; callers using it as "the peak" will over-estimate the BEP target. Tracked — rename is a follow-up.
|
||||
|
||||
### `calcAbsoluteTotals` implicit pressure coupling
|
||||
|
||||
`TotalsCalculator.calcAbsoluteTotals` iterates a machine's `predictFlow.inputCurve` and re-indexes the SAME pressure key into `predictPower.inputCurve`. If the two curves were sampled at different pressures the lookup is `undefined` and the call throws. Mitigation deferred to the rotatingMachine curveLoader pass (P5).
|
||||
|
||||
### Power-cap parameter has no canonical topic
|
||||
|
||||
`handleInput(source, demand, powerCap)` accepts a `powerCap` argument and threads it to `validPumpCombinations`, but there is no `set.power-cap` topic in `commands/index.js`. Only programmatic callers can set it. Tracked.
|
||||
|
||||
### Per-pump fan-out not on Port 0
|
||||
|
||||
MGC's Port 0 carries the group aggregate only (`atEquipment_predicted_flow`, `headerDiffPa`, etc.). If you want per-pump trends on a dashboard you must wire each `rotatingMachine`'s Port 0 separately. By design — the alternative would put N × M fields on the MGC payload.
|
||||
|
||||
### Curve-less members silently drop out
|
||||
|
||||
`combinatorics/pumpCombinations.validPumpCombinations` filters by FSM state and mode but not by curve presence. A machine with `predictFlow === null` (because its curve loader failed at startup) has `currentFxyYMin / Max = 0`, so its contribution to subset envelopes is zero. It can still appear in subsets — the optimizer just gives it zero flow. The drop-out is silent; the only signal is the curve-loader's error log at startup.
|
||||
|
||||
---
|
||||
|
||||
## Open questions (tracked)
|
||||
|
||||
| Question | Where it lives |
|
||||
|:---|:---|
|
||||
| Should the planner ever decline a combination when the slowest startup exceeds an SLA on demand spikes? | [machineGroupControl#1](https://gitea.wbd-rd.nl/RnD/machineGroupControl/issues/1) |
|
||||
| Wire the movement planner through `priorityControl` | Internal — not yet ticketed |
|
||||
| Remove the `mgc.scaling` artifact + the `scaling` badge field | Internal |
|
||||
| Rename `maxEfficiency` → `meanGroupCog` in `GroupEfficiency` | Internal |
|
||||
| Decline-and-fall-back vs always-commit on planner level | Same as the Gitea issue above |
|
||||
|
||||
---
|
||||
|
||||
## Migration notes
|
||||
|
||||
### From pre-planner
|
||||
|
||||
The MGC's `_optimalControl` used to fan commands out inline (lines 226–239 in `26e92b5^`):
|
||||
|
||||
```js
|
||||
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
|
||||
if (flow > 0) {
|
||||
await machine.handleInput('parent', 'flowmovement', ...);
|
||||
if (state === 'idle') await machine.handleInput('parent', 'execsequence', 'startup');
|
||||
} else if (ACTIVE_STATES.has(state)) {
|
||||
await machine.handleInput('parent', 'execsequence', 'shutdown');
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
That code is gone. The new path: build profiles → `scheduler.plan` → `executor.replan` → `await executor.tick()` (synchronous first tick) → `setInterval(1000)` for the rest. The flow / power numbers and the optimizer's pick are unchanged; only the **timing** of the per-pump commands changed.
|
||||
|
||||
If your test fixture relied on commands firing inline during `_runDispatch`, the new behaviour fires `fireAtTickN=0` commands synchronously inside the first `await executor.tick()` and later ones on the wall-clock interval. Tests that asserted exact timing should use the `executor.schedule()` introspection getter.
|
||||
|
||||
### From pre-unit-self-describing demand
|
||||
|
||||
The old `set.scaling` topic and its persistent `scaling.current` config field have been removed. Each `set.demand` now carries its own unit context:
|
||||
|
||||
| Pre | Post |
|
||||
|:---|:---|
|
||||
| `set.scaling = "absolute"`; `set.demand = 80` | `set.demand = {value: 80, unit: "m3/h"}` |
|
||||
| `set.scaling = "normalized"`; `set.demand = 50` | `set.demand = 50` (bare number = %) |
|
||||
| `set.scaling = "absolute"`; `set.demand = 0.022` (m³/s) | `set.demand = {value: 0.022, unit: "m3/s"}` |
|
||||
|
||||
Old flows that still send `set.scaling` will silently ignore it; the topic is no longer registered.
|
||||
|
||||
### From `prioritypercentagecontrol`
|
||||
|
||||
The mode `prioritypercentagecontrol` was retired with the unit-self-describing refactor. Use `priorityControl` with absolute-unit `set.demand` payloads, or `optimalControl` with the same.
|
||||
|
||||
---
|
||||
|
||||
## Related pages
|
||||
|
||||
| Page | Why |
|
||||
|:---|:---|
|
||||
| [Home](Home) | Intuitive overview |
|
||||
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||
| [Reference — Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
|
||||
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||
| [rotatingMachine — Limitations](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Reference-Limitations) | The child's own limitations (drift, multi-parent, virtual-child stale data) |
|
||||
19
wiki/_Sidebar.md
Normal file
19
wiki/_Sidebar.md
Normal file
@@ -0,0 +1,19 @@
|
||||
### machineGroupControl
|
||||
|
||||
- [Home](Home)
|
||||
|
||||
**Reference**
|
||||
|
||||
- [Contracts](Reference-Contracts)
|
||||
- [Architecture](Reference-Architecture)
|
||||
- [Examples](Reference-Examples)
|
||||
- [Limitations](Reference-Limitations)
|
||||
|
||||
**Related**
|
||||
|
||||
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
|
||||
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
|
||||
- [pumpingStation wiki](https://gitea.wbd-rd.nl/RnD/pumpingStation/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)
|
||||
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)
|
||||
Reference in New Issue
Block a user