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:
znetsixe
2026-05-17 19:43:55 +02:00
parent 26e92b54f7
commit 472402c62d
26 changed files with 3048 additions and 280 deletions

View File

@@ -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.
![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)
## 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&nbsp;+ 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 &mdash; 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&nbsp;s.
2. `set.demand = 50` (bare number = percent of group capacity) &rarr; MGC picks the best 1- or 2-pump combination by BEP-Gravitation.
3. `set.demand = { value: 80, unit: "m3/h" }` &rarr; absolute-flow setpoint.
4. `set.mode = priorityControl` &rarr; equal-flow distribution by priority order.
5. `set.demand = -1` &rarr; 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 % &rarr; 100 % &rarr; -1 with the live status panel. Save as `wiki/_partial-gifs/machineGroupControl/01-basic-demo.gif`, target &le; 1&nbsp;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** &mdash; 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`, &hellip;); 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 &mdash; 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 &mdash; 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>` &mdash; 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 &mdash; 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 -->|"&lt;type&gt;.measured.&lt;position&gt;"| 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&#40;eta_i&#41;]
plan --> exec[movementExecutor.replan<br/>+ await tick&#40;&#41;]
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`) &mdash; 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 &mdash; Contracts](Reference-Contracts) | Topic registry, config schema, child registration filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals, output ports |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows, debug recipes |
| [Reference &mdash; 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) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)