Compare commits

...

19 Commits

Author SHA1 Message Date
znetsixe
f18f3cc673 feat(mgc-dashboard): -1 OFF sentinel on per-pump % control chart
fn_chart_pump_a/b/c now emit -1 on the ctrl output when the cached pump
state is off/idle/maintenance, instead of the residual ctrl% (which would
sit at 0 and be indistinguishable from a pump genuinely running at 0%).
ui_chart_pumps_ctrl ymin set to -5 so the OFF rail is visible below the
0-100 band.

Adds test/integration/per-pump-ctrl-fanout.integration.test.js covering
both chart outputs of all three pumps in populated (running), OFF
(off/idle/maintenance), and degraded (missing state/ctrl/flow, pre-tick,
NaN, ctrl-only delta) states per .claude/rules/output-coverage.md. Updates
test/_output-manifest.md to document the previously-undocumented per-pump
fan-out functions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:16:50 +02:00
znetsixe
2af6c904da feat(mgc): rendezvous lock + emergency bypass (no re-plan mid-rendezvous)
Once a rendezvous plan is committed it now runs to completion untouched: an
ordinary new setpoint arriving while the group is 'working' is remembered
(latest wins) and dispatched sequentially when the group reaches 'ready',
instead of aborting + re-planning. A re-plan mid-flight dropped the in-flight
schedule and re-deferred a pump that was mid-sequence, parking starting pumps
at minimum flow.

Only an EMERGENCY pre-empts the lock: a stop (≤0) or a pressure excursion.
_isUrgentDemand (which pre-empted on any large step) is replaced by
_isEmergencyDemand; the large-step pre-emption is gone — large operator steps
now defer like any other setpoint. _pressureEmergency() reads
planner.emergencyPressurePa and is INERT until that threshold is configured;
handlePressureChange fires a latched bypass dispatch when it breaches.

Verified live on the E2E Isolated MGC rig: a 1→2 pump staging transition ramps
the added pump straight through (no wait-at-minimum, no start-then-stop) and the
group total climbs monotonically. (The Pump-tab node's hunting is a separate
demand-feedback-loop issue in that flow's wiring, not the rendezvous.)

Integration tests now settle to 'ready' between demands (waitReady) since the
lock defers setpoints arriving mid-move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:47:50 +02:00
znetsixe
f41e319b30 test(mgc): cover fn_status_split output 17 (% of capacity); fix stale 17→18 count
The dashboard fan-out grew to 18 outputs (output 17 = '% of capacity' chart)
but dashboard-fanout.integration.test.js still asserted 17 and had no PORT
entry or coverage for output 17. Add chart_pctcap (17) with populated (State C,
flow/capMax×100) and degraded (State A → null-drop) assertions, fix the count
assertion, and add the fan-out enumeration table to _output-manifest.md per
.claude/rules/output-coverage.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:24:22 +02:00
znetsixe
551ee6d70e fix(mgc): just-in-time startup in rendezvous planner (kill staging flow bump)
Delay a startup's execsequence by (t* − eta) instead of firing it at tick 0.
Previously the ladder fired immediately for every starting pump; a
faster-than-slowest startup then reached `operational` early and sat at its
minimum flow (calcFlow at min position is non-zero) from warmup-end until its
delayed ramp — leaking ~one pump's minimum flow into the group total before
the rendezvous instant t* (the 207→309 staging bump observed live).

Now the whole startup (ladder + ramp) is delayed: the ladder begins at
(t* − eta), completes at (t* − rampS), then the queued flowmovement ramps to
finish exactly at t*. The slowest pump (eta == t*) still fires at tick 0.
Sum-of-flows is monotonic through the transition.

Updated movementScheduler.basic.test.js mixed-speed multi-startup assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:22:32 +02:00
znetsixe
b59d8e60f7 feat(mgc): demand telemetry + movement gate (demand debounce)
- Movement gate: hold non-urgent demand while the group is 'working'
  (mid-ramp/sequencing) and flush it once 'ready', instead of aborting
  in-flight ramps on every incoming demand — which could freeze pumps at 0.
  Urgent demand (stop, mode/priority change, large step) still pre-empts.
- getMovementState()/_isUrgentDemand()/_maybeFlushPendingDemand() helpers.
- Demand telemetry: emit demandFlow (m³/h) and demandPct (0..100 of envelope)
  resolved by the last dispatch; omitted before the first demand (degraded).
- Capacity envelope now emitted in output flow unit (m³/h) not raw m³/s.
- Manifest + populated/degraded tests for the new outputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:09:18 +02:00
e1e1977139 wip: pre-ship-it state — example dashboard tweaks 2026-05-26 17:31:43 +02:00
znetsixe
ddf2b07424 test: point structure check at renamed 02-Dashboard.json
Example flows were renamed to the numbered-tier convention
(01-Basic.json / 02-Dashboard.json). The structure test still pointed
at the old basic.flow.json path. Rewire to the current filename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:29:49 +02:00
znetsixe
c982c9bef7 refactor(units): route _canonicalToOutputFlow + setDemand through UnitPolicy.convert
Drop the direct convert() import — both call sites now go through
this.unitPolicy.convert. setDemand keeps its try/catch around the
absolute-flow branch (legitimate Bucket-2 case: % vs flow demux
prevents declaring `units:` on the dispatcher). Matches the
contract direction in .claude/refactor/CONTRACTS.md §6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:43:44 +02:00
znetsixe
a47aa53d17 style + ui(editor): palette swatch #B5651D + compact-fields tweak
mgc.html: sidebar swatch → #B5651D (mid-orange, rotating-machinery family) as part of the EVOLV palette redesign 2026-05-21. See superproject .claude/rules/node-red-flow-layout.md §10.0 and .claude/refactor/OPEN_QUESTIONS.md.

src/editor/compact-fields.js: minor field tweak (separate, in-progress work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:06:35 +02:00
znetsixe
aeb938c205 feat(setDemand): surface specificClass.setDemand(value, unit='%') + slim npm pack
Why:
- pumpingStation level-based control was calling MGC.handleInput(percent)
  directly. handleInput expects canonical m³/s; a 1 % keep-alive arrived
  as 1 m³/s ≈ 3600 m³/h, the dispatcher clamped to dt.flow.max and the
  group ran at 100 %. The unit math already existed inside the set.demand
  command handler — but only that handler could reach it.

What:
- New public method `async setDemand(value, unit='%')` on MachineGroup
  (specificClass.js). Resolves the unit (`%` → interpolate against the
  dynamic-totals envelope, absolute units → convert(value)) and calls
  handleInput with canonical m³/s. Negative value remains the operator
  stop-all signal. Single source of truth for the percent → m³/s rule.
- Refactor handlers.setDemand to parse the payload + apply mode gating
  and then delegate to source.setDemand. Drops the local `convert` import
  (now reached via the source).
- Update commands.basic.test.js mock with a setDemand shim that mirrors
  the real method, so existing handleInput assertions still hold.

Packaging:
- Add .npmignore mirroring .gitignore plus dev-only trees (test/, wiki/,
  CLAUDE.md, …) so the published tarball stays small.
- Extend .gitignore with the standard dev-artifact deny list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:36:00 +02:00
znetsixe
a57e0095a3 fix(commands+CONTRACT): correct set.mode mode list
- src/commands/index.js: description now lists the actual schema modes
  (`optimalControl`, `priorityControl`, `maintenance`); was generic
  "auto / manual" which never matched the schema.
- CONTRACT.md: same fix — old list included `dynamiccontrol` (doesn't
  exist) and used lowercase names that don't match the canonical
  schema enum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:05:48 +02:00
znetsixe
047229c514 fix(CONTRACT): remove stale set.scaling row — topic removed in refactor
groupcontrol.test.js comment confirms `setScaling is gone — handleInput
now takes canonical m³/s directly` since the refactor. CONTRACT.md
still listed it; contract-verify now agrees with the registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:59:03 +02:00
znetsixe
998e9bd758 docs: Folder & File Layout section + flag mgc.{js,html} naming drift
Entry/HTML files should be machineGroupControl.{js,html} to match the folder
name. Rename when the file is next touched. Full rule:
.claude/rules/node-architecture.md in the EVOLV superproject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:41 +02:00
znetsixe
6833e9f3a8 feat(mgc): consume shared icon-picker visuals + modernize editor menu
* compact-fields.js (new): trimmed to MGC-only output-format pickers
  (processOutputFormat / dbaseOutputFormat). The logger toggle/level
  and physical-position visuals now come from generalFunctions'
  shared iconHelpers, auto-injected via /machineGroupControl/menu.js.
* mode-cards.js: strategy cards re-styled — Most-efficient (BEP bell
  with dot on the curve peak), Priority (clean staircase), Maintenance
  (Font Awesome fa-wrench). Rendezvous toggle flips Active / Inactive
  label dynamically.
* mgc.html: dropped the duplicated .mgc-icon-* CSS rules (now live in
  the shared iconHelpers stylesheet). Strategy + rendezvous CSS stays
  local (MGC-specific). Output picker holders switched to the shared
  .evolv-icon-picker class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:10:54 +02:00
znetsixe
472402c62d feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.

Architecture (src/movement/):
- machineProfile.js   – pure snapshot of a registered child (state,
                        position, velocityPctPerS, ladder timings,
                        flowAt / positionForFlow). Reads timings from
                        child.state.config.time (the actual storage
                        location — previous fallback paths silently
                        produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js   – seconds-to-target per machine; handles
                        idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
                        command is delayed so its move finishes at t*.
                        Startup execsequence fires at 0; its flowmovement
                        is gated by max(ladderS, t* − rampS) so a fast
                        pump waits before ramping rather than landing
                        early. useRendezvous=false collapses to all
                        fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
                        every command whose fireAtTickN ≤ floor(elapsed/tickS).
                        tick() no longer awaits pending fireCommand
                        promises — the synchronous prologue of
                        handleInput claims the latest-wins gate, which
                        is what race-favouring relies on.

Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
  _optimalControl. Builds profiles, calls movementScheduler.plan,
  replans the executor, ticks once. Reads
  config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
  flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
  Same-time landing now applies in BOTH modes.

Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
  config.planner.useRendezvous. Default ON.

Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
  diagnostic — drives a 3-pump mixed-state dispatch and asserts both
  convergence to the demand setpoint AND same-time landing within
  one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
  assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
  multi-startup case proving the fast pumps wait so all three land
  together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.

Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
  helper; every command now checks isValidActionForMode +
  isValidSourceForMode before dispatching. Status-level commands
  (set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
  Contracts,Examples,Limitations}.md split with _Sidebar.md index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:55 +02:00
znetsixe
26e92b54f7 governance + unit-self-describing demand + dashboard fixes
Two governance items from the 2026-05-14 quality review:
- test/_output-manifest.md enumerates every Port 0/1/2 key MGC emits, its
  source, type, range, and which tests cover it in populated/degraded states
  (per .claude/rules/output-coverage.md).
- src/control/strategies.js extracts computeEqualFlowDistribution as a pure
  function so the equal-flow algorithm is testable without an MGC fixture.
  test/basic/equalFlowDistribution.basic.test.js (6 tests) covers all three
  demand branches and pins the legacy quirk where the default branch counts
  active machines but iterates priority-ordered first-N (documented in the
  test so the future cleanup is a deliberate change).

Plus rolled-up session work that landed alongside:
- set.demand is now unit-self-describing ({value, unit:'m3/h'|'l/s'|'%'|...}
  or bare number = %); setScaling/scaling.current removed from MGC, commands,
  editor (mgc.html), specificClass.
- _optimalControl + equalFlowControl now compute eta = (Q*dP)/P_shaft rather
  than Q/P, keeping the metric in the same scale as each child's cog.
- groupEfficiency.calcRelativeDistanceFromPeak returns undefined (was 1) when
  pumps are homogeneous (|max-min| < 1e-9). Dashboard treats undefined as
  '-' instead of showing a misleading 100% / 0% reading.
- examples/02-Dashboard.json: auto-init inject so the dashboard populates at
  deploy, NCog formatter normalizes the SUM emitted by MGC by
  machineCountActive, Q-H fanout trims the flat-Q tail so the H axis isn't
  stretched to 40m by curve-envelope clamp points, num/pct treat null AND
  undefined as no-data (closes the +null === 0 trap).
- new test/integration/dashboard-fanout.integration.test.js (17 tests),
  bep-distance-demand-sweep.integration.test.js (3 tests),
  group-bep-cascade.integration.test.js -- total suite now 108/108 green.
- .gitignore: wiki/test.gif (143 MB screen recording, kept locally only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:31:25 +02:00
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
58 changed files with 6961 additions and 569 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Repo dev artifacts. Mirrors the deny list in .npmignore so the two stay
# in sync — anything that shouldn't be committed AND shouldn't ship in the
# npm tarball goes in both files.
node_modules/
package-lock.json
*.tgz
.env
.env.*
.DS_Store
npm-debug.log*
# Large local artifacts that don't belong in Git.
# wiki/test.gif: screen recordings of the dashboard are kept locally for
# reference but exceed 100 MB — use Git LFS or external storage if they
# need to be shared.
wiki/test.gif

28
.npmignore Normal file
View File

@@ -0,0 +1,28 @@
# === Mirrors .gitignore — items below this block are also excluded from
# the npm tarball. Kept here verbatim so npm pack doesn't fall back to
# the .gitignore inheritance (silent + surprising). ===
node_modules/
package-lock.json
*.tgz
.env
.env.*
.DS_Store
npm-debug.log*
# Large local screen recording (>100 MB) — kept out of both repo and pack.
wiki/test.gif
# === Dev-only content the npm tarball doesn't need ===
# Tests + their harness — Node-RED loads the entry .js, not the test tree.
test/
*.test.js
# Wiki / docs — useful in the repo, big in the pack.
wiki/
# Project memory + IDE configs.
.claude/
.codex/
.repo-mem/
CLAUDE.md
CLAUDE.local.md

View File

@@ -21,3 +21,28 @@ Key points for this node:
- Stack same-level siblings vertically. - Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right). - Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `#50a8d9` (Unit). - Wrap in a Node-RED group box coloured `#50a8d9` (Unit).
## Folder & File Layout
Every per-node file MUST use the folder name (`machineGroupControl`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
| Path | Required name |
|---|---|
| Entry file | `machineGroupControl.js` |
| Editor HTML | `machineGroupControl.html` |
| Node adapter | `src/nodeClass.js` |
| Domain logic | `src/specificClass.js` |
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
> ⚠️ **Legacy naming drift in this repo** — to be renamed when the file is next touched:
>
> | Path | Currently | Should be |
> |---|---|---|
> | Entry file | `mgc.js` | `machineGroupControl.js` |
> | Editor HTML | `mgc.html` | `machineGroupControl.html` |
>
> Renames require updating: the file itself, `package.json#node-red.nodes`, any `require()` / `import` paths, and superproject submodule references in one commit.
When adding new files, read the rule above first to avoid drift.

View File

@@ -7,8 +7,7 @@ Hand-maintained for Phase 4; the `## Inputs` table is generated from
| Canonical | Aliases (deprecated) | Payload | Effect | | Canonical | Aliases (deprecated) | Payload | Effect |
|---|---|---|---| |---|---|---|---|
| `set.mode` | `setMode` | `string` — one of `prioritycontrol`, `optimalcontrol`, `dynamiccontrol`, … | Switches the control strategy via `source.setMode(payload)`. | | `set.mode` | `setMode` | `string` — one of `optimalControl`, `priorityControl`, `maintenance` (schema-validated) | 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)`. | | `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. | | `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. |

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

@@ -0,0 +1,83 @@
[
{
"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": 714,
"y": 19,
"w": 292,
"h": 122
},
{
"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": 870,
"y": 60,
"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": 870,
"y": 100,
"wires": [
[
"mgc_basic_node"
]
]
}
]

1893
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 ## Files
- basic.flow.json
- integration.flow.json | File | Tier | What it shows |
- edge.flow.json |---|---|---|
| `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":[]}
]

103
mgc.html
View File

@@ -8,19 +8,69 @@
| **Control Module** | `#a9daee` | zwart | | **Control Module** | `#a9daee` | zwart |
--> -->
<script src="/machineGroupControl/menu.js"></script> <!-- Load the menu script for dynamic dropdowns --> <script src="/machineGroupControl/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
<script src="/machineGroupControl/configData.js"></script> <!-- Load the config script for node information --> <script src="/machineGroupControl/configData.js"></script> <!-- Load the config script for node information -->
<!-- Editor JS modules — see nodes/machineGroupControl/src/editor/. Loaded in
dependency order: index.js (namespace + helpers) → modules → oneditprepare. -->
<script src="/machineGroupControl/editor/index.js"></script>
<script src="/machineGroupControl/editor/mode-cards.js"></script>
<script src="/machineGroupControl/editor/compact-fields.js"></script>
<script src="/machineGroupControl/editor/oneditprepare.js"></script>
<style>
/* MGC-specific UI: strategy mode cards + rendezvous toggle.
Generic .evolv-icon-picker / .evolv-icon-option styles for the
output-format pickers come from generalFunctions' iconHelpers (auto-
injected by /menu.js). */
.mgc-mode-cards,
.mgc-toggle-row { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 4px 0; }
.mgc-mode-card,
.mgc-toggle-card {
width:94px; height:86px; box-sizing:border-box;
border:2px solid #d0d0d0; border-radius:4px; background:#fafafa;
padding:4px; cursor:pointer; user-select:none;
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px;
transition:border-color 80ms ease-out, background 80ms ease-out;
}
.mgc-mode-card:hover,
.mgc-toggle-card:hover { border-color:#86bbdd; background:#f5fafd; }
.mgc-mode-card:focus,
.mgc-toggle-card:focus { outline:2px solid #1F4E79; outline-offset:2px; }
.mgc-mode-card-on,
.mgc-toggle-card-on { border-color:#50a8d9; background:#eaf4fb; }
.mgc-mode-card-svg,
.mgc-toggle-card-svg { width:100%; height:54px; display:flex; align-items:center; justify-content:center; }
.mgc-mode-card-svg svg,
.mgc-toggle-card-svg svg { width:100%; height:100%; display:block; }
.mgc-mode-card-label,
.mgc-toggle-card-label { font-size:10px; line-height:1; font-weight:600; color:#333; white-space:nowrap; letter-spacing:0; }
.mgc-toggle-card:not(.mgc-toggle-card-on) .mgc-toggle-card-svg { opacity:0.45; filter:grayscale(1); }
.mgc-toggle-card:not(.mgc-toggle-card-on) .mgc-toggle-card-label { color:#888; }
.mgc-hidden-checkbox { position:absolute; opacity:0; width:1px; height:1px; pointer-events:none; }
.mgc-section-divider { border:0; border-top:1px solid #d6d6d6; margin:12px 0; }
.mgc-output-row > label { white-space:nowrap; width:130px; }
</style>
<script> <script>
RED.nodes.registerType('machineGroupControl',{ RED.nodes.registerType('machineGroupControl',{
category: "EVOLV", category: "EVOLV",
color: "#50a8d9", color: "#B5651D",
defaults: { defaults: {
// Define default properties // Define default properties
name: { value: "" }, name: { value: "" },
processOutputFormat: { value: "process" }, processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" }, dbaseOutputFormat: { value: "influxdb" },
// Control strategy
mode: { value: "optimalControl" }, // optimalControl | priorityControl | maintenance
// Same-time landing (rendezvous planner). When ON the planner
// delays each pump's move so all pumps reach their setpoint at
// the same wall-clock instant t* = max(eta_i). When OFF each
// pump moves at its own pace and lands at its own eta.
useRendezvous: { value: true },
//define asset properties //define asset properties
uuid: { value: "" }, uuid: { value: "" },
supplier: { value: "" }, supplier: { value: "" },
@@ -40,7 +90,7 @@
distance: { value: 0 }, distance: { value: 0 },
distanceUnit: { value: "m" }, distanceUnit: { value: "m" },
distanceDescription: { value: "" } distanceDescription: { value: "" }
}, },
inputs:1, inputs:1,
outputs:3, outputs:3,
@@ -52,10 +102,17 @@
return (this.positionIcon || "") + " machineGroup"; return (this.positionIcon || "") + " machineGroup";
}, },
oneditprepare: function() { oneditprepare: function() {
// Initialize the menu data for the node const self = this;
// Initialize the menu data for the node, then the visual modules.
// Both attach to window.EVOLV.nodes.machineGroupControl.* — the
// menu endpoint populates loggerMenu/positionMenu/initEditor; the
// editor scripts populate editor.modeCards/rendezvousToggle/compactFields.
const waitForMenuData = () => { const waitForMenuData = () => {
if (window.EVOLV?.nodes?.machineGroupControl?.initEditor) { if (window.EVOLV?.nodes?.machineGroupControl?.initEditor) {
window.EVOLV.nodes.machineGroupControl.initEditor(this); window.EVOLV.nodes.machineGroupControl.initEditor(self);
if (window.EVOLV.nodes.machineGroupControl.editor?.initVisuals) {
window.EVOLV.nodes.machineGroupControl.editor.initVisuals(self);
}
} else { } else {
setTimeout(waitForMenuData, 50); setTimeout(waitForMenuData, 50);
} }
@@ -84,23 +141,49 @@
<script type="text/html" data-template-name="machineGroupControl"> <script type="text/html" data-template-name="machineGroupControl">
<h3>Control strategy</h3>
<!-- Hidden input is the canonical Node-RED-readable field. The visible
picker is rendered by src/editor/mode-cards.js into the placeholder
below, and clicks on a card write back to this input. -->
<input type="hidden" id="node-input-mode" />
<div id="mgc-mode-cards" class="mgc-mode-cards"
role="radiogroup" aria-label="Control strategy mode">
<!-- mode-cards.js renders three card divs here -->
</div>
<hr class="mgc-section-divider" />
<h3>Rendezvous planner</h3>
<div class="form-row mgc-toggle-row">
<input type="checkbox" id="node-input-useRendezvous" class="mgc-hidden-checkbox" />
<div id="mgc-rendezvous-toggle" class="mgc-toggle-card"
role="switch" tabindex="0" aria-label="Same-time landing"
aria-checked="false" title="Same-time landing"></div>
</div>
<hr class="mgc-section-divider" />
<h3>Output Formats</h3> <h3>Output Formats</h3>
<div class="form-row"> <div class="form-row mgc-output-row">
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label> <label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
<select id="node-input-processOutputFormat" style="width:60%;"> <select id="node-input-processOutputFormat" class="evolv-native-hidden" style="width:60%;">
<option value="process">process</option> <option value="process">process</option>
<option value="json">json</option> <option value="json">json</option>
<option value="csv">csv</option> <option value="csv">csv</option>
</select> </select>
<div id="mgc-process-output-picker" class="evolv-icon-picker"
role="radiogroup" aria-label="Process output format"></div>
</div> </div>
<div class="form-row"> <div class="form-row mgc-output-row">
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label> <label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;"> <select id="node-input-dbaseOutputFormat" class="evolv-native-hidden" style="width:60%;">
<option value="influxdb">influxdb</option> <option value="influxdb">influxdb</option>
<option value="frost">frost</option>
<option value="json">json</option> <option value="json">json</option>
<option value="csv">csv</option> <option value="csv">csv</option>
</select> </select>
<div id="mgc-dbase-output-picker" class="evolv-icon-picker"
role="radiogroup" aria-label="Database output format"></div>
</div> </div>
<hr class="mgc-section-divider" />
<!-- Logger fields injected here --> <!-- Logger fields injected here -->
<div id="logger-fields-placeholder"></div> <div id="logger-fields-placeholder"></div>

13
mgc.js
View File

@@ -1,4 +1,5 @@
const nameOfNode = 'machineGroupControl'; // this is the name of the node, it should match the file name and the node type in Node-RED const nameOfNode = 'machineGroupControl'; // this is the name of the node, it should match the file name and the node type in Node-RED
const path = require('path');
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
const { MenuManager, configManager } = require('generalFunctions'); const { MenuManager, configManager } = require('generalFunctions');
@@ -36,4 +37,16 @@ module.exports = function(RED) {
res.status(500).send(`// Error generating configData: ${err.message}`); res.status(500).send(`// Error generating configData: ${err.message}`);
} }
}); });
// Editor JS modules — loaded by mgc.html via <script src="/machineGroupControl/editor/*.js">.
// Files live in src/editor/. Filename restricted to a safe charset to prevent
// path-traversal. Mirrors pumpingStation.js:44-51.
RED.httpAdmin.get(`/${nameOfNode}/editor/:file`, (req, res) => {
const safe = String(req.params.file || '').replace(/[^a-zA-Z0-9._-]/g, '');
if (!safe.endsWith('.js')) return res.status(400).send('// invalid');
res.type('application/javascript');
res.sendFile(path.join(__dirname, 'src', 'editor', safe), (err) => {
if (err && !res.headersSent) res.status(404).send('// editor module not found');
});
});
}; };

View File

@@ -1,7 +1,7 @@
'use strict'; 'use strict';
// Handler functions for machineGroupControl commands. Each handler receives: // Handler functions for machineGroupControl commands. Each handler receives:
// source: the domain (specificClass) instance — exposes setMode, setScaling, // source: the domain (specificClass) instance — exposes setMode,
// handleInput, childRegistrationUtils.registerChild, logger, // handleInput, childRegistrationUtils.registerChild, logger,
// config.general.name. // config.general.name.
// msg: the Node-RED input message. // msg: the Node-RED input message.
@@ -14,15 +14,31 @@ function _logger(source, ctx) {
return ctx?.logger || source?.logger || null; return ctx?.logger || source?.logger || null;
} }
// Gate one command against the mode-allowed action and source allow-lists.
// Returns true if both gates pass (or if the source lacks the gate methods —
// keeps backward compat with fakes/specifics that haven't adopted the pattern
// yet). When a gate fails the source already warn-logs; we just bail out.
function _gate(source, action, msg) {
if (typeof source?.isValidActionForMode === 'function') {
if (!source.isValidActionForMode(action, source.mode)) return false;
}
if (typeof source?.isValidSourceForMode === 'function') {
const src = (typeof msg?.source === 'string' && msg.source) ? msg.source : 'parent';
if (!source.isValidSourceForMode(src, source.mode)) return false;
}
return true;
}
exports.setMode = (source, msg) => { exports.setMode = (source, msg) => {
// set.mode is a status-level operation — allowed in every mode by the
// default schema (incl. maintenance). The gate still fires so an
// unauthorised source is rejected even for mode switching.
if (!_gate(source, 'statusCheck', msg)) return;
source.setMode(msg.payload); source.setMode(msg.payload);
}; };
exports.setScaling = (source, msg) => {
source.setScaling(msg.payload);
};
exports.registerChild = (source, msg, ctx) => { exports.registerChild = (source, msg, ctx) => {
if (!_gate(source, 'statusCheck', msg)) return;
const log = _logger(source, ctx); const log = _logger(source, ctx);
const childId = msg.payload; const childId = msg.payload;
const childObj = ctx?.RED?.nodes?.getNode?.(childId); const childObj = ctx?.RED?.nodes?.getNode?.(childId);
@@ -35,13 +51,43 @@ exports.registerChild = (source, msg, ctx) => {
exports.setDemand = async (source, msg, ctx) => { exports.setDemand = async (source, msg, ctx) => {
const log = _logger(source, ctx); const log = _logger(source, ctx);
const demand = parseFloat(msg.payload); // Operator demand is self-describing: the unit on the message decides how
if (Number.isNaN(demand)) { // the value is interpreted. There is no persistent scaling state on MGC.
log?.error?.(`set.demand: invalid Qd value '${msg.payload}'`); //
// payload = number → unit defaults to '%'
// payload = { value, unit:'%' }→ percent of group capacity
// payload = { value, unit:'m3/h' | 'l/s' | 'm3/s' | ... } → absolute flow
// payload < 0 (any unit) → operator stop-all signal
//
// Unit resolution + canonical dispatch lives in source.setDemand. The
// handler's job is payload parsing, mode gating, and the "done" reply.
const p = msg?.payload;
let rawValue;
let unit;
if (p !== null && typeof p === 'object') {
rawValue = p.value;
unit = (typeof p.unit === 'string' && p.unit.trim()) ? p.unit.trim() : '%';
} else {
rawValue = p;
unit = '%';
}
const value = Number(rawValue);
if (!Number.isFinite(value)) {
log?.error?.(`set.demand: invalid Qd value '${JSON.stringify(msg?.payload)}'`);
return; return;
} }
// Gate the demand against the current mode. Action kind depends on whether
// this is a stop-all (negative) or a dispatch — the schema declares which
// are accepted per mode (maintenance gets neither). Done after numeric
// parse so an unparseable payload is still surfaced as an error, not a
// silent mode-rejection.
let action;
if (value < 0) action = 'emergencyStop';
else if (source?.mode === 'priorityControl') action = 'execSequentialControl';
else action = 'execOptimalCombination';
if (!_gate(source, action, msg)) return;
try { try {
await source.handleInput('parent', demand); await source.setDemand(value, unit);
} catch (err) { } catch (err) {
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`); log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
return; return;

View File

@@ -12,16 +12,9 @@ module.exports = [
topic: 'set.mode', topic: 'set.mode',
aliases: ['setMode'], aliases: ['setMode'],
payloadSchema: { type: 'string' }, payloadSchema: { type: 'string' },
description: 'Switch the machine group between auto / manual modes.', description: 'Switch the operating mode. Allowed: `optimalControl`, `priorityControl`, `maintenance` (schema-validated in `machineGroupControl.json` → `mode.current`).',
handler: handlers.setMode, handler: handlers.setMode,
}, },
{
topic: 'set.scaling',
aliases: ['setScaling'],
payloadSchema: { type: 'string' },
description: 'Select the group scaling strategy.',
handler: handlers.setScaling,
},
{ {
topic: 'child.register', topic: 'child.register',
aliases: ['registerChild'], aliases: ['registerChild'],
@@ -33,10 +26,13 @@ module.exports = [
{ {
topic: 'set.demand', topic: 'set.demand',
aliases: ['Qd'], aliases: ['Qd'],
// any: number or numeric string — handler runs parseFloat. // payload is either a bare number (interpreted as %) or
// { value: number, unit: '%' | 'm3/h' | 'l/s' | 'm3/s' | ... }.
// No `units` descriptor — the handler resolves the unit explicitly so
// commandRegistry._normaliseUnits doesn't pre-convert a percentage into
// a flow rate. Negative value is the operator stop-all signal.
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' }, description: 'Operator demand setpoint. Bare number = %; {value, unit} for absolute flow units. Negative = stop all.',
description: 'Operator demand setpoint dispatched to the child machines.',
handler: handlers.setDemand, handler: handlers.setDemand,
}, },
]; ];

View File

@@ -6,12 +6,9 @@
// machines, falling back to start/stop the next priority when the current // machines, falling back to start/stop the next priority when the current
// active set can't deliver. // active set can't deliver.
// //
// prioPercentageControl: percentage-style ctrl distribution (only valid with // Extracted from specificClass during the P4 refactor; the orchestrator
// normalized scaling). // wires it in via the strategies map below. It depends on the same
// // group-curve helpers the optimizer uses, so allocation and power
// 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. // evaluation stay on the equalised group operating point.
const { POSITIONS } = require('generalFunctions'); const { POSITIONS } = require('generalFunctions');
@@ -49,162 +46,137 @@ function capFlowDemand(Qd, dynamicTotals, logger) {
return Qd; return Qd;
} }
// Pure distribution math: given the demand, group envelope, priority list, and
// per-machine curve helpers, return the {machineId, flow} mapping plus running
// totals. No side effects, no mgc reference — testable without an MGC fixture.
//
// Inputs:
// machines: dict {id → machine} (machine objects need group-curve fields set)
// Qd: demand in canonical m³/s
// dynamicTotals: {flow: {min, max}} — envelope across ALL registered pumps
// activeTotals: {flow: {min, max}} — envelope across currently-active pumps
// priorityList: optional array of ids; null = default ordering
// isMachineActive: (id) → boolean (state-aware predicate)
// groupFlow: (machine) → {currentFxyYMin, currentFxyYMax}
// groupCalcPower: (machine, flow) → number (W)
// logger: { warn, error, … } or null
//
// Returns: { flowDistribution: [{machineId, flow}], totalFlow, totalPower, totalCog }
function computeEqualFlowDistribution({
machines, Qd, dynamicTotals, activeTotals, priorityList,
isMachineActive, groupFlow, groupCalcPower, logger,
}) {
Qd = capFlowDemand(Qd, dynamicTotals, logger);
let machinesInPriorityOrder = sortMachinesByPriority(machines, priorityList);
machinesInPriorityOrder = filterOutUnavailableMachines(machinesInPriorityOrder);
const flowDistribution = [];
let totalFlow = 0;
let totalPower = 0;
// Equal-flow doesn't compute a meaningful cog — only BEP-Gravitation does.
// Preserved at 0 for backwards-compat; pinned by a basic test so a future
// change that introduces a fake non-zero value will fail loudly.
const totalCog = 0;
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 (isMachineActive(m.id)) {
flowDistribution.push({ machineId: m.id, flow: 0 });
availableFlow -= groupFlow(m.machine).currentFxyYMin;
}
}
const remaining = machinesInPriorityOrder.filter(({ id }) =>
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 (!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 }) => 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;
}
}
return { flowDistribution, totalFlow, totalPower, totalCog };
}
// Orchestrator: equalize the operating point, call the pure distribution math,
// write outputs, dispatch children. The mgc reaches happen here, not in the
// algorithm — see computeEqualFlowDistribution above for the part that's
// testable in isolation.
async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = null) { async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = null) {
const { mgc } = ctx; const { mgc } = ctx;
try { try {
mgc.equalizePressure(); mgc.equalizePressure();
const dynamicTotals = mgc.calcDynamicTotals(); 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(); const activeTotals = mgc.totals.activeTotals();
switch (true) { const { flowDistribution, totalFlow, totalPower, totalCog } = computeEqualFlowDistribution({
case (Qd < activeTotals.flow.min && activeTotals.flow.min !== 0): { machines: mgc.machines,
let availableFlow = activeTotals.flow.min; Qd, dynamicTotals, activeTotals, priorityList,
for (let i = machinesInPriorityOrder.length - 1; i >= 0 && availableFlow > Qd; i--) { isMachineActive: (id) => mgc.isMachineActive(id),
const m = machinesInPriorityOrder[i]; groupFlow, groupCalcPower,
if (mgc.isMachineActive(m.id)) { logger: mgc.logger,
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 pUnit = mgc.unitPolicy.canonical.power;
const sumF = totalFlow.reduce((a, b) => a + b, 0); const fUnit = mgc.unitPolicy.canonical.flow;
mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, sumP, mgc.unitPolicy.canonical.power); mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, totalPower, pUnit);
mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, sumF, mgc.unitPolicy.canonical.flow); mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, totalFlow, fUnit);
if (sumP > 0) { // Hydraulic efficiency η = (Q·ΔP)/P_shaft, same scale as child cogs.
mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(sumF / sumP); const dP = mgc.operatingPoint.headerDiffPa;
if (Number.isFinite(dP) && dP > 0 && totalPower > 0) {
mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT)
.value((totalFlow * dP) / totalPower);
} }
mgc.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(totalCog);
// Route the chosen distribution through the shared planner/executor
// path. With planner.useRendezvous=true (the default) all pumps
// reach their per-pump flow target at the same wall-clock instant;
// with it false, every command fires at tick 0 — same effect as
// the legacy Promise.all dispatch but with correct startup/shutdown
// ordering (the planner emits execsequence BEFORE flowmovement for
// idle pumps, where the legacy code emitted them in the opposite
// order and relied on the pump's delayedMove queue to recover).
await mgc._dispatchFlowDistribution(flowDistribution);
} catch (err) { } catch (err) {
mgc.logger?.error?.(err); mgc.logger?.error?.(err);
} }
} }
module.exports = { equalFlowControl, prioPercentageControl, capFlowDemand, sortMachinesByPriority, filterOutUnavailableMachines }; module.exports = {
equalFlowControl, computeEqualFlowDistribution,
capFlowDemand, sortMachinesByPriority, filterOutUnavailableMachines,
};

View File

@@ -0,0 +1,94 @@
// compact-fields.js — MGC-only output-format icon picker.
//
// Logger toggle/level and physical-position visuals now live in the shared
// generalFunctions/src/menu/iconHelpers.js (auto-injected by MenuManager), so
// the only MGC-local visuals left are the two output-format dropdowns
// (processOutputFormat, dbaseOutputFormat) — those fields aren't part of any
// shared menu.
(function () {
const editor = window.EVOLV?.nodes?.machineGroupControl?.editor;
if (!editor) return;
const BLUE = '#1F4E79';
const STEEL = '#607484';
// MGC-only SVGs (output formats only — logger/position SVGs come from
// window.EVOLV.iconHelpers.SVG).
const SVG = {
process: `
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="10" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
<rect x="50" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
<line x1="30" y1="29" x2="46" y2="29" stroke="${BLUE}" stroke-width="3" stroke-linecap="round"/>
<path d="M42 24 L48 29 L42 34" fill="none" stroke="${BLUE}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`,
json: `
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g fill="none" stroke="${BLUE}" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M30 14 C22 16 22 26 27 29 C22 32 22 42 30 44"/>
<path d="M50 14 C58 16 58 26 53 29 C58 32 58 42 50 44"/>
</g>
<g fill="${STEEL}">
<circle cx="36" cy="29" r="2.2"/>
<circle cx="44" cy="29" r="2.2"/>
</g>
</svg>`,
csv: `
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="12" y="12" width="56" height="34" rx="2" fill="#fff" stroke="${STEEL}" stroke-width="2.4"/>
<line x1="12" y1="22" x2="68" y2="22" stroke="${STEEL}" stroke-width="2"/>
<g stroke="${STEEL}" stroke-width="1.6">
<line x1="12" y1="34" x2="68" y2="34"/>
<line x1="31" y1="12" x2="31" y2="46"/>
<line x1="49" y1="12" x2="49" y2="46"/>
</g>
</svg>`,
influxdb: `
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<ellipse cx="40" cy="15" rx="22" ry="6" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
<path d="M18 15 V42 C18 46 28 49 40 49 C52 49 62 46 62 42 V15" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
<path d="M18 28 C26 32 54 32 62 28" fill="none" stroke="${STEEL}" stroke-width="1.6" opacity="0.6"/>
<path d="M22 39 L30 32 L38 41 L46 34 L54 38" fill="none" stroke="${BLUE}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`,
};
const outputIcons = {
process: SVG.process,
json: SVG.json,
csv: SVG.csv,
influxdb: SVG.influxdb,
frost: SVG.influxdb,
};
const outputLabels = {
process: 'Process',
json: 'JSON',
csv: 'CSV',
influxdb: 'Influx',
frost: 'FROST',
};
function initOutputFormats() {
const helpers = window.EVOLV?.iconHelpers;
if (!helpers) return;
const processSelect = document.getElementById('node-input-processOutputFormat');
const processHolder = document.getElementById('mgc-process-output-picker');
if (processSelect && processHolder) {
helpers.renderSelectPicker(processSelect, processHolder, outputIcons, outputLabels);
}
const dbaseSelect = document.getElementById('node-input-dbaseOutputFormat');
const dbaseHolder = document.getElementById('mgc-dbase-output-picker');
if (dbaseSelect && dbaseHolder) {
helpers.renderSelectPicker(dbaseSelect, dbaseHolder, outputIcons, outputLabels);
}
}
function init() {
initOutputFormats();
}
editor.compactFields = { init };
})();

34
src/editor/index.js Normal file
View File

@@ -0,0 +1,34 @@
// machineGroupControl editor — namespace bootstrap.
//
// Attaches the editor's submodule registry to the shared
// window.EVOLV.nodes.machineGroupControl namespace (same one the menuManager
// and configManager endpoints populate). Each sibling module in this
// directory (mode-cards.js, demand-contract.js, oneditprepare.js) registers
// itself by writing additional members onto this namespace.
//
// Loaded first by mgc.html — must not depend on any other src/editor module.
(function () {
const root = window.EVOLV = window.EVOLV || {};
const nodes = root.nodes = root.nodes || {};
const ns = nodes.machineGroupControl = nodes.machineGroupControl || {};
const editor = ns.editor = ns.editor || {};
// Pub/sub for mode changes — mode-cards.js fires, anything that wants to
// re-render on mode change subscribes. Keep it tiny; no third-party emitter.
const modeListeners = [];
editor.onModeChange = (cb) => { if (typeof cb === 'function') modeListeners.push(cb); };
editor.emitModeChange = (newMode) => {
for (const cb of modeListeners) {
try { cb(newMode); } catch (e) { /* swallow — UI helper */ }
}
};
// Read the currently selected mode from the hidden input that mode-cards.js
// keeps in sync with the active card. Falls back to optimalControl if the
// input isn't on the page yet (race against oneditprepare).
editor.getMode = () => {
const el = document.getElementById('node-input-mode');
return (el && el.value) || 'optimalControl';
};
})();

139
src/editor/mode-cards.js Normal file
View File

@@ -0,0 +1,139 @@
// mode-cards.js — visual pickers for control-strategy modes and planner flags.
//
// Replaces the plain mode field with compact illustrated controls. The
// original inputs stay in the DOM but are hidden — Node-RED reads their values
// on save, exactly as before.
//
// SVGs are inline so the editor doesn't need to fetch additional assets.
(function () {
const editor = window.EVOLV?.nodes?.machineGroupControl?.editor;
if (!editor) return;
const MODES = [
{
value: 'optimalControl',
ariaLabel: 'Optimal control',
label: 'Most-efficient',
svg: `
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<path d="M 16 60 Q 62 -30 108 60" fill="none" stroke="#1E8449" stroke-width="3" stroke-linecap="round"/>
<line x1="62" y1="15" x2="62" y2="60" stroke="#1F4E79" stroke-dasharray="3 3" stroke-width="1.2"/>
<circle cx="62" cy="15" r="5.5" fill="#1E8449" stroke="#fff" stroke-width="1.6"/>
</svg>`,
},
{
value: 'priorityControl',
ariaLabel: 'Priority control',
label: 'Priority',
svg: `
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<polyline points="14,54 38,54 38,40 62,40 62,26 86,26 86,14 110,14"
fill="none" stroke="#1F4E79" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>
</svg>`,
},
{
value: 'maintenance',
ariaLabel: 'Maintenance',
label: 'Maintenance',
svg: `<i class="fa fa-wrench" style="font-size:40px;color:#607484;" aria-hidden="true"></i>`,
},
];
const RENDEZVOUS_SVG = `
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="96" y1="12" x2="96" y2="60" stroke="#1F4E79" stroke-dasharray="3 3" stroke-width="1.4"/>
<path d="M 18 52 C 38 50, 64 38, 96 20" fill="none" stroke="#1E8449" stroke-width="2.6" stroke-linecap="round"/>
<path d="M 18 58 C 40 56, 64 42, 96 20" fill="none" stroke="#50a8d9" stroke-width="2.6" stroke-linecap="round"/>
<path d="M 18 44 C 42 44, 66 34, 96 20" fill="none" stroke="#C0392B" stroke-width="2.6" stroke-linecap="round"/>
<circle cx="96" cy="20" r="6" fill="#1F4E79" stroke="#fff" stroke-width="1.6"/>
</svg>`;
// Render the three cards into the placeholder div. The hidden input stays
// intact; the card click handler writes its value back so Node-RED's save
// path is unchanged.
function init(/* node */) {
const placeholder = document.getElementById('mgc-mode-cards');
const hidden = document.getElementById('node-input-mode');
if (!placeholder || !hidden) return;
placeholder.innerHTML = MODES.map((m) => `
<div class="mgc-mode-card" data-mode="${m.value}" role="radio" tabindex="0"
aria-label="${m.ariaLabel}" aria-checked="false" title="${m.ariaLabel}">
<div class="mgc-mode-card-svg">${m.svg}</div>
<div class="mgc-mode-card-label">${m.label}</div>
</div>
`).join('');
const cards = Array.from(placeholder.querySelectorAll('.mgc-mode-card'));
function syncHighlight() {
const current = hidden.value || 'optimalControl';
for (const c of cards) {
const on = c.getAttribute('data-mode') === current;
c.classList.toggle('mgc-mode-card-on', on);
c.setAttribute('aria-checked', String(on));
}
}
function pick(mode) {
hidden.value = mode;
// Fire change so any other listener bound to the input (Node-RED's
// dirty-tracker, plus our pub/sub) sees the update.
hidden.dispatchEvent(new Event('change', { bubbles: true }));
syncHighlight();
editor.emitModeChange(mode);
}
for (const c of cards) {
c.addEventListener('click', () => pick(c.getAttribute('data-mode')));
c.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
pick(c.getAttribute('data-mode'));
}
});
}
syncHighlight();
}
function initRendezvousToggle(/* node */) {
const placeholder = document.getElementById('mgc-rendezvous-toggle');
const checkbox = document.getElementById('node-input-useRendezvous');
if (!placeholder || !checkbox) return;
placeholder.innerHTML = `
<div class="mgc-toggle-card-svg">${RENDEZVOUS_SVG}</div>
<div class="mgc-toggle-card-label">Inactive</div>
`;
const labelEl = placeholder.querySelector('.mgc-toggle-card-label');
function syncHighlight() {
const on = checkbox.checked;
placeholder.classList.toggle('mgc-toggle-card-on', on);
placeholder.setAttribute('aria-checked', String(on));
if (labelEl) labelEl.textContent = on ? 'Active' : 'Inactive';
}
function toggle() {
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
syncHighlight();
}
placeholder.addEventListener('click', toggle);
placeholder.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
toggle();
}
});
checkbox.addEventListener('change', syncHighlight);
syncHighlight();
}
editor.modeCards = { init };
editor.rendezvousToggle = { init: initRendezvousToggle };
})();

View File

@@ -0,0 +1,22 @@
// oneditprepare.js — initialise the editor's visual modules.
//
// Called from mgc.html's oneditprepare alongside the existing menuManager
// initialiser (logger/position dropdowns). Each module is responsible for
// its own placeholder; we just kick them off in dependency order.
(function () {
const ns = window.EVOLV?.nodes?.machineGroupControl;
if (!ns || !ns.editor) return;
ns.editor.initVisuals = function (node) {
if (ns.editor.modeCards && typeof ns.editor.modeCards.init === 'function') {
ns.editor.modeCards.init(node);
}
if (ns.editor.rendezvousToggle && typeof ns.editor.rendezvousToggle.init === 'function') {
ns.editor.rendezvousToggle.init(node);
}
if (ns.editor.compactFields && typeof ns.editor.compactFields.init === 'function') {
ns.editor.compactFields.init(node);
}
};
})();

View File

@@ -44,19 +44,25 @@ class GroupEfficiency {
} }
// Maps current efficiency onto [0..1] across [maxEfficiency..minEfficiency]. // Maps current efficiency onto [0..1] across [maxEfficiency..minEfficiency].
// Degenerate case (max === min) collapses the band to a point — return 1. // Returns undefined for any case where the metric is meaningless:
// - currentEfficiency missing
// - the [max..min] band has collapsed (homogeneous pump group, OR float
// noise so |max-min| < DEGENERATE_EPS).
// Consumers must treat undefined as "no data" and display accordingly,
// not as 0% / 100% — both readings would be misleading.
calcRelativeDistanceFromPeak(currentEfficiency, maxEfficiency, minEfficiency) { calcRelativeDistanceFromPeak(currentEfficiency, maxEfficiency, minEfficiency) {
let distance = 1; const DEGENERATE_EPS = 1e-9; // η points are 0..1, so 1e-9 catches float noise.
if (currentEfficiency != null && maxEfficiency !== minEfficiency && this.interpolation) { if (currentEfficiency == null) return undefined;
distance = this.interpolation.interpolate_lin_single_point( if (!this.interpolation) return undefined;
currentEfficiency, if (!Number.isFinite(maxEfficiency) || !Number.isFinite(minEfficiency)) return undefined;
maxEfficiency, if (Math.abs(maxEfficiency - minEfficiency) < DEGENERATE_EPS) return undefined;
minEfficiency, return this.interpolation.interpolate_lin_single_point(
0, currentEfficiency,
1, maxEfficiency,
); minEfficiency,
} 0,
return distance; 1,
);
} }
// Returns both abs + rel; orchestrator decides whether to mirror onto // Returns both abs + rel; orchestrator decides whether to mirror onto

View File

@@ -13,6 +13,10 @@ class GroupOperatingPoint {
// Late-binding via getters in the orchestrator works too — but // Late-binding via getters in the orchestrator works too — but
// passing the live references avoids re-plumbing setters. // passing the live references avoids re-plumbing setters.
this.ctx = ctx; this.ctx = ctx;
// Last header differential pressure (Pa) computed by equalize().
// Consumers (optimizer, strategies) read this to convert raw
// flow/power to hydraulic efficiency η = (Q·ΔP)/P.
this.headerDiffPa = 0;
} }
get measurements() { return this.ctx.measurements; } get measurements() { return this.ctx.measurements; }
@@ -72,6 +76,9 @@ class GroupOperatingPoint {
this.logger?.debug?.(`Skipping equalization: invalid header diff ${headerDiff} (down=${headerDownstream}, up=${headerUpstream})`); this.logger?.debug?.(`Skipping equalization: invalid header diff ${headerDiff} (down=${headerDownstream}, up=${headerUpstream})`);
return; return;
} }
// Stash so downstream callers (optimizer, strategies) can compute
// hydraulic efficiency without re-reading every machine's pressure.
this.headerDiffPa = headerDiff;
this.logger?.debug?.(`Equalizing operating point: down=${headerDownstream}, up=${headerUpstream}, diff=${headerDiff}`); this.logger?.debug?.(`Equalizing operating point: down=${headerDownstream}, up=${headerUpstream}, diff=${headerDiff}`);

View File

@@ -5,11 +5,13 @@ const Machine = require('../../rotatingMachine/src/specificClass');
const Measurement = require('../../measurement/src/specificClass'); const Measurement = require('../../measurement/src/specificClass');
const baseCurve = require('../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json'); const baseCurve = require('../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json');
const CONTROL_MODES = ['optimalcontrol', 'prioritycontrol', 'prioritypercentagecontrol']; // prioritypercentagecontrol mode and per-instance scaling state were
// removed when set.demand became unit-self-describing — see
// commands/handlers.js (bare number = %, {value, unit} = absolute).
const CONTROL_MODES = ['optimalcontrol', 'prioritycontrol'];
const MODE_LABELS = { const MODE_LABELS = {
optimalcontrol: 'OPT', optimalcontrol: 'OPT',
prioritycontrol: 'PRIO', prioritycontrol: 'PRIO',
prioritypercentagecontrol: 'PERC'
}; };
const stateConfig = { const stateConfig = {
@@ -60,7 +62,6 @@ function createGroupConfig(name) {
return { return {
general: { logging: { enabled: false, logLevel: 'error' }, name: `machinegroup-${name}` }, general: { logging: { enabled: false, logLevel: 'error' }, name: `machinegroup-${name}` },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
scaling: { current: 'normalized' },
mode: { current: 'optimalcontrol' } mode: { current: 'optimalcontrol' }
}; };
} }
@@ -185,7 +186,9 @@ async function driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrd
await sleep(15); await sleep(15);
mg.setMode(mode); mg.setMode(mode);
mg.setScaling('normalized'); // required for prioritypercentagecontrol, works for others too // setScaling is gone — handleInput now takes canonical m³/s directly. This
// legacy diagnostic still works in % terms by sweeping demand 0..100 and
// mapping each step to canonical before dispatch.
const dynamic = mg.calcDynamicTotals(); const dynamic = mg.calcDynamicTotals();
const span = Math.max(dynamic.flow.max - dynamic.flow.min, 1); const span = Math.max(dynamic.flow.max - dynamic.flow.min, 1);
@@ -197,7 +200,10 @@ async function driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrd
let best = { demand, flow: 0, power: 0, efficiency: 0, error: Infinity }; let best = { demand, flow: 0, power: 0, efficiency: 0, error: Infinity };
for (let attempt = 0; attempt < 4; attempt += 1) { for (let attempt = 0; attempt < 4; attempt += 1) {
await mg.handleInput('parent', demand, Infinity, priorityOrder); // demand is a percent (0..100); convert to canonical m³/s for the
// post-refactor handleInput signature.
const canonical = dynamic.flow.min + (demand / 100) * (dynamic.flow.max - dynamic.flow.min);
await mg.handleInput('parent', canonical, Infinity, priorityOrder);
await sleep(30); await sleep(30);
const totals = captureTotals(mg); const totals = captureTotals(mg);

View File

@@ -41,6 +41,65 @@ function getOutput(mgc) {
out.scaling = scaling; out.scaling = scaling;
out.absDistFromPeak = absDistFromPeak; out.absDistFromPeak = absDistFromPeak;
out.relDistFromPeak = relDistFromPeak; out.relDistFromPeak = relDistFromPeak;
// System (header) differential pressure resolved by the last equalize.
// Dashboards use this to compute head = ΔP / (ρ · g) for Q-H plots
// and to scale the BEP indicators without re-reading every child.
// Emitted in canonical Pa and in the configured output unit (mbar
// by default) so the dashboard can pick whichever it prefers.
const headerDiffPa = mgc.operatingPoint?.headerDiffPa;
if (Number.isFinite(headerDiffPa) && headerDiffPa > 0) {
out.headerDiffPa = headerDiffPa;
const pUnit = unitPolicy.output.pressure;
// 1 mbar = 100 Pa. Only convert when we recognise mbar; otherwise
// leave the raw Pa to avoid a stale or silently wrong unit label.
if (pUnit === 'mbar') out.headerDiffMbar = headerDiffPa / 100;
}
// Group capacity + active-machine counts. Surfaced so dashboards can
// show the same numbers the status badge does without subscribing to
// every child node individually. Emitted in the output flow unit (m³/h)
// so the dashed capacity envelope lands on the SAME axis as the predicted-
// flow series — dynamicTotals is canonical m³/s, so convert here. (Both
// telemetry consumers — the Grafana flow panel and the FlowFuse fanout —
// assume m³/h; emitting raw m³/s made the capacity lines render as ~0.)
const fUnit = unitPolicy.output.flow;
const capMax = mgc.dynamicTotals?.flow?.max;
const capMin = mgc.dynamicTotals?.flow?.min;
out.flowCapacityMax = Number.isFinite(capMax)
? unitPolicy.convert(capMax, 'm3/s', fUnit, 'MGC flow capacity max') : 0;
out.flowCapacityMin = Number.isFinite(capMin)
? unitPolicy.convert(capMin, 'm3/s', fUnit, 'MGC flow capacity min') : 0;
// Operator demand resolved by the last dispatch. Surfaced so the dashboard
// can overlay "what was asked" against the achieved total flow:
// - demandFlow: resolved flow setpoint (post-envelope-clamp) in the output
// flow unit (m³/h), same scale as the total-flow series.
// - demandPct: that setpoint as 0..100 % of the live capacity envelope
// (flow.min..flow.max), so a % demand entered by the operator round-trips
// regardless of whether they asked in % or absolute flow.
// Omitted entirely before the first demand arrives (degraded state).
if (mgc._lastDemand) {
const clampedCanonical = mgc._lastDemand.clamped;
out.demandFlow = unitPolicy.convert(clampedCanonical, 'm3/s', fUnit, 'MGC demand setpoint');
const span = Number.isFinite(capMin) && Number.isFinite(capMax) ? capMax - capMin : 0;
out.demandPct = span > 0
? Math.max(0, Math.min(100, ((clampedCanonical - capMin) / span) * 100))
: 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;
// Group movement status: 'working' while any child is still ramping /
// sequencing toward its dispatched setpoint, 'ready' once all have settled.
// The dispatch gate holds non-urgent demand until 'ready'; surfacing it lets
// a dashboard show why a fresh setpoint hasn't been applied yet.
out.movementState = typeof mgc.getMovementState === 'function' ? mgc.getMovementState() : 'ready';
return out; return out;
} }
@@ -55,15 +114,16 @@ function getStatusBadge(mgc) {
const md = m?.currentMode; const md = m?.currentMode;
return s && s !== 'off' && s !== 'maintenance' && md !== 'maintenance'; return s && s !== 'off' && s !== 'maintenance' && md !== 'maintenance';
}); });
const status = available.length > 0 ? `${available.length} machine(s)` : 'No machines'; const machineCount = Object.keys(mgc.machines || {}).length;
let scalingSymbol; const scaling = String(mgc.scaling || '').toLowerCase() === 'absolute' ? 'abs' : 'norm';
switch ((mgc.scaling || '').toLowerCase()) { const parts = [
case 'absolute': scalingSymbol = ''; break; mgc.mode || '?',
case 'normalized': scalingSymbol = 'Ⓝ'; break; scaling,
default: scalingSymbol = mgc.mode || ''; break; `Q=${Math.round(totalFlow)}/${Math.round(totalCapacity)} m³/h`,
} `P=${Math.round(totalPower)} kW`,
const text = ` ${mgc.mode || 'Unknown'} | ${scalingSymbol}: 💨=${Math.round(totalFlow)}/${Math.round(totalCapacity)} | ⚡=${Math.round(totalPower)} | ${status}`; `${available.length}/${machineCount}x`,
return statusBadge.text(text, { fill: available.length > 0 ? 'green' : 'red', shape: 'dot' }); ];
return statusBadge.compose(parts, { fill: available.length > 0 ? 'green' : (machineCount > 0 ? 'yellow' : 'grey'), shape: 'dot' });
} }
module.exports = { getOutput, getStatusBadge }; module.exports = { getOutput, getStatusBadge };

View File

@@ -0,0 +1,90 @@
'use strict';
// Builds a plain-object snapshot of a registered child machine for the
// movement planner. Pure read — no contract changes to the parent/child
// registration handshake, no mutation of the child.
function buildProfile(child) {
if (!child) throw new TypeError('buildProfile: child is required');
const id = child?.config?.general?.id ?? null;
const state = typeof child.state?.getCurrentState === 'function'
? child.state.getCurrentState()
: null;
const position = typeof child.state?.getCurrentPosition === 'function'
? child.state.getCurrentPosition()
: null;
const mm = child.state?.movementManager;
const minPosition = Number(mm?.minPosition);
const maxPosition = Number(mm?.maxPosition);
const velocityPctPerS = (() => {
if (typeof mm?.getNormalizedSpeed === 'function' && Number.isFinite(maxPosition) && Number.isFinite(minPosition)) {
return mm.getNormalizedSpeed() * (maxPosition - minPosition);
}
const s = Number(mm?.speed);
return Number.isFinite(s) ? s : 0;
})();
// Source of truth for ladder durations is the child state's config.time
// (state.js stores the merged stateConfig there). Older fallbacks
// (child.config.stateConfig, child.stateConfig) are kept for callers
// that pre-populate them, but rotatingMachine doesn't — it stores
// timings under state.config.time. Reading the wrong path is silent:
// every duration defaults to 0, the planner thinks startup is
// instantaneous, tStar collapses to the ramp time, and same-time
// landing breaks.
const t = child.state?.config?.time
?? child.config?.stateConfig?.time
?? child.stateConfig?.time
?? {};
const timings = {
startingS: Number(t.starting) || 0,
warmingupS: Number(t.warmingup) || 0,
stoppingS: Number(t.stopping) || 0,
coolingdownS: Number(t.coolingdown) || 0,
};
const remainingTransitionS = typeof child.state?.stateManager?.getRemainingTransitionS === 'function'
? child.state.stateManager.getRemainingTransitionS()
: null;
const flowAt = (pos, pressure) => {
if (typeof child.predictFlow?.evaluate === 'function') {
return child.predictFlow.evaluate(pos, pressure);
}
return null;
};
// Inverse curve: target flow (canonical m³/s, in the child's output unit
// since predictCtrl was built from the same curve units) → control %.
// Mirrors the conversion the pump performs in flowController on a
// `flowmovement` command (rotatingMachine/src/flow/flowController.js:52).
// Returns null when the child has no curve loaded so the scheduler can
// fall back gracefully.
const positionForFlow = (flow) => {
if (!Number.isFinite(flow)) return null;
if (typeof child.predictCtrl?.y !== 'function') return null;
try {
const v = child.predictCtrl.y(flow);
return Number.isFinite(v) ? v : null;
} catch (_) {
return null;
}
};
return {
id,
state,
position,
minPosition,
maxPosition,
velocityPctPerS,
timings,
remainingTransitionS,
flowAt,
positionForFlow,
};
}
module.exports = { buildProfile };

View File

@@ -0,0 +1,86 @@
'use strict';
// Per-machine time-parameterised plan. Pure: given a MachineProfile
// snapshot and a target position, computes how long the move will take.
//
// Cases by profile.state:
// idle / off startup ladder + ramp from min to target
// operational |target position| / velocity
// accelerating |
// decelerating post-abort residue, same as operational
// starting remaining-in-starting + full warmup + ramp from min
// warmingup remaining-in-warmingup + ramp from min
// stopping | coolingdown non-interruptible deload; cannot contribute flow
// in this dispatch — returns null so the scheduler
// can exclude the machine from "up" candidates.
//
// Velocity of 0 returns Infinity (misconfigured speed) so the scheduler
// can demote the machine without crashing.
const ACTIVE_OPERATIONAL = new Set(['operational', 'accelerating', 'decelerating']);
const STARTUP_LADDER = new Set(['starting', 'warmingup']);
const SHUTDOWN_LADDER = new Set(['stopping', 'coolingdown']);
class MoveTrajectory {
constructor(profile, { targetPosition } = {}) {
if (!profile || typeof profile !== 'object') {
throw new TypeError('MoveTrajectory: profile is required');
}
if (!Number.isFinite(targetPosition)) {
throw new TypeError('MoveTrajectory: targetPosition must be a finite number');
}
this.profile = profile;
this.targetPosition = this._clampToBounds(targetPosition);
}
_clampToBounds(p) {
const { minPosition, maxPosition } = this.profile;
if (Number.isFinite(minPosition) && p < minPosition) return minPosition;
if (Number.isFinite(maxPosition) && p > maxPosition) return maxPosition;
return p;
}
// Seconds from "fire" until the machine is delivering flow at
// targetPosition. Null when the machine is in a non-contributing
// (shutting-down) state.
etaToTargetS() {
const p = this.profile;
const v = p.velocityPctPerS;
const target = this.targetPosition;
if (SHUTDOWN_LADDER.has(p.state)) return null;
if (!Number.isFinite(v) || v <= 0) return Infinity;
if (p.state === 'operational' || ACTIVE_OPERATIONAL.has(p.state)) {
const dist = Math.abs(target - p.position);
return dist / v;
}
if (p.state === 'warmingup') {
// Remaining warmup, then ramp from minPosition to target.
// Ramp starts from minPosition because the pump is not moving
// during warmup — position is held at min.
const remW = p.remainingTransitionS ?? p.timings.warmingupS;
const rampDist = Math.max(0, target - p.minPosition);
return remW + rampDist / v;
}
if (p.state === 'starting') {
// Remaining-in-starting + full warmup duration + ramp from min.
const remS = p.remainingTransitionS ?? p.timings.startingS;
const rampDist = Math.max(0, target - p.minPosition);
return remS + p.timings.warmingupS + rampDist / v;
}
// idle / off / emergencystop / maintenance / any non-active state
// not in the ladders: full startup sequence to operational, then ramp.
const rampDist = Math.max(0, target - p.minPosition);
return p.timings.startingS + p.timings.warmingupS + rampDist / v;
}
}
MoveTrajectory.SHUTDOWN_LADDER = SHUTDOWN_LADDER;
MoveTrajectory.STARTUP_LADDER = STARTUP_LADDER;
module.exports = MoveTrajectory;

View File

@@ -0,0 +1,121 @@
'use strict';
// Tick-driven executor for the schedule produced by movementScheduler.plan.
//
// - Holds the current schedule + a cursor that advances one per tick().
// - Fires any unfired command whose fireAtTickN <= cursor.
// - replan(newSchedule) replaces the schedule and resets the cursor —
// already-fired commands stay fired (the pump's FSM is downstream and
// handles their consequences; the executor never tries to "undo" a
// fired startup, which keeps warmup/cooldown safety intact).
// - fireCommand is injected for unit-testability — production wires it to
// `machine.handleInput(...)`.
class MovementExecutor {
constructor({ fireCommand, logger } = {}) {
if (typeof fireCommand !== 'function') {
throw new TypeError('MovementExecutor: fireCommand callback is required');
}
this._fireCommand = fireCommand;
this._logger = logger || null;
this._schedule = null;
this._cursor = 0;
this._firedIdx = new Set();
// Wall-clock anchor for the active schedule. Each tick recomputes
// a "virtual cursor" from elapsed time so the schedule survives a
// blocking first tick (e.g. an awaited startup sequence that takes
// multiple seconds to settle).
this._dispatchT0 = null;
}
// Replace the active schedule. Cursor starts at 0 (new dispatch is
// anchored to "now"). The previous schedule's unfired commands are
// dropped; already-fired commands are not retracted.
replan(schedule) {
this._schedule = schedule || { commands: [] };
this._cursor = 0;
this._firedIdx = new Set();
this._dispatchT0 = Date.now();
if (this._logger?.debug) {
const cmds = this._schedule.commands || [];
this._logger.debug(`MovementExecutor.replan: ${cmds.length} commands, tStar=${this._schedule.tStarS ?? '?'}s`);
}
}
// Advance one tick. Returns a Promise resolving to the list of
// commands fired this tick once their async work settles. Awaiting
// the FIRST tick from within a dispatch is what gives the new move
// priority over an in-flight shutdown sequence — fire-and-forget
// gives the shutdown's for-loop a window to progress through state
// transitions before the new move's residue handler claims the FSM.
async tick() {
// Virtual cursor = max(advanced cursor, elapsed wall-clock ticks).
// If a previous tick blocked on a long await, elapsed time has
// already passed and we should fire every command whose
// fireAtTickN now lies in the past — not wait another N timer
// cycles to catch up. tickS is stamped on the schedule by the
// planner (defaults to 1 s).
const tickS = Number.isFinite(this._schedule?.tickS) && this._schedule.tickS > 0
? this._schedule.tickS
: 1;
const elapsedS = this._dispatchT0 != null ? (Date.now() - this._dispatchT0) / 1000 : 0;
const wallTick = Math.floor(elapsedS / tickS);
const virtCursor = Math.max(this._cursor, wallTick);
const fired = [];
const cmds = this._schedule?.commands || [];
for (let i = 0; i < cmds.length; i++) {
if (this._firedIdx.has(i)) continue;
const c = cmds[i];
if (c.fireAtTickN <= virtCursor) {
this._firedIdx.add(i);
try {
// Fire-and-forget. The synchronous prologue of
// handleInput claims the latest-wins gate before
// returning its promise — that's enough for race
// favouring. AWAITing the returned promise here
// would block the executor for the entire ladder +
// ramp duration of a flowmovement-after-startup
// (because the pump's delayedMove only resolves
// when the ramp completes), preventing the
// wall-clock timer from starting and dragging every
// delayed command in the schedule forward by that
// amount.
const r = this._fireCommand(c);
if (r && typeof r.then === 'function') {
r.catch((e) => {
if (this._logger?.error) {
this._logger.error(`MovementExecutor: fireCommand rejected for ${c.machineId}/${c.action}: ${e?.message || e}`);
}
});
}
fired.push(c);
} catch (e) {
if (this._logger?.error) {
this._logger.error(`MovementExecutor: fireCommand failed for ${c.machineId}/${c.action}: ${e?.message || e}`);
}
}
}
}
this._cursor = virtCursor + 1;
return fired;
}
// Telemetry — number of commands not yet fired.
pending() {
const cmds = this._schedule?.commands || [];
return cmds.length - this._firedIdx.size;
}
// Telemetry — current tick cursor.
cursor() {
return this._cursor;
}
// Telemetry — the live schedule (read-only view).
schedule() {
return this._schedule;
}
}
module.exports = MovementExecutor;

View File

@@ -0,0 +1,243 @@
'use strict';
// Pure movement planner. Given a set of machine profile snapshots and the
// optimizer's chosen flow combination, returns a tick-indexed schedule of
// commands that minimises flow disruption during the transition.
//
// Algorithm — rendezvous-on-demand-at-current-pressure:
//
// 1. For each machine, classify the move it needs (startup, flow-move
// up, flow-move down, shutdown, no-op) based on its current FSM state
// and the optimizer's target flow for it.
// 2. Compute eta_i (seconds-to-target-flow) per machine via
// MoveTrajectory. Machines that can't contribute on this dispatch
// (stopping / coolingdown / unknown) are skipped.
// 3. Rendezvous time t* = max(eta_i over ALL non-noop moves). The
// slowest move (typically a startup ladder + ramp) sets the deadline.
// 4. Every command — including a startup's `execsequence` — is delayed by
// (t* eta_j) so its move FINISHES at t*. A startup is delayed as a
// whole: its ladder begins at (t* eta) and completes at (t* rampS),
// then the queued flowmovement (held in the pump's delayedMove) ramps to
// finish at t*. The slowest mover (t* eta == 0) fires immediately.
// Delaying the ladder — rather than firing it at tick 0 — is what keeps a
// faster-than-slowest startup from reaching `operational` early and
// sitting at its MINIMUM flow before t* (calcFlow at min position is not
// zero), which otherwise leaks ~min-flow into the group total ahead of
// the rendezvous (the staging bump).
//
// Net effect: ALL pumps reach their per-pump flow target at the same
// wall-clock instant t*. Sum-of-flows is monotonic during the transition
// (no overshoot from a fast in-flight retarget arriving before the
// startup pumps catch up).
//
// The pump's flow→position conversion (via predictCtrl.y) lives in the
// profile so this module is pure: no Node-RED calls, no live child reads.
const MoveTrajectory = require('./moveTrajectory');
const ACTIVE_STATES = new Set(['operational', 'accelerating', 'decelerating']);
const STARTUP_LADDER = new Set(['starting', 'warmingup']);
const SHUTDOWN_LADDER = new Set(['stopping', 'coolingdown']);
// Tick cadence — MGC main loop is 1 Hz per .claude/rules tick convention.
const DEFAULT_TICK_S = 1;
function isOn(state) {
return ACTIVE_STATES.has(state) || STARTUP_LADDER.has(state);
}
// Classify the action a machine needs. The optimizer's combination is a
// canonical statement of "what flow should this machine deliver now."
// `targetFlow == 0` (or absence from combination) means "this machine is
// not part of the new combination."
function classify(profile, targetFlow) {
const isOff = !isOn(profile.state) && !SHUTDOWN_LADDER.has(profile.state);
if (targetFlow > 0) {
if (isOff) return 'startup';
return 'flowmove'; // up or down depending on current vs target
}
// targetFlow <= 0
if (ACTIVE_STATES.has(profile.state) || STARTUP_LADDER.has(profile.state)) {
return 'shutdown';
}
return 'noop';
}
// Direction in flow-space: increasing, decreasing, or unchanged. Drives
// rendezvous: t* is the max eta over INCREASING moves; DECREASING moves
// get delayed to land at t*.
function directionOf(profile, targetFlow) {
if (!isOn(profile.state)) return targetFlow > 0 ? 'increasing' : 'unchanged';
const currentFlow = Number.isFinite(profile.flowAt?.(profile.position, profile._pressureForClassification))
? profile.flowAt(profile.position, profile._pressureForClassification)
: null;
if (currentFlow == null) {
// Without a current-flow read, assume increasing iff target > 0.
return targetFlow > 0 ? 'increasing' : 'decreasing';
}
if (targetFlow > currentFlow) return 'increasing';
if (targetFlow < currentFlow) return 'decreasing';
return 'unchanged';
}
// Plan the schedule.
//
// profiles — array from buildProfile(child)
// combination — array of {machineId, flow} from optimizer
// currentPressure — Pa, for flow→flow and flow→position conversions
// options — { tickS?: 1, useRendezvous?: true }
//
// useRendezvous=false collapses the schedule to "all commands fire at
// tick 0" — every pump moves at its own speed and lands at its own eta.
// Used when the operator explicitly opts out of same-time landing.
function plan(profiles, combination, currentPressure, options = {}) {
const tickS = Number.isFinite(options.tickS) && options.tickS > 0 ? options.tickS : DEFAULT_TICK_S;
const useRendezvous = options.useRendezvous !== false;
const targets = new Map();
for (const item of combination || []) {
if (item && item.machineId != null) targets.set(String(item.machineId), Number(item.flow) || 0);
}
// First pass: classify + compute eta per machine.
const plans = [];
for (const p of profiles) {
const id = String(p.id);
const targetFlow = targets.get(id) ?? 0;
// Stash pressure on a copy of the profile so directionOf can read it
// without changing the public profile shape. Non-mutating: classify
// only needs the value during this pass.
const probeProfile = Object.assign({}, p, { _pressureForClassification: currentPressure });
const action = classify(p, targetFlow);
const direction = directionOf(probeProfile, targetFlow);
if (action === 'noop') {
plans.push({ machineId: id, action, direction, eta: 0, targetFlow, skip: true });
continue;
}
// Convert target flow to target position using the pump's inverse
// curve (lives on the profile). Fallback: linear interpolation
// across [min,max] using the curve domain we know.
let targetPosition = null;
if (action !== 'shutdown' && typeof p.positionForFlow === 'function') {
targetPosition = p.positionForFlow(targetFlow);
}
if (targetPosition == null) {
// Shutdown: target is the minimum position.
targetPosition = action === 'shutdown' ? (Number.isFinite(p.minPosition) ? p.minPosition : 0) : p.position;
}
let eta;
// Per-pump ladder duration; used to gate the flowmovement so it
// can't fire before warmup completes (the pump won't accept it).
const ladderS = action === 'startup'
? ((Number(p.timings?.startingS) || 0) + (Number(p.timings?.warmingupS) || 0))
: 0;
// Ramp-only portion of the eta. For startup this is eta ladder.
// For flow-move or shutdown the entire eta IS the ramp.
let rampS = 0;
if (action === 'shutdown') {
// Time for flow to reach zero = position ramp from current
// position to minPosition. stoppingS / coolingdownS happen
// AFTER flow is zero; they don't affect rendezvous.
const v = Number(p.velocityPctPerS) > 0 ? p.velocityPctPerS : Infinity;
const dist = Math.max(0, p.position - (p.minPosition ?? 0));
eta = v === Infinity ? 0 : dist / v;
rampS = eta;
} else {
const traj = new MoveTrajectory(p, { targetPosition });
eta = traj.etaToTargetS();
if (eta == null) eta = Infinity; // shouldn't happen for non-shutdown actions, but defensive
rampS = Math.max(0, Number.isFinite(eta) ? eta - ladderS : 0);
}
plans.push({ machineId: id, action, direction, eta, ladderS, rampS, targetFlow, targetPosition, skip: false });
}
// Rendezvous: t* = max eta over ALL non-noop moves. Includes
// increasing AND decreasing flow-moves so the slowest mover sets the
// deadline for everyone. When useRendezvous=false, tStar is forced
// to 0 so every command's delay collapses to 0 (legacy behaviour).
const allEtas = plans
.filter((q) => !q.skip && Number.isFinite(q.eta))
.map((q) => q.eta);
const tStar = useRendezvous && allEtas.length > 0 ? Math.max(...allEtas) : 0;
// Second pass: assign fireAtTickN. Every command is delayed so its
// move finishes at t*; the lone exception is the startup ladder's
// execsequence (the ladder must begin now because eta == ladder + ramp).
const commands = [];
for (const q of plans) {
if (q.skip) continue;
// Delay-to-rendezvous: fire (t* eta) seconds from now so the
// move FINISHES at t*. Clamped to >= 0 (the eta == t* mover fires
// immediately).
const fireAtSDelayed = Math.max(0, tStar - q.eta);
const fireAtTickNDelayed = Math.round(fireAtSDelayed / tickS);
// Unchanged moves are no-ops; fire at 0 for simplicity (the pump
// ignores them and we don't pollute the schedule with delays).
const isUnchanged = q.direction === 'unchanged';
if (q.action === 'startup') {
// Just-in-time start. Delay the ENTIRE startup — ladder AND ramp —
// by (t* eta), so the warmup ladder finishes (and the ramp
// begins) at (t* rampS) and the flow lands exactly at t*.
//
// The ladder duration can't be compressed, but it CAN be delayed.
// Firing the execsequence at tick 0 (the old behaviour) made a
// faster-than-slowest startup reach `operational` early and sit at
// its minimum flow from warmup-end until its delayed ramp — leaking
// ~min-flow into the group total before t* (the staging bump). For
// the slowest pump (eta == t*) fireAtTickNDelayed is 0, so it still
// fires immediately. The flowmovement fires on the same tick; the
// pump holds it in delayedMove through the ladder, then ramps over
// rampS to finish at t*.
commands.push({
machineId: q.machineId,
action: 'execsequence',
sequence: 'startup',
fireAtTickN: fireAtTickNDelayed,
eta: q.eta,
});
commands.push({
machineId: q.machineId,
action: 'flowmovement',
flow: q.targetFlow,
fireAtTickN: fireAtTickNDelayed,
eta: q.eta,
});
} else if (q.action === 'flowmove') {
commands.push({
machineId: q.machineId,
action: 'flowmovement',
flow: q.targetFlow,
// Unchanged moves are no-ops; fire immediately so we
// don't park them behind a long startup ladder for no
// reason. Up/down moves both delay so they land at t*.
fireAtTickN: isUnchanged ? 0 : fireAtTickNDelayed,
eta: q.eta,
});
} else if (q.action === 'shutdown') {
commands.push({
machineId: q.machineId,
action: 'execsequence',
sequence: 'shutdown',
fireAtTickN: fireAtTickNDelayed,
eta: q.eta,
});
}
}
return {
tStarS: tStar,
tickS,
commands,
// Debugging telemetry — kept in the output so tests can introspect.
_plans: plans,
};
}
module.exports = { plan, DEFAULT_TICK_S };

View File

@@ -12,8 +12,17 @@ class nodeClass extends BaseNodeAdapter {
static tickInterval = null; static tickInterval = null;
static statusInterval = 1000; static statusInterval = 1000;
buildDomainConfig() { buildDomainConfig(uiConfig = {}) {
return {}; // 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 };
if (uiConfig.useRendezvous !== undefined) {
out.planner = { useRendezvous: uiConfig.useRendezvous };
}
return out;
} }
} }

View File

@@ -2,12 +2,16 @@
// //
// All real work lives in the concern modules under src/{groupOps,totals, // All real work lives in the concern modules under src/{groupOps,totals,
// combinatorics,optimizer,efficiency,dispatch,control}. This file stitches // combinatorics,optimizer,efficiency,dispatch,control}. This file stitches
// them together: child-event routing, demand serialization, mode/scaling, // them together: child-event routing, demand serialization, mode selection,
// and the per-mode dispatch switch. // and the per-mode dispatch switch.
//
// Operator demand is always passed in here as a canonical m³/s number. The
// set.demand handler resolves units (%, m³/h, l/s, etc.) before calling
// handleInput, so this orchestrator has no scaling state and no unit logic.
'use strict'; 'use strict';
const { BaseDomain, UnitPolicy, POSITIONS, interpolation, convert } = require('generalFunctions'); const { BaseDomain, UnitPolicy, POSITIONS, interpolation } = require('generalFunctions');
const GroupOperatingPoint = require('./groupOps/groupOperatingPoint'); const GroupOperatingPoint = require('./groupOps/groupOperatingPoint');
const groupCurves = require('./groupOps/groupCurves'); const groupCurves = require('./groupOps/groupCurves');
const TotalsCalculator = require('./totals/totalsCalculator'); const TotalsCalculator = require('./totals/totalsCalculator');
@@ -17,9 +21,30 @@ const GroupEfficiency = require('./efficiency/groupEfficiency');
const control = require('./control/strategies'); const control = require('./control/strategies');
const io = require('./io/output'); const io = require('./io/output');
const DemandDispatcher = require('./dispatch/demandDispatcher'); const DemandDispatcher = require('./dispatch/demandDispatcher');
const { buildProfile } = require('./movement/machineProfile');
const movementScheduler = require('./movement/movementScheduler');
const MovementExecutor = require('./movement/movementExecutor');
const ACTIVE_STATES = new Set(['operational', 'accelerating', 'decelerating']); const ACTIVE_STATES = new Set(['operational', 'accelerating', 'decelerating']);
// A machine in one of these states has settled — it is not mid-ramp and is
// not stepping through a start/stop sequence. Anything else (starting,
// warmingup, accelerating, decelerating, stopping, coolingdown) means the
// group is still converging on its last dispatched intent. Drives
// getMovementState(): 'ready' when every machine is settled, else 'working'.
const SETTLED_STATES = new Set(['operational', 'idle', 'off', 'maintenance', 'emergencystop']);
// Canonical mode names (camelCase). The dispatcher already lowercases for its
// switch, but we normalise at setMode so this.mode is always in the canonical
// form — keeps allowedActions/allowedSources lookups (which key on the
// canonical form) honest. Module-level so tests can import without spinning
// up a full MachineGroup instance.
const ALLOWED_MODES = ['optimalControl', 'priorityControl', 'maintenance'];
function _normaliseMode(input) {
const lc = String(input || '').toLowerCase();
return ALLOWED_MODES.find((m) => m.toLowerCase() === lc) || null;
}
class MachineGroup extends BaseDomain { class MachineGroup extends BaseDomain {
static name = 'machineGroupControl'; static name = 'machineGroupControl';
@@ -37,10 +62,32 @@ class MachineGroup extends BaseDomain {
// tests still write directly (matches the pumpingStation pattern). // tests still write directly (matches the pumpingStation pattern).
this.machines = {}; this.machines = {};
this.scaling = this.config.scaling.current; // Persisted flows may have stored the mode in lowercase (legacy editor
this.mode = this.config.mode.current; // behaviour); normalise at construction so allow-list lookups against
// the schema's camelCase keys work consistently. Fallback to
// optimalControl if the persisted value is missing/garbage so a typo
// doesn't quietly disable dispatch.
this.mode = _normaliseMode(this.config.mode.current) || 'optimalControl';
this.absDistFromPeak = 0; this.absDistFromPeak = 0;
this.relDistFromPeak = 0; this.relDistFromPeak = 0;
// Last operator demand resolved by _runDispatch. `null` until the first
// demand arrives so getOutput can omit the demand telemetry (the
// degraded / pre-first-demand state) rather than emit a misleading 0.
// { canonical: m³/s requested, clamped: m³/s after envelope clamp }.
this._lastDemand = null;
// Demand held by the movement gate while the group is 'working'. Latest
// wins; flushed by _maybeFlushPendingDemand once the group is 'ready'.
this._pendingDemand = null;
// Intent of the last dispatch that actually proceeded — recorded so a
// pressure-emergency re-dispatch can re-plan the SAME intent against
// the new envelope without inventing a setpoint.
this._lastDispatchedMode = null;
this._lastPriorityKey = JSON.stringify(null);
this._lastPriorityList = null;
// Pressure-emergency latch. Set when handlePressureChange fires a
// bypass dispatch; cleared once pressure falls back below threshold,
// so the (several-times-a-second) handler doesn't re-fire every tick.
this._emergencyLatched = false;
this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog: 0 }; 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 } }; this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
@@ -49,10 +96,20 @@ class MachineGroup extends BaseDomain {
// call that is later superseded resolves with { superseded: true }. // call that is later superseded resolves with { superseded: true }.
this._demandDispatcher = new DemandDispatcher( this._demandDispatcher = new DemandDispatcher(
{ logger: this.logger }, { logger: this.logger },
(payload) => this._runDispatch(payload.source, payload.demand, payload.powerCap, payload.priorityList), (payload) => this._runDispatch(payload.source, payload.demand, payload.powerCap, payload.priorityList, { emergency: payload.emergency === true }),
); );
this._shutdownInFlight = new Set(); this._shutdownInFlight = new Set();
// Tick-driven executor for the movement schedule produced by the
// planner. MGC owns the wall-clock setInterval that calls tick();
// the executor itself is pure (testable without timers).
this.movementExecutor = new MovementExecutor({
logger: this.logger,
fireCommand: (cmd) => this._fireSchedulerCommand(cmd),
});
this._executorTimer = null;
this._executorIntervalMs = 1000;
this.operatingPoint = new GroupOperatingPoint({ this.operatingPoint = new GroupOperatingPoint({
measurements: this.measurements, measurements: this.measurements,
machines: this.machines, machines: this.machines,
@@ -116,11 +173,30 @@ class MachineGroup extends BaseDomain {
} }
// ── Surface kept for tests + commands ────────────────────────────── // ── Surface kept for tests + commands ──────────────────────────────
setMode(mode) { this.mode = mode; this.notifyOutputChanged(); } // Mirror of rotatingMachine/src/specificClass.js:329-339 — same pattern,
setScaling(scaling) { // mode/source allow-lists live in this.config.mode (loaded from the
const allowed = new Set(this.defaultConfig.scaling.current.rules.values.map(v => v.value)); // schema as Set instances). Anything not declared in the schema is
if (allowed.has(scaling)) { this.scaling = scaling; this.notifyOutputChanged(); } // dropped silently with a warn-level log.
else this.logger.warn(`${scaling} is not a valid scaling option.`); isValidActionForMode(action, mode) {
const ok = !!this.config?.mode?.allowedActions?.[mode]?.has?.(action);
if (ok) this.logger.debug(`action '${action}' allowed in mode '${mode}'`);
else this.logger.warn(`action '${action}' not allowed in mode '${mode}'`);
return ok;
}
isValidSourceForMode(source, mode) {
const ok = !!this.config?.mode?.allowedSources?.[mode]?.has?.(source);
if (ok) this.logger.debug(`source '${source}' allowed in mode '${mode}'`);
else this.logger.warn(`source '${source}' not allowed in mode '${mode}'`);
return ok;
}
setMode(mode) {
const canonical = _normaliseMode(mode);
if (!canonical) {
this.logger.warn(`Invalid mode '${mode}'. Allowed: ${ALLOWED_MODES.join(', ')}`);
return;
}
this.mode = canonical;
this.notifyOutputChanged();
} }
isMachineActive(id) { isMachineActive(id) {
const s = this.machines[id]?.state?.getCurrentState?.(); const s = this.machines[id]?.state?.getCurrentState?.();
@@ -162,6 +238,97 @@ class MachineGroup extends BaseDomain {
const eff = this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue() ?? null; const eff = this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue() ?? null;
this.calcDistanceBEP(eff, maxEfficiency, lowestEfficiency); this.calcDistanceBEP(eff, maxEfficiency, lowestEfficiency);
this.notifyOutputChanged(); this.notifyOutputChanged();
// Emergency bypass: a pressure excursion pre-empts the rendezvous lock
// and re-plans the last intent against the new envelope immediately.
// Inert until planner.emergencyPressurePa is configured (see
// _pressureEmergency). Latched so we fire once per excursion, not every
// tick; the latch clears when pressure falls back below threshold.
if (this._pressureEmergency()) {
if (!this._emergencyLatched && Number.isFinite(this._lastDemand?.canonical)) {
this._emergencyLatched = true;
this.logger.warn(`Pressure emergency — pre-empting rendezvous, re-planning last demand ${this._lastDemand.canonical.toFixed(3)}.`);
Promise.resolve(this._demandDispatcher.fireAndWait({
source: 'pressure-emergency',
demand: this._lastDemand.canonical,
powerCap: Infinity,
priorityList: this._lastPriorityList,
emergency: true,
})).catch((e) => this.logger?.error?.(`emergency dispatch failed: ${e?.message || e}`));
}
} else {
this._emergencyLatched = false;
}
// Group may have just settled — release any demand the lock is holding.
this._maybeFlushPendingDemand();
}
// Aggregate movement status of the group:
// 'working' — at least one machine is mid-ramp, has a queued setpoint
// (delayedMove), still has move time left, OR the executor
// has scheduled commands that haven't fired yet.
// 'ready' — every machine has settled; a fresh demand can be dispatched
// cleanly without interrupting an in-flight move.
// Surfaced as telemetry (out.movementState) and used by the dispatch gate
// to hold non-urgent demand until the group is ready, instead of aborting
// ramps on every incoming demand (which froze pumps at 0 — connected
// devices must never be able to do that). Urgent demand still pre-empts.
getMovementState() {
const machines = Object.values(this.machines);
if (machines.length === 0) return 'ready';
if (typeof this.movementExecutor?.pending === 'function' && this.movementExecutor.pending() > 0) {
return 'working';
}
for (const m of machines) {
const st = m?.state?.getCurrentState?.();
if (st && !SETTLED_STATES.has(st)) return 'working';
if (m?.state?.delayedMove != null) return 'working';
if ((m?.state?.getMoveTimeLeft?.() ?? 0) > 0) return 'working';
}
return 'ready';
}
// May this demand pre-empt an in-flight rendezvous? Only an EMERGENCY may —
// a committed rendezvous is otherwise locked, and ordinary new setpoints
// (any size, mode/priority changes included) are deferred and dispatched
// sequentially once the group is 'ready' (_maybeFlushPendingDemand). This
// is what stops a re-plan from re-deferring a pump that's mid-sequence
// (which parked starting pumps at minimum flow → the staging bump).
// • a stop (≤0) is always an emergency — never make the operator wait;
// • the first demand (no prior intent) must proceed or nothing ever runs;
// • a pressure excursion (opts.emergency, raised by handlePressureChange)
// pre-empts so rising discharge pressure is actioned immediately.
// Everything else returns false → defer.
_isEmergencyDemand(demandQ, opts = {}) {
if (!(demandQ > 0)) return true;
if (this._lastDemand?.canonical == null) return true;
return opts.emergency === true;
}
// Pressure-excursion detector for the emergency bypass. Returns true when
// the resolved header pressure breaches a configured safety threshold.
// INERT BY DEFAULT: with no `planner.emergencyPressurePa` set, this always
// returns false — the bypass mechanism is wired and tested but never fires
// until a real threshold is configured. (Rate-of-rise can be added here
// later behind its own config key without touching the call sites.)
_pressureEmergency() {
const absPa = Number(this.config?.planner?.emergencyPressurePa);
if (!Number.isFinite(absPa) || absPa <= 0) return false;
const p = this.operatingPoint?.headerDiffPa;
return Number.isFinite(p) && p >= absPa;
}
// Dispatch a demand held by the movement gate, once the group has settled.
// Driven off handlePressureChange (fires several times/s), so a held demand
// is applied promptly when the last ramp completes. Routed back through the
// latest-wins dispatcher so a demand arriving in the same window still wins.
_maybeFlushPendingDemand() {
if (!this._pendingDemand) return;
if (this.getMovementState() !== 'ready') return;
const p = this._pendingDemand;
this._pendingDemand = null;
this.logger.debug(`Group 'ready' — dispatching held demand ${Number(p.demand).toFixed(3)}.`);
Promise.resolve(this._demandDispatcher.fireAndWait(p))
.catch((e) => this.logger?.error?.(`deferred dispatch failed: ${e?.message || e}`));
} }
async abortActiveMovements(reason = 'new demand') { async abortActiveMovements(reason = 'new demand') {
@@ -214,23 +381,91 @@ class MachineGroup extends BaseDomain {
// INTENT lands on AT_EQUIPMENT only; DOWNSTREAM is the live aggregate. // INTENT lands on AT_EQUIPMENT only; DOWNSTREAM is the live aggregate.
this.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestPower, this.unitPolicy.canonical.power); this.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestPower, this.unitPolicy.canonical.power);
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestFlow, this.unitPolicy.canonical.flow); this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestFlow, this.unitPolicy.canonical.flow);
this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestFlow / bestResult.bestPower); // Hydraulic efficiency η = (Q·ΔP)/P_shaft — a dimensionless 0..1
// ratio in the same scale as each child rotatingMachine's `cog`.
// Keeps `calcDistanceBEP(eff, maxEfficiency, lowestEfficiency)` in
// handlePressureChange comparing apples to apples.
const dP = this.operatingPoint.headerDiffPa;
if (Number.isFinite(dP) && dP > 0 && bestResult.bestPower > 0) {
this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT)
.value((bestResult.bestFlow * dP) / bestResult.bestPower);
}
this.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestCog); this.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestCog);
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => { const distribution = bestResult.bestCombination.map((it) => ({ machineId: String(it.machineId), flow: it.flow }));
const pumpInfo = bestResult.bestCombination.find(it => it.machineId == id); await this._dispatchFlowDistribution(distribution);
const flow = pumpInfo ? pumpInfo.flow : 0; }
const state = machineStates[id];
// flowmovement BEFORE startup so concurrent retargets update // Shared dispatch path used by every control strategy. Takes a flow
// delayedMove without a stale chained flowmovement landing // distribution {machineId, flow}[] and routes it through the planner
// post-startup — see idle-startup-deadlock Scenario 4. // and executor. Same-time-landing (rendezvous) is the default and can
if (flow > 0) { // be turned off via config.planner.useRendezvous, in which case every
await machine.handleInput('parent', 'flowmovement', this._canonicalToOutputFlow(flow)); // command fires at tick 0 (legacy fire-and-forget behaviour, like the
if (state === 'idle') await machine.handleInput('parent', 'execsequence', 'startup'); // pre-planner equalFlowControl).
} else if (ACTIVE_STATES.has(state)) { async _dispatchFlowDistribution(distribution) {
await machine.handleInput('parent', 'execsequence', 'shutdown'); const profiles = Object.values(this.machines).map((m) => buildProfile(m));
const headerPa = Number.isFinite(this.operatingPoint.headerDiffPa) ? this.operatingPoint.headerDiffPa : 0;
const useRendezvous = this.config?.planner?.useRendezvous !== false; // default true
const schedule = movementScheduler.plan(profiles, distribution, headerPa, { tickS: 1, useRendezvous });
this.movementExecutor.replan(schedule);
// AWAIT the first tick to preserve the race-favouring behaviour
// of the original code. The new move's full chain (residue
// handler → operational → ramp) settles before _runDispatch
// returns; the in-flight shutdown sequence's for-loop runs on
// other microtasks but its invalid-transition exits truncate it.
await this.movementExecutor.tick();
this._ensureExecutorTimer();
if (this.logger?.debug) {
this.logger.debug(`MGC planner: ${schedule.commands.length} commands queued, tStar=${schedule.tStarS.toFixed(1)}s, rendezvous=${useRendezvous}`);
}
}
// Dispatch one scheduled command to the appropriate child. Returns
// synchronously — the underlying handleInput is fire-and-forget from
// the executor's perspective, mirroring the existing optimal-control
// behaviour where commands are scheduled, not awaited.
_fireSchedulerCommand(cmd) {
const machine = this.machines[cmd.machineId];
if (!machine) {
this.logger?.warn?.(`Scheduler fired ${cmd.action} for unknown machine ${cmd.machineId}`);
return undefined;
}
const handle = typeof machine.handleInput === 'function' ? machine.handleInput.bind(machine) : null;
if (!handle) return undefined;
if (cmd.action === 'execsequence') {
return Promise.resolve(handle('parent', 'execsequence', cmd.sequence))
.catch((e) => this.logger?.error?.(`execsequence ${cmd.sequence} on ${cmd.machineId} failed: ${e?.message || e}`));
}
if (cmd.action === 'flowmovement') {
const outFlow = this._canonicalToOutputFlow(cmd.flow);
return Promise.resolve(handle('parent', 'flowmovement', outFlow))
.catch((e) => this.logger?.error?.(`flowmovement on ${cmd.machineId} failed: ${e?.message || e}`));
}
return undefined;
}
// Wall-clock driver for the executor. Auto-stops when there's nothing
// pending so we don't burn a forever-running setInterval.
_ensureExecutorTimer() {
if (this._executorTimer) return;
this._executorTimer = setInterval(() => {
this.movementExecutor.tick();
if (this.movementExecutor.pending() === 0) {
clearInterval(this._executorTimer);
this._executorTimer = null;
} }
})); }, this._executorIntervalMs);
// Unref so the timer doesn't keep Node-RED alive on shutdown.
if (typeof this._executorTimer.unref === 'function') this._executorTimer.unref();
}
// Stop the executor's wall-clock driver. Called from teardown paths.
_stopExecutorTimer() {
if (this._executorTimer) {
clearInterval(this._executorTimer);
this._executorTimer = null;
}
} }
// Returns when THIS call's dispatch settles. If overwritten by a later // Returns when THIS call's dispatch settles. If overwritten by a later
@@ -240,33 +475,89 @@ class MachineGroup extends BaseDomain {
return this._demandDispatcher.fireAndWait({ source, demand, powerCap, priorityList }); return this._demandDispatcher.fireAndWait({ source, demand, powerCap, priorityList });
} }
async _runDispatch(source, demand, powerCap, priorityList) { // Operator-style entry point: accepts a (value, unit) pair and resolves
// to canonical m³/s before delegating to handleInput. Single source of
// truth for the unit math shared by the set.demand command handler and
// by parent nodes (e.g. pumpingStation level-based control) that hold a
// direct reference to this specificClass and need to push a % demand
// without re-implementing the interpolation. Negative value is the
// stop-all signal regardless of unit.
async setDemand(value, unit = '%') {
const v = Number(value);
if (!Number.isFinite(v)) {
this.logger?.error?.(`setDemand: invalid value '${value}'`);
return undefined;
}
if (v < 0) {
await this.turnOffAllMachines();
return undefined;
}
let canonical;
if (unit === '%') {
const dt = this.calcDynamicTotals();
canonical = this.interpolation.interpolate_lin_single_point(
v, 0, 100, dt.flow.min, dt.flow.max);
} else {
try {
canonical = this.unitPolicy.convert(v, unit, 'm3/s', 'setDemand absolute flow');
} catch (err) {
this.logger?.error?.(`setDemand: cannot convert ${v} ${unit} -> m3/s: ${err?.message || err}`);
return undefined;
}
}
return this.handleInput('parent', canonical);
}
async _runDispatch(source, demand, powerCap, priorityList, opts = {}) {
const demandQ = parseFloat(demand); const demandQ = parseFloat(demand);
if (!Number.isFinite(demandQ)) { if (!Number.isFinite(demandQ)) {
this.logger.error(`Invalid flow demand input: ${demand}.`); this.logger.error(`Invalid flow demand input: ${demand}.`);
return; return;
} }
// Demand is canonical m³/s (the handler has already resolved units).
// The handler routes negatives directly to turnOffAllMachines, but
// keep a defensive check in case turnOff-state arrives some other way.
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
// Rendezvous lock. While the group is still converging on its committed
// plan ('working'), an ordinary new setpoint is NOT applied — it is
// remembered (latest wins) and dispatched sequentially once the group
// reports 'ready' (_maybeFlushPendingDemand, off handlePressureChange).
// This keeps a re-plan from dropping the in-flight schedule and
// re-deferring a pump that's mid-sequence — which parked starting pumps
// at minimum flow (the staging bump). Only an EMERGENCY (stop, or a
// pressure excursion flagged via opts.emergency) pre-empts.
if (this.getMovementState() === 'working' && !this._isEmergencyDemand(demandQ, opts)) {
this._pendingDemand = { source, demand: demandQ, powerCap, priorityList };
this.logger.debug(`Demand ${demandQ.toFixed(3)} held — rendezvous locked ('working'); will dispatch when 'ready'.`);
return;
}
this._pendingDemand = null;
// Record the intent now driving the group, so a pressure-emergency
// re-dispatch can re-plan the same intent against the new envelope.
this._lastDispatchedMode = this.mode;
this._lastPriorityKey = JSON.stringify(priorityList ?? null);
this._lastPriorityList = priorityList ?? null;
await this.abortActiveMovements('new demand received'); await this.abortActiveMovements('new demand received');
const dt = this.calcDynamicTotals(); const dt = this.calcDynamicTotals();
let demandQout = 0; // Clamp against the current-pressure envelope.
let demandQout = demandQ;
if (demandQout < dt.flow.min) demandQout = dt.flow.min;
else if (demandQout > dt.flow.max) demandQout = dt.flow.max;
if (this.scaling === 'absolute') { // Record what the operator asked for (canonical) and the setpoint we
if (demandQ <= 0) { await this.turnOffAllMachines(); return; } // actually drive after the current-pressure envelope clamp. getOutput
if (demandQ < this.absoluteTotals.flow.min) demandQout = this.absoluteTotals.flow.min; // turns this into the demand telemetry the dashboard overlays on the
else if (demandQ > this.absoluteTotals.flow.max) demandQout = this.absoluteTotals.flow.max; // total-flow graph (resolved flow setpoint + % of group capacity).
else demandQout = demandQ; this._lastDemand = { canonical: demandQ, clamped: demandQout };
} else if (this.scaling === 'normalized') {
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
demandQout = this.interpolation.interpolate_lin_single_point(demandQ, 0, 100, dt.flow.min, dt.flow.max);
}
// Normalize for the switch — schema enum values use camelCase
// (optimalControl, priorityControl) while legacy callers send
// lowercase. Accept both rather than silently falling through.
const ctx = { mgc: this }; const ctx = { mgc: this };
switch (this.mode) { switch (String(this.mode || '').toLowerCase()) {
case 'prioritycontrol': await control.equalFlowControl(ctx, demandQout, powerCap, priorityList); break; case 'prioritycontrol': await control.equalFlowControl(ctx, demandQout, powerCap, priorityList); break;
case 'prioritypercentagecontrol':
if (this.scaling !== 'normalized') { this.logger.warn('Priority percentage control needs normalized scaling.'); return; }
await control.prioPercentageControl(ctx, demandQout, priorityList);
break;
case 'optimalcontrol': await this._optimalControl(demandQout, powerCap); break; case 'optimalcontrol': await this._optimalControl(demandQout, powerCap); break;
default: this.logger.warn(`${this.mode} is not a valid mode.`); default: this.logger.warn(`${this.mode} is not a valid mode.`);
} }
@@ -281,6 +572,8 @@ class MachineGroup extends BaseDomain {
// Cancel any parked demand — turnOff is latest user intent so a // Cancel any parked demand — turnOff is latest user intent so a
// pending fireAndWait must not re-engage pumps post-shutdown. // pending fireAndWait must not re-engage pumps post-shutdown.
this._demandDispatcher.cancelPending(); this._demandDispatcher.cancelPending();
// Demand resolved to "stop": reflect 0 setpoint in the telemetry.
this._lastDemand = { canonical: 0, clamped: 0 };
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => { await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
if (this._shutdownInFlight.has(id)) return; if (this._shutdownInFlight.has(id)) return;
if (this.isMachineActive(id)) { if (this.isMachineActive(id)) {
@@ -298,10 +591,12 @@ class MachineGroup extends BaseDomain {
} }
_canonicalToOutputFlow(value) { _canonicalToOutputFlow(value) {
const from = this.unitPolicy.canonical.flow; return this.unitPolicy.convert(
const to = this.unitPolicy.output.flow; value,
if (!from || !to || from === to) return value; this.unitPolicy.canonical.flow,
return convert(value).from(from).to(to); this.unitPolicy.output.flow,
'canonical->output flow',
);
} }
getOutput() { return io.getOutput(this); } getOutput() { return io.getOutput(this); }
@@ -309,3 +604,6 @@ class MachineGroup extends BaseDomain {
} }
module.exports = MachineGroup; module.exports = MachineGroup;
// Module-level helpers exposed for unit tests.
module.exports._normaliseMode = _normaliseMode;
module.exports.ALLOWED_MODES = ALLOWED_MODES;

180
test/_output-manifest.md Normal file
View File

@@ -0,0 +1,180 @@
# machineGroupControl — Output Manifest
Per `.claude/rules/output-coverage.md`. Single source of truth for what MGC
emits on Port 0/1/2, where the value comes from, and which test exercises it
in populated AND degraded states.
**Convention for missing values:** keys are **absent** when the underlying
source has not produced a value yet (pre-first-tick, no demand, no pressure).
Once produced, a key may be **explicitly null/undefined** only in the
documented degenerate cases below. The dashboard formatter must treat both
absent and null/undefined as "no data" (display `'—'`) — see the
`pct`/`num` helpers in `examples/02-Dashboard.json :: fn_status_split`.
---
## Port 0 — process data
Built by `src/io/output.js :: getOutput(mgc)`. Delta-compressed by
`outputUtils.formatMsg(..., 'process')` — only changed keys appear in each emit.
### Static fields (always emitted once MGC has been initialised)
| Key | Source | Type / Range | Populated test | Degraded test |
|---|---|---|---|---|
| `mode` | `mgc.mode` (set via `set.mode` command; normalised by `specificClass.setMode`) | string ∈ {`optimalControl`, `priorityControl`, `maintenance`} (canonical camelCase) | commands.basic.test.js, ncog-distribution.integration.test.js | n/a — always set from constructor default |
| `scaling` | `mgc.scaling` | string ∈ {`absolute`, `normalized`} or undefined | commands.basic.test.js | dashboard-fanout (undefined → raw-rows shows '—') |
| `absDistFromPeak` | `groupEfficiency.calcDistanceFromPeak` (specificClass.js:132) | number ≥ 0 (η-points) | bep-distance-demand-sweep, group-bep-cascade, groupEfficiency.basic | groupEfficiency.basic test 7 (undefined when current = null) |
| `relDistFromPeak` | `groupEfficiency.calcRelativeDistanceFromPeak` | number ∈ [0,1] **OR `undefined`** for degenerate (homogeneous pumps) | bep-distance-demand-sweep, group-bep-cascade | groupEfficiency.basic tests 5/6/7 (undefined cases), dashboard-fanout test 11 (undefined → '—' display) |
| `flowCapacityMax` | `mgc.dynamicTotals.flow.max` (totalsCalculator), **converted to `unitPolicy.output.flow` (m³/h)** in output.js:62 | number m³/h ≥ 0; `0` when envelope unresolved (Infinity/NaN) | totalsCalculator.basic, dashboard-fanout (post-setup), demand-telemetry.basic | absent until first equalize; dashboard-fanout (state A); demand-telemetry (Infinity → 0) |
| `flowCapacityMin` | `mgc.dynamicTotals.flow.min`, **converted to output flow unit (m³/h)** | number m³/h ≥ 0; `0` when unresolved | totalsCalculator.basic, demand-telemetry.basic | same as above |
| `demandFlow` | `mgc._lastDemand.clamped` (set in `_runDispatch`, output.js:62) | number, canonical m³/s clamped to envelope, converted to `unitPolicy.output.flow` | demand-telemetry.basic (populated) | demand-telemetry.basic (absent before first demand); turnOff → 0 |
| `demandPct` | derived `(clamped flow.min)/(flow.max flow.min)·100` (output.js:62) | number ∈ [0,100], `0` when capacity span ≤ 0 | demand-telemetry.basic (populated) | demand-telemetry.basic (absent before first demand) |
| `machineCount` | `Object.keys(mgc.machines).length` | integer ≥ 0 | demand-cycle-walkthrough, ncog-distribution | n/a — always reflects current registration count |
| `machineCountActive` | filtered count excluding `off`/`maintenance` states | integer ≥ 0 | demand-cycle-walkthrough, ncog-distribution | dashboard-fanout (state A: 0 active) |
| `movementState` | `mgc.getMovementState()` (specificClass) — `'working'` while any child is ramping/sequencing or the executor has pending commands, else `'ready'` | string `'working'`\|`'ready'`, never null | movement-gate.basic (working: accelerating/warmingup/delayedMove/moveTimeLeft/executor-pending) | movement-gate.basic (ready: no machines, all settled) |
### Conditional pressure-header fields (emitted only when equalize resolved a positive ΔP)
| Key | Source | Type / Range | Populated test | Degraded test |
|---|---|---|---|---|
| `headerDiffPa` | `mgc.operatingPoint.headerDiffPa` (groupOperatingPoint.equalize) | number Pa > 0 | groupOperatingPoint.basic, dashboard-fanout (state B/C) | dashboard-fanout (state A — absent) |
| `headerDiffMbar` | derived `headerDiffPa / 100` when `unitPolicy.output.pressure === 'mbar'` | number mbar > 0 | dashboard-fanout (state B/C) | absent when output pressure unit ≠ mbar — **not explicitly tested** |
### Dynamic measurement fields — pattern `{position}_{variant}_{type}`
Built by the loop at `io/output.js:23-39`. For each type×variant×position the
container holds, one key is emitted **only if the value is non-null**.
Positions: `downstream`, `upstream`, `atEquipment`. Plus `differential_<variant>_<type>` when both `downstream` and `upstream` exist.
**Predicted measurements MGC writes itself (via writeOwn):**
| Key | Source (write site) | Type / Range | Populated test | Degraded test |
|---|---|---|---|---|
| `atEquipment_predicted_flow` | `handlePressureChange` (specificClass:153), `_optimalControl` (specificClass:214), `equalFlowControl` (control/strategies:118), `turnOffAllMachines` (specificClass:297) | number, canonical m³/s converted to `unitPolicy.output.flow` | bep-distance-demand-sweep, dashboard-fanout (state B/C), ncog-distribution | dashboard-fanout (state A: absent), turnoff-deadlock (post-shutdown = 0) |
| `downstream_predicted_flow` | `handlePressureChange` (specificClass:156 — mirrors AT_EQUIPMENT for PS contract), `turnOffAllMachines` (specificClass:296) | same as above | implicit in bep-distance-demand-sweep getOutput | turnoff-deadlock (post-shutdown = 0) |
| `atEquipment_predicted_power` | same call sites as flow (specificClass:157, 213; strategies:117; specificClass:298) | number, canonical W converted to `unitPolicy.output.power` | bep-distance-demand-sweep, dashboard-fanout, distribution-power-table | turnoff-deadlock (= 0) |
| `atEquipment_predicted_efficiency` | `_optimalControl` (specificClass:221), `equalFlowControl` (strategies:122) — only when `dP > 0 && bestPower > 0` | number ∈ [0, 1] hydraulic η = (Q·ΔP)/P | bep-distance-demand-sweep, dashboard-fanout (state C) | **absent** when dP ≤ 0 or bestPower ≤ 0 — guarded but not explicitly tested |
| `atEquipment_predicted_Ncog` | `_optimalControl` (specificClass:224), `equalFlowControl` (strategies:125) | number, range **0..N where N = active pumps** (SUM of per-pump NCog from `bepGravitation.js:162` totalCog) — NOT 0..1; see [[project-mgc-bep-metrics-semantics]] | ncog-distribution (9 tests), bep-distance-demand-sweep, dashboard-fanout (state C) | dashboard-fanout normalizes by `machineCountActive` for display — tests 6/7/8/9/10 |
**Measured pressures forwarded from children:**
MGC subscribes to each registered measurement child (specificClass.js:91-104)
and re-emits the child's reading on its own `MeasurementContainer`. If a
pressure measurement child registers at position `downstream`, MGC will
emit `downstream_measured_pressure` on Port 0 the next time `getOutput` runs.
| Key pattern | Source | Tests |
|---|---|---|
| `<position>_measured_<type>` | child measurement node forwarded via `MeasurementContainer.emitter` (specificClass:91-105) | indirect — group-bep-cascade.integration drives pressure events through registered children; not asserted as a named output key |
| `differential_measured_pressure` | computed when both `downstream_measured_pressure` and `upstream_measured_pressure` exist (output.js:33-37) | indirect via dashboard-fanout (used by fn_qh_point for header ΔP fallback) |
---
## Port 1 — InfluxDB telemetry
Built by `outputUtils.formatMsg(..., 'influxdb')` — same `getOutput` source,
different formatter. Emits the same key set as Port 0 with InfluxDB
line-protocol tag/field discipline (cardinality rules per `.claude/rules/telemetry.md`).
| Concern | Status |
|---|---|
| Keys | Identical to Port 0; the influxdb formatter (`generalFunctions/src/helper/formatters/influxdbFormatter.js`) decides which become tags vs fields. |
| Test coverage | **None.** No test file imports/asserts the influxdb formatter for MGC. Regression vector if a key is added/renamed without checking cardinality. Tracked. |
---
## Port 2 — registration / control plumbing
Emitted on startup by `BaseNodeAdapter` (one message per node).
| Topic | Payload shape | Source | Tests |
|---|---|---|---|
| `registerChild` | `{ id: node.id, positionVsParent: <string> }` | BaseNodeAdapter init — sends to upstream parent so it can subscribe to this node's measurements | structure-examples.integration, commands.basic.test.js test 5 (`child.register`) — receiver side |
---
## Events emitted on `mgc.source.measurements.emitter`
These are NOT Port 0/1/2 emissions — they're in-process events that downstream
EVOLV nodes (e.g., pumpingStation) subscribe to via the parent-child handshake.
Listed here for completeness; covered by `.claude/rules/telemetry.md` rather
than this manifest.
- `flow.predicted.atequipment` — fired on every `writeOwn` to flow/predicted/AT_EQUIPMENT
- `flow.predicted.downstream` — fired on every `writeOwn` to flow/predicted/DOWNSTREAM (the live aggregate the PS subscribes to)
- `power.predicted.atequipment`
- `efficiency.predicted.atequipment`
- `Ncog.predicted.atequipment`
- `<type>.measured.<position>` — re-emit of any registered measurement child
Documented in `CONTRACT.md`; tested indirectly via `group-bep-cascade.integration.test.js` and `ncog-distribution.integration.test.js`.
---
## Example flow fan-out — `examples/02-Dashboard.json :: fn_status_split` (outputs: 18)
Delta-caches Port 0 then fans one msg per dashboard widget. Charts return the
whole msg as `null` (drop the output) when their source is missing — never
`{ payload: null }`. All ports covered by `test/integration/dashboard-fanout.integration.test.js`.
| # | Target widget | Topic / payload | Populated | Degraded (missing source) |
|---|---|---|---|---|
| 0 | ui_txt_mode | string | ✔ State C | ✔ State A → mode string |
| 1 | ui_txt_flow | `'… m³/h'` | ✔ | ✔ State A → `—` |
| 2 | ui_txt_power | `'… kW'` | ✔ | ✔ → `—` |
| 3 | ui_txt_capacity | `'min max m³/h'` | ✔ State B | ✔ → `—` |
| 4 | ui_txt_machines | `'nAct / nTot'` | ✔ | ✔ → `—` |
| 5 | ui_txt_bep (rel%) | `'… %'` | ✔ | ✔ null/undefined → `—` |
| 6 | ui_txt_eta | `'… %'` | ✔ | ✔ → `—` |
| 7 | ui_txt_eta_peak | `'… %'` | ✔ | ✔ → `—` |
| 8 | ui_txt_bep_abs | `'…'` (η pts, 3dp) | ✔ | ✔ → `—` |
| 9 | ui_txt_ncog | `'… %'` (sum/nAct) | ✔ | ✔ nAct=0/missing → `—` |
| 10 | ui_chart_flow | `{topic:'Flow', payload:number}` | ✔ | ✔ → null (drop) |
| 11 | ui_chart_flow (capacity) | `{topic:'Capacity', …}` | ✔ | ✔ → null |
| 12 | ui_chart_power | `{topic:'Power', …}` | ✔ | ✔ → null |
| 13 | ui_chart_bep | `{topic:'BEP rel %', ×100}` | ✔ | ✔ → null |
| 14 | ui_chart_eta | `{topic:'η (%)', ×100}` | ✔ | ✔ → null |
| 15 | ui_tpl_raw | `[{key,value}]` rows | ✔ | ✔ |
| 16 | ui_chart_qh (passthrough) | raw `msg.payload` | ✔ | ✔ |
| 17 | ui_chart_mgc_pctcap | `{topic:'% of capacity', payload:flow/capMax×100}` | ✔ State C | ✔ State A → null (drop) |
## Example flow fan-out — `examples/02-Dashboard.json :: fn_chart_pump_a/b/c` (outputs: 2 each)
Each per-pump fan-out delta-caches the pump's Port 0 then emits two chart msgs.
The ctrl output carries a **-1 OFF sentinel**: when the cached pump `state` is
`off` / `idle` / `maintenance` the pump is not running, so it plots `-1` (below
the 0100 band) — a clear OFF rail distinct from a pump genuinely running at 0%.
`ui_chart_pumps_ctrl` has `ymin: "-5"` so the sentinel is visible. Charts return
the whole msg as `null` (drop the output) when their source is missing — never
`{ payload: null }`. All ports covered by
`test/integration/per-pump-ctrl-fanout.integration.test.js`.
| # | Target chart | Topic / payload | Populated | Degraded |
|---|---|---|---|---|
| 0 | ui_chart_per_pump_flow | `{topic:'Pump A/B/C', payload:flow m³/h}` | ✔ running state | ✔ no `flow.predicted.downstream.*` key → null (drop) |
| 1 | ui_chart_pumps_ctrl | `{topic:'Pump A/B/C', payload:ctrl%}`, or `payload:-1` when state ∈ {off,idle,maintenance} | ✔ running → +ctrl; ✔ off/idle/maintenance → -1 | ✔ no state + ctrl missing/NaN/null → null (drop); ✔ ctrl-only delta keeps cached OFF state |
`fn_chart_total` (outputs: 1) feeds the same flow chart with the group total
(`downstream_predicted_flow ?? atEquipment_predicted_flow`); returns `null` when
both are absent.
## Coverage gaps (open items)
These are known holes flagged during the 2026-05-14 governance review; not yet
fixed but documented so they don't regress silently.
1. **Port 1 (InfluxDB) has no dedicated tests.** Any rename of a Port 0 key
should add an explicit Port 1 assertion to prevent silent cardinality
regressions.
2. **`headerDiffMbar` only emitted when `unitPolicy.output.pressure === 'mbar'`.**
The fallback (non-mbar configurations) isn't explicitly tested.
3. **`atEquipment_predicted_efficiency` absent-state isn't asserted.** The
`dP > 0 && bestPower > 0` guard exists but no test pins the absence.
4. **Forwarded measured measurements** (`<position>_measured_<type>`) aren't
asserted as named output keys — only their underlying behaviour is exercised.
5. **`scaling` undefined behaviour** — schema removed `scaling.current` for
several modes; what MGC emits for those is implicit, not tested.
When any of these is closed, move the row up into the appropriate table and
delete the entry here.

View File

@@ -22,23 +22,76 @@ function makeLogger() {
}; };
} }
function makeSource({ name = 'mgc-1', handleInputResult = undefined } = {}) { function makeSource({
name = 'mgc-1',
handleInputResult = undefined,
dt = { flow: { min: 0, max: 100 } },
// Initial mode for the fake. Defaults to optimalControl so gates pass for
// the historical tests; per-test override via the returned `source.mode = …`.
mode = 'optimalControl',
// Override the gate decisions. Default-true matches the no-gating world
// tests assumed before this change; negative-path tests pass functions that
// return false for specific actions / sources.
isValidActionForMode = () => true,
isValidSourceForMode = () => true,
} = {}) {
const calls = { const calls = {
setMode: [], setMode: [],
setScaling: [],
handleInput: [], handleInput: [],
registerChild: [], registerChild: [],
turnOffAllMachines: 0,
gateAction: [],
gateSource: [],
}; };
const source = { const source = {
logger: makeLogger(), logger: makeLogger(),
config: { general: { name } }, config: { general: { name } },
setMode: (m) => calls.setMode.push(m), mode,
setScaling: (s) => calls.setScaling.push(s), setMode: (m) => { calls.setMode.push(m); /* keep fake.mode unchanged unless test does it */ },
isValidActionForMode: (action, m) => {
const ok = isValidActionForMode(action, m);
calls.gateAction.push({ action, mode: m, ok });
if (!ok) source.logger.warn(`action '${action}' not allowed in mode '${m}'`);
return ok;
},
isValidSourceForMode: (src, m) => {
const ok = isValidSourceForMode(src, m);
calls.gateSource.push({ src, mode: m, ok });
if (!ok) source.logger.warn(`source '${src}' not allowed in mode '${m}'`);
return ok;
},
handleInput: async (src, demand) => { handleInput: async (src, demand) => {
calls.handleInput.push({ src, demand }); calls.handleInput.push({ src, demand });
if (handleInputResult instanceof Error) throw handleInputResult; if (handleInputResult instanceof Error) throw handleInputResult;
return handleInputResult; return handleInputResult;
}, },
// Mirror of the real specificClass.setDemand: resolves unit -> canonical
// m³/s and forwards to handleInput. With dt.flow {min:0,max:100} the %
// interpolation is identity, so a bare numeric demand round-trips through
// handleInput unchanged — keeping the existing assertions stable.
setDemand: async (value, unit = '%') => {
const v = Number(value);
if (!Number.isFinite(v)) return undefined;
if (v < 0) { await source.turnOffAllMachines(); return undefined; }
let canonical;
if (unit === '%') {
canonical = source.interpolation.interpolate_lin_single_point(
v, 0, 100, dt.flow.min, dt.flow.max);
} else {
const { convert } = require('generalFunctions');
canonical = convert(v).from(unit).to('m3/s');
}
return source.handleInput('parent', canonical);
},
// Retained for completeness — the mock setDemand uses these internally.
calcDynamicTotals: () => dt,
interpolation: {
interpolate_lin_single_point: (x, ix, iy, ox, oy) => {
if (iy === ix) return ox;
return ox + ((x - ix) * (oy - ox)) / (iy - ix);
},
},
turnOffAllMachines: async () => { calls.turnOffAllMachines += 1; },
childRegistrationUtils: { childRegistrationUtils: {
registerChild: (childSource, position) => registerChild: (childSource, position) =>
calls.registerChild.push({ childSource, position }), calls.registerChild.push({ childSource, position }),
@@ -69,14 +122,31 @@ test('canonical topics dispatch to their handlers', async () => {
await reg.dispatch({ topic: 'set.mode', payload: 'prioritycontrol' }, source, makeCtx()); await reg.dispatch({ topic: 'set.mode', payload: 'prioritycontrol' }, source, makeCtx());
assert.deepEqual(calls.setMode, ['prioritycontrol']); assert.deepEqual(calls.setMode, ['prioritycontrol']);
await reg.dispatch({ topic: 'set.scaling', payload: 'normalized' }, source, makeCtx()); // bare-number demand → interpreted as % → interpolated against dt.flow.
assert.deepEqual(calls.setScaling, ['normalized']); // Default test dt is {min:0,max:100} so % is identity.
await reg.dispatch({ topic: 'set.demand', payload: '12.5' }, source, makeCtx()); await reg.dispatch({ topic: 'set.demand', payload: '12.5' }, source, makeCtx());
assert.equal(calls.handleInput.length, 1); assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 12.5 }); assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 12.5 });
}); });
test('set.demand with explicit flow unit converts to canonical m³/s', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: { value: 200, unit: 'm3/h' } }, source, makeCtx());
assert.equal(calls.handleInput.length, 1);
// 200 m³/h = 0.0555... m³/s
assert.ok(Math.abs(calls.handleInput[0].demand - 0.05555555555555556) < 1e-9,
`expected ~0.0556 m³/s, got ${calls.handleInput[0].demand}`);
});
test('set.demand negative value triggers turnOffAllMachines and bypasses handleInput', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: -1 }, source, makeCtx());
assert.equal(calls.turnOffAllMachines, 1);
assert.equal(calls.handleInput.length, 0);
});
test('child.register canonical resolves child via RED.nodes.getNode', async () => { test('child.register canonical resolves child via RED.nodes.getNode', async () => {
const { source, calls } = makeSource(); const { source, calls } = makeSource();
const child = { id: 'child-1', source: { tag: 'child-domain' } }; const child = { id: 'child-1', source: { tag: 'child-domain' } };
@@ -103,11 +173,6 @@ test('aliases dispatch to the same handler and log a one-time deprecation', asyn
let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated")); let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated"));
assert.equal(warns.length, 1, 'setMode deprecation warning should log exactly once'); 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 })); await reg.dispatch({ topic: 'Qd', payload: 5 }, source, makeCtx({ logger: ctxLogger }));
warns = ctxLogger.calls.warn.filter((m) => m.includes("'Qd' is deprecated")); warns = ctxLogger.calls.warn.filter((m) => m.includes("'Qd' is deprecated"));
assert.equal(warns.length, 1); assert.equal(warns.length, 1);
@@ -170,3 +235,124 @@ test('child.register with unknown child id logs warn and does not throw', async
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}` `expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
); );
}); });
// --- mode gate tests -------------------------------------------------------
test('gate: set.demand in maintenance mode is dropped (action not allowed)', async () => {
// Mirror schema: maintenance allows only statusCheck. The dispatch action
// for a positive demand under optimalControl/priorityControl is
// execOptimalCombination / execSequentialControl — neither in maintenance.
const { source, calls } = makeSource({
mode: 'maintenance',
isValidActionForMode: (action) => action === 'statusCheck',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 50 }, source, makeCtx());
assert.equal(calls.handleInput.length, 0, 'handleInput must not be invoked');
assert.equal(calls.turnOffAllMachines, 0, 'turnOffAllMachines must not be invoked');
assert.ok(
source.logger.calls.warn.some((m) => m.includes('not allowed')),
`expected warn about action not allowed in maintenance, got: ${JSON.stringify(source.logger.calls.warn)}`
);
});
test("gate: set.demand from msg.source 'physical' in maintenance is dropped (source not allowed)", async () => {
// Maintenance accepts sources ['parent','GUI'] per schema. Physical/HMI is
// rejected by the source gate even before we ask which action to perform.
const { source, calls } = makeSource({
mode: 'maintenance',
isValidActionForMode: () => true, // pretend action is allowed; source gate must still reject
isValidSourceForMode: (src) => src === 'parent' || src === 'GUI',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 50, source: 'physical' }, source, makeCtx());
assert.equal(calls.handleInput.length, 0);
assert.equal(calls.turnOffAllMachines, 0);
assert.ok(
source.logger.calls.warn.some((m) => m.includes("'physical'") && m.includes('not allowed')),
`expected warn about physical source not allowed, got: ${JSON.stringify(source.logger.calls.warn)}`
);
});
test('gate: set.demand from msg.source GUI in optimalControl reaches handleInput', async () => {
const { source, calls } = makeSource({
mode: 'optimalControl',
isValidActionForMode: (action) =>
['statusCheck', 'execOptimalCombination', 'balanceLoad', 'emergencyStop'].includes(action),
isValidSourceForMode: (src) => ['parent', 'GUI', 'physical', 'API'].includes(src),
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 25, source: 'GUI' }, source, makeCtx());
assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 25 });
// Sanity check on the gate plumbing: both gates were consulted with the
// expected (action, source, mode) tuple.
assert.ok(calls.gateAction.some((g) => g.action === 'execOptimalCombination' && g.mode === 'optimalControl' && g.ok));
assert.ok(calls.gateSource.some((g) => g.src === 'GUI' && g.mode === 'optimalControl' && g.ok));
});
test('gate: emergencyStop (negative demand) gated by mode → maintenance blocks the stop-all', async () => {
// A negative demand is the operator stop-all signal. The schema declares
// emergencyStop in optimalControl/priorityControl but NOT in maintenance,
// so this should be rejected too — maintenance is "monitor only", which
// includes "no dispatch decisions, even shutdowns".
const { source, calls } = makeSource({
mode: 'maintenance',
isValidActionForMode: (action) => action === 'statusCheck',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: -1 }, source, makeCtx());
assert.equal(calls.turnOffAllMachines, 0, 'turnOff must be gated');
assert.ok(
source.logger.calls.warn.some((m) => m.includes('emergencyStop') && m.includes('not allowed')),
`expected warn about emergencyStop not allowed, got: ${JSON.stringify(source.logger.calls.warn)}`
);
});
// --- mode-string normalisation (specificClass internals) --------------------
const { _normaliseMode, ALLOWED_MODES } = require('../../src/specificClass');
test('mode normalisation: camelCase pass-through, lowercase accepted, garbage rejected', () => {
assert.equal(_normaliseMode('optimalControl'), 'optimalControl');
assert.equal(_normaliseMode('optimalcontrol'), 'optimalControl');
assert.equal(_normaliseMode('OPTIMALCONTROL'), 'optimalControl');
assert.equal(_normaliseMode('priorityControl'), 'priorityControl');
assert.equal(_normaliseMode('prioritycontrol'), 'priorityControl');
assert.equal(_normaliseMode('maintenance'), 'maintenance');
assert.equal(_normaliseMode('MAINTENANCE'), 'maintenance');
assert.equal(_normaliseMode('wat'), null);
assert.equal(_normaliseMode(''), null);
assert.equal(_normaliseMode(null), null);
assert.equal(_normaliseMode(undefined), null);
assert.deepEqual(ALLOWED_MODES, ['optimalControl', 'priorityControl', 'maintenance']);
});
// --- schema-shape regression -----------------------------------------------
test('schema regression: allowedSources keys are camelCase for all three modes', () => {
// Read the JSON directly — generalFunctions' package.json `exports` map
// doesn't expose the configs subpath, and we don't want to add it just for
// a test. Path is repo-relative from this test file.
const fs = require('node:fs');
const path = require('node:path');
const schemaPath = path.resolve(__dirname, '../../../generalFunctions/src/configs/machineGroupControl.json');
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
const allowedSourcesSchema = schema.mode.allowedSources.rules.schema;
assert.ok(allowedSourcesSchema.optimalControl, 'optimalControl key must exist on allowedSources');
assert.ok(allowedSourcesSchema.priorityControl, 'priorityControl key must exist on allowedSources');
assert.ok(allowedSourcesSchema.maintenance, 'maintenance key must exist on allowedSources');
// Maintenance is monitor-only: parent + GUI permitted, physical/API rejected.
const mDefaults = allowedSourcesSchema.maintenance.default;
assert.ok(mDefaults.includes('parent'), `maintenance default should permit parent, got ${mDefaults}`);
assert.ok(mDefaults.includes('GUI'), `maintenance default should permit GUI, got ${mDefaults}`);
assert.ok(!mDefaults.includes('physical'), 'maintenance must NOT permit physical writes');
assert.ok(!mDefaults.includes('API'), 'maintenance must NOT permit API writes');
// Catch a regression to lowercase keys.
assert.equal(allowedSourcesSchema.optimalcontrol, undefined, 'lowercase optimalcontrol key must NOT exist');
assert.equal(allowedSourcesSchema.prioritycontrol, undefined, 'lowercase prioritycontrol key must NOT exist');
});

View File

@@ -0,0 +1,83 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { getOutput } = require('../../src/io/output.js');
const MachineGroup = require('../../src/specificClass.js');
// Real declared unit policy so the m³/s → m³/h conversion is the production one.
const unitPolicy = MachineGroup.unitPolicy;
// Minimal MGC stand-in exposing exactly the surface getOutput reads. The
// measurement loop is short-circuited with an empty type list so the test
// isolates the demand telemetry without needing curves / CoolProp.
function mockMgc(overrides = {}) {
return {
measurements: { getTypes: () => [] },
unitPolicy,
mode: 'optimalControl',
scaling: 'absolute',
absDistFromPeak: 0,
relDistFromPeak: 0,
dynamicTotals: { flow: { min: 0.05, max: 0.25 } }, // m³/s
machines: {},
operatingPoint: {},
_lastDemand: null,
...overrides,
};
}
test('demandFlow + demandPct emitted once a demand is resolved', () => {
// Demand resolved to 0.15 m³/s inside a 0.05..0.25 envelope → midpoint = 50%.
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0.15, clamped: 0.15 } }));
// m³/s → m³/h is ×3600. 0.15 m³/s = 540 m³/h.
assert.equal(out.demandFlow, 540);
assert.ok(Math.abs(out.demandPct - 50) < 1e-9, `expected ~50%, got ${out.demandPct}`);
});
test('demandPct reflects the clamped setpoint, not the raw request', () => {
// Operator asked for 0.40 m³/s but the envelope caps at 0.25 → 100%.
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0.40, clamped: 0.25 } }));
assert.equal(out.demandFlow, 900); // 0.25 m³/s = 900 m³/h
assert.equal(out.demandPct, 100);
});
test('demandPct is 0 (never NaN) when the capacity span is zero', () => {
const out = getOutput(mockMgc({
dynamicTotals: { flow: { min: 0.1, max: 0.1 } },
_lastDemand: { canonical: 0.1, clamped: 0.1 },
}));
assert.equal(out.demandPct, 0);
assert.ok(Number.isFinite(out.demandFlow));
});
test('turnOff demand (0) emits a zero setpoint, not absent', () => {
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0, clamped: 0 } }));
assert.equal(out.demandFlow, 0);
assert.equal(out.demandPct, 0);
});
test('demand telemetry is absent before the first demand (degraded state)', () => {
const out = getOutput(mockMgc({ _lastDemand: null }));
assert.ok(!('demandFlow' in out), 'demandFlow must be absent pre-first-demand');
assert.ok(!('demandPct' in out), 'demandPct must be absent pre-first-demand');
// The always-on capacity fields are still present, converted to the output
// flow unit (m³/h): 0.05 m³/s → 180, 0.25 m³/s → 900.
assert.equal(out.flowCapacityMin, 180);
assert.equal(out.flowCapacityMax, 900);
});
test('flow capacity is emitted in the output unit (m³/h), matching the flow series', () => {
const out = getOutput(mockMgc({ dynamicTotals: { flow: { min: 0.1, max: 0.3 } } }));
assert.equal(out.flowCapacityMin, 360); // 0.1 m³/s × 3600
assert.equal(out.flowCapacityMax, 1080); // 0.3 m³/s × 3600
});
test('flow capacity falls back to 0 when the envelope is unresolved (Infinity)', () => {
// Pre-first-equalize: dynamicTotals seeds min=Infinity, max=0.
const out = getOutput(mockMgc({ dynamicTotals: { flow: { min: Infinity, max: 0 } } }));
assert.equal(out.flowCapacityMin, 0);
assert.equal(out.flowCapacityMax, 0);
});

View File

@@ -0,0 +1,132 @@
// Unit tests for the pure distribution math extracted out of equalFlowControl.
// Decoupling target: the algorithm should be testable without a full MGC.
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { computeEqualFlowDistribution } = require('../../src/control/strategies.js');
// Tiny helpers to make synthetic machines. The pure function still calls
// filterOutUnavailableMachines, which reads machine.state.getCurrentState()
// and machine.isValidActionForMode() — stub both so the algorithm sees the
// machine as available. groupFlow/groupCalcPower are injected.
function mkMachine(id, capability = { min: 0.01, max: 0.10, power: (flow) => flow * 1000 }, state = 'operational') {
return {
id,
machine: {
__testCapability: capability,
state: { getCurrentState: () => state },
isValidActionForMode: () => true,
},
};
}
const dummyLogger = { warn() {}, error() {}, debug() {}, info() {} };
// Default injected helpers: read from the synthetic machine's __testCapability.
const groupFlow = (m) => ({
currentFxyYMin: m.__testCapability.min,
currentFxyYMax: m.__testCapability.max,
});
const groupCalcPower = (m, flow) => m.__testCapability.power(flow);
function basicArgs(overrides = {}) {
const m = { a: mkMachine('a').machine, b: mkMachine('b').machine, c: mkMachine('c').machine };
return {
machines: m, Qd: 0.06,
dynamicTotals: { flow: { min: 0.01, max: 0.30 } },
activeTotals: { flow: { min: 0.03, max: 0.30 } },
priorityList: ['a', 'b', 'c'],
isMachineActive: () => true,
groupFlow, groupCalcPower, logger: dummyLogger,
...overrides,
};
}
test('default case: distributes Qd equally across active machines', () => {
const r = computeEqualFlowDistribution(basicArgs({ Qd: 0.06 }));
// 3 active pumps, demand 0.06 → 0.02 per pump.
assert.equal(r.flowDistribution.length, 3);
for (const entry of r.flowDistribution) {
assert.ok(Math.abs(entry.flow - 0.02) < 1e-12, `entry.flow=${entry.flow}`);
}
assert.ok(Math.abs(r.totalFlow - 0.06) < 1e-12);
// power(flow) = flow * 1000 in the test capability → 0.02 * 1000 = 20 W per pump.
assert.ok(Math.abs(r.totalPower - 60) < 1e-9);
});
test('Qd above active capacity: starts additional priority machines until covered', () => {
// Only one machine "active" to start with; demand exceeds its envelope.
// Algorithm should bring more priority machines online via the high-demand branch.
const active = new Set(['a']);
const args = basicArgs({
Qd: 0.18, // above any single pump's max (0.10)
activeTotals: { flow: { min: 0.01, max: 0.10 } },
isMachineActive: (id) => active.has(id),
});
const r = computeEqualFlowDistribution(args);
// The algorithm reduces Qd iteratively (Qd /= i) until it fits per-pump max.
// We don't assert exact splits — only that flowDistribution is non-empty
// and totalFlow is finite, since the legacy algorithm is preserved as-is.
assert.ok(r.flowDistribution.length >= 1);
assert.ok(Number.isFinite(r.totalFlow));
assert.ok(Number.isFinite(r.totalPower));
});
test('Qd below active min flow: routes excess machines to flow=0 and redistributes', () => {
// demand below active min — algorithm shuts off lowest-priority machine(s)
// and redistributes Qd across the remainder.
const args = basicArgs({
Qd: 0.015,
dynamicTotals: { flow: { min: 0.01, max: 0.30 } },
activeTotals: { flow: { min: 0.03, max: 0.30 } }, // active min > Qd
});
const r = computeEqualFlowDistribution(args);
const offCount = r.flowDistribution.filter(e => e.flow === 0).length;
assert.ok(offCount >= 1, `expected ≥1 machine to be shut off, got distribution: ${JSON.stringify(r.flowDistribution)}`);
const totalServed = r.flowDistribution.filter(e => e.flow > 0).reduce((s, e) => s + e.flow, 0);
assert.ok(Math.abs(totalServed - 0.015) < 1e-12, `served flow ${totalServed} should equal Qd 0.015`);
});
test('totalCog is always 0 for equalFlow — preserves legacy contract', () => {
// The historical algorithm sets totalCog = 0 in this strategy (BEP-Gravitation
// is the only optimizer that produces a meaningful per-combination cog).
// Pinned here so a future "improvement" doesn't silently introduce a fake value.
const r = computeEqualFlowDistribution(basicArgs());
assert.equal(r.totalCog, 0);
});
test('isMachineActive is consulted for COUNT but not for SELECTION (legacy quirk)', () => {
// Pins pre-existing behaviour of the default branch: it counts how many
// machines are active (countActive) to decide how to split Qd, but then
// iterates the FIRST countActive machines in priority order — which may
// include inactive ones. So 2 of 3 active + Qd within range → first 2 in
// priorityList both get flow, regardless of which are actually active.
//
// This is a latent bug that pre-dates the strategies decoupling refactor.
// Documenting it here so a future cleanup is a deliberate change with a
// failing-then-passing test, not a silent semantic shift.
const active = new Set(['a', 'c']);
const r = computeEqualFlowDistribution(basicArgs({
Qd: 0.06,
isMachineActive: (id) => active.has(id),
}));
// Today: machinesInPriorityOrder[0]='a', [1]='b' → 'a' and 'b' both get 0.03.
// 'c' (active but third in priority order) gets nothing.
const aFlow = r.flowDistribution.find(e => e.machineId === 'a')?.flow;
const bFlow = r.flowDistribution.find(e => e.machineId === 'b')?.flow;
const cFlow = r.flowDistribution.find(e => e.machineId === 'c')?.flow;
assert.equal(aFlow, 0.03, 'a (priority 0, active)');
assert.equal(bFlow, 0.03, 'b (priority 1, INACTIVE — receives flow anyway, bug)');
assert.equal(cFlow, undefined, 'c (priority 2, active — does NOT receive flow, bug)');
});
test('priorityList controls iteration order', () => {
// The order in flowDistribution should match priorityList — i.e., machine 'c'
// appears before machine 'a' when priorityList = ['c', 'b', 'a'].
const r = computeEqualFlowDistribution(basicArgs({
priorityList: ['c', 'b', 'a'],
}));
assert.equal(r.flowDistribution[0].machineId, 'c');
});

View File

@@ -53,14 +53,33 @@ test('calcDistanceBEP returns both abs + rel', () => {
assert.ok(Math.abs(relDistFromPeak - expectedRel) < 1e-9); assert.ok(Math.abs(relDistFromPeak - expectedRel) < 1e-9);
}); });
test('calcRelativeDistanceFromPeak returns 1 when max === min (degenerate)', () => { test('calcRelativeDistanceFromPeak returns undefined when max === min (degenerate)', () => {
// For homogeneous pump groups (all cogs equal), the [max..min] band
// collapses and the metric is mathematically undefined. Return undefined
// so the dashboard displays "—" instead of a misleading 0% / 100%.
const ge = makeGE(); const ge = makeGE();
assert.equal(ge.calcRelativeDistanceFromPeak(0.85, 0.8, 0.8), 1); assert.equal(ge.calcRelativeDistanceFromPeak(0.85, 0.8, 0.8), undefined);
}); });
test('calcRelativeDistanceFromPeak returns 1 when current is null', () => { test('calcRelativeDistanceFromPeak returns undefined when max ≈ min within epsilon', () => {
// Float noise from identical pumps: max-min might be 1e-12 rather than 0.
// Must still report undefined — the interpolation extrapolates wildly here.
const ge = makeGE(); const ge = makeGE();
assert.equal(ge.calcRelativeDistanceFromPeak(null, 0.92, 0.7), 1); assert.equal(ge.calcRelativeDistanceFromPeak(0.85, 0.211264, 0.211263999), undefined);
});
test('calcRelativeDistanceFromPeak returns undefined when current is null', () => {
const ge = makeGE();
assert.equal(ge.calcRelativeDistanceFromPeak(null, 0.92, 0.7), undefined);
});
test('calcDistanceBEP propagates undefined relDist for degenerate input', () => {
// Regression: if currentEff is finite, absDist is still computed (it's
// just |current - peak|), but relDist must be undefined for degenerate.
const ge = makeGE();
const { absDistFromPeak, relDistFromPeak } = ge.calcDistanceBEP(0.206, 0.211, 0.211);
assert.ok(Math.abs(absDistFromPeak - 0.005) < 1e-9);
assert.equal(relDistFromPeak, undefined);
}); });
test('calcGroupEfficiency handles a single machine', () => { test('calcGroupEfficiency handles a single machine', () => {

View File

@@ -0,0 +1,142 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const MoveTrajectory = require('../../src/movement/moveTrajectory');
// Reusable profile builder — keeps each test focused on the field(s) it cares
// about. Anything not overridden is in a sane "operational at 0%" baseline.
function makeProfile(over = {}) {
return Object.assign({
id: 'P1',
state: 'operational',
position: 0,
minPosition: 0,
maxPosition: 100,
velocityPctPerS: 2,
timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 },
remainingTransitionS: null,
flowAt: () => null,
}, over);
}
// TC1 — idle, full startup ladder + ramp from min.
test('TC1 idle → target = startingS + warmingupS + (targetmin)/velocity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'idle' }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 10 + 20 + 60 / 2); // 60s
});
// TC2 — operational up.
test('TC2 operational up = |targetposition|/velocity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 40 }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 10);
});
// TC3 — operational down. ETA is positive.
test('TC3 operational down = |targetposition|/velocity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 80 }), { targetPosition: 30 });
assert.equal(t.etaToTargetS(), 25);
});
// TC4 — no-op.
test('TC4 operational, target == position → 0s', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 50 }), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 0);
});
// TC5 — accelerating post-abort residue, same formula as operational.
test('TC5 accelerating residue = operational formula', () => {
const t = new MoveTrajectory(makeProfile({ state: 'accelerating', position: 35 }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 12.5);
});
// TC6 — decelerating residue.
test('TC6 decelerating residue = operational formula', () => {
const t = new MoveTrajectory(makeProfile({ state: 'decelerating', position: 70 }), { targetPosition: 40 });
assert.equal(t.etaToTargetS(), 15);
});
// TC7 — warmingup, remaining time from stateManager.
test('TC7 warmingup = remainingWarmupS + (targetmin)/velocity', () => {
const t = new MoveTrajectory(makeProfile({
state: 'warmingup',
position: 0,
remainingTransitionS: 12,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 12 + 50 / 2); // 37s
});
// TC7b — warmingup but no remaining-time observation: falls back to full
// configured warmup (worst-case). Kept for resilience when the state machine
// pre-dates the getter.
test('TC7b warmingup fallback to full warmingupS when no remaining provided', () => {
const t = new MoveTrajectory(makeProfile({
state: 'warmingup',
position: 0,
remainingTransitionS: null,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 20 + 50 / 2); // 45s
});
// TC8 — starting: remaining + full warmup + ramp.
test('TC8 starting = remainingStartingS + warmingupS + (targetmin)/velocity', () => {
const t = new MoveTrajectory(makeProfile({
state: 'starting',
position: 0,
remainingTransitionS: 8,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 8 + 20 + 50 / 2); // 53s
});
// TC8b — boundary: remaining hits 0 just before the setTimeout fires.
test('TC8b starting with remainingTransitionS=0 still yields positive ETA', () => {
const t = new MoveTrajectory(makeProfile({
state: 'starting',
position: 0,
remainingTransitionS: 0,
}), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), 0 + 20 + 50 / 2); // 45s
});
// TC9 — shutdown ladder excluded: returns null so scheduler skips it.
test('TC9a stopping → null', () => {
const t = new MoveTrajectory(makeProfile({ state: 'stopping', position: 30 }), { targetPosition: 0 });
assert.equal(t.etaToTargetS(), null);
});
test('TC9b coolingdown → null', () => {
const t = new MoveTrajectory(makeProfile({ state: 'coolingdown', position: 0 }), { targetPosition: 0 });
assert.equal(t.etaToTargetS(), null);
});
// TC10 — target above max clamps; ETA uses clamped value.
test('TC10 target above maxPosition clamps to max', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, maxPosition: 100 }), { targetPosition: 120 });
assert.equal(t.targetPosition, 100);
assert.equal(t.etaToTargetS(), 50);
});
// TC11 — target below min clamps; ETA zero when already at min.
test('TC11 target below min clamps to min; ETA = 0 when at min', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, minPosition: 0 }), { targetPosition: -5 });
assert.equal(t.targetPosition, 0);
assert.equal(t.etaToTargetS(), 0);
});
// TC12 — zero velocity yields Infinity, not NaN or crash.
test('TC12 zero velocity → Infinity', () => {
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, velocityPctPerS: 0 }), { targetPosition: 50 });
assert.equal(t.etaToTargetS(), Infinity);
});
// TC13 — non-finite target throws at construction (totality of etaToTargetS).
test('TC13 non-finite target throws at construction', () => {
assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: NaN }), TypeError);
assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: undefined }), TypeError);
});
// Extra: minPosition above 0 is honoured in ramp distance for startup cases.
test('TC1b idle with minPosition=10 → ramp from 10, not 0', () => {
const t = new MoveTrajectory(makeProfile({ state: 'idle', minPosition: 10 }), { targetPosition: 60 });
assert.equal(t.etaToTargetS(), 10 + 20 + (60 - 10) / 2); // 55s
});

View File

@@ -0,0 +1,86 @@
// Unit tests for the MGC movement state + rendezvous-lock helpers
// (getMovementState / _isEmergencyDemand / _pressureEmergency). Exercised via
// prototype.call with a
// minimal fake `this` so no Node-RED runtime or full MachineGroup boot is
// needed. See project rule .claude/rules/testing.md (basic = pure logic).
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
function machine(state, { delayedMove = null, moveTimeLeft = 0 } = {}) {
return { state: { getCurrentState: () => state, delayedMove, getMoveTimeLeft: () => moveTimeLeft } };
}
function movementStateOf(machines, pending = 0) {
return MachineGroup.prototype.getMovementState.call({
machines,
movementExecutor: { pending: () => pending },
});
}
test('movementState: ready when no machines are registered', () => {
assert.equal(movementStateOf({}), 'ready');
});
test('movementState: ready when every machine is settled and nothing is pending', () => {
assert.equal(movementStateOf({ a: machine('operational'), b: machine('idle') }), 'ready');
});
test('movementState: working while a machine is mid-ramp', () => {
assert.equal(movementStateOf({ a: machine('operational'), b: machine('accelerating') }), 'working');
});
test('movementState: working during a start/stop sequence step', () => {
assert.equal(movementStateOf({ a: machine('warmingup') }), 'working');
});
test('movementState: working when a setpoint is queued (delayedMove)', () => {
assert.equal(movementStateOf({ a: machine('operational', { delayedMove: 50 }) }), 'working');
});
test('movementState: working while move time remains', () => {
assert.equal(movementStateOf({ a: machine('operational', { moveTimeLeft: 1.2 }) }), 'working');
});
test('movementState: working when the executor still has scheduled commands', () => {
assert.equal(movementStateOf({ a: machine('operational') }, 2), 'working');
});
// Rendezvous lock: only an EMERGENCY pre-empts an in-flight rendezvous; every
// ordinary setpoint (any size, mode/priority change included) defers.
function emergency(demandQ, { last = 10, emergency = false } = {}) {
return MachineGroup.prototype._isEmergencyDemand.call({
_lastDemand: last == null ? null : { canonical: last },
}, demandQ, { emergency });
}
test('emergency: a stop (≤0) always pre-empts', () => {
assert.equal(emergency(0), true);
assert.equal(emergency(-5), true);
});
test('emergency: the first demand (no prior) dispatches immediately', () => {
assert.equal(emergency(50, { last: null }), true);
});
test('emergency: an explicit emergency flag pre-empts', () => {
assert.equal(emergency(60, { last: 10, emergency: true }), true);
});
test('emergency: an ordinary same-mode step defers (large or small)', () => {
assert.equal(emergency(12, { last: 10 }), false); // small nudge — defer
assert.equal(emergency(60, { last: 10 }), false); // large step — also defers now
});
// Pressure-excursion detector — inert until planner.emergencyPressurePa is set.
function pressureEmergency({ thr, headerPa } = {}) {
return MachineGroup.prototype._pressureEmergency.call({
config: { planner: thr == null ? {} : { emergencyPressurePa: thr } },
operatingPoint: { headerDiffPa: headerPa },
});
}
test('pressureEmergency: inert (false) when no threshold is configured', () => {
assert.equal(pressureEmergency({ headerPa: 999999 }), false);
});
test('pressureEmergency: false when header is below the configured threshold', () => {
assert.equal(pressureEmergency({ thr: 200000, headerPa: 150000 }), false);
});
test('pressureEmergency: true when header breaches the configured threshold', () => {
assert.equal(pressureEmergency({ thr: 200000, headerPa: 210000 }), true);
});
test('pressureEmergency: false when header pressure is unknown', () => {
assert.equal(pressureEmergency({ thr: 200000, headerPa: undefined }), false);
});

View File

@@ -0,0 +1,136 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const MovementExecutor = require('../../src/movement/movementExecutor');
function mkSchedule(commands, tStarS = 0, tickS = 1) {
return { tStarS, tickS, commands };
}
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
test('executor: throws if fireCommand callback missing', () => {
assert.throws(() => new MovementExecutor({}), TypeError);
});
test('executor: fires commands whose fireAtTickN <= cursor', async () => {
const fired = [];
const ex = new MovementExecutor({
fireCommand: (c) => fired.push(c),
logger: noopLogger,
});
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 2, eta: 2 },
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 5, eta: 5 },
]));
let firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 1);
assert.equal(firedThisTick[0].machineId, 'A');
firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 0);
firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 1);
assert.equal(firedThisTick[0].machineId, 'B');
await ex.tick(); await ex.tick();
firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 1);
assert.equal(firedThisTick[0].machineId, 'C');
assert.deepEqual(fired.map((c) => c.machineId), ['A', 'B', 'C']);
assert.equal(ex.pending(), 0);
});
test('executor: replan drops unfired commands and resets cursor', async () => {
const fired = [];
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 10, eta: 10 },
]));
await ex.tick(); // A fires
assert.deepEqual(fired, ['A']);
assert.equal(ex.pending(), 1);
ex.replan(mkSchedule([
{ machineId: 'X', action: 'flowmovement', flow: 80, fireAtTickN: 0, eta: 0 },
{ machineId: 'Y', action: 'flowmovement', flow: 20, fireAtTickN: 3, eta: 3 },
]));
assert.equal(ex.cursor(), 0, 'cursor reset on replan');
await ex.tick(); // X fires
assert.deepEqual(fired, ['A', 'X']);
await ex.tick(); await ex.tick(); await ex.tick();
assert.ok(!fired.includes('B'), 'old B move was dropped by replan');
assert.ok(fired.includes('Y'), 'new Y move fired after delay');
});
test('executor: fires only once per command even across many ticks', async () => {
const fired = [];
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
]));
for (let i = 0; i < 5; i++) await ex.tick();
assert.deepEqual(fired, ['A']);
});
test('executor: catches fireCommand errors and continues', async () => {
const fired = [];
const ex = new MovementExecutor({
fireCommand: (c) => {
if (c.machineId === 'B') throw new Error('boom');
fired.push(c.machineId);
},
logger: noopLogger,
});
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 0, eta: 0 },
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 0, eta: 0 },
]));
await ex.tick();
// B's error must not block A or C.
assert.deepEqual(fired, ['A', 'C']);
});
test('executor: empty / null schedule is safe to tick', async () => {
const ex = new MovementExecutor({ fireCommand: () => {}, logger: noopLogger });
assert.deepEqual(await ex.tick(), []);
ex.replan({ commands: [] });
assert.deepEqual(await ex.tick(), []);
});
test('executor: tick fires commands synchronously and does NOT await their promises', async () => {
// Contract: tick() returns as soon as every due fireCommand has been
// invoked. It does NOT wait for the returned promises to resolve.
// This matters because a flowmovement-after-startup resolves only
// after the pump's entire ramp completes — awaiting it would freeze
// the executor's wall-clock progression and drag every delayed
// command in the schedule forward by that duration.
const order = [];
let resolveFire;
const firePromise = new Promise((r) => { resolveFire = r; });
const ex = new MovementExecutor({
fireCommand: (c) => {
order.push(`fire-start-${c.machineId}`);
return firePromise.then(() => { order.push(`fire-end-${c.machineId}`); });
},
logger: noopLogger,
});
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
]));
const tickPromise = ex.tick().then(() => order.push('tick-resolved'));
// Wait one microtask cycle: tick should already have resolved even
// though fire is still pending.
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(order, ['fire-start-A', 'tick-resolved'],
'tick must resolve immediately after invoking fireCommand — not wait for its promise');
resolveFire();
await tickPromise;
// The fire's tail runs in the background and lands after tick resolved.
assert.deepEqual(order, ['fire-start-A', 'tick-resolved', 'fire-end-A']);
});

View File

@@ -0,0 +1,302 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { plan } = require('../../src/movement/movementScheduler');
// Profile builder — same shape as buildProfile output. positionForFlow
// approximates the inverse curve as a linear mapping over [min,max] for
// flow ∈ [0, maxFlow], which is enough to test scheduler logic without
// dragging real curve math in.
function makeProfile(over = {}) {
const defaults = {
id: 'A',
state: 'operational',
position: 0,
minPosition: 0,
maxPosition: 100,
velocityPctPerS: 2,
timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 },
remainingTransitionS: null,
maxFlow: 100, // synthetic — for the test mapping below
};
const p = Object.assign(defaults, over);
// Linear position-for-flow over [min,max].
p.positionForFlow = (flow) => {
if (!Number.isFinite(flow) || flow <= 0) return p.minPosition;
return p.minPosition + (flow / p.maxFlow) * (p.maxPosition - p.minPosition);
};
// flowAt — inverse of the above.
p.flowAt = (pos /*, pressure */) => {
if (!Number.isFinite(pos)) return 0;
if (p.maxPosition === p.minPosition) return 0;
return ((pos - p.minPosition) / (p.maxPosition - p.minPosition)) * p.maxFlow;
};
return p;
}
// Tick rounding helper — scheduler uses Math.round(eta/tickS).
function tickRound(s, tickS = 1) { return Math.round(s / tickS); }
test('plan: idle → start a single pump (no other pumps online)', () => {
const profiles = [makeProfile({ id: 'A', state: 'idle', position: 0 })];
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
// Two commands: execsequence(startup) + flowmovement(60). Both at tick 0.
assert.equal(out.commands.length, 2);
assert.equal(out.commands[0].action, 'execsequence');
assert.equal(out.commands[0].sequence, 'startup');
assert.equal(out.commands[0].fireAtTickN, 0);
assert.equal(out.commands[1].action, 'flowmovement');
assert.equal(out.commands[1].flow, 60);
assert.equal(out.commands[1].fireAtTickN, 0);
// tStar = full startup ladder + ramp from 0 to position-for-60 (= 60%).
// = 10 + 20 + 60/2 = 60s.
assert.equal(out.tStarS, 60);
});
test('plan: operational up-move (no rendezvous partner)', () => {
const profiles = [makeProfile({ id: 'A', state: 'operational', position: 40 })];
// Currently delivering 40 (at maxFlow=100 → linear), targeting 60.
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
assert.equal(out.commands.length, 1);
assert.equal(out.commands[0].action, 'flowmovement');
assert.equal(out.commands[0].flow, 60);
assert.equal(out.commands[0].fireAtTickN, 0);
// eta = |6040|/2 = 10s
assert.equal(out.tStarS, 10);
});
test('plan: rendezvous — startup pump + running pump that needs to shed load', () => {
// A: starting from idle, target 60. eta = 10 + 20 + 60/2 = 60s.
// B: operational at 80 (flow=80), target 40 (down). eta_B = 40/2 = 20s.
// Expectation: A fires at tick 0; B fires at tick (6020) = 40 so B
// FINISHES at the same time A reaches its target.
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0 }),
makeProfile({ id: 'B', state: 'operational', position: 80 }),
];
const combination = [
{ machineId: 'A', flow: 60 },
{ machineId: 'B', flow: 40 },
];
const out = plan(profiles, combination, 100_000);
const cmdA_startup = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence');
const cmdA_flow = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement');
const cmdB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
assert.ok(cmdA_startup, 'A startup');
assert.ok(cmdA_flow, 'A flowmovement (queued)');
assert.ok(cmdB, 'B flowmovement');
assert.equal(cmdA_startup.fireAtTickN, 0);
assert.equal(cmdA_flow.fireAtTickN, 0);
// B delayed so it finishes at tStar=60 → fires at 6020 = 40.
assert.equal(cmdB.fireAtTickN, 40);
assert.equal(out.tStarS, 60);
});
test('plan: all machines moving down — all land at slowest mover\'s eta', () => {
// Two operational pumps, both reducing flow. tStar = max eta over
// ALL non-noop moves (not just increasing) so the slower pump
// defines the rendezvous and the faster one is delayed to land
// with it. Net effect: same-time landing in pure-down scenarios too,
// sum-of-flows stays at the OLD setpoint until t* then drops cleanly.
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 80, velocityPctPerS: 2 }),
makeProfile({ id: 'B', state: 'operational', position: 70, velocityPctPerS: 2 }),
];
const combination = [
{ machineId: 'A', flow: 40 }, // target position via inverse curve → 40 (identity makeProfile)
{ machineId: 'B', flow: 30 },
];
const out = plan(profiles, combination, 100_000);
// eta_A = |80-40|/2 = 20s, eta_B = |70-30|/2 = 20s → tStar = 20s.
assert.equal(out.tStarS, 20);
// Both pumps have eta == tStar so neither is delayed (fireAtTickN = 0).
for (const c of out.commands) {
assert.equal(c.fireAtTickN, 0, `${c.machineId} should fire at 0 when eta == tStar`);
}
});
test('plan: asymmetric down moves — faster one delayed to land with slower one', () => {
// A and B both reduce flow but A's move is faster. The new
// symmetric-rendezvous semantics delay the faster mover so both land
// at tStar = max eta.
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 60, velocityPctPerS: 4 }), // fast
makeProfile({ id: 'B', state: 'operational', position: 80, velocityPctPerS: 2 }), // slow
];
const combination = [
{ machineId: 'A', flow: 40 },
{ machineId: 'B', flow: 40 },
];
const out = plan(profiles, combination, 100_000);
// eta_A = |60-40|/4 = 5s, eta_B = |80-40|/2 = 20s → tStar = 20s.
assert.equal(out.tStarS, 20);
const cA = out.commands.find((c) => c.machineId === 'A');
const cB = out.commands.find((c) => c.machineId === 'B');
assert.equal(cA.fireAtTickN, 15, 'A (fast) delayed by tStar eta_A = 20 5 = 15');
assert.equal(cB.fireAtTickN, 0, 'B (slow) defines tStar — fires immediately');
});
test('plan: shutdown — removed machine gets execsequence(shutdown)', () => {
// A staying at flow 60, B getting shut down (target 0).
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 60 }),
makeProfile({ id: 'B', state: 'operational', position: 50 }),
];
const combination = [
{ machineId: 'A', flow: 60 }, // unchanged
{ machineId: 'B', flow: 0 },
];
const out = plan(profiles, combination, 100_000);
const shutdownB = out.commands.find((c) => c.machineId === 'B' && c.action === 'execsequence' && c.sequence === 'shutdown');
assert.ok(shutdownB, 'B shutdown command present');
});
test('plan: noop — machine not in combination and already off does nothing', () => {
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 60 }),
makeProfile({ id: 'B', state: 'idle', position: 0 }),
];
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
const bAny = out.commands.find((c) => c.machineId === 'B');
assert.equal(bAny, undefined, 'B should be omitted (no-op)');
});
test('plan: rendezvous with three pumps — slowest startup sets the pace', () => {
// A: idle → 50 (full startup, slow).
// B: operational at 80 → 40 (down).
// C: operational at 30 → 50 (up, fast).
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0 }),
makeProfile({ id: 'B', state: 'operational', position: 80 }),
makeProfile({ id: 'C', state: 'operational', position: 30 }),
];
const combination = [
{ machineId: 'A', flow: 50 },
{ machineId: 'B', flow: 40 },
{ machineId: 'C', flow: 50 },
];
const out = plan(profiles, combination, 100_000);
// eta_A = 10 + 20 + 50/2 = 55s (startup ladder + ramp; defines tStar)
// eta_B = |80-40|/2 = 20s (decreasing)
// eta_C = |50-30|/2 = 10s (increasing)
// tStar = max(55, 20, 10) = 55.
assert.equal(out.tStarS, 55);
const cA = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence');
const cC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement');
const cB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
// A's startup must begin NOW; its delayed flowmovement lands at t*
// by construction.
assert.equal(cA.fireAtTickN, 0);
// Symmetric rendezvous: BOTH B and C are delayed to land at t*.
// C (up, fast) gets delayed by t* eta_C = 45.
// B (down, mid) gets delayed by t* eta_B = 35.
assert.equal(cC.fireAtTickN, 55 - 10, 'C delayed to land at tStar (same-time landing)');
assert.equal(cB.fireAtTickN, 55 - 20, 'B delayed to land at tStar (same-time landing)');
});
test('plan: mixed-speed multi-startup — fast pumps wait so all land at tStar together', () => {
// Three idle pumps starting from min position. Different per-pump
// velocities → different etas. Without the rampStart gating, each
// pump's delayedMove would fire at warmup-end and ramp at its own
// speed, so the FAST pump lands long before the SLOW one — visible
// on the dashboard as staggered landing curves.
//
// Real-world reproducer: pumpingstation-complete-example with the
// editor's Reaction Speed set to A=3 %/s, B=10 %/s, C=1 %/s.
//
// Velocities here mirror that ratio but scaled for unit-test
// readability. Position range is [0,100] so rampDist = 100.
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0, velocityPctPerS: 3 }),
makeProfile({ id: 'B', state: 'idle', position: 0, velocityPctPerS: 10 }),
makeProfile({ id: 'C', state: 'idle', position: 0, velocityPctPerS: 1 }),
];
const combination = [
{ machineId: 'A', flow: 100 },
{ machineId: 'B', flow: 100 },
{ machineId: 'C', flow: 100 },
];
const out = plan(profiles, combination, 100_000);
// Default ladder = starting(10) + warmingup(20) = 30 s.
// ramp_A = 100/3 ≈ 33.33 s → eta_A ≈ 63.33 s
// ramp_B = 100/10 = 10 s → eta_B = 40 s
// ramp_C = 100/1 = 100 s → eta_C = 130 s
// tStar = max(eta_A, eta_B, eta_C) = 130 s.
assert.ok(Math.abs(out.tStarS - 130) < 0.01, `tStar should be 130; got ${out.tStarS}`);
// Just-in-time: the WHOLE startup (ladder + ramp) is delayed by (tStar
// eta), so both execsequence and flowmovement fire at the same delayed
// tick. eta_A = 30 + 33.33 ≈ 63.33, eta_B = 40, eta_C = 130.
// A: round(130 63.33) = 67
// B: round(130 40) = 90
// C: round(130 130) = 0 (slowest — defines tStar, fires now)
const delays = { A: Math.round(130 - (30 + 100 / 3)), B: 90, C: 0 };
for (const id of ['A', 'B', 'C']) {
const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence');
const flow = out.commands.find((c) => c.machineId === id && c.action === 'flowmovement');
assert.ok(exec, `${id} execsequence present`);
assert.ok(flow, `${id} flowmovement present`);
assert.equal(exec.fireAtTickN, delays[id], `${id} ladder delayed to land at tStar`);
assert.equal(flow.fireAtTickN, delays[id], `${id} flowmovement fires with the ladder`);
}
// Sanity: with the ladder delayed, each pump reaches `operational` only at
// (delay + ladderS) and its ramp ends at the same wall-clock instant ≈ 130.
// A: 67 + 30 (op) + 33.33 ≈ 130.33
// B: 90 + 30 (op) + 10 = 130
// C: 0 + 30 (op) + 100 = 130
// No pump sits at `operational` (and minimum flow) before its ramp — that
// early min-flow was the staging bump this just-in-time start removes.
});
test('plan: zero-velocity machine is demoted (infinite eta) but does not crash', () => {
const profiles = [
makeProfile({ id: 'A', state: 'operational', position: 0, velocityPctPerS: 0 }),
];
const combination = [{ machineId: 'A', flow: 60 }];
const out = plan(profiles, combination, 100_000);
// Eta is Infinity → filtered out of tStar computation (only finite etas count).
// Command still scheduled; fireAtTickN remains 0 for increasing move.
const c = out.commands.find((c) => c.action === 'flowmovement');
assert.ok(c);
assert.equal(c.fireAtTickN, 0);
assert.equal(out.tStarS, 0); // no finite increasing eta → tStar collapses to 0
});
test('plan: respects custom tickS option', () => {
// Same as the rendezvous test but with tickS=5 → fireAt should be in
// ticks-of-5-seconds, not seconds.
const profiles = [
makeProfile({ id: 'A', state: 'idle', position: 0 }),
makeProfile({ id: 'B', state: 'operational', position: 80 }),
];
const combination = [
{ machineId: 'A', flow: 60 },
{ machineId: 'B', flow: 40 },
];
const out = plan(profiles, combination, 100_000, { tickS: 5 });
const cmdB = out.commands.find((c) => c.machineId === 'B');
assert.equal(out.tStarS, 60);
assert.equal(out.tickS, 5);
assert.equal(cmdB.fireAtTickN, tickRound(60 - 20, 5)); // = 8
});

View File

@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8')); const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8'));
test('basic example includes node type machineGroupControl', () => { test('basic example includes node type machineGroupControl', () => {
const count = flow.filter((n) => n && n.type === 'machineGroupControl').length; const count = flow.filter((n) => n && n.type === 'machineGroupControl').length;

View File

@@ -0,0 +1,138 @@
// Empirical answer: does absDistFromPeak / relDistFromPeak move with demand?
// Drives the live MGC + 3 identical pumps (same model as the dashboard demo)
// across a demand sweep and records what each metric actually does. The test
// asserts the expected qualitative shape, so any future change that
// regresses BEP-distance sensitivity will fail loudly.
const test = require('node:test');
const assert = require('node:assert/strict');
const RM = require('../../../rotatingMachine/src/specificClass');
const MGC = require('../../src/specificClass');
const { getOutput } = require('../../src/io/output');
const PUMP_MODEL = 'hidrostal-H05K-S03R';
const HEADER_DP_MBAR = 1100;
// stateConfig.time = 0 for every transition so warmup/cooldown don't add real
// seconds — without this the 4-demand sweep × 3 pumps takes >120s and the test
// runner kills it.
const INSTANT_STATE = {
time: { starting: 0, warmingup: 0, operational: 0, accelerating: 0,
decelerating: 0, stopping: 0, coolingdown: 0, idle: 0,
maintenance: 0, emergencystop: 0, off: 0 },
};
function mkPump(id) {
return new RM({
general: { id, name: id },
asset: { model: PUMP_MODEL, unit: 'm3/h' },
}, INSTANT_STATE);
}
async function buildGroupWithPressure() {
const mgc = new MGC({
general: { id: 'mgc', name: 'mgc' },
functionality: { mode: { current: 'optimalControl' }, positionVsParent: 'atEquipment' },
});
const pumps = ['A','B','C'].map(l => mkPump(`pump-${l}`));
for (const p of pumps) {
mgc.childRegistrationUtils?.registerChild?.(p, 'atEquipment');
}
for (const p of pumps) {
p.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'sim-up' });
p.updateMeasuredPressure(HEADER_DP_MBAR, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'sim-dn' });
}
// Let pressure events propagate through the emitter chain.
await new Promise(r => setTimeout(r, 50));
return { mgc, pumps };
}
// Settle to 'ready' between demands. The rendezvous lock defers a new setpoint
// that arrives while the group is still 'working', so each sweep step must wait
// for the previous move to land before issuing (and reading) the next.
async function waitReady(mgc, timeoutMs = 6000) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
if (mgc.getMovementState?.() === 'ready') return true;
try { await mgc.movementExecutor?.tick?.(); } catch { /* ignore */ }
await new Promise(r => setTimeout(r, 40));
}
return false;
}
async function sweepDemand(mgc, demands_m3h) {
const rows = [];
for (const Qd_m3h of demands_m3h) {
const Qd = Qd_m3h / 3600; // m3/h → m3/s
try { await mgc.handleInput('parent', Qd); }
catch (e) { /* turnOff or no-combination paths are part of the contract */ }
await waitReady(mgc);
const out = getOutput(mgc);
rows.push({
demand: Qd_m3h,
flow: out.atEquipment_predicted_flow,
eta: out.atEquipment_predicted_efficiency,
absDist: out.absDistFromPeak,
relDist: out.relDistFromPeak,
ncog: out.atEquipment_predicted_Ncog,
nAct: out.machineCountActive,
});
}
return rows;
}
test('absDistFromPeak rises when demand pushes pumps off BEP', async () => {
const { mgc } = await buildGroupWithPressure();
// Sweep covers "comfortably within combined BEP" (low/mid) and "over the
// group's BEP envelope, pumps must push" (high). For hidrostal-H05K-S03R
// at 1100 mbar, single-pump max ≈ 230 m³/h, 3-pump max ≈ 680 m³/h. Demand
// 600 m³/h forces each pump well past BEP.
const rows = await sweepDemand(mgc, [100, 200, 300, 600]);
// Sanity: pumps actually accepted the demand and flow is rising.
assert.ok(rows[3].flow > rows[0].flow + 100,
`flow should rise with demand, got ${JSON.stringify(rows.map(r => r.flow))}`);
// absDist should be larger at over-capacity demand than at within-capacity.
// Use a generous tolerance — the test asserts the QUALITATIVE shape, not
// exact numbers (which depend on curve interpolation).
const lowAbs = Math.min(rows[0].absDist, rows[1].absDist, rows[2].absDist);
const highAbs = rows[3].absDist;
assert.ok(highAbs > lowAbs + 0.005,
`absDistFromPeak should be larger off-BEP than on-BEP. ` +
`low (Qd∈{100,200,300}): min=${lowAbs}, high (Qd=600): ${highAbs}. ` +
`Full rows: ${JSON.stringify(rows, null, 2)}`);
});
test('absDistFromPeak ≈ 0 across the within-BEP demand range (working as designed)', async () => {
const { mgc } = await buildGroupWithPressure();
const rows = await sweepDemand(mgc, [100, 200, 300]);
// The BEP-Gravitation optimizer is supposed to KEEP us at BEP for demands
// the group can absorb at BEP. So absDist staying near zero across the
// "easy" range is the correct outcome — NOT a bug. This test pins that
// behaviour so any future "fix" that introduces drift here fails.
for (const r of rows) {
assert.ok(r.absDist != null && r.absDist < 0.02,
`at demand ${r.demand} m³/h, absDist=${r.absDist} should be near zero ` +
`(optimizer holds BEP); only off-BEP demand should produce noticeable drift`);
}
});
test('relDistFromPeak is structurally ill-defined for homogeneous pump groups', async () => {
const { mgc } = await buildGroupWithPressure();
const rows = await sweepDemand(mgc, [100, 200, 300, 600]);
// 3 identical pumps → all cogs equal → max=mean=min in calcDistanceBEP.
// The interpolation [max..min] → [0..1] collapses; the metric is
// mathematically undefined here. Whatever value comes out is float-noise
// dependent and MUST NOT be interpreted as "BEP distance percentage".
// This test documents the limitation as a contract; it deliberately does
// not assert a specific value — it asserts the metric does NOT move
// monotonically with demand (which it shouldn't for identical pumps).
const uniqueRel = new Set(rows.map(r => r.relDist));
assert.ok(uniqueRel.size <= 2,
`relDistFromPeak is expected to be effectively constant for identical pumps. ` +
`Distinct values across sweep: ${[...uniqueRel].join(', ')}. ` +
`If you want this metric to track demand, configure pumps with different ` +
`peak η (different models or different curve scaling).`);
});

View File

@@ -0,0 +1,251 @@
// Output-coverage tests for examples/02-Dashboard.json :: fn_status_split.
// Exercises every output port in three states (deploy / post-setup / post-demand)
// AND verifies the per-port format contract that every downstream ui-* widget
// or chart expects. Per .claude/rules/output-coverage.md.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const flow = JSON.parse(fs.readFileSync(
path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8'));
const fn = flow.find(n => n.id === 'fn_status_split');
function runFn(msgs) {
let ctxStore = {};
const context = {
get: (k) => ctxStore[k],
set: (k, v) => { ctxStore[k] = v; },
};
const fn_body = new Function('msg', 'context', fn.func);
return msgs.map(msg => fn_body(msg, context));
}
// Indices into the 18-output return array. Kept here as the manifest contract
// for this function — every test below references these names, never raw ints.
const PORT = {
text_mode: 0, text_flow: 1, text_power: 2, text_capacity: 3,
text_machines: 4, text_bep_rel: 5, text_eta: 6, text_eta_peak: 7,
text_bep_abs: 8, text_ncog: 9,
chart_flow: 10, chart_capacity: 11, chart_power: 12, chart_bep_rel: 13,
chart_eta: 14,
raw_rows: 15, raw_passthrough: 16,
chart_pctcap: 17,
};
const initialMsg = {
payload: {
mode: 'optimalControl', scaling: 'normalized',
absDistFromPeak: 0, relDistFromPeak: 0,
flowCapacityMax: 0, flowCapacityMin: 0,
machineCount: 3, machineCountActive: 0,
},
};
const postSetupMsg = {
payload: {
atEquipment_predicted_flow: 0, downstream_predicted_flow: 0,
atEquipment_predicted_power: 0,
flowCapacityMax: 450, flowCapacityMin: 0,
machineCountActive: 0,
headerDiffPa: 110000, headerDiffMbar: 1100,
},
};
const postDemandMsg = {
payload: {
atEquipment_predicted_flow: 200,
downstream_predicted_flow: 200,
atEquipment_predicted_power: 11.4,
atEquipment_predicted_efficiency: 0.62,
// Ncog as MGC actually emits it: SUM of per-pump NCog values.
// 2 pumps each at NCog=0.6 → sum=1.2; per-pump average should display as 60.0 %.
atEquipment_predicted_Ncog: 1.2,
absDistFromPeak: 0.05, relDistFromPeak: 0.08,
flowCapacityMax: 450, machineCountActive: 2,
},
};
test('manifest: function has exactly 18 outputs and wires array matches', () => {
assert.equal(fn.outputs, 18);
assert.equal(fn.wires.length, 18);
});
test('State A (deploy-time): no AT_EQUIPMENT keys → flow/power text show em-dash', () => {
const [out] = runFn([initialMsg]);
assert.equal(out[PORT.text_mode].payload, 'optimalControl');
assert.equal(out[PORT.text_flow].payload, '—');
assert.equal(out[PORT.text_power].payload, '—');
assert.equal(out[PORT.text_ncog].payload, '—');
assert.equal(out[PORT.text_eta].payload, '—');
});
test('State A: charts with no source data emit null msg, never { payload: null }', () => {
const [out] = runFn([initialMsg]);
// Charts 10, 12, 14 have no source data in State A → must be null (drop msg).
assert.equal(out[PORT.chart_flow], null, 'chart_flow must be null when flow missing');
assert.equal(out[PORT.chart_power], null, 'chart_power must be null when power missing');
assert.equal(out[PORT.chart_eta], null, 'chart_eta must be null when eta missing');
// For every msg-emitting chart output: payload is never literally null.
for (const idx of Object.values(PORT)) {
if (out[idx] && Object.prototype.hasOwnProperty.call(out[idx], 'payload')) {
assert.notEqual(out[idx].payload, null,
`port ${idx} emitted { payload: null } — would crash ui-chart`);
}
}
});
test('State B (post-setup, no demand): flow/power = 0, eta missing', () => {
const [, out] = runFn([initialMsg, postSetupMsg]);
assert.equal(out[PORT.text_flow].payload, '0.0 m³/h');
assert.equal(out[PORT.text_power].payload, '0.00 kW');
assert.equal(out[PORT.text_capacity].payload, '0.0 450.0 m³/h');
// η still missing → '—'
assert.equal(out[PORT.text_eta].payload, '—');
});
test('State C (post-demand): every text/chart output has real value', () => {
const [, , out] = runFn([initialMsg, postSetupMsg, postDemandMsg]);
assert.equal(out[PORT.text_flow].payload, '200.0 m³/h');
assert.equal(out[PORT.text_power].payload, '11.40 kW');
assert.equal(out[PORT.text_eta].payload, '62.0 %');
// BEP abs gap: η-points dimensionless, 3 dp.
assert.equal(out[PORT.text_bep_abs].payload, '0.050');
// Charts have numeric payload.
assert.equal(out[PORT.chart_flow].payload, 200);
assert.equal(out[PORT.chart_power].payload, 11.4);
assert.equal(out[PORT.chart_eta].payload, 62);
// % of capacity = flow / flowCapacityMax × 100 = 200 / 450 × 100 ≈ 44.44.
assert.equal(out[PORT.chart_pctcap].topic, '% of capacity');
assert.ok(Math.abs(out[PORT.chart_pctcap].payload - (200 / 450) * 100) < 1e-6);
});
test('% of capacity chart: drops msg when flow or capacity missing (no payload:null)', () => {
// State A: no flow + flowCapacityMax=0 → pctCap undefined → chart() returns
// null so the function node skips the output, never { payload: null }.
const [out] = runFn([initialMsg]);
assert.equal(out[PORT.chart_pctcap], null, 'chart_pctcap must drop msg when source missing');
});
test('NCog formatter: SUM is normalized by machineCountActive before display', () => {
// The fix under test. MGC emits Ncog as the SUM of per-pump NCog values
// (range 0..N), so a raw pct() would display 120% for 2 pumps at 0.6 each.
// The formatter must divide by machineCountActive first.
const [, , out] = runFn([initialMsg, postSetupMsg, postDemandMsg]);
// 2 pumps × 0.6 each = sum 1.2, mean 0.6, displayed "60.0 %".
assert.equal(out[PORT.text_ncog].payload, '60.0 %');
});
test('NCog formatter: ncogSum=0 with active pumps → 0.0 %, not em-dash', () => {
const msg = { payload: { ...postSetupMsg.payload,
atEquipment_predicted_Ncog: 0, machineCountActive: 3 } };
const [out] = runFn([msg]);
// Today this is exactly what the live MGC emits (per-pump groupNCog=0
// for the hidrostal-H05K-S03R curve at 110 kPa). The dashboard must show
// a clean "0.0 %" — not "—" — because we DO have data, it's just zero.
assert.equal(out[PORT.text_ncog].payload, '0.0 %');
});
test('NCog formatter: ncogSum present but machineCountActive = 0 → em-dash (no /0)', () => {
const msg = { payload: { atEquipment_predicted_Ncog: 1.5, machineCountActive: 0 } };
const [out] = runFn([msg]);
assert.equal(out[PORT.text_ncog].payload, '—');
});
test('NCog formatter: ncogSum present but machineCountActive missing → em-dash', () => {
const msg = { payload: { atEquipment_predicted_Ncog: 1.5 /* no nAct */ } };
const [out] = runFn([msg]);
assert.equal(out[PORT.text_ncog].payload, '—');
});
test('NCog formatter: 3 pumps each at NCog=0.5 (sum 1.5) → 50.0 %, not 150 %', () => {
// Regression test for the bug class — the formatter was displaying sum × 100,
// so 1.5 became "150.0 %". Verify the normalization sticks.
const msg = { payload: {
atEquipment_predicted_Ncog: 1.5,
machineCountActive: 3,
} };
const [out] = runFn([msg]);
assert.equal(out[PORT.text_ncog].payload, '50.0 %');
});
test('BEP rel%: undefined bepRel → "—" (degenerate homogeneous-pump case)', () => {
// After today's groupEfficiency fix, MGC emits relDistFromPeak=undefined when
// pumps are identical. The dashboard text formatter must display "—" — NOT
// "0.0 %" via the +null === 0 trap.
const msg = { payload: { mode: 'optimalControl', relDistFromPeak: undefined } };
const [out] = runFn([msg]);
assert.equal(out[PORT.text_bep_rel].payload, '—');
});
test('BEP rel%: null bepRel → "—" (defensive against null emission)', () => {
// Same trap as the NCog fix: +null === 0 → pct() would return "0.0 %".
const msg = { payload: { relDistFromPeak: null } };
const [out] = runFn([msg]);
assert.equal(out[PORT.text_bep_rel].payload, '—');
});
test('BEP rel% chart: drops msg when bepRel is null/undefined (no payload:null)', () => {
const msg = { payload: { relDistFromPeak: undefined } };
const [out] = runFn([msg]);
assert.equal(out[PORT.chart_bep_rel], null, 'chart must drop msg when bepRel missing');
});
// ── fn_qh_fanout: Q-H curve → chart points ────────────────────────────
const fnQH = flow.find(n => n.id === 'fn_qh_fanout');
function runFanout(payload) {
const fn_body = new Function('msg', fnQH.func);
return fn_body({ payload });
}
test('Q-H fanout: trims trailing flat-Q tail so chart axis doesn\'t blow up', () => {
// Synthetic input mimics buildQHCurve at low ctrl%: useful range followed by
// a horizontal tail (Q clamped to env minimum across high H).
const points = [
{ Q: 100, H: 7 }, { Q: 80, H: 10 }, { Q: 50, H: 15 },
{ Q: 20, H: 20 }, { Q: 9.5, H: 24 }, { Q: 9.5, H: 28 },
{ Q: 9.5, H: 32 }, { Q: 9.5, H: 36 }, { Q: 9.5, H: 40 },
];
const [out] = runFanout({ points });
const curvePoints = out.filter(m => m.topic === 'Curve' && m.payload);
// The 5 tail points at Q=9.5 should collapse to (at most) one — the first
// one to mark the curve's tail entry, not all five.
const tailPoints = curvePoints.filter(p => p.payload.Q === 9.5 || p.payload.x === 9.5);
assert.ok(tailPoints.length <= 1,
`expected ≤1 flat-tail point, got ${tailPoints.length}: ${JSON.stringify(curvePoints)}`);
});
test('Q-H fanout: still emits the rising portion of the curve unchanged', () => {
const points = [
{ Q: 100, H: 7 }, { Q: 80, H: 10 }, { Q: 50, H: 15 }, { Q: 20, H: 20 },
{ Q: 9.5, H: 24 }, { Q: 9.5, H: 28 }, // flat tail
];
const [out] = runFanout({ points });
const curvePoints = out.filter(m => m.topic === 'Curve' && m.payload);
const rising = curvePoints.filter(p => p.payload.x > 10);
assert.equal(rising.length, 4, `expected 4 rising points, got ${rising.length}`);
// First rising point preserves Q=100, H=7.
assert.equal(rising[0].payload.x, 100);
assert.equal(rising[0].payload.y, 7);
});
test('Q-H fanout: empty/error input → null msg', () => {
assert.equal(runFanout({ error: 'no curve', points: [] }), null);
assert.equal(runFanout({ points: [] }), null);
});
test('contract: no output ever emits { payload: null } for any of the three states', () => {
// The original η-null bug. Re-asserted across all three states because a
// regression here crashes the FlowFuse ui-chart with TypeError on .y.
const states = runFn([initialMsg, postSetupMsg, postDemandMsg]);
for (let s = 0; s < states.length; s++) {
const out = states[s];
for (let i = 0; i < out.length; i++) {
const msg = out[i];
if (msg && Object.prototype.hasOwnProperty.call(msg, 'payload')) {
assert.notEqual(msg.payload, null,
`state ${s} port ${i} → { payload: null } would crash ui-chart`);
}
}
}
});

View File

@@ -49,7 +49,7 @@ function machineConfig(id) {
return { return {
general: { logging: logCfg, name: id, id, unit: 'm3/h' }, general: { logging: logCfg, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' }, 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: { mode: {
current: 'auto', current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] }, allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
@@ -67,8 +67,10 @@ function groupConfig() {
return { return {
general: { logging: logCfg, name: 'mgc', id: 'mgc' }, general: { logging: logCfg, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
scaling: { current: 'normalized' }, // demand expressed as 0..100 %
mode: { current: 'optimalcontrol' }, // production mode mode: { current: 'optimalcontrol' }, // production mode
// No scaling config: post-refactor MGC has no scaling state. handleInput
// takes canonical m³/s. Test converts pct → m³/s before dispatch (mirrors
// what the set.demand handler does for bare-number payloads).
}; };
} }
@@ -159,24 +161,33 @@ test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps,
console.log(`MGC station envelope at head ${HEAD_MBAR} mbar (${N_PUMPS} pumps):`); console.log(`MGC station envelope at head ${HEAD_MBAR} mbar (${N_PUMPS} pumps):`);
console.log(` per-pump: ${perPumpMin_m3h.toFixed(1)} .. ${perPumpMax_m3h.toFixed(1)} m³/h`); console.log(` per-pump: ${perPumpMin_m3h.toFixed(1)} .. ${perPumpMax_m3h.toFixed(1)} m³/h`);
console.log(` station: ${flowMin_m3h.toFixed(1)} .. ${flowMax_m3h.toFixed(1)} m³/h`); console.log(` station: ${flowMin_m3h.toFixed(1)} .. ${flowMax_m3h.toFixed(1)} m³/h`);
console.log(` scaling=normalized: 0% → ${flowMin_m3h.toFixed(1)} m³/h, 100% → ${flowMax_m3h.toFixed(1)} m³/h`); console.log(` 0% → ${flowMin_m3h.toFixed(1)} m³/h, 100% → ${flowMax_m3h.toFixed(1)} m³/h`);
console.log(` (demand 0% turns ALL pumps off — see MGC handleInput)`); console.log(` (demand < 0 turns ALL pumps off; 0 = minimum-control floor)`);
console.log(''); console.log('');
printHeader(pumps); printHeader(pumps);
// Build demand sweep: 0..100% up, then 100..0% down. // Build demand sweep: 0..100% up, then 100..0% down, then -1 (all-off sentinel).
const upSteps = []; const upSteps = [];
for (let pct = 0; pct <= 100 + 1e-9; pct += STEP_PERCENT) upSteps.push(Math.min(pct, 100)); for (let pct = 0; pct <= 100 + 1e-9; pct += STEP_PERCENT) upSteps.push(Math.min(pct, 100));
const downSteps = upSteps.slice(0, -1).reverse(); // skip the duplicate 100 const downSteps = upSteps.slice(0, -1).reverse(); // skip the duplicate 100
const sequence = [...upSteps, ...downSteps]; const sequence = [...upSteps, ...downSteps, -1];
let stuckSeen = 0; let stuckSeen = 0;
for (const pct of sequence) { for (const pct of sequence) {
await mgc.handleInput('parent', pct); // Post-refactor handleInput takes canonical m³/s; the percent → m³/s
// mapping the set.demand handler does is replicated here in test.
if (pct < 0) {
await mgc.turnOffAllMachines();
} else {
const flowMin_m3s = flowMin_m3h / 3600;
const flowMax_m3s = flowMax_m3h / 3600;
const canonical = flowMin_m3s + (pct / 100) * (flowMax_m3s - flowMin_m3s);
await mgc.handleInput('parent', canonical);
}
await sleep(DWELL_MS); await sleep(DWELL_MS);
// Mirror MGC's normalized→absolute mapping for the printed Qd column. // pct < 0 → all off (Qd = 0); pct >= 0 → linear interpolation across [min, max].
const demandQout_m3h = pct <= 0 const demandQout_m3h = pct < 0
? 0 ? 0
: (flowMax_m3h - flowMin_m3h) * (pct / 100) + flowMin_m3h; : (flowMax_m3h - flowMin_m3h) * (pct / 100) + flowMin_m3h;
@@ -194,11 +205,11 @@ test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps,
if (s.state === 'accelerating' || s.state === 'decelerating') stuckSeen += 1; if (s.state === 'accelerating' || s.state === 'decelerating') stuckSeen += 1;
} }
if (pct === 0) { if (pct < 0) {
// Demand 0% must turn ALL pumps off (or to a non-running state). // Strict negative demand turns ALL pumps off (the explicit "all off" signal).
for (const s of snaps) { for (const s of snaps) {
assert.ok(['idle', 'off', 'stopping', 'coolingdown'].includes(s.state), assert.ok(['idle', 'off', 'stopping', 'coolingdown'].includes(s.state),
`demand 0% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`); `demand ${pct}% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`);
} }
} }
} }

View File

@@ -26,7 +26,7 @@ function machineConfig(id, model) {
return { return {
general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' }, general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' }, functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal', model, supplier: 'hidrostal' }, asset: { model, unit: 'm3/h' },
mode: { mode: {
current: 'auto', current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] }, allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
@@ -44,7 +44,7 @@ function groupConfig() {
return { return {
general: { logging: { enabled: false, logLevel: 'error' }, name: 'station' }, general: { logging: { enabled: false, logLevel: 'error' }, name: 'station' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
scaling: { current: 'absolute' }, // No scaling field — handleInput always takes canonical m³/s post-refactor.
mode: { current: 'optimalcontrol' } mode: { current: 'optimalcontrol' }
}; };
} }
@@ -139,7 +139,6 @@ test('machineGroupControl vs naive baselines — real curves, verified flow', as
// Run machineGroupControl optimalControl with absolute scaling // Run machineGroupControl optimalControl with absolute scaling
mg.setMode('optimalcontrol'); mg.setMode('optimalcontrol');
mg.setScaling('absolute');
mg.calcAbsoluteTotals(); mg.calcAbsoluteTotals();
mg.calcDynamicTotals(); mg.calcDynamicTotals();
await mg.handleInput('parent', Qd); await mg.handleInput('parent', Qd);
@@ -196,7 +195,6 @@ test('machineGroupControl vs naive baselines — real curves, verified flow', as
injectPressure(m); injectPressure(m);
} }
mg.setMode('optimalcontrol'); mg.setMode('optimalcontrol');
mg.setScaling('absolute');
mg.calcAbsoluteTotals(); mg.calcAbsoluteTotals();
mg.calcDynamicTotals(); mg.calcDynamicTotals();
await mg.handleInput('parent', Qd); await mg.handleInput('parent', Qd);

View File

@@ -0,0 +1,93 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
const Machine = require('../../../rotatingMachine/src/specificClass');
const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json');
/**
* After fixing rotatingMachine + MGC to use hydraulic efficiency
* (η = Q·ΔP / P_shaft) instead of raw flow/power, every BEP-related output
* on MGC should be in the dimensionless 0..1 range and respond to demand
* changes. This check ties the whole chain together:
* - per-machine cog updates after equalize
* - group efficiency measurement is hydraulic (matches scale of cogs)
* - calcDistanceBEP(eff, mean(cog), min(cog)) is non-degenerate
*/
const stateConfig = {
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 },
};
function machineConfig(id, label) {
return {
general: { logging: { enabled: false, logLevel: 'error' }, name: label, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
allowedSources: { auto: ['parent', 'GUI'] },
},
sequences: {
startup: ['starting', 'warmingup', 'operational'],
shutdown: ['stopping', 'coolingdown', 'idle'],
emergencystop: ['emergencystop', 'off'],
},
};
}
function groupConfig() {
return {
general: { logging: { enabled: false, logLevel: 'error' }, name: 'TestGroup' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
mode: { current: 'optimalcontrol' },
};
}
async function setupGroupWithTwoPumps() {
const m1 = new Machine(machineConfig(1, 'pump-1'), stateConfig);
const m2 = new Machine(machineConfig(2, 'pump-2'), stateConfig);
m1.config.asset.machineCurve = baseCurve;
m2.config.asset.machineCurve = baseCurve;
await m1.handleInput('parent', 'execSequence', 'startup');
await m2.handleInput('parent', 'execSequence', 'startup');
const mgc = new MachineGroup(groupConfig(), stateConfig);
// Mutate the existing machines object — replacing the reference would
// strand operatingPoint/totals/efficiency on the original empty bag.
mgc.machines[1] = m1;
mgc.machines[2] = m2;
// Set header (system) pressure differential: 800/1200 mbar => 400 mbar = 40 kPa
mgc.measurements.type('pressure').variant('measured').position('upstream').value(80000, Date.now(), 'Pa');
mgc.measurements.type('pressure').variant('measured').position('downstream').value(120000, Date.now(), 'Pa');
mgc.operatingPoint.equalize();
return { mgc, m1, m2 };
}
test('after equalize, each child cog is a dimensionless 0..1 hydraulic efficiency', async () => {
const { m1, m2 } = await setupGroupWithTwoPumps();
// Trigger updatePosition by setting ctrl explicitly
m1.updatePosition();
m2.updatePosition();
for (const m of [m1, m2]) {
assert.ok(Number.isFinite(m.cog), `cog must be finite, got ${m.cog}`);
assert.ok(m.cog >= 0 && m.cog <= 1.0,
`cog must be a 0..1 hydraulic efficiency, got ${m.cog}`);
}
});
test('operatingPoint.headerDiffPa is set by equalize and matches measured differential', async () => {
const { mgc, m1 } = await setupGroupWithTwoPumps();
// Equalize reads from host measurements; falls back to children when
// header is missing. Either path should produce headerDiffPa > 0.
// headerDiff must equal the measured differential (40 kPa) once any
// pressure source is populated.
assert.equal(mgc.operatingPoint.headerDiffPa, 40000,
`headerDiffPa should equal downstream-upstream = 40000 Pa, got ${mgc.operatingPoint.headerDiffPa}`);
// Sanity: the host's child reference is still consumable for diagnostics.
void m1.measurements;
});

View File

@@ -39,7 +39,7 @@ function machineConfig(id) {
return { return {
general: { logging: logCfg, name: id, id, unit: 'm3/h' }, general: { logging: logCfg, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' }, 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: { mode: {
current: 'auto', current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] }, allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
@@ -57,11 +57,20 @@ function groupConfig() {
return { return {
general: { logging: logCfg, name: 'mgc', id: 'mgc' }, general: { logging: logCfg, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
scaling: { current: 'normalized' },
mode: { current: 'optimalcontrol' }, mode: { current: 'optimalcontrol' },
}; };
} }
// Post-refactor handleInput takes canonical m³/s. This helper mirrors what
// the set.demand handler does for a bare-number (percent) payload, so test
// scenarios that previously sent `mgc.handleInput('parent', pctToCanonical(mgc, 100))` (= 100 %)
// keep their intent.
function pctToCanonical(mgc, pct) {
if (pct < 0) return -1;
const dt = mgc.calcDynamicTotals();
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
}
function buildGroup({ withPressure = true } = {}) { function buildGroup({ withPressure = true } = {}) {
const mgc = new MachineGroup(groupConfig()); const mgc = new MachineGroup(groupConfig());
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`); const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
@@ -137,7 +146,7 @@ test('Scenario 1 — single-shot 100% demand to idle pumps', async () => {
console.log(`\n[Scenario 1] head=${HEAD_MBAR_DOWN} mbar, time.starting=${stateConfig.time.starting}s, time.warmingup=${stateConfig.time.warmingup}s`); console.log(`\n[Scenario 1] head=${HEAD_MBAR_DOWN} mbar, time.starting=${stateConfig.time.starting}s, time.warmingup=${stateConfig.time.warmingup}s`);
printSnapshots('before handleInput', pumps); printSnapshots('before handleInput', pumps);
await mgc.handleInput('parent', 100); await mgc.handleInput('parent', pctToCanonical(mgc, 100));
printSnapshots('immediately after handleInput returns', pumps); printSnapshots('immediately after handleInput returns', pumps);
// Wait for full startup (3s) + movement (~0.5s) + slack // Wait for full startup (3s) + movement (~0.5s) + slack
@@ -159,16 +168,16 @@ test('Scenario 2 — rapid 100% retargeting during startup window', async () =>
// mid-flight, parking it in 'accelerating'/'decelerating'. // mid-flight, parking it in 'accelerating'/'decelerating'.
const { mgc, pumps } = buildGroup(); const { mgc, pumps } = buildGroup();
console.log(`\n[Scenario 2] firing mgc.handleInput('parent', 100) every 200ms for 5s`); console.log(`\n[Scenario 2] firing mgc.handleInput('parent', pctToCanonical(mgc, 100)) every 200ms for 5s`);
printSnapshots('before any handleInput', pumps); printSnapshots('before any handleInput', pumps);
// First call (kicks off startup); not awaited so retargets can layer on. // First call (kicks off startup); not awaited so retargets can layer on.
mgc.handleInput('parent', 100).catch(e => console.log(`first call rejected: ${e.message}`)); mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(e => console.log(`first call rejected: ${e.message}`));
// Spam additional retargets every 200ms for 5s — covers the 3s startup // Spam additional retargets every 200ms for 5s — covers the 3s startup
// window with 25 extra retargeting calls. // window with 25 extra retargeting calls.
const interval = setInterval(() => { const interval = setInterval(() => {
mgc.handleInput('parent', 100).catch(e => console.log(`retarget rejected: ${e.message}`)); mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(e => console.log(`retarget rejected: ${e.message}`));
}, 200); }, 200);
await sleep(5000); await sleep(5000);
clearInterval(interval); clearInterval(interval);
@@ -199,7 +208,7 @@ test('Scenario 3 — pumps with NO pressure measurements injected', async () =>
console.log(`\n[Scenario 3] no pressure injected. per-pump curve envelope: ${minQ.toFixed(1)} .. ${maxQ.toFixed(1)} m³/h, station: ${(dyn.flow.min*3600).toFixed(1)} .. ${(dyn.flow.max*3600).toFixed(1)} m³/h`); console.log(`\n[Scenario 3] no pressure injected. per-pump curve envelope: ${minQ.toFixed(1)} .. ${maxQ.toFixed(1)} m³/h, station: ${(dyn.flow.min*3600).toFixed(1)} .. ${(dyn.flow.max*3600).toFixed(1)} m³/h`);
printSnapshots('before handleInput', pumps); printSnapshots('before handleInput', pumps);
await mgc.handleInput('parent', 100); await mgc.handleInput('parent', pctToCanonical(mgc, 100));
await sleep(6000); await sleep(6000);
printSnapshots('after 6s settle (no pressure)', pumps); printSnapshots('after 6s settle (no pressure)', pumps);
@@ -228,7 +237,7 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
printSnapshots('before any handleInput', pumps); printSnapshots('before any handleInput', pumps);
// Phase 1: drive up to 100% from idle. // Phase 1: drive up to 100% from idle.
await mgc.handleInput('parent', 100); await mgc.handleInput('parent', pctToCanonical(mgc, 100));
await sleep(5000); // full startup + ramp await sleep(5000); // full startup + ramp
printSnapshots('after settle at 100%', pumps); printSnapshots('after settle at 100%', pumps);
for (const p of pumps) { for (const p of pumps) {
@@ -236,12 +245,14 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
`Phase 1: pump ${p.config.general.id} not operational at 100% (got ${p.state.getCurrentState()})`); `Phase 1: pump ${p.config.general.id} not operational at 100% (got ${p.state.getCurrentState()})`);
} }
// Phase 2: demand drops to 0% — pumps begin shutdown sequence. // Phase 2: demand drops below 0 — pumps begin shutdown sequence. Use a
// FIRE-AND-FORGET: handleInput(0) awaits turnOffAllMachines which // strictly-negative percent because 0% now means "minimum-control"
// (interpolates to dt.flow.min), not shutdown.
// FIRE-AND-FORGET: handleInput(-1) awaits turnOffAllMachines which
// awaits the full per-pump shutdown sequence. We need the next 100% // awaits the full per-pump shutdown sequence. We need the next 100%
// demand to arrive WHILE pumps are still in stopping/coolingdown, // demand to arrive WHILE pumps are still in stopping/coolingdown,
// not after they've reached idle. // not after they've reached idle.
mgc.handleInput('parent', 0).catch(e => console.log(`0% rejected: ${e.message}`)); mgc.turnOffAllMachines().catch(e => console.log(`-1% rejected: ${e.message}`));
// Wait briefly so the shutdown sequence enters but does NOT complete. // Wait briefly so the shutdown sequence enters but does NOT complete.
// shutdown=['stopping','coolingdown','idle'] with stopping=1s, // shutdown=['stopping','coolingdown','idle'] with stopping=1s,
// coolingdown=2s. 500ms puts us solidly inside 'stopping'. // coolingdown=2s. 500ms puts us solidly inside 'stopping'.
@@ -252,7 +263,7 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
console.log(` states mid-shutdown: ${midShutdownStates.join(', ')}`); console.log(` states mid-shutdown: ${midShutdownStates.join(', ')}`);
// Phase 3: demand returns to 100% while pumps are mid-shutdown. // Phase 3: demand returns to 100% while pumps are mid-shutdown.
await mgc.handleInput('parent', 100); await mgc.handleInput('parent', pctToCanonical(mgc, 100));
// Generous: full coolingdown remaining + full startup + ramp. // Generous: full coolingdown remaining + full startup + ramp.
await sleep(8000); await sleep(8000);
printSnapshots('after re-engage to 100%', pumps); printSnapshots('after re-engage to 100%', pumps);
@@ -279,7 +290,7 @@ test('Scenario 6 — full up sweep then full down sweep', async () => {
console.log(' --- up sweep ---'); console.log(' --- up sweep ---');
for (const pct of upSteps) { for (const pct of upSteps) {
mgc.handleInput('parent', pct).catch(e => console.log(`up ${pct}% rejected: ${e.message}`)); mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`up ${pct}% rejected: ${e.message}`));
await sleep(600); await sleep(600);
const snaps = pumps.map(snapshot); const snaps = pumps.map(snapshot);
const totalQ = snaps.reduce((s, x) => s + x.flow, 0); const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
@@ -291,7 +302,7 @@ test('Scenario 6 — full up sweep then full down sweep', async () => {
console.log(' --- down sweep ---'); console.log(' --- down sweep ---');
for (const pct of downSteps) { for (const pct of downSteps) {
mgc.handleInput('parent', pct).catch(e => console.log(`down ${pct}% rejected: ${e.message}`)); mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`down ${pct}% rejected: ${e.message}`));
await sleep(600); await sleep(600);
const snaps = pumps.map(snapshot); const snaps = pumps.map(snapshot);
const totalQ = snaps.reduce((s, x) => s + x.flow, 0); const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
@@ -340,7 +351,7 @@ test('Scenario 4 — varying demand during startup (combo flips)', async () => {
for (const pct of sequence) { for (const pct of sequence) {
console.log(` → demand ${pct}%`); console.log(` → demand ${pct}%`);
mgc.handleInput('parent', pct).catch(e => console.log(`call ${pct}% rejected: ${e.message}`)); mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`call ${pct}% rejected: ${e.message}`));
await sleep(400); await sleep(400);
} }

View File

@@ -27,6 +27,19 @@ const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/h
/* ---- helpers ---- */ /* ---- helpers ---- */
// Settle the group to 'ready'. The rendezvous lock defers a setpoint arriving
// while the group is still 'working', so a full-MGC test must wait for each
// move to land before reading steady state or issuing the next demand.
async function waitReady(mgc, timeoutMs = 6000) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
if (mgc.getMovementState?.() === 'ready') return true;
try { await mgc.movementExecutor?.tick?.(); } catch { /* ignore */ }
await new Promise(r => setTimeout(r, 40));
}
return false;
}
function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); } function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }
function distortSeries(series, scale = 1, tilt = 0) { function distortSeries(series, scale = 1, tilt = 0) {
@@ -54,7 +67,7 @@ function createMachineConfig(id, label) {
return { return {
general: { logging: { enabled: false, logLevel: 'error' }, name: label, id, unit: 'm3/h' }, general: { logging: { enabled: false, logLevel: 'error' }, name: label, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' }, 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: { mode: {
current: 'auto', current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] }, allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
@@ -72,7 +85,6 @@ function createGroupConfig(name) {
return { return {
general: { logging: { enabled: false, logLevel: 'error' }, name }, general: { logging: { enabled: false, logLevel: 'error' }, name },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
scaling: { current: 'normalized' },
mode: { current: 'optimalcontrol' } mode: { current: 'optimalcontrol' }
}; };
} }
@@ -407,10 +419,15 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
await m.handleInput('parent', 'execSequence', 'startup'); await m.handleInput('parent', 'execSequence', 'startup');
} }
// Run optimalControl // Run optimalControl. handleInput takes canonical m³/s post-refactor —
// mirror the set.demand handler's percent → canonical mapping inline.
mg.setMode('optimalcontrol'); mg.setMode('optimalcontrol');
mg.setScaling('normalized'); function pctCanonical(mgc, pct) {
await mg.handleInput('parent', 50, Infinity); const dt = mgc.calcDynamicTotals();
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
}
await mg.handleInput('parent', pctCanonical(mg, 50), Infinity);
await waitReady(mg); // rendezvous lock — let the move land before reading steady state
const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0; const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0; const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
@@ -419,10 +436,12 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
await m.handleInput('parent', 'execSequence', 'shutdown'); await m.handleInput('parent', 'execSequence', 'shutdown');
await m.handleInput('parent', 'execSequence', 'startup'); await m.handleInput('parent', 'execSequence', 'startup');
} }
await waitReady(mg); // ensure the group is settled so the next demand isn't deferred
// Run priorityControl // Run priorityControl
mg.setMode('prioritycontrol'); mg.setMode('prioritycontrol');
await mg.handleInput('parent', 50, Infinity, ['eff', 'std', 'weak']); await mg.handleInput('parent', pctCanonical(mg, 50), Infinity, ['eff', 'std', 'weak']);
await waitReady(mg);
const prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0; const prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0; const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;

View File

@@ -28,7 +28,7 @@ function machineConfig(id) {
return { return {
general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' }, general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' }, 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: { mode: {
current: 'auto', current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] }, allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
@@ -46,7 +46,6 @@ function groupConfig() {
return { return {
general: { logging: { enabled: false, logLevel: 'error' }, name: 'mgc', id: 'mgc' }, general: { logging: { enabled: false, logLevel: 'error' }, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
scaling: { current: 'absolute' }, // talk to MGC in m³/h directly
mode: { current: 'optimalcontrol' }, mode: { current: 'optimalcontrol' },
}; };
} }

View File

@@ -0,0 +1,117 @@
// Output-coverage tests for examples/02-Dashboard.json :: fn_chart_pump_a/b/c.
// These per-pump fan-out functions feed two charts:
// output 0 → ui_chart_per_pump_flow (topic = 'Pump A/B/C', payload = flow m³/h)
// output 1 → ui_chart_pumps_ctrl (topic = 'Pump A/B/C', payload = ctrl %)
// The ctrl output carries a -1 OFF sentinel: when the pump is off / idle /
// maintenance it is not running, so we plot -1 (below the 0100 band) to give
// the chart a clear OFF rail distinct from a pump genuinely running at 0%.
// Every output is exercised in populated AND degraded states per
// .claude/rules/output-coverage.md.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const flow = JSON.parse(fs.readFileSync(
path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8'));
const PUMPS = [
{ id: 'fn_chart_pump_a', topic: 'Pump A' },
{ id: 'fn_chart_pump_b', topic: 'Pump B' },
{ id: 'fn_chart_pump_c', topic: 'Pump C' },
];
const FLOW = 0; // output index → ui_chart_per_pump_flow
const CTRL = 1; // output index → ui_chart_pumps_ctrl
// Each fan-out caches Port 0 deltas in context('c'). Build a fresh runner per
// test so state never leaks between cases.
function makeRunner(node) {
let store = {};
const context = { get: (k) => store[k], set: (k, v) => { store[k] = v; } };
const body = new Function('msg', 'context', node.func);
return (payload) => body({ payload }, context);
}
// A populated downstream-flow key uses the 4-segment MeasurementContainer
// convention the function matches with find('flow.predicted.downstream.').
const flowKey = (id) => `flow.predicted.downstream.${id}`;
test('every per-pump fan-out has exactly 2 outputs wired to flow + ctrl charts', () => {
for (const { id } of PUMPS) {
const node = flow.find(n => n.id === id);
assert.ok(node, `${id} present in flow`);
assert.equal(node.outputs, 2, `${id} outputs`);
assert.equal(node.wires.length, 2, `${id} wires`);
assert.deepEqual(node.wires[FLOW], ['ui_chart_per_pump_flow'], `${id} flow wire`);
assert.deepEqual(node.wires[CTRL], ['ui_chart_pumps_ctrl'], `${id} ctrl wire`);
}
});
test('ui_chart_pumps_ctrl ymin is -5 so the OFF sentinel (-1) is visible', () => {
const chart = flow.find(n => n.id === 'ui_chart_pumps_ctrl');
assert.ok(chart, 'ui_chart_pumps_ctrl present');
assert.equal(chart.ymin, '-5');
assert.equal(chart.ymax, '100');
});
for (const { id, topic } of PUMPS) {
test(`${id}: populated running state → flow + ctrl carry real numbers`, () => {
const run = makeRunner(flow.find(n => n.id === id));
const out = run({ [flowKey(id)]: 478 / 3, ctrl: 72, state: 'operational' });
assert.deepEqual(out[FLOW], { topic, payload: 478 / 3 });
assert.deepEqual(out[CTRL], { topic, payload: 72 });
});
for (const offState of ['off', 'idle', 'maintenance']) {
test(`${id}: state '${offState}' → ctrl emits -1 sentinel (even if ctrl% is 0/stale)`, () => {
const run = makeRunner(flow.find(n => n.id === id));
// ctrl stale at 0 (or any residual) must be overridden by the sentinel.
const out = run({ [flowKey(id)]: 0, ctrl: 0, state: offState });
assert.deepEqual(out[CTRL], { topic, payload: -1 });
});
}
test(`${id}: degraded — no state, ctrl missing → ctrl output is null (drop, never payload:null)`, () => {
const run = makeRunner(flow.find(n => n.id === id));
const out = run({ [flowKey(id)]: 50 });
assert.equal(out[CTRL], null, 'ctrl must drop when no state and no ctrl');
// flow still present.
assert.deepEqual(out[FLOW], { topic, payload: 50 });
});
test(`${id}: degraded — no flow key → flow output is null (drop)`, () => {
const run = makeRunner(flow.find(n => n.id === id));
const out = run({ ctrl: 40, state: 'operational' });
assert.equal(out[FLOW], null, 'flow must drop when source key missing');
assert.deepEqual(out[CTRL], { topic, payload: 40 });
});
test(`${id}: pre-first-tick — empty payload → both outputs null, no payload:null`, () => {
const run = makeRunner(flow.find(n => n.id === id));
const out = run({});
assert.equal(out[FLOW], null);
assert.equal(out[CTRL], null);
for (const m of out) {
if (m && Object.prototype.hasOwnProperty.call(m, 'payload')) {
assert.notEqual(m.payload, null, `${id} emitted { payload: null }`);
}
}
});
test(`${id}: running ctrl with NaN/null ctrl value → ctrl drops (no payload:null)`, () => {
const run = makeRunner(flow.find(n => n.id === id));
assert.equal(run({ [flowKey(id)]: 10, ctrl: null, state: 'operational' })[CTRL], null);
assert.equal(run({ [flowKey(id)]: 10, ctrl: NaN, state: 'operational' })[CTRL], null);
});
test(`${id}: delta-cache holds last state so a ctrl-only delta still rails OFF`, () => {
// Realistic: pump first reports state:'off', then a later tick carries only
// a ctrl delta (no state). The cached 'off' must keep the sentinel engaged.
const run = makeRunner(flow.find(n => n.id === id));
run({ state: 'off', ctrl: 0 });
const out = run({ ctrl: 5 }); // ctrl-only delta; cached state still 'off'
assert.deepEqual(out[CTRL], { topic, payload: -1 });
});
}

View File

@@ -0,0 +1,254 @@
// MGC planner — real-time CONVERGENCE diagnostic.
//
// Where planner-rendezvous.integration.test.js intercepts _fireCommand to
// only assert schedule SHAPE, this test lets the executor REALLY run on
// real pumps with non-zero startup/warmup times, and asks two questions:
//
// (a) does sum-of-pump-flows converge to the demand setpoint?
// (b) do all pumps reach their individual flow target at roughly the
// same wall-clock instant (the rendezvous)?
//
// Realistic scenario: ONE pump already operational, TWO pumps idle. A new
// demand requires (i) the two idle pumps to start (slow, ~3.5s) AND (ii)
// the running pump to retarget. Per the planner code, only flow-DECREASING
// moves get delayed to land at t*; flow-INCREASING moves on running pumps
// fire at tick 0 and land at their own eta. So the running pump's landing
// time should NOT match the two idle pumps unless its target equals its
// current flow (an unusual coincidence). This test surfaces that.
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
const Machine = require('../../../rotatingMachine/src/specificClass');
const HEAD_MBAR_UP = 0;
const HEAD_MBAR_DOWN = 1100;
const N_PUMPS = 3;
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
const stateConfig = {
general: { logging: logCfg },
state: { current: 'idle' },
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
// REAL ladder times — this is the whole point of the test.
time: { starting: 1, warmingup: 2, stopping: 1, coolingdown: 2 },
};
function machineConfig(id) {
return {
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
allowedSources: { auto: ['parent', 'GUI'] },
},
sequences: {
startup: ['starting', 'warmingup', 'operational'],
shutdown: ['stopping', 'coolingdown', 'idle'],
emergencystop: ['emergencystop', 'off'],
},
};
}
function groupConfig() {
return {
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
mode: { current: 'optimalcontrol' },
};
}
function pctToCanonical(mgc, pct) {
if (pct < 0) return -1;
const dt = mgc.calcDynamicTotals();
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
}
const NON_RUNNING = new Set(['idle', 'off', 'stopping', 'coolingdown', 'emergencystop']);
function pumpFlow_m3h(pump) {
const state = pump.state.getCurrentState();
if (NON_RUNNING.has(state)) return 0;
return Number(pump.predictFlow?.outputY ?? 0) * 3600;
}
function buildGroup() {
const mgc = new MachineGroup(groupConfig());
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
const pumps = ids.map((id) => new Machine(machineConfig(id), stateConfig));
for (const m of pumps) {
m.updateMeasuredPressure(HEAD_MBAR_UP, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
m.updateMeasuredPressure(HEAD_MBAR_DOWN, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
mgc.childRegistrationUtils.registerChild(m, 'downstream');
}
mgc.calcAbsoluteTotals();
mgc.calcDynamicTotals();
return { mgc, pumps };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Sample per-pump flow at fixed intervals and return a trajectory: an array
// of {tMs, perPump:[...], sum}.
async function sampleFlows(pumps, durationMs, intervalMs = 200) {
const t0 = Date.now();
const out = [];
while (Date.now() - t0 < durationMs) {
const perPump = pumps.map(pumpFlow_m3h);
out.push({ tMs: Date.now() - t0, perPump, sum: perPump.reduce((a, b) => a + b, 0) });
await sleep(intervalMs);
}
return out;
}
// Find the wall-clock instant (in ms from t0) at which a given series
// REACHES and STAYS within `tol` of `target` for the rest of the run. If
// never reached, returns null.
function arrivalTimeMs(series, target, tol) {
for (let i = 0; i < series.length; i++) {
const v = series[i];
if (Math.abs(v - target) <= tol) {
// require it to stay close
let stayed = true;
for (let j = i + 1; j < series.length; j++) {
if (Math.abs(series[j] - target) > tol * 1.5) { stayed = false; break; }
}
if (stayed) return i;
}
}
return null;
}
function printTrace(label, traj, demand_m3h) {
console.log(`\n${label} (demand=${demand_m3h.toFixed(1)} m³/h)`);
const head = [' t(s)'.padStart(7), 'pump_a'.padStart(8), 'pump_b'.padStart(8), 'pump_c'.padStart(8), 'Σ m³/h'.padStart(8), 'err'.padStart(7)];
console.log(head.join(' '));
console.log('─'.repeat(head.join(' ').length));
for (const s of traj) {
const err = s.sum - demand_m3h;
console.log([
(s.tMs / 1000).toFixed(2).padStart(7),
s.perPump[0].toFixed(1).padStart(8),
s.perPump[1].toFixed(1).padStart(8),
s.perPump[2].toFixed(1).padStart(8),
s.sum.toFixed(1).padStart(8),
err.toFixed(1).padStart(7),
].join(' '));
}
}
// ── The diagnostic ──────────────────────────────────────────────────────
test('planner-convergence: mixed-state dispatch — sum reaches demand AND lands together', async () => {
const { mgc, pumps } = buildGroup();
const dyn = mgc.calcDynamicTotals();
const flowMin_m3h = dyn.flow.min * 3600;
const flowMax_m3h = dyn.flow.max * 3600;
console.log(`\nStation envelope at head ${HEAD_MBAR_DOWN} mbar (${N_PUMPS} pumps): ${flowMin_m3h.toFixed(1)} .. ${flowMax_m3h.toFixed(1)} m³/h`);
// Phase 1: bring pump_a (only) to operational at a low setpoint via a
// direct child command. This bypasses the optimizer and gives us a
// deterministic mixed state: 1 running, 2 idle. We then drive a global
// demand to ramp up — the planner must coordinate one in-flight retarget
// with two startups.
const pumpA = pumps[0];
await pumpA.handleInput('parent', 'execsequence', 'startup');
// wait for warmup to complete
for (let i = 0; i < 200 && pumpA.state.getCurrentState() !== 'operational'; i++) await sleep(50);
assert.equal(pumpA.state.getCurrentState(), 'operational', 'pre-condition: pump_a should be operational');
// Put pump_a at ~30% of its per-pump flow range. This guarantees the
// optimizer's later combination will want pump_a to MOVE (either up to
// share work with the new pumps, or down to balance them) — either
// direction surfaces a rendezvous concern.
const sample = pumpA.groupPredictFlow ?? pumpA.predictFlow;
const perPumpMin_m3h = sample.currentFxyYMin * 3600;
const perPumpMax_m3h = sample.currentFxyYMax * 3600;
const initialFlow_m3h = perPumpMin_m3h + 0.30 * (perPumpMax_m3h - perPumpMin_m3h);
await pumpA.handleInput('parent', 'flowmovement', initialFlow_m3h);
await sleep(500); // let pump_a settle
const initialSnap = pumps.map((p) => ({ state: p.state.getCurrentState(), q: pumpFlow_m3h(p) }));
console.log('\nInitial state (1 running, 2 idle):');
for (let i = 0; i < pumps.length; i++) {
console.log(` ${pumps[i].config.general.id}: ${initialSnap[i].state.padEnd(13)} Q=${initialSnap[i].q.toFixed(1)} m³/h`);
}
assert.equal(initialSnap[0].state, 'operational', 'pump_a operational at start');
assert.equal(initialSnap[1].state, 'idle', 'pump_b idle at start');
assert.equal(initialSnap[2].state, 'idle', 'pump_c idle at start');
// Phase 2: drive 90% demand — needs all 3 pumps.
const demandPct = 90;
const demand_m3s = pctToCanonical(mgc, demandPct);
const demand_m3h = demand_m3s * 3600;
console.log(`\nDispatching ${demandPct}% → ${demand_m3h.toFixed(1)} m³/h demand…`);
// Fire-and-don't-wait so we can sample DURING the move.
mgc.handleInput('parent', demand_m3s).catch(() => {});
// Give the dispatcher a microtask + tick to plan, then dump the
// schedule so we can see WHAT the planner produced (vs. what the
// executor actually does).
await sleep(60);
const sched = mgc.movementExecutor.schedule();
console.log(`\nPlanner schedule (tStar=${sched?.tStarS?.toFixed(2)}s, ${sched?.commands?.length} cmds):`);
for (const c of (sched?.commands || [])) {
console.log(` ${c.machineId.padEnd(8)} ${c.action.padEnd(13)} ${c.sequence ?? ('flow=' + (c.flow?.toFixed(1) ?? 'n/a')).padEnd(12)} fireAtTickN=${c.fireAtTickN} eta=${c.eta?.toFixed(2)}s`);
}
// Sample for 8 seconds at 200 ms — long enough for tStar ≈ 3.5 s + ramp.
const traj = await sampleFlows(pumps, 8000, 200);
printTrace('Per-pump flow trajectory', traj, demand_m3h);
// ── Question (a): does sum-of-flows converge to demand? ────────────
const finalSum = traj[traj.length - 1].sum;
const tolAbs = demand_m3h * 0.05; // 5% tolerance
console.log(`\nFinal ΣQ = ${finalSum.toFixed(1)} m³/h vs demand ${demand_m3h.toFixed(1)} m³/h (tol ±${tolAbs.toFixed(1)})`);
assert.ok(
Math.abs(finalSum - demand_m3h) <= tolAbs,
`(a) CONVERGENCE FAILED: final ΣQ=${finalSum.toFixed(1)} m³/h, demand=${demand_m3h.toFixed(1)} m³/h, err=${(finalSum - demand_m3h).toFixed(1)} m³/h (>${tolAbs.toFixed(1)})`,
);
// ── Question (b): same-time landing? ───────────────────────────────
//
// For each pump, find when its flow first reached a stable value (its
// own steady-state target). Compare the spread across the three pumps:
// if they "land together", all arrival indices are within ~1 sample.
const sampleTargets = pumps.map((_, i) => {
// Use the LAST sample's flow as that pump's actual landing value.
// We're measuring "when did this pump stop moving" not "did it hit
// some externally-specified target" — that's what same-time-landing
// is about.
return traj[traj.length - 1].perPump[i];
});
const arrivalIdx = pumps.map((_, i) => {
const series = traj.map((s) => s.perPump[i]);
const tgt = sampleTargets[i];
const tol = Math.max(2.0, Math.abs(tgt) * 0.05); // 5% or 2 m³/h, whichever larger
return arrivalTimeMs(series, tgt, tol);
});
console.log('\nArrival index per pump (sample # where flow stabilises within 5%):');
for (let i = 0; i < pumps.length; i++) {
const idx = arrivalIdx[i];
const t = idx == null ? 'NEVER' : `${(traj[idx].tMs / 1000).toFixed(2)} s`;
console.log(` ${pumps[i].config.general.id}: idx=${idx}, t=${t}, finalQ=${sampleTargets[i].toFixed(1)} m³/h`);
}
const validIdx = arrivalIdx.filter((x) => x != null);
assert.equal(validIdx.length, N_PUMPS, '(b) one or more pumps never landed on a stable flow');
const spreadSamples = Math.max(...validIdx) - Math.min(...validIdx);
const spreadMs = spreadSamples * 200;
console.log(`Same-time-landing spread: ${spreadSamples} samples = ${spreadMs} ms`);
// Loose bound: within 1.5 s. A bigger spread means the schedule did
// NOT bring the pumps to their setpoints together.
assert.ok(
spreadMs <= 1500,
`(b) SAME-TIME LANDING FAILED: pumps landed ${spreadMs} ms apart (>1500 ms tolerance). ` +
`This means flow-INCREASING moves on running pumps land BEFORE startup pumps reach operational.`,
);
});

View File

@@ -0,0 +1,210 @@
// MGC + planner end-to-end integration. Proves the timing-aware
// rendezvous schedule actually fires on real rotatingMachine objects
// (not just the abstract scheduler unit tests).
//
// Layout mirrors idle-startup-deadlock.integration.test.js: three real
// pump objects, a real MGC, registration via childRegistrationUtils. The
// difference: instead of asserting end-state, we tap into the executor's
// schedule + intercept fireCommand to record exact ordering.
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
const Machine = require('../../../rotatingMachine/src/specificClass');
const HEAD_MBAR_UP = 0;
const HEAD_MBAR_DOWN = 1100;
const N_PUMPS = 3;
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
const stateConfig = {
general: { logging: logCfg },
state: { current: 'idle' },
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
time: { starting: 1, warmingup: 2, stopping: 1, coolingdown: 2 },
};
function machineConfig(id) {
return {
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
allowedSources: { auto: ['parent', 'GUI'] },
},
sequences: {
startup: ['starting', 'warmingup', 'operational'],
shutdown: ['stopping', 'coolingdown', 'idle'],
emergencystop: ['emergencystop', 'off'],
},
};
}
function groupConfig() {
return {
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
mode: { current: 'optimalcontrol' },
};
}
function pctToCanonical(mgc, pct) {
if (pct < 0) return -1;
const dt = mgc.calcDynamicTotals();
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
}
function buildGroup() {
const mgc = new MachineGroup(groupConfig());
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
const pumps = ids.map((id) => new Machine(machineConfig(id), stateConfig));
for (const m of pumps) {
m.updateMeasuredPressure(HEAD_MBAR_UP, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
m.updateMeasuredPressure(HEAD_MBAR_DOWN, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
mgc.childRegistrationUtils.registerChild(m, 'downstream');
}
mgc.calcAbsoluteTotals();
mgc.calcDynamicTotals();
return { mgc, pumps };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Wrap the MGC's executor.fireCommand so we record every command in
// timing order. Replaces the actual fireCommand so the test stays
// hermetic (pumps don't actually move — we just verify the SCHEDULE).
function tapExecutor(mgc) {
const log = [];
const originalFire = mgc.movementExecutor._fireCommand;
mgc.movementExecutor._fireCommand = (cmd) => {
log.push({ ...cmd, firedAtMs: Date.now() });
// Still call the original so the FSM moves and the test stays realistic.
try { originalFire(cmd); } catch (_) { /* ignore */ }
};
return log;
}
// ── Tests ───────────────────────────────────────────────────────────────
test('planner-integration: idle group → demand brings up all 3 pumps in lockstep', async () => {
const { mgc, pumps } = buildGroup();
const log = tapExecutor(mgc);
// 100% demand from idle → optimizer picks a 3-pump combination.
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
// Wait one tick so the executor's setInterval-driven follow-up ticks
// (if any) have a chance to fire. Three-pump symmetric startup has
// identical etas → tStar = max(eta) = eta itself → all commands at
// fireAtTickN=0 → all fire synchronously.
await sleep(50);
const startupCmds = log.filter((c) => c.action === 'execsequence' && c.sequence === 'startup');
const flowCmds = log.filter((c) => c.action === 'flowmovement');
assert.equal(startupCmds.length, N_PUMPS, 'one startup per pump');
assert.equal(flowCmds.length, N_PUMPS, 'one flowmovement per pump (queued via delayedMove)');
// All startups must be fired in the same tick — i.e. roughly the same
// wall-clock instant (within a few ms).
const spread = Math.max(...startupCmds.map((c) => c.firedAtMs)) - Math.min(...startupCmds.map((c) => c.firedAtMs));
assert.ok(spread < 50, `startup spread too wide: ${spread}ms`);
});
test('planner-integration: rendezvous — startup pump fires immediately, retarget on running pump is delayed', async () => {
// Bring up two pumps first; then change demand so the third pump
// starts AND the two existing pumps shed load. The two running pumps'
// flowmovement should be delayed so they land at the rendezvous time
// matching the third pump's startup completion.
const { mgc, pumps } = buildGroup();
// Phase 1: low demand so optimizer picks a sub-set of pumps and at
// least one stays idle. We try a few decreasing values until we find
// one that leaves an idle pump (optimizer's combination choice is
// sensitive to curve/pressure, hard to predict precisely).
let idlePumpFound = false;
for (const pct of [30, 20, 10, 5, 1]) {
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(() => {});
await sleep(4500);
const states0 = pumps.map((p) => p.state.getCurrentState());
if (states0.includes('idle')) { idlePumpFound = true; break; }
}
if (!idlePumpFound) {
const finalStates = pumps.map((p) => p.state.getCurrentState());
console.log(` (skipping) optimizer always picked all 3 pumps even at low demand: ${finalStates.join(',')}`);
return; // optimizer behaviour denies us the scenario — not a failure of the planner.
}
// Start tapping AFTER the first ramp settles — we only care about
// the schedule from the next dispatch.
const log = tapExecutor(mgc);
// Phase 2: drive to 100%. Now optimizer wants all 3 pumps. The idle
// pump needs full startup; existing pumps adjust their flow.
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
// Wait long enough for the executor's wall-clock ticks to fire
// delayed commands. tStar can be up to startingS + warmingupS + ramp
// = 1 + 2 + 0.5 = 3.5s.
await sleep(5000);
const startupCmds = log.filter((c) => c.action === 'execsequence' && c.sequence === 'startup');
const flowCmds = log.filter((c) => c.action === 'flowmovement');
// We expect: at least one startup (for the idle pump) AND flow
// adjustments on the running pumps. The exact split depends on
// optimizer behaviour, so assert loosely.
assert.ok(startupCmds.length >= 1, 'at least one startup expected for the idle pump');
assert.ok(flowCmds.length >= 1, 'at least one flowmovement expected');
// The schedule snapshot stored on the executor should record a
// positive tStar (rendezvous time).
const lastSchedule = mgc.movementExecutor.schedule();
assert.ok(lastSchedule, 'executor schedule should be set');
// The schedule should have at least one increasing eta (the startup),
// which sets tStar > 0.
assert.ok(lastSchedule.tStarS > 0, `tStar should be > 0 when a startup is in the plan; got ${lastSchedule.tStarS}`);
// If any flowmovement on an EXISTING (then-operational) pump was a
// down-move, its fireAtTickN should be > 0 (delayed). Find any such
// command in the schedule.
const delayedDownMoves = lastSchedule.commands.filter((c) => c.action === 'flowmovement' && c.fireAtTickN > 0);
// Note: this assertion is "expected on most runs" rather than
// "guaranteed every time" — depends on whether the optimizer picks a
// combination that requires existing pumps to reduce. We assert the
// schedule SHAPE (positive tStar) and accept that delayed-down moves
// are common-but-not-mandatory.
if (delayedDownMoves.length === 0) {
// Surface a debug print if the run didn't exercise delayed moves —
// helps when reading test logs to know what happened.
console.log(' (planner-integration) note: no delayed down-moves this run — combination may have been all-up.');
}
});
test('planner-integration: replan drops unfired commands when a new demand arrives', async () => {
const { mgc, pumps } = buildGroup();
const log = tapExecutor(mgc);
// First demand: 100% from idle. tStar will be ~3.5s; all startup
// cmds fire at tick 0 (synchronous), but if there were any delayed
// down-moves, they'd be in the schedule.
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
await sleep(100);
const firstSnapshot = mgc.movementExecutor.schedule().commands.length;
// Immediately fire a second demand: 50%. Replan happens; some unfired
// commands from the first schedule get dropped.
mgc.handleInput('parent', pctToCanonical(mgc, 50)).catch(() => {});
await sleep(100);
// Schedule was replaced.
const secondSnapshot = mgc.movementExecutor.schedule();
assert.ok(secondSnapshot, 'executor schedule replaced after replan');
// Cursor reset to a low value (≤ a couple of ticks from the replan).
assert.ok(mgc.movementExecutor.cursor() <= 2, `cursor should reset on replan; got ${mgc.movementExecutor.cursor()}`);
// Sanity: replan didn't blow up the executor.
assert.ok(firstSnapshot > 0, 'first dispatch should have queued at least one command');
});

View File

@@ -9,14 +9,16 @@ function loadJson(file) {
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
} }
const FLOW_FILES = ['01-Basic.json', '02-Dashboard.json'];
test('examples package exists for machineGroupControl', () => { test('examples package exists for machineGroupControl', () => {
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) { for (const file of ['README.md', ...FLOW_FILES]) {
assert.equal(fs.existsSync(path.join(dir, file)), true, file + ' missing'); assert.equal(fs.existsSync(path.join(dir, file)), true, file + ' missing');
} }
}); });
test('example flows are parseable arrays for machineGroupControl', () => { test('example flows are parseable arrays for machineGroupControl', () => {
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) { for (const file of FLOW_FILES) {
const parsed = loadJson(file); const parsed = loadJson(file);
assert.equal(Array.isArray(parsed), true); assert.equal(Array.isArray(parsed), true);
} }

View File

@@ -44,7 +44,7 @@ function machineConfig(id) {
return { return {
general: { logging: logCfg, name: id, id, unit: 'm3/h' }, general: { logging: logCfg, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' }, 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: { mode: {
current: 'auto', current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] }, allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
@@ -62,7 +62,6 @@ function groupConfig() {
return { return {
general: { logging: logCfg, name: 'mgc', id: 'mgc' }, general: { logging: logCfg, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
scaling: { current: 'normalized' },
mode: { current: 'optimalcontrol' }, mode: { current: 'optimalcontrol' },
}; };
} }

View File

@@ -1,18 +1,30 @@
# machineGroupControl # machineGroupControl
> **Reflects code as of `afc304b` · regenerated `2026-05-11` via `npm run wiki:all`** ![code-ref](https://img.shields.io/badge/code--ref-26e92b5-blue) ![s88](https://img.shields.io/badge/S88-Unit-50a8d9) ![status](https://img.shields.io/badge/status-trial--ready-brightgreen)
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
## 1. What this node is A `machineGroupControl` (MGC) coordinates two or more `rotatingMachine` children that share a common header. It accepts an operator demand setpoint, enumerates the valid pump combinations against the group's live flow/power envelope, picks the best operating point (BEP-Gravitation by default), and schedules per-machine flow setpoints + start/stop commands with **timing-aware rendezvous** so the running aggregate stays close to demand during transitions.
**machineGroupControl (MGC)** is an S88 Unit orchestrator that coordinates multiple `rotatingMachine` children sharing a common header. It receives a demand setpoint, evaluates valid pump combinations against the group's totals and curves, picks the best operating point (BEP-Gravitation or NCog), and dispatches per-machine flow setpoints + start/stop commands. ---
## 2. Position in the platform ## At a glance
| Thing | Value |
|:---|:---|
| What it represents | A pump group sharing one suction + one discharge header |
| S88 level | Unit |
| Use it when | You have 2&nbsp;+ pumps that can substitute for each other on the same header and you want efficient load-sharing |
| Don't use it for | A single pump (wire `rotatingMachine` directly), valves (use `valveGroupControl`), or pumps living behind independent headers |
| Children it accepts | `machine` (rotatingMachine), `measurement` (pressure / others) |
| Parent it talks to | `pumpingStation` (typical), or any node that issues `set.demand` |
---
## How it fits
```mermaid ```mermaid
flowchart LR flowchart LR
parent[pumpingStation<br/>Process Cell]:::pc -->|set.demand| mgc[machineGroupControl<br/>Unit]:::unit parent[pumpingStation<br/>Process Cell]:::pc -->|set.demand| mgc[machineGroupControl<br/>Unit]:::unit
header[measurement<br/>header pressure]:::ctrl -.data.-> mgc header[measurement<br/>header pressure]:::ctrl -.measured.-> mgc
mgc -->|flowmovement / execsequence| m_a[rotatingMachine A]:::equip mgc -->|flowmovement / execsequence| m_a[rotatingMachine A]:::equip
mgc -->|flowmovement / execsequence| m_b[rotatingMachine B]:::equip mgc -->|flowmovement / execsequence| m_b[rotatingMachine B]:::equip
mgc -->|flowmovement / execsequence| m_c[rotatingMachine C]:::equip mgc -->|flowmovement / execsequence| m_c[rotatingMachine C]:::equip
@@ -26,252 +38,111 @@ flowchart LR
classDef ctrl fill:#a9daee,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`. S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`.
## 3. Capability matrix ---
| Capability | Status | Notes | ## Try it &mdash; 3-minute demo
|---|---|---|
| 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) | ✅ | Inline gate; deferred call drains on completion. |
| Forced shutdown on `Qd ≤ 0` | ✅ | `turnOffAllMachines()`. |
## 4. Code map Import the basic example flow, deploy, and watch three pumps come online together when demand rises.
```mermaid ```bash
flowchart TB curl -X POST -H 'Content-Type: application/json' \
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"] --data @nodes/machineGroupControl/examples/01-Basic.json \
nc["buildDomainConfig()<br/>static DomainClass, commands"] http://localhost:1880/flow
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"]
dispatch["control/<br/>strategies (equalFlow / prioPct)"]
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 --> dispatch
sc --> io
nc --> commands
``` ```
| Module | Owns | Read first if you're changing… | What to click in the dashboard after deploy:
|---|---|---|
| `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/` | Demand fan-out helpers (legacy alongside inline gate) | 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 1. The Setup group auto-fires `virtualControl` + `cmd.startup` on each child pump after ~1.5&nbsp;s.
2. `set.demand = 50` (bare number = percent of group capacity) &rarr; MGC picks the best 1- or 2-pump combination by BEP-Gravitation.
3. `set.demand = { value: 80, unit: "m3/h" }` &rarr; absolute-flow setpoint.
4. `set.mode = priorityControl` &rarr; equal-flow distribution by priority order.
5. `set.demand = -1` &rarr; operator stop-all; `turnOffAllMachines` cancels any pending dispatch and shuts every active pump down.
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`. > [!IMPORTANT]
> **GIF needed.** Demo recording of demand 50 % &rarr; 100 % &rarr; -1 with the live status panel. Save as `wiki/_partial-gifs/machineGroupControl/01-basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
<!-- BEGIN AUTOGEN: topic-contract --> ---
| Canonical topic | Aliases | Payload | Unit | Effect | ## The three things you'll send
|---|---|---|---|---|
| `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 --> `set.demand` is **unit-self-describing** &mdash; the payload itself decides how the value is interpreted. There is no persistent `scaling` state on the orchestrator.
## 6. Child registration | Topic | Aliases | Payload | What it does |
|:---|:---|:---|:---|
| `set.mode` | `setMode` | `"optimalControl"` \| `"priorityControl"` \| `"maintenance"` | Switches dispatch strategy. `maintenance` is monitoring-only. |
| `set.demand` | `Qd` | bare number = %; `{value, unit}` for absolute units (`m3/h`, `l/s`, `m3/s`, &hellip;); negative = stop all | Operator demand setpoint. Resolves to canonical m³/s before dispatch. |
| `child.register` | `registerChild` | child node id (string) | Manually register a child (Port 2 wiring does this automatically in most flows). |
`ChildRouter` declarations in `specificClass.js → configure()`. ---
## What you'll see come out
Sample Port 0 message (delta-compressed &mdash; only changed fields each tick):
```json
{
"topic": "machineGroupControl#MGC1",
"payload": {
"mode": "optimalControl",
"atEquipment_predicted_flow": 42.5,
"downstream_predicted_flow": 42.5,
"atEquipment_predicted_power": 18.0,
"headerDiffPa": 145000,
"headerDiffMbar": 1450,
"flowCapacityMax": 90,
"flowCapacityMin": 6,
"machineCount": 3,
"machineCountActive": 2,
"absDistFromPeak": 0.02,
"relDistFromPeak": 0.10
}
}
```
| Field | Meaning |
|:---|:---|
| `mode` | Current dispatch mode. |
| `atEquipment_predicted_flow` / `_power` | Group aggregate at the pump shafts. The optimizer writes intent here; `handlePressureChange` keeps it in sync with the live totals. |
| `downstream_predicted_flow` | Live aggregate mirrored onto DOWNSTREAM &mdash; pumpingStation parents subscribe here. |
| `headerDiffPa` / `headerDiffMbar` | Last header differential the equalizer resolved. Dashboards use it for Q-H plots without re-reading every child. |
| `flowCapacityMax` / `flowCapacityMin` | The group's dynamic envelope at the current header pressure. Defines where `set.demand` (as %) maps to. |
| `machineCount` / `machineCountActive` | All registered children, and how many are in a state other than `off` / `maintenance`. |
| `absDistFromPeak` / `relDistFromPeak` | Distance from group BEP. `relDistFromPeak` is `undefined` when the η spread collapses (homogeneous pump group). |
The key shape is `<position>_<variant>_<type>` &mdash; the inverse of `rotatingMachine`'s `<type>.<variant>.<position>.<childId>` key shape, because MGC's output is the group aggregate, not a per-child snapshot.
---
## The new bit &mdash; the movement planner
When MGC computes a new optimal combination it doesn't fan the commands out instantly. It builds a **schedule** that times each command so the running aggregate stays close to demand during the transition.
```mermaid ```mermaid
flowchart LR flowchart LR
subgraph kids["accepted children (softwareType)"] demand[set.demand] --> dispatch[_runDispatch<br/>latest-wins]
mach["machine<br/>(rotatingMachine)"]:::equip dispatch --> abort[abortActiveMovements]
m["measurement<br/>(header pressure)"]:::ctrl abort --> opt[optimizer.calcBestCombination*]
end opt --> profiles[buildProfile<br/>x children]
mach -->|"pressure.measured.downstream<br/>pressure.measured.differential<br/>flow.predicted.downstream"| eq[operatingPoint.equalize<br/>+ totals refresh] profiles --> plan[movementScheduler.plan<br/>rendezvous t* = max&#40;eta_i&#41;]
m -->|"&lt;type&gt;.measured.&lt;position&gt;"| mirror[mirror into own<br/>MeasurementContainer] plan --> exec[movementExecutor.replan<br/>+ await tick&#40;&#41;]
mirror -->|"if type === 'pressure'"| eq exec --> kids[rotatingMachine x N<br/>flowmovement / execsequence]
eq --> emit[notifyOutputChanged]
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
``` ```
| softwareType | filter / subscribed events | Side-effect | The planner classifies each pump's required move (`startup` / `flowmove` / `shutdown` / `noop`), computes an ETA per move via `MoveTrajectory`, sets the rendezvous time `t* = max(eta_i)` over flow-INCREASING moves, and delays flow-DECREASING moves so they FINISH at `t*`. Net effect: the sum of flows tracks the demand smoothly during the transition; on overshoot the header pressure rises and self-corrects.
|---|---|---|
| `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 This path is exercised in `optimalControl` mode. `priorityControl` mode still uses the legacy direct-dispatch path (`control.equalFlowControl`) &mdash; the planner has not been wired through there yet.
```mermaid ---
sequenceDiagram
participant parent as pumpingStation
participant mgc as MGC
participant op as GroupOperatingPoint
participant tot as TotalsCalculator
participant opt as optimizer
participant kids as rotatingMachine[]
parent->>mgc: set.demand (Qd) ## Need more?
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()` | Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Topic registry, config schema, child registration filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals, output ports |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows, debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | When not to use, known issues, open questions |
What lands on Port 0. Composed in `io/output.js → getOutput(this)` and delta-compressed by `outputUtils.formatMsg`. [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
<!-- 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",
"flow.predicted.atequipment.<nodeId>": 0.0125,
"flow.predicted.downstream.<nodeId>": 0.0125,
"power.predicted.atequipment.<nodeId>": 1800,
"efficiency.predicted.atequipment.<nodeId>": 0.65,
"absDistFromPeak": 0.02,
"relDistFromPeak": 0.10
}
~~~
The `<nodeId>` segment is the Node-RED node id assigned at deploy time.
## 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 overwrite `_delayedCall` (latest-wins); the gate drains the latest one on completion. `turnOffAllMachines()` clears `_delayedCall` to make turn-off the final intent.
## 11. Examples
| Tier | File | What it shows | Status |
|---|---|---|---|
| Basic | `examples/basic.flow.json` | Single MGC + 2 pumps, manual setDemand | ⚠️ legacy shape, pre-refactor |
| Integration | `examples/integration.flow.json` | MGC wired under pumpingStation | ⚠️ legacy shape, pre-refactor |
| Edge | `examples/edge.flow.json` | Mid-flight demand override + abort | ⚠️ legacy shape, pre-refactor |
Tier 1/2/3 visual-first example flows are still TODO (see `MEMORY.md` "TODO: Example Flows"). Screenshots will land under `wiki/_partial-screenshots/machineGroupControl/` when the new flows ship.
## 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 collapsed multiple calls — confirm `_delayedCall` was honoured. | `handleInput` finally block. |
| 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 | Tier 1/2/3 visual-first example flows not yet written. | P9 follow-up. |

View File

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

196
wiki/Reference-Contracts.md Normal file
View File

@@ -0,0 +1,196 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-26e92b5-blue)
> [!NOTE]
> Full topic contract, configuration schema, and child-registration filters for `machineGroupControl`. Source of truth: `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/machineGroupControl.json`.
>
> For an intuitive overview, return to the [Home](Home).
---
## Topic contract
The MGC accepts three canonical topics. `set.demand` is the only one with semantic content; the other two are simple state changes.
| Canonical topic | Aliases | Payload | Unit handling | Effect |
|:---|:---|:---|:---|:---|
| `set.mode` | `setMode` | `string` (`"optimalControl"` \| `"priorityControl"` \| `"maintenance"`) | &mdash; | Switch the dispatch strategy. `maintenance` is monitoring-only &mdash; the dispatch switch warns and skips. |
| `set.demand` | `Qd` | bare number, OR `{value: number, unit: string}` | self-describing (see below) | Operator demand setpoint. Resolves to canonical m³/s, then enters the latest-wins gate. Negative value = stop all (any unit). |
| `child.register` | `registerChild` | `string` (Node-RED node id) | &mdash; | Register a child machine manually. Port 2 wiring does this automatically in normal flows. |
### `set.demand` &mdash; unit-self-describing semantics
`src/commands/handlers.js` `setDemand`. The payload itself decides the meaning:
| Payload form | Interpretation |
|:---|:---|
| `42` (bare number) | 42&nbsp;%. Mapped through `interpolation.interpolate_lin_single_point(value, 0, 100, dt.flow.min, dt.flow.max)` to a canonical m³/s, clamped to the dynamic envelope. |
| `{value: 42, unit: '%'}` | Same as above &mdash; explicit-percent form. |
| `{value: 80, unit: 'm3/h'}` (or `l/s` / `m3/s` / &hellip;) | Absolute flow. Converted via `convert(value).from(unit).to('m3/s')`. |
| `42` or `{value: …, unit: 'm3/h'}` with `value < 0` | Triggers `turnOffAllMachines()` regardless of unit. |
| Anything else (`NaN`, missing) | Logged at error level; dispatch is skipped. |
There is **no persistent `scaling` state** on the orchestrator. Each `set.demand` carries its own unit context; callers can switch between absolute and percent at will.
After a successful dispatch the handler replies on the input port with `{topic: <node.name>, payload: 'done'}` &mdash; the legacy "done" handshake some downstream flows still rely on.
---
## Data model &mdash; `getOutput()` shape
Composed each tick by `src/io/output.js` `getOutput()` and emitted via `outputUtils.formatMsg` on Port 0. Delta-compressed: consumers see only the keys that changed.
### Per-measurement keys
For every `(type, variant)` MeasurementContainer pair, the formatter emits **up to four keys** &mdash; one per position plus a differential when both upstream and downstream are present:
```
<position>_<variant>_<type>
```
Examples (with `variant=predicted`, `type=flow`):
| Key | Source |
|:---|:---|
| `downstream_predicted_flow` | Group aggregate at the discharge side. |
| `atEquipment_predicted_flow` | Optimizer intent (what the controller's solving for). |
| `upstream_predicted_flow` | Group suction-side aggregate (when populated). |
| `differential_predicted_flow` | `downstream upstream` when both legs read. |
Same shape for `pressure`, `power`, `temperature`, `efficiency`, `Ncog`. Output units are taken from the unit policy (`flow=m3/h`, `pressure=mbar`, `power=kW`, `temperature=°C`).
### Scalar group keys
| Key | Type | Source | Notes |
|:---|:---|:---|:---|
| `mode` | string | `mgc.mode` | Current dispatch mode. |
| `scaling` | (legacy) | `mgc.scaling` | Always `undefined` in the current code &mdash; the orchestrator no longer carries a scaling field. Kept in the formatter for now; will be removed. |
| `absDistFromPeak` | number | `mgc.efficiency.calcDistanceBEP` | Absolute η distance to the group "peak" (mean of per-pump cogs). |
| `relDistFromPeak` | number \| undefined | same | Normalised 0..1; `undefined` when the η spread collapses (homogeneous pump group). |
| `headerDiffPa` | number | `mgc.operatingPoint.headerDiffPa` | Last header differential the equaliser resolved. Pa. |
| `headerDiffMbar` | number | derived | Only emitted when `output.pressure === 'mbar'`. |
| `flowCapacityMax` / `flowCapacityMin` | number | `mgc.dynamicTotals.flow.{max,min}` | The group's current envelope at the active header pressure. |
| `machineCount` | number | `Object.keys(mgc.machines).length` | All registered children. |
| `machineCountActive` | number | derived | Children whose state ≠ `off` / `maintenance` and currentMode ≠ `maintenance`. |
### Status badge
`src/io/output.js` `getStatusBadge()` composes:
```
<mode> · <scaling-abbrev> · Q=<flow>/<capacity> m³/h · P=<power> kW · <active>/<count>x
```
Fill colour: `green` when any pump is available, `yellow` when machines are registered but all are off/maintenance, `grey` when no pumps are registered.
---
## Configuration schema &mdash; editor form to config keys
Source of truth: `generalFunctions/src/configs/machineGroupControl.json` plus `nodeClass.buildDomainConfig`.
### General (`config.general`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Name | `general.name` | `Machine Group Configuration` | Human-readable label. |
| (auto-assigned) | `general.id` | `null` | Node-RED node id; assigned at deploy. |
| Default unit | `general.unit` | `m3/h` | Surfaces as the unit-policy output for `flow`. |
| Enable logging | `general.logging.enabled` | `true` | Master logger switch. |
| Log level | `general.logging.logLevel` | `info` | `debug` / `info` / `warn` / `error`. |
### Functionality (`config.functionality`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | One of `atEquipment` / `upstream` / `downstream`. Used in the child-register payload. |
| (hidden) | `functionality.softwareType` | `machinegroupcontrol` | Constant. |
| (hidden) | `functionality.role` | `GroupController` | Constant. |
| Distance offset | `functionality.distance` | `null` | Optional spatial offset; populated from the editor when `hasDistance` is enabled. |
| Distance unit | `functionality.distanceUnit` | `m` | |
| Distance description | `functionality.distanceDescription` | `""` | Free-text. |
### Output (`config.output`)
| Form field | Config key | Default | Range | Notes |
|:---|:---|:---|:---|:---|
| Process Output | `output.process` | `process` | `process` / `json` / `csv` | Port-0 formatter. |
| Database Output | `output.dbase` | `influxdb` | `influxdb` / `json` / `csv` | Port-1 formatter. |
### Mode (`config.mode`)
| Form field | Config key | Default | Range | Where used |
|:---|:---|:---|:---|:---|
| Control mode | `mode.current` | `optimalControl` | `optimalControl` / `priorityControl` / `maintenance` | dispatch switch in `_runDispatch`; mode-source/-action gates in `commands/handlers.js`. |
| (defaults) | `mode.allowedActions.optimalControl` | `[statusCheck, execOptimalCombination, balanceLoad, emergencyStop]` | &mdash; | Enforced at command-handler entry via `specificClass.isValidActionForMode`. |
| (defaults) | `mode.allowedActions.priorityControl` | `[statusCheck, execSequentialControl, balanceLoad, emergencyStop]` | &mdash; | Same. |
| (defaults) | `mode.allowedActions.maintenance` | `[statusCheck]` | &mdash; | Same &mdash; dispatch/emergencyStop are dropped with a warn log. |
| (defaults) | `mode.allowedSources.optimalControl` | `["parent","GUI","physical","API"]` | &mdash; | Enforced via `specificClass.isValidSourceForMode`. |
| (defaults) | `mode.allowedSources.priorityControl` | `["parent","GUI","physical","API"]` | &mdash; | Same. |
| (defaults) | `mode.allowedSources.maintenance` | `["parent","GUI"]` | &mdash; | Physical/HMI and API writes dropped in maintenance &mdash; monitoring only. |
> [!NOTE]
> `mode.current` is normalised at write time by `specificClass.setMode`: legacy lowercase inputs (`optimalcontrol`, `prioritycontrol`) are accepted and stored as the canonical camelCase. The `_runDispatch` switch then lowercases for its comparison &mdash; both forms reach the correct branch. Garbage modes (e.g. `'wat'`) are rejected with a warn log and the previous mode is preserved.
>
> Selecting `maintenance` no longer reaches `_runDispatch` at all in normal operation: the mode-action gate at `commands/handlers.js` drops the incoming `set.demand` before the dispatcher sees it. Status messages (`set.mode`, `child.register`) continue to flow.
### Unit policy
Source: `src/specificClass.js` lines 33&ndash;37.
| Quantity | Canonical (internal) | Output (rendered) | Required-unit |
|:---|:---|:---|:---:|
| Flow | `m3/s` | `m3/h` | ✓ |
| Pressure | `Pa` | `mbar` | ✓ |
| Power | `W` | `kW` | ✓ |
| Temperature | `K` | `°C` | ✓ |
`requireUnitForTypes` means MeasurementContainer rejects writes without an explicit unit for these types &mdash; protects against accidentally writing raw numbers in the wrong scale.
---
## Child registration
Source: `src/specificClass.js` `configure()` lines 92&ndash;118.
| softwareType | Filter / subscribed events | Side-effect |
|:---|:---|:---|
| `machine` | `onRegister` stores the child in `this.machines[id]`. Subscribes to `pressure.measured.downstream`, `pressure.measured.differential`, and `flow.predicted.downstream` from the child's emitter. | Every event calls `handlePressureChange()` &mdash; equalises the header, recomputes dynamic totals, refreshes group η, fires `notifyOutputChanged()`. |
| `measurement` | `onRegister` reads `asset.type` and `positionVsParent`, subscribes to `<type>.measured.<position>` on the child's measurement emitter. | Mirrors the value into MGC's own MeasurementContainer; pressure values additionally trigger `handlePressureChange()`. |
A child whose `asset.type` or `positionVsParent` is missing is logged at warn and skipped (not registered).
There is **no filter on `machinegroup` / `pumpingstation` children** &mdash; MGC is a leaf controller; it parents pumps but doesn't accept fellow aggregators.
---
## Header-pressure equalisation
Source: `src/groupOps/groupOperatingPoint.js` `equalize()`.
MGC ensures every registered child uses the **same** header differential pressure when computing predicted flow / power. Algorithm:
1. Read MGC's own group-scope pressure (downstream and upstream) from its MeasurementContainer.
2. Read each child's measured pressure (downstream / upstream).
3. Pick:
- `headerDownstream` = group reading if positive, else `max` across children.
- `headerUpstream` = group reading if positive, else `min` across children.
4. If the differential is non-positive, skip the equalisation (debug log).
5. Stash the diff on `this.headerDiffPa` (used by `getOutput` and by every η computation).
6. Push the diff onto each child's `predictFlow.fDimension` / `predictPower.fDimension` / `predictCtrl.fDimension` &mdash; preferred path is `child.setGroupOperatingPoint(downstream, upstream)`, which lets the child re-build its `groupPredict*` interpolators. Older children fall back to a direct `fDimension` write.
The equaliser is called from `handlePressureChange` (on every child pressure / predicted-flow event) and from the start of `_optimalControl`.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [EVOLV &mdash; Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
| [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |

155
wiki/Reference-Examples.md Normal file
View File

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

View File

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

19
wiki/_Sidebar.md Normal file
View File

@@ -0,0 +1,19 @@
### machineGroupControl
- [Home](Home)
**Reference**
- [Contracts](Reference-Contracts)
- [Architecture](Reference-Architecture)
- [Examples](Reference-Examples)
- [Limitations](Reference-Limitations)
**Related**
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
- [pumpingStation wiki](https://gitea.wbd-rd.nl/RnD/pumpingStation/wiki/Home)
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)