Compare commits

...

36 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
znetsixe
7d19fc1db0 P11.6 wiki regen + Phase 10 private-test rewrites where applicable
For all 11 nodes with auto-gen markers: wiki/Home.md sections 5 (topic
contract) and 9 (data model) regenerated via npm run wiki:all. New
Unit column shows '<measure> (default <unit>)' for declared topics,
'—' otherwise. Effect column now uses descriptor.description (P11.2
field) overriding the generic per-prefix fallback.

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

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

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

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

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

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

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

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

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

53 basic + 23 integration + 1 edge tests pass.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:45:23 +02:00
Rene De Ren
ea2857fb25 fix: serialize per-pump shutdown + cancel deferred dispatch in turnOffAllMachines
PS calls turnOffAllMachines on every tick once level < stopLevel. Two
ways the pump could re-engage after we shut it down:

1. _delayedCall: a 1% dead-zone keep-alive parked in MGC's deferred
   dispatch fires from the in-flight handleInput's finally block AFTER
   the shutdown completes, dispatching flow + startup to a fresh pump.
   Clear _delayedCall at the top of turnOff.

2. Concurrent shutdown calls on the same pump interrupt each other
   before the sequence can transition past stopping. Track shutdown-
   in-flight per pump and skip if one is already underway.

Together with the rotatingMachine delayedMove-clearing fix, this lets
the level-based hysteresis cycle complete: pumps shut off cleanly at
stopLevel, basin reverses direction, refills to startLevel, repeat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:17:55 +02:00
Rene De Ren
2651aaf409 abortActiveMovements: only WARN when actually aborting an in-flight move
In normal operation the _dispatchInFlight gate (handleInput)
guarantees no pump movement is in flight when a new dispatch starts,
so the per-machine abort call is a no-op. The previous unconditional
WARN flooded the log with one line per pump per tick (~3/s) for what
was actually a normal-path no-op.

Now the WARN fires ONLY when a pump's state is accelerating or
decelerating — i.e. the gate has been bypassed and we're force-
aborting an in-flight ramp. The wording reflects that:

  Force-aborting in-flight movement on pump_a (state=accelerating)
  due to: new demand received — _dispatchInFlight gate bypassed.

If you ever see this in production logs, the gate has a hole and
needs investigating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:43:12 +02:00
Rene De Ren
df74ea0fac Serialize handleInput dispatches via _dispatchInFlight gate
Mirrors rotatingMachine state.delayedMove. PS ticks demand into MGC at
1 Hz but a real pump ramp takes several seconds; before this gate every
PS tick aborted the in-flight optimalControl and started a new one, so
pumps never reached their setpoint. Live observation: 120 aborts / 2
min, pump_a drifting to 138 m³/h while pump_b stayed clamped at minFlow
60 m³/h ("near_curve_edge").

While a dispatch is in flight, the latest {source, demand, powerCap,
priorityList} is parked in _delayedCall and the new call returns.
The in-flight dispatch's finally block picks up the latest delayed
value when it settles. Latest-wins — intermediate demands are stomped
because they were obsolete by the time the pumps were ready for them.

Regression test in superproject:
test/mgc-overactive-demand-serialization.integration.test.js
30 concurrent demand calls now produce ≤ 5 aborts (was 30).

All existing tests still pass: 21 MGC integration + 7 cross-node
integration (incl. realistic-startup-timing, inflow-overcapacity-
stability, ps-mgc-flow-contract, idle-startup-deadlock).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:14:59 +02:00
Rene De Ren
96b84d3124 Revert: handleInput unchanged-demand short-circuit
Reverts a14aa0d. The "skip when demand unchanged" optimisation broke
the live demo: in some real conditions (basin transitions, safety
controller activations) PS sends repeated demand=0 and the optimisation
correctly turned pumps off the first time but then declined to re-act
when conditions changed in a way the test suite didn't cover. Live
result: pumps stayed off even when basin filled to overflow.

The original symptom (pumps stuck mid-ramp under saturated demand) needs
a different approach — likely a pump-side guard rather than an MGC-side
demand filter. Investigating in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:55:41 +02:00
Rene De Ren
a14aa0dab8 handleInput: skip abort+redispatch when demand unchanged
Live trace showed PS ticking every 1 s and re-firing the SAME demand
(100% saturated under storm inflow) while the basin level evolved slowly.
Each tick was calling abortActiveMovements + optimalControl, which
aborted in-flight pump moves before they could finish (move duration
~0.4 s vs 1 s tick) and immediately re-issued the same setpoint. Pumps
got stuck ramping from the same starting position toward the same
target indefinitely — moveTimeleft stable at 0.379 s for minutes,
flow.predicted frozen.

Now early-return when |demandQ - prev| < max(0.5, prev*0.005). PS
hysteresis float jitter is filtered, real demand changes still
propagate. Pumps finish their first move and stay at the right
setpoint instead of being aborted forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:10:50 +02:00
Rene De Ren
69bdf11fc4 DOWNSTREAM is the live aggregate; AT_EQUIPMENT is the optimizer's intent
handlePressureChange writes the live aggregate (sum of every pump's
current predicted-flow measurement) to flow.predicted.downstream — that
is the channel PS subscribes to for its outflow estimate, and it must
reflect what pumps are actually delivering.

optimalControl + equalFlowControl + prioPercentageControl were also
writing to DOWNSTREAM with the optimizer's TARGET (bestFlow / totalFlow).
That's a planned setpoint, not an achieved aggregate, and it was
clobbering the live value every handleInput tick — leaving PS reading
e.g. 105 m³/h while the real aggregate was 681 m³/h. Test
ps-mgc-flow-contract caught this deterministically.

Move all the optimizer-target writes to AT_EQUIPMENT (the "what we
commanded the equipment to do" channel). DOWNSTREAM is now
single-writer (handlePressureChange) and faithfully tracks reality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:32:58 +02:00
Rene De Ren
dc27a569d9 handlePressureChange: mirror aggregate flow onto DOWNSTREAM
PS subscribes to MGC's flow.predicted.downstream and uses it as the
outflow estimate for net-flow computation. MGC was only writing to
DOWNSTREAM inside optimalControl (the optimizer's bestFlow TARGET, not
the achieved aggregate), and to AT_EQUIPMENT in handlePressureChange.

During transients — e.g. demand dropping to dead-band keep-alive while
pumps are still ramping down from full throttle — PS saw a stale 25 m³/h
target on DOWNSTREAM while pumps were physically delivering 500+ m³/h.
NetFlow looked small and stable when the basin was actually draining
fast.

flow.act = sum of every pump's current predicted output = achieved
aggregate. Mirror it onto DOWNSTREAM so PS gets a live signal on every
pump flow/pressure update, not just every MGC.handleInput.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:20:21 +02:00
Rene De Ren
b7c40b0ddc equalFlowControl: mirror the optimalControl dispatch reorder
The priority-control codepath had the same stale dispatch shape that
caused the live deadlock in optimalControl: only handling idle and
operational states, and chaining flowmovement after execsequence
startup. Aligns it with the optimalControl fix so a future mode switch
to prioritycontrol doesn't reintroduce the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:47:17 +02:00
Rene De Ren
8e684203a8 Test: add full-cycle and up/down-sweep regression scenarios
Scenario 5 covers 100% → 0% → 100% with the second 100% landing
mid-shutdown (stopping/coolingdown) — exercises the path where
delayedMove must NOT be saved on a non-idle non-residue state without
a follow-up startup, since transitionToState('idle') doesn't fire it.

Scenario 6 walks 10%→100%→10% monotonically and asserts the down-sweep's
final demand is honoured (catches the user's observed "stuck around
60% going up, no reaction going down" symptom — where pumps would
otherwise freeze at a stale setpoint from the up-sweep).

Both pass with the current MGC dispatch fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:33:56 +02:00
Rene De Ren
9916527790 optimalControl: dispatch setpoint to non-operational pumps too
Previously the dispatch loop only fired flowmovement for pumps in
'operational' or transitioned 'idle' pumps via execsequence-startup-then-flowmovement.
Pumps mid-startup (starting/warmingup) were silently skipped. With PS
sending demand every tick, intermediate setpoints during the startup
window never reached the pump — it locked onto the very first
snapshot's flowmovement and froze there.

Now flowmovement is sent regardless of state and rotatingMachine's
state.moveTo handles the queueing (delayedMove for transients, unpark
for residue, immediate for operational). Crucially, flowmovement runs
BEFORE execsequence-startup so the FIRST call's stale setpoint can't
land on an already-operational pump and overwrite the latest
delayedMove that fires at end of startup.

Adds three integration tests:
- demand-cycle-walkthrough: 0..100% sweep with clean per-step table
- idle-startup-deadlock: four scenarios that pin the dispatch behaviour
  including the regression guard for varying-demand-during-startup
- optimizer-combination-choice: physical-validity invariants

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:19:47 +02:00
73 changed files with 10052 additions and 1854 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.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- 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.

70
CONTRACT.md Normal file
View File

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

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

View File

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

View File

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

View File

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

103
mgc.html
View File

@@ -8,19 +8,69 @@
| **Control Module** | `#a9daee` | zwart |
-->
<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/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
<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>
RED.nodes.registerType('machineGroupControl',{
category: "EVOLV",
color: "#50a8d9",
color: "#B5651D",
defaults: {
// Define default properties
name: { value: "" },
processOutputFormat: { value: "process" },
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
uuid: { value: "" },
supplier: { value: "" },
@@ -40,7 +90,7 @@
distance: { value: 0 },
distanceUnit: { value: "m" },
distanceDescription: { value: "" }
},
inputs:1,
outputs:3,
@@ -52,10 +102,17 @@
return (this.positionIcon || "") + " machineGroup";
},
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 = () => {
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 {
setTimeout(waitForMenuData, 50);
}
@@ -84,23 +141,49 @@
<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>
<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>
<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="json">json</option>
<option value="csv">csv</option>
</select>
<div id="mgc-process-output-picker" class="evolv-icon-picker"
role="radiogroup" aria-label="Process output format"></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>
<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="frost">frost</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
<div id="mgc-dbase-output-picker" class="evolv-icon-picker"
role="radiogroup" aria-label="Database output format"></div>
</div>
<hr class="mgc-section-divider" />
<!-- Logger fields injected here -->
<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 path = require('path');
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
const { MenuManager, configManager } = require('generalFunctions');
@@ -36,4 +37,16 @@ module.exports = function(RED) {
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

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

View File

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

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

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

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

@@ -0,0 +1,38 @@
'use strict';
// machineGroupControl command registry. Consumed by BaseNodeAdapter via
// `static commands = require('./commands')`. Each descriptor maps a
// canonical msg.topic to its handler; legacy names are listed under
// `aliases` and emit a one-time deprecation warning at runtime.
const handlers = require('./handlers');
module.exports = [
{
topic: 'set.mode',
aliases: ['setMode'],
payloadSchema: { type: 'string' },
description: 'Switch the operating mode. Allowed: `optimalControl`, `priorityControl`, `maintenance` (schema-validated in `machineGroupControl.json` → `mode.current`).',
handler: handlers.setMode,
},
{
topic: 'child.register',
aliases: ['registerChild'],
// payload is the Node-RED id (string) of the child node.
payloadSchema: { type: 'string' },
description: 'Register a child machine with this group.',
handler: handlers.registerChild,
},
{
topic: 'set.demand',
aliases: ['Qd'],
// 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' },
description: 'Operator demand setpoint. Bare number = %; {value, unit} for absolute flow units. Negative = stop all.',
handler: handlers.setDemand,
},
];

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

@@ -0,0 +1,182 @@
'use strict';
// Priority-based control strategies for machineGroupControl.
//
// equalFlowControl: distribute demand equally across priority-ordered active
// machines, falling back to start/stop the next priority when the current
// active set can't deliver.
//
// Extracted from specificClass during the P4 refactor; the orchestrator
// wires it in via the strategies map below. It depends on the same
// group-curve helpers the optimizer uses, so allocation and power
// evaluation stay on the equalised group operating point.
const { POSITIONS } = require('generalFunctions');
const { groupFlow, groupCalcPower } = require('../groupOps/groupCurves');
function sortMachinesByPriority(machines, priorityList) {
if (priorityList && Array.isArray(priorityList)) {
return priorityList
.filter(id => machines[id])
.map(id => ({ id, machine: machines[id] }));
}
return Object.entries(machines)
.map(([id, machine]) => ({ id, machine }))
.sort((a, b) => a.id - b.id);
}
function filterOutUnavailableMachines(list) {
return list.filter(({ machine }) => {
const state = machine.state.getCurrentState();
const validActionForMode = machine.isValidActionForMode('execsequence', 'auto');
return !(state === 'off' || state === 'coolingdown' || state === 'stopping'
|| state === 'emergencystop' || !validActionForMode);
});
}
function capFlowDemand(Qd, dynamicTotals, logger) {
if (Qd < dynamicTotals.flow.min && Qd > 0) {
logger?.warn?.(`Flow demand ${Qd} below min ${dynamicTotals.flow.min}; capping.`);
return dynamicTotals.flow.min;
}
if (Qd > dynamicTotals.flow.max) {
logger?.warn?.(`Flow demand ${Qd} above max ${dynamicTotals.flow.max}; capping.`);
return dynamicTotals.flow.max;
}
return Qd;
}
// 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) {
const { mgc } = ctx;
try {
mgc.equalizePressure();
const dynamicTotals = mgc.calcDynamicTotals();
const activeTotals = mgc.totals.activeTotals();
const { flowDistribution, totalFlow, totalPower, totalCog } = computeEqualFlowDistribution({
machines: mgc.machines,
Qd, dynamicTotals, activeTotals, priorityList,
isMachineActive: (id) => mgc.isMachineActive(id),
groupFlow, groupCalcPower,
logger: mgc.logger,
});
const pUnit = mgc.unitPolicy.canonical.power;
const fUnit = mgc.unitPolicy.canonical.flow;
mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, totalPower, pUnit);
mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, totalFlow, fUnit);
// Hydraulic efficiency η = (Q·ΔP)/P_shaft, same scale as child cogs.
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) {
mgc.logger?.error?.(err);
}
}
module.exports = {
equalFlowControl, computeEqualFlowDistribution,
capFlowDemand, sortMachinesByPriority, filterOutUnavailableMachines,
};

View File

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

View File

@@ -0,0 +1,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

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

View File

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

View File

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

View File

@@ -5,11 +5,13 @@ const Machine = require('../../rotatingMachine/src/specificClass');
const Measurement = require('../../measurement/src/specificClass');
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 = {
optimalcontrol: 'OPT',
prioritycontrol: 'PRIO',
prioritypercentagecontrol: 'PERC'
};
const stateConfig = {
@@ -60,7 +62,6 @@ function createGroupConfig(name) {
return {
general: { logging: { enabled: false, logLevel: 'error' }, name: `machinegroup-${name}` },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
scaling: { current: 'normalized' },
mode: { current: 'optimalcontrol' }
};
}
@@ -185,7 +186,9 @@ async function driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrd
await sleep(15);
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 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 };
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);
const totals = captureTotals(mg);

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

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

View File

@@ -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

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

@@ -0,0 +1,358 @@
// Basic tests for the machineGroupControl commands registry.
// Run with: node --test test/basic/commands.basic.test.js
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createRegistry } = require('generalFunctions');
const commands = require('../../src/commands');
// --- helpers ---------------------------------------------------------------
function makeLogger() {
const calls = { warn: [], error: [], info: [], debug: [] };
return {
calls,
warn: (m) => calls.warn.push(String(m)),
error: (m) => calls.error.push(String(m)),
info: (m) => calls.info.push(String(m)),
debug: (m) => calls.debug.push(String(m)),
};
}
function makeSource({
name = 'mgc-1',
handleInputResult = undefined,
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 = {
setMode: [],
handleInput: [],
registerChild: [],
turnOffAllMachines: 0,
gateAction: [],
gateSource: [],
};
const source = {
logger: makeLogger(),
config: { general: { name } },
mode,
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) => {
calls.handleInput.push({ src, demand });
if (handleInputResult instanceof Error) throw 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: {
registerChild: (childSource, position) =>
calls.registerChild.push({ childSource, position }),
},
};
return { source, calls };
}
function makeCtx({ child = null, logger = makeLogger(), sendSpy = null } = {}) {
return {
logger,
RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } },
node: {},
send: sendSpy || (() => {}),
};
}
function makeRegistry(logger) {
return createRegistry(commands, { logger });
}
// --- tests -----------------------------------------------------------------
test('canonical topics dispatch to their handlers', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.mode', payload: 'prioritycontrol' }, source, makeCtx());
assert.deepEqual(calls.setMode, ['prioritycontrol']);
// bare-number demand → interpreted as % → interpolated against dt.flow.
// Default test dt is {min:0,max:100} so % is identity.
await reg.dispatch({ topic: 'set.demand', payload: '12.5' }, source, makeCtx());
assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 12.5 });
});
test('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 () => {
const { source, calls } = makeSource();
const child = { id: 'child-1', source: { tag: 'child-domain' } };
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'child.register', payload: 'child-1', positionVsParent: 'upstream' },
source,
makeCtx({ child })
);
assert.equal(calls.registerChild.length, 1);
assert.equal(calls.registerChild[0].childSource, child.source);
assert.equal(calls.registerChild[0].position, 'upstream');
});
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch({ topic: 'setMode', payload: 'prioritycontrol' }, source, makeCtx({ logger: ctxLogger }));
await reg.dispatch({ topic: 'setMode', payload: 'optimalcontrol' }, source, makeCtx({ logger: ctxLogger }));
assert.deepEqual(calls.setMode, ['prioritycontrol', 'optimalcontrol']);
let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated"));
assert.equal(warns.length, 1, 'setMode deprecation warning should log exactly once');
await reg.dispatch({ topic: 'Qd', payload: 5 }, source, makeCtx({ logger: ctxLogger }));
warns = ctxLogger.calls.warn.filter((m) => m.includes("'Qd' is deprecated"));
assert.equal(warns.length, 1);
assert.equal(calls.handleInput.length, 1);
const child = { id: 'child-x', source: { tag: 'child-domain' } };
await reg.dispatch(
{ topic: 'registerChild', payload: 'child-x', positionVsParent: 'atEquipment' },
source,
makeCtx({ child, logger: ctxLogger })
);
warns = ctxLogger.calls.warn.filter((m) => m.includes("'registerChild' is deprecated"));
assert.equal(warns.length, 1);
assert.equal(calls.registerChild.length, 1);
});
test('set.demand with non-numeric payload logs error and does not call handleInput', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 'oops' }, source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.handleInput.length, 0);
assert.ok(
ctxLogger.calls.error.some((m) => m.includes('set.demand') && m.includes('oops')),
`expected error about invalid Qd, got: ${JSON.stringify(ctxLogger.calls.error)}`
);
});
test('set.demand on success calls ctx.send with reply { topic: config.general.name, payload: "done" }', async () => {
const { source, calls } = makeSource({ name: 'mgc-A' });
const sent = [];
const ctx = makeCtx({ sendSpy: (m) => sent.push(m) });
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 7.5 }, source, ctx);
assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 7.5 });
assert.equal(sent.length, 1);
assert.equal(sent[0].topic, 'mgc-A');
assert.equal(sent[0].payload, 'done');
});
test('child.register with unknown child id logs warn and does not throw', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await assert.doesNotReject(() =>
reg.dispatch(
{ topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' },
source,
makeCtx({ logger: ctxLogger })
)
);
assert.equal(calls.registerChild.length, 0);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')),
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});
// --- 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,140 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DemandDispatcher = require('../../src/dispatch/demandDispatcher.js');
const silentLogger = { warn() {}, error() {}, debug() {}, info() {} };
// Helper: a manually-resolvable promise so we can pin a dispatch in flight.
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
test('fire(50) triggers runFn with 50', async () => {
const calls = [];
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => { calls.push(demand); },
);
dispatcher.fire(50);
await dispatcher.drain();
assert.deepEqual(calls, [50]);
});
test('two fires back-to-back during in-flight — only the second runs after first settles', async () => {
const calls = [];
const gates = [deferred()];
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => {
calls.push(demand);
await gates[0].promise;
},
);
dispatcher.fire(10);
// first invocation is now in flight (after a microtask)
await Promise.resolve();
await Promise.resolve();
dispatcher.fire(20);
// 20 should be pending, not yet run.
assert.deepEqual(calls, [10]);
gates[0].resolve();
await dispatcher.drain();
assert.deepEqual(calls, [10, 20]);
});
test('three rapid fires — only first + last run; middle dropped', async () => {
const calls = [];
const gate = deferred();
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => {
calls.push(demand);
if (calls.length === 1) await gate.promise;
},
);
dispatcher.fire(1);
await Promise.resolve();
await Promise.resolve();
dispatcher.fire(2);
dispatcher.fire(3); // overwrites the pending 2
assert.deepEqual(calls, [1]);
gate.resolve();
await dispatcher.drain();
assert.deepEqual(calls, [1, 3]);
});
test('drain() resolves only when idle', async () => {
const gate = deferred();
let runs = 0;
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async () => { runs++; await gate.promise; },
);
// drain() on an idle gate resolves immediately.
await dispatcher.drain();
dispatcher.fire('a');
let drained = false;
const drainPromise = dispatcher.drain().then(() => { drained = true; });
// Let a few microtasks run — drain must NOT be resolved while in flight.
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(drained, false);
assert.equal(runs, 1);
gate.resolve();
await drainPromise;
assert.equal(drained, true);
});
test('error in runFn does not deadlock; subsequent fire still works', async () => {
const calls = [];
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => {
calls.push(demand);
if (demand === 'boom') throw new Error('boom');
},
);
dispatcher.fire('boom');
await dispatcher.drain();
dispatcher.fire('ok');
await dispatcher.drain();
assert.deepEqual(calls, ['boom', 'ok']);
});
test('inFlight getter reports correctly', async () => {
const gate = deferred();
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async () => { await gate.promise; },
);
assert.equal(dispatcher.inFlight, false);
dispatcher.fire(1);
// Microtask scheduling — gate flips to inFlight after one tick.
await Promise.resolve();
assert.equal(dispatcher.inFlight, true);
gate.resolve();
await dispatcher.drain();
assert.equal(dispatcher.inFlight, false);
});
test('runFn receives the ctx supplied at construction', async () => {
const seen = [];
const ctx = { logger: silentLogger, marker: 'mgc-A' };
const dispatcher = new DemandDispatcher(
ctx,
async (demand, runCtx) => { seen.push({ demand, marker: runCtx.marker }); },
);
dispatcher.fire(42);
await dispatcher.drain();
assert.deepEqual(seen, [{ demand: 42, marker: 'mgc-A' }]);
});

View File

@@ -0,0 +1,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

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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

View File

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

View File

@@ -3,7 +3,7 @@ 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/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', () => {
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

@@ -0,0 +1,222 @@
// MGC demand-cycle walkthrough — drive the machine group through a
// configurable demand sweep and print a clean per-step snapshot of every
// pump's state, ctrl%, flow and power. This is a diagnostic test, not a
// strict invariant guard: it asserts only the basics (no stuck states,
// total flow tracks demand) and prints a readable table for visual
// inspection.
//
// Knobs (env vars):
// STEP_PERCENT — demand step in percent (default 10)
// DWELL_MS — wait per step for movement (default 800)
// HEAD_MBAR — pump head in mbar (default 1100)
// N_PUMPS — number of identical pumps (default 3)
// LOG_DEBUG=1 — enable verbose domain logging (default off)
//
// Run:
// node --test nodes/machineGroupControl/test/integration/demand-cycle-walkthrough.integration.test.js
// STEP_PERCENT=5 DWELL_MS=400 node --test ...
// LOG_DEBUG=1 node --test ... # firehose mode
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
const Machine = require('../../../rotatingMachine/src/specificClass');
const STEP_PERCENT = parseFloat(process.env.STEP_PERCENT || '10');
const DWELL_MS = parseInt(process.env.DWELL_MS || '800', 10);
const HEAD_MBAR = parseFloat(process.env.HEAD_MBAR || '1100');
const N_PUMPS = parseInt(process.env.N_PUMPS || '3', 10);
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
const HEAD_MBAR_UP = 0;
const HEAD_MBAR_DOWN = HEAD_MBAR;
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
const stateConfig = {
general: { logging: logCfg },
state: { current: 'idle' },
// Fast ramp so each step settles within DWELL_MS.
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
// Zero sequence-step durations — startup/shutdown are instantaneous so
// the per-step delta is purely the optimizer's response, not waiting
// for the FSM.
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
};
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' }, // 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).
};
}
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));
// States where the pump is not actually producing flow/power. When the FSM
// is parked in any of these, predictFlow.outputY / predictPower.outputY
// still reflect the curve floor at the current operating point — that is
// useful for the optimizer but misleading in this walkthrough table. Show
// zeros instead so each row's per-pump column matches the optimizer's
// chosen split and ΣQ matches Qd.
const NON_RUNNING = new Set(['idle', 'off', 'stopping', 'coolingdown', 'emergencystop']);
function snapshot(pump) {
const state = pump.state.getCurrentState();
const ctrl = Number(pump.state.getCurrentPosition?.() ?? 0);
const running = !NON_RUNNING.has(state);
const flow = running ? Number(pump.predictFlow?.outputY ?? 0) * 3600 : 0; // m³/s → m³/h
const power = running ? Number(pump.predictPower?.outputY ?? 0) / 1000 : 0; // W → kW
return { state, ctrl, flow, power };
}
function fmt(x, w, d = 1) { return Number.isFinite(x) ? x.toFixed(d).padStart(w) : ' n/a'.padStart(w); }
function printHeader(pumps) {
const head = ['cmd%'.padStart(5), 'Qd m³/h'.padStart(9)];
for (const p of pumps) {
head.push('|', `${p.config.general.id}`.padEnd(8), 'state'.padEnd(13), 'ctrl%'.padStart(6),
'Q m³/h'.padStart(7), 'kW'.padStart(6));
}
head.push('|', 'ΣQ m³/h'.padStart(8), 'ΣkW'.padStart(6));
const line = head.join(' ');
console.log(line);
console.log('─'.repeat(line.length));
}
function printRow(pct, demandQout_m3h, pumps) {
const snaps = pumps.map(snapshot);
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
const totalP = snaps.reduce((s, x) => s + x.power, 0);
const cells = [fmt(pct, 5), fmt(demandQout_m3h, 9)];
for (let i = 0; i < pumps.length; i++) {
const s = snaps[i];
cells.push('|', ''.padEnd(8), s.state.padEnd(13), fmt(s.ctrl, 6), fmt(s.flow, 7), fmt(s.power, 6));
}
cells.push('|', fmt(totalQ, 8), fmt(totalP, 6));
console.log(cells.join(' '));
return { totalQ, totalP, snaps };
}
test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps, step=${STEP_PERCENT}%`, async () => {
const { mgc, pumps } = buildGroup();
// Bring all pumps to operational up-front so the very first row of the
// table reflects the optimizer's response, not "the FSM is still
// booting".
for (const m of pumps) await m.handleInput('parent', 'execsequence', 'startup');
for (let i = 0; i < 50 && pumps.some(p => p.state.getCurrentState() !== 'operational'); i++) await sleep(20);
for (const p of pumps) {
assert.equal(p.state.getCurrentState(), 'operational',
`pre-condition: pump ${p.config.general.id} should be operational; got ${p.state.getCurrentState()}`);
}
const dyn = mgc.calcDynamicTotals();
const flowMin_m3h = dyn.flow.min * 3600;
const flowMax_m3h = dyn.flow.max * 3600;
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
const perPumpMin_m3h = sample.currentFxyYMin * 3600;
const perPumpMax_m3h = sample.currentFxyYMax * 3600;
console.log('');
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(` station: ${flowMin_m3h.toFixed(1)} .. ${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; 0 = minimum-control floor)`);
console.log('');
printHeader(pumps);
// Build demand sweep: 0..100% up, then 100..0% down, then -1 (all-off sentinel).
const upSteps = [];
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 sequence = [...upSteps, ...downSteps, -1];
let stuckSeen = 0;
for (const pct of sequence) {
// 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);
// pct < 0 → all off (Qd = 0); pct >= 0 → linear interpolation across [min, max].
const demandQout_m3h = pct < 0
? 0
: (flowMax_m3h - flowMin_m3h) * (pct / 100) + flowMin_m3h;
const { totalQ, snaps } = printRow(pct, demandQout_m3h, pumps);
// Loose invariants:
// - demand > 0% → station total flow within 10% of optimizer's chosen
// Qout (allow slack: optimizer may pick a smaller combo for
// efficiency, in which case totalQ falls below demand only inside
// the per-pump curve envelope; we ONLY check above feasibility).
// - no pump should sit in a residue state ('accelerating' /
// 'decelerating') AFTER the dwell — that's the deadlock symptom
// the abort-deadlock test guards against.
for (const s of snaps) {
if (s.state === 'accelerating' || s.state === 'decelerating') stuckSeen += 1;
}
if (pct < 0) {
// Strict negative demand turns ALL pumps off (the explicit "all off" signal).
for (const s of snaps) {
assert.ok(['idle', 'off', 'stopping', 'coolingdown'].includes(s.state),
`demand ${pct}% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`);
}
}
}
console.log('');
console.log(`Stuck-state observations across ${sequence.length} steps: ${stuckSeen}`);
assert.equal(stuckSeen, 0,
`${stuckSeen} pump×step observations parked in accelerating/decelerating after dwell — ` +
`would indicate the abort-deadlock regression has returned (state.js post-abort residue).`);
});

View File

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

@@ -0,0 +1,365 @@
// MGC + idle pumps under realistic startup times — three scenarios that
// pin down WHERE the live deadlock is happening when PS sends 100% but
// pumps "show on" without adopting the control value.
//
// All three scenarios start with idle pumps (NOT pre-started) and use
// non-zero state.time values so startup is observable. Each scenario
// prints the per-pump snapshot at the end. The asserts state what we
// EXPECT to happen — failures point at the exact codepath that breaks.
//
// Compare to demand-cycle-walkthrough.integration.test.js which
// pre-starts every pump to 'operational' and therefore CANNOT exercise
// the idle-during-rapid-retarget paths described here.
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' };
// Production-realistic-but-shrunk: starting=1s, warmingup=2s. Total
// startup ~3s. Long enough for rapid retargeting (every 200ms) to land
// 10+ extra calls during the transient, short enough to keep the test
// well under 30s.
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' },
};
}
// 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 } = {}) {
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) {
if (withPressure) {
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));
const NON_RUNNING = new Set(['idle', 'off', 'stopping', 'coolingdown', 'emergencystop']);
function snapshot(pump) {
const state = pump.state.getCurrentState();
const ctrl = Number(pump.state.getCurrentPosition?.() ?? 0);
const running = !NON_RUNNING.has(state);
const flow = running ? Number(pump.predictFlow?.outputY ?? 0) * 3600 : 0;
const power = running ? Number(pump.predictPower?.outputY ?? 0) / 1000 : 0;
return { state, ctrl, flow, power, delayedMove: pump.state.delayedMove };
}
function printSnapshots(label, pumps) {
console.log(`\n --- ${label} ---`);
console.log(' ' + ['id'.padEnd(8), 'state'.padEnd(14), 'ctrl%'.padStart(6), 'Q m³/h'.padStart(8), 'kW'.padStart(6), 'delayedMove'.padStart(12)].join(' '));
console.log(' ' + '-'.repeat(60));
for (const p of pumps) {
const s = snapshot(p);
console.log(' ' + [
p.config.general.id.padEnd(8),
s.state.padEnd(14),
s.ctrl.toFixed(1).padStart(6),
s.flow.toFixed(1).padStart(8),
s.power.toFixed(1).padStart(6),
String(s.delayedMove).padStart(12),
].join(' '));
}
}
function expectAllRunningAt100(pumps, label) {
// After settle every pump should be operational with high ctrl% and
// measurable flow. "high" is conservative — at 100% normalized demand,
// 3-pump split puts each pump near 100% ctrl. Allow >70% as the floor
// (accommodates BEP-Gravitation's slight asymmetry at the curve edges).
for (const p of pumps) {
const s = snapshot(p);
assert.equal(s.state, 'operational',
`${label}: pump ${p.config.general.id} expected operational, got '${s.state}' (ctrl=${s.ctrl.toFixed(1)}, delayedMove=${s.delayedMove})`);
assert.ok(s.ctrl > 70,
`${label}: pump ${p.config.general.id} expected ctrl% > 70 at 100% demand, got ${s.ctrl.toFixed(2)} (state=${s.state}, delayedMove=${s.delayedMove})`);
assert.ok(s.flow > 100,
`${label}: pump ${p.config.general.id} expected flow > 100 m³/h, got ${s.flow.toFixed(2)} (state=${s.state}, ctrl=${s.ctrl.toFixed(1)})`);
}
}
// ---------------------------------------------------------------------------
test('Scenario 1 — single-shot 100% demand to idle pumps', async () => {
// Hypothesis A: a SINGLE handleInput call to MGC with all pumps idle is
// enough to surface the bug. If pumps end up at 100% ctrl, the bug is
// elsewhere (rapid retargeting OR pressure plumbing). If pumps stay at
// 0%, the dispatch loop itself doesn't follow through on
// execsequence-startup → flowmovement.
const { mgc, pumps } = buildGroup();
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);
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
printSnapshots('immediately after handleInput returns', pumps);
// Wait for full startup (3s) + movement (~0.5s) + slack
await sleep(6000);
printSnapshots('after 6s settle', pumps);
expectAllRunningAt100(pumps, 'Scenario 1');
});
// ---------------------------------------------------------------------------
test('Scenario 2 — rapid 100% retargeting during startup window', async () => {
// Hypothesis B: PS fires _applyMachineGroupLevelControl on every level
// tick (every few hundred ms). While pumps are in 'starting' /
// 'warmingup', MGC's optimalControl loop snapshots them, hits NONE of
// its three branches (idle / operational / flow<=0), and dispatches
// nothing. The only reason pumps eventually move is the FIRST call's
// queued `await flowmovement` after `await execsequence startup` —
// unless a subsequent call's abortActiveMovements aborts that move
// mid-flight, parking it in 'accelerating'/'decelerating'.
const { mgc, pumps } = buildGroup();
console.log(`\n[Scenario 2] firing mgc.handleInput('parent', pctToCanonical(mgc, 100)) every 200ms for 5s`);
printSnapshots('before any handleInput', pumps);
// First call (kicks off startup); not awaited so retargets can layer on.
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
// window with 25 extra retargeting calls.
const interval = setInterval(() => {
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(e => console.log(`retarget rejected: ${e.message}`));
}, 200);
await sleep(5000);
clearInterval(interval);
printSnapshots('right after retarget barrage stops', pumps);
// Drain: let any pending moves finish and let the FSM settle.
await sleep(3000);
printSnapshots('after 3s drain', pumps);
expectAllRunningAt100(pumps, 'Scenario 2');
});
// ---------------------------------------------------------------------------
test('Scenario 3 — pumps with NO pressure measurements injected', async () => {
// Hypothesis C: in production, MGC may receive a demand BEFORE the
// first pressure measurement has propagated. Without head, the curve's
// operating point is at fDimension=defaults, and currentFxyYMin/Max
// may not correspond to a usable envelope. If MGC's distributor then
// hands every pump flow≤0, the dispatch loop falls into the 'flow<=0
// → shutdown' branch and pumps go straight to idle.
const { mgc, pumps } = buildGroup({ withPressure: false });
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
const minQ = sample.currentFxyYMin * 3600;
const maxQ = sample.currentFxyYMax * 3600;
const dyn = mgc.calcDynamicTotals();
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);
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
await sleep(6000);
printSnapshots('after 6s settle (no pressure)', pumps);
// We don't assert success here — this scenario is exploratory. Just
// log what happens. If pumps DO ramp despite no pressure, MGC is
// resilient. If they stay idle, that's a meaningful failure mode for
// the live system because a redeploy may rebuild the world before
// sensors republish.
console.log(' (Scenario 3 is exploratory — no asserts; review the snapshot above.)');
});
// ---------------------------------------------------------------------------
test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
// Hypothesis E: when demand goes 100% → 0% → 100% (basin fills, drains
// past stopLevel, then refills), pumps pass through stopping →
// coolingdown → idle. If a fresh flow>0 demand arrives while a pump is
// mid-shutdown, the current MGC dispatch saves flowmovement to
// delayedMove (good) but doesn't issue execsequence-startup because
// state !== 'idle' (bug). The pump completes shutdown, reaches 'idle',
// and stays there because transitionToState('idle') doesn't fire
// delayedMove — only the transition INTO 'operational' does. Pump is
// stuck with delayedMove orphaned.
const { mgc, pumps } = buildGroup();
console.log('\n[Scenario 5] cycle: 100% → 0% → 100% with mid-shutdown re-engage');
printSnapshots('before any handleInput', pumps);
// Phase 1: drive up to 100% from idle.
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
await sleep(5000); // full startup + ramp
printSnapshots('after settle at 100%', pumps);
for (const p of pumps) {
assert.equal(p.state.getCurrentState(), 'operational',
`Phase 1: pump ${p.config.general.id} not operational at 100% (got ${p.state.getCurrentState()})`);
}
// Phase 2: demand drops below 0 — pumps begin shutdown sequence. Use a
// 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%
// demand to arrive WHILE pumps are still in stopping/coolingdown,
// not after they've reached idle.
mgc.turnOffAllMachines().catch(e => console.log(`-1% rejected: ${e.message}`));
// Wait briefly so the shutdown sequence enters but does NOT complete.
// shutdown=['stopping','coolingdown','idle'] with stopping=1s,
// coolingdown=2s. 500ms puts us solidly inside 'stopping'.
await sleep(500);
printSnapshots('mid-shutdown (pumps should be in stopping/coolingdown)', pumps);
const midShutdownStates = pumps.map(p => p.state.getCurrentState());
console.log(` states mid-shutdown: ${midShutdownStates.join(', ')}`);
// Phase 3: demand returns to 100% while pumps are mid-shutdown.
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
// Generous: full coolingdown remaining + full startup + ramp.
await sleep(8000);
printSnapshots('after re-engage to 100%', pumps);
expectAllRunningAt100(pumps, 'Scenario 5');
});
// ---------------------------------------------------------------------------
test('Scenario 6 — full up sweep then full down sweep', async () => {
// Hypothesis F: the user observed "going up stuck ~60%, going down
// not reacting". Mirror that with an explicit up-then-down monotonic
// sweep, every step holding 600 ms (slightly longer than DWELL on
// production basin model). After the sweep, we expect the LATEST
// demand (the final value of the down-sweep, which is 10%) to be
// honoured: pumps either at 1-pump combo's split or all idle if that
// demand falls below the per-pump minimum.
const { mgc, pumps } = buildGroup();
console.log('\n[Scenario 6] up-sweep 10%→100% then down-sweep 100%→10%, each step 600 ms');
printSnapshots('before any handleInput', pumps);
const upSteps = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
const downSteps = [90, 80, 70, 60, 50, 40, 30, 20, 10];
console.log(' --- up sweep ---');
for (const pct of upSteps) {
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`up ${pct}% rejected: ${e.message}`));
await sleep(600);
const snaps = pumps.map(snapshot);
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
console.log(` cmd=${pct.toFixed(0).padStart(3)}% states=[${snaps.map(s=>s.state.padEnd(13)).join(', ')}] ctrl=[${snaps.map(s=>s.ctrl.toFixed(1).padStart(5)).join(', ')}] ΣQ=${totalQ.toFixed(1)}`);
}
printSnapshots('top of up-sweep (cmd=100%) after full settle', pumps);
await sleep(2000);
printSnapshots('top of up-sweep + 2s drain', pumps);
console.log(' --- down sweep ---');
for (const pct of downSteps) {
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`down ${pct}% rejected: ${e.message}`));
await sleep(600);
const snaps = pumps.map(snapshot);
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
console.log(` cmd=${pct.toFixed(0).padStart(3)}% states=[${snaps.map(s=>s.state.padEnd(13)).join(', ')}] ctrl=[${snaps.map(s=>s.ctrl.toFixed(1).padStart(5)).join(', ')}] ΣQ=${totalQ.toFixed(1)}`);
}
printSnapshots('bottom of down-sweep (cmd=10%) after sequence', pumps);
await sleep(3000);
printSnapshots('bottom of down-sweep + 3s drain', pumps);
// Final demand was 10% (≈ 148 m³/h). At head 1100 mbar with per-pump
// min ≈ 89.5, this is solvable by a 1-pump combo near 148 m³/h.
// Optimizer typically picks the 1-pump combo. Either way, pumps are
// NOT supposed to be stuck at the prior up-sweep's 100% setpoint.
const flowMin_m3h = mgc.calcDynamicTotals().flow.min * 3600;
const flowMax_m3h = mgc.calcDynamicTotals().flow.max * 3600;
const expectedQ_m3h = flowMin_m3h + (flowMax_m3h - flowMin_m3h) * 0.10; // 10% scaled
console.log(` expected total flow at 10%: ~${expectedQ_m3h.toFixed(1)} m³/h`);
const snaps = pumps.map(snapshot);
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
// Loose: total within 30 m³/h of expectation. Catches the obvious
// stuck-at-old-position regression.
assert.ok(Math.abs(totalQ - expectedQ_m3h) < 30,
`Scenario 6: total flow ${totalQ.toFixed(1)} m³/h diverged from expected ${expectedQ_m3h.toFixed(1)} after down-sweep — pumps did not adopt latest demand. Per-pump: ${snaps.map(s => `${s.state}@${s.ctrl.toFixed(0)}%`).join(', ')}`);
});
// ---------------------------------------------------------------------------
test('Scenario 4 — varying demand during startup (combo flips)', async () => {
// Hypothesis D: in production the demand is NOT constant — as basin
// level rises, percControl ramps from startLevel→maxLevel over the
// basin model. Demand can flip between 1-pump / 2-pump / 3-pump
// combinations every PS tick. Each flip in optimalControl tells some
// pumps to start, others to shutdown, others nothing. If a pump that
// was just told "startup" is told "shutdown" 1s later (still in
// 'starting' state — neither idle nor operational), nothing happens
// for that pump in this snapshot. The execsequence shutdown branch
// requires state to be operational/accelerating/decelerating — a
// 'starting'/'warmingup' pump is silently passed over for shutdown
// too. The pump then proceeds to operational AND obeys its queued
// flowmovement, even though MGC's intent has since changed.
const { mgc, pumps } = buildGroup();
const sequence = [25, 75, 50, 100, 30, 90, 60, 100];
console.log(`\n[Scenario 4] varying demand sequence: ${sequence.join(' → ')} (each held 400ms)`);
printSnapshots('before any handleInput', pumps);
for (const pct of sequence) {
console.log(` → demand ${pct}%`);
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`call ${pct}% rejected: ${e.message}`));
await sleep(400);
}
printSnapshots('right after sequence ends', pumps);
// Final demand was 100% — drain and verify pumps converged.
await sleep(4000);
printSnapshots('after 4s drain (demand was last set to 100%)', pumps);
expectAllRunningAt100(pumps, 'Scenario 4');
});

View File

@@ -27,6 +27,19 @@ const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/h
/* ---- 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 distortSeries(series, scale = 1, tilt = 0) {
@@ -54,7 +67,7 @@ function createMachineConfig(id, label) {
return {
general: { logging: { enabled: false, logLevel: 'error' }, name: label, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
@@ -72,7 +85,6 @@ function createGroupConfig(name) {
return {
general: { logging: { enabled: false, logLevel: 'error' }, name },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
scaling: { current: 'normalized' },
mode: { current: 'optimalcontrol' }
};
}
@@ -407,10 +419,15 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
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.setScaling('normalized');
await mg.handleInput('parent', 50, Infinity);
function pctCanonical(mgc, pct) {
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 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', 'startup');
}
await waitReady(mg); // ensure the group is settled so the next demand isn't deferred
// Run 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 prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;

View File

@@ -0,0 +1,168 @@
// MGC optimizer combination choice — given a known operating point and
// 3 identical pumps, walk demand from below per-pump min through to
// full station capacity and assert the optimizer always returns a
// combination whose per-pump split lies within each pump's curve.
//
// This is a regression test. Earlier traces showed per-pump flow values
// that looked impossible (78 m³/h while we believed min was ~99). The
// real explanation: the curve's currentFxyYMin shifts with head — at
// 1652 mbar the per-pump min IS 49 m³/h. This test pins the optimizer's
// behaviour at a single deterministic head so the asserted ranges are
// stable.
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_DOWN = 1100;
const HEAD_MBAR_UP = 0;
const stateConfig = {
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 },
};
function machineConfig(id) {
return {
general: { logging: { enabled: false, logLevel: 'error' }, 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: { enabled: false, logLevel: 'error' }, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
mode: { current: 'optimalcontrol' },
};
}
function buildGroup() {
const mgc = new MachineGroup(groupConfig());
const ids = ['pump_a', 'pump_b', 'pump_c'];
const pumps = ids.map(id => new Machine(machineConfig(id), stateConfig));
for (const m of pumps) {
// Inject deterministic pressures so every pump sees the same head.
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 };
}
test('optimizer always returns a physically valid split (head=1100 mbar)', () => {
// The core invariant: whatever combination the optimizer picks, every
// per-pump assignment must lie inside that pump's curve envelope at
// the current operating point, and the total must equal the demand.
// This is what makes a combo "physically valid". The optimizer is
// free to pick fewer or more pumps based on efficiency — that is NOT
// a violation.
const { mgc, pumps } = buildGroup();
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
const minPerPump = sample.currentFxyYMin * 3600;
const maxPerPump = sample.currentFxyYMax * 3600;
// Guard against a curve-data change silently invalidating the asserts.
assert.ok(minPerPump > 80 && minPerPump < 100,
`unexpected curve min ${minPerPump} at 1100 mbar`);
assert.ok(maxPerPump > 220 && maxPerPump < 230,
`unexpected curve max ${maxPerPump} at 1100 mbar`);
const stationMax = maxPerPump * pumps.length; // ≈ 681
// Note: we deliberately stay 1 m³/h short of stationMax to avoid a
// floating-point edge where validPumpCombinations rejects an exact
// boundary demand. Real demand is never exactly station max anyway.
const demands = [0, 50, minPerPump - 5, minPerPump, 150, 200, 230, 250, 300, 400, 500, 600, stationMax - 1];
const rows = [];
for (const Qd_m3h of demands) {
const Qd_m3s = Qd_m3h / 3600;
const combos = mgc.validPumpCombinations(mgc.machines, Qd_m3s, Infinity);
if (combos.length === 0) {
rows.push({ Qd_m3h, picked: null, perPump: [], total: 0 });
// The validity rule rejects a combo when Qd is outside its
// [sum(min), sum(max)] envelope. With only 3 identical pumps at
// this head, that means Qd < minPerPump (no combo's min envelope
// contains it) or Qd > stationMax. Strict zero is also rejected.
assert.ok(Qd_m3h <= 0 || Qd_m3h < minPerPump,
`unexpected: no valid combo for Qd=${Qd_m3h} (per-pump ${minPerPump.toFixed(2)}..${maxPerPump.toFixed(2)}, station max ${stationMax.toFixed(2)})`);
continue;
}
const best = mgc.calcBestCombinationBEPGravitation(combos, Qd_m3s, 'BEP-Gravitation-Directional');
assert.ok(best.bestCombination, `no bestCombination for Qd=${Qd_m3h}`);
const split = best.bestCombination.map(e => e.flow * 3600);
const total = split.reduce((s, x) => s + x, 0);
rows.push({ Qd_m3h, picked: best.bestCombination.length, perPump: split, total });
// Each per-pump split must lie in [minPerPump, maxPerPump].
for (const f of split) {
assert.ok(f >= minPerPump - 1e-3,
`Qd=${Qd_m3h}: per-pump ${f.toFixed(2)} below min ${minPerPump.toFixed(2)}`);
assert.ok(f <= maxPerPump + 1e-3,
`Qd=${Qd_m3h}: per-pump ${f.toFixed(2)} above max ${maxPerPump.toFixed(2)}`);
}
assert.ok(Math.abs(total - Qd_m3h) < Math.max(1, Qd_m3h * 0.01),
`Qd=${Qd_m3h}: total ${total.toFixed(2)} ≠ demand`);
}
// Print the chosen combinations for inspection.
console.log(`\nHead = ${HEAD_MBAR_DOWN - HEAD_MBAR_UP} mbar`);
console.log(`Per-pump curve: min=${minPerPump.toFixed(2)} m³/h, max=${maxPerPump.toFixed(2)} m³/h`);
console.log(`Station max (3 pumps × max): ${stationMax.toFixed(2)} m³/h\n`);
console.log(' demand pumps per-pump split');
console.log(' ────── ───── ─────────────────────────────');
for (const r of rows) {
if (r.picked == null) {
console.log(` ${r.Qd_m3h.toFixed(1).padStart(6)} none no valid combo`);
} else {
console.log(` ${r.Qd_m3h.toFixed(1).padStart(6)} ${r.picked} [${r.perPump.map(f => f.toFixed(1)).join(', ')}] total=${r.total.toFixed(1)}`);
}
}
});
test('feasibility floor and ceiling: only 1-pump combo serves demand below 2×min', () => {
// The optimizer is allowed to pick larger combos for efficiency, but
// it CANNOT pick a combo whose [sum(min), sum(max)] doesn't contain
// the demand. This pins down the floor / ceiling rules.
const { mgc, pumps } = buildGroup();
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
const minPerPump = sample.currentFxyYMin * 3600;
const maxPerPump = sample.currentFxyYMax * 3600;
// Demand below per-pump min → no combo at all. (sum(min) ≥ minPerPump
// for every non-empty combo, and Qd < sum(min) ⇒ rejected.)
let combos = mgc.validPumpCombinations(mgc.machines, (minPerPump - 5) / 3600, Infinity);
assert.equal(combos.length, 0, `demand below per-pump min should yield 0 valid combos, got ${combos.length}`);
// Demand within [minPerPump, 2*minPerPump): only 1-pump combos pass.
// (2-pump min envelope = 2×minPerPump > Qd.)
const Qd1 = (minPerPump + 5) / 3600;
combos = mgc.validPumpCombinations(mgc.machines, Qd1, Infinity);
for (const c of combos) {
assert.equal(c.length, 1,
`demand ${minPerPump+5} m³/h: only 1-pump combos should be valid (got ${c.length}-pump)`);
}
// Demand above station max → no valid combo.
combos = mgc.validPumpCombinations(mgc.machines, (maxPerPump * 3 + 50) / 3600, Infinity);
assert.equal(combos.length, 0, `demand above station max should yield 0 valid combos`);
});

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'));
}
const FLOW_FILES = ['01-Basic.json', '02-Dashboard.json'];
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');
}
});
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);
assert.equal(Array.isArray(parsed), true);
}

View File

@@ -0,0 +1,141 @@
// Regression: pump A in pumpingstation-complete-example demo got stuck
// running at minimum flow while basin level dropped past stopLevel and
// kept dropping all the way to dry-run threshold.
//
// Root cause (two parts):
//
// 1. rotatingMachine.executeSequence on shutdown went through an
// interruptible-abort path that returned the FSM to 'operational',
// triggering state.transitionToState's auto-pickup of the queued
// delayedMove — re-engaging the pump before the shutdown sequence
// could reach stopping/coolingdown/idle. Fix: clear delayedMove at
// the top of shutdown/emergencystop sequences.
//
// 2. PS calls turnOffAllMachines on every tick (every 2 s) while
// level < stopLevel. Each call interrupted the still-running prior
// shutdown's transitions, resetting the FSM to 'accelerating'. The
// pump bounced accelerating ↔ decelerating forever and the actual
// shutdown sequence transitions never ran. Fix: serialize per-pump
// shutdown calls in turnOffAllMachines so concurrent invocations
// are no-ops while a shutdown is already in flight.
//
// This test exercises part 2 — the per-pump serialization at the MGC
// level — by hammering turnOffAllMachines from a tight loop, mirroring
// the live tick cadence.
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
const Machine = require('../../../rotatingMachine/src/specificClass');
const logCfg = { enabled: false, logLevel: 'error' };
const stateConfig = {
general: { logging: logCfg },
state: { current: 'idle' },
movement: { mode: 'staticspeed', speed: 50, maxSpeed: 100, interval: 10 },
// Non-zero shutdown timing so a shutdown takes long enough that a
// concurrent turnOff call lands mid-sequence — exactly the live race.
time: { starting: 0, warmingup: 0, stopping: 1, coolingdown: 1 },
};
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 buildGroup() {
const mgc = new MachineGroup(groupConfig());
const ids = ['pump_a', 'pump_b', 'pump_c'];
const pumps = ids.map(id => new Machine(machineConfig(id), stateConfig));
for (const m of pumps) {
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
m.updateMeasuredPressure(1100, '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));
test('repeated turnOffAllMachines reaches idle (serializes concurrent shutdowns)', async () => {
const { mgc, pumps } = buildGroup();
const pumpA = pumps[0];
// Start pump A and queue a delayedMove the way MGC's optimalControl
// would when PS sends a 1% dead-zone keep-alive.
await pumpA.handleInput('parent', 'execsequence', 'startup');
assert.equal(pumpA.state.getCurrentState(), 'operational');
pumpA.setpoint(80); // start a slow move (not awaited)
await sleep(50);
assert.equal(pumpA.state.getCurrentState(), 'accelerating');
pumpA.state.delayedMove = 75;
// Mimic PS's tick loop: fire turnOffAllMachines on a tight cadence
// without awaiting. Without the per-pump serialization in
// turnOffAllMachines, each call hits the still-running prior shutdown
// and bounces the pump back to accelerating — the live deadlock.
const ticks = [];
for (let i = 0; i < 6; i++) {
ticks.push(mgc.turnOffAllMachines());
await sleep(80); // half the realtime tick — tighter race
}
await Promise.all(ticks);
// Allow the (single) in-flight shutdown to finish its 1+1 s timed
// transitions through stopping → coolingdown → idle.
await sleep(2500);
assert.equal(pumpA.state.getCurrentState(), 'idle',
`pump must reach idle under repeated turnOff calls; got ${pumpA.state.getCurrentState()} (delayedMove=${pumpA.state.delayedMove})`);
assert.equal(pumpA.state.delayedMove, null,
'delayedMove must be cleared after shutdown');
});
test('turnOffAllMachines cancels any parked demand so it cannot re-engage pumps', async () => {
// PS sends a 1% keep-alive while MGC is mid-dispatch. MGC parks it in
// its demand dispatcher's latest-wins slot. PS then crosses stopLevel
// and calls turnOffAllMachines. Without cancelPending(), the parked
// 1% call would fire AFTER the shutdown — re-engaging the pump.
const { mgc } = buildGroup();
const gate = mgc._demandDispatcher._gate;
// Pin a fake in-flight dispatch then park a pending call behind it.
gate._inFlight = true;
const parked = mgc.handleInput('parent', 1, Infinity, null);
await mgc.turnOffAllMachines();
// Re-open the gate: the in-flight pin is artificial. Awaiting the
// parked promise must yield the SUPERSEDED sentinel (i.e. it was
// cancelled, not run).
const res = await parked;
assert.ok(res && res.superseded === true,
'parked demand must resolve as superseded after turnOffAllMachines cancels it');
// Idle now — pending slot must be clear.
assert.equal(gate._pending, null,
'turnOff must cancel any parked demand so it cannot re-engage pumps post-shutdown');
gate._inFlight = false;
});

148
wiki/Home.md Normal file
View File

@@ -0,0 +1,148 @@
# machineGroupControl
![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)
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.
---
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | A pump group sharing one suction + one discharge header |
| S88 level | Unit |
| Use it when | You have 2&nbsp;+ pumps that can substitute for each other on the same header and you want efficient load-sharing |
| Don't use it for | A single pump (wire `rotatingMachine` directly), valves (use `valveGroupControl`), or pumps living behind independent headers |
| Children it accepts | `machine` (rotatingMachine), `measurement` (pressure / others) |
| Parent it talks to | `pumpingStation` (typical), or any node that issues `set.demand` |
---
## How it fits
```mermaid
flowchart LR
parent[pumpingStation<br/>Process Cell]:::pc -->|set.demand| mgc[machineGroupControl<br/>Unit]:::unit
header[measurement<br/>header pressure]:::ctrl -.measured.-> mgc
mgc -->|flowmovement / execsequence| m_a[rotatingMachine A]:::equip
mgc -->|flowmovement / execsequence| m_b[rotatingMachine B]:::equip
mgc -->|flowmovement / execsequence| m_c[rotatingMachine C]:::equip
mgc -->|child.register| parent
m_a -->|child.register| mgc
m_b -->|child.register| mgc
m_c -->|child.register| mgc
classDef pc fill:#0c99d9,color:#fff
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
```
S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`.
---
## Try it &mdash; 3-minute demo
Import the basic example flow, deploy, and watch three pumps come online together when demand rises.
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/machineGroupControl/examples/01-Basic.json \
http://localhost:1880/flow
```
What to click in the dashboard after deploy:
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.
> [!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`.
---
## The three things you'll send
`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.
| 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). |
---
## What you'll see come out
Sample Port 0 message (delta-compressed &mdash; only changed fields each tick):
```json
{
"topic": "machineGroupControl#MGC1",
"payload": {
"mode": "optimalControl",
"atEquipment_predicted_flow": 42.5,
"downstream_predicted_flow": 42.5,
"atEquipment_predicted_power": 18.0,
"headerDiffPa": 145000,
"headerDiffMbar": 1450,
"flowCapacityMax": 90,
"flowCapacityMin": 6,
"machineCount": 3,
"machineCountActive": 2,
"absDistFromPeak": 0.02,
"relDistFromPeak": 0.10
}
}
```
| Field | Meaning |
|:---|:---|
| `mode` | Current dispatch mode. |
| `atEquipment_predicted_flow` / `_power` | Group aggregate at the pump shafts. The optimizer writes intent here; `handlePressureChange` keeps it in sync with the live totals. |
| `downstream_predicted_flow` | Live aggregate mirrored onto DOWNSTREAM &mdash; pumpingStation parents subscribe here. |
| `headerDiffPa` / `headerDiffMbar` | Last header differential the equalizer resolved. Dashboards use it for Q-H plots without re-reading every child. |
| `flowCapacityMax` / `flowCapacityMin` | The group's dynamic envelope at the current header pressure. Defines where `set.demand` (as %) maps to. |
| `machineCount` / `machineCountActive` | All registered children, and how many are in a state other than `off` / `maintenance`. |
| `absDistFromPeak` / `relDistFromPeak` | Distance from group BEP. `relDistFromPeak` is `undefined` when the η spread collapses (homogeneous pump group). |
The key shape is `<position>_<variant>_<type>` &mdash; the inverse of `rotatingMachine`'s `<type>.<variant>.<position>.<childId>` key shape, because MGC's output is the group aggregate, not a per-child snapshot.
---
## The new bit &mdash; the movement planner
When MGC computes a new optimal combination it doesn't fan the commands out instantly. It builds a **schedule** that times each command so the running aggregate stays close to demand during the transition.
```mermaid
flowchart LR
demand[set.demand] --> dispatch[_runDispatch<br/>latest-wins]
dispatch --> abort[abortActiveMovements]
abort --> opt[optimizer.calcBestCombination*]
opt --> profiles[buildProfile<br/>x children]
profiles --> plan[movementScheduler.plan<br/>rendezvous t* = max&#40;eta_i&#41;]
plan --> exec[movementExecutor.replan<br/>+ await tick&#40;&#41;]
exec --> kids[rotatingMachine x N<br/>flowmovement / execsequence]
```
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.
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.
---
## Need more?
| 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 |
[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)

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)