Compare commits

..

10 Commits

Author SHA1 Message Date
znetsixe
d238270530 test(mgc): drop denormalized asset fields from integration fixtures
Each fixture's machineConfig() now passes asset: { model, unit } only —
the supplier / category / type strings are derived at runtime via
assetResolver in rotatingMachine's _setupCurves. Six integration tests
updated. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:13:02 +02:00
znetsixe
4cb9c5084c feat(mgc): editor defaults, compact status badge, mode-case fix, real example flows + dashboard
Editor (mgc.html)
- Drag-in defaults now expose mode (optimalControl) and scaling (normalized)
  via dropdowns in the edit dialog. Was: no control fields in the UI at all,
  so users had to send set.mode/set.scaling after deploy or live with the
  hidden schema defaults.

Wire-up (src/nodeClass.js)
- buildDomainConfig now bridges the flat editor fields (mode, scaling) into
  the nested schema shape (mode.current, scaling.current). Was: returned {}
  so the editor's mode/scaling never reached the runtime.

Mode-case bug fix (src/specificClass.js)
- Schema enum values are camelCase (optimalControl, priorityControl) but the
  runtime switch in _runDispatch matched lowercase only. With the default
  config, dispatch silently fell through to the warning branch and nothing
  ran. Normalise via String(this.mode).toLowerCase() so both forms work.

Status badge (src/io/output.js)
- Compacted from ~80 chars (mode | Ⓝ: 💨=Q/Qmax | =P | N machine(s)) to
  ~50 chars (mode | norm | Q=Q/Qmax m³/h | P=P kW | active/total x).
  Drops emoji glyphs that rendered inconsistently across themes; uses the
  same dot+fill convention as pumpingStation.

Output extension (src/io/output.js)
- getOutput() now also emits flowCapacityMin/Max, machineCount,
  machineCountActive. Was: only group-level totals + dist-from-peak +
  mode/scaling, so dashboards couldn't show capacity / active count
  without subscribing to each rotatingMachine individually.

Examples
- Drop pre-refactor stubs (basic.flow.json, integration.flow.json,
  edge.flow.json). They had a single MGC + inject + debug, no children,
  and never dispatched anything.
- 01-Basic.json: 1 MGC + 3 rotatingMachine pumps + Setup once-fires
  virtualControl + cmd.startup on all pumps via fan-out function. Numbered
  driver groups for Control mode / Scaling / Operator demand. Pumps
  register with MGC via Port 2 (child.register, automatic).
- 02-Dashboard.json: same plumbing + FlowFuse Dashboard 2.0 page with
  Controls (mode + scaling buttons, demand slider 0–100, stop + init
  buttons), Status (7 ui-text rows), Trends (3 charts: flow + capacity,
  power, BEP rel %), and a raw-output ui-template dumping every Port 0
  field. Fan-out function caches last-known values so deltas don't blank.

Wiki + README
- examples/README.md rewritten for the two-file set with canonical command
  surface table and "what to try" recipes.
- wiki/Home.md §11 (Examples) updated; §14 #4 (TODO flow item) replaced
  with the actual current limitation (no per-pump fan-out on Port 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:24:03 +02:00
znetsixe
05de4ee29a wiki: rewrite Home.md per visual-first 14-section template
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:04:14 +02:00
znetsixe
7d19fc1db0 P11.6 wiki regen + Phase 10 private-test rewrites where applicable
For all 11 nodes with auto-gen markers: wiki/Home.md sections 5 (topic
contract) and 9 (data model) regenerated via npm run wiki:all. New
Unit column shows '<measure> (default <unit>)' for declared topics,
'—' otherwise. Effect column now uses descriptor.description (P11.2
field) overriding the generic per-prefix fallback.

For rotatingMachine + reactor: Phase 10 test rewrites — 3 + 8 files
moved off private nodeClass internals (_attachInputHandler, _commands,
_pendingExtras, _registerChild, _tick, etc.) to the public
BaseNodeAdapter surface (node.handlers.input, node.source.*).
+6 / +7 net new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:03 +02:00
znetsixe
3ee1939b0a P11.5 + B2.1/B2.2: per-command units + description (where applicable)
Adds  to scalar setters whose payloads are
plain numbers OR {value, unit}. Skipped where payload is compound or
mode-dependent (control-%, {F, C: [...]}, etc.) — documented inline.
Every command gains a description field for wikiGen consumption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:41:07 +02:00
znetsixe
31324ae82d B2.3: migrate MGC to LatestWinsGate.fireAndWait
specificClass.js 319 → 311 lines. Removed inline _dispatchInFlight +
_delayedCall + finally block. handleInput is now a 1-line delegate
to DemandDispatcher.fireAndWait({source, demand, ...}).
turnOffAllMachines calls _demandDispatcher.cancelPending().
DemandDispatcher 39 → 53 lines. One integration test rewritten to
use the new sentinel-resolution semantics. 77/77 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:29:18 +02:00
znetsixe
0e8cab5d3f B3.3 follow-up: drop _unitView mirror; use UnitPolicy property bags directly
UnitPolicy now exposes canonical/output/curve as both methods AND
frozen property bags, so this.unitPolicy = this.constructor.unitPolicy
works directly. Removes the 14-line _unitView assembly in configure().
specificClass.js 336→318. 77/77 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:13:18 +02:00
znetsixe
045a941ab4 P9.3: wiki/Home.md following 14-section visual-first template + wiki:* scripts
Auto-generated topic-contract + data-model sections via shared wikiGen
script. Hand-written Mermaid diagrams for position-in-platform, code
map, child registration, lifecycle, configuration, state chart (where
applicable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:17:35 +02:00
znetsixe
bb2f3bea82 P4 wave 2: convert MGC to BaseDomain + extract control/ + io/
specificClass.js: 1808 → 336 lines.
  MachineGroup extends BaseDomain. Configure() wires GroupOperatingPoint,
  TotalsCalculator, GroupEfficiency, DemandDispatcher (built but unused —
  see OPEN_QUESTIONS); ChildRouter handles registration + measurement
  events; tick is event-driven (no setInterval, recomputes on pressure
  events).

  src/control/strategies.js (210 lines, new) — extracted equalFlowControl
  + prioPercentageControl from the orchestrator to fit the line budget.
  src/io/output.js (69 lines, new) — extracted getOutput + getStatusBadge
  composition.

  Public surface preserved: machines / setMode / setScaling / handleInput
  / isMachineActive / handlePressureChange / dynamicTotals / absoluteTotals
  / absDistFromPeak / relDistFromPeak. _delayedCall + _dispatchInFlight
  inline gate kept (tests await handleInput; LatestWinsGate.fire is
  void) — see OPEN_QUESTIONS for the deferred decision.

nodeClass.js: 280 → 20 lines.
  Extends BaseNodeAdapter. tickInterval=null (event-driven), commands
  registry from src/commands/. buildDomainConfig returns {} (MGC has
  no node-specific domain slice).

53 basic + 23 integration + 1 edge tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:32:11 +02:00
znetsixe
619b1311d2 P4 wave 1: extract MGC concerns into focused modules
src/groupOps/        groupOperatingPoint + groupCurves (pure functions)
  src/totals/          totalsCalculator (dynamic + absolute + active)
  src/combinatorics/   pumpCombinations (validPumpCombinations + checkSpecialCases)
  src/optimizer/       bestCombination (CoG) + bepGravitation (BEP-G + marginal-cost)
  src/efficiency/      groupEfficiency (calc + distance helpers)
  src/dispatch/        demandDispatcher (LatestWinsGate-based; replaces
                       _dispatchInFlight + _delayedCall)
  src/commands/        canonical names from start (set.mode/scaling/demand,
                       child.register) + legacy aliases
  CONTRACT.md          inputs/outputs/events surface

53 basic tests pass (52 new + 1 pre-existing).
specificClass.js / nodeClass.js untouched — integration in P4 wave 2.

Findings flagged via agents (TODO append to OPEN_QUESTIONS.md):
  - calcGroupEfficiency.maxEfficiency is actually the mean (misleading name)
  - checkSpecialCases has a no-op `return false` inside forEach
  - MGC doesn't route cmd.startup/shutdown/estop — confirm if station broadcasts need it

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:45:23 +02:00
40 changed files with 4742 additions and 2057 deletions

71
CONTRACT.md Normal file
View File

@@ -0,0 +1,71 @@
# machineGroupControl — Contract
Hand-maintained for Phase 4; the `## Inputs` table is generated from
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines.
## Inputs (msg.topic on Port 0)
| Canonical | Aliases (deprecated) | Payload | Effect |
|---|---|---|---|
| `set.mode` | `setMode` | `string` — one of `prioritycontrol`, `optimalcontrol`, `dynamiccontrol`, … | Switches the control strategy via `source.setMode(payload)`. |
| `set.scaling` | `setScaling` | `string` — one of `absolute`, `normalized` | Sets the demand-scaling convention via `source.setScaling(payload)`. |
| `child.register` | `registerChild` | `string` — the child node's Node-RED id | Resolves the child via `RED.nodes.getNode` and registers it through `childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent)`. |
| `set.demand` | `Qd` | numeric (number or numeric string) | Calls `source.handleInput('parent', parseFloat(payload))`. On success, replies on Port 0 with `topic = source.config.general.name`, `payload = 'done'`. Non-numeric payloads log `error` and are skipped. |
Aliases log a one-time deprecation warning the first time they fire.
## Outputs (msg.topic on Port 0/1/2)
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
`outputUtils.formatMsg(..., 'process')` from `getOutput()` — delta-compressed
(only changed fields are emitted). On a successful `set.demand` dispatch the
node additionally emits `{ topic: <name>, payload: 'done' }` as an
acknowledgement.
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
`'influxdb'` formatter.
- **Port 2 (registration):** at startup the node sends one
`{ topic: 'registerChild', payload: <node.id>, positionVsParent }`
to the upstream parent.
## Events emitted by `source.measurements.emitter`
The `MeasurementContainer` fires `<type>.<variant>.<position>` whenever
the corresponding series receives a new value. Parents subscribe via the
generic `child.measurements.emitter.on(eventName, ...)` handshake.
machineGroupControl publishes:
- `flow.predicted.atequipment` — aggregated predicted group flow (sum of
member-machine predicted flows at the group operating point).
- `flow.predicted.downstream` — mirror of the live group flow seen at
the discharge header (written by `handlePressureChange` for downstream
consumers such as pumpingStation).
- `power.predicted.atequipment` — aggregated predicted group power.
- `efficiency.predicted.atequipment` — group efficiency = flow/power at
the selected operating point.
- `Ncog.predicted.atequipment` — group normalised cost-of-goods score.
- `pressure.measured.upstream`, `pressure.measured.downstream`,
`pressure.measured.differential` — mirrored from header-side
measurement children (`asset.type='pressure'`), when registered.
The exact set is data-driven by which children register and what they
publish; downstream consumers should subscribe by event name, not assume
a fixed catalogue.
## Children registered by this node
machineGroupControl accepts two `softwareType`s through the
`childRegistrationUtils` handshake:
- `machine` — a rotatingMachine. Stored in `source.machines[id]`.
The group subscribes to its child's
`pressure.measured.differential`, `pressure.measured.downstream`, and
`flow.predicted.downstream` events to trigger `handlePressureChange`.
- `measurement` — a header-side sensor (typically a pressure transmitter
at the discharge or suction manifold). The group subscribes to the
matching `<asset.type>.measured.<positionVsParent>` event and mirrors
the value into its own MeasurementContainer; pressure events also
trigger `handlePressureChange` so optimalControl can use ONE header
operating point for all pumps.
Position labels accepted from children are `upstream`, `downstream`,
`atequipment` (and case variants — normalised internally).

677
examples/01-Basic.json Normal file
View File

@@ -0,0 +1,677 @@
[
{
"id": "tab_mgc_basic",
"type": "tab",
"label": "MGC - Basic",
"disabled": false,
"info": "Tier 1: one machineGroupControl (MGC) coordinating three rotatingMachine pumps. Setup once-fires virtualControl + cmd.startup on all three pumps; then operator demand drives the MGC, which dispatches per-pump flow setpoints."
},
{
"id": "grp_mgc_unit",
"type": "group",
"z": "tab_mgc_basic",
"name": "Machine Group (Unit)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#50a8d9",
"fill-opacity": "0.10"
},
"nodes": [
"mgc_basic_node"
],
"x": 974,
"y": 359,
"w": 152,
"h": 122
},
{
"id": "grp_pump_a",
"type": "group",
"z": "tab_mgc_basic",
"name": "Pump A (Equipment)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#86bbdd",
"fill-opacity": "0.10"
},
"nodes": [
"rm_basic_pump_a"
],
"x": 694,
"y": 199,
"w": 142,
"h": 82
},
{
"id": "grp_pump_b",
"type": "group",
"z": "tab_mgc_basic",
"name": "Pump B (Equipment)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#86bbdd",
"fill-opacity": "0.10"
},
"nodes": [
"rm_basic_pump_b"
],
"x": 694,
"y": 379,
"w": 142,
"h": 82
},
{
"id": "grp_pump_c",
"type": "group",
"z": "tab_mgc_basic",
"name": "Pump C (Equipment)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#86bbdd",
"fill-opacity": "0.10"
},
"nodes": [
"rm_basic_pump_c"
],
"x": 694,
"y": 559,
"w": 142,
"h": 82
},
{
"id": "grp_drv_mode",
"type": "group",
"z": "tab_mgc_basic",
"name": "1. Control mode",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"inj_mode_optimal",
"inj_mode_priority"
],
"x": 94,
"y": 99,
"w": 312,
"h": 122
},
{
"id": "grp_drv_scaling",
"type": "group",
"z": "tab_mgc_basic",
"name": "2. Scaling",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"inj_scaling_norm",
"inj_scaling_abs"
],
"x": 94,
"y": 259,
"w": 312,
"h": 122
},
{
"id": "grp_drv_demand",
"type": "group",
"z": "tab_mgc_basic",
"name": "3. Operator demand (% of group capacity)",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"inj_demand_25",
"inj_demand_50",
"inj_demand_75",
"inj_demand_100",
"inj_demand_0"
],
"x": 94,
"y": 419,
"w": 312,
"h": 222
},
{
"id": "grp_setup",
"type": "group",
"z": "tab_mgc_basic",
"name": "Setup — once on deploy",
"style": {
"stroke": "#666666",
"fill": "#dddddd",
"fill-opacity": "0.20",
"label": true,
"color": "#333333"
},
"nodes": [
"inj_setup_start",
"fn_setup_fanout"
],
"x": 94,
"y": 679,
"w": 532,
"h": 82
},
{
"id": "grp_dbg",
"type": "group",
"z": "tab_mgc_basic",
"name": "Debug outputs (sidebar)",
"style": {
"stroke": "#666666",
"fill": "#d1d1d1",
"fill-opacity": "0.2",
"label": true,
"color": "#333333"
},
"nodes": [
"dbg_port0",
"dbg_port1",
"dbg_port2"
],
"x": 1234,
"y": 339,
"w": 232,
"h": 202
},
{
"id": "cmt_title",
"type": "comment",
"z": "tab_mgc_basic",
"name": "MGC — Basic (Tier 1)",
"info": "One machineGroupControl coordinating three rotatingMachine pumps.\n\nDefaults: mode=optimalControl, scaling=normalized.\n\nSETUP — fires once on deploy\n- Switches all 3 pumps to virtualControl mode\n- Sends cmd.startup to all 3 pumps\nPumps register with the MGC automatically via Port 2 (child.register).\n\nHOW TO USE\n1. Deploy — the Setup group auto-runs after ~1.5 s, putting pumps in virtual + started.\n2. Click any \"set.demand = N %\" — MGC dispatches per-pump flow setpoints by BEP-gravitation (default) or priority list, depending on the mode.\n3. Switch scaling to `absolute` to interpret set.demand as m³/h instead of %.\n4. Switch mode to `priorityControl` for sequential equal-flow control; `optimalControl` (default) picks the best combination automatically.\n5. Send `set.demand = 0` to drain the group (turnOffAllMachines).\n\nPORTS (MGC)\n- Port 0: process output (mode, scaling, totals, dist-from-peak)\n- Port 1: InfluxDB-shaped payload\n- Port 2: parent-registration handshake (when wired into a pumpingStation)",
"x": 1100,
"y": 280,
"wires": []
},
{
"id": "inj_mode_optimal",
"type": "inject",
"z": "tab_mgc_basic",
"g": "grp_drv_mode",
"name": "set.mode = optimalControl",
"props": [
{ "p": "topic", "vt": "str" },
{ "p": "payload", "v": "optimalControl", "vt": "str" }
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.mode",
"x": 260,
"y": 140,
"wires": [
[
"mgc_basic_node"
]
]
},
{
"id": "inj_mode_priority",
"type": "inject",
"z": "tab_mgc_basic",
"g": "grp_drv_mode",
"name": "set.mode = priorityControl",
"props": [
{ "p": "topic", "vt": "str" },
{ "p": "payload", "v": "priorityControl", "vt": "str" }
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.mode",
"x": 260,
"y": 180,
"wires": [
[
"mgc_basic_node"
]
]
},
{
"id": "inj_scaling_norm",
"type": "inject",
"z": "tab_mgc_basic",
"g": "grp_drv_scaling",
"name": "set.scaling = normalized",
"props": [
{ "p": "topic", "vt": "str" },
{ "p": "payload", "v": "normalized", "vt": "str" }
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.scaling",
"x": 260,
"y": 300,
"wires": [
[
"mgc_basic_node"
]
]
},
{
"id": "inj_scaling_abs",
"type": "inject",
"z": "tab_mgc_basic",
"g": "grp_drv_scaling",
"name": "set.scaling = absolute",
"props": [
{ "p": "topic", "vt": "str" },
{ "p": "payload", "v": "absolute", "vt": "str" }
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.scaling",
"x": 260,
"y": 340,
"wires": [
[
"mgc_basic_node"
]
]
},
{
"id": "inj_demand_0",
"type": "inject",
"z": "tab_mgc_basic",
"g": "grp_drv_demand",
"name": "set.demand = 0 (stop)",
"props": [
{ "p": "topic", "vt": "str" },
{ "p": "payload", "v": "0", "vt": "num" }
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.demand",
"x": 260,
"y": 460,
"wires": [
[
"mgc_basic_node"
]
]
},
{
"id": "inj_demand_25",
"type": "inject",
"z": "tab_mgc_basic",
"g": "grp_drv_demand",
"name": "set.demand = 25 %",
"props": [
{ "p": "topic", "vt": "str" },
{ "p": "payload", "v": "25", "vt": "num" }
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.demand",
"x": 260,
"y": 500,
"wires": [
[
"mgc_basic_node"
]
]
},
{
"id": "inj_demand_50",
"type": "inject",
"z": "tab_mgc_basic",
"g": "grp_drv_demand",
"name": "set.demand = 50 %",
"props": [
{ "p": "topic", "vt": "str" },
{ "p": "payload", "v": "50", "vt": "num" }
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.demand",
"x": 260,
"y": 540,
"wires": [
[
"mgc_basic_node"
]
]
},
{
"id": "inj_demand_75",
"type": "inject",
"z": "tab_mgc_basic",
"g": "grp_drv_demand",
"name": "set.demand = 75 %",
"props": [
{ "p": "topic", "vt": "str" },
{ "p": "payload", "v": "75", "vt": "num" }
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.demand",
"x": 260,
"y": 580,
"wires": [
[
"mgc_basic_node"
]
]
},
{
"id": "inj_demand_100",
"type": "inject",
"z": "tab_mgc_basic",
"g": "grp_drv_demand",
"name": "set.demand = 100 %",
"props": [
{ "p": "topic", "vt": "str" },
{ "p": "payload", "v": "100", "vt": "num" }
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.demand",
"x": 260,
"y": 620,
"wires": [
[
"mgc_basic_node"
]
]
},
{
"id": "inj_setup_start",
"type": "inject",
"z": "tab_mgc_basic",
"g": "grp_setup",
"name": "Auto-start pumps",
"props": [
{ "p": "payload", "v": "go", "vt": "str" }
],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "1.5",
"topic": "",
"x": 220,
"y": 720,
"wires": [
[
"fn_setup_fanout"
]
]
},
{
"id": "fn_setup_fanout",
"type": "function",
"z": "tab_mgc_basic",
"g": "grp_setup",
"name": "fan-out: virtualControl + startup → A/B/C",
"func": "// Fire two messages per pump: set.mode = virtualControl, then cmd.startup.\n// Each output is a message array — Node-RED dispatches them sequentially.\nconst setMode = { topic: 'set.mode', payload: 'virtualControl' };\nconst startup = { topic: 'cmd.startup', payload: {} };\nreturn [\n [setMode, startup], // → Pump A\n [setMode, startup], // → Pump B\n [setMode, startup], // → Pump C\n];\n",
"outputs": 3,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 720,
"wires": [
[
"rm_basic_pump_a"
],
[
"rm_basic_pump_b"
],
[
"rm_basic_pump_c"
]
]
},
{
"id": "rm_basic_pump_a",
"type": "rotatingMachine",
"z": "tab_mgc_basic",
"g": "grp_pump_a",
"name": "Pump A",
"speed": "1",
"startup": "2",
"warmup": "1",
"shutdown": "2",
"cooldown": "1",
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": "mgc-basic-pump-a",
"supplier": "hidrostal",
"category": "pump",
"assetType": "pump-centrifugal",
"model": "hidrostal-H05K-S03R",
"unit": "m3/h",
"curvePressureUnit": "mbar",
"curveFlowUnit": "m3/h",
"curvePowerUnit": "kW",
"curveControlUnit": "%",
"enableLog": false,
"logLevel": "info",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"distanceDescription": "",
"x": 760,
"y": 240,
"wires": [
[],
[],
[
"mgc_basic_node"
]
]
},
{
"id": "rm_basic_pump_b",
"type": "rotatingMachine",
"z": "tab_mgc_basic",
"g": "grp_pump_b",
"name": "Pump B",
"speed": "1",
"startup": "2",
"warmup": "1",
"shutdown": "2",
"cooldown": "1",
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": "mgc-basic-pump-b",
"supplier": "hidrostal",
"category": "pump",
"assetType": "pump-centrifugal",
"model": "hidrostal-H05K-S03R",
"unit": "m3/h",
"curvePressureUnit": "mbar",
"curveFlowUnit": "m3/h",
"curvePowerUnit": "kW",
"curveControlUnit": "%",
"enableLog": false,
"logLevel": "info",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"distanceDescription": "",
"x": 760,
"y": 420,
"wires": [
[],
[],
[
"mgc_basic_node"
]
]
},
{
"id": "rm_basic_pump_c",
"type": "rotatingMachine",
"z": "tab_mgc_basic",
"g": "grp_pump_c",
"name": "Pump C",
"speed": "1",
"startup": "2",
"warmup": "1",
"shutdown": "2",
"cooldown": "1",
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": "mgc-basic-pump-c",
"supplier": "hidrostal",
"category": "pump",
"assetType": "pump-centrifugal",
"model": "hidrostal-H05K-S03R",
"unit": "m3/h",
"curvePressureUnit": "mbar",
"curveFlowUnit": "m3/h",
"curvePowerUnit": "kW",
"curveControlUnit": "%",
"enableLog": false,
"logLevel": "info",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"distanceDescription": "",
"x": 760,
"y": 600,
"wires": [
[],
[],
[
"mgc_basic_node"
]
]
},
{
"id": "mgc_basic_node",
"type": "machineGroupControl",
"z": "tab_mgc_basic",
"g": "grp_mgc_unit",
"name": "Machine Group",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"mode": "optimalControl",
"scaling": "normalized",
"uuid": "",
"supplier": "",
"category": "",
"assetType": "",
"model": "",
"unit": "",
"enableLog": false,
"logLevel": "info",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"distanceDescription": "",
"x": 1050,
"y": 420,
"wires": [
[
"dbg_port0"
],
[
"dbg_port1"
],
[
"dbg_port2"
]
]
},
{
"id": "dbg_port0",
"type": "debug",
"z": "tab_mgc_basic",
"g": "grp_dbg",
"name": "Port 0: Process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 1340,
"y": 380,
"wires": []
},
{
"id": "dbg_port1",
"type": "debug",
"z": "tab_mgc_basic",
"g": "grp_dbg",
"name": "Port 1: InfluxDB",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1340,
"y": 440,
"wires": []
},
{
"id": "dbg_port2",
"type": "debug",
"z": "tab_mgc_basic",
"g": "grp_dbg",
"name": "Port 2: Parent reg",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1350,
"y": 500,
"wires": []
},
{
"id": "mgc_global_cfg",
"type": "global-config",
"env": [],
"modules": {
"EVOLV": "1.0.29"
}
}
]

1205
examples/02-Dashboard.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,51 @@
# machineGroupControl Example Flows
# machineGroupControl - Example Flows
Import-ready Node-RED examples for machineGroupControl.
Import-ready Node-RED examples for `machineGroupControl` (MGC). MGC is not a standalone node — it needs at least one `rotatingMachine` child to dispatch demand to. Both flows below ship three child pumps.
## Files
- basic.flow.json
- integration.flow.json
- edge.flow.json
| File | Tier | What it shows |
|---|---|---|
| `01-Basic.json` | 1 | One MGC + three `rotatingMachine` pumps driven by inject buttons. Setup once-fires `virtualControl` + `cmd.startup` on all three pumps; mode / scaling / demand are then driven by buttons. |
| `02-Dashboard.json` | 2 | Same command surface driven by a FlowFuse Dashboard 2.0 page — mode + scaling buttons, demand slider, live status rows, three trend charts, and a raw-output table. |
## Prerequisites
- Node-RED with the EVOLV package installed (`machineGroupControl` and `rotatingMachine` registered).
- For `02-Dashboard.json`: `@flowfuse/node-red-dashboard` (Dashboard 2.0).
## Load a flow
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/machineGroupControl/examples/01-Basic.json \
http://localhost:1880/flows
```
Or in the editor: Menu → Import → drag the file → Import.
## Canonical command surface
| Topic | Aliases | Payload | What it does |
|---|---|---|---|
| `set.mode` | `setMode` | `"optimalControl"`, `"priorityControl"`, `"prioritypercentagecontrol"`, `"maintenance"` | Switch dispatch strategy |
| `set.scaling` | `setScaling` | `"normalized"`, `"absolute"` | Interpret demand as 0100 % vs m³/h |
| `set.demand` | `Qd` | number | Operator demand setpoint |
| `child.register` | `registerChild` | child node id (string) | Manually register a child (Port 2 wiring does this automatically) |
## 01-Basic — what to try
1. Deploy. After ~1.5 s the Setup group auto-fires, putting all three pumps in `virtualControl` mode + sending `cmd.startup` to each.
2. Click `set.demand = 50 %` — MGC's `optimalControl` picks the best pump combination by BEP-gravitation and dispatches `flowmovement` to the selected pumps.
3. Click `set.demand = 100 %` — MGC switches to a higher combination, possibly engaging an extra pump.
4. Switch mode to `priorityControl` and try the same demands — pumps now run equal-flow by priority order.
5. Switch scaling to `absolute` — set.demand is now interpreted as m³/h (capped at the group min / max).
6. `set.demand = 0` — MGC calls `turnOffAllMachines`, all pumps shut down.
## 02-Dashboard — what to try
1. Deploy → open `http://localhost:1880/dashboard/mgc-basic`.
2. The dashboard auto-initialises the pumps; the `Initialize pumps` button on the page re-runs the setup manually.
3. Drag the **Demand** slider — MGC dispatches and the Flow / Power / BEP charts react.
4. Switch modes and scalings via the buttons; the Mode / Scaling rows in the Status panel reflect the change.
5. Inspect the **Raw output** table for the full Port 0 surface (every field MGC emits, including `flowCapacityMax`, `machineCountActive`, `absDistFromPeak`, `relDistFromPeak`).

View File

@@ -1,6 +0,0 @@
[
{"id":"machineGroupControl_basic_tab","type":"tab","label":"machineGroupControl basic","disabled":false,"info":"machineGroupControl basic example"},
{"id":"machineGroupControl_basic_node","type":"machineGroupControl","z":"machineGroupControl_basic_tab","name":"machineGroupControl basic","x":420,"y":180,"wires":[["machineGroupControl_basic_dbg"]]},
{"id":"machineGroupControl_basic_inj","type":"inject","z":"machineGroupControl_basic_tab","name":"basic trigger","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"ping","payload":"1","payloadType":"str","x":160,"y":180,"wires":[["machineGroupControl_basic_node"]]},
{"id":"machineGroupControl_basic_dbg","type":"debug","z":"machineGroupControl_basic_tab","name":"machineGroupControl basic debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
]

View File

@@ -1,6 +0,0 @@
[
{"id":"machineGroupControl_edge_tab","type":"tab","label":"machineGroupControl edge","disabled":false,"info":"machineGroupControl edge example"},
{"id":"machineGroupControl_edge_node","type":"machineGroupControl","z":"machineGroupControl_edge_tab","name":"machineGroupControl edge","x":420,"y":180,"wires":[["machineGroupControl_edge_dbg"]]},
{"id":"machineGroupControl_edge_inj","type":"inject","z":"machineGroupControl_edge_tab","name":"unknown topic","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"doesNotExist","payload":"x","payloadType":"str","x":170,"y":180,"wires":[["machineGroupControl_edge_node"]]},
{"id":"machineGroupControl_edge_dbg","type":"debug","z":"machineGroupControl_edge_tab","name":"machineGroupControl edge debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
]

View File

@@ -1,6 +0,0 @@
[
{"id":"machineGroupControl_int_tab","type":"tab","label":"machineGroupControl integration","disabled":false,"info":"machineGroupControl integration example"},
{"id":"machineGroupControl_int_node","type":"machineGroupControl","z":"machineGroupControl_int_tab","name":"machineGroupControl integration","x":420,"y":180,"wires":[["machineGroupControl_int_dbg"]]},
{"id":"machineGroupControl_int_inj","type":"inject","z":"machineGroupControl_int_tab","name":"registerChild","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"registerChild","payload":"example-child-id","payloadType":"str","x":170,"y":180,"wires":[["machineGroupControl_int_node"]]},
{"id":"machineGroupControl_int_dbg","type":"debug","z":"machineGroupControl_int_tab","name":"machineGroupControl integration debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":680,"y":180,"wires":[]}
]

View File

@@ -21,6 +21,10 @@
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
// Control strategy
mode: { value: "optimalControl" }, // optimalControl | priorityControl | prioritypercentagecontrol | maintenance
scaling: { value: "normalized" }, // normalized (0100 %) | absolute (m³/h)
//define asset properties
uuid: { value: "" },
supplier: { value: "" },
@@ -40,7 +44,7 @@
distance: { value: 0 },
distanceUnit: { value: "m" },
distanceDescription: { value: "" }
},
inputs:1,
outputs:3,
@@ -84,6 +88,24 @@
<script type="text/html" data-template-name="machineGroupControl">
<h3>Control strategy</h3>
<div class="form-row">
<label for="node-input-mode"><i class="fa fa-cogs"></i> Mode</label>
<select id="node-input-mode" style="width:60%;">
<option value="optimalControl">optimalControl &mdash; pick the best valid pump combination by BEP-gravitation / NCog</option>
<option value="priorityControl">priorityControl &mdash; sequential equal-flow control by priority list</option>
<option value="prioritypercentagecontrol">prioritypercentagecontrol &mdash; sequential percentage control (requires normalized scaling)</option>
<option value="maintenance">maintenance &mdash; monitoring only, no dispatch</option>
</select>
</div>
<div class="form-row">
<label for="node-input-scaling"><i class="fa fa-arrows-h"></i> Scaling</label>
<select id="node-input-scaling" style="width:60%;">
<option value="normalized">normalized &mdash; demand interpreted as 0&ndash;100 % of group capacity</option>
<option value="absolute">absolute &mdash; demand interpreted as /h, capped by group min/max</option>
</select>
</div>
<h3>Output Formats</h3>
<div class="form-row">
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>

View File

@@ -4,7 +4,10 @@
"description": "Control module machineGroupControl",
"main": "mgc.js",
"scripts": {
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js",
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
},
"repository": {
"type": "git",

View File

@@ -0,0 +1,96 @@
// Pure subset/combination generators used by the optimizer.
// All callable through `ctx` so this file stays free of class state.
// `ctx` must provide:
// - groupCurves: { groupFlow, groupPower } (from ../groupOps/groupCurves)
// - logger (warn/debug)
// - readChildMeasurement(machine, type, variant, position, canonicalUnit)
// - POSITIONS, unitPolicy.canonical.flow
const EXCLUDED_STATES = new Set(['off', 'coolingdown', 'stopping', 'emergencystop']);
// Reduce demand by the flow that manually-driven operational machines
// are already delivering. Returns the adjusted Qd (may be < 0).
function checkSpecialCases(machines, Qd, ctx) {
const { logger, readChildMeasurement, POSITIONS, unitPolicy } = ctx;
const canonicalFlow = unitPolicy?.canonical?.flow;
Object.values(machines).forEach(machine => {
const state = machine.state?.getCurrentState?.();
const mode = machine.currentMode;
if (state !== 'operational') return;
if (mode !== 'virtualControl' && mode !== 'fysicalControl') return;
const measuredFlow = readChildMeasurement
? readChildMeasurement(machine, 'flow', 'measured', POSITIONS.DOWNSTREAM, canonicalFlow)
: undefined;
const predictedFlow = readChildMeasurement
? readChildMeasurement(machine, 'flow', 'predicted', POSITIONS.DOWNSTREAM, canonicalFlow)
: undefined;
let flow = 0;
if (Number.isFinite(measuredFlow) && measuredFlow !== 0) {
flow = measuredFlow;
} else if (Number.isFinite(predictedFlow) && predictedFlow !== 0) {
flow = predictedFlow;
} else {
// Unrecoverable: a machine is producing flow we can't quantify.
// Caller decides whether to abort the dispatch tick.
logger?.error?.(
"Dont perform calculation at all seeing that there is a machine working but we dont know the flow its producing"
);
return;
}
Qd = Qd - flow;
});
return Qd;
}
// Generate all non-empty machine subsets that can deliver Qd within powerCap.
// Inputs that can't possibly contribute (off / coolingdown / mode-locked) are
// excluded before the power set is built, so 2^N stays small in practice.
function validPumpCombinations(machines, Qd, ctx, powerCap = Infinity) {
const { groupCurves } = ctx;
const groupFlow = groupCurves?.groupFlow;
const groupPower = groupCurves?.groupPower;
Qd = checkSpecialCases(machines, Qd, ctx);
let subsets = [[]];
Object.keys(machines).forEach(machineId => {
const machine = machines[machineId];
const state = machine.state?.getCurrentState?.();
const validActionForMode =
typeof machine.isValidActionForMode === 'function'
? machine.isValidActionForMode('execsequence', 'auto')
: true;
if (EXCLUDED_STATES.has(state) || !validActionForMode) return;
const newSubsets = subsets.map(set => [...set, machineId]);
subsets = subsets.concat(newSubsets);
});
return subsets.filter(subset => {
if (subset.length === 0) return false;
const { maxFlow, minFlow, maxPower } = subset.reduce(
(acc, machineId) => {
const machine = machines[machineId];
const f = groupFlow(machine);
const p = groupPower(machine);
return {
maxFlow: acc.maxFlow + f.currentFxyYMax,
minFlow: acc.minFlow + f.currentFxyYMin,
maxPower: acc.maxPower + p.currentFxyYMax,
};
},
{ maxFlow: 0, minFlow: 0, maxPower: 0 },
);
return maxFlow >= Qd && minFlow <= Qd && maxPower <= powerCap;
});
}
module.exports = { validPumpCombinations, checkSpecialCases, EXCLUDED_STATES };

58
src/commands/handlers.js Normal file
View File

@@ -0,0 +1,58 @@
'use strict';
// Handler functions for machineGroupControl commands. Each handler receives:
// source: the domain (specificClass) instance — exposes setMode, setScaling,
// handleInput, childRegistrationUtils.registerChild, logger,
// config.general.name.
// msg: the Node-RED input message.
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
//
// Pure functions: no module-level state. The registry already enforces the
// typeof-check ladder; per-topic semantic validation lives here.
function _logger(source, ctx) {
return ctx?.logger || source?.logger || null;
}
exports.setMode = (source, msg) => {
source.setMode(msg.payload);
};
exports.setScaling = (source, msg) => {
source.setScaling(msg.payload);
};
exports.registerChild = (source, msg, ctx) => {
const log = _logger(source, ctx);
const childId = msg.payload;
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
if (!childObj || !childObj.source) {
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
return;
}
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
};
exports.setDemand = async (source, msg, ctx) => {
const log = _logger(source, ctx);
const demand = parseFloat(msg.payload);
if (Number.isNaN(demand)) {
log?.error?.(`set.demand: invalid Qd value '${msg.payload}'`);
return;
}
try {
await source.handleInput('parent', demand);
} catch (err) {
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
return;
}
// Reply on Port 0 with the configured node name as topic — preserves the
// legacy "done" handshake some downstream flows rely on.
if (typeof ctx?.send === 'function') {
const reply = Object.assign({}, msg, {
topic: source?.config?.general?.name,
payload: 'done',
});
ctx.send(reply);
}
};

42
src/commands/index.js Normal file
View File

@@ -0,0 +1,42 @@
'use strict';
// machineGroupControl command registry. Consumed by BaseNodeAdapter via
// `static commands = require('./commands')`. Each descriptor maps a
// canonical msg.topic to its handler; legacy names are listed under
// `aliases` and emit a one-time deprecation warning at runtime.
const handlers = require('./handlers');
module.exports = [
{
topic: 'set.mode',
aliases: ['setMode'],
payloadSchema: { type: 'string' },
description: 'Switch the machine group between auto / manual modes.',
handler: handlers.setMode,
},
{
topic: 'set.scaling',
aliases: ['setScaling'],
payloadSchema: { type: 'string' },
description: 'Select the group scaling strategy.',
handler: handlers.setScaling,
},
{
topic: 'child.register',
aliases: ['registerChild'],
// payload is the Node-RED id (string) of the child node.
payloadSchema: { type: 'string' },
description: 'Register a child machine with this group.',
handler: handlers.registerChild,
},
{
topic: 'set.demand',
aliases: ['Qd'],
// any: number or numeric string — handler runs parseFloat.
payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' },
description: 'Operator demand setpoint dispatched to the child machines.',
handler: handlers.setDemand,
},
];

210
src/control/strategies.js Normal file
View File

@@ -0,0 +1,210 @@
'use strict';
// Priority-based control strategies for machineGroupControl.
//
// equalFlowControl: distribute demand equally across priority-ordered active
// machines, falling back to start/stop the next priority when the current
// active set can't deliver.
//
// prioPercentageControl: percentage-style ctrl distribution (only valid with
// normalized scaling).
//
// Both extracted verbatim from specificClass during the P4 refactor; the
// orchestrator wires them in via the strategies map below. They depend on
// the same group-curve helpers the optimizer uses, so allocation and power
// evaluation stay on the equalised group operating point.
const { POSITIONS } = require('generalFunctions');
const { groupFlow, groupCalcPower } = require('../groupOps/groupCurves');
function sortMachinesByPriority(machines, priorityList) {
if (priorityList && Array.isArray(priorityList)) {
return priorityList
.filter(id => machines[id])
.map(id => ({ id, machine: machines[id] }));
}
return Object.entries(machines)
.map(([id, machine]) => ({ id, machine }))
.sort((a, b) => a.id - b.id);
}
function filterOutUnavailableMachines(list) {
return list.filter(({ machine }) => {
const state = machine.state.getCurrentState();
const validActionForMode = machine.isValidActionForMode('execsequence', 'auto');
return !(state === 'off' || state === 'coolingdown' || state === 'stopping'
|| state === 'emergencystop' || !validActionForMode);
});
}
function capFlowDemand(Qd, dynamicTotals, logger) {
if (Qd < dynamicTotals.flow.min && Qd > 0) {
logger?.warn?.(`Flow demand ${Qd} below min ${dynamicTotals.flow.min}; capping.`);
return dynamicTotals.flow.min;
}
if (Qd > dynamicTotals.flow.max) {
logger?.warn?.(`Flow demand ${Qd} above max ${dynamicTotals.flow.max}; capping.`);
return dynamicTotals.flow.max;
}
return Qd;
}
async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = null) {
const { mgc } = ctx;
try {
mgc.equalizePressure();
const dynamicTotals = mgc.calcDynamicTotals();
Qd = capFlowDemand(Qd, dynamicTotals, mgc.logger);
let machinesInPriorityOrder = sortMachinesByPriority(mgc.machines, priorityList);
machinesInPriorityOrder = filterOutUnavailableMachines(machinesInPriorityOrder);
const flowDistribution = [];
let totalFlow = 0;
let totalPower = 0;
const totalCog = 0;
const activeTotals = mgc.totals.activeTotals();
switch (true) {
case (Qd < activeTotals.flow.min && activeTotals.flow.min !== 0): {
let availableFlow = activeTotals.flow.min;
for (let i = machinesInPriorityOrder.length - 1; i >= 0 && availableFlow > Qd; i--) {
const m = machinesInPriorityOrder[i];
if (mgc.isMachineActive(m.id)) {
flowDistribution.push({ machineId: m.id, flow: 0 });
availableFlow -= groupFlow(m.machine).currentFxyYMin;
}
}
const remaining = machinesInPriorityOrder.filter(({ id }) =>
mgc.isMachineActive(id) && !flowDistribution.some(it => it.machineId === id));
const distributedFlow = Qd / remaining.length;
for (const m of remaining) {
flowDistribution.push({ machineId: m.id, flow: distributedFlow });
totalFlow += distributedFlow;
totalPower += groupCalcPower(m.machine, distributedFlow);
}
break;
}
case (Qd > activeTotals.flow.max): {
let i = 1;
while (totalFlow < Qd && i <= machinesInPriorityOrder.length) {
Qd = Qd / i;
if (groupFlow(machinesInPriorityOrder[i - 1].machine).currentFxyYMax >= Qd) {
for (let i2 = 0; i2 < i; i2++) {
if (!mgc.isMachineActive(machinesInPriorityOrder[i2].id)) {
flowDistribution.push({ machineId: machinesInPriorityOrder[i2].id, flow: Qd });
totalFlow += Qd;
totalPower += groupCalcPower(machinesInPriorityOrder[i2].machine, Qd);
}
}
}
i++;
}
break;
}
default: {
const countActive = machinesInPriorityOrder.filter(({ id }) => mgc.isMachineActive(id)).length;
Qd /= countActive;
for (let i = 0; i < countActive; i++) {
flowDistribution.push({ machineId: machinesInPriorityOrder[i].id, flow: Qd });
totalFlow += Qd;
totalPower += groupCalcPower(machinesInPriorityOrder[i].machine, Qd);
}
break;
}
}
const fUnit = mgc.unitPolicy.canonical.power;
const flUnit = mgc.unitPolicy.canonical.flow;
mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, totalPower, fUnit);
mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, totalFlow, flUnit);
mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(totalFlow / totalPower);
mgc.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(totalCog);
await Promise.all(flowDistribution.map(async ({ machineId, flow }) => {
const machine = mgc.machines[machineId];
const currentState = machine.state.getCurrentState();
if (flow > 0) {
await machine.handleInput('parent', 'flowmovement', mgc._canonicalToOutputFlow(flow));
if (currentState === 'idle') {
await machine.handleInput('parent', 'execsequence', 'startup');
}
} else if (currentState === 'operational' || currentState === 'accelerating' || currentState === 'decelerating') {
await machine.handleInput('parent', 'execsequence', 'shutdown');
}
}));
} catch (err) {
mgc.logger?.error?.(err);
}
}
async function prioPercentageControl(ctx, input, priorityList = null) {
const { mgc } = ctx;
try {
if (input < 0) { await mgc.turnOffAllMachines(); return; }
if (input > 100) input = 100;
const numOfMachines = Object.keys(mgc.machines).length;
const procentTotal = numOfMachines * input;
const machinesNeeded = Math.ceil(procentTotal / 100);
const activeTotals = mgc.totals.activeTotals();
const machinesActive = activeTotals.countActiveMachines;
const machinesInPriorityOrder = sortMachinesByPriority(mgc.machines, priorityList);
const ctrlDistribution = [];
if (machinesNeeded > machinesActive) {
machinesInPriorityOrder.forEach(({ id }, index) => {
if (index < machinesNeeded) ctrlDistribution.push({ machineId: id, ctrl: 0 });
});
}
if (machinesNeeded < machinesActive) {
machinesInPriorityOrder.forEach(({ id }, index) => {
if (mgc.isMachineActive(id)) {
ctrlDistribution.push({ machineId: id, ctrl: index < machinesNeeded ? 100 : -1 });
}
});
}
if (machinesNeeded === machinesActive) {
const ctrlPerMachine = procentTotal / machinesActive;
machinesInPriorityOrder.forEach(({ id }) => {
if (mgc.isMachineActive(id)) {
ctrlDistribution.push({ machineId: id, ctrl: Math.max(0, Math.min(ctrlPerMachine, 100)) });
}
});
}
await Promise.all(ctrlDistribution.map(async ({ machineId, ctrl }) => {
const machine = mgc.machines[machineId];
const currentState = machine.state.getCurrentState();
if (ctrl < 0 && (currentState === 'operational' || currentState === 'accelerating' || currentState === 'decelerating')) {
await machine.handleInput('parent', 'execsequence', 'shutdown');
} else if (currentState === 'idle' && ctrl >= 0) {
await machine.handleInput('parent', 'execsequence', 'startup');
} else if (currentState === 'operational' && ctrl > 0) {
await machine.handleInput('parent', 'execmovement', ctrl);
}
}));
const totalPower = [];
const totalFlow = [];
Object.values(mgc.machines).forEach(machine => {
const p = mgc.operatingPoint.readChild(machine, 'power', 'predicted', POSITIONS.AT_EQUIPMENT, mgc.unitPolicy.canonical.power);
const f = mgc.operatingPoint.readChild(machine, 'flow', 'predicted', POSITIONS.DOWNSTREAM, mgc.unitPolicy.canonical.flow);
if (p !== null) totalPower.push(p);
if (f !== null) totalFlow.push(f);
});
const sumP = totalPower.reduce((a, b) => a + b, 0);
const sumF = totalFlow.reduce((a, b) => a + b, 0);
mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, sumP, mgc.unitPolicy.canonical.power);
mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, sumF, mgc.unitPolicy.canonical.flow);
if (sumP > 0) {
mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(sumF / sumP);
}
} catch (err) {
mgc.logger?.error?.(err);
}
}
module.exports = { equalFlowControl, prioPercentageControl, capFlowDemand, sortMachinesByPriority, filterOutUnavailableMachines };

View File

@@ -0,0 +1,53 @@
'use strict';
const { LatestWinsGate } = require('generalFunctions');
// Thin wrapper around LatestWinsGate for the MGC demand path. Replaces
// the original `_dispatchInFlight` + `_delayedCall` pair in
// specificClass.handleInput: a new demand arriving while a dispatch is
// in flight overwrites any pending one, so the latest value always wins
// and intermediates are dropped silently.
class DemandDispatcher {
constructor(ctx = {}, runFn) {
if (typeof runFn !== 'function') {
throw new TypeError('DemandDispatcher requires a runFn');
}
this.ctx = ctx;
this.logger = ctx.logger || null;
this._runFn = runFn;
this._gate = new LatestWinsGate(
async (demand) => this._runFn(demand, this.ctx),
{ logger: this.logger },
);
}
fire(demand) {
this._gate.fire(demand);
}
// Returns a promise that resolves when THIS demand's dispatch settles.
// If superseded by a later fireAndWait while parked, the promise
// resolves with the LatestWinsGate SUPERSEDED sentinel
// ({ superseded: true }) — callers can branch on it without try/catch.
fireAndWait(demand) {
return this._gate.fireAndWait(demand);
}
drain() {
return this._gate.drain();
}
// Cancels any parked pending value so it cannot run. The currently
// in-flight dispatch (if any) still runs to completion. A parked
// fireAndWait promise resolves with the SUPERSEDED sentinel.
cancelPending() {
if (this._gate._pending) this._gate._supersedePending();
}
get inFlight() {
return this._gate.size > 0;
}
}
module.exports = DemandDispatcher;

View File

@@ -0,0 +1,90 @@
'use strict';
// Aggregates per-machine efficiency (cog) into group-level metrics and
// computes distance-from-peak. Extracted verbatim from specificClass.js
// (calcGroupEfficiency / calcDistanceFromPeak / calcRelativeDistanceFromPeak /
// calcDistanceBEP) so the orchestrator can delegate without inheriting
// the arithmetic.
class GroupEfficiency {
constructor(ctx = {}) {
this.ctx = ctx;
this.logger = ctx.logger || null;
this.interpolation = ctx.interpolation || null;
this.measurements = ctx.measurements || null;
this.machines = ctx.machines || null;
}
// Average of per-machine cog plus the worst-performing machine's cog.
// `maxEfficiency` is misleadingly named — it is in fact the MEAN cog
// across all machines, treated as the group-level "peak" target.
// Kept that way for behavioural parity with the original.
calcGroupEfficiency(machines) {
const target = machines || this.machines;
let cumEfficiency = 0;
let machineCount = 0;
let lowestEfficiency = Infinity;
Object.entries(target || {}).forEach(([_id, machine]) => {
cumEfficiency += machine.cog;
if (machine.cog < lowestEfficiency) {
lowestEfficiency = machine.cog;
}
machineCount++;
});
const maxEfficiency = cumEfficiency / machineCount;
const currentEfficiency = this._readCurrentEfficiency();
return { maxEfficiency, lowestEfficiency, currentEfficiency };
}
calcDistanceFromPeak(currentEfficiency, peakEfficiency) {
return Math.abs(currentEfficiency - peakEfficiency);
}
// Maps current efficiency onto [0..1] across [maxEfficiency..minEfficiency].
// Degenerate case (max === min) collapses the band to a point — return 1.
calcRelativeDistanceFromPeak(currentEfficiency, maxEfficiency, minEfficiency) {
let distance = 1;
if (currentEfficiency != null && maxEfficiency !== minEfficiency && this.interpolation) {
distance = this.interpolation.interpolate_lin_single_point(
currentEfficiency,
maxEfficiency,
minEfficiency,
0,
1,
);
}
return distance;
}
// Returns both abs + rel; orchestrator decides whether to mirror onto
// its own this.absDistFromPeak / this.relDistFromPeak fields.
calcDistanceBEP(currentEfficiency, maxEfficiency, minEfficiency) {
const absDistFromPeak = this.calcDistanceFromPeak(currentEfficiency, maxEfficiency);
const relDistFromPeak = this.calcRelativeDistanceFromPeak(
currentEfficiency,
maxEfficiency,
minEfficiency,
);
return { absDistFromPeak, relDistFromPeak };
}
// Pull the latest measured efficiency from the container if one was
// provided. Optional convenience — orchestrator may read it directly.
_readCurrentEfficiency() {
if (!this.measurements) return null;
try {
return this.measurements
.type('efficiency')
.variant('predicted')
.position('atequipment')
.getCurrentValue();
} catch (_err) {
return null;
}
}
}
module.exports = GroupEfficiency;

View File

@@ -0,0 +1,27 @@
// Group-scope read helpers for pump curves.
//
// Optimizers and totals evaluate each pump at the GROUP operating point
// (set by GroupOperatingPoint.equalize), not the pump's individual sensor-
// driven point. Each pump exposes a parallel "group*" predict object —
// these helpers fall back to the individual predicts when the pump hasn't
// been initialised for group operation yet (first tick after register).
function groupFlow(machine /*, ctx */) {
return machine.groupPredictFlow ?? machine.predictFlow;
}
function groupPower(machine /*, ctx */) {
return machine.groupPredictPower ?? machine.predictPower;
}
function groupNCog(machine /*, ctx */) {
return machine.groupPredictFlow ? (machine.groupNCog ?? 0) : (machine.NCog ?? 0);
}
function groupCalcPower(machine, flow /*, ctx */) {
return typeof machine.groupCalcPower === 'function'
? machine.groupCalcPower(flow)
: machine.inputFlowCalcPower(flow);
}
module.exports = { groupFlow, groupPower, groupNCog, groupCalcPower };

View File

@@ -0,0 +1,93 @@
const { POSITIONS } = require('generalFunctions');
// Group-scope measurement read/write + header equalization.
//
// Pulled out of specificClass during the P4 refactor: the equalization
// logic is the source of truth for the "one consistent header operating
// point" that the optimizer and totals modules both depend on. Keeping it
// in one place makes the order-of-operations explicit (read header, write
// onto every machine's group-scope predicts).
class GroupOperatingPoint {
constructor(ctx = {}) {
// ctx: { measurements, machines, unitPolicy, logger }
// Late-binding via getters in the orchestrator works too — but
// passing the live references avoids re-plumbing setters.
this.ctx = ctx;
}
get measurements() { return this.ctx.measurements; }
get machines() { return this.ctx.machines; }
get unitPolicy() { return this.ctx.unitPolicy; }
get logger() { return this.ctx.logger; }
readChild(machine, type, variant, position, unit = null) {
return machine?.measurements
?.type(type)
?.variant(variant)
?.position(position)
?.getCurrentValue(unit || undefined);
}
writeOwn(type, variant, position, value, unit = null, timestamp = Date.now()) {
if (!Number.isFinite(value)) return;
this.measurements
.type(type)
.variant(variant)
.position(position)
.value(value, timestamp, unit || undefined);
}
// Force every machine's predict-curve interpolators to use the same
// (header) differential pressure for MGC's optimization. See the
// original _equalizeOperatingPoint commentary in specificClass for
// the full rationale (header source order, fDimension fallback).
equalize() {
const machines = this.machines || {};
if (Object.keys(machines).length === 0) return;
const pressureUnit = this.unitPolicy.canonical.pressure;
const groupHeaderDown = this.measurements
.type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM)
.getCurrentValue(pressureUnit);
const groupHeaderUp = this.measurements
.type('pressure').variant('measured').position(POSITIONS.UPSTREAM)
.getCurrentValue(pressureUnit);
const childDown = [];
const childUp = [];
Object.values(machines).forEach(machine => {
const d = this.readChild(machine, 'pressure', 'measured', POSITIONS.DOWNSTREAM, pressureUnit);
const u = this.readChild(machine, 'pressure', 'measured', POSITIONS.UPSTREAM, pressureUnit);
if (Number.isFinite(d) && d > 0) childDown.push(d);
if (Number.isFinite(u) && u > 0) childUp.push(u);
});
const downIsHeader = Number.isFinite(groupHeaderDown) && groupHeaderDown > 0;
const upIsHeader = Number.isFinite(groupHeaderUp) && groupHeaderUp > 0;
const headerDownstream = downIsHeader ? groupHeaderDown : (childDown.length ? Math.max(...childDown) : 0);
const headerUpstream = upIsHeader ? groupHeaderUp : (childUp.length ? Math.min(...childUp) : 0);
const headerDiff = headerDownstream - headerUpstream;
if (!Number.isFinite(headerDiff) || headerDiff <= 0) {
this.logger?.debug?.(`Skipping equalization: invalid header diff ${headerDiff} (down=${headerDownstream}, up=${headerUpstream})`);
return;
}
this.logger?.debug?.(`Equalizing operating point: down=${headerDownstream}, up=${headerUpstream}, diff=${headerDiff}`);
Object.values(machines).forEach(machine => {
if (typeof machine.setGroupOperatingPoint === 'function') {
machine.setGroupOperatingPoint(headerDownstream, headerUpstream);
} else {
// Older rotatingMachine without the group API — direct
// fDimension write keeps demos working while submodules
// are rolled forward.
if (machine.predictFlow) machine.predictFlow.fDimension = headerDiff;
if (machine.predictPower) machine.predictPower.fDimension = headerDiff;
if (machine.predictCtrl) machine.predictCtrl.fDimension = headerDiff;
}
});
}
}
module.exports = GroupOperatingPoint;

82
src/io/output.js Normal file
View File

@@ -0,0 +1,82 @@
'use strict';
// Output + status-badge composition for machineGroupControl. Kept off the
// orchestrator so specificClass stays under the file-size budget. Both
// functions take the live MGC instance and reach for the same public surface
// the rest of the package already uses (measurements, dynamicTotals, mode).
const { statusBadge, POSITIONS } = require('generalFunctions');
function _outputUnitForType(unitPolicy, type) {
switch (String(type || '').toLowerCase()) {
case 'flow': return unitPolicy.output.flow;
case 'power': return unitPolicy.output.power;
case 'pressure': return unitPolicy.output.pressure;
case 'temperature': return unitPolicy.output.temperature;
default: return null;
}
}
function getOutput(mgc) {
const out = {};
const { measurements, unitPolicy, mode, scaling, absDistFromPeak, relDistFromPeak } = mgc;
measurements.getTypes().forEach(type => {
measurements.getVariants(type).forEach(variant => {
const unit = _outputUnitForType(unitPolicy, type);
const read = (pos) => measurements.type(type).variant(variant).position(pos).getCurrentValue(unit || undefined);
const dn = read(POSITIONS.DOWNSTREAM);
const at = read(POSITIONS.AT_EQUIPMENT);
const up = read(POSITIONS.UPSTREAM);
if (dn != null) out[`downstream_${variant}_${type}`] = dn;
if (up != null) out[`upstream_${variant}_${type}`] = up;
if (at != null) out[`atEquipment_${variant}_${type}`] = at;
if (dn != null && up != null) {
const diff = measurements.type(type).variant(variant)
.difference({ from: POSITIONS.DOWNSTREAM, to: POSITIONS.UPSTREAM, unit });
if (diff?.value != null) out[`differential_${variant}_${type}`] = diff.value;
}
});
});
out.mode = mode;
out.scaling = scaling;
out.absDistFromPeak = absDistFromPeak;
out.relDistFromPeak = relDistFromPeak;
// Group capacity + active-machine counts. Surfaced so dashboards can
// show the same numbers the status badge does without subscribing to
// every child node individually.
out.flowCapacityMax = mgc.dynamicTotals?.flow?.max ?? 0;
out.flowCapacityMin = mgc.dynamicTotals?.flow?.min ?? 0;
out.machineCount = Object.keys(mgc.machines || {}).length;
out.machineCountActive = Object.values(mgc.machines || {}).filter((m) => {
const s = m?.state?.getCurrentState?.();
const md = m?.currentMode;
return s && s !== 'off' && s !== 'maintenance' && md !== 'maintenance';
}).length;
return out;
}
function getStatusBadge(mgc) {
const totalFlow = mgc.measurements.type('flow').variant('predicted').position(POSITIONS.AT_EQUIPMENT)
.getCurrentValue(mgc.unitPolicy.output.flow) ?? 0;
const totalPower = mgc.measurements.type('power').variant('predicted').position(POSITIONS.AT_EQUIPMENT)
.getCurrentValue(mgc.unitPolicy.output.power) ?? 0;
const totalCapacity = mgc.dynamicTotals?.flow?.max ?? 0;
const available = Object.values(mgc.machines).filter(m => {
const s = m?.state?.getCurrentState?.();
const md = m?.currentMode;
return s && s !== 'off' && s !== 'maintenance' && md !== 'maintenance';
});
const machineCount = Object.keys(mgc.machines || {}).length;
const scaling = String(mgc.scaling || '').toLowerCase() === 'absolute' ? 'abs' : 'norm';
const parts = [
mgc.mode || '?',
scaling,
`Q=${Math.round(totalFlow)}/${Math.round(totalCapacity)} m³/h`,
`P=${Math.round(totalPower)} kW`,
`${available.length}/${machineCount}x`,
];
return statusBadge.compose(parts, { fill: available.length > 0 ? 'green' : (machineCount > 0 ? 'yellow' : 'grey'), shape: 'dot' });
}
module.exports = { getOutput, getStatusBadge };

View File

@@ -1,280 +1,26 @@
const { outputUtils, configManager, convert } = require("generalFunctions");
const Specific = require("./specificClass");
'use strict';
class nodeClass {
/**
* Create a MeasurementNode.
* @param {object} uiConfig - Node-RED node configuration.
* @param {object} RED - Node-RED runtime API.
* @param {object} nodeInstance - The Node-RED node instance.
* @param {string} nameOfNode - The name of the node, used for
*/
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
// Preserve RED reference for HTTP endpoints if needed
this.node = nodeInstance; // This is the Node-RED node instance, we can use this to send messages and update status
this.RED = RED; // This is the Node-RED runtime API, we can use this to create endpoints if needed
this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED
this.source = null; // Will hold the specific class instance
const { BaseNodeAdapter } = require('generalFunctions');
const MachineGroup = require('./specificClass');
const commands = require('./commands');
// Load default & UI config
this._loadConfig(uiConfig, this.node);
// Event-driven: the domain emits 'output-changed' from handlePressureChange
// (pump events) and from handleInput/turnOff. No tick loop needed.
class nodeClass extends BaseNodeAdapter {
static DomainClass = MachineGroup;
static commands = commands;
static tickInterval = null;
static statusInterval = 1000;
// Instantiate core Measurement class
this._setupSpecificClass();
// Wire up event and lifecycle handlers
this._bindEvents();
this._registerChild();
this._startTickLoop();
this._attachInputHandler();
this._attachCloseHandler();
}
/**
* Load and merge default config with user-defined settings.
* @param {object} uiConfig - Raw config from Node-RED UI.
*/
_loadConfig(uiConfig, node) {
const cfgMgr = new configManager();
this.defaultConfig = cfgMgr.getConfig(this.name);
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
// Build config: base sections (no domain-specific config for group controller)
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id);
// Utility for formatting outputs
this._output = new outputUtils();
}
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) {
const raw = typeof candidate === "string" ? candidate.trim() : "";
const fallback = String(fallbackUnit || "").trim();
if (!raw) {
return fallback;
}
try {
const desc = convert().describe(raw);
if (expectedMeasure && desc.measure !== expectedMeasure) {
throw new Error(`expected '${expectedMeasure}' but got '${desc.measure}'`);
}
return raw;
} catch (error) {
this.node?.warn?.(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`);
return fallback;
}
}
_updateNodeStatus() {
//console.log('Updating node status...');
const mg = this.source;
const mode = mg.mode;
const scaling = mg.scaling;
// Add safety checks for measurements
const totalFlow = mg.measurements
?.type("flow")
?.variant("predicted")
?.position("atequipment")
?.getCurrentValue(mg?.unitPolicy?.output?.flow || 'm3/h') || 0;
const totalPower = mg.measurements
?.type("power")
?.variant("predicted")
?.position("atEquipment")
?.getCurrentValue(mg?.unitPolicy?.output?.power || 'kW') || 0;
// Calculate total capacity based on available machines with safety checks
const availableMachines = Object.values(mg.machines || {}).filter((machine) => {
// Safety check: ensure machine and machine.state exist
if (!machine || !machine.state || typeof machine.state.getCurrentState !== 'function') {
mg.logger?.warn(`Machine missing or invalid: ${machine?.config?.general?.id || 'unknown'}`);
return false;
}
const state = machine.state.getCurrentState();
const mode = machine.currentMode;
return !(
state === "off" ||
state === "maintenance" ||
mode === "maintenance"
);
});
const totalCapacity = Math.round((mg.dynamicTotals?.flow?.max || 0) * 1) / 1;
// Determine overall status based on available machines
const status = availableMachines.length > 0
? `${availableMachines.length} machine(s) connected`
: "No machines";
let scalingSymbol = "";
switch ((scaling || "").toLowerCase()) {
case "absolute":
scalingSymbol = "Ⓐ";
break;
case "normalized":
scalingSymbol = "Ⓝ";
break;
default:
scalingSymbol = mode || "";
break;
}
const text = ` ${mode || 'Unknown'} | ${scalingSymbol}: 💨=${Math.round(totalFlow)}/${totalCapacity} | ⚡=${Math.round(totalPower)} | ${status}`;
return {
fill: availableMachines.length > 0 ? "green" : "red",
shape: "dot",
text,
};
}
/**
* Instantiate the core logic and store as source.
*/
_setupSpecificClass() {
this.source = new Specific(this.config);
this.node.source = this.source; // Store the source in the node instance for easy access
}
/**
* Bind events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES
*/
_bindEvents() {
this.source.emitter.on("mAbs", (val) => {
this.node.status({
fill: "green",
shape: "dot",
text: `${val} ${this.config.general.unit}`,
});
});
}
/**
* Register this node as a child upstream and downstream.
* Delayed to avoid Node-RED startup race conditions.
*/
_registerChild() {
setTimeout(() => {
this.node.send([
null,
null,
{
topic: "registerChild",
payload: this.node.id,
positionVsParent:
this.config?.functionality?.positionVsParent || "atEquipment",
},
]);
}, 100);
}
/**
* Start the periodic tick loop to drive the Measurement class.
*/
_startTickLoop() {
setTimeout(() => {
this._tickInterval = setInterval(() => this._tick(), 1000);
// Update node status on nodered screen every second ( this is not the best way to do this, but it works for now)
this._statusInterval = setInterval(() => {
const status = this._updateNodeStatus();
this.node.status(status);
}, 1000);
}, 1000);
}
/**
* Execute a single tick: update measurement, format and send outputs.
*/
_tick() {
const raw = this.source.getOutput();
const processMsg = this._output.formatMsg(raw, this.source.config, "process");
const influxMsg = this._output.formatMsg(raw, this.source.config, "influxdb");
// Send only updated outputs on ports 0 & 1
this.node.send([processMsg, influxMsg]);
}
/**
* Attach the node's input handler, routing control messages to the class.
*/
_attachInputHandler() {
this.node.on(
"input",
async (msg, send, done) => {
const mg = this.source;
const RED = this.RED;
try {
switch (msg.topic) {
case "registerChild": {
const childId = msg.payload;
const childObj = RED.nodes.getNode(childId);
if (!childObj || !childObj.source) {
mg.logger.warn(`registerChild skipped: missing child/source for id=${childId}`);
break;
}
mg.logger.debug(`Registering child: ${childId}, found: ${!!childObj}, source: ${!!childObj?.source}`);
mg.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
mg.logger.debug(`Total machines after registration: ${Object.keys(mg.machines || {}).length}`);
break;
}
case "setMode": {
const mode = msg.payload;
mg.setMode(mode);
break;
}
case "setScaling": {
const scaling = msg.payload;
mg.setScaling(scaling);
break;
}
case "Qd": {
const Qd = parseFloat(msg.payload);
const sourceQd = "parent";
if (isNaN(Qd)) {
mg.logger.error(`Invalid demand value: ${msg.payload}`);
break;
}
try {
await mg.handleInput(sourceQd, Qd);
msg.topic = mg.config.general.name;
msg.payload = "done";
send(msg);
} catch (error) {
mg.logger.error(`Failed to process Qd: ${error.message}`);
}
break;
}
default:
mg.logger.warn(`Unknown topic: ${msg.topic}`);
break;
}
} catch (error) {
mg.logger.error(`Input handler failure: ${error.message}`);
}
if (typeof done === 'function') done();
}
);
}
/**
* Clean up timers and intervals when Node-RED stops the node.
*/
_attachCloseHandler() {
this.node.on("close", (done) => {
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
this.node.status({}); // clear node status badge
if (typeof done === 'function') done();
});
buildDomainConfig(uiConfig = {}) {
// Schema shape is mode.current / scaling.current (the schema nests
// value + allowedActions/allowedSources under `current`). Editor field
// names are flat — bridge here.
const out = {};
if (uiConfig.mode) out.mode = { current: uiConfig.mode };
if (uiConfig.scaling) out.scaling = { current: uiConfig.scaling };
return out;
}
}
module.exports = nodeClass; // Export the class for Node-RED to use
module.exports = nodeClass;

View File

@@ -0,0 +1,188 @@
// BEP-gravitation optimizer: bias flow allocation toward each pump's BEP,
// then refine via marginal-cost swaps. `ctx` shape matches bestCombination.js.
const MC_ITER_CAP = 50; // marginal-cost refinement iterations
const MC_RELATIVE_EXIT = 0.001; // exit when the mc gap is < 0.1% of expensive.mc
// Estimate dP/dQ slopes around the BEP on the group operating point.
// Returns finite numbers for everything; falls back to zero slopes if the
// curve is flat or the machine has not been initialised.
function estimateSlopesAtBEP(machine, Q_BEP, ctx, delta = 1.0) {
const { groupCurves } = ctx;
const { groupFlow, groupNCog, groupCalcPower } = groupCurves;
const minFlow = groupFlow(machine).currentFxyYMin;
const maxFlow = groupFlow(machine).currentFxyYMax;
const span = Math.max(0, maxFlow - minFlow);
const normalizedCog = Math.max(0, Math.min(1, groupNCog(machine) || 0));
const targetBEP = Q_BEP ?? (minFlow + span * normalizedCog);
const clampFlow = (flow) => Math.min(maxFlow, Math.max(minFlow, flow));
const center = clampFlow(targetBEP);
const deltaSafe = Math.max(delta, 0.01);
const leftFlow = clampFlow(center - deltaSafe);
const rightFlow = clampFlow(center + deltaSafe);
const powerAt = (flow) => groupCalcPower(machine, flow);
const P_center = powerAt(center);
const P_left = powerAt(leftFlow);
const P_right = powerAt(rightFlow);
const slopeLeft = (P_center - P_left) / Math.max(1e-6, center - leftFlow);
const slopeRight = (P_right - P_center) / Math.max(1e-6, rightFlow - center);
const alpha = Math.max(1e-6, (Math.abs(slopeLeft) + Math.abs(slopeRight)) / 2);
return { slopeLeft, slopeRight, alpha, Q_BEP: center, P_BEP: P_center };
}
// Redistribute `delta` across pumps using slope-derived weights; flatter
// curves attract more flow. Bounded: exits on zero progress or no capacity.
function redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional = true) {
const tolerance = 1e-3;
let remaining = delta;
const entryMap = new Map(flowDistribution.map(entry => [entry.machineId, entry]));
while (Math.abs(remaining) > tolerance) {
const increasing = remaining > 0;
const candidates = pumpInfos.map(info => {
const entry = entryMap.get(info.id);
if (!entry) return null;
const capacity = increasing ? info.maxFlow - entry.flow : entry.flow - info.minFlow;
if (capacity <= tolerance) return null;
const slope = increasing
? (directional ? info.slopes.slopeRight : info.slopes.alpha)
: (directional ? info.slopes.slopeLeft : info.slopes.alpha);
const weight = 1 / Math.max(1e-6, Math.abs(slope) || info.slopes.alpha || 1);
return { entry, capacity, weight };
}).filter(Boolean);
if (!candidates.length) break;
const weightSum = candidates.reduce((sum, c) => sum + c.weight * c.capacity, 0);
if (weightSum <= 0) break;
let progress = 0;
candidates.forEach(candidate => {
let share = (candidate.weight * candidate.capacity / weightSum) * Math.abs(remaining);
share = Math.min(share, candidate.capacity);
if (share <= 0) return;
if (increasing) candidate.entry.flow += share;
else candidate.entry.flow -= share;
progress += share;
});
if (progress <= tolerance) break;
remaining += increasing ? -progress : progress;
}
}
function _marginalCostRefine(flowDistribution, pumpInfos, Qd, ctx) {
const { groupCalcPower } = ctx.groupCurves;
const mcDelta = Math.max(1e-6, (Qd / pumpInfos.length) * 0.005);
for (let iter = 0; iter < MC_ITER_CAP; iter++) {
const mcEntries = flowDistribution.map(entry => {
const info = pumpInfos.find(i => i.id === entry.machineId);
const pNow = groupCalcPower(info.machine, entry.flow);
const pUp = groupCalcPower(info.machine, Math.min(info.maxFlow, entry.flow + mcDelta));
return { entry, info, mc: (pUp - pNow) / mcDelta };
});
let expensive = null;
let cheap = null;
for (const e of mcEntries) {
if (e.entry.flow > e.info.minFlow + mcDelta && (!expensive || e.mc > expensive.mc)) expensive = e;
if (e.entry.flow < e.info.maxFlow - mcDelta && (!cheap || e.mc < cheap.mc)) cheap = e;
}
if (!expensive || !cheap || expensive === cheap) break;
if (expensive.mc - cheap.mc < expensive.mc * MC_RELATIVE_EXIT) break;
const before = groupCalcPower(expensive.info.machine, expensive.entry.flow)
+ groupCalcPower(cheap.info.machine, cheap.entry.flow);
const after = groupCalcPower(expensive.info.machine, expensive.entry.flow - mcDelta)
+ groupCalcPower(cheap.info.machine, cheap.entry.flow + mcDelta);
if (after < before) {
expensive.entry.flow -= mcDelta;
cheap.entry.flow += mcDelta;
} else {
break;
}
}
}
function calcBestCombinationBEPGravitation(combinations, Qd, ctx, method = 'BEP-Gravitation-Directional') {
const { machines, groupCurves } = ctx;
const { groupFlow, groupNCog, groupCalcPower } = groupCurves;
const directional = method === 'BEP-Gravitation-Directional';
let bestCombination = null;
let bestPower = Infinity;
let bestFlow = 0;
let bestCog = 0;
let bestDeviation = Infinity;
combinations.forEach(combination => {
const pumpInfos = combination.map(machineId => {
const machine = machines[machineId];
const minFlow = groupFlow(machine).currentFxyYMin;
const maxFlow = groupFlow(machine).currentFxyYMax;
const span = Math.max(0, maxFlow - minFlow);
const NCog = Math.max(0, Math.min(1, groupNCog(machine) || 0));
const estimatedBEP = minFlow + span * NCog;
const slopes = estimateSlopesAtBEP(machine, estimatedBEP, ctx);
return { id: machineId, machine, minFlow, maxFlow, NCog, Q_BEP: slopes.Q_BEP, slopes };
});
if (pumpInfos.length === 0) return;
const flowDistribution = pumpInfos.map(info => ({
machineId: info.id,
flow: Math.min(info.maxFlow, Math.max(info.minFlow, info.Q_BEP)),
}));
let totalFlow = flowDistribution.reduce((s, e) => s + e.flow, 0);
const delta = Qd - totalFlow;
if (Math.abs(delta) > 1e-6) {
redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional);
}
flowDistribution.forEach(entry => {
const info = pumpInfos.find(i => i.id === entry.machineId);
entry.flow = Math.min(info.maxFlow, Math.max(info.minFlow, entry.flow));
});
_marginalCostRefine(flowDistribution, pumpInfos, Qd, ctx);
let totalPower = 0;
totalFlow = 0;
flowDistribution.forEach(entry => {
totalFlow += entry.flow;
const info = pumpInfos.find(i => i.id === entry.machineId);
totalPower += groupCalcPower(info.machine, entry.flow);
});
const totalCog = pumpInfos.reduce((s, info) => s + info.NCog, 0);
const deviation = pumpInfos.reduce((sum, info) => {
const entry = flowDistribution.find(item => item.machineId === info.id);
const deltaFlow = entry ? (entry.flow - info.Q_BEP) : 0;
return sum + (deltaFlow * deltaFlow) * (info.slopes.alpha || 1);
}, 0);
const shouldUpdate = totalPower < bestPower
|| (totalPower === bestPower && deviation < bestDeviation);
if (shouldUpdate) {
bestCombination = flowDistribution.map(e => ({ ...e }));
bestPower = totalPower;
bestFlow = totalFlow;
bestCog = totalCog;
bestDeviation = deviation;
}
});
return { bestCombination, bestPower, bestFlow, bestCog, bestDeviation, method };
}
module.exports = {
calcBestCombinationBEPGravitation,
estimateSlopesAtBEP,
redistributeFlowBySlope,
};

View File

@@ -0,0 +1,88 @@
// CoG-based combination optimizer.
// Pure function: picks the combination whose CoG-weighted flow allocation
// yields the lowest total power, clamped to each machine's curve envelope.
//
// `ctx` must provide:
// - machines: machineId -> machine
// - groupCurves: { groupFlow, groupNCog, groupCalcPower }
// - logger (optional, for debug traces)
const ROUND_2 = 100;
function calcBestCombination(combinations, Qd, ctx) {
const { machines, groupCurves, logger } = ctx;
const { groupFlow, groupNCog, groupCalcPower } = groupCurves;
let bestCombination = null;
let bestPower = Infinity;
let bestFlow = 0;
let bestCog = 0;
combinations.forEach(combination => {
const totalCoG = combination.reduce((sum, id) => {
return sum + Math.round((groupNCog(machines[id]) || 0) * ROUND_2) / ROUND_2;
}, 0);
// CoG-weighted initial distribution; if all CoGs are 0, split evenly.
let flowDistribution = combination.map(machineId => {
const machine = machines[machineId];
let flow;
if (totalCoG === 0) {
flow = Qd / combination.length;
} else {
flow = ((groupNCog(machine) || 0) / totalCoG) * Qd;
logger?.debug?.(`Machine Normalized CoG-based distribution ${machineId} flow: ${flow}`);
}
return { machineId, flow };
});
const clamped = flowDistribution.map(entry => {
const machine = machines[entry.machineId];
const min = groupFlow(machine).currentFxyYMin;
const max = groupFlow(machine).currentFxyYMax;
const clampedFlow = Math.min(max, Math.max(min, entry.flow));
return { ...entry, flow: clampedFlow, min, max, desired: entry.flow };
});
// Spill the unmet remainder once: distribute proportionally to each
// machine's *desired* share, weighted toward those with headroom.
let remainder = Qd - clamped.reduce((sum, entry) => sum + entry.flow, 0);
if (Math.abs(remainder) > 1e-6) {
const adjustable = clamped.filter(entry =>
remainder > 0 ? entry.flow < entry.max : entry.flow > entry.min,
);
const weightSum = adjustable.reduce((s, e) => s + e.desired, 0) || adjustable.length;
adjustable.forEach(entry => {
const weight = entry.desired / weightSum || 1 / adjustable.length;
const delta = remainder * weight;
const next = remainder > 0
? Math.min(entry.max, entry.flow + delta)
: Math.max(entry.min, entry.flow + delta);
remainder -= (next - entry.flow);
entry.flow = next;
});
}
flowDistribution = clamped;
let totalFlow = 0;
let totalPower = 0;
flowDistribution.forEach(({ machineId, flow }) => {
totalFlow += flow;
totalPower += groupCalcPower(machines[machineId], flow);
});
if (totalPower < bestPower) {
logger?.debug?.(`New best combination found: ${totalPower} < ${bestPower}`);
bestPower = totalPower;
bestFlow = totalFlow;
bestCog = totalCoG;
bestCombination = flowDistribution;
}
});
return { bestCombination, bestPower, bestFlow, bestCog };
}
module.exports = { calcBestCombination };

17
src/optimizer/index.js Normal file
View File

@@ -0,0 +1,17 @@
const cog = require('./bestCombination');
const bep = require('./bepGravitation');
// Pick the optimizer module by config string.
// Anything other than the two BEP variants falls back to CoG.
function pickOptimizer(method) {
if (method === 'BEP-Gravitation' || method === 'BEP-Gravitation-Directional') return bep;
return cog;
}
module.exports = {
pickOptimizer,
calcBestCombination: cog.calcBestCombination,
calcBestCombinationBEPGravitation: bep.calcBestCombinationBEPGravitation,
estimateSlopesAtBEP: bep.estimateSlopesAtBEP,
redistributeFlowBySlope: bep.redistributeFlowBySlope,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
const { POSITIONS } = require('generalFunctions');
const { groupFlow, groupPower, groupNCog } = require('../groupOps/groupCurves');
// Aggregations across every machine in the group.
//
// calcAbsoluteTotals scans the full input-curve envelope (worst/best case
// over the pump's entire pressure range). calcDynamicTotals reads the
// current group operating point (after equalize). activeTotals only sums
// machines that are operationally active right now.
class TotalsCalculator {
constructor(ctx = {}) {
// ctx: { machines, unitPolicy, logger, operatingPoint, isMachineActive }
// operatingPoint is a GroupOperatingPoint instance (for readChild).
// isMachineActive is delegated back to the orchestrator so the
// state-machine vocabulary lives in one place.
this.ctx = ctx;
this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog: 0 };
this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
}
get machines() { return this.ctx.machines || {}; }
get unitPolicy() { return this.ctx.unitPolicy; }
get logger() { return this.ctx.logger; }
get operatingPoint() { return this.ctx.operatingPoint; }
isMachineActive(id) {
if (typeof this.ctx.isMachineActive === 'function') return this.ctx.isMachineActive(id);
const s = this.machines[id]?.state?.getCurrentState?.();
return s === 'operational' || s === 'accelerating' || s === 'decelerating';
}
calcAbsoluteTotals() {
const out = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
Object.values(this.machines).forEach(machine => {
const totals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
Object.entries(machine.predictFlow.inputCurve).forEach(([pressure, xyCurve]) => {
const minFlow = Math.min(...xyCurve.y);
const maxFlow = Math.max(...xyCurve.y);
const minPower = Math.min(...machine.predictPower.inputCurve[pressure].y);
const maxPower = Math.max(...machine.predictPower.inputCurve[pressure].y);
if (minFlow < totals.flow.min) totals.flow.min = minFlow;
if (minPower < totals.power.min) totals.power.min = minPower;
if (maxFlow > totals.flow.max) totals.flow.max = maxFlow;
if (maxPower > totals.power.max) totals.power.max = maxPower;
});
if (totals.flow.min < out.flow.min) out.flow.min = totals.flow.min;
if (totals.power.min < out.power.min) out.power.min = totals.power.min;
out.flow.max += totals.flow.max;
out.power.max += totals.power.max;
});
// Empty-group + sentinel reset: Infinity / -Infinity are math
// artefacts of the reducer's initial values; downstream code
// expects clean zeros.
if (out.flow.min === Infinity) { this.logger?.warn?.('Flow min Infinity — zeroing.'); out.flow.min = 0; }
if (out.power.min === Infinity) { this.logger?.warn?.('Power min Infinity — zeroing.'); out.power.min = 0; }
if (out.flow.max === -Infinity) { this.logger?.warn?.('Flow max -Infinity — zeroing.'); out.flow.max = 0; }
if (out.power.max === -Infinity) { this.logger?.warn?.('Power max -Infinity — zeroing.'); out.power.max = 0; }
this.absoluteTotals = out;
return out;
}
calcDynamicTotals() {
const out = { flow: { min: Infinity, max: 0, act: 0 }, power: { min: Infinity, max: 0, act: 0 }, NCog: 0 };
const fUnit = this.unitPolicy.canonical.flow;
const pUnit = this.unitPolicy.canonical.power;
Object.values(this.machines).forEach(machine => {
if (!machine.hasCurve) {
this.logger?.error?.(`Machine ${machine.config?.general?.id} has no valid curve — skipping.`);
return;
}
const gpf = groupFlow(machine);
const gpp = groupPower(machine);
const minFlow = gpf.currentFxyYMin;
const maxFlow = gpf.currentFxyYMax;
const minPower = gpp.currentFxyYMin;
const maxPower = gpp.currentFxyYMax;
const actFlow = this.operatingPoint?.readChild(machine, 'flow', 'predicted', POSITIONS.DOWNSTREAM, fUnit) || 0;
const actPower = this.operatingPoint?.readChild(machine, 'power', 'predicted', POSITIONS.AT_EQUIPMENT, pUnit) || 0;
if (minFlow < out.flow.min) out.flow.min = minFlow;
if (minPower < out.power.min) out.power.min = minPower;
out.flow.max += maxFlow;
out.power.max += maxPower;
out.flow.act += actFlow;
out.power.act += actPower;
out.NCog += groupNCog(machine);
});
this.dynamicTotals = out;
return out;
}
activeTotals() {
const out = { flow: { min: 0, max: 0 }, power: { min: 0, max: 0 }, countActiveMachines: 0 };
Object.entries(this.machines).forEach(([id, machine]) => {
if (!this.isMachineActive(id)) return;
const gpf = groupFlow(machine);
const gpp = groupPower(machine);
out.flow.min += gpf.currentFxyYMin;
out.flow.max += gpf.currentFxyYMax;
out.power.min += gpp.currentFxyYMin;
out.power.max += gpp.currentFxyYMax;
out.countActiveMachines += 1;
});
return out;
}
}
module.exports = TotalsCalculator;

View File

@@ -0,0 +1,110 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
calcBestCombinationBEPGravitation,
estimateSlopesAtBEP,
redistributeFlowBySlope,
} = require('../../src/optimizer/bepGravitation');
const optimizerIndex = require('../../src/optimizer');
function makeMachine({ id, fMin = 0, fMax = 100, NCog = 0.5, costFn } = {}) {
return {
config: { general: { id } },
NCog,
predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax },
predictPower: { currentFxyYMin: 0, currentFxyYMax: fMax * 2 },
// Default: convex cost so marginal-cost refinement has a clear winner.
inputFlowCalcPower: costFn ?? ((f) => 0.001 * f * f + f),
};
}
function mkCtx(machines) {
return {
machines,
groupCurves: {
groupFlow: (m) => m.predictFlow,
groupPower: (m) => m.predictPower,
groupNCog: (m) => m.NCog ?? 0,
groupCalcPower: (m, f) => m.inputFlowCalcPower(f),
},
logger: { debug: () => {} },
};
}
test('estimateSlopesAtBEP: returns finite slopes/alpha/Q_BEP/P_BEP for a typical machine', () => {
const machine = makeMachine({ id: 'a', fMin: 10, fMax: 90, NCog: 0.5 });
const ctx = mkCtx({ a: machine });
const slopes = estimateSlopesAtBEP(machine, 50, ctx);
assert.ok(Number.isFinite(slopes.slopeLeft));
assert.ok(Number.isFinite(slopes.slopeRight));
assert.ok(Number.isFinite(slopes.alpha));
assert.ok(slopes.alpha > 0);
assert.ok(Number.isFinite(slopes.Q_BEP));
assert.equal(slopes.Q_BEP, 50);
assert.ok(Number.isFinite(slopes.P_BEP));
});
test('redistributeFlowBySlope: redistributes within capacity, never exceeding min/max', () => {
const pumpInfos = [
{ id: 'a', minFlow: 0, maxFlow: 50,
slopes: { slopeLeft: 1, slopeRight: 1, alpha: 1 } },
{ id: 'b', minFlow: 0, maxFlow: 50,
slopes: { slopeLeft: 1, slopeRight: 1, alpha: 1 } },
];
const flowDist = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }];
redistributeFlowBySlope(pumpInfos, flowDist, 30); // add 30 across 2 pumps
const total = flowDist.reduce((s, e) => s + e.flow, 0);
assert.ok(Math.abs(total - 50) < 1e-2, `expected total ~50, got ${total}`);
for (const e of flowDist) {
assert.ok(e.flow <= 50 + 1e-6 && e.flow >= 0 - 1e-6);
}
});
test('marginal-cost refinement bounded (no infinite loop on a flat-curve scenario)', () => {
// Flat cost everywhere -> marginal cost identical -> loop must exit cleanly.
const machines = {
a: makeMachine({ id: 'a', fMin: 0, fMax: 100, costFn: (f) => f }),
b: makeMachine({ id: 'b', fMin: 0, fMax: 100, costFn: (f) => f }),
};
const ctx = mkCtx(machines);
const start = Date.now();
const res = calcBestCombinationBEPGravitation([['a', 'b']], 30, ctx);
const elapsed = Date.now() - start;
assert.ok(elapsed < 1000, `refinement should be fast, took ${elapsed}ms`);
assert.ok(res.bestCombination);
const total = res.bestCombination.reduce((s, e) => s + e.flow, 0);
assert.ok(Math.abs(total - 30) < 1e-2, `total should be ~Qd, got ${total}`);
});
test('method selection: directional uses slopeRight/slopeLeft; non-directional uses alpha', () => {
// Asymmetric slopes so the two methods produce different allocations.
const pumpInfos = [
{ id: 'a', minFlow: 0, maxFlow: 100,
slopes: { slopeLeft: 10, slopeRight: 0.1, alpha: 5 } },
{ id: 'b', minFlow: 0, maxFlow: 100,
slopes: { slopeLeft: 0.1, slopeRight: 10, alpha: 5 } },
];
const distDir = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }];
const distAlpha = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }];
// Increase by 30 -> directional should prefer 'a' (shallow right slope).
redistributeFlowBySlope(pumpInfos, distDir, 30, true);
// Alpha mode: same slope-weight per pump -> roughly equal split.
redistributeFlowBySlope(pumpInfos, distAlpha, 30, false);
const aDir = distDir.find(e => e.machineId === 'a').flow;
const bDir = distDir.find(e => e.machineId === 'b').flow;
const aAlpha = distAlpha.find(e => e.machineId === 'a').flow;
const bAlpha = distAlpha.find(e => e.machineId === 'b').flow;
assert.ok(aDir > bDir, `directional should send more to a (got a=${aDir}, b=${bDir})`);
assert.ok(Math.abs(aAlpha - bAlpha) < 1e-2, `alpha mode should split evenly (got a=${aAlpha}, b=${bAlpha})`);
// pickOptimizer wires the right module.
assert.equal(optimizerIndex.pickOptimizer('BEP-Gravitation-Directional').calcBestCombinationBEPGravitation,
calcBestCombinationBEPGravitation);
assert.equal(optimizerIndex.pickOptimizer('BEP-Gravitation').calcBestCombinationBEPGravitation,
calcBestCombinationBEPGravitation);
assert.ok(optimizerIndex.pickOptimizer('CoG').calcBestCombination);
});

View File

@@ -0,0 +1,67 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { calcBestCombination } = require('../../src/optimizer/bestCombination');
function makeMachine({ id, fMin = 0, fMax = 100, NCog = 0.5, costFn } = {}) {
return {
config: { general: { id } },
NCog,
predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax },
predictPower: { currentFxyYMin: 0, currentFxyYMax: fMax * 2 },
// Power model: caller picks the cost function so we can shape who wins.
inputFlowCalcPower: costFn ?? ((flow) => flow * 1.0),
};
}
function mkCtx(machines) {
return {
machines,
groupCurves: {
groupFlow: (m) => m.predictFlow,
groupPower: (m) => m.predictPower,
groupNCog: (m) => m.NCog ?? 0,
groupCalcPower: (m, f) => m.inputFlowCalcPower(f),
},
logger: { debug: () => {} },
};
}
test('calcBestCombination: 1 machine in combination receives Qd clamped to its range', () => {
const machines = { a: makeMachine({ id: 'a', fMin: 5, fMax: 60 }) };
const ctx = mkCtx(machines);
const res = calcBestCombination([['a']], 40, ctx);
assert.ok(res.bestCombination);
assert.equal(res.bestCombination.length, 1);
assert.equal(res.bestCombination[0].flow, 40);
// Above max — clamps to max.
const high = calcBestCombination([['a']], 200, ctx);
assert.equal(high.bestCombination[0].flow, 60);
});
test('calcBestCombination: 2 machines with equal NCog split flow evenly', () => {
const machines = {
a: makeMachine({ id: 'a', NCog: 0.5, fMin: 0, fMax: 100 }),
b: makeMachine({ id: 'b', NCog: 0.5, fMin: 0, fMax: 100 }),
};
const ctx = mkCtx(machines);
const res = calcBestCombination([['a', 'b']], 40, ctx);
const aFlow = res.bestCombination.find(e => e.machineId === 'a').flow;
const bFlow = res.bestCombination.find(e => e.machineId === 'b').flow;
assert.ok(Math.abs(aFlow - bFlow) < 1e-6, `expected even split, got a=${aFlow} b=${bFlow}`);
assert.ok(Math.abs(aFlow + bFlow - 40) < 1e-6);
});
test('calcBestCombination: returns combination with the lowest total power', () => {
// Two combinations: [a] (expensive) vs [b] (cheap). Both can deliver Qd=20.
const machines = {
a: makeMachine({ id: 'a', fMin: 0, fMax: 100, costFn: (f) => f * 10 }),
b: makeMachine({ id: 'b', fMin: 0, fMax: 100, costFn: (f) => f * 1 }),
};
const ctx = mkCtx(machines);
const res = calcBestCombination([['a'], ['b']], 20, ctx);
assert.equal(res.bestCombination[0].machineId, 'b');
assert.equal(res.bestPower, 20);
});

View File

@@ -0,0 +1,172 @@
// Basic tests for the machineGroupControl commands registry.
// Run with: node --test test/basic/commands.basic.test.js
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createRegistry } = require('generalFunctions');
const commands = require('../../src/commands');
// --- helpers ---------------------------------------------------------------
function makeLogger() {
const calls = { warn: [], error: [], info: [], debug: [] };
return {
calls,
warn: (m) => calls.warn.push(String(m)),
error: (m) => calls.error.push(String(m)),
info: (m) => calls.info.push(String(m)),
debug: (m) => calls.debug.push(String(m)),
};
}
function makeSource({ name = 'mgc-1', handleInputResult = undefined } = {}) {
const calls = {
setMode: [],
setScaling: [],
handleInput: [],
registerChild: [],
};
const source = {
logger: makeLogger(),
config: { general: { name } },
setMode: (m) => calls.setMode.push(m),
setScaling: (s) => calls.setScaling.push(s),
handleInput: async (src, demand) => {
calls.handleInput.push({ src, demand });
if (handleInputResult instanceof Error) throw handleInputResult;
return handleInputResult;
},
childRegistrationUtils: {
registerChild: (childSource, position) =>
calls.registerChild.push({ childSource, position }),
},
};
return { source, calls };
}
function makeCtx({ child = null, logger = makeLogger(), sendSpy = null } = {}) {
return {
logger,
RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } },
node: {},
send: sendSpy || (() => {}),
};
}
function makeRegistry(logger) {
return createRegistry(commands, { logger });
}
// --- tests -----------------------------------------------------------------
test('canonical topics dispatch to their handlers', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.mode', payload: 'prioritycontrol' }, source, makeCtx());
assert.deepEqual(calls.setMode, ['prioritycontrol']);
await reg.dispatch({ topic: 'set.scaling', payload: 'normalized' }, source, makeCtx());
assert.deepEqual(calls.setScaling, ['normalized']);
await reg.dispatch({ topic: 'set.demand', payload: '12.5' }, source, makeCtx());
assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 12.5 });
});
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
const { source, calls } = makeSource();
const child = { id: 'child-1', source: { tag: 'child-domain' } };
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'child.register', payload: 'child-1', positionVsParent: 'upstream' },
source,
makeCtx({ child })
);
assert.equal(calls.registerChild.length, 1);
assert.equal(calls.registerChild[0].childSource, child.source);
assert.equal(calls.registerChild[0].position, 'upstream');
});
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch({ topic: 'setMode', payload: 'prioritycontrol' }, source, makeCtx({ logger: ctxLogger }));
await reg.dispatch({ topic: 'setMode', payload: 'optimalcontrol' }, source, makeCtx({ logger: ctxLogger }));
assert.deepEqual(calls.setMode, ['prioritycontrol', 'optimalcontrol']);
let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated"));
assert.equal(warns.length, 1, 'setMode deprecation warning should log exactly once');
await reg.dispatch({ topic: 'setScaling', payload: 'absolute' }, source, makeCtx({ logger: ctxLogger }));
warns = ctxLogger.calls.warn.filter((m) => m.includes("'setScaling' is deprecated"));
assert.equal(warns.length, 1);
assert.deepEqual(calls.setScaling, ['absolute']);
await reg.dispatch({ topic: 'Qd', payload: 5 }, source, makeCtx({ logger: ctxLogger }));
warns = ctxLogger.calls.warn.filter((m) => m.includes("'Qd' is deprecated"));
assert.equal(warns.length, 1);
assert.equal(calls.handleInput.length, 1);
const child = { id: 'child-x', source: { tag: 'child-domain' } };
await reg.dispatch(
{ topic: 'registerChild', payload: 'child-x', positionVsParent: 'atEquipment' },
source,
makeCtx({ child, logger: ctxLogger })
);
warns = ctxLogger.calls.warn.filter((m) => m.includes("'registerChild' is deprecated"));
assert.equal(warns.length, 1);
assert.equal(calls.registerChild.length, 1);
});
test('set.demand with non-numeric payload logs error and does not call handleInput', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 'oops' }, source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.handleInput.length, 0);
assert.ok(
ctxLogger.calls.error.some((m) => m.includes('set.demand') && m.includes('oops')),
`expected error about invalid Qd, got: ${JSON.stringify(ctxLogger.calls.error)}`
);
});
test('set.demand on success calls ctx.send with reply { topic: config.general.name, payload: "done" }', async () => {
const { source, calls } = makeSource({ name: 'mgc-A' });
const sent = [];
const ctx = makeCtx({ sendSpy: (m) => sent.push(m) });
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 7.5 }, source, ctx);
assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 7.5 });
assert.equal(sent.length, 1);
assert.equal(sent[0].topic, 'mgc-A');
assert.equal(sent[0].payload, 'done');
});
test('child.register with unknown child id logs warn and does not throw', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await assert.doesNotReject(() =>
reg.dispatch(
{ topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' },
source,
makeCtx({ logger: ctxLogger })
)
);
assert.equal(calls.registerChild.length, 0);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')),
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});

View File

@@ -0,0 +1,140 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DemandDispatcher = require('../../src/dispatch/demandDispatcher.js');
const silentLogger = { warn() {}, error() {}, debug() {}, info() {} };
// Helper: a manually-resolvable promise so we can pin a dispatch in flight.
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
test('fire(50) triggers runFn with 50', async () => {
const calls = [];
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => { calls.push(demand); },
);
dispatcher.fire(50);
await dispatcher.drain();
assert.deepEqual(calls, [50]);
});
test('two fires back-to-back during in-flight — only the second runs after first settles', async () => {
const calls = [];
const gates = [deferred()];
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => {
calls.push(demand);
await gates[0].promise;
},
);
dispatcher.fire(10);
// first invocation is now in flight (after a microtask)
await Promise.resolve();
await Promise.resolve();
dispatcher.fire(20);
// 20 should be pending, not yet run.
assert.deepEqual(calls, [10]);
gates[0].resolve();
await dispatcher.drain();
assert.deepEqual(calls, [10, 20]);
});
test('three rapid fires — only first + last run; middle dropped', async () => {
const calls = [];
const gate = deferred();
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => {
calls.push(demand);
if (calls.length === 1) await gate.promise;
},
);
dispatcher.fire(1);
await Promise.resolve();
await Promise.resolve();
dispatcher.fire(2);
dispatcher.fire(3); // overwrites the pending 2
assert.deepEqual(calls, [1]);
gate.resolve();
await dispatcher.drain();
assert.deepEqual(calls, [1, 3]);
});
test('drain() resolves only when idle', async () => {
const gate = deferred();
let runs = 0;
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async () => { runs++; await gate.promise; },
);
// drain() on an idle gate resolves immediately.
await dispatcher.drain();
dispatcher.fire('a');
let drained = false;
const drainPromise = dispatcher.drain().then(() => { drained = true; });
// Let a few microtasks run — drain must NOT be resolved while in flight.
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(drained, false);
assert.equal(runs, 1);
gate.resolve();
await drainPromise;
assert.equal(drained, true);
});
test('error in runFn does not deadlock; subsequent fire still works', async () => {
const calls = [];
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => {
calls.push(demand);
if (demand === 'boom') throw new Error('boom');
},
);
dispatcher.fire('boom');
await dispatcher.drain();
dispatcher.fire('ok');
await dispatcher.drain();
assert.deepEqual(calls, ['boom', 'ok']);
});
test('inFlight getter reports correctly', async () => {
const gate = deferred();
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async () => { await gate.promise; },
);
assert.equal(dispatcher.inFlight, false);
dispatcher.fire(1);
// Microtask scheduling — gate flips to inFlight after one tick.
await Promise.resolve();
assert.equal(dispatcher.inFlight, true);
gate.resolve();
await dispatcher.drain();
assert.equal(dispatcher.inFlight, false);
});
test('runFn receives the ctx supplied at construction', async () => {
const seen = [];
const ctx = { logger: silentLogger, marker: 'mgc-A' };
const dispatcher = new DemandDispatcher(
ctx,
async (demand, runCtx) => { seen.push({ demand, marker: runCtx.marker }); },
);
dispatcher.fire(42);
await dispatcher.drain();
assert.deepEqual(seen, [{ demand: 42, marker: 'mgc-A' }]);
});

View File

@@ -0,0 +1,66 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { groupFlow, groupPower, groupNCog, groupCalcPower } = require('../../src/groupOps/groupCurves');
function predictView(min, max, current = (min + max) / 2) {
return {
currentF: current,
currentFxyYMin: min,
currentFxyYMax: max,
};
}
test('groupFlow returns the same shape as the original _groupFlow (groupPredictFlow preferred)', () => {
const machine = {
predictFlow: predictView(0, 1, 0.5),
groupPredictFlow: predictView(0.1, 0.9, 0.4),
};
const v = groupFlow(machine);
assert.equal(v, machine.groupPredictFlow);
assert.equal(v.currentFxyYMin, 0.1);
assert.equal(v.currentFxyYMax, 0.9);
assert.equal(v.currentF, 0.4);
});
test('groupFlow falls back to predictFlow when groupPredictFlow is absent', () => {
const machine = { predictFlow: predictView(0, 1) };
assert.equal(groupFlow(machine), machine.predictFlow);
});
test('groupPower returns groupPredictPower when present, else predictPower', () => {
const m1 = { predictPower: predictView(0, 100), groupPredictPower: predictView(10, 90) };
assert.equal(groupPower(m1), m1.groupPredictPower);
const m2 = { predictPower: predictView(0, 100) };
assert.equal(groupPower(m2), m2.predictPower);
});
test('groupNCog returns the group value when groupPredictFlow is present', () => {
const m = { groupPredictFlow: predictView(0, 1), groupNCog: 0.42, NCog: 0.99, predictFlow: predictView(0, 1) };
assert.equal(groupNCog(m), 0.42);
});
test('groupNCog falls back to NCog when no groupPredictFlow', () => {
const m = { predictFlow: predictView(0, 1), NCog: 0.7 };
assert.equal(groupNCog(m), 0.7);
});
test('groupNCog defaults to 0 when neither is defined', () => {
const m = { predictFlow: predictView(0, 1) };
assert.equal(groupNCog(m), 0);
});
test('groupCalcPower prefers machine.groupCalcPower', () => {
let lastFlow = null;
const m = {
groupCalcPower(flow) { lastFlow = flow; return flow * 2; },
inputFlowCalcPower(flow) { return flow * 999; },
};
assert.equal(groupCalcPower(m, 0.3), 0.6);
assert.equal(lastFlow, 0.3);
});
test('groupCalcPower falls back to inputFlowCalcPower when groupCalcPower missing', () => {
const m = { inputFlowCalcPower(flow) { return flow + 1; } };
assert.equal(groupCalcPower(m, 5), 6);
});

View File

@@ -0,0 +1,71 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { interpolation } = require('generalFunctions');
const GroupEfficiency = require('../../src/efficiency/groupEfficiency.js');
function makeMachines(cogs) {
const out = {};
cogs.forEach((cog, i) => { out[`m${i}`] = { cog }; });
return out;
}
function makeGE(extra = {}) {
return new GroupEfficiency({
interpolation: new interpolation(),
logger: { warn() {}, error() {}, debug() {}, info() {} },
...extra,
});
}
test('calcGroupEfficiency aggregates across 3 machines', () => {
const ge = makeGE();
const machines = makeMachines([0.9, 0.8, 0.7]);
const { maxEfficiency, lowestEfficiency } = ge.calcGroupEfficiency(machines);
assert.equal(lowestEfficiency, 0.7);
// maxEfficiency in the original code is actually the MEAN cog.
assert.ok(Math.abs(maxEfficiency - 0.8) < 1e-12);
});
test('calcDistanceFromPeak returns |a - b|', () => {
const ge = makeGE();
assert.ok(Math.abs(ge.calcDistanceFromPeak(0.85, 0.92) - 0.07) < 1e-12);
assert.ok(Math.abs(ge.calcDistanceFromPeak(0.92, 0.85) - 0.07) < 1e-12);
});
test('calcRelativeDistanceFromPeak maps current onto [0..1]', () => {
const ge = makeGE();
// current=0.85, max=0.92, min=0.7 → maps 0.85 in [0.92..0.7] onto [0..1].
// interpolate_lin_single_point treats first range as input domain:
// 0.85 → ((0.85 - 0.92) / (0.7 - 0.92)) * (1 - 0) + 0 = 0.07/0.22 ≈ 0.3181818...
const v = ge.calcRelativeDistanceFromPeak(0.85, 0.92, 0.7);
const expected = (0.85 - 0.92) / (0.7 - 0.92);
assert.ok(Math.abs(v - expected) < 1e-9, `got ${v} expected ${expected}`);
});
test('calcDistanceBEP returns both abs + rel', () => {
const ge = makeGE();
const { absDistFromPeak, relDistFromPeak } = ge.calcDistanceBEP(0.85, 0.92, 0.7);
assert.ok(Math.abs(absDistFromPeak - 0.07) < 1e-12);
const expectedRel = (0.85 - 0.92) / (0.7 - 0.92);
assert.ok(Math.abs(relDistFromPeak - expectedRel) < 1e-9);
});
test('calcRelativeDistanceFromPeak returns 1 when max === min (degenerate)', () => {
const ge = makeGE();
assert.equal(ge.calcRelativeDistanceFromPeak(0.85, 0.8, 0.8), 1);
});
test('calcRelativeDistanceFromPeak returns 1 when current is null', () => {
const ge = makeGE();
assert.equal(ge.calcRelativeDistanceFromPeak(null, 0.92, 0.7), 1);
});
test('calcGroupEfficiency handles a single machine', () => {
const ge = makeGE();
const { maxEfficiency, lowestEfficiency } = ge.calcGroupEfficiency(makeMachines([0.77]));
assert.equal(maxEfficiency, 0.77);
assert.equal(lowestEfficiency, 0.77);
});

View File

@@ -0,0 +1,131 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { MeasurementContainer, POSITIONS } = require('generalFunctions');
const GroupOperatingPoint = require('../../src/groupOps/groupOperatingPoint');
const unitPolicy = {
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
output: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
};
const silentLogger = { debug() {}, info() {}, warn() {}, error() {} };
function makeContainer() {
return new MeasurementContainer({
defaultUnits: unitPolicy.output,
preferredUnits: unitPolicy.output,
canonicalUnits: unitPolicy.canonical,
storeCanonical: true,
autoConvert: true,
});
}
function makeMachine(id, pressures = {}) {
// pressures: { down?: Pa, up?: Pa } — written into a real container
const m = {
config: { general: { id } },
measurements: makeContainer(),
setGroupOperatingPointCalls: [],
setGroupOperatingPoint(down, up) {
this.setGroupOperatingPointCalls.push({ down, up });
},
};
const now = Date.now();
if (pressures.down != null) {
m.measurements.type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM).value(pressures.down, now, 'Pa');
}
if (pressures.up != null) {
m.measurements.type('pressure').variant('measured').position(POSITIONS.UPSTREAM).value(pressures.up, now, 'Pa');
}
return m;
}
test('readChild returns value in requested unit when present', () => {
const machines = {};
const m = makeMachine('m1', { down: 150000 });
machines[m.config.general.id] = m;
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger });
const v = gop.readChild(m, 'pressure', 'measured', POSITIONS.DOWNSTREAM, 'Pa');
assert.equal(v, 150000);
});
test('readChild returns null when measurement missing', () => {
const m = makeMachine('m1');
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: { m1: m }, unitPolicy, logger: silentLogger });
const v = gop.readChild(m, 'pressure', 'measured', POSITIONS.UPSTREAM, 'Pa');
assert.equal(v, null);
});
test("writeOwn writes to the group's measurements container", () => {
const ownC = makeContainer();
const gop = new GroupOperatingPoint({ measurements: ownC, machines: {}, unitPolicy, logger: silentLogger });
gop.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, 0.1, 'm3/s');
const v = ownC.type('flow').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('m3/s');
assert.equal(v, 0.1);
});
test('writeOwn skips non-finite values', () => {
const ownC = makeContainer();
const gop = new GroupOperatingPoint({ measurements: ownC, machines: {}, unitPolicy, logger: silentLogger });
gop.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, NaN, 'm3/s');
const v = ownC.type('flow').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('m3/s');
assert.equal(v, null);
});
test('equalize() pushes the worst-case header onto each machine when 3 pressures differ', () => {
// No group header → max child downstream, min positive child upstream.
// max(120k, 140k, 100k) = 140000, min(80k, 90k, 70k) = 70000.
const machines = {
a: makeMachine('a', { down: 120000, up: 80000 }),
b: makeMachine('b', { down: 140000, up: 90000 }),
c: makeMachine('c', { down: 100000, up: 70000 }),
};
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger });
gop.equalize();
for (const id of ['a', 'b', 'c']) {
const last = machines[id].setGroupOperatingPointCalls.at(-1);
assert.ok(last, `machine ${id} should have been called`);
assert.equal(last.down, 140000);
assert.equal(last.up, 70000);
}
});
test('equalize() is a no-op when there is no pressure data', () => {
const machines = { a: makeMachine('a'), b: makeMachine('b') };
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger });
gop.equalize();
assert.equal(machines.a.setGroupOperatingPointCalls.length, 0);
assert.equal(machines.b.setGroupOperatingPointCalls.length, 0);
});
test('equalize() is a no-op when machines map is empty', () => {
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: {}, unitPolicy, logger: silentLogger });
assert.doesNotThrow(() => gop.equalize());
});
test('equalize() falls back to direct fDimension when setGroupOperatingPoint is missing', () => {
const m = {
config: { general: { id: 'old' } },
measurements: makeContainer(),
predictFlow: { fDimension: 0 },
predictPower: { fDimension: 0 },
predictCtrl: { fDimension: 0 },
};
m.measurements.type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM).value(200000, Date.now(), 'Pa');
m.measurements.type('pressure').variant('measured').position(POSITIONS.UPSTREAM).value(100000, Date.now(), 'Pa');
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: { old: m }, unitPolicy, logger: silentLogger });
gop.equalize();
assert.equal(m.predictFlow.fDimension, 100000);
assert.equal(m.predictPower.fDimension, 100000);
assert.equal(m.predictCtrl.fDimension, 100000);
});

View File

@@ -0,0 +1,90 @@
const test = require('node:test');
const assert = require('node:assert/strict');
// Local stub for groupCurves — replace once ../groupOps/groupCurves lands.
const groupCurves = {
groupFlow: (m) => m.predictFlow,
groupPower: (m) => m.predictPower,
groupNCog: (m) => m.NCog ?? 0,
groupCalcPower: (m, f) => m.inputFlowCalcPower(f),
};
const { validPumpCombinations, checkSpecialCases } =
require('../../src/combinatorics/pumpCombinations');
function makeMachine({ id, state = 'off', mode = 'auto',
fMin = 0, fMax = 100, pMax = 100,
NCog = 0.5, validAction = true } = {}) {
return {
config: { general: { id } },
state: { getCurrentState: () => state },
currentMode: mode,
NCog,
predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax },
predictPower: { currentFxyYMin: 0, currentFxyYMax: pMax },
inputFlowCalcPower: (flow) => flow * 0.5,
isValidActionForMode: () => validAction,
};
}
const POSITIONS = { DOWNSTREAM: 'downstream' };
const baseCtx = (extra = {}) => ({
groupCurves,
logger: { warn: () => {}, debug: () => {}, error: () => {} },
readChildMeasurement: () => undefined,
POSITIONS,
unitPolicy: { canonical: { flow: 'm3/s' } },
...extra,
});
test('validPumpCombinations: 3 idle machines + Qd in range returns subsets that can deliver', () => {
const machines = {
a: makeMachine({ id: 'a', state: 'idle', fMin: 10, fMax: 50 }),
b: makeMachine({ id: 'b', state: 'idle', fMin: 10, fMax: 50 }),
c: makeMachine({ id: 'c', state: 'idle', fMin: 10, fMax: 50 }),
};
const combos = validPumpCombinations(machines, 40, baseCtx());
assert.ok(combos.length > 0, 'expected at least one combination');
// every combination must be able to deliver Qd
for (const subset of combos) {
const maxF = subset.reduce((s, id) => s + machines[id].predictFlow.currentFxyYMax, 0);
const minF = subset.reduce((s, id) => s + machines[id].predictFlow.currentFxyYMin, 0);
assert.ok(maxF >= 40);
assert.ok(minF <= 40);
}
});
test('validPumpCombinations: excludes machines in off/coolingdown/stopping/emergencystop', () => {
const machines = {
a: makeMachine({ id: 'a', state: 'off', fMin: 10, fMax: 50 }),
b: makeMachine({ id: 'b', state: 'coolingdown', fMin: 10, fMax: 50 }),
c: makeMachine({ id: 'c', state: 'stopping', fMin: 10, fMax: 50 }),
d: makeMachine({ id: 'd', state: 'emergencystop', fMin: 10, fMax: 50 }),
e: makeMachine({ id: 'e', state: 'idle', fMin: 10, fMax: 50 }),
};
const combos = validPumpCombinations(machines, 30, baseCtx());
// Only "e" can be in a combination
for (const subset of combos) {
for (const id of subset) assert.equal(id, 'e');
}
});
test('checkSpecialCases: reduces Qd by flow of manually controlled operational machines', () => {
const machines = {
a: makeMachine({ id: 'a', state: 'operational', mode: 'virtualControl' }),
b: makeMachine({ id: 'b', state: 'idle' }),
};
const ctx = baseCtx({
readChildMeasurement: (m, type, variant) => {
if (m.config.general.id === 'a' && variant === 'measured') return 12;
return undefined;
},
});
const adjusted = checkSpecialCases(machines, 50, ctx);
assert.equal(adjusted, 38);
});
test('validPumpCombinations: no machines returns empty array', () => {
const combos = validPumpCombinations({}, 10, baseCtx());
assert.deepEqual(combos, []);
});

View File

@@ -0,0 +1,128 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const TotalsCalculator = require('../../src/totals/totalsCalculator');
const unitPolicy = {
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
output: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
};
const silent = { debug() {}, info() {}, warn() {}, error() {} };
function predictView(min, max) {
return { currentF: (min + max) / 2, currentFxyYMin: min, currentFxyYMax: max };
}
function makeMachine(id, opts = {}) {
const {
flowMin = 0.0, flowMax = 1.0,
powerMin = 100, powerMax = 1000,
state = 'operational',
hasCurve = true,
NCog = 0.5,
// Input-curve envelope (for calcAbsoluteTotals): { [pressureKey]: { y: [...] } }
inputCurve = null,
actFlow = 0,
actPower = 0,
} = opts;
const fakeInput = inputCurve || {
'50000': { y: [flowMin, (flowMin + flowMax) / 2, flowMax] },
};
const fakePower = inputCurve
? Object.fromEntries(Object.keys(inputCurve).map(k => [k, { y: [powerMin, (powerMin + powerMax) / 2, powerMax] }]))
: { '50000': { y: [powerMin, (powerMin + powerMax) / 2, powerMax] } };
return {
config: { general: { id } },
hasCurve,
state: { getCurrentState: () => state },
NCog,
predictFlow: { inputCurve: fakeInput, ...predictView(flowMin, flowMax) },
predictPower: { inputCurve: fakePower, ...predictView(powerMin, powerMax) },
_actFlow: actFlow,
_actPower: actPower,
};
}
function fakeOperatingPoint(/* machines */) {
return {
readChild(machine, type, _variant, _position /*, _unit */) {
if (type === 'flow') return machine._actFlow;
if (type === 'power') return machine._actPower;
return null;
},
};
}
test('calcAbsoluteTotals returns zeros when no machines', () => {
const tc = new TotalsCalculator({ machines: {}, unitPolicy, logger: silent });
const t = tc.calcAbsoluteTotals();
assert.deepEqual(t, { flow: { min: 0, max: 0 }, power: { min: 0, max: 0 } });
});
test('calcAbsoluteTotals scans curve envelope (sum of maxes, min of mins)', () => {
const machines = {
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500 }),
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.8, powerMin: 200, powerMax: 700 }),
};
const tc = new TotalsCalculator({ machines, unitPolicy, logger: silent });
const t = tc.calcAbsoluteTotals();
assert.equal(t.flow.min, 0.1);
assert.equal(t.power.min, 100);
// max is summed across all machines
assert.equal(t.flow.max, 0.5 + 0.8);
assert.equal(t.power.max, 500 + 700);
});
test('calcDynamicTotals sums across machines and skips machines with no valid curve', () => {
const machines = {
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, actFlow: 0.3, actPower: 300 }),
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, actFlow: 0.4, actPower: 400 }),
skip: makeMachine('skip', { hasCurve: false }),
};
const tc = new TotalsCalculator({
machines, unitPolicy, logger: silent,
operatingPoint: fakeOperatingPoint(machines),
});
const t = tc.calcDynamicTotals();
assert.equal(t.flow.min, 0.1);
assert.equal(t.flow.max, 0.5 + 0.7);
assert.equal(t.flow.act, 0.3 + 0.4);
assert.equal(t.power.min, 100);
assert.equal(t.power.max, 500 + 600);
assert.equal(t.power.act, 300 + 400);
assert.equal(t.NCog, machines.a.NCog + machines.b.NCog);
});
test('activeTotals skips machines whose state is off or maintenance', () => {
const machines = {
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, state: 'operational' }),
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, state: 'off' }),
c: makeMachine('c', { flowMin: 0.3, flowMax: 0.9, powerMin: 300, powerMax: 900, state: 'maintenance' }),
d: makeMachine('d', { flowMin: 0.05, flowMax: 0.4, powerMin: 50, powerMax: 400, state: 'accelerating' }),
};
const tc = new TotalsCalculator({ machines, unitPolicy, logger: silent });
const t = tc.activeTotals();
assert.equal(t.countActiveMachines, 2); // a + d
assert.equal(t.flow.min, 0.1 + 0.05);
assert.equal(t.flow.max, 0.5 + 0.4);
assert.equal(t.power.min, 100 + 50);
assert.equal(t.power.max, 500 + 400);
});
test('activeTotals honours the injected isMachineActive override', () => {
const machines = {
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, state: 'operational' }),
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, state: 'operational' }),
};
const tc = new TotalsCalculator({
machines, unitPolicy, logger: silent,
isMachineActive: (id) => id === 'b',
});
const t = tc.activeTotals();
assert.equal(t.countActiveMachines, 1);
assert.equal(t.flow.max, 0.7);
});

View File

@@ -49,7 +49,7 @@ function machineConfig(id) {
return {
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },

View File

@@ -26,7 +26,7 @@ function machineConfig(id, model) {
return {
general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal', model, supplier: 'hidrostal' },
asset: { model, unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },

View File

@@ -39,7 +39,7 @@ function machineConfig(id) {
return {
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },

View File

@@ -54,7 +54,7 @@ function createMachineConfig(id, label) {
return {
general: { logging: { enabled: false, logLevel: 'error' }, name: label, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },

View File

@@ -28,7 +28,7 @@ function machineConfig(id) {
return {
general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },

View File

@@ -44,7 +44,7 @@ function machineConfig(id) {
return {
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
@@ -116,16 +116,27 @@ test('repeated turnOffAllMachines reaches idle (serializes concurrent shutdowns)
'delayedMove must be cleared after shutdown');
});
test('turnOffAllMachines clears MGC._delayedCall to cancel any deferred dispatch', async () => {
test('turnOffAllMachines cancels any parked demand so it cannot re-engage pumps', async () => {
// PS sends a 1% keep-alive while MGC is mid-dispatch. MGC parks it in
// _delayedCall. PS then crosses stopLevel and calls turnOffAllMachines.
// Without clearing _delayedCall, MGC's finally block fires the parked
// 1% call AFTER the shutdown — re-engaging the pump.
// its demand dispatcher's latest-wins slot. PS then crosses stopLevel
// and calls turnOffAllMachines. Without cancelPending(), the parked
// 1% call would fire AFTER the shutdown — re-engaging the pump.
const { mgc } = buildGroup();
mgc._delayedCall = { source: 'parent', demand: 1, powerCap: Infinity, priorityList: null };
const gate = mgc._demandDispatcher._gate;
// Pin a fake in-flight dispatch then park a pending call behind it.
gate._inFlight = true;
const parked = mgc.handleInput('parent', 1, Infinity, null);
await mgc.turnOffAllMachines();
assert.equal(mgc._delayedCall, null,
'turnOff must cancel any deferred dispatch so it cannot re-engage pumps post-shutdown');
// Re-open the gate: the in-flight pin is artificial. Awaiting the
// parked promise must yield the SUPERSEDED sentinel (i.e. it was
// cancelled, not run).
const res = await parked;
assert.ok(res && res.superseded === true,
'parked demand must resolve as superseded after turnOffAllMachines cancels it');
// Idle now — pending slot must be clear.
assert.equal(gate._pending, null,
'turnOff must cancel any parked demand so it cannot re-engage pumps post-shutdown');
gate._inFlight = false;
});

284
wiki/Home.md Normal file
View File

@@ -0,0 +1,284 @@
# 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
**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
```mermaid
flowchart LR
parent[pumpingStation<br/>Process Cell]:::pc -->|set.demand| mgc[machineGroupControl<br/>Unit]:::unit
header[measurement<br/>header pressure]:::ctrl -.data.-> mgc
mgc -->|flowmovement / execsequence| m_a[rotatingMachine A]:::equip
mgc -->|flowmovement / execsequence| m_b[rotatingMachine B]:::equip
mgc -->|flowmovement / execsequence| m_c[rotatingMachine C]:::equip
mgc -->|child.register| parent
m_a -->|child.register| mgc
m_b -->|child.register| mgc
m_c -->|child.register| mgc
classDef pc fill:#0c99d9,color:#fff
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
```
S88 colours: Process Cell `#0c99d9`, Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
## 3. Capability matrix
| Capability | Status | Notes |
|---|---|---|
| Aggregate group flow / power totals | ✅ | `TotalsCalculator` — absolute and dynamic. |
| Valid-combination enumeration | ✅ | `combinatorics/pumpCombinations`. |
| Best-combination optimiser (BEP-Gravitation) | ✅ | Directional or symmetric variant. |
| Best-combination optimiser (NCog) | ✅ | Normalised cost-of-goods score. |
| Priority / equal-flow control | ✅ | `mode='prioritycontrol'`. |
| Priority percentage control | ✅ | Requires `scaling='normalized'`. |
| Optimal control | ✅ | `mode='optimalcontrol'`. |
| Group efficiency + BEP distance | ✅ | `GroupEfficiency`. |
| Header-pressure equalisation | ✅ | `operatingPoint.equalize()`. |
| Demand serialisation (latest-wins) | ✅ | `DemandDispatcher` / `LatestWinsGate.fireAndWait`. |
| Forced shutdown on `Qd ≤ 0` | ✅ | `turnOffAllMachines()`. |
## 4. Code map
```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
```
| Module | Owns | Read first if you're changing… |
|---|---|---|
| `groupOps/` | Group operating point + child read helpers | Header pressure handling, child measurement plumbing. |
| `totals/` | Absolute + dynamic flow/power totals | Demand clamping, totals math. |
| `combinatorics/` | Enumeration of valid pump subsets | Which combinations are considered eligible. |
| `optimizer/` | Best-combination selectors | Optimiser selection method, scoring math. |
| `efficiency/` | Group efficiency, BEP distance | BEP gravitation tuning, peak math. |
| `control/strategies.js` | Per-mode dispatch (priority, prioPct) | Mode behaviour, priorityList usage. |
| `dispatch/` | `DemandDispatcher` wrapping `LatestWinsGate.fireAndWait` | Demand serialisation, mid-flight overrides. |
| `commands/` | Input-topic registry and handlers | New input topics, payload validation. |
| `io/` | `getOutput`, `getStatusBadge` | Output shape, dashboard badge. |
## 5. Topic contract
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
<!-- 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. |
<!-- END AUTOGEN: topic-contract -->
## 6. Child registration
`ChildRouter` declarations in `specificClass.js → configure()`.
```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
```
| softwareType | filter / subscribed events | Side-effect |
|---|---|---|
| `machine` | onRegister stores in `this.machines[id]`; subscribes to `pressure.measured.downstream`, `pressure.measured.differential`, `flow.predicted.downstream` | `handlePressureChange()` — equalise + recompute totals + recompute group efficiency. |
| `measurement` | onRegister attaches listener for `<asset.type>.measured.<positionVsParent>` | Mirror value into MGC's own MeasurementContainer; pressure also triggers `handlePressureChange()`. |
## 7. Lifecycle — what one event does
```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()
```
## 8. Data model — `getOutput()`
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. |