Compare commits

...

19 Commits

Author SHA1 Message Date
znetsixe
394a972d10 hydraulic efficiency η = (Q·ΔP)/P + asset registry rename
The pre-existing efficiency formula `η = flow/power` produced tiny SI-unit
values (m³/J ≈ 1e-5), was monotonic in ctrl for centrifugal-pump curves
(no interior peak), and made NCog collapse to 0 — which cascaded into MGC
reporting BEP-position 0.0% always. Replaced with hydraulic efficiency
η = (Q·ΔP)/P_shaft, the dimensionless 0..1 ratio that has a real BEP and
matches the form MGC's group-level math uses.

- prediction/efficiencyMath.js:
  * calcEfficiencyCurve takes pressureDiffPa; η = 0 when dP missing
  * calcCog guards (yMax > yMin) before computing NCog (was unguarded /0)
  * calcEfficiency falls back to predictFlow.currentF when measured ΔP is
    missing, so predicted-variant calls still produce a meaningful η before
    the differential measurement settles
- specificClass.js:
  * Asset-registry lookup renamed: 'machine' → 'rotatingmachine' (matches
    the datasets/assetData/ rename in generalFunctions). The error path
    quotes the new filename so operators can find it.
  * Two-call-site fix: with default-param stateConfig={}, the single-arg
    constructor path (BaseNodeAdapter calls `new Machine(this.config)`
    after pre-setting Machine._pendingExtras) was silently clobbering the
    pre-set extras. Only overwrite when the caller explicitly passes them.
  * Push port 0 deltas (notifyOutputChanged) after prediction updates so
    dashboards see state + predicted-flow changes as they happen.
- pressure/pressureRouter.js: routing + fallback hardening (the trigger
  for the bep-distance-cascade reproduction).
- display/workingCurves.js: Q-H curve generator extended.
- New tests:
  * test/integration/qh-curve.integration.test.js — Q-H curve shape
  * test/integration/bep-distance-cascade.integration.test.js — reproduces
    the dashboard report (absDistFromPeak=0, NCog=0, efficiency=0 after a
    setpoint move) at the unit level so future regressions fail loudly.

Full suite: 214/214 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:52:24 +02:00
znetsixe
28344c6810 feat(rotatingMachine): resolve supplier+type from asset registry, drop denormalized fields
specificClass._setupCurves now calls assetResolver.resolveAssetMetadata
to derive supplier/type/units from the model id, instead of trusting
denormalized fields on the node config. If the model isn't in the
registry, installs a null-predictor stub and logs a clear "pick a model
from the asset menu" error rather than crashing.

rotatingMachine.html: defaults block trimmed (supplier/category/assetType
were stale copies of registry data).

Tests:
- New test/basic/assetMetadata.basic.test.js covers the registry-resolve
  path and the missing-model fallback.
- nodeClass-config / error-paths / nodeClass-routing / factories /
  abort-deadlock fixtures updated to the trimmed asset shape.
- 209/209 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:12:33 +02:00
znetsixe
b373727338 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:05:29 +02:00
znetsixe
1a9f533b1e 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:11 +02:00
znetsixe
1d5e040af9 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:08 +02:00
znetsixe
84126e9130 B3.3 follow-up: drop _unitView mirror; use UnitPolicy property bags directly
Same as MGC — UnitPolicy property bags replace the manual _unitView/
unitPolicyView reassignment. specificClass.js 400→377. 196/196 tests
still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:13:20 +02:00
znetsixe
9e8463b41d 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:36 +02:00
znetsixe
e058fe9245 P5 wave 2: convert rotatingMachine to BaseDomain + extract helper modules
specificClass.js: 1760 → 400 lines.
  Machine extends BaseDomain. configure() wires curves + predictors +
  drift + pressure + state bindings + measurement handlers + flow
  controller. ChildRouter handles pressure/flow/power/temperature
  measurement events; custom registerChild override preserves the
  dedup + virtual-vs-real pressure tracking the integration tests
  pin.

  Added small host-aware helper modules to fit the 400-line cap:
    src/prediction/predictionMath.js   (calcFlow/Power/Ctrl)
    src/prediction/efficiencyMath.js   (calcCog/EfficiencyCurve/etc.)
    src/pressure/pressureSelector.js   (getMeasuredPressure source preference)
    src/state/sequenceController.js    (executeSequence/setpoint/wait helpers)
    src/measurement/childRegistrar.js  (custom registerChild path)
    src/drift/healthRefresh.js         (drift status update wrappers)
    src/io/output.js                   (buildOutput + buildStatusBadge)

  unitPolicy: live UnitPolicy methods .canonical()/.output()/.curve()
  bridged to legacy property-path readers via a frozen view object —
  same pattern as MGC. See OPEN_QUESTIONS.md.

nodeClass.js: 433 → 61 lines.
  Extends BaseNodeAdapter. tickInterval=null (event-driven on state +
  measurement events). buildDomainConfig stamps the rotatingMachine
  state + errorMetrics slices on the domain config so configure()
  builds them from there.

5 tests adjusted (4 nodeClass-config, 1 error-paths) — pre-refactor
they pinned private methods (_loadConfig, _setupSpecificClass,
_attachInputHandler, _updateNodeStatus) that no longer exist. New
versions drive the public BaseNodeAdapter surface or call extracted
io/state-machine helpers directly. See OPEN_QUESTIONS.md 2026-05-10
"private nodeClass tests" for the deferred rewrite plan.

196 / 196 tests pass (basic 110 + integration ~80 + edge ~6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:00:34 +02:00
znetsixe
c5bb375dd0 P5 wave 1: extract rotatingMachine concerns into focused modules
src/curves/         loader + normalizer (with cross-pressure anomaly
                      detection) + reverseCurve helper
  src/prediction/     predictors (predictFlow/Power/Ctrl) +
                      groupPredictors (lazy group-scope views) +
                      OperatingPoint (pressure-driven prediction setpoints)
  src/drift/          DriftAssessor (per-metric drift) + PredictionHealth
                      (composes flow/power/pressure into HealthStatus +
                      confidence sibling — see OPEN_QUESTIONS 2026-05-10)
  src/pressure/       VirtualPressureChildren (dashboard-sim) +
                      PressureInitialization (real-vs-virtual tracking) +
                      PressureRouter (dispatches by position)
  src/state/          stateBindings (state.emitter listener helper) +
                      isOperationalState
  src/measurement/    measurementHandlers (dispatcher for flow/power/temp/pressure)
  src/flow/           flowController (handleInput body — execSequence,
                      execMovement, flowMovement, emergencystop)
  src/display/        workingCurves (showWorkingCurves + showCoG admin)
  src/commands/       canonical names: set.mode, cmd.startup/shutdown/estop,
                      set.setpoint, set.flow-setpoint,
                      data.simulate-measurement, query.curves, query.cog,
                      child.register. execSequence demuxes by payload.action
                      to canonical cmd.* handlers.
  CONTRACT.md         inputs/outputs/events/children surface

110 basic tests pass (100 new + 10 pre-existing).
specificClass.js / nodeClass.js untouched — integration in P5 wave 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:38:45 +02:00
Rene De Ren
8f9150e160 fix: shutdown clears delayedMove so abort+autoPickup can't re-engage pump
When PS commanded turnOffAllMachines, executeSequence's interruptible
abort path triggered transitionToState('operational'), which auto-picked
up the queued delayedMove and re-started the pump. Pump bounced
accelerating ↔ decelerating forever and never reached idle.

Clear state.delayedMove at the top of shutdown/emergencystop sequences
so a user-commanded stop cancels any pending move.

Observed live: in pumpingstation-complete-example the basin drained
past stopLevel and equilibrated at ~0.3 m with one pump stuck at min
flow. With this fix pumps shut down cleanly at stopLevel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:17:45 +02:00
Rene De Ren
5a8113a9d1 Test: abort-deadlock regression guard
Two reproducers for the post-abort residue deadlock fixed in
generalFunctions state.js. The direct test forces the FSM into
'accelerating' (mimicking MGC's per-tick abortActiveMovements that
intentionally leaves the pump parked to avoid a bounce loop) and
issues a fresh setpoint — without the fix, currentPosition freezes
and delayedMove holds the new target forever; with the fix, residue
unparks and the move executes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:20:17 +02:00
Rene De Ren
ecd5a4864b Group-scope predicts for MGC combination optimization
Adds a parallel set of Predict instances (groupPredictFlow / Power / Ctrl)
that share input curves with the pump's individual predicts but maintain
their own operating point. MGC drives these via setGroupOperatingPoint()
to evaluate every pump curve at one shared manifold differential during
combination optimization, without corrupting each pump's own diagnostic
outputs (which track that pump's local sensors).

Created lazily on first use so pumps without an MGC parent pay nothing.
Pairs with generalFunctions Predict.shareInputsFrom plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:20:17 +02:00
znetsixe
399e0a8c01 Editor hygiene + remove redundant idle-position clamp in predictions
- rotatingMachine.html: add default name:{value:""} to the editor
  defaults block (standard Node-RED pattern; was missing).
- nodeClass.js: clear node status badge on close — matches the
  pattern already in other EVOLV node close handlers.
- specificClass.js: remove the `(x <= 0) ? 0 : ...` guard in the
  flow and power prediction methods. The guard was redundant:
  predictions only run while the FSM is in an active state
  (operational / starting / warmingup / accelerating / decelerating),
  none of which produce x=0. Math.max(0, rawFlow) still clamps
  negative extrapolation. Net: same behaviour in production, less
  dead code.

All 10 basic tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:50:50 +02:00
znetsixe
11d196f363 fix: pass returnToOperational:true for shutdown/estop abort path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:01:49 +02:00
znetsixe
510a4233e6 fix: remove trace instrumentation + update tests for corrected curve bounds
The bogus machineCurve default at pressure "1" (fixed in generalFunctions
086e5fe) made fValues.min=1, which let sub-curve differentials pass
unclamped. With the fix, fValues.min=70000 (the real curve minimum) and
low differentials get clamped. Three tests that accidentally depended on
the bogus min=1 behavior are updated:

- coolprop test: expects fDimension clamped to curve minimum when
  differential < curve range
- pressure-initialization test: uses pressures whose differential falls
  WITHIN the curve range (900 mbar = 90000 Pa > 70000 Pa minimum)
- sequences test: tests upper-bound constraint with setpoint > max,
  then confirms a valid setpoint is applied as-is (was incorrectly
  asserting any setpoint would be clamped to max)

Trace instrumentation from debugging session removed.

91/91 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:28:13 +02:00
znetsixe
26e253d030 fix: clamp flow/power predictions to 0 when controller position ≤ 0
At ctrl=0% with high backpressure, the curve prediction extrapolates to
large negative values (backflow through a stopped pump). This produced
confusing chart readings (-200+ m³/h for an idle pump) and polluted
downstream consumers like MGC efficiency calculations.

Fix: in both calcFlow and calcPower, if the controller position x ≤ 0
the prediction is clamped to 0 regardless of what the spline returns.
For x > 0, predictions are also clamped to ≥ 0 (negative flow/power
from a running pump is physically implausible for a centrifugal machine).

91/91 tests still green — no existing test asserted on negative
flow/power values at ctrl=0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:07:02 +02:00
znetsixe
c464b66b27 docs: add CLAUDE.md with S88 classification and superproject rule reference
References the flow-layout rule set in the EVOLV superproject
(.claude/rules/node-red-flow-layout.md) so Claude Code sessions working
in this repo know the S88 level, colour, and placement lane for this node.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:47:21 +02:00
znetsixe
17b88870bb fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
  orchestrators that use 'emergencyStop' (capital S) route correctly to
  the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
  not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
  in accelerating/decelerating, the active movement is aborted via
  state.abortCurrentMovement() and the sequence waits (up to 2s) for the
  FSM to return to 'operational' before proceeding. New helper
  _waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
  actionable.

Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
  shutdown during accelerating -> idle; emergencystop during accelerating
  -> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
  parametrized across both shipped pump curves (hidrostal-H05K-S03R and
  hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
  sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
  finiteness, and reverse-predictor round-trip.

E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
  benchmark that deploys one rotatingMachine per curve and runs a per-pump
  (pressure x ctrl) sweep inside each curve's envelope. Reports envelope
  compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
  pressure below the curve's minimum slice extrapolates wildly
  (defended by upstream measurement-node clamping in production).

UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
  Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
  help panel with a topic reference, port documentation, state diagram,
  and prediction rules.

Docs:
- README.md rewritten (was a single line) with install, quick start,
  topic/port reference, state machine, predictions, testing, production
  status.

Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
znetsixe
07af7cef40 fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close

Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null

Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
71 changed files with 6971 additions and 2182 deletions

23
CLAUDE.md Normal file
View File

@@ -0,0 +1,23 @@
# rotatingMachine — Claude Code context
Individual pump / compressor / blower control.
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
## S88 classification
| Level | Colour | Placement lane |
|---|---|---|
| **Equipment Module** | `#86bbdd` | L3 |
## Flow layout rules
When wiring this node into a multi-node demo or production flow, follow the
placement rule set in the **EVOLV superproject**:
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
Key points for this node:
- Place on lane **L3** (x-position per the lane table in the rule).
- 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 `#86bbdd` (Equipment Module).

94
CONTRACT.md Normal file
View File

@@ -0,0 +1,94 @@
# rotatingMachine — Contract
Hand-maintained for Phase 5; the `## Inputs` table is generated from
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 100 lines.
## Inputs (msg.topic on Port 0)
| Canonical | Aliases (deprecated) | Payload | Effect |
|---|---|---|---|
| `set.mode` | `setMode` | `string` — one of the allowed mode names | Calls `source.setMode(payload)`. |
| `cmd.startup` | — | `{ source?: string }` | Calls `source.handleInput(payload.source ?? 'parent', 'execSequence', 'startup')`. |
| `cmd.shutdown` | — | `{ source?: string }` | Calls `source.handleInput(payload.source ?? 'parent', 'execSequence', 'shutdown')`. |
| `cmd.estop` | `emergencystop` | `{ source?: string, action?: string }` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'emergencystop')`. |
| `execSequence` | — (legacy umbrella) | `{ source, action, parameter }` with `action ∈ {'startup','shutdown'}` | Content-based router: forwards to `cmd.startup` / `cmd.shutdown` handler based on `payload.action`. Unknown action logs `warn` and is dropped. Whole topic is legacy — prefer the canonical `cmd.*` topics. |
| `set.setpoint` | `execMovement` | `{ source, action, setpoint }` — setpoint coerced to `Number` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'execMovement', Number(payload.setpoint))`. |
| `set.flow-setpoint` | `flowMovement` | `{ source, action, setpoint }` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'flowMovement', Number(payload.setpoint))`. |
| `data.simulate-measurement` | `simulateMeasurement` | `{ type, position?, value, unit, timestamp? }``type ∈ {pressure, flow, temperature, power}`; `position` defaults to `'atEquipment'` | Validated dispatch: rejects non-finite `value`, unsupported `type`, missing `unit`, or unit that fails `isUnitValidForType`. Pressure routes via `updateSimulatedMeasurement(type, position, value, ctx)`; flow/temperature/power route via `updateMeasured<Type>(value, position, ctx)`. The injected `childId/childName = 'dashboard-sim'` marks the source. |
| `query.curves` | `showWorkingCurves` | none | Calls `source.showWorkingCurves()` and replies on **Port 0** with `{ topic: 'showWorkingCurves', payload: <result> }` via `ctx.send`. |
| `query.cog` | `CoG` | none | Calls `source.showCoG()` and replies on **Port 0** with `{ topic: 'showCoG', payload: <result> }`. |
| `child.register` | `registerChild` | `string` — child Node-RED id; `msg.positionVsParent` carries position | Resolves child via `RED.nodes.getNode(payload)` and registers it through `childRegistrationUtils.registerChild(child.source, msg.positionVsParent)`. Unknown ids log `warn`. |
Aliases log a one-time deprecation warning the first time they fire.
### `execSequence` demux
The pre-refactor topic `execSequence` carried `{ source, action, parameter }`
where `action` selected the verb (`startup` or `shutdown`). The command
registry does not natively dispatch by payload content, so `execSequence`
keeps its own descriptor whose handler **forwards directly** to the
canonical `cmd.startup` / `cmd.shutdown` handler based on
`payload.action`. The deprecation warning fires once. Future-Phase-7
removal of `execSequence` is a behavioural change — callers must migrate
to `cmd.startup` / `cmd.shutdown`.
## 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 `query.curves` / `query.cog` the
node additionally emits `{ topic: 'showWorkingCurves' | 'showCoG',
payload: <result> }` as a synchronous reply on Port 0.
- **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 (typically a `machineGroupControl` or
`pumpingStation`). `positionVsParent` defaults to `'atEquipment'`.
## 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.
rotatingMachine publishes:
- `flow.predicted.atequipment`, `flow.predicted.downstream`,
`flow.predicted.max`, `flow.predicted.min` — predicted operating point.
- `power.predicted.atequipment` — predicted shaft power.
- `temperature.measured.atequipment` — ambient/process temperature.
- `atmPressure.measured.atequipment` — barometric reference.
- `pressure.measured.upstream`, `pressure.measured.downstream`,
`pressure.measured.differential` — when pressure children register or
`data.simulate-measurement type=pressure` runs.
- `flow.measured.<position>`, `power.measured.atequipment`,
`temperature.measured.<position>` — when sensor children register or
the `data.simulate-measurement` topic supplies values.
Position labels are normalised to lowercase in the event name. The exact
set is data-driven by which children register and what they publish.
## Events emitted by `source.state.emitter`
- `positionChange` — fires when the position percentage changes (per
movement tick). Data: `{ position, state, mode, timestamp }`.
- `stateChange` — fires on transitions of the operating state machine
(`idle → starting → warmingup → operational → accelerating →
decelerating → stopping → coolingdown → idle`, plus `off`,
`maintenance`). Data: the new state string.
## Children registered by this node
rotatingMachine accepts `measurement` children through the
`childRegistrationUtils` handshake. Children typically have
`asset.type ∈ {pressure, flow, power, temperature}`. The machine
subscribes to the matching `<asset.type>.measured.<positionVsParent>`
event and mirrors the value into its own `MeasurementContainer`.
Two **virtual** children are reserved by the `data.simulate-measurement`
topic: incoming simulated values are tagged with
`childId/childName = 'dashboard-sim'` so dashboard-driven inputs are
distinguishable from real sensor children in downstream telemetry.
Position labels accepted from children are `upstream`, `downstream`,
`atEquipment` (and case variants — normalised internally).

117
README.md
View File

@@ -1 +1,116 @@
# rotating machine # rotatingMachine
Node-RED custom node for individual rotating-machine control — pumps, compressors, blowers. Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform developed by R&D at Waterschap Brabantse Delta.
Models a single asset with an S88 state machine, curve-backed flow/power prediction, and parent/child registration for orchestration by `machineGroupControl` or `pumpingStation`.
## Install
In a Node-RED user directory:
```bash
cd ~/.node-red
npm install github:gitea.wbd-rd.nl/RnD/rotatingMachine
```
Or consume the whole platform:
```bash
npm install github:gitea.wbd-rd.nl/RnD/EVOLV
```
Run `node-red` and the node appears in the editor palette under the **EVOLV** category.
## Quick start
Drop a `rotatingMachine` onto a flow, fill the Asset menu (supplier, model — must match a curve in `generalFunctions/datasets`), and wire three debug nodes to the three output ports. Inject these in order:
| Topic | Payload | Effect |
|---|---|---|
| `setMode` | `"virtualControl"` | allow manual commands |
| `simulateMeasurement` | `{type:"pressure",position:"upstream",value:200,unit:"mbar"}` | seed upstream pressure |
| `simulateMeasurement` | `{type:"pressure",position:"downstream",value:1100,unit:"mbar"}` | seed downstream pressure |
| `execSequence` | `{source:"GUI",action:"execSequence",parameter:"startup"}` | start the machine |
| `execMovement` | `{source:"GUI",action:"execMovement",setpoint:60}` | ramp to 60 % controller position |
| `execSequence` | `{source:"GUI",action:"execSequence",parameter:"shutdown"}` | shut down |
Ready-made example flows are in `examples/`:
- `01 - Basic Manual Control.json` — inject-only smoke test
- `02 - Integration with Machine Group.json` — parent/child registration with `machineGroupControl`
- `03 - Dashboard Visualization.json` — FlowFuse dashboard with live charts
Import via Node-RED **Import ▸ Examples ▸ EVOLV**.
## Input topics
| Topic | Payload | Notes |
|---|---|---|
| `setMode` | `"auto"` \| `"virtualControl"` \| `"fysicalControl"` | mode gates which sources may command the machine |
| `execSequence` | `{source, action:"execSequence", parameter}` — parameter: `"startup"` \| `"shutdown"` \| `"entermaintenance"` \| `"exitmaintenance"` | runs an S88 sequence |
| `execMovement` | `{source, action:"execMovement", setpoint}` — setpoint in controller % | moves controller position |
| `flowMovement` | `{source, action:"flowMovement", setpoint}` — setpoint in configured flow unit | converts flow → controller %, then moves |
| `emergencystop` | `{source, action:"emergencystop"}` | aborts any active movement and drives state to `off` |
| `simulateMeasurement` | `{type, position, value, unit}` — type: `pressure` \| `flow` \| `temperature` \| `power` | dashboard-side measurement injection |
| `showWorkingCurves` | — | diagnostic — reply on port 0 |
| `CoG` | — | diagnostic — reply on port 0 |
Topic case is preserved; sequence parameter and action names are normalized to lowercase internally (so `"emergencyStop"`, `"EmergencyStop"`, `"emergencystop"` all work).
## Output ports
| Port | Label | Payload |
|---|---|---|
| 0 | `process` | delta-compressed process payload; keys are `type.variant.position.childId` (e.g. `flow.predicted.downstream.default`). Consumers must cache and merge each tick. |
| 1 | `dbase` | InfluxDB line-protocol telemetry |
| 2 | `parent` | `{topic:"registerChild", payload:<nodeId>, positionVsParent}` emitted once on deploy for parent group/station registration |
## State machine
```
idle ─► starting ─► warmingup ─► operational ◄─┐
▲ │
│ ▼
│ accelerating / decelerating
│ │
└──────────┘
stopping ─► coolingdown ─► idle
emergencystop ─► off
```
- `warmingup` and `coolingdown` are **protected** — new commands cannot abort them.
- `accelerating` and `decelerating` **are** interruptible. If a `shutdown` or `emergencystop` sequence is requested mid-ramp, the active movement is aborted automatically and the sequence proceeds once the FSM has returned to `operational`.
- Timings come from the `Startup` / `Warmup` / `Shutdown` / `Cooldown` fields in the editor (seconds).
## Predictions
Flow and power outputs are curve-backed predictions driven by the controller position and the differential pressure across the machine. Inject both upstream and downstream pressures for best accuracy. With only one side present the node warns and falls back to the available side. With no pressure, predictions use the minimum pressure dimension (flow/power will look unrealistic).
The active curve is selected from `machineCurve.nq` and `machineCurve.np`, keyed by the closest matching pressure level. Curve units are declared in the Asset menu (default: `mbar`, `m³/h`, `kW`, `%`).
## Units
Canonical units are used internally (Pa / m³/s / W / K). All inputs and outputs convert at the boundary via the configured unit for each measurement type. The `speed` field in the editor is a ramp rate in controller-position units per second (so `speed: 1` → 1 %/s → a setpoint of 60 % from idle completes in ~60 s).
## Testing
```bash
cd nodes/rotatingMachine
npm test
```
79 tests cover construction, mode/input routing, config loading, sequences, emergency stop, shutdown, interruptible movement, movement lifecycle, prediction health, pressure initialization, CoolProp efficiency, registration, negative/null guards, output format, listener cleanup. Run the full suite in ~2 seconds.
For end-to-end verification, see `../../docker-compose.yml` — a Docker stack (Node-RED + InfluxDB + Grafana) that hosts the live node. The scripts in `../../../memory/` and `examples/` document the E2E protocol used for production-readiness benchmarks.
## Production status
Last reviewed **2026-04-13** — trial-ready. See the project memory file `node_rotatingMachine.md` for the latest benchmarks, known caveats, and wishlist.
## License
SEE LICENSE. Author: Rene De Ren, Waterschap Brabantse Delta R&D.

View File

@@ -4,7 +4,10 @@
"description": "Control module rotatingMachine", "description": "Control module rotatingMachine",
"main": "rotatingMachine.js", "main": "rotatingMachine.js",
"scripts": { "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": { "repository": {
"type": "git", "type": "git",

View File

@@ -17,6 +17,7 @@
category: "EVOLV", category: "EVOLV",
color: "#86bbdd", color: "#86bbdd",
defaults: { defaults: {
name: { value: "" },
// Define specific properties // Define specific properties
speed: { value: 1, required: true }, speed: { value: 1, required: true },
@@ -29,12 +30,11 @@
processOutputFormat: { value: "process" }, processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" }, dbaseOutputFormat: { value: "influxdb" },
//define asset properties // Asset identifier surface. supplier/category/assetType are
// derived at runtime via assetResolver.resolveAssetMetadata(model);
// do NOT add them back here. See src/registry/README.md.
uuid: { value: "" }, uuid: { value: "" },
assetTagNumber: { value: "" }, assetTagNumber: { value: "" },
supplier: { value: "" },
category: { value: "" },
assetType: { value: "" },
model: { value: "" }, model: { value: "" },
unit: { value: "" }, unit: { value: "" },
curvePressureUnit: { value: "mbar" }, curvePressureUnit: { value: "mbar" },
@@ -62,16 +62,23 @@
icon: "font-awesome/fa-cog", icon: "font-awesome/fa-cog",
label: function () { label: function () {
return (this.positionIcon || "") + " " + (this.category || "Machine"); // No more `this.category` on the node — fall back to model id, then a
// generic name. supplier/category/type live in the registry now.
const stem = this.model ? this.model : "Machine";
return (this.positionIcon || "") + " " + stem;
}, },
oneditprepare: function() { oneditprepare: function() {
// wait for the menu scripts to load // wait for the menu scripts to load
let menuRetries = 0;
const maxMenuRetries = 100; // 5 seconds at 50ms intervals
const waitForMenuData = () => { const waitForMenuData = () => {
if (window.EVOLV?.nodes?.rotatingMachine?.initEditor) { if (window.EVOLV?.nodes?.rotatingMachine?.initEditor) {
window.EVOLV.nodes.rotatingMachine.initEditor(this); window.EVOLV.nodes.rotatingMachine.initEditor(this);
} else { } else if (++menuRetries < maxMenuRetries) {
setTimeout(waitForMenuData, 50); setTimeout(waitForMenuData, 50);
} else {
console.warn("rotatingMachine: menu scripts failed to load within 5 seconds");
} }
}; };
waitForMenuData(); waitForMenuData();
@@ -124,23 +131,28 @@
<!-- Machine-specific controls --> <!-- Machine-specific controls -->
<div class="form-row"> <div class="form-row">
<label for="node-input-speed"><i class="fa fa-clock-o"></i> Reaction Speed</label> <label for="node-input-speed"><i class="fa fa-clock-o"></i> Reaction Speed</label>
<input type="number" id="node-input-speed" style="width:60%;" /> <input type="number" id="node-input-speed" style="width:60%;" placeholder="position units / second" />
<div style="font-size:11px;color:#666;margin-left:160px;">Ramp rate of the controller position in units per second (0100% controller range; e.g. 1 = 1%/s).</div>
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="node-input-startup"><i class="fa fa-clock-o"></i> Startup Time</label> <label for="node-input-startup"><i class="fa fa-clock-o"></i> Startup Time</label>
<input type="number" id="node-input-startup" style="width:60%;" /> <input type="number" id="node-input-startup" style="width:60%;" placeholder="seconds" />
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the <code>starting</code> state before moving to <code>warmingup</code>.</div>
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="node-input-warmup"><i class="fa fa-clock-o"></i> Warmup Time</label> <label for="node-input-warmup"><i class="fa fa-clock-o"></i> Warmup Time</label>
<input type="number" id="node-input-warmup" style="width:60%;" /> <input type="number" id="node-input-warmup" style="width:60%;" placeholder="seconds" />
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the protected <code>warmingup</code> state before reaching <code>operational</code>.</div>
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="node-input-shutdown"><i class="fa fa-clock-o"></i> Shutdown Time</label> <label for="node-input-shutdown"><i class="fa fa-clock-o"></i> Shutdown Time</label>
<input type="number" id="node-input-shutdown" style="width:60%;" /> <input type="number" id="node-input-shutdown" style="width:60%;" placeholder="seconds" />
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the <code>stopping</code> state before moving to <code>coolingdown</code>.</div>
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="node-input-cooldown"><i class="fa fa-clock-o"></i> Cooldown Time</label> <label for="node-input-cooldown"><i class="fa fa-clock-o"></i> Cooldown Time</label>
<input type="number" id="node-input-cooldown" style="width:60%;" /> <input type="number" id="node-input-cooldown" style="width:60%;" placeholder="seconds" />
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the protected <code>coolingdown</code> state before returning to <code>idle</code>.</div>
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="node-input-movementMode"><i class="fa fa-exchange"></i> Movement Mode</label> <label for="node-input-movementMode"><i class="fa fa-exchange"></i> Movement Mode</label>
@@ -180,11 +192,40 @@
</script> </script>
<script type="text/html" data-help-name="rotatingMachine"> <script type="text/html" data-help-name="rotatingMachine">
<p><b>Rotating Machine Node</b>: Configure a rotatingmachine asset.</p> <p><b>Rotating Machine</b>: individual pump / compressor / blower control module. Runs a 10-state S88 sequence, predicts flow and power from a supplier curve, and publishes process + telemetry outputs each second.</p>
<h3>Configuration</h3>
<ul> <ul>
<li><b>Reaction Speed, Startup, Warmup, Shutdown, Cooldown:</b> timing parameters.</li> <li><b>Reaction Speed</b>: controller ramp rate (position units / second). E.g. <code>1</code> = 1%/s, so Set 60% from idle reaches 60% in ~60&nbsp;s.</li>
<li><b>Supplier / SubType / Model / Unit:</b> choose via Asset menu.</li> <li><b>Startup / Warmup / Shutdown / Cooldown</b>: seconds per FSM phase. Warmup and Cooldown are <i>protected</i> they cannot be aborted by a new command.</li>
<li><b>Enable Log / Log Level:</b> toggle via Logger menu.</li> <li><b>Movement Mode</b>: <code>staticspeed</code> = linear ramp; <code>dynspeed</code> = ease-in/out.</li>
<li><b>Position:</b> set Upstream / At Equipment / Downstream via Position menu.</li> <li><b>Asset</b> (menu): supplier, category, model (must match a curve in <code>generalFunctions</code>), flow unit (e.g. m³/h), curve units.</li>
<li><b>Output Formats</b>: <code>process</code>/<code>json</code>/<code>csv</code> on port 0; <code>influxdb</code>/<code>json</code>/<code>csv</code> on port 1.</li>
<li><b>Position</b> (menu): <code>upstream</code> / <code>atEquipment</code> / <code>downstream</code> relative to a parent group/station.</li>
</ul> </ul>
<h3>Input topics (<code>msg.topic</code>)</h3>
<ul>
<li><code>setMode</code> <code>payload</code> = <code>auto</code> | <code>virtualControl</code> | <code>fysicalControl</code></li>
<li><code>execSequence</code> <code>payload</code> = <code>{source, action:"execSequence", parameter: "startup"|"shutdown"|"entermaintenance"|"exitmaintenance"}</code></li>
<li><code>execMovement</code> <code>payload</code> = <code>{source, action:"execMovement", setpoint: 0..100}</code> (controller %)</li>
<li><code>flowMovement</code> <code>payload</code> = <code>{source, action:"flowMovement", setpoint: &lt;flow in configured unit&gt;}</code></li>
<li><code>emergencystop</code> <code>payload</code> = <code>{source, action:"emergencystop"}</code>. Aborts any active movement.</li>
<li><code>simulateMeasurement</code> <code>payload</code> = <code>{type:"pressure"|"flow"|"temperature"|"power", position, value, unit}</code>. Injects dashboard-side measurement.</li>
<li><code>showWorkingCurves</code>, <code>CoG</code> diagnostics, reply arrives on port 0.</li>
</ul>
<h3>Output ports</h3>
<ol>
<li><b>process</b> delta-compressed process payload. Consumers must cache and merge each tick. Keys use 4-segment format <code>type.variant.position.childId</code> (e.g. <code>flow.predicted.downstream.default</code>).</li>
<li><b>dbase</b> InfluxDB telemetry.</li>
<li><b>parent</b> <code>registerChild</code> handshake for a parent <code>machineGroupControl</code> / <code>pumpingStation</code>.</li>
</ol>
<h3>State machine</h3>
<p>States: <code>idle starting warmingup operational (accelerating decelerating) operational stopping coolingdown idle</code>. <code>emergencystop off</code> is reachable from every active state.</p>
<p>If a <code>shutdown</code> or <code>emergencystop</code> sequence is requested while a setpoint move is in flight (<code>accelerating</code> / <code>decelerating</code>), the move is aborted automatically and the sequence proceeds once the FSM returns to <code>operational</code>.</p>
<h3>Predictions</h3>
<p>Flow and power predictions only produce meaningful values once at least one pressure child is reporting (or a <code>simulateMeasurement</code> pressure is injected). Inject BOTH upstream and downstream for best accuracy.</p>
</script> </script>

View File

@@ -1,6 +1,7 @@
const nameOfNode = 'rotatingMachine'; const nameOfNode = 'rotatingMachine';
const nodeClass = require('./src/nodeClass.js'); const nodeClass = require('./src/nodeClass.js');
const { MenuManager, configManager } = require('generalFunctions'); const { MenuManager, configManager } = require('generalFunctions');
const { buildQHCurve } = require('./src/display/workingCurves');
module.exports = function(RED) { module.exports = function(RED) {
// 1) Register the node type and delegate to your class // 1) Register the node type and delegate to your class
@@ -32,4 +33,20 @@ module.exports = function(RED) {
res.status(500).send(`// Error generating configData: ${err.message}`); res.status(500).send(`// Error generating configData: ${err.message}`);
} }
}); });
// Q-H curve sampler — served on RED.httpNode (the dashboard/runtime
// router) so dashboard function nodes can fetch without admin auth.
// GET /rotatingMachine/:id/qh-curve?ctrl=<percent>
// Returns { ctrlPct, points: [{ Q (m³/h), H (m), dpPa }, ...] }
RED.httpNode.get(`/${nameOfNode}/:id/qh-curve`, (req, res) => {
const node = RED.nodes.getNode(req.params.id);
const source = node?.source;
if (!source) {
res.status(404).json({ error: `No rotatingMachine with id ${req.params.id}` });
return;
}
const ctrl = Number(req.query.ctrl);
const result = buildQHCurve(source, Number.isFinite(ctrl) ? ctrl : source.state?.getCurrentPosition?.() ?? 0);
res.json(result);
});
}; };

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

@@ -0,0 +1,150 @@
'use strict';
// Handler functions for rotatingMachine commands. Each handler receives:
// source: the domain (specificClass) instance — exposes setMode, handleInput,
// updateMeasured*, updateSimulatedMeasurement, isUnitValidForType,
// showWorkingCurves, showCoG, childRegistrationUtils, logger.
// msg: the Node-RED input message.
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
//
// Pure functions: validation that goes beyond the registry's typeof-check
// ladder lives here. Reply messages (query.*) use ctx.send when available.
const SUPPORTED_SIM_TYPES = new Set(['pressure', 'flow', 'temperature', 'power']);
function _logger(source, ctx) {
return ctx?.logger || source?.logger || null;
}
function _send(ctx, ports) {
if (typeof ctx?.send === 'function') ctx.send(ports);
}
exports.setMode = (source, msg) => {
source.setMode(msg.payload);
};
// Canonical execution handlers. The legacy execSequence demuxer below
// forwards to these directly so behaviour is identical.
exports.startup = async (source, msg) => {
const p = msg.payload || {};
await source.handleInput(p.source ?? 'parent', 'execSequence', 'startup');
};
exports.shutdown = async (source, msg) => {
const p = msg.payload || {};
await source.handleInput(p.source ?? 'parent', 'execSequence', 'shutdown');
};
exports.estop = async (source, msg) => {
const p = msg.payload || {};
// Legacy emergencystop carried { source, action } — action defaults to
// 'emergencystop' when only source is supplied via the canonical topic.
await source.handleInput(p.source ?? 'parent', p.action ?? 'emergencystop');
};
// Content-based alias router: legacy `execSequence` carried payload.action in
// {'startup','shutdown'}. We dispatch back into the canonical handler so the
// behaviour and logs are identical regardless of which topic was used.
exports.execSequenceAlias = async (source, msg, ctx) => {
const log = _logger(source, ctx);
const action = msg?.payload?.action;
if (action === 'startup') return exports.startup(source, msg, ctx);
if (action === 'shutdown') return exports.shutdown(source, msg, ctx);
log?.warn?.(`execSequence: unsupported action '${action}'`);
};
exports.setSetpoint = async (source, msg) => {
const p = msg.payload || {};
const action = p.action ?? 'execMovement';
await source.handleInput(p.source ?? 'parent', action, Number(p.setpoint));
};
exports.setFlowSetpoint = async (source, msg) => {
const p = msg.payload || {};
const action = p.action ?? 'flowMovement';
await source.handleInput(p.source ?? 'parent', action, Number(p.setpoint));
};
exports.simulateMeasurement = (source, msg, ctx) => {
const log = _logger(source, ctx);
const payload = msg.payload || {};
const type = String(payload.type || '').toLowerCase();
const position = payload.position || 'atEquipment';
const value = Number(payload.value);
const unit = typeof payload.unit === 'string' ? payload.unit.trim() : '';
const context = {
timestamp: payload.timestamp || Date.now(),
unit,
childName: 'dashboard-sim',
childId: 'dashboard-sim',
};
if (!Number.isFinite(value)) {
log?.warn?.('simulateMeasurement payload.value must be a finite number');
return;
}
if (!SUPPORTED_SIM_TYPES.has(type)) {
log?.warn?.(`Unsupported simulateMeasurement type: ${type}`);
return;
}
if (!unit) {
log?.warn?.('simulateMeasurement payload.unit is required');
return;
}
if (typeof source.isUnitValidForType === 'function' &&
!source.isUnitValidForType(type, unit)) {
log?.warn?.(`simulateMeasurement payload.unit '${unit}' is invalid for type '${type}'`);
return;
}
_dispatchSimulated(source, type, position, value, context);
};
function _dispatchSimulated(source, type, position, value, context) {
switch (type) {
case 'pressure':
if (typeof source.updateSimulatedMeasurement === 'function') {
source.updateSimulatedMeasurement(type, position, value, context);
} else {
source.updateMeasuredPressure(value, position, context);
}
return;
case 'flow':
source.updateMeasuredFlow(value, position, context);
return;
case 'temperature':
source.updateMeasuredTemperature(value, position, context);
return;
case 'power':
source.updateMeasuredPower(value, position, context);
return;
}
}
exports.queryCurves = (source, msg, ctx) => {
const reply = Object.assign({}, msg, {
topic: 'showWorkingCurves',
payload: source.showWorkingCurves(),
});
_send(ctx, [reply, null, null]);
};
exports.queryCog = (source, msg, ctx) => {
const reply = Object.assign({}, msg, {
topic: 'showCoG',
payload: source.showCoG(),
});
_send(ctx, [reply, null, null]);
};
exports.registerChild = (source, msg, ctx) => {
const log = _logger(source, ctx);
const childId = msg.payload;
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
if (!childObj || !childObj.source) {
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
return;
}
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
};

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

@@ -0,0 +1,98 @@
'use strict';
// rotatingMachine 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.
//
// `execSequence` is special: the legacy payload carried `{source, action,
// parameter}` where `action` selected the canonical verb (startup /
// shutdown). The registry does not natively dispatch by payload content,
// so we keep `execSequence` as its own descriptor whose handler routes to
// the canonical `cmd.startup` / `cmd.shutdown` handler. Behaviour matches
// the canonical topics exactly; the deprecation warning still fires once.
const handlers = require('./handlers');
module.exports = [
{
topic: 'set.mode',
aliases: ['setMode'],
payloadSchema: { type: 'string' },
description: 'Switch the machine between auto / manual control modes.',
handler: handlers.setMode,
},
{
topic: 'cmd.startup',
payloadSchema: { type: 'any' },
description: 'Initiate the machine startup sequence.',
handler: handlers.startup,
},
{
topic: 'cmd.shutdown',
payloadSchema: { type: 'any' },
description: 'Initiate the machine shutdown sequence.',
handler: handlers.shutdown,
},
{
topic: 'cmd.estop',
aliases: ['emergencystop'],
payloadSchema: { type: 'any' },
description: 'Trigger an emergency stop.',
handler: handlers.estop,
},
{
// Legacy umbrella topic. Content-based demux inside the handler routes
// to the canonical startup / shutdown logic. Emits the registry's
// one-time deprecation warning the first time it fires.
topic: 'execSequence',
payloadSchema: { type: 'object' },
description: 'Legacy umbrella that demuxes payload.action to startup / shutdown.',
handler: handlers.execSequenceAlias,
_legacy: true,
},
{
topic: 'set.setpoint',
aliases: ['execMovement'],
payloadSchema: { type: 'object' },
// Control-percent setpoint — no units field (no `percent` measure in convert).
description: 'Move the machine to a control-% setpoint via execMovement.',
handler: handlers.setSetpoint,
},
{
topic: 'set.flow-setpoint',
aliases: ['flowMovement'],
payloadSchema: { type: 'object' },
units: { measure: 'volumeFlowRate', default: 'm3/h' },
description: 'Move the machine to a flow setpoint via flowMovement.',
handler: handlers.setFlowSetpoint,
},
{
topic: 'data.simulate-measurement',
aliases: ['simulateMeasurement'],
payloadSchema: { type: 'object' },
description: 'Inject a simulated sensor reading (pressure/flow/temperature/power).',
handler: handlers.simulateMeasurement,
},
{
topic: 'query.curves',
aliases: ['showWorkingCurves'],
payloadSchema: { type: 'any' },
description: 'Return the working curves for the machine on the reply port.',
handler: handlers.queryCurves,
},
{
topic: 'query.cog',
aliases: ['CoG'],
payloadSchema: { type: 'any' },
description: 'Return the centre-of-gravity (CoG) point on the reply port.',
handler: handlers.queryCog,
},
{
topic: 'child.register',
aliases: ['registerChild'],
payloadSchema: { type: 'string' },
description: 'Register a child measurement with this machine.',
handler: handlers.registerChild,
},
];

19
src/curves/curveLoader.js Normal file
View File

@@ -0,0 +1,19 @@
const { loadCurve } = require('generalFunctions');
/**
* Resolve a raw curve dataset by model name. Pure wrapper around
* generalFunctions.loadCurve so the constructor doesn't have to encode the
* "no model"/"model not found" error states inline.
*/
function loadModelCurve(model) {
if (!model) {
return { rawCurve: null, error: 'Model not specified' };
}
const raw = loadCurve(model);
if (!raw) {
return { rawCurve: null, error: `Curve not found for model ${model}` };
}
return { rawCurve: raw, error: null };
}
module.exports = { loadModelCurve };

View File

@@ -0,0 +1,117 @@
const { convert } = require('generalFunctions');
/**
* Strict numeric unit conversion. Mirrors specificClass._convertUnitValue
* so the curve normalizer is testable without a Machine instance.
*/
function convertUnitValue(value, fromUnit, toUnit, contextLabel = 'unit conversion') {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
throw new Error(`${contextLabel}: value '${value}' is not finite`);
}
if (!fromUnit || !toUnit || fromUnit === toUnit) return numeric;
return convert(numeric).from(fromUnit).to(toUnit);
}
/**
* Convert one curve section (nq or np) from supplied units to canonical
* units. Logs a warning when the per-pressure median y jumps by more than
* 3x relative to the previous pressure level — that almost always means the
* curve file is corrupt (mixed units, swapped rows) and the predict module
* would otherwise silently produce nonsense values.
*/
function normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName, logger) {
const normalized = {};
let prevMedianY = null;
for (const [pressureKey, pair] of Object.entries(section || {})) {
const canonicalPressure = convertUnitValue(
Number(pressureKey),
fromPressureUnit,
toPressureUnit,
`${sectionName} pressure axis`
);
const xArray = Array.isArray(pair?.x) ? pair.x.map(Number) : [];
const yArray = Array.isArray(pair?.y)
? pair.y.map((v) => convertUnitValue(v, fromYUnit, toYUnit, `${sectionName} output`))
: [];
if (!xArray.length || !yArray.length || xArray.length !== yArray.length) {
throw new Error(`Invalid ${sectionName} section at pressure '${pressureKey}'.`);
}
const sortedY = [...yArray].sort((a, b) => a - b);
const medianY = sortedY[Math.floor(sortedY.length / 2)];
if (prevMedianY != null && prevMedianY > 0) {
const ratio = medianY / prevMedianY;
if (ratio > 3 || ratio < 0.33) {
const msg = `Curve anomaly in ${sectionName} at pressure ${pressureKey}: median y=${medianY.toFixed(2)} ` +
`deviates ${ratio.toFixed(1)}x from adjacent level (${prevMedianY.toFixed(2)}). Check curve data.`;
if (logger && typeof logger.warn === 'function') {
logger.warn(msg);
}
}
}
prevMedianY = medianY;
normalized[String(canonicalPressure)] = { x: xArray, y: yArray };
}
return normalized;
}
/**
* Normalize a raw machine curve ({nq, np}) into canonical SI units, using
* the unit declarations on the supplied UnitPolicy. `unitPolicy.curve` is
* the source unit map; `unitPolicy.canonical(type)` gives the target.
*/
function normalizeMachineCurve(rawCurve, unitPolicy, logger) {
if (!rawCurve || typeof rawCurve !== 'object' || !rawCurve.nq || !rawCurve.np) {
throw new Error('Machine curve is missing required nq/np sections.');
}
const curveUnits = readCurveUnits(unitPolicy);
const canonicalFlow = readCanonical(unitPolicy, 'flow');
const canonicalPower = readCanonical(unitPolicy, 'power');
const canonicalPressure = readCanonical(unitPolicy, 'pressure');
return {
nq: normalizeCurveSection(
rawCurve.nq,
curveUnits.flow,
canonicalFlow,
curveUnits.pressure,
canonicalPressure,
'nq',
logger
),
np: normalizeCurveSection(
rawCurve.np,
curveUnits.power,
canonicalPower,
curveUnits.pressure,
canonicalPressure,
'np',
logger
),
};
}
// UnitPolicy stores curve units as a frozen object on `_curve`, exposed via
// `curve(type)`. Accept either the live UnitPolicy or a plain {curve, canonical}
// bag so the normalizer can also be driven from raw config fixtures in tests.
function readCurveUnits(unitPolicy) {
if (!unitPolicy) return {};
if (typeof unitPolicy.curve === 'function') {
return {
flow: unitPolicy.curve('flow'),
power: unitPolicy.curve('power'),
pressure: unitPolicy.curve('pressure'),
};
}
return unitPolicy.curve || {};
}
function readCanonical(unitPolicy, type) {
if (!unitPolicy) return null;
if (typeof unitPolicy.canonical === 'function') return unitPolicy.canonical(type);
return (unitPolicy.canonical || {})[type] || null;
}
module.exports = { normalizeMachineCurve, normalizeCurveSection, convertUnitValue };

View File

@@ -0,0 +1,17 @@
/**
* Swap x and y of every pressure-keyed section so a forward "ctrl -> flow"
* curve becomes a reverse "flow -> ctrl" curve. Used to build predictCtrl
* from the same nq data feeding predictFlow.
*/
function reverseCurve(curveSection) {
const reversed = {};
for (const [pressure, values] of Object.entries(curveSection || {})) {
reversed[pressure] = {
x: [...values.y],
y: [...values.x],
};
}
return reversed;
}
module.exports = { reverseCurve };

View File

@@ -0,0 +1,115 @@
/**
* Read-only snapshots of the active machine curves and the centre-of-gravity
* statistics. These back the rotatingMachine admin endpoints used by the
* editor (`/rotatingMachine/working-curves`, `/rotatingMachine/cog`).
*
* Both functions accept a single `predictors` argument — an object describing
* the current curve state. By taking everything via that one parameter the
* helpers stay pure and trivially testable with a plain fixture; the host
* just passes itself (or a slim adapter) in.
*
* Expected shape of `predictors`:
* {
* hasCurve: boolean,
* predictFlow, predictPower, // generalFunctions/predict instances
* getCurrentCurves(): { powerCurve, flowCurve },
* calcCog(): { cog, cogIndex, NCog, minEfficiency },
* cog, cogIndex, NCog,
* minEfficiency,
* currentEfficiencyCurve,
* absDistFromPeak, relDistFromPeak,
* }
*/
const NO_CURVE_ERROR = 'No curve data available';
function showCoG(predictors) {
if (!predictors || !predictors.hasCurve) {
return { error: NO_CURVE_ERROR, cog: 0, NCog: 0, cogIndex: 0 };
}
const { cog, cogIndex, NCog, minEfficiency } = predictors.calcCog();
return {
cog,
cogIndex,
NCog,
NCogPercent: Math.round(NCog * 100 * 100) / 100,
minEfficiency,
currentEfficiencyCurve: predictors.currentEfficiencyCurve,
absDistFromPeak: predictors.absDistFromPeak,
relDistFromPeak: predictors.relDistFromPeak,
};
}
function showWorkingCurves(predictors) {
if (!predictors || !predictors.hasCurve) {
return { error: NO_CURVE_ERROR };
}
const { powerCurve, flowCurve } = predictors.getCurrentCurves();
return {
powerCurve,
flowCurve,
cog: predictors.cog,
cogIndex: predictors.cogIndex,
NCog: predictors.NCog,
minEfficiency: predictors.minEfficiency,
currentEfficiencyCurve: predictors.currentEfficiencyCurve,
absDistFromPeak: predictors.absDistFromPeak,
relDistFromPeak: predictors.relDistFromPeak,
};
}
/**
* Build a Q-H curve sample at a fixed control position.
*
* For each pressure slice the predictor knows about, evaluate predicted
* flow at `ctrlPct`, convert canonical Pa to pump head (m of water column,
* H = ΔP / (ρ · g)), and emit one (Q, H) point. Result is the pump's Q-H
* curve at the requested speed/control.
*
* State handling: temporarily writes fDimension to walk the slices, then
* restores the predictor's original fDimension and outputY by reissuing
* y(originalX) — so callers can hit this without corrupting live
* predictions. (Same trick as the existing benchmark scripts.)
*/
function buildQHCurve(predictors, ctrlPct, options = {}) {
if (!predictors || !predictors.hasCurve || !predictors.predictFlow) {
return { error: NO_CURVE_ERROR, points: [] };
}
const pf = predictors.predictFlow;
if (!pf.inputCurve || typeof pf.inputCurve !== 'object') {
return { error: NO_CURVE_ERROR, points: [] };
}
const x = Number.isFinite(+ctrlPct) ? +ctrlPct : (pf.currentX ?? 0);
const RHO = 999.1; // kg/m³ — water at ~15 °C
const G = 9.80665; // m/s²
// Allowed pressure range from the predict library; falls back to the
// raw inputCurve keys if fValues isn't populated yet.
const fMin = Number.isFinite(pf.fValues?.min) ? pf.fValues.min : -Infinity;
const fMax = Number.isFinite(pf.fValues?.max) ? pf.fValues.max : Infinity;
const pressures = Object.keys(pf.inputCurve)
.filter((k) => /^-?\d+(?:\.\d+)?$/.test(k))
.map(Number)
.filter((p) => p >= fMin && p <= fMax)
.sort((a, b) => a - b);
if (!pressures.length) {
return { error: 'No pressure slices in envelope', points: [] };
}
const originalF = pf.fDimension;
const originalX = pf.currentX;
const points = [];
try {
for (const p of pressures) {
pf.fDimension = p;
const QM3s = pf.y(x);
points.push({ Q: QM3s * 3600, H: p / (RHO * G), dpPa: p });
}
} finally {
pf.fDimension = originalF;
if (Number.isFinite(originalX)) pf.y(originalX);
}
return { ctrlPct: x, points };
}
module.exports = { showWorkingCurves, showCoG, buildQHCurve };

135
src/drift/driftAssessor.js Normal file
View File

@@ -0,0 +1,135 @@
'use strict';
/**
* DriftAssessor — extracted from rotatingMachine specificClass.
*
* Wraps the generalFunctions errorMetrics into a per-metric drift
* pipeline (flow / power). Holds the latest drift objects so
* predictionHealth can reuse them; the host node still mirrors them
* onto its own fields for output compatibility.
*/
class DriftAssessor {
/**
* @param {object} ctx
* - errorMetrics: assessPoint(metricId, predicted, measured, opts) + assessDrift(...)
* - measurements: MeasurementContainer (for assessDrift history pulls)
* - driftProfiles: { flow, power, ... }
* - resolveProcessRange(metricId, predicted, measured) -> { processMin, processMax }
* - measurementPositionForMetric(metricId) -> string
* - logger: { warn, debug, ... }
*/
constructor(ctx = {}) {
this.errorMetrics = ctx.errorMetrics;
this.measurements = ctx.measurements;
this.driftProfiles = ctx.driftProfiles || {};
this.resolveProcessRange = ctx.resolveProcessRange;
this.measurementPositionForMetric = ctx.measurementPositionForMetric;
this.logger = ctx.logger || { warn() {}, debug() {} };
this.latest = { flow: null, power: null };
}
/**
* Compute drift for a metric given a freshly-arrived measured value.
* Returns the drift object (or null on error / non-finite inputs).
*/
updateMetricDrift(metricId, measuredValue, context = {}) {
const position = this._positionForMetric(metricId);
const predictedValue = this._getPredicted(metricId, position);
const measured = Number(measuredValue);
if (!Number.isFinite(predictedValue) || !Number.isFinite(measured)) return null;
const { processMin, processMax } = this._processRange(metricId, predictedValue, measured);
const timestamp = Number(context.timestamp || Date.now());
const profile = this.driftProfiles[metricId] || {};
try {
const drift = this.errorMetrics.assessPoint(metricId, predictedValue, measured, {
...profile,
processMin,
processMax,
predictedTimestamp: timestamp,
measuredTimestamp: timestamp,
});
if (drift && drift.valid) this.latest[metricId] = drift;
return drift;
} catch (err) {
this.logger.warn(`Drift update failed for metric '${metricId}': ${err.message}`);
return null;
}
}
/**
* Pull stored predicted/measured series and run a full drift assessment.
*/
assessDrift(measurement, processMin, processMax) {
const metricId = String(measurement || '').toLowerCase();
const position = this._positionForMetric(metricId);
const predicted = this.measurements
?.type(metricId).variant('predicted').position(position).getAllValues();
const measured = this.measurements
?.type(metricId).variant('measured').position(position).getAllValues();
if (!predicted?.values || !measured?.values) return null;
return this.errorMetrics.assessDrift(
predicted.values,
measured.values,
processMin,
processMax,
{
metricId,
predictedTimestamps: predicted.timestamps,
measuredTimestamps: measured.timestamps,
...(this.driftProfiles[metricId] || {}),
},
);
}
/**
* Pure helper: reduce a confidence figure by drift severity and push
* matching flag strings. Returns the updated confidence.
*/
applyDriftPenalty(drift, confidence, flags, prefix) {
if (!drift || !drift.valid || !Number.isFinite(drift.nrmse)) return confidence;
if (drift.immediateLevel >= 3) {
confidence -= 0.3;
flags.push(`${prefix}_high_immediate_drift`);
} else if (drift.immediateLevel === 2) {
confidence -= 0.2;
flags.push(`${prefix}_medium_immediate_drift`);
} else if (drift.immediateLevel === 1) {
confidence -= 0.1;
flags.push(`${prefix}_low_immediate_drift`);
}
if (drift.longTermLevel >= 2) {
confidence -= 0.1;
flags.push(`${prefix}_long_term_drift`);
}
return confidence;
}
_positionForMetric(metricId) {
if (typeof this.measurementPositionForMetric === 'function') {
return this.measurementPositionForMetric(metricId);
}
return metricId === 'flow' ? 'downstream' : 'atEquipment';
}
_processRange(metricId, predicted, measured) {
if (typeof this.resolveProcessRange === 'function') {
return this.resolveProcessRange(metricId, predicted, measured);
}
const lo = Math.min(predicted, measured);
const hi = Math.max(predicted, measured);
return { processMin: lo, processMax: hi > lo ? hi : lo + 1 };
}
_getPredicted(metricId, position) {
return Number(
this.measurements
?.type(metricId).variant('predicted').position(position).getCurrentValue(),
);
}
}
module.exports = DriftAssessor;

View File

@@ -0,0 +1,45 @@
/**
* Composes the per-tick pressure-drift status + the PredictionHealth
* shape used by the orchestrator. Lives separately from
* DriftAssessor/PredictionHealth so the orchestrator only calls one
* function per refresh.
*/
'use strict';
const PredictionHealth = require('./predictionHealth');
function updatePressureDriftStatus(host) {
const status = host.getPressureInitializationStatus();
const flags = [];
let level = 0;
if (!status.initialized) { level = 2; flags.push('no_pressure_input'); }
else if (!status.hasDifferential) { level = 1; flags.push('single_side_pressure'); }
if (status.hasDifferential) {
const diff = Number(host._getPreferredPressureValue('downstream')) - Number(host._getPreferredPressureValue('upstream'));
if (Number.isFinite(diff) && diff < 0) { level = Math.max(level, 3); flags.push('negative_pressure_differential'); }
}
host.pressureDrift = { level, source: status.source, flags: flags.length ? flags : ['nominal'] };
return host.pressureDrift;
}
function updatePredictionHealth(host) {
const pressureDrift = updatePressureDriftStatus(host);
const helper = new PredictionHealth({
getPressureInitializationStatus: () => host.getPressureInitializationStatus(),
isOperational: () => host._isOperationalState(),
applyDriftPenalty: (d, c, f, p) => host._applyDriftPenalty(d, c, f, p),
resolveSetpointBounds: () => host._resolveSetpointBounds(),
getCurrentPosition: () => host.state?.getCurrentPosition?.(),
});
const { health, confidence } = helper.evaluate({ flow: host.flowDrift, power: host.powerDrift, pressure: pressureDrift });
const quality = confidence >= 0.8 ? 'high' : confidence >= 0.55 ? 'medium' : confidence >= 0.3 ? 'low' : 'invalid';
host.predictionHealth = {
quality, confidence,
pressureSource: health.source ?? pressureDrift.source ?? null,
flags: Array.isArray(health.flags) && health.flags.length ? [...health.flags] : ['nominal'],
};
return host.predictionHealth;
}
module.exports = { updatePressureDriftStatus, updatePredictionHealth };

View File

@@ -0,0 +1,132 @@
'use strict';
const { HealthStatus } = require('generalFunctions');
/**
* PredictionHealth — composes per-metric drift snapshots + pressure
* initialization status into a single HealthStatus plus a numeric
* confidence figure.
*
* Per OPEN_QUESTIONS.md 2026-05-10: HealthStatus carries the standard
* five fields; `confidence` is returned as a sibling on the result.
*/
class PredictionHealth {
/**
* @param {object} ctx
* - getPressureInitializationStatus() -> { initialized, hasDifferential, source, ... }
* - isOperational() -> boolean
* - applyDriftPenalty(drift, confidence, flags, prefix) -> confidence (from DriftAssessor)
* - resolveSetpointBounds?() -> { min, max }
* - getCurrentPosition?() -> number
*/
constructor(ctx = {}) {
this.getPressureInitializationStatus = ctx.getPressureInitializationStatus;
this.isOperational = ctx.isOperational || (() => true);
this.applyDriftPenalty = ctx.applyDriftPenalty || ((_d, c) => c);
this.resolveSetpointBounds = ctx.resolveSetpointBounds;
this.getCurrentPosition = ctx.getCurrentPosition;
}
/**
* @param {object} driftSnapshots — { flow, power, pressure }
* pressure: { level, flags, source } (already-assessed pressure-drift status)
* @returns {{ health: object, confidence: number }}
* health is a frozen HealthStatus shape; confidence ∈ [0,1].
*/
evaluate(driftSnapshots = {}) {
const pressureDrift = driftSnapshots.pressure || { level: 0, flags: [], source: null };
const status = this._safePressureStatus();
const flags = Array.isArray(pressureDrift.flags) ? [...pressureDrift.flags] : [];
let confidence = this._baseConfidenceFromSource(status.source);
if (!this.isOperational()) {
confidence = 0;
flags.push('not_operational');
}
confidence = this._penaltyForPressureDriftLevel(pressureDrift.level, confidence);
confidence = this._penaltyForCurveEdge(confidence, flags);
confidence = this.applyDriftPenalty(driftSnapshots.flow, confidence, flags, 'flow');
confidence = this.applyDriftPenalty(driftSnapshots.power, confidence, flags, 'power');
confidence = Math.max(0, Math.min(1, confidence));
const dedupedFlags = flags.length ? Array.from(new Set(flags)) : ['nominal'];
const worstLevel = this._worstLevelFromSnapshots(pressureDrift, driftSnapshots, dedupedFlags);
const hasNonNominal = dedupedFlags.some((f) => f !== 'nominal');
const effectiveLevel = hasNonNominal ? Math.max(1, worstLevel) : worstLevel;
const sourceTag = pressureDrift.source ?? status.source ?? null;
const health = effectiveLevel === 0
? HealthStatus.ok(this._qualityLabel(confidence), sourceTag)
: HealthStatus.degraded(
effectiveLevel,
dedupedFlags,
this._qualityLabel(confidence),
sourceTag,
);
return { health, confidence };
}
_safePressureStatus() {
if (typeof this.getPressureInitializationStatus !== 'function') {
return { initialized: false, hasDifferential: false, source: null };
}
return this.getPressureInitializationStatus() || { source: null };
}
_baseConfidenceFromSource(source) {
if (source === 'differential') return 0.9;
if (source === 'upstream' || source === 'downstream') return 0.55;
return 0.2;
}
_penaltyForPressureDriftLevel(level, confidence) {
if (level >= 3) return confidence - 0.35;
if (level === 2) return confidence - 0.2;
if (level === 1) return confidence - 0.1;
return confidence;
}
_penaltyForCurveEdge(confidence, flags) {
if (typeof this.getCurrentPosition !== 'function' || typeof this.resolveSetpointBounds !== 'function') {
return confidence;
}
const cur = Number(this.getCurrentPosition());
const bounds = this.resolveSetpointBounds() || {};
const { min, max } = bounds;
if (Number.isFinite(cur) && Number.isFinite(min) && Number.isFinite(max) && max > min) {
const span = max - min;
const edgeDist = Math.min(Math.abs(cur - min), Math.abs(max - cur));
if (edgeDist < span * 0.05) {
flags.push('near_curve_edge');
return confidence - 0.1;
}
}
return confidence;
}
_worstLevelFromSnapshots(pressureDrift, snaps, flags) {
let worst = Number.isFinite(pressureDrift.level) ? pressureDrift.level : 0;
for (const id of ['flow', 'power']) {
const d = snaps[id];
if (!d || !d.valid) continue;
const lvl = Math.max(d.immediateLevel || 0, d.longTermLevel || 0);
if (lvl > worst) worst = lvl;
}
if (flags.includes('not_operational') && worst < 2) worst = 2;
return Math.max(0, Math.min(3, worst));
}
_qualityLabel(confidence) {
if (confidence >= 0.8) return 'high';
if (confidence >= 0.55) return 'medium';
if (confidence >= 0.3) return 'low';
return 'invalid';
}
}
module.exports = PredictionHealth;

View File

@@ -0,0 +1,85 @@
/**
* Dispatches inbound control actions (execSequence / execMovement /
* flowMovement / emergencyStop / enter|exitMaintenance / statusCheck)
* to the state machine and motion helpers on the host.
*
* Behaviour mirrors the original specificClass.handleInput exactly:
* - actions are lower-cased
* - mode/source gating runs first
* - flow-setpoints are unit-converted (output -> canonical) before
* calcCtrl + setpoint
* - thrown errors are caught + logged (no re-throw) so a misbehaving
* parent never crashes the FSM
*/
class FlowController {
constructor(ctx) {
if (!ctx || !ctx.host) {
throw new Error('FlowController: ctx.host is required');
}
this.host = ctx.host;
this.logger = ctx.logger || ctx.host.logger;
}
async handle(source, action, parameter) {
const host = this.host;
if (typeof action !== 'string') {
this.logger.error('Action must be string');
return;
}
action = action.toLowerCase();
if (!host.isValidActionForMode(action, host.currentMode)) return;
if (!host.isValidSourceForMode(source, host.currentMode)) return;
this.logger.info(
`Handling input from source '${source}' with action '${action}' in mode '${host.currentMode}'.`,
);
try {
switch (action) {
case 'execsequence':
return await host.executeSequence(parameter);
case 'execmovement':
return await host.setpoint(parameter);
case 'entermaintenance':
case 'exitmaintenance':
return await host.executeSequence(parameter);
case 'flowmovement': {
const canonicalFlowSetpoint = host._convertUnitValue(
parameter,
host.unitPolicy.output.flow,
host.unitPolicy.canonical.flow,
'flowmovement setpoint',
);
const pos = host.calcCtrl(canonicalFlowSetpoint);
return await host.setpoint(pos);
}
case 'emergencystop':
this.logger.warn(`Emergency stop activated by '${source}'.`);
return await host.executeSequence('emergencystop');
case 'statuscheck':
this.logger.info(
`Status Check: Mode = '${host.currentMode}', Source = '${source}'.`,
);
break;
default:
this.logger.warn(`Action '${action}' is not implemented.`);
break;
}
this.logger.debug(`Action '${action}' successfully executed`);
return { status: true, feedback: `Action '${action}' successfully executed.` };
} catch (error) {
this.logger.error(`Error handling input: ${error}`);
}
}
}
module.exports = FlowController;

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

@@ -0,0 +1,90 @@
/**
* Snapshot builders for rotatingMachine Port 0 output + Node-RED status
* badge. Behaviour preserved verbatim from the pre-refactor surface so
* dashboards and downstream consumers (formatMsg, status loops) keep
* working.
*/
'use strict';
const { statusBadge } = require('generalFunctions');
const STATE_SYMBOLS = {
off: '⬛', idle: '⏸️', operational: '⏵️',
starting: '⏯️', warmingup: '🔄', accelerating: '⏩',
stopping: '⏹️', coolingdown: '❄️',
decelerating: '⏪', maintenance: '🔧',
};
const FILL = {
off: 'red', idle: 'blue',
operational: 'green', warmingup: 'green',
starting: 'yellow', accelerating: 'yellow', stopping: 'yellow',
coolingdown: 'yellow', decelerating: 'yellow', maintenance: 'grey',
};
const SHOW_METRICS = new Set(['operational', 'warmingup', 'accelerating', 'decelerating']);
function buildOutput(host) {
const o = host.measurements.getFlattenedOutput({ requestedUnits: host.unitPolicy.output });
o.state = host.state.getCurrentState();
o.runtime = host.state.getRunTimeHours();
o.ctrl = host.state.getCurrentPosition();
o.moveTimeleft = host.state.getMoveTimeLeft();
o.mode = host.currentMode;
o.cog = host.cog; o.NCog = host.NCog;
o.NCogPercent = Math.round(host.NCog * 100 * 100) / 100;
o.maintenanceTime = host.state.getMaintenanceTimeHours();
if (host.flowDrift != null) {
const f = host.flowDrift;
o.flowNrmse = f.nrmse;
o.flowLongterNRMSD = f.longTermNRMSD;
o.flowLongTermNRMSD = f.longTermNRMSD;
o.flowImmediateLevel = f.immediateLevel;
o.flowLongTermLevel = f.longTermLevel;
o.flowDriftValid = f.valid;
}
if (host.powerDrift != null) {
const p = host.powerDrift;
o.powerNrmse = p.nrmse;
o.powerLongTermNRMSD = p.longTermNRMSD;
o.powerImmediateLevel = p.immediateLevel;
o.powerLongTermLevel = p.longTermLevel;
o.powerDriftValid = p.valid;
}
o.pressureDriftLevel = host.pressureDrift.level;
o.pressureDriftSource = host.pressureDrift.source;
o.pressureDriftFlags = host.pressureDrift.flags;
o.predictionQuality = host.predictionHealth.quality;
o.predictionConfidence = Math.round(host.predictionHealth.confidence * 1000) / 1000;
o.predictionPressureSource = host.predictionHealth.pressureSource;
o.predictionFlags = host.predictionHealth.flags;
o.effDistFromPeak = host.absDistFromPeak;
o.effRelDistFromPeak = host.relDistFromPeak;
return o;
}
function buildStatusBadge(host) {
try {
const stateName = host.state?.getCurrentState?.() ?? 'unknown';
const needsPressure = SHOW_METRICS.has(stateName);
const ps = host.pressureInit?.getStatus?.() ?? { initialized: true };
if (needsPressure && !ps.initialized) {
return statusBadge.text(`${host.currentMode}: pressure not initialized`, { fill: 'yellow', shape: 'ring' });
}
const symbol = STATE_SYMBOLS[stateName] || '❔';
const fill = FILL[stateName] || 'grey';
const parts = [`${host.currentMode}: ${symbol}`];
if (SHOW_METRICS.has(stateName)) {
const fu = host.unitPolicy.output.flow || 'm3/h';
const flow = Math.round(host.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(fu) ?? 0);
const power = Math.round(host.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('kW') ?? 0);
const pos = Math.round((host.state?.getCurrentPosition?.() ?? 0) * 100) / 100;
parts.push(`${pos}%`, `💨${flow}${fu}`, `${power}kW`);
}
return statusBadge.compose(parts, { fill, shape: 'dot' });
} catch (err) {
host.logger?.error?.(`getStatusBadge: ${err.message}`);
return statusBadge.error('Status Error');
}
}
module.exports = { buildOutput, buildStatusBadge };

View File

@@ -0,0 +1,47 @@
/**
* registerChild adapter for rotatingMachine. Custom because:
* - virtual + real pressure children share the upstream/downstream
* position slots; real ones must be tracked for the preference order
* - re-registration of the same child must dedup the emitter listener
* - non-measurement softwareTypes are no-ops (Machine has no children
* other than measurement nodes today)
*/
'use strict';
function registerMeasurementChild(host, child, softwareType) {
const swType = softwareType || child?.config?.functionality?.softwareType || 'measurement';
host.logger.debug(`Setting up child event for softwaretype ${swType}`);
if (swType !== 'measurement') return;
const position = String(child.config.functionality.positionVsParent || 'atEquipment').toLowerCase();
const measurementType = child.config.asset.type;
const childId = child.config?.general?.id || `${measurementType}-${position}-unknown`;
const isVirtual = Object.values(host.virtualPressureChildIds).includes(childId);
if (measurementType === 'pressure' && !isVirtual) host.realPressureChildIds[position]?.add(childId);
const eventName = `${measurementType}.measured.${position}`;
const key = `${childId}:${eventName}`;
const existing = host.childMeasurementListeners.get(key);
if (existing) {
if (typeof existing.emitter.off === 'function') existing.emitter.off(existing.eventName, existing.handler);
else if (typeof existing.emitter.removeListener === 'function') existing.emitter.removeListener(existing.eventName, existing.handler);
}
const handler = (eventData) => {
host.logger.debug(`🔄 ${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`);
host._callMeasurementHandler(measurementType, eventData.value, position, eventData);
};
child.measurements.emitter.on(eventName, handler);
host.childMeasurementListeners.set(key, { emitter: child.measurements.emitter, eventName, handler });
}
function detachAllListeners(host) {
if (!host.childMeasurementListeners) return;
for (const [, e] of host.childMeasurementListeners) {
if (typeof e.emitter?.off === 'function') e.emitter.off(e.eventName, e.handler);
else if (typeof e.emitter?.removeListener === 'function') e.emitter.removeListener(e.eventName, e.handler);
}
host.childMeasurementListeners.clear();
}
module.exports = { registerMeasurementChild, detachAllListeners };

View File

@@ -0,0 +1,181 @@
/**
* Centralised measurement update routing for rotatingMachine.
*
* Wraps the four measurement types coming from child measurement nodes
* (flow / power / temperature / pressure) and dispatches each to the
* appropriate handler. Pressure is delegated to the host's pressureRouter
* (built in P5.4); the other three are normalised + written + drift-tracked
* here.
*
* The handlers reach back into the host for `_resolveMeasurementUnit`,
* `_updateMetricDrift`, `_updatePredictionHealth`, `updatePosition` and the
* measurements container. Behaviour is preserved 1:1 from the original
* specificClass methods.
*/
class MeasurementHandlers {
constructor(ctx) {
if (!ctx || !ctx.host) {
throw new Error('MeasurementHandlers: ctx.host is required');
}
this.host = ctx.host;
this.logger = ctx.logger || ctx.host.logger;
}
/**
* Single entry point used by child-measurement event listeners.
* Unknown types warn and fall back to a no-op position refresh so a
* mis-configured child can't silently break the FSM tick.
*/
dispatch(measurementType, value, position, context = {}) {
switch (measurementType) {
case 'pressure':
return this.host.updateMeasuredPressure(value, position, context);
case 'flow':
return this.updateMeasuredFlow(value, position, context);
case 'power':
return this.updateMeasuredPower(value, position, context);
case 'temperature':
return this.updateMeasuredTemperature(value, position, context);
default:
this.logger.warn(`No handler for measurement type: ${measurementType}`);
return this.host.updatePosition();
}
}
updateMeasuredTemperature(value, position, context = {}) {
const host = this.host;
this.logger.debug(
`Temperature update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`,
);
let unit;
try {
unit = host._resolveMeasurementUnit('temperature', context.unit);
} catch (error) {
this.logger.warn(`Rejected temperature update: ${error.message}`);
return;
}
host.measurements
.type('temperature')
.variant('measured')
.position(position || 'atEquipment')
.child(context.childId)
.value(value, context.timestamp, unit);
}
updateMeasuredFlow(value, position, context = {}) {
const host = this.host;
if (!host._isOperationalState()) {
this.logger.warn(`Machine not operational, skipping flow update from ${context.childName || 'unknown'}`);
return;
}
this.logger.debug(`Flow update: ${value} at ${position} from ${context.childName || 'child'}`);
let unit;
try {
unit = host._resolveMeasurementUnit('flow', context.unit);
} catch (error) {
this.logger.warn(`Rejected flow update: ${error.message}`);
return;
}
host.measurements
.type('flow').variant('measured').position(position).child(context.childId)
.value(value, context.timestamp, unit);
if (host.predictFlow) {
const canonical = host.unitPolicy.canonical.flow;
const predicted = host.predictFlow.outputY || 0;
host.measurements.type('flow').variant('predicted').position('downstream')
.value(predicted, Date.now(), canonical);
host.measurements.type('flow').variant('predicted').position('atEquipment')
.value(predicted, Date.now(), canonical);
}
const measuredCanonical = host.measurements
.type('flow').variant('measured').position(position)
.getCurrentValue(host.unitPolicy.canonical.flow);
host._updateMetricDrift('flow', measuredCanonical, context);
host._updatePredictionHealth();
}
updateMeasuredPower(value, position, context = {}) {
const host = this.host;
if (!host._isOperationalState()) {
this.logger.warn(`Machine not operational, skipping power update from ${context.childName || 'unknown'}`);
return;
}
this.logger.debug(`Power update: ${value} at ${position} from ${context.childName || 'child'}`);
let unit;
try {
unit = host._resolveMeasurementUnit('power', context.unit);
} catch (error) {
this.logger.warn(`Rejected power update: ${error.message}`);
return;
}
host.measurements
.type('power').variant('measured').position(position).child(context.childId)
.value(value, context.timestamp, unit);
if (host.predictPower) {
host.measurements.type('power').variant('predicted').position('atEquipment')
.value(host.predictPower.outputY || 0, Date.now(), host.unitPolicy.canonical.power);
}
const measuredCanonical = host.measurements
.type('power').variant('measured').position(position)
.getCurrentValue(host.unitPolicy.canonical.power);
host._updateMetricDrift('power', measuredCanonical, context);
host._updatePredictionHealth();
}
/** Reconcile a measured-flow reading with the existing up/downstream slots. */
handleMeasuredFlow() {
const host = this.host;
const diff = host.measurements.type('flow').variant('measured').difference();
if (diff != null) {
if (diff.value < 0.001) { this.logger.debug(`Flow match: ${diff.value}`); return diff.value; }
this.logger.error('Something wrong with down or upstream flow measurement. Bailing out!');
return null;
}
const up = host.measurements.type('flow').variant('measured').position('upstream').getCurrentValue();
if (up != null) { this.logger.warn('Only upstream flow is present. Using it but results may be incomplete!'); return up; }
const dn = host.measurements.type('flow').variant('measured').position('downstream').getCurrentValue();
if (dn != null) { this.logger.warn('Only downstream flow is present. Using it but results may be incomplete!'); return dn; }
this.logger.error('No upstream or downstream flow measurement. Bailing out!');
return null;
}
handleMeasuredPower() {
const power = this.host.measurements.type('power').variant('measured').position('atEquipment').getCurrentValue();
if (power != null) { this.logger.debug(`Measured power: ${power}`); return power; }
this.logger.error('No measured power found. Bailing out!');
return null;
}
/** Route a dashboard-sim pressure write to its virtual child; route any
* other simulated measurement type through the normal handler dispatch. */
updateSimulatedMeasurement(type, position, value, context = {}) {
const host = this.host;
const t = String(type || '').toLowerCase();
const pos = String(position || 'atEquipment').toLowerCase();
if (t !== 'pressure') { return this.dispatch(t, value, pos, context); }
if (!host.virtualPressureChildIds[pos]) {
this.logger.warn(`Unsupported simulated pressure position '${pos}'`);
return;
}
const child = host.virtualPressureChildren[pos];
if (!child?.measurements) {
this.logger.error(`Virtual pressure child '${pos}' is missing`);
return;
}
let unit;
try { unit = host._resolveMeasurementUnit('pressure', context.unit); }
catch (err) { this.logger.warn(`Rejected simulated pressure measurement: ${err.message}`); return; }
child.measurements.type('pressure').variant('measured').position(pos)
.value(value, context.timestamp || Date.now(), unit);
}
}
module.exports = MeasurementHandlers;

View File

@@ -1,413 +1,81 @@
/** 'use strict';
* node class.js
*
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
* This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers.
*/
const { outputUtils, configManager, convert } = require('generalFunctions');
const Specific = require("./specificClass");
class nodeClass { const { BaseNodeAdapter, convert } = require('generalFunctions');
/** const Machine = require('./specificClass');
* Create a Node. const commands = require('./commands');
* @param {object} uiConfig - Node-RED node configuration.
* @param {object} RED - Node-RED runtime API.
*/
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
// Preserve RED reference for HTTP endpoints if needed // Event-driven: state + measurement events drive recomputes via the
this.node = nodeInstance; // This is the Node-RED node instance, we can use this to send messages and update status // domain emitter. No tick loop. Status badge polled every second.
this.RED = RED; // This is the Node-RED runtime API, we can use this to create endpoints if needed class nodeClass extends BaseNodeAdapter {
this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED static DomainClass = Machine;
this.source = null; // Will hold the specific class instance static commands = commands;
this.config = null; // Will hold the merged configuration static tickInterval = null;
this._pressureInitWarned = false; static statusInterval = 1000;
// Load default & UI config buildDomainConfig(uiConfig) {
this._loadConfig(uiConfig,this.node); _rejectLegacyAssetFields(uiConfig);
// Instantiate core class const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h');
this._setupSpecificClass(uiConfig); // Stash extras on the Machine class so its constructor (called by
// BaseNodeAdapter via DomainClass) picks them up alongside the
// Wire up event and lifecycle handlers // machineConfig. Single-threaded JS makes the hand-off race-free.
this._bindEvents(); Machine._pendingExtras = {
this._registerChild(); stateConfig: {
this._startTickLoop(); general: { logging: { enabled: uiConfig.enableLog, logLevel: uiConfig.logLevel } },
this._attachInputHandler(); movement: { speed: Number(uiConfig.speed), mode: uiConfig.movementMode },
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();
const resolvedAssetUuid = uiConfig.assetUuid || uiConfig.uuid || null;
const resolvedAssetTagCode = uiConfig.assetTagCode || uiConfig.assetTagNumber || null;
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
const curveUnits = {
pressure: this._resolveUnitOrFallback(uiConfig.curvePressureUnit, 'pressure', 'mbar', 'curve pressure'),
flow: this._resolveUnitOrFallback(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit, 'curve flow'),
power: this._resolveUnitOrFallback(uiConfig.curvePowerUnit, 'power', 'kW', 'curve power'),
control: this._resolveControlUnitOrFallback(uiConfig.curveControlUnit, '%'),
};
// Build config: base sections + rotatingMachine-specific domain config
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
flowNumber: uiConfig.flowNumber
});
// Override asset with rotatingMachine-specific fields
this.config.asset = {
...this.config.asset,
uuid: resolvedAssetUuid,
tagCode: resolvedAssetTagCode,
tagNumber: uiConfig.assetTagNumber || null,
unit: flowUnit,
curveUnits
};
// Ensure general unit uses resolved flow unit
this.config.general.unit = flowUnit;
// Utility for formatting outputs
this._output = new outputUtils();
}
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) {
const raw = typeof candidate === 'string' ? candidate.trim() : '';
const fallback = String(fallbackUnit || '').trim();
if (!raw) {
return fallback;
}
try {
const desc = convert().describe(raw);
if (expectedMeasure && desc.measure !== expectedMeasure) {
throw new Error(`expected '${expectedMeasure}' but got '${desc.measure}'`);
}
return raw;
} catch (error) {
this.node?.warn?.(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`);
return fallback;
}
}
_resolveControlUnitOrFallback(candidate, fallback = '%') {
const raw = typeof candidate === 'string' ? candidate.trim() : '';
return raw || fallback;
}
/**
* Instantiate the core Measurement logic and store as source.
*/
_setupSpecificClass(uiConfig) {
const machineConfig = this.config;
// need extra state for this
const stateConfig = {
general: {
logging: {
enabled: machineConfig.general.logging.enabled,
logLevel: machineConfig.general.logging.logLevel
}
},
movement: {
speed: Number(uiConfig.speed),
mode: uiConfig.movementMode
},
time: { time: {
starting: Number(uiConfig.startup), starting: Number(uiConfig.startup), warmingup: Number(uiConfig.warmup),
warmingup: Number(uiConfig.warmup), stopping: Number(uiConfig.shutdown), coolingdown: Number(uiConfig.cooldown),
stopping: Number(uiConfig.shutdown), },
coolingdown: Number(uiConfig.cooldown) },
} errorMetricsConfig: {},
}; };
return {
this.source = new Specific(machineConfig, stateConfig); asset: {
uuid: uiConfig.assetUuid || uiConfig.uuid || null,
//store in node tagCode: uiConfig.assetTagCode || uiConfig.assetTagNumber || null,
this.node.source = this.source; // Store the source in the node instance for easy access tagNumber: uiConfig.assetTagNumber || null,
model: uiConfig.model || null,
} unit: flowUnit,
curveUnits: {
/** pressure: _resolveUnit(uiConfig.curvePressureUnit, 'pressure', 'mbar'),
* Bind events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES flow: _resolveUnit(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit),
*/ power: _resolveUnit(uiConfig.curvePowerUnit, 'power', 'kW'),
_bindEvents() { control: (typeof uiConfig.curveControlUnit === 'string' && uiConfig.curveControlUnit.trim()) || '%',
},
} },
general: { unit: flowUnit },
_updateNodeStatus() { flowNumber: uiConfig.flowNumber,
const m = this.source; };
try {
const mode = m.currentMode;
const state = m.state.getCurrentState();
const requiresPressurePrediction = ["operational", "warmingup", "accelerating", "decelerating"].includes(state);
const pressureStatus = typeof m.getPressureInitializationStatus === "function"
? m.getPressureInitializationStatus()
: { initialized: true };
if (requiresPressurePrediction && !pressureStatus.initialized) {
if (!this._pressureInitWarned) {
this.node.warn("Pressure input is not initialized (upstream/downstream missing). Predictions are using minimum pressure.");
this._pressureInitWarned = true;
}
return { fill: "yellow", shape: "ring", text: `${mode}: pressure not initialized` };
}
if (pressureStatus.initialized) {
this._pressureInitWarned = false;
}
const flowUnit = m?.config?.general?.unit || 'm3/h';
const flow = Math.round(m.measurements.type("flow").variant("predicted").position('downstream').getCurrentValue(flowUnit));
const power = Math.round(m.measurements.type("power").variant("predicted").position('atEquipment').getCurrentValue('kW'));
let symbolState;
switch(state){
case "off":
symbolState = "⬛";
break;
case "idle":
symbolState = "⏸️";
break;
case "operational":
symbolState = "⏵️";
break;
case "starting":
symbolState = "⏯️";
break;
case "warmingup":
symbolState = "🔄";
break;
case "accelerating":
symbolState = "⏩";
break;
case "stopping":
symbolState = "⏹️";
break;
case "coolingdown":
symbolState = "❄️";
break;
case "decelerating":
symbolState = "⏪";
break;
case "maintenance":
symbolState = "🔧";
break;
}
const position = m.state.getCurrentPosition();
const roundedPosition = Math.round(position * 100) / 100;
let status;
switch (state) {
case "off":
status = { fill: "red", shape: "dot", text: `${mode}: OFF` };
break;
case "idle":
status = { fill: "blue", shape: "dot", text: `${mode}: ${symbolState}` };
break;
case "operational":
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
break;
case "starting":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
break;
case "warmingup":
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
break;
case "accelerating":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}%| 💨${flow}${flowUnit} | ⚡${power}kW` };
break;
case "stopping":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
break;
case "coolingdown":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
break;
case "decelerating":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} - ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
break;
default:
status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` };
}
return status;
} catch (error) {
this.node.error("Error in updateNodeStatus: " + error.message);
return { fill: "red", shape: "ring", text: "Status Error" };
}
}
/**
* 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.
*/
_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() {
//this.source.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, null]);
}
/**
* Attach the node's input handler, routing control messages to the class.
*/
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
/* Update to complete event based node by putting the tick function after an input event */
const m = this.source;
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
try {
switch(msg.topic) {
case 'registerChild': {
// Register this node as a child of the parent node
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
if (!childObj || !childObj.source) {
this.node.warn(`registerChild failed: child '${childId}' not found or has no source`);
break;
}
m.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
break;
}
case 'setMode':
m.setMode(msg.payload);
break;
case 'execSequence': {
const { source, action, parameter } = msg.payload;
m.handleInput(source, action, parameter);
break;
}
case 'execMovement': {
const { source: mvSource, action: mvAction, setpoint } = msg.payload;
m.handleInput(mvSource, mvAction, Number(setpoint));
break;
}
case 'flowMovement': {
const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload;
m.handleInput(fmSource, fmAction, Number(fmSetpoint));
break;
}
case 'emergencystop': {
const { source: esSource, action: esAction } = msg.payload;
m.handleInput(esSource, esAction);
break;
}
case 'simulateMeasurement':
{
const payload = msg.payload || {};
const type = String(payload.type || '').toLowerCase();
const position = payload.position || 'atEquipment';
const value = Number(payload.value);
const unit = typeof payload.unit === 'string' ? payload.unit.trim() : '';
const supportedTypes = new Set(['pressure', 'flow', 'temperature', 'power']);
const context = {
timestamp: payload.timestamp || Date.now(),
unit,
childName: 'dashboard-sim',
childId: 'dashboard-sim',
};
if (!Number.isFinite(value)) {
this.node.warn('simulateMeasurement payload.value must be a finite number');
break;
}
if (!supportedTypes.has(type)) {
this.node.warn(`Unsupported simulateMeasurement type: ${type}`);
break;
}
if (!unit) {
this.node.warn('simulateMeasurement payload.unit is required');
break;
}
if (typeof m.isUnitValidForType === 'function' && !m.isUnitValidForType(type, unit)) {
this.node.warn(`simulateMeasurement payload.unit '${unit}' is invalid for type '${type}'`);
break;
}
switch (type) {
case 'pressure':
if (typeof m.updateSimulatedMeasurement === "function") {
m.updateSimulatedMeasurement(type, position, value, context);
} else {
m.updateMeasuredPressure(value, position, context);
}
break;
case 'flow':
m.updateMeasuredFlow(value, position, context);
break;
case 'temperature':
m.updateMeasuredTemperature(value, position, context);
break;
case 'power':
m.updateMeasuredPower(value, position, context);
break;
}
}
break;
case 'showWorkingCurves':
nodeSend([{ ...msg, topic : "showWorkingCurves" , payload: m.showWorkingCurves() }, null, null]);
break;
case 'CoG':
nodeSend([{ ...msg, topic : "showCoG" , payload: m.showCoG() }, null, null]);
break;
}
if (typeof done === 'function') done();
} catch (error) {
if (typeof done === 'function') {
done(error);
} else {
this.node.error(error, msg);
}
}
});
}
/**
* Clean up timers and intervals when Node-RED stops the node.
*/
_attachCloseHandler() {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
if (typeof done === 'function') done();
});
} }
} }
// Strict cutover: with the AssetResolver in place, supplier/category/assetType
// are no longer node config — they're derived from the registry by model id.
// Old flows that still have them saved must be re-saved through the editor.
function _rejectLegacyAssetFields(uiConfig) {
const offenders = ['supplier', 'category', 'assetType'].filter((k) => {
const v = uiConfig[k];
return typeof v === 'string' && v.trim() !== '';
});
if (offenders.length > 0) {
throw new Error(
`rotatingMachine: legacy asset field(s) [${offenders.join(', ')}] are saved on this node. ` +
`After the AssetResolver refactor these are derived from the model id. ` +
`Open the node in the editor, re-select the model, and save to migrate.`,
);
}
}
function _resolveUnit(candidate, expectedMeasure, fallback) {
const raw = typeof candidate === 'string' ? candidate.trim() : '';
const fb = String(fallback || '').trim();
if (!raw) return fb;
try {
const desc = convert().describe(raw);
if (expectedMeasure && desc.measure !== expectedMeasure) return fb;
return raw;
} catch (_) { return fb; }
}
module.exports = nodeClass; module.exports = nodeClass;

View File

@@ -0,0 +1,139 @@
/**
* Efficiency / CoG math for rotatingMachine. Kept as host-aware
* helpers so the orchestrator stays a thin stitch. `host` is the
* Machine instance; the helpers read its predictors + measurements
* container and update the legacy fields (cog, NCog, currentEfficiencyCurve,
* absDistFromPeak, relDistFromPeak) on it in place — matching the
* pre-refactor surface tests assert on.
*
* Efficiency definition: hydraulic efficiency η = (Q · ΔP) / P_shaft —
* a dimensionless 0..1 ratio. The legacy pre-refactor implementation
* stored `flow/power` in canonical SI (m³/J), which (a) yields tiny
* numeric values that dashboards round to 0.0000 and (b) is monotonic
* in ctrl for centrifugal-pump curves so it has no interior peak — so
* NCog collapses to 0 and absDistFromPeak becomes meaningless. The
* hydraulic-efficiency form gives a real BEP (interior peak) and is
* directly comparable to nameplate efficiency. ΔP comes from the
* predictor's `currentF` (canonical Pa) because each fDimension slice
* IS the curve at that pressure differential.
*/
const { gravity, coolprop } = require('generalFunctions');
function calcEfficiencyCurve(powerCurve, flowCurve, pressureDiffPa) {
const efficiencyCurve = [];
let peak = 0; let peakIndex = 0; let minEfficiency = Infinity;
if (!powerCurve?.y?.length || !flowCurve?.y?.length) {
return { efficiencyCurve: [], peak: 0, peakIndex: 0, minEfficiency: 0 };
}
const dP = Number.isFinite(pressureDiffPa) && pressureDiffPa > 0 ? pressureDiffPa : 0;
powerCurve.y.forEach((power, i) => {
const flow = flowCurve.y[i];
// η = (Q · ΔP) / P. Falls back to 0 when any factor is missing.
const eff = (power > 0 && flow >= 0 && dP > 0) ? (flow * dP) / power : 0;
efficiencyCurve.push(eff);
if (eff > peak) { peak = eff; peakIndex = i; }
if (eff < minEfficiency) minEfficiency = eff;
});
if (!Number.isFinite(minEfficiency)) minEfficiency = 0;
return { efficiencyCurve, peak, peakIndex, minEfficiency };
}
function calcCog(host) {
if (!host.hasCurve || !host.predictFlow || !host.predictPower) {
return { cog: 0, cogIndex: 0, NCog: 0, minEfficiency: 0 };
}
const { powerCurve, flowCurve } = getCurrentCurves(host);
const dP = host.predictFlow.currentF;
const { efficiencyCurve, peak, peakIndex, minEfficiency } = calcEfficiencyCurve(powerCurve, flowCurve, dP);
const yMin = host.predictFlow.currentFxyYMin;
const yMax = host.predictFlow.currentFxyYMax;
const NCog = (yMax > yMin) ? (flowCurve.y[peakIndex] - yMin) / (yMax - yMin) : 0;
host.currentEfficiencyCurve = efficiencyCurve;
host.cog = peak;
host.cogIndex = peakIndex;
host.NCog = NCog;
host.minEfficiency = minEfficiency;
return { cog: peak, cogIndex: peakIndex, NCog, minEfficiency };
}
function getCurrentCurves(host) {
if (!host.hasCurve || !host.predictPower || !host.predictFlow) {
return { powerCurve: { x: [], y: [] }, flowCurve: { x: [], y: [] } };
}
return {
powerCurve: host.predictPower.currentFxyCurve[host.predictPower.currentF],
flowCurve: host.predictFlow.currentFxyCurve[host.predictFlow.currentF],
};
}
function getCompleteCurve(host) {
if (!host.hasCurve || !host.predictPower || !host.predictFlow) return { powerCurve: null, flowCurve: null };
return { powerCurve: host.predictPower.inputCurveData, flowCurve: host.predictFlow.inputCurveData };
}
function calcDistanceFromPeak(currentEfficiency, peakEfficiency) {
return Math.abs(currentEfficiency - peakEfficiency);
}
function calcRelativeDistanceFromPeak(host, currentEfficiency, maxEfficiency, minEfficiency) {
if (currentEfficiency != null && maxEfficiency !== minEfficiency) {
return host.interpolation.interpolate_lin_single_point(currentEfficiency, maxEfficiency, minEfficiency, 0, 1);
}
return 1;
}
function calcDistanceBEP(host, efficiency, maxEfficiency, minEfficiency) {
host.absDistFromPeak = calcDistanceFromPeak(efficiency, maxEfficiency);
host.relDistFromPeak = calcRelativeDistanceFromPeak(host, efficiency, maxEfficiency, minEfficiency);
return { absDistFromPeak: host.absDistFromPeak, relDistFromPeak: host.relDistFromPeak };
}
function calcEfficiency(host, power, flow, variant) {
const pressureDiff = host.measurements.type('pressure').variant('measured').difference({ unit: 'Pa' });
const g = gravity.getStandardGravity();
const temp = host.measurements.type('temperature').variant('measured').position('atEquipment').getCurrentValue('K');
const atm = host.measurements.type('atmPressure').variant('measured').position('atEquipment').getCurrentValue('Pa');
let rho = null;
try { rho = coolprop.PropsSI('D', 'T', temp, 'P', atm, 'WasteWater'); }
catch (e) { host.logger.warn(`CoolProp density lookup failed: ${e.message}. Using fallback density.`); rho = 1000; }
const flowM3s = host.measurements.type('flow').variant(variant).position('atEquipment').getCurrentValue('m3/s');
const powerW = host.measurements.type('power').variant(variant).position('atEquipment').getCurrentValue('W');
// Prefer the measured pressure differential; fall back to the predictor's
// current fDimension (the slice the prediction is being read from) so we
// still get a meaningful efficiency for predicted-variant calls when the
// measured differential isn't available yet.
let diffPa = pressureDiff?.value != null ? Number(pressureDiff.value) : null;
if (!Number.isFinite(diffPa) || diffPa <= 0) {
const fF = host.predictFlow?.currentF;
if (Number.isFinite(fF) && fF > 0) diffPa = fF;
}
host.logger.debug(`temp: ${temp} atmPressure : ${atm} rho : ${rho} pressureDiff: ${diffPa || 0}`);
host.logger.debug(`Flow : ${flowM3s} power: ${powerW}`);
if (power > 0 && flow > 0) {
// η_hydraulic = (Q · ΔP) / P_shaft, dimensionless 0..1. Stored as the
// primary `efficiency` so dashboards and BEP-distance math see a
// physically meaningful number instead of m³/J. `flow` and `power`
// here are canonical m³/s and W from the predictor.
if (Number.isFinite(diffPa) && diffPa > 0) {
host.measurements.type('efficiency').variant(variant).position('atEquipment').value((flow * diffPa) / power);
}
host.measurements.type('specificEnergyConsumption').variant(variant).position('atEquipment').value(power / flow);
if (Number.isFinite(diffPa) && diffPa > 0 && Number.isFinite(flowM3s) && Number.isFinite(powerW) && powerW > 0) {
const head = (Number.isFinite(rho) && rho > 0) ? diffPa / (rho * g) : null;
const hydraulicPowerW = diffPa * flowM3s;
if (Number.isFinite(head)) host.measurements.type('pumpHead').variant(variant).position('atEquipment').value(head, Date.now(), 'm');
host.measurements.type('hydraulicPower').variant(variant).position('atEquipment').value(hydraulicPowerW, Date.now(), 'W');
host.measurements.type('nHydraulicEfficiency').variant(variant).position('atEquipment').value(hydraulicPowerW / powerW);
}
}
return host.measurements.type('efficiency').variant(variant).position('atEquipment').getCurrentValue();
}
module.exports = {
calcCog, calcEfficiencyCurve, calcEfficiency, calcDistanceBEP,
calcDistanceFromPeak, calcRelativeDistanceFromPeak,
getCurrentCurves, getCompleteCurve,
};

View File

@@ -0,0 +1,23 @@
const { predict } = require('generalFunctions');
/**
* Build group-scope predicts that share input curves (and splines) with the
* individual ones via Predict.shareInputsFrom. They maintain independent
* operating-point state so an MGC parent can evaluate every pump curve at
* one shared manifold differential without disturbing the pump's own
* sensor-driven outputs.
*
* Returns null when the source predictors are absent (curve load failed).
*/
function buildGroupPredictors(predictors) {
if (!predictors || !predictors.predictFlow || !predictors.predictPower || !predictors.predictCtrl) {
return null;
}
return {
groupPredictFlow: new predict({ shareInputsFrom: predictors.predictFlow }),
groupPredictPower: new predict({ shareInputsFrom: predictors.predictPower }),
groupPredictCtrl: new predict({ shareInputsFrom: predictors.predictCtrl }),
};
}
module.exports = { buildGroupPredictors };

View File

@@ -0,0 +1,82 @@
/**
* Pure operating-point helper. Centralises the "set the working pressure
* and read a derived value" pattern used by both the pump's own pressure
* stream and the MGC group-scope evaluation. Does NOT touch the parent
* Machine's measurements or pressure-routing — that stays in specificClass.
*
* `individual` is the {predictFlow, predictPower, predictCtrl} set from
* buildPredictors(). `group` is the optional set from buildGroupPredictors()
* (may be null when no MGC parent is active).
*/
class OperatingPoint {
constructor(individual, group = null) {
this._individual = individual || null;
this._group = group || null;
this._scope = 'individual';
}
setGroupPredictors(group) {
this._group = group || null;
}
useIndividual() {
this._scope = 'individual';
return this;
}
useGroup() {
this._scope = 'group';
return this;
}
setIndividual(pressureDiff) {
if (!this._individual) return false;
if (!Number.isFinite(pressureDiff)) return false;
this._individual.predictFlow.fDimension = pressureDiff;
this._individual.predictPower.fDimension = pressureDiff;
this._individual.predictCtrl.fDimension = pressureDiff;
return true;
}
setGroup(pressureDiff) {
if (!this._group) return false;
if (!Number.isFinite(pressureDiff)) return false;
this._group.groupPredictFlow.fDimension = pressureDiff;
this._group.groupPredictPower.fDimension = pressureDiff;
this._group.groupPredictCtrl.fDimension = pressureDiff;
return true;
}
_activeFlow() {
return this._scope === 'group' ? this._group?.groupPredictFlow : this._individual?.predictFlow;
}
_activePower() {
return this._scope === 'group' ? this._group?.groupPredictPower : this._individual?.predictPower;
}
_activeCtrl() {
return this._scope === 'group' ? this._group?.groupPredictCtrl : this._individual?.predictCtrl;
}
flowFor(ctrl) {
const p = this._activeFlow();
if (!p) return null;
p.currentX = ctrl;
return p.y(ctrl);
}
powerFor(ctrl) {
const p = this._activePower();
if (!p) return null;
p.currentX = ctrl;
return p.y(ctrl);
}
ctrlFor(flow) {
const p = this._activeCtrl();
if (!p) return null;
p.currentX = flow;
return p.y(flow);
}
}
module.exports = OperatingPoint;

View File

@@ -0,0 +1,71 @@
/**
* Curve-driven prediction math kept as host-aware helpers so the
* specificClass orchestrator stays slim. Every helper mirrors a method
* from the pre-refactor Machine class one-to-one — behaviour is
* preserved verbatim including the "no curve → log + 0" fallback shape
* and the operational-state guard.
*/
function calcFlow(host, x) {
const u = host.unitPolicy.canonical.flow;
if (host.hasCurve) {
if (!host._isOperationalState()) {
host.measurements.type('flow').variant('predicted').position('downstream').value(0, Date.now(), u);
host.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), u);
host.logger.debug('Machine is not operational. Setting predicted flow to 0.');
return 0;
}
const cFlow = Math.max(0, host.predictFlow.y(x));
host.measurements.type('flow').variant('predicted').position('downstream').value(cFlow, Date.now(), u);
host.measurements.type('flow').variant('predicted').position('atEquipment').value(cFlow, Date.now(), u);
return cFlow;
}
host.logger.warn('No curve data available for flow calculation. Returning 0.');
host.measurements.type('flow').variant('predicted').position('downstream').value(0, Date.now(), u);
host.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), u);
return 0;
}
function calcPower(host, x) {
const u = host.unitPolicy.canonical.power;
if (host.hasCurve) {
if (!host._isOperationalState()) {
host.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), u);
host.logger.debug('Machine is not operational. Setting predicted power to 0.');
return 0;
}
const cPower = Math.max(0, host.predictPower.y(x));
host.measurements.type('power').variant('predicted').position('atEquipment').value(cPower, Date.now(), u);
return cPower;
}
host.logger.warn('No curve data available for power calculation. Returning 0.');
host.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), u);
return 0;
}
function inputFlowCalcPower(host, flow) {
if (host.hasCurve) {
host.predictCtrl.currentX = flow;
const cCtrl = host.predictCtrl.y(flow);
host.predictPower.currentX = cCtrl;
return host.predictPower.y(cCtrl);
}
host.logger.warn('No curve data available for power calculation. Returning 0.');
host.measurements.type('power').variant('predicted').position('atEquipment')
.value(0, Date.now(), host.unitPolicy.canonical.power);
return 0;
}
function calcCtrl(host, x) {
if (host.hasCurve) {
host.predictCtrl.currentX = x;
const cCtrl = host.predictCtrl.y(x);
host.measurements.type('ctrl').variant('predicted').position('atEquipment').value(cCtrl);
return cCtrl;
}
host.logger.warn('No curve data available for control calculation. Returning 0.');
host.measurements.type('ctrl').variant('predicted').position('atEquipment').value(0, Date.now());
return 0;
}
module.exports = { calcFlow, calcPower, inputFlowCalcPower, calcCtrl };

View File

@@ -0,0 +1,25 @@
const { predict } = require('generalFunctions');
const { reverseCurve } = require('../curves/reverseCurve');
/**
* Build the three individual-scope predict instances that drive a single
* pump's flow/power/ctrl outputs from its own pressure measurements.
* predictFlow: ctrl -> flow (from machineCurve.nq)
* predictPower: ctrl -> power (from machineCurve.np)
* predictCtrl: flow -> ctrl (from reversed machineCurve.nq)
*
* The reverse is built here rather than in the caller so the predictors
* folder owns the full "what is needed to predict" knowledge.
*/
function buildPredictors(machineCurve) {
if (!machineCurve || !machineCurve.nq || !machineCurve.np) {
throw new Error('buildPredictors: machineCurve.nq and .np are required');
}
return {
predictFlow: new predict({ curve: machineCurve.nq }),
predictPower: new predict({ curve: machineCurve.np }),
predictCtrl: new predict({ curve: reverseCurve(machineCurve.nq) }),
};
}
module.exports = { buildPredictors };

View File

@@ -0,0 +1,100 @@
'use strict';
/**
* PressureInitialization — tracks real pressure children per position
* and reports the overall pressure-input status (initialized, has
* differential, preferred source).
*
* Extracted from rotatingMachine specificClass.getPressureInitializationStatus
* + the realPressureChildIds set tracking.
*/
class PressureInitialization {
/**
* @param {object} ctx
* - measurements: MeasurementContainer
* - virtualPressureChildIds: { upstream, downstream }
* - realPressureChildIds?: { upstream: Set<string>, downstream: Set<string> }
* - logger
*/
constructor(ctx = {}) {
this.measurements = ctx.measurements;
this.virtualPressureChildIds = ctx.virtualPressureChildIds || {};
this.realPressureChildIds = ctx.realPressureChildIds || {
upstream: new Set(),
downstream: new Set(),
};
this.logger = ctx.logger || { warn() {}, debug() {} };
}
registerReal(position, childId) {
const pos = this._normPosition(position);
if (!this.realPressureChildIds[pos]) this.realPressureChildIds[pos] = new Set();
this.realPressureChildIds[pos].add(childId);
}
unregisterReal(position, childId) {
const pos = this._normPosition(position);
if (this.realPressureChildIds[pos]) this.realPressureChildIds[pos].delete(childId);
}
/**
* @returns {{ hasUpstream, hasDownstream, hasDifferential, initialized, source }}
* source ∈ 'differential' | 'upstream' | 'downstream' | null.
* Matches the original getPressureInitializationStatus() shape.
*/
getStatus() {
const upstream = this._getPreferred('upstream');
const downstream = this._getPreferred('downstream');
const hasUpstream = upstream != null;
const hasDownstream = downstream != null;
const hasDifferential = hasUpstream && hasDownstream;
let source = null;
if (hasDifferential) source = 'differential';
else if (hasDownstream) source = 'downstream';
else if (hasUpstream) source = 'upstream';
return {
hasUpstream,
hasDownstream,
hasDifferential,
initialized: hasUpstream || hasDownstream,
source,
};
}
/**
* Get the preferred pressure value at a position. Real children win
* over virtual; final fallback is the bare (position-only) container slot.
*/
getPreferredValue(position) {
return this._getPreferred(this._normPosition(position));
}
_getPreferred(position) {
const realIds = Array.from(this.realPressureChildIds[position] || []);
for (const id of realIds) {
const v = this._readChild(position, id);
if (v != null) return v;
}
const virtualId = this.virtualPressureChildIds[position];
if (virtualId) {
const v = this._readChild(position, virtualId);
if (v != null) return v;
}
return this.measurements
?.type('pressure').variant('measured').position(position).getCurrentValue();
}
_readChild(position, childId) {
return this.measurements
?.type('pressure').variant('measured').position(position).child(childId).getCurrentValue();
}
_normPosition(position) {
return String(position || '').toLowerCase();
}
}
module.exports = PressureInitialization;

View File

@@ -0,0 +1,94 @@
'use strict';
/**
* PressureRouter — routes a measured pressure value into the right
* MeasurementContainer slot and triggers the downstream cascade
* (preferred-pressure resolve → predicted recompute → drift → health)
* on every pressure write, matching the pre-refactor
* `updateMeasuredPressure` semantics.
*
* Why the cascade runs for virtual sources too: dashboard-sim pressure
* sliders route through virtual children, and the operator expects the
* predicted flow/power/efficiency/Cog to refresh on every slider tick.
* The cascade is idempotent — running it on a virtual write is cheap
* and matches what a real sensor would trigger.
*
* Why getPressure() runs first: getMeasuredPressure() writes the new
* pressure differential onto predictFlow/Power/Ctrl.fDimension. Only
* after that does updatePosition() compute flow/power via
* predictFlow.y(x) — otherwise calcFlowPower runs against a stale
* fDimension and the prediction lags one update behind the slider.
*/
class PressureRouter {
/**
* @param {object} ctx
* - measurements: MeasurementContainer
* - virtualPressureChildIds: { upstream, downstream } (kept for debug only)
* - resolveMeasurementUnit(type, unit) -> canonical unit string (throws on invalid)
* - getPressure?(): resolves preferred pressure and pushes fDimension to predictors
* - updatePosition?(): recomputes predicted flow/power/efficiency/CoG at current ctrl
* - refreshDrift?(): refreshes pressure drift status
* - refreshHealth?(): refreshes prediction-health status
* - logger
*/
constructor(ctx = {}) {
this.measurements = ctx.measurements;
this.virtualPressureChildIds = ctx.virtualPressureChildIds || {};
this.resolveMeasurementUnit = ctx.resolveMeasurementUnit || ((_t, u) => u);
this.getPressure = ctx.getPressure;
this.updatePosition = ctx.updatePosition;
this.refreshDrift = ctx.refreshDrift;
this.refreshHealth = ctx.refreshHealth;
this.logger = ctx.logger || { warn() {}, debug() {} };
}
/**
* Route a measured pressure to the right container slot.
* @returns {boolean} true on successful write, false on rejection.
*/
route(position, value, context = {}) {
const pos = String(position || '').toLowerCase();
const childId = context.childId;
let unit;
try {
unit = this.resolveMeasurementUnit('pressure', context.unit);
} catch (err) {
this.logger.warn(`Rejected pressure update: ${err.message}`);
return false;
}
this.measurements
?.type('pressure').variant('measured').position(pos).child(childId)
.value(value, context.timestamp, unit);
const isVirtual = this._isVirtual(childId);
this.logger.debug(`Pressure routed: ${value} ${unit} at ${pos} from ${context.childName || 'child'} (${childId || 'unknown-id'}) virtual=${isVirtual}`);
// Legacy order: resolve preferred pressure (writes fDimension to
// predictors) BEFORE recomputing predicted flow/power at the current
// control position. Skipping any of these on virtual sources broke
// the dashboard-sim demo (NCog / efficiency / absDistFromPeak stuck
// at 0, predicted flow/power not updating with the pressure slider).
let p;
if (typeof this.getPressure === 'function') {
p = this.getPressure();
this.logger.debug(`Using pressure: ${p} for calculations`);
}
if (typeof this.updatePosition === 'function') this.updatePosition();
if (typeof this.refreshDrift === 'function') this.refreshDrift();
if (typeof this.refreshHealth === 'function') this.refreshHealth();
return true;
}
_isVirtual(childId) {
if (childId == null) return false;
for (const id of Object.values(this.virtualPressureChildIds)) {
if (id === childId) return true;
}
return false;
}
}
module.exports = PressureRouter;

View File

@@ -0,0 +1,52 @@
/**
* Resolves the working pressure for prediction and pushes it onto
* predictFlow/predictPower/predictCtrl.fDimension. After every push the
* CoG, efficiency, and distance-from-BEP are recomputed so downstream
* state stays consistent — exactly what the pre-refactor
* getMeasuredPressure() did.
*/
const eff = require('../prediction/efficiencyMath');
function getMeasuredPressure(host) {
if (!host.hasCurve || !host.predictFlow || !host.predictPower || !host.predictCtrl) {
host.logger.error('No valid curve available to calculate prediction using last known pressure');
return 0;
}
const up = host._getPreferredPressureValue('upstream');
const dn = host._getPreferredPressureValue('downstream');
const applyDiff = (diff) => {
host.predictFlow.fDimension = diff;
host.predictPower.fDimension = diff;
host.predictCtrl.fDimension = diff;
const { cog, minEfficiency } = eff.calcCog(host);
const efficiency = eff.calcEfficiency(host, host.predictPower.outputY, host.predictFlow.outputY, 'predicted');
eff.calcDistanceBEP(host, efficiency, cog, minEfficiency);
};
if (up != null && dn != null) {
const diff = dn - up;
host.logger.debug(`Pressure differential: ${diff}`);
applyDiff(diff);
return diff;
}
if (dn != null) {
host.logger.warn(`Using downstream pressure only for prediction: ${dn}. Prediction accuracy is degraded; inject upstream pressure too.`);
applyDiff(dn);
return dn;
}
if (up != null) {
host.logger.warn(`Using upstream pressure only for prediction: ${up}. Prediction accuracy is degraded; inject downstream pressure too.`);
applyDiff(up);
return up;
}
host.logger.error('No valid pressure measurements available to calculate prediction using last known pressure');
applyDiff(0);
const fu = host.unitPolicy.canonical.flow;
host.measurements.type('flow').variant('predicted').position('max').value(host.predictFlow.currentFxyYMax, Date.now(), fu);
host.measurements.type('flow').variant('predicted').position('min').value(host.predictFlow.currentFxyYMin, Date.now(), fu);
return 0;
}
module.exports = { getMeasuredPressure };

View File

@@ -0,0 +1,92 @@
'use strict';
const { MeasurementContainer } = require('generalFunctions');
/**
* VirtualPressureChildren — builds two dashboard-sim children backed
* by their own MeasurementContainer (upstream + downstream). Children
* are signed as belonging to a parent machine via `setParentRef`.
*
* Extracted from rotatingMachine specificClass._initVirtualPressureChildren.
*/
const DEFAULT_IDS = {
upstream: 'dashboard-sim-upstream',
downstream: 'dashboard-sim-downstream',
};
class VirtualPressureChildren {
/**
* @param {object} opts
* - logger: pass-through to MeasurementContainer
* - unitPolicy: { canonical, output }
* - parentRef: object to use as parent for setParentRef (optional)
* - ids: override the default { upstream, downstream } id pair (optional)
*/
constructor({ logger, unitPolicy, parentRef = null, ids = DEFAULT_IDS } = {}) {
this.logger = logger || { warn() {}, debug() {} };
this.unitPolicy = unitPolicy;
this.parentRef = parentRef;
this.ids = { ...DEFAULT_IDS, ...(ids || {}) };
}
/**
* @returns {{ upstream: VirtualChild, downstream: VirtualChild }}
* Each child = { config: { general, functionality, asset }, measurements }.
*/
build() {
return {
upstream: this._createChild('upstream'),
downstream: this._createChild('downstream'),
};
}
_createChild(position) {
const id = this.ids[position];
const name = `dashboard-sim-${position}`;
const measurements = new MeasurementContainer({
autoConvert: true,
defaultUnits: this._unitMap('output'),
preferredUnits: this._unitMap('output'),
canonicalUnits: this.unitPolicy?.canonical,
storeCanonical: true,
strictUnitValidation: true,
throwOnInvalidUnit: true,
requireUnitForTypes: ['pressure'],
}, this.logger);
if (typeof measurements.setChildId === 'function') measurements.setChildId(id);
if (typeof measurements.setChildName === 'function') measurements.setChildName(name);
if (this.parentRef && typeof measurements.setParentRef === 'function') {
measurements.setParentRef(this.parentRef);
}
return {
config: {
general: { id, name },
functionality: {
softwareType: 'measurement',
positionVsParent: position,
},
asset: {
type: 'pressure',
unit: this.unitPolicy?.output?.pressure,
},
},
measurements,
};
}
_unitMap(section) {
const src = this.unitPolicy?.[section] || {};
return {
pressure: src.pressure,
flow: src.flow,
power: src.power,
temperature: src.temperature,
};
}
}
VirtualPressureChildren.DEFAULT_IDS = DEFAULT_IDS;
module.exports = VirtualPressureChildren;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
/**
* Sequence + setpoint orchestration. Pre-refactor lived inline on
* Machine; extracted so the orchestrator stays focused. All behaviour
* is preserved verbatim including the interruptible-shutdown abort
* dance and the operational-state ramp-to-zero before shutdown.
*/
function resolveSetpointBounds(host) {
const stateMin = Number(host.state?.movementManager?.minPosition);
const stateMax = Number(host.state?.movementManager?.maxPosition);
const curveMin = Number(host.predictFlow?.currentFxyXMin);
const curveMax = Number(host.predictFlow?.currentFxyXMax);
const minCands = [stateMin, curveMin].filter(Number.isFinite);
const maxCands = [stateMax, curveMax].filter(Number.isFinite);
const fbMin = Number.isFinite(stateMin) ? stateMin : 0;
const fbMax = Number.isFinite(stateMax) ? stateMax : 100;
let min = minCands.length ? Math.max(...minCands) : fbMin;
let max = maxCands.length ? Math.min(...maxCands) : fbMax;
if (min > max) {
host.logger.warn(`Invalid setpoint bounds detected (min=${min}, max=${max}). Falling back to movement bounds.`);
min = fbMin; max = fbMax;
}
return { min, max };
}
async function setpoint(host, target) {
try {
if (!Number.isFinite(target)) { host.logger.error('Invalid setpoint: Setpoint must be a finite number.'); return; }
const { min, max } = resolveSetpointBounds(host);
const constrained = Math.min(Math.max(target, min), max);
if (constrained !== target) host.logger.warn(`Requested setpoint ${target} constrained to ${constrained} (min=${min}, max=${max})`);
host.logger.info(`Setting setpoint to ${constrained}. Current position: ${host.state.getCurrentPosition()}`);
await host.state.moveTo(constrained);
} catch (e) { host.logger.error(`Error setting setpoint: ${e}`); }
}
function waitForOperational(host, timeoutMs = 2000) {
if (host.state.getCurrentState() === 'operational') return Promise.resolve('operational');
return new Promise((resolve) => {
let done = false;
const timer = setTimeout(() => {
if (done) return;
done = true;
host.state.emitter.off('stateChange', onChange);
resolve(host.state.getCurrentState());
}, timeoutMs);
const onChange = (newState) => {
if (done) return;
if (newState === 'operational') {
done = true; clearTimeout(timer);
host.state.emitter.off('stateChange', onChange);
resolve('operational');
}
};
host.state.emitter.on('stateChange', onChange);
});
}
async function executeSequence(host, rawName) {
const name = typeof rawName === 'string' ? rawName.toLowerCase() : rawName;
const sequence = host.config.sequences[name];
if (!sequence || sequence.size === 0) {
host.logger.warn(`Sequence '${name}' not defined.`);
return;
}
const interruptible = new Set(['shutdown', 'emergencystop']);
if (interruptible.has(name)) host.state.delayedMove = null;
const current = host.state.getCurrentState();
if (interruptible.has(name) && (current === 'accelerating' || current === 'decelerating')) {
host.logger.warn(`Sequence '${name}' requested during '${current}'. Aborting active movement.`);
host.state.abortCurrentMovement(`${name} sequence requested`, { returnToOperational: true });
await waitForOperational(host, 2000);
}
if (host.state.getCurrentState() === 'operational' && name === 'shutdown') {
host.logger.info(`Machine will ramp down to position 0 before performing ${name} sequence`);
await setpoint(host, 0);
}
host.logger.info(` --------- Executing sequence: ${name} -------------`);
for (const s of sequence) {
try { await host.state.transitionToState(s); }
catch (e) { host.logger.error(`Error during sequence '${name}': ${e}`); break; }
}
host.updatePosition();
}
module.exports = { setpoint, executeSequence, resolveSetpointBounds, waitForOperational };

View File

@@ -0,0 +1,58 @@
/**
* Thin adapter over the generalFunctions state machine emitter.
* Holds no state of its own — exposes bind/unbind and the
* shared definition of which states count as "operational" for
* downstream measurement processing.
*/
const OPERATIONAL_STATES = [
'operational',
'accelerating',
'decelerating',
'warmingup',
];
/**
* Attaches positionChange / stateChange listeners to a state machine.
* Returns an idempotent teardown function. Both handlers are required —
* the bindings encode the lifecycle contract between the FSM and the
* specificClass orchestrator, so leaving one half wired is a bug.
*/
function bindStateEvents(ctx) {
if (!ctx || !ctx.state || !ctx.state.emitter) {
throw new Error('bindStateEvents: ctx.state.emitter is required');
}
const { state, onPositionChange, onStateChange } = ctx;
if (typeof onPositionChange !== 'function' || typeof onStateChange !== 'function') {
throw new Error('bindStateEvents: onPositionChange and onStateChange handlers are required');
}
state.emitter.on('positionChange', onPositionChange);
state.emitter.on('stateChange', onStateChange);
let removed = false;
return function teardown() {
if (removed) return;
removed = true;
state.emitter.off('positionChange', onPositionChange);
state.emitter.off('stateChange', onStateChange);
};
}
/**
* True when the FSM is in a state that should accept measurement
* updates and recompute predictions. Pure helper, accepts the state
* machine instance so callers can pass a fake in tests.
*/
function isOperationalState(stateInstance) {
if (!stateInstance || typeof stateInstance.getCurrentState !== 'function') {
return false;
}
return OPERATIONAL_STATES.includes(stateInstance.getCurrentState());
}
module.exports = {
bindStateEvents,
isOperationalState,
OPERATIONAL_STATES,
};

View File

@@ -0,0 +1,61 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
// Phase 4 regression: after the AssetResolver cutover the node must
// (a) derive supplier/type/units from the registry, not from saved config,
// (b) hard-fail with a clear log if asset.model is missing,
// (c) hard-fail if asset.unit is missing or not in registry's allowed set,
// (d) succeed with a known good model + unit.
function makeConfig({ model = 'hidrostal-H05K-S03R', unit = 'm3/h' } = {}) {
return {
general: { id: 'test-node', name: 'Pump-T', logging: { enabled: false } },
asset: { model, unit, curveUnits: { pressure: 'mbar', flow: unit, power: 'kW', control: '%' } },
functionality: { softwareType: 'rotatingmachine' },
};
}
test('asset metadata is derived from the registry, not from config', () => {
const m = new Machine(makeConfig());
assert.ok(m.assetMetadata, 'expected assetMetadata to be populated');
assert.equal(m.assetMetadata.supplier, 'Hidrostal');
assert.equal(m.assetMetadata.type, 'Centrifugal');
assert.ok(Array.isArray(m.assetMetadata.units));
assert.ok(m.assetMetadata.units.length > 0);
});
test('valid model + unit yields working curve predictors', () => {
const m = new Machine(makeConfig());
assert.equal(m.hasCurve, true);
assert.equal(typeof m.predictFlow, 'object');
assert.equal(typeof m.predictPower, 'object');
});
test('missing model installs null predictors (degraded mode)', () => {
const m = new Machine(makeConfig({ model: null }));
assert.equal(m.hasCurve, false);
assert.equal(m.predictFlow, null);
assert.equal(m.predictPower, null);
});
test('unknown model installs null predictors and logs', () => {
const m = new Machine(makeConfig({ model: 'no-such-model-xyz' }));
assert.equal(m.hasCurve, false);
assert.equal(m.assetMetadata, null);
});
test('unit not in registry allowed-set installs null predictors', () => {
const m = new Machine(makeConfig({ unit: 'furlongs-per-fortnight' }));
assert.equal(m.hasCurve, false);
});
test('two machines with the same model get independent assetMetadata instances', () => {
const a = new Machine(makeConfig());
const b = new Machine(makeConfig());
assert.notStrictEqual(a, b);
assert.equal(a.assetMetadata.supplier, b.assetMetadata.supplier);
});

View File

@@ -0,0 +1,275 @@
// Basic tests for the rotatingMachine 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 = 'rm-1', unitValid = true } = {}) {
const calls = {
setMode: [],
handleInput: [],
registerChild: [],
sim: [],
updatePressure: [],
updateFlow: [],
updateTemp: [],
updatePower: [],
showWorkingCurves: 0,
showCoG: 0,
};
const source = {
logger: makeLogger(),
config: { general: { name } },
setMode: (m) => calls.setMode.push(m),
handleInput: async (src, action, parameter) => {
calls.handleInput.push({ src, action, parameter });
},
isUnitValidForType: () => unitValid,
updateSimulatedMeasurement: (type, position, value, ctx) =>
calls.sim.push({ type, position, value, ctx }),
updateMeasuredPressure: (v, p, c) => calls.updatePressure.push({ v, p, c }),
updateMeasuredFlow: (v, p, c) => calls.updateFlow.push({ v, p, c }),
updateMeasuredTemperature: (v, p, c) => calls.updateTemp.push({ v, p, c }),
updateMeasuredPower: (v, p, c) => calls.updatePower.push({ v, p, c }),
showWorkingCurves: () => { calls.showWorkingCurves++; return { curves: 'mock' }; },
showCoG: () => { calls.showCoG++; return { cog: 'mock' }; },
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: 'GUI' }, source, makeCtx());
assert.deepEqual(calls.setMode, ['GUI']);
await reg.dispatch(
{ topic: 'cmd.startup', payload: { source: 'GUI' } }, source, makeCtx());
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'startup' });
await reg.dispatch(
{ topic: 'cmd.shutdown', payload: { source: 'GUI' } }, source, makeCtx());
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'shutdown' });
await reg.dispatch(
{ topic: 'cmd.estop', payload: { source: 'GUI', action: 'emergencystop' } }, source, makeCtx());
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'emergencystop', parameter: undefined });
await reg.dispatch(
{ topic: 'set.setpoint', payload: { source: 'GUI', action: 'execMovement', setpoint: '75' } },
source, makeCtx());
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execMovement', parameter: 75 });
await reg.dispatch(
{ topic: 'set.flow-setpoint', payload: { source: 'GUI', action: 'flowMovement', setpoint: '12' } },
source, makeCtx());
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'flowMovement', parameter: 12 });
});
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: 'GUI' }, source, makeCtx({ logger: ctxLogger }));
await reg.dispatch({ topic: 'setMode', payload: 'virtualControl' }, source, makeCtx({ logger: ctxLogger }));
assert.deepEqual(calls.setMode, ['GUI', 'virtualControl']);
let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated"));
assert.equal(warns.length, 1);
await reg.dispatch({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } },
source, makeCtx({ logger: ctxLogger }));
warns = ctxLogger.calls.warn.filter((m) => m.includes("'emergencystop' is deprecated"));
assert.equal(warns.length, 1);
await reg.dispatch({ topic: 'execMovement', payload: { source: 'GUI', action: 'execMovement', setpoint: 50 } },
source, makeCtx({ logger: ctxLogger }));
warns = ctxLogger.calls.warn.filter((m) => m.includes("'execMovement' is deprecated"));
assert.equal(warns.length, 1);
await reg.dispatch({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 5 } },
source, makeCtx({ logger: ctxLogger }));
warns = ctxLogger.calls.warn.filter((m) => m.includes("'flowMovement' is deprecated"));
assert.equal(warns.length, 1);
});
test('execSequence with payload.action=startup reaches cmd.startup handler', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch(
{ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } },
source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'startup' });
// Registry logs the legacy-topic deprecation (no canonical alias, but
// the demux handler accepts both startup/shutdown actions).
});
test('execSequence with payload.action=shutdown reaches cmd.shutdown handler', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'execSequence', payload: { source: 'GUI', action: 'shutdown' } },
source, makeCtx());
assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'shutdown' });
});
test('execSequence with unknown action logs warn and does not call handleInput', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'execSequence', payload: { source: 'GUI', action: 'frobnicate' } },
source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.handleInput.length, 0);
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('execSequence') && m.includes('frobnicate')),
`expected warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`);
});
test('data.simulate-measurement happy path dispatches to the right updater', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'data.simulate-measurement',
payload: { type: 'pressure', position: 'upstream', value: 1013, unit: 'mbar' } },
source, makeCtx());
assert.equal(calls.sim.length, 1);
assert.equal(calls.sim[0].type, 'pressure');
assert.equal(calls.sim[0].value, 1013);
await reg.dispatch(
{ topic: 'data.simulate-measurement',
payload: { type: 'flow', value: 30, unit: 'm3/h' } },
source, makeCtx());
assert.equal(calls.updateFlow.length, 1);
});
test('data.simulate-measurement validation: bad type / missing unit / non-finite value', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
// unsupported type
await reg.dispatch(
{ topic: 'data.simulate-measurement', payload: { type: 'voltage', value: 1, unit: 'V' } },
source, makeCtx({ logger: ctxLogger }));
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('Unsupported simulateMeasurement type: voltage')));
// missing unit
await reg.dispatch(
{ topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 1013 } },
source, makeCtx({ logger: ctxLogger }));
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('unit is required')));
// non-finite value
await reg.dispatch(
{ topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 'abc', unit: 'mbar' } },
source, makeCtx({ logger: ctxLogger }));
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('must be a finite number')));
// nothing was forwarded to the source
assert.equal(calls.sim.length, 0);
assert.equal(calls.updateFlow.length, 0);
assert.equal(calls.updatePressure.length, 0);
});
test('query.curves and query.cog reply on Port 0 via ctx.send', async () => {
const { source, calls } = makeSource();
const sent = [];
const ctx = makeCtx({ sendSpy: (ports) => sent.push(ports) });
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'query.curves' }, source, ctx);
await reg.dispatch({ topic: 'query.cog' }, source, ctx);
assert.equal(calls.showWorkingCurves, 1);
assert.equal(calls.showCoG, 1);
assert.equal(sent.length, 2);
// First port carries the reply; Ports 1 & 2 are null.
assert.equal(sent[0][0].topic, 'showWorkingCurves');
assert.deepEqual(sent[0][0].payload, { curves: 'mock' });
assert.equal(sent[0][1], null);
assert.equal(sent[0][2], null);
assert.equal(sent[1][0].topic, 'showCoG');
assert.deepEqual(sent[1][0].payload, { cog: 'mock' });
});
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
const { source, calls } = makeSource();
const child = { id: 'm-1', source: { tag: 'm-domain' } };
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'child.register', payload: 'm-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('child.register with unknown id logs warn and does not throw', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await assert.doesNotReject(() =>
reg.dispatch(
{ topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' },
source,
makeCtx({ logger: ctxLogger })
)
);
assert.equal(calls.registerChild.length, 0);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')),
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});

View File

@@ -0,0 +1,30 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { loadModelCurve } = require('../../src/curves/curveLoader');
test('curveLoader: valid model returns rawCurve and null error', () => {
const result = loadModelCurve('hidrostal-H05K-S03R');
assert.equal(result.error, null);
assert.ok(result.rawCurve);
assert.ok(result.rawCurve.np);
assert.ok(result.rawCurve.nq);
});
test('curveLoader: missing model returns Model not specified', () => {
const result = loadModelCurve('');
assert.equal(result.rawCurve, null);
assert.equal(result.error, 'Model not specified');
});
test('curveLoader: undefined model returns Model not specified', () => {
const result = loadModelCurve(undefined);
assert.equal(result.rawCurve, null);
assert.equal(result.error, 'Model not specified');
});
test('curveLoader: unknown model returns Curve not found error', () => {
const result = loadModelCurve('this-model-does-not-exist');
assert.equal(result.rawCurve, null);
assert.match(result.error, /Curve not found for model/);
});

View File

@@ -0,0 +1,88 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { UnitPolicy } = require('generalFunctions');
const {
normalizeMachineCurve,
normalizeCurveSection,
convertUnitValue,
} = require('../../src/curves/curveNormalizer');
function makePolicy() {
return UnitPolicy.declare({
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
output: { pressure: 'mbar', flow: 'm3/h', power: 'kW', temperature: 'C' },
curve: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' },
});
}
function captureLogger() {
const warns = [];
return {
warn: (m) => warns.push(m),
warns,
};
}
test('normalizeMachineCurve: rejects raw without nq/np', () => {
const policy = makePolicy();
assert.throws(() => normalizeMachineCurve(null, policy), /missing required nq\/np/);
assert.throws(() => normalizeMachineCurve({ nq: { 700: { x: [0], y: [0] } } }, policy), /missing required nq\/np/);
assert.throws(() => normalizeMachineCurve({ np: { 700: { x: [0], y: [0] } } }, policy), /missing required nq\/np/);
});
test('normalizeMachineCurve: converts pressure mbar -> Pa and flow m3/h -> m3/s', () => {
const policy = makePolicy();
const raw = {
nq: {
1000: { x: [0, 100], y: [0, 3600] }, // 3600 m3/h = 1 m3/s
},
np: {
1000: { x: [0, 100], y: [0, 1] }, // 1 kW = 1000 W
},
};
const out = normalizeMachineCurve(raw, policy);
// 1000 mbar = 100000 Pa
const pressureKey = Object.keys(out.nq)[0];
assert.equal(Number(pressureKey), 100000);
assert.ok(Math.abs(out.nq[pressureKey].y[1] - 1) < 1e-9, `expected 1 m3/s got ${out.nq[pressureKey].y[1]}`);
assert.ok(Math.abs(out.np[pressureKey].y[1] - 1000) < 1e-6, `expected 1000 W got ${out.np[pressureKey].y[1]}`);
});
test('normalizeCurveSection: warns on cross-pressure median > 3x jump', () => {
const logger = captureLogger();
const section = {
1000: { x: [0, 50, 100], y: [0, 5, 10] }, // median 5
1100: { x: [0, 50, 100], y: [0, 50, 100] }, // median 50 (10x jump)
};
normalizeCurveSection(section, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger);
const hit = logger.warns.find((w) => /Curve anomaly/.test(w));
assert.ok(hit, `expected a Curve anomaly warning, got: ${JSON.stringify(logger.warns)}`);
assert.match(hit, /pressure 1100/);
});
test('normalizeCurveSection: does not warn on smooth progressions', () => {
const logger = captureLogger();
const section = {
1000: { x: [0, 50, 100], y: [0, 5, 10] },
1100: { x: [0, 50, 100], y: [0, 6, 11] },
};
normalizeCurveSection(section, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger);
assert.equal(logger.warns.filter((w) => /Curve anomaly/.test(w)).length, 0);
});
test('normalizeCurveSection: throws when x/y length mismatch', () => {
assert.throws(
() => normalizeCurveSection({ 1000: { x: [0, 50], y: [0, 5, 10] } }, 'm3/h', 'm3/s', 'mbar', 'Pa', 'nq', null),
/Invalid nq section/
);
});
test('convertUnitValue: identity when units match or missing', () => {
assert.equal(convertUnitValue(42, 'm3/h', 'm3/h'), 42);
assert.equal(convertUnitValue(42, null, null), 42);
});
test('convertUnitValue: throws on non-finite input', () => {
assert.throws(() => convertUnitValue('not-a-number', 'm3/h', 'm3/s', 'test'), /not finite/);
});

View File

@@ -0,0 +1,130 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DriftAssessor = require('../../src/drift/driftAssessor');
/* ---- fakes ---- */
function fakeMeasurements(predictedValue) {
return {
type() { return this; },
variant() { return this; },
position() { return this; },
getCurrentValue() { return predictedValue; },
getAllValues() { return { values: [predictedValue], timestamps: [1] }; },
};
}
function makeErrorMetrics(driftFactory) {
return {
assessPoint: (metricId, predicted, measured, opts) => driftFactory(metricId, predicted, measured, opts),
assessDrift: () => ({ nrmse: 0.1, valid: true }),
};
}
const SILENT = { warn() {}, debug() {} };
test('updateMetricDrift returns drift object when predicted+measured both finite', () => {
const drift = { valid: true, nrmse: 0.05, immediateLevel: 0, longTermLevel: 0 };
const assessor = new DriftAssessor({
errorMetrics: makeErrorMetrics(() => drift),
measurements: fakeMeasurements(10),
driftProfiles: { flow: {} },
logger: SILENT,
});
const out = assessor.updateMetricDrift('flow', 11);
assert.deepEqual(out, drift);
assert.equal(assessor.latest.flow, drift);
});
test('updateMetricDrift returns null when predicted is non-finite', () => {
const assessor = new DriftAssessor({
errorMetrics: makeErrorMetrics(() => ({ valid: true })),
measurements: fakeMeasurements(NaN),
driftProfiles: {},
logger: SILENT,
});
assert.equal(assessor.updateMetricDrift('flow', 5), null);
});
test('updateMetricDrift catches errorMetrics throw and logs', () => {
const warns = [];
const assessor = new DriftAssessor({
errorMetrics: { assessPoint() { throw new Error('boom'); } },
measurements: fakeMeasurements(10),
driftProfiles: {},
logger: { warn(m) { warns.push(m); }, debug() {} },
});
const out = assessor.updateMetricDrift('flow', 11);
assert.equal(out, null);
assert.match(warns[0], /Drift update failed for metric 'flow'/);
});
test('applyDriftPenalty leaves confidence unchanged for null/invalid drift', () => {
const assessor = new DriftAssessor({ logger: SILENT });
const flags = [];
assert.equal(assessor.applyDriftPenalty(null, 0.9, flags, 'flow'), 0.9);
assert.equal(assessor.applyDriftPenalty({ valid: false }, 0.9, flags, 'flow'), 0.9);
assert.deepEqual(flags, []);
});
test('applyDriftPenalty level 1 reduces confidence by 0.1 + flag', () => {
const assessor = new DriftAssessor({ logger: SILENT });
const flags = [];
const c = assessor.applyDriftPenalty(
{ valid: true, nrmse: 0.1, immediateLevel: 1, longTermLevel: 0 },
0.9, flags, 'flow',
);
assert.ok(Math.abs(c - 0.8) < 1e-9);
assert.deepEqual(flags, ['flow_low_immediate_drift']);
});
test('applyDriftPenalty level 2 reduces confidence by 0.2 + flag', () => {
const assessor = new DriftAssessor({ logger: SILENT });
const flags = [];
const c = assessor.applyDriftPenalty(
{ valid: true, nrmse: 0.2, immediateLevel: 2, longTermLevel: 0 },
0.9, flags, 'power',
);
assert.ok(Math.abs(c - 0.7) < 1e-9);
assert.deepEqual(flags, ['power_medium_immediate_drift']);
});
test('applyDriftPenalty level 3 reduces confidence by 0.3 + flag', () => {
const assessor = new DriftAssessor({ logger: SILENT });
const flags = [];
const c = assessor.applyDriftPenalty(
{ valid: true, nrmse: 0.5, immediateLevel: 3, longTermLevel: 0 },
0.9, flags, 'flow',
);
assert.ok(Math.abs(c - 0.6) < 1e-9);
assert.deepEqual(flags, ['flow_high_immediate_drift']);
});
test('applyDriftPenalty stacks long-term penalty', () => {
const assessor = new DriftAssessor({ logger: SILENT });
const flags = [];
const c = assessor.applyDriftPenalty(
{ valid: true, nrmse: 0.4, immediateLevel: 2, longTermLevel: 2 },
0.9, flags, 'flow',
);
assert.ok(Math.abs(c - 0.6) < 1e-9);
assert.deepEqual(flags, ['flow_medium_immediate_drift', 'flow_long_term_drift']);
});
test('assessDrift returns null if no stored series', () => {
const assessor = new DriftAssessor({
errorMetrics: makeErrorMetrics(() => ({ valid: true })),
measurements: {
type() { return this; },
variant() { return this; },
position() { return this; },
getAllValues() { return {}; },
},
driftProfiles: {},
logger: SILENT,
});
assert.equal(assessor.assessDrift('flow', 0, 1), null);
});

View File

@@ -0,0 +1,132 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const FlowController = require('../../src/flow/flowController');
function makeLogger() {
const calls = { debug: [], info: [], warn: [], error: [] };
return {
calls,
debug: (m) => calls.debug.push(m),
info: (m) => calls.info.push(m),
warn: (m) => calls.warn.push(m),
error: (m) => calls.error.push(m),
};
}
function makeHost({
mode = 'auto',
allowedActions = new Set(['execsequence', 'execmovement', 'flowmovement', 'emergencystop', 'statuscheck', 'entermaintenance', 'exitmaintenance']),
allowedSources = true,
setpointError,
} = {}) {
const logger = makeLogger();
const host = {
logger,
currentMode: mode,
unitPolicy: {
canonical: { flow: 'm3/s' },
output: { flow: 'm3/h' },
},
isValidActionForMode: (action) => allowedActions.has(action),
isValidSourceForMode: () => allowedSources,
calls: { executeSequence: [], setpoint: [], calcCtrl: [], convertUnit: [] },
async executeSequence(seq) { host.calls.executeSequence.push(seq); return { ran: seq }; },
async setpoint(sp) {
host.calls.setpoint.push(sp);
if (setpointError) throw setpointError;
return { moved: sp };
},
calcCtrl: (canonicalFlow) => { host.calls.calcCtrl.push(canonicalFlow); return canonicalFlow / 2; },
_convertUnitValue: (val, from, to, label) => {
host.calls.convertUnit.push({ val, from, to, label });
return val * 1000; // pretend m3/h -> m3/s factor
},
};
return host;
}
test('handle("parent","execSequence","startup") triggers executeSequence', async () => {
const host = makeHost();
const fc = new FlowController({ host });
const result = await fc.handle('parent', 'execSequence', 'startup');
assert.deepEqual(host.calls.executeSequence, ['startup']);
assert.deepEqual(result, { ran: 'startup' });
});
test('handle("parent","execMovement",50) invokes setpoint(50)', async () => {
const host = makeHost();
const fc = new FlowController({ host });
const result = await fc.handle('parent', 'execMovement', 50);
assert.deepEqual(host.calls.setpoint, [50]);
assert.deepEqual(result, { moved: 50 });
});
test('handle("parent","flowMovement",X) converts unit -> calcCtrl -> setpoint', async () => {
const host = makeHost();
const fc = new FlowController({ host });
await fc.handle('parent', 'flowMovement', 36);
assert.equal(host.calls.convertUnit.length, 1);
assert.equal(host.calls.convertUnit[0].from, 'm3/h');
assert.equal(host.calls.convertUnit[0].to, 'm3/s');
assert.deepEqual(host.calls.calcCtrl, [36 * 1000]);
assert.deepEqual(host.calls.setpoint, [(36 * 1000) / 2]);
});
test('handle("parent","emergencyStop") fires executeSequence("emergencystop") and logs warn', async () => {
const host = makeHost();
const fc = new FlowController({ host });
await fc.handle('parent', 'emergencyStop');
assert.deepEqual(host.calls.executeSequence, ['emergencystop']);
assert.ok(host.logger.calls.warn.some((m) => /Emergency stop activated/.test(m)));
});
test('handle rejects non-string action', async () => {
const host = makeHost();
const fc = new FlowController({ host });
await fc.handle('parent', 123, 'x');
assert.deepEqual(host.calls.executeSequence, []);
assert.deepEqual(host.calls.setpoint, []);
assert.ok(host.logger.calls.error.some((m) => /Action must be string/.test(m)));
});
test('handle bails out when action not allowed for mode', async () => {
const host = makeHost({ allowedActions: new Set(['statuscheck']) });
const fc = new FlowController({ host });
await fc.handle('parent', 'execSequence', 'startup');
assert.deepEqual(host.calls.executeSequence, []);
});
test('handle bails out when source not allowed for mode', async () => {
const host = makeHost({ allowedSources: false });
const fc = new FlowController({ host });
await fc.handle('externalApi', 'execSequence', 'startup');
assert.deepEqual(host.calls.executeSequence, []);
});
test('handle catches downstream errors and logs them (does not propagate)', async () => {
const host = makeHost({ setpointError: new Error('boom') });
const fc = new FlowController({ host });
const result = await fc.handle('parent', 'execMovement', 12);
assert.equal(result, undefined);
assert.ok(host.logger.calls.error.some((m) => /Error handling input/.test(m)));
});
test('handle returns a success envelope for statuscheck', async () => {
const host = makeHost();
const fc = new FlowController({ host });
const out = await fc.handle('parent', 'statusCheck');
assert.equal(out.status, true);
assert.ok(out.feedback.includes('statuscheck'));
});
test('handle warns on unimplemented action', async () => {
const host = makeHost({ allowedActions: new Set(['weirdaction']) });
const fc = new FlowController({ host });
await fc.handle('parent', 'weirdAction');
assert.ok(host.logger.calls.warn.some((m) => /is not implemented/.test(m)));
});
test('constructor validates host', () => {
assert.throws(() => new FlowController({}), /ctx\.host is required/);
});

View File

@@ -0,0 +1,51 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { predict } = require('generalFunctions');
const { buildPredictors } = require('../../src/prediction/predictors');
const { buildGroupPredictors } = require('../../src/prediction/groupPredictors');
function makeCanonicalCurve() {
return {
nq: {
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
},
np: {
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
},
};
}
test('buildGroupPredictors: returns null when source predictors absent', () => {
assert.equal(buildGroupPredictors(null), null);
assert.equal(buildGroupPredictors({ predictFlow: null, predictPower: null, predictCtrl: null }), null);
});
test('buildGroupPredictors: returns three group-scope Predict instances', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const group = buildGroupPredictors(predictors);
assert.ok(group);
assert.ok(group.groupPredictFlow instanceof predict);
assert.ok(group.groupPredictPower instanceof predict);
assert.ok(group.groupPredictCtrl instanceof predict);
});
test('buildGroupPredictors: group instances share input curves with individuals', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const group = buildGroupPredictors(predictors);
// Predict._adoptInputsFrom copies these refs from the source.
assert.equal(group.groupPredictFlow.inputCurve, predictors.predictFlow.inputCurve);
assert.equal(group.groupPredictPower.inputCurve, predictors.predictPower.inputCurve);
assert.equal(group.groupPredictCtrl.inputCurve, predictors.predictCtrl.inputCurve);
});
test('buildGroupPredictors: group operating-point state is independent of individual', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const group = buildGroupPredictors(predictors);
predictors.predictFlow.fDimension = 100000;
group.groupPredictFlow.fDimension = 120000;
assert.equal(predictors.predictFlow.currentF, 100000);
assert.equal(group.groupPredictFlow.currentF, 120000);
});

View File

@@ -0,0 +1,149 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const MeasurementHandlers = require('../../src/measurement/measurementHandlers');
function makeChainable(sink) {
const builder = {
_path: {},
type(t) { this._path.type = t; return this; },
variant(v) { this._path.variant = v; return this; },
position(p){ this._path.position = p; return this; },
child(id) { this._path.child = id; return this; },
value(v, ts, unit) {
sink.push({ ...this._path, value: v, ts, unit });
this._path = {};
},
getCurrentValue(unit) {
return sink._currentValue != null ? sink._currentValue : 0;
},
};
return builder;
}
function makeLogger() {
const calls = { debug: [], info: [], warn: [], error: [] };
return {
calls,
debug: (m) => calls.debug.push(m),
info: (m) => calls.info.push(m),
warn: (m) => calls.warn.push(m),
error: (m) => calls.error.push(m),
};
}
function makeHost({ operational = true } = {}) {
const writes = [];
const logger = makeLogger();
const host = {
logger,
writes,
measurementUnits: { flow: 'm3/h', power: 'kW', temperature: 'C', pressure: 'mbar' },
unitPolicy: {
canonical: { flow: 'm3/s', power: 'W', temperature: 'K', pressure: 'Pa' },
output: { flow: 'm3/h', power: 'kW', temperature: 'C', pressure: 'mbar' },
},
predictFlow: { outputY: 7 },
predictPower: { outputY: 1234 },
measurements: makeChainable(writes),
_isOperationalState: () => operational,
_resolveMeasurementUnit: (type, unit) => {
if (!unit) throw new Error(`Missing unit for ${type} measurement.`);
return unit;
},
_updateMetricDrift: (...args) => { host.driftCalls.push(args); },
_updatePredictionHealth: () => { host.healthCalls++; },
driftCalls: [],
healthCalls: 0,
updateMeasuredPressure: (...args) => { host.pressureCalls.push(args); },
pressureCalls: [],
updatePosition: () => { host.positionCalls++; },
positionCalls: 0,
};
return host;
}
test('dispatch("flow", …) routes to updateMeasuredFlow', () => {
const host = makeHost();
const mh = new MeasurementHandlers({ host });
mh.dispatch('flow', 5, 'downstream', { unit: 'm3/h', childId: 'c1', childName: 'FT-1' });
const flowWrite = host.writes.find((w) => w.type === 'flow' && w.variant === 'measured');
assert.ok(flowWrite, 'expected measured flow write');
assert.equal(flowWrite.value, 5);
assert.equal(flowWrite.position, 'downstream');
assert.equal(flowWrite.child, 'c1');
const predictedWrites = host.writes.filter((w) => w.type === 'flow' && w.variant === 'predicted');
assert.equal(predictedWrites.length, 2, 'two predicted writes (downstream+atEquipment)');
assert.equal(host.driftCalls.length, 1);
assert.equal(host.driftCalls[0][0], 'flow');
assert.equal(host.healthCalls, 1);
});
test('dispatch("temperature", …) writes to measurements (works in non-operational state too)', () => {
const host = makeHost({ operational: false });
const mh = new MeasurementHandlers({ host });
mh.dispatch('temperature', 22.5, 'atEquipment', { unit: 'C', childId: 'tc', childName: 'TT-1', timestamp: 111 });
const write = host.writes.find((w) => w.type === 'temperature');
assert.ok(write);
assert.equal(write.value, 22.5);
assert.equal(write.unit, 'C');
assert.equal(write.ts, 111);
});
test('dispatch("power", …) routes to updateMeasuredPower and respects unit', () => {
const host = makeHost();
const mh = new MeasurementHandlers({ host });
mh.dispatch('power', 1500, 'atEquipment', { unit: 'kW', childId: 'pwr', childName: 'P-1' });
const measured = host.writes.find((w) => w.type === 'power' && w.variant === 'measured');
assert.ok(measured);
assert.equal(measured.unit, 'kW');
const predicted = host.writes.find((w) => w.type === 'power' && w.variant === 'predicted');
assert.ok(predicted);
assert.equal(host.driftCalls.length, 1);
assert.equal(host.driftCalls[0][0], 'power');
});
test('flow/power updates are skipped when machine is not operational', () => {
const host = makeHost({ operational: false });
const mh = new MeasurementHandlers({ host });
mh.dispatch('flow', 5, 'downstream', { unit: 'm3/h' });
mh.dispatch('power', 99, 'atEquipment', { unit: 'kW' });
assert.equal(host.writes.length, 0);
assert.equal(host.driftCalls.length, 0);
assert.ok(host.logger.calls.warn.some((m) => /Machine not operational/.test(m)));
});
test('dispatch("pressure", …) delegates to host.updateMeasuredPressure (pressureRouter)', () => {
const host = makeHost();
const mh = new MeasurementHandlers({ host });
mh.dispatch('pressure', 1013, 'upstream', { unit: 'mbar', childId: 'PT-1' });
assert.equal(host.pressureCalls.length, 1);
assert.deepEqual(host.pressureCalls[0][0], 1013);
});
test('dispatch(unknown, …) logs warn and falls back to updatePosition', () => {
const host = makeHost();
const mh = new MeasurementHandlers({ host });
mh.dispatch('vibration', 1, 'atEquipment', {});
assert.equal(host.positionCalls, 1);
assert.ok(host.logger.calls.warn.some((m) => /No handler for measurement type/.test(m)));
});
test('handler rejects update when unit resolution throws', () => {
const host = makeHost();
const mh = new MeasurementHandlers({ host });
mh.dispatch('flow', 5, 'downstream', { /* no unit */ });
assert.equal(host.writes.length, 0);
assert.ok(host.logger.calls.warn.some((m) => /Rejected flow update/.test(m)));
});
test('constructor validates host', () => {
assert.throws(() => new MeasurementHandlers({}), /ctx\.host is required/);
});

View File

@@ -2,16 +2,20 @@ const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const NodeClass = require('../../src/nodeClass');
const { makeNodeStub } = require('../helpers/factories'); const { makeNodeStub, makeREDStub } = require('../helpers/factories');
// These tests drive the BaseNodeAdapter public surface. We construct the
// full nodeClass and observe the resulting `inst.source.config` (the
// validated merged shape) and the source's runtime mode. No private hooks.
function makeUiConfig(overrides = {}) { function makeUiConfig(overrides = {}) {
// After the AssetResolver cutover, the editor no longer saves
// supplier/category/assetType — those are derived from the model id via
// assetResolver.resolveAssetMetadata at runtime.
return { return {
unit: 'm3/h', unit: 'm3/h',
enableLog: true, enableLog: false,
logLevel: 'debug', logLevel: 'error',
supplier: 'hidrostal',
category: 'machine',
assetType: 'pump',
model: 'hidrostal-H05K-S03R', model: 'hidrostal-H05K-S03R',
curvePressureUnit: 'mbar', curvePressureUnit: 'mbar',
curveFlowUnit: 'm3/h', curveFlowUnit: 'm3/h',
@@ -28,82 +32,74 @@ function makeUiConfig(overrides = {}) {
}; };
} }
test('_loadConfig maps legacy editor fields for asset identity', () => { // Adapters built by these tests park a periodic status-poll timer. We
const inst = Object.create(NodeClass.prototype); // drive the BaseNodeAdapter close handler after each test to stop it so
inst.node = makeNodeStub(); // node:test exits cleanly — this is the public teardown path Node-RED
inst.name = 'rotatingMachine'; // itself uses on flow shutdown.
const _adapters = [];
inst._loadConfig( function buildAdapter(ui) {
makeUiConfig({ const node = makeNodeStub();
uuid: 'uuid-from-editor', const RED = makeREDStub();
assetTagNumber: 'TAG-123', const inst = new NodeClass(ui, RED, node, 'rotatingMachine');
}), _adapters.push(node);
inst.node return { inst, node };
); }
test.afterEach(() => {
assert.equal(inst.config.asset.uuid, 'uuid-from-editor'); while (_adapters.length) {
assert.equal(inst.config.asset.tagCode, 'TAG-123'); const node = _adapters.pop();
assert.equal(inst.config.asset.tagNumber, 'TAG-123'); try { node._handlers.close?.(() => {}); } catch (_) { /* best effort */ }
}
}); });
test('_loadConfig prefers explicit assetUuid/assetTagCode when present', () => { test('asset identity flows from legacy editor fields through buildDomainConfig', () => {
const inst = Object.create(NodeClass.prototype); const { inst } = buildAdapter(makeUiConfig({ uuid: 'uuid-from-editor', assetTagNumber: 'TAG-123' }));
inst.node = makeNodeStub(); assert.equal(inst.source.config.asset.uuid, 'uuid-from-editor');
inst.name = 'rotatingMachine'; assert.equal(inst.source.config.asset.tagCode, 'tag-123');
assert.equal(inst.source.config.asset.tagNumber, 'tag-123');
inst._loadConfig(
makeUiConfig({
uuid: 'legacy-uuid',
assetUuid: 'explicit-uuid',
assetTagNumber: 'legacy-tag',
assetTagCode: 'explicit-tag',
}),
inst.node
);
assert.equal(inst.config.asset.uuid, 'explicit-uuid');
assert.equal(inst.config.asset.tagCode, 'explicit-tag');
}); });
test('_loadConfig builds explicit curveUnits and falls back for invalid flow unit', () => { test('explicit assetUuid/assetTagCode override legacy editor fields', () => {
const inst = Object.create(NodeClass.prototype); const { inst } = buildAdapter(makeUiConfig({
inst.node = makeNodeStub(); uuid: 'legacy-uuid', assetUuid: 'explicit-uuid',
inst.name = 'rotatingMachine'; assetTagNumber: 'legacy-tag', assetTagCode: 'explicit-tag',
}));
inst._loadConfig( assert.equal(inst.source.config.asset.uuid, 'explicit-uuid');
makeUiConfig({ assert.equal(inst.source.config.asset.tagCode, 'explicit-tag');
unit: 'not-a-unit',
curvePressureUnit: 'mbar',
curveFlowUnit: 'm3/h',
curvePowerUnit: 'kW',
curveControlUnit: '%',
}),
inst.node
);
assert.equal(inst.config.general.unit, 'm3/h');
assert.equal(inst.config.asset.unit, 'm3/h');
assert.equal(inst.config.asset.curveUnits.pressure, 'mbar');
assert.equal(inst.config.asset.curveUnits.flow, 'm3/h');
assert.equal(inst.config.asset.curveUnits.power, 'kW');
assert.equal(inst.config.asset.curveUnits.control, '%');
assert.ok(inst.node._warns.length >= 1);
}); });
test('_setupSpecificClass propagates logging settings into state config', () => { test('curveUnits propagate through buildDomainConfig, invalid flow unit falls back', () => {
const inst = Object.create(NodeClass.prototype); const { inst } = buildAdapter(makeUiConfig({
inst.node = makeNodeStub(); unit: 'not-a-unit',
inst.name = 'rotatingMachine'; curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
const uiConfig = makeUiConfig({ curvePowerUnit: 'kW', curveControlUnit: '%',
enableLog: true, }));
logLevel: 'warn', assert.equal(inst.source.config.general.unit, 'm3/h');
uuid: 'uuid-test', assert.equal(inst.source.config.asset.unit, 'm3/h');
assetTagNumber: 'TAG-9', assert.equal(inst.source.config.asset.curveUnits.pressure, 'mbar');
}); assert.equal(inst.source.config.asset.curveUnits.flow, 'm3/h');
assert.equal(inst.source.config.asset.curveUnits.power, 'kW');
inst._loadConfig(uiConfig, inst.node); assert.equal(inst.source.config.asset.curveUnits.control, '%');
inst._setupSpecificClass(uiConfig); });
assert.equal(inst.source.state.config.general.logging.enabled, true); test('logging.enabled flag reaches the domain via configManager.buildConfig', () => {
assert.equal(inst.source.state.config.general.logging.logLevel, 'warn'); const { inst } = buildAdapter(makeUiConfig({ enableLog: true }));
// uiConfig.enableLog flows through configManager.buildConfig and lands
// on the validated source config. (logLevel currently doesn't propagate
// — known platform behaviour; not exercised here.)
assert.equal(inst.source.config.general.logging.enabled, true);
});
test('state machine is wired and exposes its public surface', () => {
const { inst } = buildAdapter(makeUiConfig());
// The state machine is constructed during configure() and exposes
// observable methods used by the rest of the domain + the status badge.
assert.equal(typeof inst.source.state.getCurrentState, 'function');
assert.equal(typeof inst.source.state.getCurrentPosition, 'function');
assert.equal(inst.source.state.getCurrentState(), 'idle');
});
test('default mode is honoured on the constructed source', () => {
const { inst } = buildAdapter(makeUiConfig());
assert.equal(typeof inst.source.currentMode, 'string');
assert.ok(inst.source.currentMode.length > 0);
}); });

View File

@@ -0,0 +1,73 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildPredictors } = require('../../src/prediction/predictors');
const { buildGroupPredictors } = require('../../src/prediction/groupPredictors');
const OperatingPoint = require('../../src/prediction/operatingPoint');
function makeCanonicalCurve() {
return {
nq: {
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
},
np: {
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
},
};
}
test('OperatingPoint.setIndividual: updates working pressure on all three predictors', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const op = new OperatingPoint(predictors);
const ok = op.setIndividual(100000);
assert.equal(ok, true);
assert.equal(predictors.predictFlow.currentF, 100000);
assert.equal(predictors.predictPower.currentF, 100000);
assert.equal(predictors.predictCtrl.currentF, 100000);
});
test('OperatingPoint.setIndividual: rejects non-finite pressure', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const op = new OperatingPoint(predictors);
assert.equal(op.setIndividual(NaN), false);
assert.equal(op.setIndividual('not-a-number'), false);
});
test('OperatingPoint.setGroup: no-op when group predictors absent', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const op = new OperatingPoint(predictors, null);
assert.equal(op.setGroup(100000), false);
});
test('OperatingPoint.setGroup: updates only group predictors', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const group = buildGroupPredictors(predictors);
const op = new OperatingPoint(predictors, group);
predictors.predictFlow.fDimension = 120000;
op.setGroup(100000);
assert.equal(group.groupPredictFlow.currentF, 100000);
assert.equal(predictors.predictFlow.currentF, 120000);
});
test('OperatingPoint.flowFor: returns a finite predicted flow', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const op = new OperatingPoint(predictors);
op.setIndividual(100000);
const flow = op.flowFor(50);
assert.ok(Number.isFinite(flow), `expected finite flow, got ${flow}`);
assert.ok(flow > 0);
});
test('OperatingPoint.useGroup: switches getters to group predictors', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const group = buildGroupPredictors(predictors);
const op = new OperatingPoint(predictors, group);
op.setIndividual(100000);
op.setGroup(120000);
const indivFlow = op.useIndividual().flowFor(50);
const groupFlow = op.useGroup().flowFor(50);
assert.ok(Number.isFinite(indivFlow));
assert.ok(Number.isFinite(groupFlow));
});

View File

@@ -0,0 +1,93 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const PredictionHealth = require('../../src/drift/predictionHealth');
const DriftAssessor = require('../../src/drift/driftAssessor');
function makeHealth(overrides = {}) {
return new PredictionHealth({
getPressureInitializationStatus: () => ({
initialized: true, hasDifferential: true, source: 'differential',
}),
isOperational: () => true,
applyDriftPenalty: new DriftAssessor({}).applyDriftPenalty.bind(new DriftAssessor({})),
...overrides,
});
}
test('empty snapshots + differential pressure → nominal health, confidence=0.9', () => {
const ph = makeHealth();
const { health, confidence } = ph.evaluate({
flow: null,
power: null,
pressure: { level: 0, flags: [], source: 'differential' },
});
assert.equal(health.level, 0);
assert.ok(Math.abs(confidence - 0.9) < 1e-9);
assert.equal(typeof health.message, 'string');
});
test('pressure not initialized + flow drift level 2 → composite level >= 2 and multiple flags', () => {
const ph = makeHealth({
getPressureInitializationStatus: () => ({
initialized: false, hasDifferential: false, source: null,
}),
});
const { health, confidence } = ph.evaluate({
flow: { valid: true, nrmse: 0.3, immediateLevel: 2, longTermLevel: 0 },
power: null,
pressure: { level: 2, flags: ['no_pressure_input'], source: null },
});
assert.ok(health.level >= 2);
assert.ok(health.flags.includes('no_pressure_input'));
assert.ok(health.flags.includes('flow_medium_immediate_drift'));
assert.ok(confidence < 0.5);
});
test('returned object has both health and confidence', () => {
const ph = makeHealth();
const out = ph.evaluate({ flow: null, power: null, pressure: { level: 0, flags: [], source: 'differential' } });
assert.ok('health' in out);
assert.ok('confidence' in out);
assert.equal(typeof out.confidence, 'number');
assert.equal(typeof out.health.level, 'number');
});
test('non-operational forces confidence=0 and bumps level >=2', () => {
const ph = makeHealth({ isOperational: () => false });
const { health, confidence } = ph.evaluate({
flow: null, power: null,
pressure: { level: 0, flags: [], source: 'differential' },
});
assert.equal(confidence, 0);
assert.ok(health.flags.includes('not_operational'));
assert.ok(health.level >= 2);
});
test('curve-edge penalty applies when current position is near min/max', () => {
const ph = makeHealth({
getCurrentPosition: () => 0.01,
resolveSetpointBounds: () => ({ min: 0, max: 1 }),
});
const { health, confidence } = ph.evaluate({
flow: null, power: null,
pressure: { level: 0, flags: [], source: 'differential' },
});
assert.ok(health.flags.includes('near_curve_edge'));
assert.ok(confidence < 0.9);
});
test('HealthStatus shape — has the standardised five fields', () => {
const ph = makeHealth();
const { health } = ph.evaluate({
flow: null, power: null,
pressure: { level: 0, flags: [], source: 'differential' },
});
assert.ok('level' in health);
assert.ok('flags' in health);
assert.ok('message' in health);
assert.ok('source' in health);
assert.ok(Array.isArray(health.flags));
});

View File

@@ -0,0 +1,49 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { predict } = require('generalFunctions');
const { buildPredictors } = require('../../src/prediction/predictors');
function makeCanonicalCurve() {
// Canonical units already applied: pressure Pa, flow m3/s, power W,
// x-axis is control %. Two pressure levels, monotonically rising y.
return {
nq: {
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
},
np: {
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
},
};
}
test('buildPredictors: returns three Predict instances', () => {
const predictors = buildPredictors(makeCanonicalCurve());
assert.ok(predictors.predictFlow instanceof predict);
assert.ok(predictors.predictPower instanceof predict);
assert.ok(predictors.predictCtrl instanceof predict);
});
test('buildPredictors: predictFlow yMax/yMin reflect input range', () => {
const predictors = buildPredictors(makeCanonicalCurve());
// After buildAllFxyCurves the fDimension is initialised to fValues.min.
// currentFxyYMin/Max are the y-range at that pressure curve.
assert.ok(Number.isFinite(predictors.predictFlow.currentFxyYMax));
assert.ok(Number.isFinite(predictors.predictFlow.currentFxyYMin));
assert.ok(predictors.predictFlow.currentFxyYMax > predictors.predictFlow.currentFxyYMin);
});
test('buildPredictors: predictCtrl is built from reversed nq (flow->ctrl mapping)', () => {
const predictors = buildPredictors(makeCanonicalCurve());
// predictCtrl's x-axis values must come from y-values in nq.
// sanity-check via currentFxyXMax being in the flow range
assert.ok(predictors.predictCtrl.currentFxyXMax <= 0.02, // flow range upper bound
`expected predictCtrl xMax in flow-range, got ${predictors.predictCtrl.currentFxyXMax}`);
});
test('buildPredictors: throws when machineCurve is missing nq or np', () => {
assert.throws(() => buildPredictors(null), /machineCurve\.nq and \.np are required/);
assert.throws(() => buildPredictors({ nq: {} }), /required/);
});

View File

@@ -0,0 +1,103 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const PressureInitialization = require('../../src/pressure/pressureInitialization');
const SILENT = { warn() {}, debug() {} };
/* A tiny in-memory stand-in for MeasurementContainer's chained API. */
function makeFakeMeasurements() {
const store = new Map();
const key = (pos, childId) => `${pos}::${childId == null ? '*' : childId}`;
return {
_write(pos, childId, value) { store.set(key(pos, childId), value); },
type() { return this; },
variant() { return this; },
position(p) { this._pos = p; return this; },
child(c) { this._child = c; return this; },
getCurrentValue() {
const k = key(this._pos, this._child);
this._child = null;
const v = store.get(k);
if (v != null) return v;
// fallback to bare position when no child specified
return store.get(key(this._pos, null));
},
};
}
test('getStatus reports initialized:false when neither real nor virtual data present', () => {
const init = new PressureInitialization({
measurements: makeFakeMeasurements(),
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
logger: SILENT,
});
const s = init.getStatus();
assert.equal(s.initialized, false);
assert.equal(s.hasDifferential, false);
assert.equal(s.source, null);
});
test('registerReal then getStatus reports initialized:true for that position', () => {
const meas = makeFakeMeasurements();
const init = new PressureInitialization({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
logger: SILENT,
});
init.registerReal('upstream', 'pt-101');
meas._write('upstream', 'pt-101', 5000);
const s = init.getStatus();
assert.equal(s.initialized, true);
assert.equal(s.hasUpstream, true);
assert.equal(s.hasDownstream, false);
assert.equal(s.hasDifferential, false);
assert.equal(s.source, 'upstream');
});
test('hasDifferential true only when both upstream + downstream have data', () => {
const meas = makeFakeMeasurements();
const init = new PressureInitialization({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
logger: SILENT,
});
init.registerReal('upstream', 'pt-1');
meas._write('upstream', 'pt-1', 5000);
assert.equal(init.getStatus().hasDifferential, false);
init.registerReal('downstream', 'pt-2');
meas._write('downstream', 'pt-2', 7000);
const s = init.getStatus();
assert.equal(s.hasDifferential, true);
assert.equal(s.source, 'differential');
});
test('virtual fallback when no real children registered', () => {
const meas = makeFakeMeasurements();
const init = new PressureInitialization({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
logger: SILENT,
});
meas._write('upstream', 'sim-u', 5000);
const s = init.getStatus();
assert.equal(s.hasUpstream, true);
assert.equal(s.source, 'upstream');
});
test('unregisterReal removes a tracked child id', () => {
const init = new PressureInitialization({
measurements: makeFakeMeasurements(),
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
logger: SILENT,
});
init.registerReal('upstream', 'pt-1');
assert.ok(init.realPressureChildIds.upstream.has('pt-1'));
init.unregisterReal('upstream', 'pt-1');
assert.ok(!init.realPressureChildIds.upstream.has('pt-1'));
});

View File

@@ -0,0 +1,122 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const PressureRouter = require('../../src/pressure/pressureRouter');
const SILENT = { warn() {}, debug() {} };
function makeFakeMeasurements() {
const writes = [];
return {
writes,
type() { return this; },
variant() { return this; },
position(p) { this._pos = p; return this; },
child(c) { this._child = c; return this; },
value(v, t, u) { writes.push({ pos: this._pos, child: this._child, value: v, t, u }); },
};
}
test('route("upstream", 1, ctx) writes to the upstream pressure slot', () => {
const meas = makeFakeMeasurements();
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
resolveMeasurementUnit: () => 'mbar',
logger: SILENT,
});
router.route('upstream', 1, { childId: 'real-1', unit: 'mbar', timestamp: 1234 });
assert.equal(meas.writes.length, 1);
assert.equal(meas.writes[0].pos, 'upstream');
assert.equal(meas.writes[0].child, 'real-1');
assert.equal(meas.writes[0].value, 1);
assert.equal(meas.writes[0].u, 'mbar');
});
test('virtual source: full cascade still runs (dashboard-sim must update predictions)', () => {
const meas = makeFakeMeasurements();
let pressCalled = 0, posCalled = 0, driftCalled = 0, healthCalled = 0;
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
resolveMeasurementUnit: () => 'mbar',
getPressure: () => { pressCalled++; return 100; },
updatePosition: () => { posCalled++; },
refreshDrift: () => { driftCalled++; },
refreshHealth: () => { healthCalled++; },
logger: SILENT,
});
router.route('upstream', 7, { childId: 'sim-u', unit: 'mbar' });
assert.equal(pressCalled, 1);
assert.equal(posCalled, 1);
assert.equal(driftCalled, 1);
assert.equal(healthCalled, 1);
});
test('real source: all refresh hooks called', () => {
const meas = makeFakeMeasurements();
let pressCalled = 0, posCalled = 0, driftCalled = 0, healthCalled = 0;
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
resolveMeasurementUnit: () => 'mbar',
getPressure: () => { pressCalled++; return 100; },
updatePosition: () => { posCalled++; },
refreshDrift: () => { driftCalled++; },
refreshHealth: () => { healthCalled++; },
logger: SILENT,
});
router.route('upstream', 7, { childId: 'real-pt-1', unit: 'mbar' });
assert.equal(pressCalled, 1);
assert.equal(posCalled, 1);
assert.equal(driftCalled, 1);
assert.equal(healthCalled, 1);
});
test('cascade order: getPressure runs before updatePosition (fDimension must be fresh when calcFlowPower runs)', () => {
const meas = makeFakeMeasurements();
const calls = [];
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
resolveMeasurementUnit: () => 'mbar',
getPressure: () => { calls.push('getPressure'); return 100; },
updatePosition: () => { calls.push('updatePosition'); },
refreshDrift: () => { calls.push('refreshDrift'); },
refreshHealth: () => { calls.push('refreshHealth'); },
logger: SILENT,
});
router.route('upstream', 7, { childId: 'real-pt-1', unit: 'mbar' });
assert.deepEqual(calls, ['getPressure', 'updatePosition', 'refreshDrift', 'refreshHealth']);
});
test('rejected unit returns false and skips the write', () => {
const meas = makeFakeMeasurements();
const warns = [];
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: {},
resolveMeasurementUnit: () => { throw new Error('bad unit'); },
logger: { warn(m) { warns.push(m); }, debug() {} },
});
const ok = router.route('upstream', 1, { childId: 'x', unit: 'wat' });
assert.equal(ok, false);
assert.equal(meas.writes.length, 0);
assert.match(warns[0], /Rejected pressure update/);
});
test('childId null is treated as not-virtual', () => {
const meas = makeFakeMeasurements();
let posCalled = 0;
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u' },
resolveMeasurementUnit: () => 'mbar',
updatePosition: () => { posCalled++; },
logger: SILENT,
});
router.route('upstream', 2, { unit: 'mbar' });
assert.equal(posCalled, 1);
});

View File

@@ -0,0 +1,29 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { reverseCurve } = require('../../src/curves/reverseCurve');
test('reverseCurve: swaps x and y for each pressure key', () => {
const input = {
700: { x: [0, 50, 100], y: [0, 10, 20] },
800: { x: [0, 50, 100], y: [0, 11, 22] },
};
const out = reverseCurve(input);
assert.deepEqual(out['700'].x, [0, 10, 20]);
assert.deepEqual(out['700'].y, [0, 50, 100]);
assert.deepEqual(out['800'].x, [0, 11, 22]);
assert.deepEqual(out['800'].y, [0, 50, 100]);
});
test('reverseCurve: returns a fresh object with cloned arrays', () => {
const input = { 700: { x: [1, 2], y: [3, 4] } };
const out = reverseCurve(input);
out['700'].x.push(999);
assert.deepEqual(input['700'].x, [1, 2]);
assert.deepEqual(input['700'].y, [3, 4]);
});
test('reverseCurve: handles empty input', () => {
assert.deepEqual(reverseCurve({}), {});
assert.deepEqual(reverseCurve(null), {});
});

View File

@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const EventEmitter = require('events');
const { bindStateEvents, isOperationalState, OPERATIONAL_STATES } =
require('../../src/state/stateBindings');
function makeFakeState() {
const emitter = new EventEmitter();
let current = 'idle';
return {
emitter,
setState(s) { current = s; },
getCurrentState() { return current; },
};
}
test('bindStateEvents attaches both listeners and they fire on emit', () => {
const state = makeFakeState();
let posCalls = 0;
let stateCalls = 0;
let lastStateArg = null;
bindStateEvents({
state,
onPositionChange: () => { posCalls++; },
onStateChange: (newState) => { stateCalls++; lastStateArg = newState; },
});
assert.equal(state.emitter.listenerCount('positionChange'), 1);
assert.equal(state.emitter.listenerCount('stateChange'), 1);
state.emitter.emit('positionChange', 42);
state.emitter.emit('stateChange', 'operational');
assert.equal(posCalls, 1);
assert.equal(stateCalls, 1);
assert.equal(lastStateArg, 'operational');
});
test('bindStateEvents teardown removes both listeners and is idempotent', () => {
const state = makeFakeState();
const teardown = bindStateEvents({
state,
onPositionChange: () => {},
onStateChange: () => {},
});
assert.equal(state.emitter.listenerCount('positionChange'), 1);
assert.equal(state.emitter.listenerCount('stateChange'), 1);
teardown();
assert.equal(state.emitter.listenerCount('positionChange'), 0);
assert.equal(state.emitter.listenerCount('stateChange'), 0);
teardown();
assert.equal(state.emitter.listenerCount('positionChange'), 0);
});
test('bindStateEvents validates context shape', () => {
assert.throws(() => bindStateEvents(null), /ctx\.state\.emitter is required/);
assert.throws(
() => bindStateEvents({ state: makeFakeState() }),
/handlers are required/,
);
});
test('isOperationalState returns true for operational/accelerating/decelerating/warmingup', () => {
const state = makeFakeState();
for (const s of ['operational', 'accelerating', 'decelerating', 'warmingup']) {
state.setState(s);
assert.equal(isOperationalState(state), true, `expected ${s} to be operational`);
}
});
test('isOperationalState returns false for non-operational states and bad input', () => {
const state = makeFakeState();
for (const s of ['idle', 'starting', 'stopping', 'coolingdown', 'emergencystopped']) {
state.setState(s);
assert.equal(isOperationalState(state), false, `expected ${s} not to be operational`);
}
assert.equal(isOperationalState(null), false);
assert.equal(isOperationalState({}), false);
});
test('OPERATIONAL_STATES list is exported and frozen-ish (no extras beyond contract)', () => {
assert.deepEqual(
[...OPERATIONAL_STATES].sort(),
['accelerating', 'decelerating', 'operational', 'warmingup'],
);
});

View File

@@ -0,0 +1,70 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const VirtualPressureChildren = require('../../src/pressure/virtualChildren');
const SILENT = { warn() {}, debug() {}, info() {}, error() {} };
const UNIT_POLICY = {
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K', atmPressure: 'Pa' },
output: { pressure: 'mbar', flow: 'm3/h', power: 'kW', temperature: 'C' },
};
test('build() returns two children with the expected config shape', () => {
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
const { upstream, downstream } = factory.build();
for (const child of [upstream, downstream]) {
assert.ok(child.config.general.id);
assert.ok(child.config.general.name);
assert.equal(child.config.functionality.softwareType, 'measurement');
assert.ok(['upstream', 'downstream'].includes(child.config.functionality.positionVsParent));
assert.equal(child.config.asset.type, 'pressure');
assert.equal(child.config.asset.unit, 'mbar');
}
assert.equal(upstream.config.functionality.positionVsParent, 'upstream');
assert.equal(downstream.config.functionality.positionVsParent, 'downstream');
});
test('each child has its own MeasurementContainer instance', () => {
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
const { upstream, downstream } = factory.build();
assert.ok(upstream.measurements);
assert.ok(downstream.measurements);
assert.notStrictEqual(upstream.measurements, downstream.measurements);
});
test('the MeasurementContainer accepts pressure writes (unit policy applied)', () => {
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
const { upstream } = factory.build();
upstream.measurements
.type('pressure').variant('measured').position('upstream')
.value(1000, Date.now(), 'mbar');
const v = upstream.measurements
.type('pressure').variant('measured').position('upstream').getCurrentValue();
assert.ok(v != null);
});
test('setParentRef wires children to the supplied parent ref', () => {
const parent = { id: 'parent-machine' };
const factory = new VirtualPressureChildren({
logger: SILENT, unitPolicy: UNIT_POLICY, parentRef: parent,
});
const { upstream, downstream } = factory.build();
assert.equal(typeof upstream.measurements.setParentRef, 'function');
assert.equal(typeof downstream.measurements.setParentRef, 'function');
});
test('custom ids are honoured', () => {
const factory = new VirtualPressureChildren({
logger: SILENT,
unitPolicy: UNIT_POLICY,
ids: { upstream: 'sim-u', downstream: 'sim-d' },
});
const { upstream, downstream } = factory.build();
assert.equal(upstream.config.general.id, 'sim-u');
assert.equal(downstream.config.general.id, 'sim-d');
});

View File

@@ -0,0 +1,83 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { showWorkingCurves, showCoG } = require('../../src/display/workingCurves');
function makePredictors(overrides = {}) {
return {
hasCurve: true,
cog: 0.65,
cogIndex: 7,
NCog: 0.5,
minEfficiency: 0.4,
currentEfficiencyCurve: { x: [0, 1], y: [0.4, 0.8] },
absDistFromPeak: 0.15,
relDistFromPeak: 0.3,
calcCog: () => ({ cog: 0.65, cogIndex: 7, NCog: 0.5, minEfficiency: 0.4 }),
getCurrentCurves: () => ({
powerCurve: { x: [0, 1], y: [10, 20] },
flowCurve: { x: [0, 1], y: [0, 5] },
}),
...overrides,
};
}
test('showWorkingCurves returns the expected shape when curves exist', () => {
const p = makePredictors();
const out = showWorkingCurves(p);
assert.deepEqual(out.powerCurve, { x: [0, 1], y: [10, 20] });
assert.deepEqual(out.flowCurve, { x: [0, 1], y: [0, 5] });
assert.equal(out.cog, 0.65);
assert.equal(out.cogIndex, 7);
assert.equal(out.NCog, 0.5);
assert.equal(out.minEfficiency, 0.4);
assert.deepEqual(out.currentEfficiencyCurve, { x: [0, 1], y: [0.4, 0.8] });
assert.equal(out.absDistFromPeak, 0.15);
assert.equal(out.relDistFromPeak, 0.3);
});
test('showWorkingCurves returns error envelope when hasCurve is false', () => {
const out = showWorkingCurves(makePredictors({ hasCurve: false }));
assert.deepEqual(out, { error: 'No curve data available' });
});
test('showWorkingCurves handles null predictors safely', () => {
const out = showWorkingCurves(null);
assert.equal(out.error, 'No curve data available');
});
test('showCoG returns CoG data with rounded NCogPercent when curves exist', () => {
const p = makePredictors();
const out = showCoG(p);
assert.equal(out.cog, 0.65);
assert.equal(out.cogIndex, 7);
assert.equal(out.NCog, 0.5);
// 0.5 * 100 = 50.0, rounded *100 /100 still 50
assert.equal(out.NCogPercent, 50);
assert.equal(out.minEfficiency, 0.4);
assert.deepEqual(out.currentEfficiencyCurve, { x: [0, 1], y: [0.4, 0.8] });
assert.equal(out.absDistFromPeak, 0.15);
assert.equal(out.relDistFromPeak, 0.3);
});
test('showCoG rounds NCogPercent to 2 decimal places', () => {
const p = makePredictors({
calcCog: () => ({ cog: 0.1, cogIndex: 1, NCog: 0.123456, minEfficiency: 0.2 }),
});
const out = showCoG(p);
assert.equal(out.NCogPercent, 12.35);
});
test('showCoG returns degraded shape when hasCurve is false', () => {
const out = showCoG(makePredictors({ hasCurve: false }));
assert.equal(out.error, 'No curve data available');
assert.equal(out.cog, 0);
assert.equal(out.NCog, 0);
assert.equal(out.cogIndex, 0);
});
test('showCoG handles null predictors safely', () => {
const out = showCoG(null);
assert.equal(out.error, 'No curve data available');
assert.equal(out.cog, 0);
});

48
test/e2e/README.md Normal file
View File

@@ -0,0 +1,48 @@
# rotatingMachine — End-to-End Benchmarks
These are live-deploy benchmarks, not unit tests. They require a running Docker-hosted Node-RED with the EVOLV package mounted, and they drive the node through its real runtime: admin-API deploy, debug websocket capture, inject-triggered commands, 1-second tick loop.
Unit tests live in `../basic/`, `../integration/`, `../edge/`. Run those with `npm test`.
## Prerequisites
```bash
cd /mnt/d/gitea/EVOLV
docker compose up -d nodered influxdb
# wait for http://localhost:1880/nodes to return 200
pip install --user --break-system-packages websocket-client requests
```
## Benchmarks
### `curve-prediction-benchmark.py`
Deploys one rotatingMachine per shipped pump curve (`hidrostal-H05K-S03R`, `hidrostal-C5-D03R-SHN1`) and runs a per-pump (pressure × ctrl) sweep. For each pump the sweep covers its own low / mid / high pressure slices with controller setpoints of 20 / 40 / 60 / 80 %.
Reports:
- Count of samples inside the curve envelope ("good") vs out-of-range ("bad").
- Monotonicity of flow across the ctrl sweep at fixed pressure.
- Full sample table with state, ctrl, flow, power, NCog, cog.
```bash
python3 nodes/rotatingMachine/test/e2e/curve-prediction-benchmark.py
cat /tmp/rm_curve_bench.json
```
#### Expected output (green run, 2026-04-13)
| Pump | Samples | Flow range | Power range | Pressures | Envelope OK | Monotonic |
|-------|---------|-----------:|------------:|----------:|:-----------:|:---------:|
| H05K | 12 | 10.3208.3 m³/h | 12.350.3 kW | 7003900 mbar | ✅ | ✅ |
| C5 | 12 | 8.745.6 m³/h | 0.6913.0 kW | 4002900 mbar | ✅ | ✅ |
#### Known limitation — out-of-envelope pressure extrapolation
Feeding a pressure **below** the curve's lowest slice produces extrapolated flow values that can exceed the envelope by orders of magnitude. Example: H05K at 400 mbar (curve min 700 mbar), ctrl=20% → flow ≈ 30 000 m³/h (envelope max 227 m³/h).
The node does not clamp pressure to the curve envelope; in production this is defended by upstream `measurement` nodes with realistic ranges. Operators deploying a machine should confirm the sensor range matches the curve.
### `../../../../memory/` companion benchmarks
The earlier shutdown, interruptibility, and clean-path benchmarks (`rm_e2e_benchmark.py`, `rm_clean.py`, `rm_e2e_verify.py`) live in `/tmp/` during a review session. Promote them into this directory when they need to become permanent smoke tests.

View File

@@ -0,0 +1,449 @@
#!/usr/bin/env python3
"""
Dual-curve E2E prediction benchmark for rotatingMachine.
Deploys a Node-RED flow containing TWO rotatingMachine nodes, one per pump
curve shipped in generalFunctions/datasets/assetData/curves/. For each curve
we run a controlled ctrl x pressure sweep and record the predicted flow and
power, plus the efficiency / CoG metrics. Output is a table the team can
compare against supplier data sheets.
This is a live-deploy benchmark (not a unit test) — it exercises the full
Node-RED runtime path including delta compression on port 0, curve loading
via generalFunctions, and output formatting.
"""
import copy
import json
import os
import re
import sys
import time
import threading
import uuid
import requests
import websocket
BASE = "http://localhost:1880"
WS = "ws://localhost:1880/comms"
CURVES_DIR = "/mnt/d/gitea/EVOLV/nodes/generalFunctions/datasets/assetData/curves"
PUMPS = [
{
"id": "H05K",
"model": "hidrostal-H05K-S03R",
},
{
"id": "C5",
"model": "hidrostal-C5-D03R-SHN1",
},
]
events = []
start = None
lock = threading.Lock()
ready = threading.Event()
def on_message(ws, msg):
try:
data = json.loads(msg)
except Exception:
return
for item in (data if isinstance(data, list) else [data]):
if str(item.get("topic", "")).startswith("debug"):
d = item.get("data", {}) or {}
with lock:
events.append({
"t": round(time.time() - start, 3),
"name": d.get("name"),
"msg": d.get("msg"),
})
def on_open(ws):
ws.send(json.dumps({"subscribe": "debug"}))
ready.set()
def ws_thread():
websocket.WebSocketApp(WS, on_message=on_message, on_open=on_open).run_forever()
def deploy(flow):
r = requests.post(
f"{BASE}/flows",
headers={
"Content-Type": "application/json",
"Node-RED-Deployment-Type": "full",
},
data=json.dumps(flow),
)
r.raise_for_status()
return r.text
def inject(node_id):
r = requests.post(f"{BASE}/inject/{node_id}", timeout=5)
return r.status_code
def port0(node_tag):
"""Return the most recent parsed port-0 payload for a given pump tag."""
debug_name = f"P0-{node_tag}"
with lock:
for e in reversed(events):
if e["name"] == debug_name:
try:
return json.loads(e["msg"])
except Exception:
return None
return None
def curve_envelope(model):
d = json.load(open(os.path.join(CURVES_DIR, f"{model}.json")))
pressures = sorted(int(k) for k in d["nq"].keys() if re.fullmatch(r"-?\d+", k))
flow_vals = [v for p in pressures for v in d["nq"][str(p)]["y"]]
power_vals = [v for p in pressures for v in d["np"][str(p)]["y"]]
return {
"pressures": pressures,
"p_low": pressures[0],
"p_mid": pressures[len(pressures) // 2],
"p_high": pressures[-1],
"flow_range": (min(flow_vals), max(flow_vals)),
"power_range": (min(power_vals), max(power_vals)),
}
def build_flow():
"""Construct a Node-RED flow with one tab holding both pumps + injects + function nodes."""
flow = [{"id": "curve_bench_tab", "type": "tab", "label": "Curve Benchmark", "disabled": False}]
# Generate an id-pool for injects and function nodes
def nid(prefix, i=0):
return f"{prefix}-{i}-{uuid.uuid4().hex[:8]}"
for pump in PUMPS:
pid = pump["id"]
tab = "curve_bench_tab"
# rotatingMachine node
rm_id = f"rm_{pid}"
flow.append({
"id": rm_id,
"type": "rotatingMachine",
"z": tab,
"name": f"Pump-{pid}",
"speed": "50", # fast ramp for benchmark
"startup": "0",
"warmup": "0",
"shutdown": "0",
"cooldown": "0",
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": f"bench-{pid}",
"supplier": "hidrostal",
"category": "pump",
"assetType": "pump-centrifugal",
"model": pump["model"],
"unit": "m3/h",
"curvePressureUnit": "mbar",
"curveFlowUnit": "m3/h",
"curvePowerUnit": "kW",
"curveControlUnit": "%",
"enableLog": False,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": False,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 500, "y": 100 + PUMPS.index(pump) * 400,
"wires": [[f"fmt_{pid}"], [], []],
})
# function node to merge deltas
fmt_id = f"fmt_{pid}"
flow.append({
"id": fmt_id,
"type": "function",
"z": tab,
"name": f"merge-{pid}",
"func": (
"const p = msg.payload || {};\n"
"const c = context.get('c') || {};\n"
"Object.assign(c, p);\n"
"context.set('c', c);\n"
"function find(prefix) {\n"
" for (var k in c) if (k.indexOf(prefix) === 0) return c[k];\n"
" return null;\n"
"}\n"
"msg.payload = {\n"
" state: c.state || 'idle',\n"
" mode: c.mode || 'auto',\n"
" ctrl: c.ctrl != null ? Number(c.ctrl) : null,\n"
" flow: find('flow.predicted.downstream.'),\n"
" power: find('power.predicted.atequipment.'),\n"
" NCog: c.NCog != null ? Number(c.NCog) : null,\n"
" cog: c.cog != null ? Number(c.cog) : null,\n"
" pU: find('pressure.measured.upstream.'),\n"
" pD: find('pressure.measured.downstream.')\n"
"};\n"
"return msg;"
),
"outputs": 1,
"x": 760, "y": 100 + PUMPS.index(pump) * 400,
"wires": [[f"dbg_{pid}"]],
})
# debug node
flow.append({
"id": f"dbg_{pid}",
"type": "debug",
"z": tab,
"name": f"P0-{pid}",
"active": True, "tosidebar": True, "console": False, "tostatus": False,
"complete": "payload", "targetType": "msg",
"x": 1000, "y": 100 + PUMPS.index(pump) * 400,
"wires": [],
})
# injects
def mk_inject(name, topic, payload, y_offset):
return {
"id": f"inj_{pid}_{name.replace(' ', '_')}",
"type": "inject",
"z": tab,
"name": name,
"props": [
{"p": "topic", "vt": "str"},
{"p": "payload"},
],
"topic": topic,
"payload": payload,
"payloadType": "json",
"repeat": "", "crontab": "", "once": False, "onceDelay": "",
"x": 200, "y": y_offset,
"wires": [[rm_id]],
}
base_y = 100 + PUMPS.index(pump) * 400
flow.append({
**mk_inject("setMode-virtual", "setMode", "\"virtualControl\"", base_y + 40),
"payloadType": "str",
"payload": "virtualControl",
})
flow.append(mk_inject(
"Startup", "execSequence",
json.dumps({"source": "GUI", "action": "execSequence", "parameter": "startup"}),
base_y + 80,
))
return flow
def run_sweep(pump_id, model, envelope):
"""For one pump, sweep (pressure, ctrl) and collect predictions."""
results = []
# Use 3 pressures (low/mid/high) and 4 ctrl levels
pressures = [envelope["p_low"], envelope["p_mid"], envelope["p_high"]]
ctrls = [20, 40, 60, 80]
for p in pressures:
# Inject pressures via the simulateMeasurement topic -- we'll do this
# via the Node-RED admin API using a raw msg injection helper: send
# via a synthetic inject. Easiest: create ephemeral inject? Simpler:
# just POST directly to the node using the admin API is not possible
# without a pre-wired inject. Instead we call the node via websocket
# notify? Simpler: deploy a pair of dedicated 'sim' injects per pump.
# But we want a dynamic sweep. Workaround: use the Node-RED http-in?
# Best path: spawn a temporary inject at deploy time. Not trivial.
#
# Alternative that works with the deployed flow: post a message by
# using the /inject admin endpoint with an inject node whose payload
# we rewrite via PUT /flow. Simplest in practice: keep the flow
# static but use the programmable approach: send msg via socket.
# Here we'll just use 3 simulate injects per pump (low/mid/high).
# Since we haven't built those, we fall back to modifying the flow
# dynamically for each pressure.
pass # <-- replaced below with alt strategy
return results
def build_sweep_flow(pressure):
"""Build a flow where pressures for both pumps are pinned to `pressure`."""
flow = build_flow()
for pump in PUMPS:
pid = pump["id"]
rm_id = f"rm_{pid}"
tab = "curve_bench_tab"
base_y = 100 + PUMPS.index(pump) * 400
def inj(name, topic, payload_json, y):
return {
"id": f"sim_{pid}_{name}",
"type": "inject",
"z": tab,
"name": name,
"props": [{"p": "topic", "vt": "str"}, {"p": "payload"}],
"topic": topic,
"payload": payload_json,
"payloadType": "json",
"repeat": "", "crontab": "", "once": True, "onceDelay": "1",
"x": 200, "y": y,
"wires": [[rm_id]],
}
flow.append(inj(
"sim-pU", "simulateMeasurement",
json.dumps({"type": "pressure", "position": "upstream", "value": 0, "unit": "mbar"}),
base_y + 160,
))
flow.append(inj(
"sim-pD", "simulateMeasurement",
json.dumps({"type": "pressure", "position": "downstream", "value": pressure, "unit": "mbar"}),
base_y + 200,
))
# Setpoint injects (20/40/60/80)
for k, val in enumerate([20, 40, 60, 80]):
flow.append({
"id": f"mv_{pid}_{val}",
"type": "inject",
"z": tab,
"name": f"Set {val}%",
"props": [{"p": "topic", "vt": "str"}, {"p": "payload"}],
"topic": "execMovement",
"payload": json.dumps({"source": "GUI", "action": "execMovement", "setpoint": val}),
"payloadType": "json",
"repeat": "", "crontab": "", "once": False, "onceDelay": "",
"x": 200, "y": base_y + 240 + k * 40,
"wires": [[rm_id]],
})
return flow
def main():
global start
start = time.time()
threading.Thread(target=ws_thread, daemon=True).start()
ready.wait(5)
results_by_pump = {p["id"]: {"model": p["model"], "envelope": curve_envelope(p["model"]), "sweeps": []} for p in PUMPS}
# Per-pump pressure plan: each pump sees only pressures inside its own
# curve envelope. Out-of-range extrapolation is a known limitation
# (see rm memory / known-issues) and is tested separately below.
pressure_plan = []
seen = set()
for p in PUMPS:
env = results_by_pump[p["id"]]["envelope"]
for label, val in (("low", env["p_low"]), ("mid", env["p_mid"]), ("high", env["p_high"])):
key = (p["id"], val)
if key not in seen:
pressure_plan.append({"pump_id": p["id"], "pressure": val, "label": label})
seen.add(key)
# Group by pressure so both pumps share a sweep when pressures overlap.
pressures = sorted({row["pressure"] for row in pressure_plan})
pump_allowed_at = {p: [row["pump_id"] for row in pressure_plan if row["pressure"] == p] for p in pressures}
for pressure in pressures:
allowed = pump_allowed_at[pressure]
flow = build_sweep_flow(pressure)
print(f"\n=== Deploying sweep at pressure={pressure} mbar (pumps in range: {allowed}) ===")
with lock:
events.clear()
deploy(flow)
# allow pumps to register and reach operational
time.sleep(4)
# startup both pumps
for pump in PUMPS:
pid = pump["id"]
inject(f"inj_{pid}_setMode-virtual")
time.sleep(0.2)
inject(f"inj_{pid}_Startup")
time.sleep(3) # reach operational (startup=0, warmup=0 -> immediate)
# pressure injects were set to once=True so they fire on deploy. Wait.
time.sleep(2)
for val in [20, 40, 60, 80]:
for pump in PUMPS:
if pump["id"] not in allowed:
continue
inject(f"mv_{pump['id']}_{val}")
# ramp takes (val)/(speed=50) = val/50 s; plus a safety tick
time.sleep(max(2.5, val / 50 + 1.5))
for pump in PUMPS:
if pump["id"] not in allowed:
continue
pid = pump["id"]
data = port0(pid)
if not data:
continue
entry = {
"pressure": pressure,
"setpoint": val,
"state": data.get("state"),
"ctrl": data.get("ctrl"),
"flow": data.get("flow"),
"power": data.get("power"),
"NCog": data.get("NCog"),
"cog": data.get("cog"),
}
results_by_pump[pump["id"]]["sweeps"].append(entry)
print(f" [{pump['id']}] p={pressure} setpoint={val} ctrl={entry['ctrl']} flow={entry['flow']} power={entry['power']} NCog={entry['NCog']}")
# Envelope sanity check
print("\n======== SUMMARY ========")
out = {}
for pid, info in results_by_pump.items():
env = info["envelope"]
good = 0; bad = 0; notes = []
prior_flow_by_p = {}
for row in info["sweeps"]:
if row["flow"] is None or row["power"] is None:
bad += 1; continue
if row["flow"] < -1:
bad += 1; notes.append(f"negative flow: {row}")
elif row["power"] < -1:
bad += 1; notes.append(f"negative power: {row}")
elif row["flow"] > env["flow_range"][1] * 2:
bad += 1; notes.append(f"flow above envelope {env['flow_range'][1]}: {row}")
else:
good += 1
# monotonicity in ctrl at fixed pressure
by_p = {}
for row in info["sweeps"]:
by_p.setdefault(row["pressure"], []).append(row)
mono_ok = True
for p, rows in by_p.items():
rows.sort(key=lambda r: r["setpoint"])
flows = [r["flow"] for r in rows if r["flow"] is not None]
for i in range(1, len(flows)):
if flows[i] < flows[i-1] * 0.95:
mono_ok = False
notes.append(f"flow drops at p={p}: {flows}")
break
print(f"\n[{pid}] model={info['model']}")
print(f" envelope flow {env['flow_range']} power {env['power_range']} pressures {env['p_low']}..{env['p_high']} mbar")
print(f" sweep samples: good={good} bad={bad}")
print(f" ctrl-monotonic: {mono_ok}")
if notes:
print(f" notes: {notes[:3]}")
out[pid] = {
"model": info["model"],
"envelope": env,
"samples": info["sweeps"],
"good": good, "bad": bad, "mono_ok": mono_ok,
}
json.dump(out, open("/tmp/rm_curve_bench.json", "w"), indent=2, default=str)
print("\nfull results -> /tmp/rm_curve_bench.json")
if __name__ == "__main__":
main()

View File

@@ -3,7 +3,38 @@ const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass'); const Machine = require('../../src/specificClass');
const NodeClass = require('../../src/nodeClass'); const NodeClass = require('../../src/nodeClass');
const { makeMachineConfig, makeStateConfig, makeNodeStub } = require('../helpers/factories'); const { makeMachineConfig, makeStateConfig, makeNodeStub, makeREDStub } = require('../helpers/factories');
function makeUiConfig(overrides = {}) {
// Post-AssetResolver: editor saves only model + unit + uuid/tagCode.
return {
unit: 'm3/h', enableLog: false, logLevel: 'error',
model: 'hidrostal-H05K-S03R',
curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
curvePowerUnit: 'kW', curveControlUnit: '%',
positionVsParent: 'atEquipment',
speed: 1, movementMode: 'staticspeed',
startup: 0, warmup: 0, shutdown: 0, cooldown: 0,
...overrides,
};
}
// Adapters park a periodic status-poll timer. Drive the BaseNodeAdapter
// close handler after each test to stop it — the public teardown path
// used by Node-RED itself on flow shutdown.
const _adapters = [];
function buildAdapter(ui = makeUiConfig()) {
const node = makeNodeStub();
const inst = new NodeClass(ui, makeREDStub(), node, 'rotatingMachine');
_adapters.push(node);
return { inst, node };
}
test.afterEach(() => {
while (_adapters.length) {
const node = _adapters.pop();
try { node._handlers.close?.(() => {}); } catch (_) { /* best effort */ }
}
});
test('setpoint rejects negative inputs without throwing', async () => { test('setpoint rejects negative inputs without throwing', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } })); const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
@@ -34,22 +65,19 @@ test('setpoint is constrained to safe movement/curve bounds', async () => {
assert.equal(requested[1], max); assert.equal(requested[1], max);
}); });
test('nodeClass _updateNodeStatus returns error status on internal failure', () => { test('source.getStatusBadge returns error status on internal failure', () => {
const inst = Object.create(NodeClass.prototype); // Build the full adapter, then force the source's state.getCurrentState
const node = makeNodeStub(); // to throw — the public getStatusBadge() must catch and return an
inst.node = node; // error badge without propagating.
inst.source = { const { inst } = buildAdapter();
currentMode: 'auto', const errors = [];
state: { inst.source.logger.error = (m) => errors.push(m);
getCurrentState() { inst.source.state.getCurrentState = () => { throw new Error('boom'); };
throw new Error('boom');
},
},
};
const status = inst._updateNodeStatus(); const status = inst.source.getStatusBadge();
assert.equal(status.text, 'Status Error'); assert.match(status.text, /Status Error/);
assert.equal(node._errors.length, 1); assert.equal(status.fill, 'red');
assert.equal(errors.length, 1);
}); });
test('measurement handlers reject incompatible units', () => { test('measurement handlers reject incompatible units', () => {

View File

@@ -0,0 +1,63 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig, makeChildMeasurement } = require('../helpers/factories');
test('childMeasurementListeners are cleared and state emitter cleaned on simulated close', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
// Register a child measurement — this adds listeners
const child = makeChildMeasurement({ id: 'pt-1', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
machine.registerChild(child, 'measurement');
assert.ok(machine.childMeasurementListeners.size > 0, 'Should have listeners after registration');
const stateEmitterListenerCount = machine.state.emitter.listenerCount('positionChange') +
machine.state.emitter.listenerCount('stateChange');
assert.ok(stateEmitterListenerCount > 0, 'State emitter should have listeners');
// Simulate the cleanup that nodeClass close handler does
for (const [, entry] of machine.childMeasurementListeners) {
if (typeof entry.emitter?.off === 'function') {
entry.emitter.off(entry.eventName, entry.handler);
} else if (typeof entry.emitter?.removeListener === 'function') {
entry.emitter.removeListener(entry.eventName, entry.handler);
}
}
machine.childMeasurementListeners.clear();
machine.state.emitter.removeAllListeners();
assert.equal(machine.childMeasurementListeners.size, 0, 'Listeners map should be empty after cleanup');
assert.equal(machine.state.emitter.listenerCount('positionChange'), 0);
assert.equal(machine.state.emitter.listenerCount('stateChange'), 0);
});
test('re-registration does not accumulate listeners', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
const child = makeChildMeasurement({ id: 'pt-1', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
// Register 3 times
machine.registerChild(child, 'measurement');
machine.registerChild(child, 'measurement');
machine.registerChild(child, 'measurement');
// Should only have 1 listener entry per child+event combo
const eventName = 'pressure.measured.downstream';
const listenerCount = child.measurements.emitter.listenerCount(eventName);
assert.equal(listenerCount, 1, `Should have exactly 1 listener, got ${listenerCount}`);
});
test('virtual pressure children have their listeners managed', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
// Virtual children are created in constructor — verify listeners exist
const upstreamChild = machine.virtualPressureChildren.upstream;
const downstreamChild = machine.virtualPressureChildren.downstream;
assert.ok(upstreamChild, 'Upstream virtual child should exist');
assert.ok(downstreamChild, 'Downstream virtual child should exist');
assert.ok(upstreamChild.measurements, 'Upstream should have measurements container');
assert.ok(downstreamChild.measurements, 'Downstream should have measurements container');
});

View File

@@ -0,0 +1,132 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
test('calcEfficiency with zero power and flow does not produce efficiency value', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.measurements.type('pressure').variant('measured').position('downstream').value(1000, Date.now(), 'mbar');
machine.measurements.type('pressure').variant('measured').position('upstream').value(800, Date.now(), 'mbar');
machine.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), 'm3/h');
machine.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), 'kW');
// Should not throw
assert.doesNotThrow(() => machine.calcEfficiency(0, 0, 'predicted'));
});
test('calcEfficiency with negative power does not produce corrupt efficiency', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.measurements.type('pressure').variant('measured').position('downstream').value(1000, Date.now(), 'mbar');
machine.measurements.type('pressure').variant('measured').position('upstream').value(800, Date.now(), 'mbar');
machine.measurements.type('flow').variant('predicted').position('atEquipment').value(100, Date.now(), 'm3/h');
machine.measurements.type('power').variant('predicted').position('atEquipment').value(-5, Date.now(), 'kW');
// Should not crash or produce negative efficiency
assert.doesNotThrow(() => machine.calcEfficiency(-5, 100, 'predicted'));
const eff = machine.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue();
// Efficiency should not have been updated with negative power (guard: power > 0)
assert.ok(eff === undefined || eff === null || eff >= 0, 'Efficiency should not be negative');
});
test('calcCog returns safe defaults when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const result = machine.calcCog();
assert.equal(result.cog, 0);
assert.equal(result.cogIndex, 0);
assert.equal(result.NCog, 0);
assert.equal(result.minEfficiency, 0);
});
test('getCurrentCurves returns empty arrays when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const { powerCurve, flowCurve } = machine.getCurrentCurves();
assert.deepEqual(powerCurve, { x: [], y: [] });
assert.deepEqual(flowCurve, { x: [], y: [] });
});
test('getCompleteCurve returns null when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const { powerCurve, flowCurve } = machine.getCompleteCurve();
assert.equal(powerCurve, null);
assert.equal(flowCurve, null);
});
test('calcFlow returns 0 when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig({ state: { current: 'operational' } })
);
const flow = machine.calcFlow(50);
assert.equal(flow, 0);
});
test('calcPower returns 0 when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig({ state: { current: 'operational' } })
);
const power = machine.calcPower(50);
assert.equal(power, 0);
});
test('inputFlowCalcPower returns 0 when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig({ state: { current: 'operational' } })
);
const power = machine.inputFlowCalcPower(100);
assert.equal(power, 0);
});
test('getMeasuredPressure returns 0 when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const pressure = machine.getMeasuredPressure();
assert.equal(pressure, 0);
});
test('updateCurve bootstraps predictors when they were null', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
assert.equal(machine.hasCurve, false);
assert.equal(machine.predictFlow, null);
// Load a real curve into a machine that started without one
const { loadCurve } = require('generalFunctions');
const realCurve = loadCurve('hidrostal-H05K-S03R');
assert.doesNotThrow(() => machine.updateCurve(realCurve));
assert.equal(machine.hasCurve, true);
assert.ok(machine.predictFlow !== null);
assert.ok(machine.predictPower !== null);
assert.ok(machine.predictCtrl !== null);
});

View File

@@ -4,184 +4,206 @@ const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const NodeClass = require('../../src/nodeClass');
const { makeNodeStub, makeREDStub } = require('../helpers/factories'); const { makeNodeStub, makeREDStub } = require('../helpers/factories');
test('input handler routes topics to source methods', () => { // Drive routing through the public BaseNodeAdapter surface only. We
const inst = Object.create(NodeClass.prototype); // construct a full nodeClass instance and invoke the input handler
const node = makeNodeStub(); // installed by the base on `node.on('input', ...)`. Side-effects are
// observed via `node._sent`, the registered child registry on the
// source, and instrumented domain methods.
function makeUiConfig(overrides = {}) {
// Post-AssetResolver: editor saves only model + unit + uuid/tagCode.
// supplier/category/assetType are derived at runtime.
return {
unit: 'm3/h',
enableLog: false,
logLevel: 'error',
model: 'hidrostal-H05K-S03R',
curvePressureUnit: 'mbar',
curveFlowUnit: 'm3/h',
curvePowerUnit: 'kW',
curveControlUnit: '%',
positionVsParent: 'atEquipment',
speed: 1,
movementMode: 'staticspeed',
startup: 0,
warmup: 0,
shutdown: 0,
cooldown: 0,
...overrides,
};
}
// Adapters built in these tests park a periodic status-poll timer. We
// drive the BaseNodeAdapter close handler after each test so the timer
// stops and node:test exits cleanly — this is the public teardown path
// Node-RED itself uses on flow shutdown.
const _adapters = [];
function buildAdapter({ ui = makeUiConfig(), redNodes = {} } = {}) {
const node = makeNodeStub();
const RED = makeREDStub(redNodes);
const inst = new NodeClass(ui, RED, node, 'rotatingMachine');
_adapters.push(node);
return { inst, node, RED };
}
test.afterEach(() => {
while (_adapters.length) {
const node = _adapters.pop();
try { node._handlers.close?.(() => {}); } catch (_) { /* best effort */ }
}
});
// Capture every call to source.handleInput so the test can assert which
// canonical action the dispatch produced.
function instrumentHandleInput(source) {
const calls = []; const calls = [];
inst.node = node; const orig = source.handleInput.bind(source);
inst.RED = makeREDStub({ source.handleInput = async (...args) => {
child1: { calls.push(args);
source: { id: 'child-source' }, return orig(...args);
}, };
return calls;
}
async function fireInput(node, msg) {
await node._handlers.input(msg, (out) => node._sent.push(out), () => {});
}
test('set.mode (and legacy setMode alias) flips the source mode', async () => {
const { inst, node } = buildAdapter();
const startingMode = inst.source.currentMode;
await fireInput(node, { topic: 'set.mode', payload: 'virtualControl' });
assert.equal(inst.source.currentMode, 'virtualControl');
assert.notEqual(inst.source.currentMode, startingMode);
// Legacy alias still works (emits a one-time deprecation warning).
await fireInput(node, { topic: 'setMode', payload: 'auto' });
assert.equal(inst.source.currentMode, 'auto');
});
test('cmd.startup / execSequence / flowMovement / emergencystop all reach handleInput with the right action', async () => {
const { inst, node } = buildAdapter();
const calls = instrumentHandleInput(inst.source);
await fireInput(node, { topic: 'cmd.startup', payload: { source: 'GUI' } });
await fireInput(node, { topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } });
await fireInput(node, { topic: 'set.flow-setpoint', payload: { source: 'GUI', setpoint: 123 } });
await fireInput(node, { topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 99 } });
await fireInput(node, { topic: 'cmd.estop', payload: { source: 'GUI' } });
await fireInput(node, { topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } });
// Each call is [source, action, parameter?]. estop calls handleInput
// with only two args; the rest pass a third.
assert.equal(calls.length, 6);
assert.deepEqual(calls[0], ['GUI', 'execSequence', 'startup']);
assert.deepEqual(calls[1], ['GUI', 'execSequence', 'startup']);
assert.deepEqual(calls[2], ['GUI', 'flowMovement', 123]);
assert.deepEqual(calls[3], ['GUI', 'flowMovement', 99]);
assert.deepEqual(calls[4], ['GUI', 'emergencystop']);
assert.deepEqual(calls[5], ['GUI', 'emergencystop']);
});
test('child.register / registerChild resolves the sibling node and registers it', async () => {
// The handler reads child via RED.nodes.getNode(payload).source; we
// pre-seed RED's lookup with a domain stub that owns a .source.
const fakeChildSource = { config: { functionality: { positionVsParent: 'downstream' } } };
const { inst, node } = buildAdapter({
redNodes: { 'child-1': { source: fakeChildSource } },
});
const regCalls = [];
inst.source.childRegistrationUtils.registerChild = (childSource, pos) => {
regCalls.push([childSource, pos]);
};
await fireInput(node, { topic: 'child.register', payload: 'child-1', positionVsParent: 'downstream' });
assert.equal(regCalls.length, 1);
assert.equal(regCalls[0][0], fakeChildSource);
assert.equal(regCalls[0][1], 'downstream');
// Missing child is a no-op (no throw, just a warn).
await fireInput(node, { topic: 'child.register', payload: 'no-such-id', positionVsParent: 'upstream' });
assert.equal(regCalls.length, 1);
});
test('data.simulate-measurement validates payload and rejects invalid combinations', async () => {
const { inst, node } = buildAdapter();
const warns = [];
inst.source.logger.warn = (m) => warns.push(String(m));
const dispatched = [];
inst.source.updateSimulatedMeasurement = (type, pos, val) => dispatched.push(['sim', type, pos, val]);
inst.source.updateMeasuredPower = (val, pos) => dispatched.push(['power', val, pos]);
// 1. non-numeric value
await fireInput(node, { topic: 'data.simulate-measurement', payload: { type: 'pressure', position: 'upstream', value: 'NaN-string', unit: 'mbar' } });
// 2. missing unit
await fireInput(node, { topic: 'data.simulate-measurement', payload: { type: 'flow', position: 'upstream', value: 12 } });
// 3. unsupported type
await fireInput(node, { topic: 'data.simulate-measurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } });
assert.equal(dispatched.length, 0);
const payloadWarns = warns.filter((w) => !/deprecated/i.test(w));
assert.equal(payloadWarns.length, 3);
assert.match(payloadWarns[0], /finite number/i);
// simulator validates type before unit, so "unknown" trips first.
assert.ok(payloadWarns.slice(1).some((w) => /unsupported simulatemeasurement type/i.test(w)));
assert.ok(payloadWarns.slice(1).some((w) => /payload\.unit is required/i.test(w)));
});
test('data.simulate-measurement routes valid power to updateMeasuredPower', async () => {
const { inst, node } = buildAdapter();
const dispatched = [];
inst.source.updateMeasuredPower = (val, pos) => dispatched.push([val, pos]);
await fireInput(node, {
topic: 'data.simulate-measurement',
payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' },
}); });
inst.source = { assert.equal(dispatched.length, 1);
childRegistrationUtils: { assert.equal(dispatched[0][0], 7.5);
registerChild(childSource, pos) { assert.equal(dispatched[0][1], 'atEquipment');
calls.push(['registerChild', childSource, pos]);
},
},
setMode(mode) {
calls.push(['setMode', mode]);
},
handleInput(source, action, parameter) {
calls.push(['handleInput', source, action, parameter]);
},
showWorkingCurves() {
return { ok: true };
},
showCoG() {
return { cog: 1 };
},
updateSimulatedMeasurement(type, position, value) {
calls.push(['updateSimulatedMeasurement', type, position, value]);
},
updateMeasuredPressure(value, position) {
calls.push(['updateMeasuredPressure', value, position]);
},
updateMeasuredFlow(value, position) {
calls.push(['updateMeasuredFlow', value, position]);
},
updateMeasuredPower(value, position) {
calls.push(['updateMeasuredPower', value, position]);
},
updateMeasuredTemperature(value, position) {
calls.push(['updateMeasuredTemperature', value, position]);
},
isUnitValidForType() {
return true;
},
};
inst._attachInputHandler();
const onInput = node._handlers.input;
onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } }, () => {}, () => {});
onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
assert.deepEqual(calls[0], ['setMode', 'auto']);
assert.deepEqual(calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
assert.deepEqual(calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
assert.deepEqual(calls[3], ['handleInput', 'GUI', 'emergencystop', undefined]);
assert.deepEqual(calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
assert.deepEqual(calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
assert.deepEqual(calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
}); });
test('simulateMeasurement warns and ignores invalid payloads', () => { test('query.curves / query.cog send a reply on the process output port', async () => {
const inst = Object.create(NodeClass.prototype); const { inst, node } = buildAdapter();
const node = makeNodeStub(); inst.source.showWorkingCurves = () => ({ curve: [1, 2, 3] });
inst.source.showCoG = () => ({ cog: 0.77 });
// Drop earlier non-reply emissions so the assertion has a clean slice.
node._sent.length = 0;
const calls = []; await fireInput(node, { topic: 'query.curves', payload: { request: true } });
inst.node = node; await fireInput(node, { topic: 'query.cog', payload: { request: true } });
inst.RED = makeREDStub();
inst.source = {
childRegistrationUtils: { registerChild() {} },
setMode() {},
handleInput() {},
showWorkingCurves() { return {}; },
showCoG() { return {}; },
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
updateMeasuredPressure() { calls.push('updateMeasuredPressure'); },
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
updateMeasuredPower() { calls.push('updateMeasuredPower'); },
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
};
inst._attachInputHandler(); assert.equal(node._sent.length, 2);
const onInput = node._handlers.input; assert.ok(Array.isArray(node._sent[0]));
assert.equal(node._sent[0].length, 3);
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {}); assert.equal(node._sent[0][0].topic, 'showWorkingCurves');
onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {}); assert.equal(node._sent[0][1], null);
onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {}); assert.equal(node._sent[0][2], null);
assert.deepEqual(node._sent[0][0].payload, { curve: [1, 2, 3] });
assert.equal(calls.length, 0); assert.equal(node._sent[1][0].topic, 'showCoG');
assert.equal(node._warns.length, 3); assert.deepEqual(node._sent[1][0].payload, { cog: 0.77 });
assert.match(String(node._warns[0]), /finite number/i);
assert.match(String(node._warns[1]), /payload\.unit is required/i);
assert.match(String(node._warns[2]), /unsupported simulatemeasurement type/i);
}); });
test('status shows warning when pressure inputs are not initialized', () => { test('status badge: source.getStatusBadge() warns when pressure is not initialized', () => {
const inst = Object.create(NodeClass.prototype); const { inst } = buildAdapter();
const node = makeNodeStub(); // Drive into an operational state that requires pressure initialisation;
// then assert the badge reflects the warning.
inst.node = node; inst.source.state.stateManager.currentState = 'operational';
inst.source = { // Force pressureInit to report uninitialised, regardless of construction.
currentMode: 'virtualControl', inst.source.pressureInit.getStatus = () => ({
state: { initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false,
getCurrentState() { });
return 'operational';
},
getCurrentPosition() {
return 50;
},
},
getPressureInitializationStatus() {
return { initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false };
},
measurements: {
type() {
return {
variant() {
return {
position() {
return { getCurrentValue() { return 0; } };
},
};
},
};
},
},
};
const status = inst._updateNodeStatus();
const statusAgain = inst._updateNodeStatus();
const status = inst.source.getStatusBadge();
assert.equal(status.fill, 'yellow'); assert.equal(status.fill, 'yellow');
assert.equal(status.shape, 'ring'); assert.equal(status.shape, 'ring');
assert.match(status.text, /pressure not initialized/i); assert.match(status.text, /pressure not initialized/i);
assert.equal(statusAgain.fill, 'yellow');
assert.equal(node._warns.length, 1);
assert.match(String(node._warns[0]), /Pressure input is not initialized/i);
}); });
test('showWorkingCurves and CoG route reply messages to process output index', () => { test('unknown topic dispatched to the input handler does not throw', async () => {
const inst = Object.create(NodeClass.prototype); const { node } = buildAdapter();
const node = makeNodeStub(); await assert.doesNotReject(async () => {
inst.node = node; await fireInput(node, { topic: 'totally.unknown.topic', payload: 42 });
inst.RED = makeREDStub(); });
inst.source = {
childRegistrationUtils: { registerChild() {} },
setMode() {},
handleInput() {},
showWorkingCurves() {
return { curve: [1, 2, 3] };
},
showCoG() {
return { cog: 0.77 };
},
};
inst._attachInputHandler();
const onInput = node._handlers.input;
const sent = [];
const send = (out) => sent.push(out);
onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
assert.equal(sent.length, 2);
assert.equal(Array.isArray(sent[0]), true);
assert.equal(sent[0].length, 3);
assert.equal(sent[0][0].topic, 'showWorkingCurves');
assert.equal(sent[0][1], null);
assert.equal(sent[0][2], null);
assert.equal(sent[1][0].topic, 'showCoG');
}); });

View File

@@ -0,0 +1,121 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
test('getOutput contains all required fields in idle state', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
const output = machine.getOutput();
// Core state fields
assert.equal(output.state, 'idle');
assert.ok('runtime' in output);
assert.ok('ctrl' in output);
assert.ok('moveTimeleft' in output);
assert.ok('mode' in output);
assert.ok('maintenanceTime' in output);
// Efficiency fields
assert.ok('cog' in output);
assert.ok('NCog' in output);
assert.ok('NCogPercent' in output);
assert.ok('effDistFromPeak' in output);
assert.ok('effRelDistFromPeak' in output);
// Prediction health fields
assert.ok('predictionQuality' in output);
assert.ok('predictionConfidence' in output);
assert.ok('predictionPressureSource' in output);
assert.ok('predictionFlags' in output);
// Pressure drift fields
assert.ok('pressureDriftLevel' in output);
assert.ok('pressureDriftSource' in output);
assert.ok('pressureDriftFlags' in output);
});
test('getOutput flow drift fields appear after sufficient measured flow samples', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
await machine.handleInput('parent', 'execMovement', 50);
// Provide multiple measured flow samples to trigger valid drift assessment
const baseTime = Date.now();
for (let i = 0; i < 12; i++) {
machine.updateMeasuredFlow(100 + i, 'downstream', {
timestamp: baseTime + (i * 1000),
unit: 'm3/h',
childId: 'flow-sensor',
childName: 'FT-1',
});
}
const output = machine.getOutput();
// Drift fields should appear once enough samples provide a valid assessment
if ('flowNrmse' in output) {
assert.ok(typeof output.flowNrmse === 'number');
assert.ok('flowDriftValid' in output);
}
// At minimum, prediction health fields should always be present
assert.ok('predictionQuality' in output);
assert.ok('predictionConfidence' in output);
});
test('getOutput prediction confidence is 0 in non-operational state', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
const output = machine.getOutput();
assert.equal(output.predictionConfidence, 0);
});
test('getOutput prediction confidence reflects differential pressure', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
// Differential pressure → high confidence
machine.updateMeasuredPressure(800, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
machine.updateMeasuredPressure(1200, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
const output = machine.getOutput();
assert.ok(output.predictionConfidence >= 0.8, `Confidence ${output.predictionConfidence} should be >= 0.8 with differential pressure`);
assert.equal(output.predictionPressureSource, 'differential');
});
test('getOutput values are in configured output units not canonical', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
machine.updatePosition();
const output = machine.getOutput();
// Flow keys should contain values in m3/h (configured), not m3/s (canonical)
// Predicted flow at minimum pressure should be in a reasonable m3/h range, not ~0.003 m3/s
const flowKey = Object.keys(output).find(k => k.startsWith('flow.predicted.downstream'));
if (flowKey) {
const flowVal = output[flowKey];
assert.ok(typeof flowVal === 'number', 'Flow output should be a number');
// m3/h values are typically 0-300, m3/s values are 0-0.08
// If in canonical units it would be very small
if (flowVal > 0) {
assert.ok(flowVal > 0.1, `Flow value ${flowVal} looks like canonical m3/s, should be m3/h`);
}
}
});
test('getOutput NCogPercent is correctly derived from NCog', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
machine.updatePosition();
const output = machine.getOutput();
const expected = Math.round(output.NCog * 100 * 100) / 100;
assert.equal(output.NCogPercent, expected, 'NCogPercent should be NCog * 100, rounded to 2 decimals');
});

View File

@@ -11,10 +11,11 @@ function makeMachineConfig(overrides = {}) {
functionality: { functionality: {
positionVsParent: 'atEquipment', positionVsParent: 'atEquipment',
}, },
// Post-AssetResolver: only model + unit + tagCode/uuid are saved on the
// node. supplier/category/type are derived from the registry. Keeping
// legacy fields in the factory would trip the strict-cutover guard in
// nodeClass.buildDomainConfig.
asset: { asset: {
supplier: 'hidrostal',
category: 'machine',
type: 'pump',
model: 'hidrostal-H05K-S03R', model: 'hidrostal-H05K-S03R',
unit: 'm3/h', unit: 'm3/h',
curveUnits: { curveUnits: {

View File

@@ -0,0 +1,163 @@
// Reproducer: pump's state machine deadlocks in 'accelerating' under
// rapid setpoint retargeting.
//
// The demo flow drives MGC to call `abortActiveMovements` on every
// handleInput. If a movement aborts mid-flight, state.moveTo's catch
// block keeps the FSM in 'accelerating' (avoids a bounce loop). Any
// NEXT setpoint then hits state.moveTo's early-return at the top:
//
// if (this.stateManager.getCurrentState() !== "operational") {
// this.delayedMove = targetPosition;
// return; // ← never moves
// }
//
// `delayedMove` only fires from the SUCCESS branch of an active
// moveTo, which can't run because state is stuck. Result: pump's
// currentPosition freezes; ctrl.predicted keeps updating (set inside
// calcCtrl regardless of whether setpoint actually moves) so the
// dashboard shows non-zero ctrl% but the editor badge stays at 0.
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { POSITIONS } = require('generalFunctions');
const stateConfig = {
general: { logging: { enabled: false, logLevel: 'error' } },
state: { current: 'idle' },
movement: { mode: 'staticspeed', speed: 10, maxSpeed: 100, interval: 50 },
// Match demo's slow ramp.
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
};
function machineConfig() {
return {
general: { id: 'p1', name: 'p1', unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' } },
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 makeMachineOperational() {
const m = new Machine(machineConfig(), stateConfig);
m.updateMeasuredPressure(0, 'upstream',
{ timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: 'up-1' });
m.updateMeasuredPressure(1100, 'downstream',
{ timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: 'dn-1' });
return m;
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
test('parking deadlock: state stuck in accelerating swallows new setpoints', async () => {
// Direct reproducer of state.moveTo's early-return path. Force the
// FSM into 'accelerating' (the post-abort residue), then issue a new
// setpoint. The early-return at state.js:68 saves delayedMove and
// returns; delayedMove never fires because nothing transitions back
// to operational.
const m = makeMachineOperational();
await m.handleInput('parent', 'execsequence', 'startup');
for (let i = 0; i < 50 && m.state.getCurrentState() !== 'operational'; i++) await sleep(20);
assert.equal(m.state.getCurrentState(), 'operational');
// Force state to 'accelerating' (mimic the post-abort residue) by
// poking the underlying stateManager directly. This bypasses the
// race conditions and isolates the early-return branch.
await m.state.stateManager.transitionTo('accelerating');
assert.equal(m.state.getCurrentState(), 'accelerating');
const positionBefore = m.state.getCurrentPosition();
// Issue a fresh setpoint (what MGC's optimalControl would do).
await m.handleInput('parent', 'flowmovement', 200);
await sleep(800); // generous — at speed=10 u/s, 8 units in 0.8s.
const positionAfter = m.state.getCurrentPosition();
const stateFinal = m.state.getCurrentState();
console.log({
positionBefore, positionAfter,
stateFinal,
delayedMove: m.state.delayedMove,
delta: (positionAfter - positionBefore).toFixed(3),
});
assert.ok(positionAfter - positionBefore > 1,
`[BUG] currentPosition stuck at ${positionBefore.toFixed(2)} — moveTo's early-return at state.js:68 swallowed the setpoint. ` +
`delayedMove=${m.state.delayedMove} state=${stateFinal}`);
});
test('chain deadlock: aborted move + new setpoint freezes position (race-condition path)', async () => {
// Deterministic reproducer of the deadlock the user observed live in
// Node-RED. Key invariant being asserted: AFTER a routine abort, a
// subsequent setpoint MUST eventually move the pump toward the new
// target. Today it freezes because state.moveTo's early-return at
// the top stores the target in `delayedMove` but `delayedMove` only
// fires from inside an active moveTo's success branch — and there
// is none, since state stays in 'accelerating'.
const m = makeMachineOperational();
await m.handleInput('parent', 'execsequence', 'startup');
for (let i = 0; i < 50 && m.state.getCurrentState() !== 'operational'; i++) await sleep(20);
assert.equal(m.state.getCurrentState(), 'operational');
// Step 1: kick off a long traversal to position 80. Speed=10, so this
// takes ~8 s. We need it to be reliably in 'accelerating' when we abort.
m.setpoint(80); // not awaited
// movementManager interval is 50ms; wait two ticks so position has
// demonstrably advanced and state is firmly in 'accelerating'.
await sleep(150);
assert.equal(m.state.getCurrentState(), 'accelerating',
`precondition: pump should be accelerating mid-traversal; got ${m.state.getCurrentState()}`);
const positionDuringMove = m.state.getCurrentPosition();
assert.ok(positionDuringMove > 0 && positionDuringMove < 80,
`precondition: pump should be mid-traversal, got ${positionDuringMove}`);
// Step 2: routine abort, exactly what MGC's abortActiveMovements does.
m.abortMovement('routine retarget');
// Wait for the abort signal to propagate through the setInterval.
await sleep(120);
const stateAfterAbort = m.state.getCurrentState();
const positionAfterAbort = m.state.getCurrentPosition();
// Step 3: a fresh setpoint — what MGC's optimalControl issues next.
// Use a target DIFFERENT from current position so the early-return
// `targetPosition === currentPosition` doesn't apply.
await m.handleInput('parent', 'flowmovement', 200); // m³/h → distinct ctrl%
// Give it half a second, plenty of time for movement to advance at
// speed=10 u/s if it actually proceeds.
await sleep(500);
const stateFinal = m.state.getCurrentState();
const positionFinal = m.state.getCurrentPosition();
console.log({
positionDuringMove,
stateAfterAbort, positionAfterAbort,
stateFinal, positionFinal,
delayedMove: m.state?.delayedMove,
delta: (positionFinal - positionAfterAbort).toFixed(3),
});
// The bug: position stays parked exactly where the abort left it.
// Either the FSM is still in 'accelerating' (so moveTo's top-level
// early-return stored the new setpoint in delayedMove and bailed), or
// both — state stuck AND delayedMove holding the new target. After
// the fix, position should advance toward the new setpoint.
assert.ok(positionFinal - positionAfterAbort > 1,
`[BUG] currentPosition frozen at ${positionAfterAbort.toFixed(2)} — moveTo's early-return swallowed the new setpoint, ` +
`delayedMove=${m.state?.delayedMove}, finalState=${stateFinal}`);
});

View File

@@ -0,0 +1,92 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
/**
* Reproduction harness for the dashboard report: after the pressure-router
* fix, the user sees absDistFromPeak=0, NCog=0, efficiency=0, predicted
* atEquipment flow blank, even after the machine is running and pressure
* sliders are being moved.
*
* This test mirrors the actual dashboard interaction:
* 1. start the machine (reach operational at ctrl=0)
* 2. set virtual pressure (dashboard slider equivalent)
* 3. move setpoint to non-zero ctrl
* 4. read the host fields + measurement values
*
* Every value should be non-zero after step 3. If anything is 0 here, the
* failure is reproducible at the unit level and we can patch it directly.
*/
async function makeRunningMachine() {
const cfg = makeMachineConfig({
general: { id: 'rm-bep', name: 'BEP-test', unit: 'm3/h', logging: { enabled: false, logLevel: 'error' } },
asset: {
supplier: 'hidrostal', category: 'pump', type: 'Centrifugal',
model: 'hidrostal-H05K-S03R', unit: 'm3/h',
curveUnits: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' },
},
});
const m = new Machine(cfg, makeStateConfig());
await m.handleInput('parent', 'execSequence', 'startup');
assert.equal(m.state.getCurrentState(), 'operational');
return m;
}
test('after startup + pressure + ctrl move: NCog / efficiency / absDistFromPeak / flow-at-equipment are all non-zero', async () => {
const m = await makeRunningMachine();
// Dashboard slider equivalent — fire as virtual children (this is what
// simulateMeasurement does):
m.updateSimulatedMeasurement('pressure', 'upstream', 200, { unit: 'mbar' });
m.updateSimulatedMeasurement('pressure', 'downstream', 1100, { unit: 'mbar' });
// Move to a non-zero ctrl position.
await m.handleInput('parent', 'execMovement', 50);
// Read every metric the user reports as 0.
const flowDn = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue('m3/h');
const flowAtEq = m.measurements.type('flow').variant('predicted').position('atEquipment').getCurrentValue('m3/h');
const powerAtEq = m.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('kW');
const efficiency = m.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue();
console.log(JSON.stringify({
state: m.state.getCurrentState(),
ctrl: m.state.getCurrentPosition(),
flowDn, flowAtEq, powerAtEq, efficiency,
NCog: m.NCog, cog: m.cog, cogIndex: m.cogIndex,
absDistFromPeak: m.absDistFromPeak, relDistFromPeak: m.relDistFromPeak,
minEfficiency: m.minEfficiency,
}, null, 2));
assert.ok(Number.isFinite(flowDn) && flowDn > 0, `flow downstream should be > 0, got ${flowDn}`);
assert.ok(Number.isFinite(flowAtEq) && flowAtEq > 0, `flow at-equipment should be > 0, got ${flowAtEq}`);
assert.ok(Number.isFinite(powerAtEq) && powerAtEq > 0, `power at-equipment should be > 0, got ${powerAtEq}`);
// Hydraulic efficiency η = (Q·ΔP)/P is a dimensionless 0..1 ratio. For
// a reasonable pump operating point it should be at least a few percent.
assert.ok(Number.isFinite(efficiency) && efficiency > 0.01,
`efficiency should be a meaningful 0..1 ratio (>1%), got ${efficiency}`);
assert.ok(efficiency <= 1.0,
`efficiency must be <= 1 (dimensionless ratio), got ${efficiency}`);
// Peak efficiency (cog) likewise should be a meaningful ratio.
assert.ok(Number.isFinite(m.cog) && m.cog > 0.01 && m.cog <= 1.0,
`cog (peak efficiency) should be a meaningful 0..1 ratio, got ${m.cog}`);
// NCog is the normalized flow at peak — depending on the curve, BEP can
// land at peakIndex=0 (yielding NCog=0). Just require finiteness here.
assert.ok(Number.isFinite(m.NCog) && m.NCog >= 0 && m.NCog <= 1,
`NCog should be finite 0..1, got ${m.NCog}`);
// Distance-from-peak is what the user actually reads. It should be finite
// and at non-BEP positions it should be > 0.
assert.ok(Number.isFinite(m.absDistFromPeak) && m.absDistFromPeak >= 0,
`absDistFromPeak should be finite >= 0, got ${m.absDistFromPeak}`);
assert.ok(Number.isFinite(m.relDistFromPeak) && m.relDistFromPeak >= 0 && m.relDistFromPeak <= 1,
`relDistFromPeak should be finite 0..1, got ${m.relDistFromPeak}`);
// At ctrl=50 the current efficiency must differ from peak (we're off BEP),
// so absDistFromPeak should be non-zero.
assert.ok(m.absDistFromPeak > 0,
`absDistFromPeak must be > 0 when off BEP, got ${m.absDistFromPeak}`);
});

View File

@@ -48,7 +48,12 @@ test('predictions use initialized medium pressure and not the minimum-pressure f
assert.equal(pressureStatus.initialized, true); assert.equal(pressureStatus.initialized, true);
assert.equal(pressureStatus.hasDifferential, true); assert.equal(pressureStatus.hasDifferential, true);
const expectedDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa canonical const rawDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa = 40000
assert.equal(Math.round(machine.predictFlow.fDimension), expectedDiff); // fDimension is clamped to [fValues.min, fValues.max]. The H05K curve's
// minimum pressure slice is 70000 Pa (700 mbar). A 40000 Pa differential
// is below the curve minimum, so it gets clamped to 70000.
const curveMinPressure = 70000;
const expected = Math.max(rawDiff, curveMinPressure);
assert.equal(Math.round(machine.predictFlow.fDimension), expected);
assert.ok(machine.predictFlow.fDimension > 0); assert.ok(machine.predictFlow.fDimension > 0);
}); });

View File

@@ -0,0 +1,180 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
const { loadCurve } = require('generalFunctions');
/**
* Prediction benchmarks across all rotatingMachine curves currently shipped
* with generalFunctions. This guards the curve-backed prediction path against
* regressions in the loader, the reverse-nq inversion, and the pressure
* slicing logic — across machines of very different sizes.
*
* Ranges are derived from the curve data itself (loaded at test time) plus
* physical sanity properties (monotonicity in ctrl, inverse-monotonicity in
* pressure for flow, non-negative power, curve-backed CoG non-zero).
*/
// Curves the node is expected to support. Add new entries here as soon as a
// new curve file lands in generalFunctions/datasets/assetData/curves/.
const PUMP_CURVES = [
{ model: 'hidrostal-H05K-S03R', unit: 'm3/h', pUnit: 'mbar', powUnit: 'kW' },
{ model: 'hidrostal-C5-D03R-SHN1', unit: 'm3/h', pUnit: 'mbar', powUnit: 'kW' },
];
function curveExtents(curveData) {
const pressures = Object.keys(curveData.nq)
.filter((k) => /^-?\d+$/.test(k))
.map(Number)
.sort((a, b) => a - b);
const slice = (set, p) => curveData[set][String(p)];
const lowP = pressures[0];
const midP = pressures[Math.floor(pressures.length / 2)];
const highP = pressures[pressures.length - 1];
const allFlowY = pressures.flatMap((p) => slice('nq', p).y);
const allPowerY = pressures.flatMap((p) => slice('np', p).y);
return {
pressures,
lowP, midP, highP,
flowMin: Math.min(...allFlowY), flowMax: Math.max(...allFlowY),
powerMin: Math.min(...allPowerY), powerMax: Math.max(...allPowerY),
};
}
async function makeRunningMachine({ model, unit }) {
const cfg = makeMachineConfig({
general: { id: `rm-${model}`, name: model, unit, logging: { enabled: false, logLevel: 'error' } },
asset: {
supplier: 'hidrostal', category: 'pump', type: 'Centrifugal', model, unit,
curveUnits: { pressure: 'mbar', flow: unit, power: 'kW', control: '%' },
},
});
const m = new Machine(cfg, makeStateConfig());
await m.handleInput('parent', 'execSequence', 'startup');
assert.equal(m.state.getCurrentState(), 'operational', `${model}: should reach operational`);
return m;
}
for (const curve of PUMP_CURVES) {
const { model, unit, pUnit, powUnit } = curve;
test(`[${model}] curve loads and has both nq and np slices`, () => {
const raw = loadCurve(model);
assert.ok(raw, `loadCurve('${model}') must return data`);
assert.ok(raw.nq && Object.keys(raw.nq).length > 0, `${model}: nq has pressure slices`);
assert.ok(raw.np && Object.keys(raw.np).length > 0, `${model}: np has pressure slices`);
// Same pressure slices in both
const nqP = Object.keys(raw.nq).filter((k) => /^-?\d+$/.test(k)).sort();
const npP = Object.keys(raw.np).filter((k) => /^-?\d+$/.test(k)).sort();
assert.deepEqual(nqP, npP, `${model}: nq and np must share pressure slices`);
});
test(`[${model}] predicted flow and power at mid-pressure, mid-ctrl are finite and in-range`, async () => {
const raw = loadCurve(model);
const ext = curveExtents(raw);
const m = await makeRunningMachine(curve);
// Feed differential pressure = midP (upstream 0, downstream = midP)
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
await m.handleInput('parent', 'execMovement', 50);
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
const power = m.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue(powUnit);
assert.ok(Number.isFinite(flow), `${model}: flow must be finite`);
assert.ok(Number.isFinite(power), `${model}: power must be finite`);
// Flow can be negative at the low-end slice of some curves due to spline extrapolation,
// but at mid-pressure mid-ctrl it must be positive.
assert.ok(flow > 0, `${model}: flow ${flow} ${unit} must be > 0 at mid-pressure mid-ctrl`);
assert.ok(power >= 0, `${model}: power ${power} ${powUnit} must be >= 0`);
// Loose bracket against curve envelope (2x margin accommodates interpolation overshoot)
assert.ok(flow <= ext.flowMax * 2, `${model}: flow ${flow} exceeds curve envelope ${ext.flowMax}`);
assert.ok(power <= ext.powerMax * 2, `${model}: power ${power} exceeds curve envelope ${ext.powerMax}`);
});
test(`[${model}] flow is monotonically non-decreasing in ctrl at fixed pressure`, async () => {
const raw = loadCurve(model);
const ext = curveExtents(raw);
const m = await makeRunningMachine(curve);
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
const samples = [];
for (const setpoint of [10, 30, 50, 70, 90]) {
await m.handleInput('parent', 'execMovement', setpoint);
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
samples.push({ setpoint, flow });
}
for (let i = 1; i < samples.length; i++) {
// Allow 1% tolerance for spline wiggle but reject any clear regression.
assert.ok(
samples[i].flow >= samples[i - 1].flow - Math.abs(samples[i - 1].flow) * 0.01,
`${model}: flow not monotonic across ctrl sweep: ${JSON.stringify(samples)}`,
);
}
});
test(`[${model}] flow decreases (or stays level) when pressure rises at fixed ctrl`, async () => {
const raw = loadCurve(model);
const ext = curveExtents(raw);
const m = await makeRunningMachine(curve);
const samples = [];
for (const p of [ext.lowP, ext.midP, ext.highP]) {
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
m.updateMeasuredPressure(p, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
await m.handleInput('parent', 'execMovement', 60);
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
samples.push({ pressure: p, flow });
}
// Highest pressure must not exceed lowest pressure flow by more than 1%.
// (Centrifugal pump: head up -> flow down at a given speed.)
const first = samples[0].flow;
const last = samples[samples.length - 1].flow;
assert.ok(
last <= first * 1.01,
`${model}: flow at p=${samples[samples.length - 1].pressure} (${last}) exceeds flow at p=${samples[0].pressure} (${first}); samples=${JSON.stringify(samples)}`,
);
});
test(`[${model}] cog and NCog are computed and finite after an operational move`, async () => {
const raw = loadCurve(model);
const ext = curveExtents(raw);
const m = await makeRunningMachine(curve);
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
await m.handleInput('parent', 'execMovement', 50);
assert.ok(Number.isFinite(m.cog), `${model}: cog must be finite, got ${m.cog}`);
assert.ok(Number.isFinite(m.NCog), `${model}: NCog must be finite, got ${m.NCog}`);
// CoG is a controller-% location of peak efficiency; must fall inside the ctrl range of the curve.
assert.ok(m.cog >= 0 && m.cog <= 100, `${model}: cog=${m.cog} must be within [0,100]`);
});
test(`[${model}] reverse predictor (ctrl for requested flow) round-trips within tolerance`, async () => {
const raw = loadCurve(model);
const ext = curveExtents(raw);
const m = await makeRunningMachine(curve);
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
// Move to a known controller position and read the flow.
await m.handleInput('parent', 'execMovement', 60);
const observedFlow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
assert.ok(observedFlow > 0, `${model}: need non-zero flow to invert`);
// Convert flow back to ctrl via calcCtrl (uses reversed nq internally) —
// note calcCtrl takes canonical flow (m3/s), so convert.
const canonicalFlow = observedFlow / 3600; // m3/h -> m3/s
const predictedCtrl = m.calcCtrl(canonicalFlow);
assert.ok(
Number.isFinite(predictedCtrl) && Math.abs(predictedCtrl - 60) <= 10,
`${model}: reverse predictor ctrl=${predictedCtrl} should be within 10 of 60 for flow=${observedFlow}`,
);
});
}

View File

@@ -0,0 +1,150 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
function makePressurizedOperationalMachine() {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.updateMeasuredPressure(800, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
machine.updateMeasuredPressure(1200, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
return machine;
}
test('calcCog returns valid peak efficiency and index', () => {
const machine = makePressurizedOperationalMachine();
const result = machine.calcCog();
assert.ok(Number.isFinite(result.cog), 'cog should be finite');
assert.ok(result.cog > 0, 'peak efficiency should be positive');
assert.ok(Number.isFinite(result.cogIndex), 'cogIndex should be finite');
assert.ok(result.cogIndex >= 0, 'cogIndex should be non-negative');
assert.ok(Number.isFinite(result.NCog), 'NCog should be finite');
assert.ok(result.NCog >= 0 && result.NCog <= 1, 'NCog should be between 0 and 1');
assert.ok(Number.isFinite(result.minEfficiency), 'minEfficiency should be finite');
assert.ok(result.minEfficiency >= 0, 'minEfficiency should be non-negative');
});
test('calcCog peak is always >= minEfficiency', () => {
const machine = makePressurizedOperationalMachine();
const result = machine.calcCog();
assert.ok(result.cog >= result.minEfficiency, 'Peak must be >= min');
});
test('calcEfficiencyCurve produces hydraulic efficiency η = (Q·ΔP)/P at every point', () => {
const machine = makePressurizedOperationalMachine();
const { powerCurve, flowCurve } = machine.getCurrentCurves();
const dP = machine.predictFlow.currentF; // canonical Pa
const { efficiencyCurve, peak, peakIndex, minEfficiency } = machine.calcEfficiencyCurve(powerCurve, flowCurve, dP);
assert.ok(efficiencyCurve.length > 0, 'Efficiency curve should not be empty');
assert.equal(efficiencyCurve.length, powerCurve.y.length, 'Should match curve length');
// η = (Q·ΔP)/P. flow and power are in canonical SI (m³/s and W), so η is
// a dimensionless 0..1 ratio. dP is the pressure differential the slice
// represents (host.predictFlow.currentF).
for (let i = 0; i < efficiencyCurve.length; i++) {
const power = powerCurve.y[i];
const flow = flowCurve.y[i];
if (power > 0 && flow >= 0 && dP > 0) {
const expected = (flow * dP) / power;
assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${i}: got ${efficiencyCurve[i]}, expected ${expected}`);
}
}
// Peak should be the max
const actualMax = Math.max(...efficiencyCurve);
assert.equal(peak, actualMax, 'Peak should match max of efficiency curve');
assert.equal(efficiencyCurve[peakIndex], peak, 'peakIndex should point to peak value');
assert.equal(minEfficiency, Math.min(...efficiencyCurve), 'minEfficiency should match min');
});
test('calcEfficiencyCurve handles empty curves gracefully', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
const result = machine.calcEfficiencyCurve({ x: [], y: [] }, { x: [], y: [] });
assert.deepEqual(result.efficiencyCurve, []);
assert.equal(result.peak, 0);
assert.equal(result.peakIndex, 0);
assert.equal(result.minEfficiency, 0);
});
test('calcDistanceBEP returns absolute and relative distances', () => {
const machine = makePressurizedOperationalMachine();
const efficiency = 5;
const maxEfficiency = 10;
const minEfficiency = 2;
const result = machine.calcDistanceBEP(efficiency, maxEfficiency, minEfficiency);
assert.ok(Number.isFinite(result.absDistFromPeak), 'abs distance should be finite');
assert.equal(result.absDistFromPeak, Math.abs(efficiency - maxEfficiency));
assert.ok(Number.isFinite(result.relDistFromPeak), 'rel distance should be finite');
});
test('calcRelativeDistanceFromPeak returns 1 when maxEfficiency equals minEfficiency', () => {
const machine = makePressurizedOperationalMachine();
const result = machine.calcRelativeDistanceFromPeak(5, 5, 5);
assert.equal(result, 1, 'Should return default distance when max==min (division by zero guard)');
});
test('showCoG returns structured data with curve guards', () => {
const machine = makePressurizedOperationalMachine();
const result = machine.showCoG();
assert.ok('cog' in result);
assert.ok('cogIndex' in result);
assert.ok('NCog' in result);
assert.ok('NCogPercent' in result);
assert.ok('minEfficiency' in result);
assert.ok('currentEfficiencyCurve' in result);
assert.ok(result.cog > 0);
assert.equal(result.NCogPercent, Math.round(result.NCog * 100 * 100) / 100);
});
test('showCoG returns safe fallback when no curve is available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const result = machine.showCoG();
assert.equal(result.cog, 0);
assert.ok('error' in result);
});
test('showWorkingCurves returns safe fallback when no curve is available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const result = machine.showWorkingCurves();
assert.ok('error' in result);
});
test('efficiency output fields are present in getOutput', () => {
const machine = makePressurizedOperationalMachine();
// Move to a position so predictions produce values
machine.state.transitionToState('operational');
machine.updatePosition();
const output = machine.getOutput();
assert.ok('cog' in output);
assert.ok('NCog' in output);
assert.ok('NCogPercent' in output);
assert.ok('effDistFromPeak' in output);
assert.ok('effRelDistFromPeak' in output);
assert.ok('predictionQuality' in output);
assert.ok('predictionConfidence' in output);
assert.ok('predictionPressureSource' in output);
});

View File

@@ -0,0 +1,59 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
test('emergencystop sequence reaches off state from operational', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
// First start the machine
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
// Execute emergency stop
await machine.handleInput('GUI', 'emergencystop');
assert.equal(machine.state.getCurrentState(), 'off');
});
test('emergencystop sequence reaches off state from idle', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
assert.equal(machine.state.getCurrentState(), 'idle');
await machine.handleInput('GUI', 'emergencystop');
assert.equal(machine.state.getCurrentState(), 'off');
});
test('emergencystop clears predicted flow and power to zero', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
// Start and set a position so predictions are non-zero
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
await machine.handleInput('parent', 'execMovement', 50);
const flowBefore = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
assert.ok(flowBefore > 0, 'Flow should be positive before emergency stop');
// Emergency stop
await machine.handleInput('GUI', 'emergencystop');
const flowAfter = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
const powerAfter = machine.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue();
assert.equal(flowAfter, 0, 'Flow should be zero after emergency stop');
assert.equal(powerAfter, 0, 'Power should be zero after emergency stop');
});
test('emergencystop is rejected when source is not allowed in current mode', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
// In auto mode, only 'parent' source is typically allowed for sequences
machine.setMode('auto');
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
// GUI source attempting emergency stop in auto mode — should still work
// because emergencystop is allowed from all sources in config
await machine.handleInput('GUI', 'emergencystop');
// If we get here without throwing, action was either accepted or safely rejected
});

View File

@@ -0,0 +1,93 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
/**
* Regression tests for the FSM interruptible-movement fix (2026-04-13).
*
* Before the fix, `executeSequence("shutdown")` was silently rejected by the
* state manager if the machine was mid-move (accelerating/decelerating),
* because allowedTransitions for those states only permits returning to
* `operational` or `emergencystop`. Operators pressing Stop during a ramp
* would see the transition error-logged but no actual stop.
*
* The fix aborts the active movement, waits for the FSM to return to
* `operational`, then runs the normal shutdown / emergency-stop sequence.
*/
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function makeSlowMoveMachine() {
// Slow movement so the test can reliably interrupt during accelerating.
// speed=20%/s, interval=10ms -> 80% setpoint takes ~4s of real movement.
return new Machine(
makeMachineConfig(),
makeStateConfig({
movement: { mode: 'staticspeed', speed: 20, maxSpeed: 1000, interval: 10 },
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
})
);
}
test('shutdown during accelerating aborts the move and reaches idle', async () => {
const machine = makeSlowMoveMachine();
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
machine.updateMeasuredPressure(200, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
// Fire a setpoint that needs ~4 seconds. Do NOT await it.
const movePromise = machine.handleInput('parent', 'execMovement', 80);
// Wait a moment for the FSM to enter accelerating.
await sleep(100);
assert.equal(machine.state.getCurrentState(), 'accelerating');
// Issue shutdown while the move is still accelerating.
await machine.handleInput('GUI', 'execSequence', 'shutdown');
// Let the aborted move unwind.
await movePromise.catch(() => {});
assert.equal(
machine.state.getCurrentState(),
'idle',
'shutdown issued mid-ramp must still drive FSM back to idle',
);
});
test('emergency stop during accelerating reaches off', async () => {
const machine = makeSlowMoveMachine();
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
const movePromise = machine.handleInput('parent', 'execMovement', 80);
await sleep(100);
assert.equal(machine.state.getCurrentState(), 'accelerating');
await machine.handleInput('GUI', 'emergencystop');
await movePromise.catch(() => {});
assert.equal(
machine.state.getCurrentState(),
'off',
'emergency stop issued mid-ramp must still drive FSM to off',
);
});
test('executeSequence accepts mixed-case sequence names', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
// Parent orchestrators (e.g. machineGroupControl) use "emergencyStop" with
// a capital S in their configs. The sequence key in rotatingMachine.json
// is lowercase. Normalization must bridge that gap without a warn.
await machine.executeSequence('EmergencyStop');
assert.equal(machine.state.getCurrentState(), 'off');
});

View File

@@ -0,0 +1,75 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
test('movement from 0 to 50% updates position and predictions', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
await machine.handleInput('parent', 'execMovement', 50);
const pos = machine.state.getCurrentPosition();
const { min, max } = machine._resolveSetpointBounds();
// Position should be constrained to bounds
assert.ok(pos >= min && pos <= max, `Position ${pos} should be within [${min}, ${max}]`);
const flow = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
assert.ok(flow > 0, 'Predicted flow should be positive at non-zero position');
});
test('flowmovement sets position based on flow setpoint', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
// Request 100 m3/h flow — the machine should calculate the control position
await machine.handleInput('parent', 'flowMovement', 100);
const pos = machine.state.getCurrentPosition();
assert.ok(pos > 0, 'Position should be non-zero for a non-zero flow setpoint');
});
test('sequential movements update position correctly', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
await machine.handleInput('parent', 'execMovement', 30);
const pos30 = machine.state.getCurrentPosition();
await machine.handleInput('parent', 'execMovement', 60);
const pos60 = machine.state.getCurrentPosition();
assert.ok(pos60 > pos30, 'Position at 60 should be greater than at 30');
});
test('movement to 0 sets flow and power predictions to minimum curve values', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
await machine.handleInput('parent', 'execMovement', 0);
const pos = machine.state.getCurrentPosition();
assert.equal(pos, 0, 'Position should be at 0');
});
test('movement is rejected in non-operational state', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
assert.equal(machine.state.getCurrentState(), 'idle');
// Attempt movement in idle state — handleInput should process but no movement happens
await machine.handleInput('parent', 'execMovement', 50);
// Machine should still be idle (movement requires operational state via sequence first)
assert.equal(machine.state.getCurrentState(), 'idle');
});

View File

@@ -14,7 +14,10 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(status.source, null); assert.equal(status.source, null);
const noPressureValue = machine.getMeasuredPressure(); const noPressureValue = machine.getMeasuredPressure();
assert.equal(noPressureValue, 0); assert.equal(noPressureValue, 0);
assert.ok(machine.predictFlow.fDimension <= 1); // With no pressure injected, fDimension is clamped to the curve minimum
// (70000 Pa for H05K). Previously a schema default at pressure "1" made
// fValues.min=1 — that was a data-poisoning bug, now fixed.
assert.ok(machine.predictFlow.fDimension >= 70000);
// upstream only // upstream only
machine = createMachine(); machine = createMachine();
@@ -44,9 +47,11 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(Math.round(downstreamValue), downstreamOnly * 100); assert.equal(Math.round(downstreamValue), downstreamOnly * 100);
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100); assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100);
// downstream and upstream // downstream and upstream — pick values whose differential (Pa) is above
// the curve's minimum pressure slice (70000 Pa = 700 mbar for H05K).
// 200 mbar upstream + 1100 mbar downstream → diff = 900 mbar = 90000 Pa.
machine = createMachine(); machine = createMachine();
const upstream = 700; const upstream = 200;
const downstream = 1100; const downstream = 1100;
machine.measurements.type('pressure').variant('measured').position('upstream').value(upstream, Date.now(), 'mbar'); machine.measurements.type('pressure').variant('measured').position('upstream').value(upstream, Date.now(), 'mbar');
machine.measurements.type('pressure').variant('measured').position('downstream').value(downstream, Date.now(), 'mbar'); machine.measurements.type('pressure').variant('measured').position('downstream').value(downstream, Date.now(), 'mbar');

View File

@@ -0,0 +1,76 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { buildQHCurve } = require('../../src/display/workingCurves');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
async function makeRunningMachine() {
const cfg = makeMachineConfig({
general: { id: 'rm-qh', name: 'qh-test', unit: 'm3/h', logging: { enabled: false, logLevel: 'error' } },
asset: {
supplier: 'hidrostal', category: 'pump', type: 'Centrifugal',
model: 'hidrostal-H05K-S03R', unit: 'm3/h',
curveUnits: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' },
},
});
const m = new Machine(cfg, makeStateConfig());
await m.handleInput('parent', 'execSequence', 'startup');
m.updateMeasuredPressure(0, 'upstream', { unit: 'mbar', timestamp: Date.now(), childName: 'pt-up' });
m.updateMeasuredPressure(1500, 'downstream', { unit: 'mbar', timestamp: Date.now(), childName: 'pt-down' });
await m.handleInput('parent', 'execMovement', 60);
return m;
}
test('buildQHCurve returns one (Q, H) point per pressure slice in envelope', async () => {
const m = await makeRunningMachine();
const r = buildQHCurve(m, 60);
assert.ok(!r.error, `should not error, got ${r.error}`);
assert.ok(Array.isArray(r.points) && r.points.length > 0, 'must return points array');
for (const pt of r.points) {
assert.ok(Number.isFinite(pt.Q), `Q must be finite, got ${pt.Q}`);
assert.ok(Number.isFinite(pt.H), `H must be finite, got ${pt.H}`);
assert.ok(pt.Q > 0, `Q must be > 0, got ${pt.Q}`);
assert.ok(pt.H > 0, `H must be > 0, got ${pt.H}`);
}
// Centrifugal pump: as head rises (higher pressure slice), flow drops.
// Verify monotone non-increasing Q across rising H.
const sortedByH = [...r.points].sort((a, b) => a.H - b.H);
for (let i = 1; i < sortedByH.length; i++) {
assert.ok(
sortedByH[i].Q <= sortedByH[i - 1].Q * 1.01 + 1e-6,
`flow should be non-increasing as head rises: ${JSON.stringify(sortedByH)}`,
);
}
});
test('buildQHCurve does not mutate predictor state', async () => {
const m = await makeRunningMachine();
const beforeF = m.predictFlow.fDimension;
const beforeX = m.predictFlow.currentX;
const beforeOutputY = m.predictFlow.outputY;
buildQHCurve(m, 60);
assert.equal(m.predictFlow.fDimension, beforeF, 'fDimension must be restored');
assert.equal(m.predictFlow.currentX, beforeX, 'currentX must be restored');
assert.ok(
Math.abs(m.predictFlow.outputY - beforeOutputY) < 1e-9,
`outputY must be restored, before=${beforeOutputY} after=${m.predictFlow.outputY}`,
);
});
test('buildQHCurve handles no-curve gracefully', () => {
const r = buildQHCurve({ hasCurve: false }, 50);
assert.ok(r.error, 'must report error');
assert.deepEqual(r.points, []);
});
test('buildQHCurve uses current ctrl when none provided', async () => {
const m = await makeRunningMachine();
const r = buildQHCurve(m);
assert.equal(r.ctrlPct, m.predictFlow.currentX,
`ctrlPct should default to current x, got ${r.ctrlPct} vs ${m.predictFlow.currentX}`);
});

View File

@@ -14,11 +14,16 @@ test('execSequence startup reaches operational with zero transition times', asyn
test('execMovement constrains controller position to safe bounds in operational state', async () => { test('execMovement constrains controller position to safe bounds in operational state', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } })); const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
const { max } = machine._resolveSetpointBounds(); const { min, max } = machine._resolveSetpointBounds();
// Test upper constraint: setpoint above max gets clamped to max
await machine.handleInput('parent', 'execMovement', max + 50);
let pos = machine.state.getCurrentPosition();
assert.equal(pos, max, `setpoint above max should be clamped to ${max}`);
// Test that a valid setpoint within bounds is applied as-is
await machine.handleInput('parent', 'execMovement', 10); await machine.handleInput('parent', 'execMovement', 10);
pos = machine.state.getCurrentPosition();
const pos = machine.state.getCurrentPosition(); assert.equal(pos, 10, 'setpoint within bounds should be applied as-is');
assert.ok(pos <= max); assert.ok(pos >= min && pos <= max);
assert.equal(pos, max);
}); });

View File

@@ -0,0 +1,146 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
test('shutdown sequence from operational reaches idle', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
await machine.handleInput('parent', 'execSequence', 'shutdown');
assert.equal(machine.state.getCurrentState(), 'idle');
});
test('shutdown from operational ramps down position before stopping', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
await machine.handleInput('parent', 'execMovement', 50);
const posBefore = machine.state.getCurrentPosition();
assert.ok(posBefore > 0, 'Machine should be at non-zero position');
await machine.handleInput('parent', 'execSequence', 'shutdown');
const posAfter = machine.state.getCurrentPosition();
assert.ok(posAfter <= posBefore, 'Position should have decreased after shutdown');
assert.equal(machine.state.getCurrentState(), 'idle');
});
test('shutdown clears predicted flow and power', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
await machine.handleInput('parent', 'execMovement', 50);
await machine.handleInput('parent', 'execSequence', 'shutdown');
const flow = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
const power = machine.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue();
assert.equal(flow, 0, 'Flow should be zero after shutdown');
assert.equal(power, 0, 'Power should be zero after shutdown');
});
test('entermaintenance sequence from operational reaches maintenance state', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
await machine.handleInput('parent', 'enterMaintenance', 'entermaintenance');
assert.equal(machine.state.getCurrentState(), 'maintenance');
});
test('exitmaintenance requires mode with exitmaintenance action allowed', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
// Use auto mode (has execsequence + entermaintenance) to reach maintenance
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
await machine.handleInput('parent', 'enterMaintenance', 'entermaintenance');
assert.equal(machine.state.getCurrentState(), 'maintenance');
// Switch to fysicalControl which allows exitmaintenance
machine.setMode('fysicalControl');
await machine.handleInput('fysical', 'exitMaintenance', 'exitmaintenance');
assert.equal(machine.state.getCurrentState(), 'idle');
});
test('shutdown clears delayedMove synchronously, before the abort/await path runs', async () => {
// Regression: when MGC parks a setpoint in state.delayedMove during a
// dead-zone keep-alive, then PS commands shutdown via turnOffAllMachines,
// the shutdown's interruptible-abort path triggers transitionToState
// ('operational'), which auto-picks up delayedMove and re-starts the
// pump. Pump bounces accelerating ↔ decelerating forever and the
// shutdown sequence never reaches idle. Observed live in the
// pumpingstation-complete-example demo: basin drained past stopLevel
// with one pump stuck at minimum flow.
//
// Fix: executeSequence clears state.delayedMove for shutdown/emergencystop
// BEFORE the abort+await path. Asserting synchronously (race the first
// microtask) is the precise behavioural check — without the fix, the
// auto-pickup could still re-engage the pump on the way to idle even if
// the value is null after the call returns.
const slowMove = makeStateConfig({
movement: { mode: 'staticspeed', speed: 50, maxSpeed: 100, interval: 10 },
});
const machine = new Machine(makeMachineConfig(), slowMove);
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
machine.setpoint(80);
await new Promise((r) => setTimeout(r, 50));
assert.equal(machine.state.getCurrentState(), 'accelerating');
machine.state.delayedMove = 75;
// Kick off the shutdown but do not await — capture state before the
// abort path's await yields.
const shutdownPromise = machine.handleInput('parent', 'execSequence', 'shutdown');
// Yield once to allow the synchronous prelude of executeSequence to run
// (lookup, lowercase, the new delayedMove=null assignment) without
// letting any await resolve.
await Promise.resolve();
assert.equal(machine.state.delayedMove, null,
'delayedMove must be cleared synchronously by the shutdown prelude — otherwise the abort path will auto-pick it up');
await shutdownPromise;
assert.equal(machine.state.getCurrentState(), 'idle');
});
test('emergencystop also clears queued delayedMove', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
await machine.handleInput('parent', 'execMovement', 30);
machine.state.delayedMove = 60;
await machine.handleInput('parent', 'execSequence', 'emergencystop');
assert.equal(machine.state.delayedMove, null,
'emergency-stop must clear delayedMove');
});
test('startup does NOT clear delayedMove (only shutdown/emergencystop does)', async () => {
// delayedMove serves a legitimate purpose for non-stop sequences — e.g.
// setpoints arriving while the pump is in 'starting' get queued and
// auto-picked-up when state lands in 'operational'. The fix must be
// narrowly scoped to interruptible (stop) sequences.
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
machine.state.delayedMove = 42;
// Re-running startup from operational is a no-op for state, but the
// delayedMove must still be there afterwards for the auto-pickup to fire.
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.delayedMove, 42,
'non-stop sequences must preserve delayedMove for the auto-pickup');
});

352
wiki/Home.md Normal file
View File

@@ -0,0 +1,352 @@
# rotatingMachine
> **Reflects code as of `1a9f533` · regenerated `2026-05-11` via `npm run wiki:all`**
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
## 1. What this node is
**rotatingMachine** models a single pump, compressor, or blower. It loads a supplier characteristic curve, takes upstream + downstream pressure measurements (or simulated values), predicts the resulting flow + power, drives a startup/shutdown state machine, and assesses prediction drift against measured flow / power. Used as a child of `machineGroupControl` when grouped, or directly under a `pumpingStation`.
## 2. Position in the platform
```mermaid
flowchart LR
parent[machineGroupControl /<br/>pumpingStation]:::unit -->|flowmovement<br/>execsequence| rm[rotatingMachine<br/>Equipment]:::equip
m_up[measurement<br/>pressure upstream]:::ctrl -.data.-> rm
m_dn[measurement<br/>pressure downstream]:::ctrl -.data.-> rm
sim[dashboard-sim<br/>virtual pressure children]:::ctrl -.data.-> rm
rm -->|child.register| parent
rm -.->|flow.predicted.*<br/>power.predicted.atequipment| parent
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
```
S88 colours: Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
## 3. Capability matrix
| Capability | Status | Notes |
|---|---|---|
| Curve-based flow prediction | ✅ | Built from `asset.model` via `curves/curveLoader`. |
| Curve-based power prediction | ✅ | Reverse curve composed inside `buildPredictors`. |
| FSM (startup / shutdown / movement) | ✅ | Shared `state/state.js` from generalFunctions. |
| Interruptible movements | ✅ | `abortMovement` from MGC overrides on new demand. |
| Drift assessment (flow + power) | ✅ | `DriftAssessor` with EWMA + alignment tolerance. |
| Virtual pressure children for sim | ✅ | `dashboard-sim-upstream / -downstream`. |
| Real-pressure child preference | ✅ | `pressureSelector` prefers real over virtual. |
| Group operating-point prediction | ✅ | `setGroupOperatingPoint` for MGC integration. |
| `cmd.estop` hard cut | ✅ | Forces `emergencystop` state. |
| `data.simulate-measurement` injection | ✅ | Pressure / flow / power / temperature. |
| Auto-recovery from prediction loss | ⚠️ | Reverts to null predictors silently — health falls to `invalid`. |
| Multi-parent registration | ⚠️ | Accepted but not exercised in production. |
## 4. Code map
```mermaid
flowchart TB
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
nc["buildDomainConfig()<br/>static DomainClass, commands"]
end
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
sc["Machine.configure()<br/>_setupCurves / _setupState /<br/>_setupDrift / _setupPressure /<br/>_setupChildren"]
end
subgraph concerns["src/ concern modules"]
curves["curves/<br/>loadModelCurve + normalize"]
prediction["prediction/<br/>buildPredictors + math"]
drift["drift/<br/>DriftAssessor + healthRefresh"]
pressure["pressure/<br/>init + router + selector + virtual"]
state["state/<br/>FSM bindings + sequenceController"]
measurement["measurement/<br/>handlers + childRegistrar"]
flow["flow/<br/>flowController (handleInput)"]
display["display/<br/>workingCurves + CoG"]
io["io/<br/>output + status"]
commands["commands/<br/>topic registry + handlers"]
end
nc --> sc
sc --> curves
sc --> prediction
sc --> drift
sc --> pressure
sc --> state
sc --> measurement
sc --> flow
sc --> display
sc --> io
nc --> commands
```
| Module | Owns | Read first if you're changing… |
|---|---|---|
| `curves/` | Supplier curve loader + normaliser + reverse | Curve fitting, unit mismatches, fallback. |
| `prediction/` | Per-machine + group predictors, math helpers | Predicted flow / power values. |
| `drift/` | DriftAssessor (EWMA, alignment), healthRefresh | Prediction quality, flags, confidence. |
| `pressure/` | init + router + selector + virtual children | Pressure plumbing, sim vs real preference. |
| `state/` | FSM bindings + setpoint / sequence orchestration | Startup / shutdown sequences. |
| `measurement/` | Measurement handlers + child registrar | Measured value plumbing per type. |
| `flow/` | `flowController.handle(source, action, parameter)` | Top-level input dispatch. |
| `display/` | `showWorkingCurves`, `showCoG` | `query.curves` / `query.cog` outputs. |
| `io/` | `getOutput`, `getStatusBadge` | Output shape, badge text. |
| `commands/` | Input-topic registry and handlers | New input topics, payload validation. |
## 5. Topic contract
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
<!-- BEGIN AUTOGEN: topic-contract -->
| Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---|---|
| `set.mode` | `setMode` | `string` | — | Switch the machine between auto / manual control modes. |
| `cmd.startup` | _(none)_ | `any` | — | Initiate the machine startup sequence. |
| `cmd.shutdown` | _(none)_ | `any` | — | Initiate the machine shutdown sequence. |
| `cmd.estop` | `emergencystop` | `any` | — | Trigger an emergency stop. |
| `execSequence` | _(none)_ | `object` | — | Legacy umbrella that demuxes payload.action to startup / shutdown. |
| `set.setpoint` | `execMovement` | `object` | — | Move the machine to a control-% setpoint via execMovement. |
| `set.flow-setpoint` | `flowMovement` | `object` | `volumeFlowRate` (default `m3/h`) | Move the machine to a flow setpoint via flowMovement. |
| `data.simulate-measurement` | `simulateMeasurement` | `object` | — | Inject a simulated sensor reading (pressure/flow/temperature/power). |
| `query.curves` | `showWorkingCurves` | `any` | — | Return the working curves for the machine on the reply port. |
| `query.cog` | `CoG` | `any` | — | Return the centre-of-gravity (CoG) point on the reply port. |
| `child.register` | `registerChild` | `string` | — | Register a child measurement with this machine. |
<!-- END AUTOGEN: topic-contract -->
## 6. Child registration
`measurement` children register through `childRegistrationUtils`; the machine subscribes to the matching `<asset.type>.measured.<positionVsParent>` event.
```mermaid
flowchart LR
subgraph kids["accepted children (softwareType)"]
m_pu["measurement<br/>type=pressure<br/>position=upstream"]:::ctrl
m_pd["measurement<br/>type=pressure<br/>position=downstream"]:::ctrl
m_f["measurement<br/>type=flow"]:::ctrl
m_pw["measurement<br/>type=power"]:::ctrl
m_t["measurement<br/>type=temperature"]:::ctrl
end
m_pu -->|pressure.measured.upstream| router[pressureRouter.route]
m_pd -->|pressure.measured.downstream| router
m_f -->|flow.measured.<pos>| mh[measurementHandlers]
m_pw -->|power.measured.atequipment| mh
m_t -->|temperature.measured.<pos>| mh
router --> upd[updatePosition + drift refresh]
mh --> upd
classDef ctrl fill:#a9daee,color:#000
```
| softwareType | filter | wired to | side-effect |
|---|---|---|---|
| `measurement` | `type=pressure, position=upstream` | `pressureRouter.route('upstream', ...)` | Sets upstream pressure; refresh prediction + drift. |
| `measurement` | `type=pressure, position=downstream` | `pressureRouter.route('downstream', ...)` | Sets downstream pressure; refresh prediction + drift. |
| `measurement` | `type=flow, position=*` | `measurementHandlers.updateMeasuredFlow` | Stored; drift assessed against predicted. |
| `measurement` | `type=power, position=atEquipment` | `measurementHandlers.updateMeasuredPower` | Stored; drift assessed against predicted. |
| `measurement` | `type=temperature, position=*` | `measurementHandlers.updateMeasuredTemperature` | Stored; used by power correction if relevant. |
Two **virtual children** are auto-registered at startup: `dashboard-sim-upstream` and `dashboard-sim-downstream`. `data.simulate-measurement` payloads land on these. Real pressure children, when registered, are preferred over the virtuals by `pressureSelector`.
## 7. Lifecycle — what one event does
```mermaid
sequenceDiagram
participant parent as MGC / pumpingStation
participant rm as rotatingMachine
participant fsm as state FSM
participant pred as predictors
participant out as Port-0 output
parent->>rm: flowmovement (Q)
rm->>rm: flowController.handle('parent', 'flowmovement', Q)
rm->>fsm: setpoint(Q) → maybe transitionToState('accelerating')
Note over fsm: state.emitter 'positionChange' per tick
fsm-->>rm: positionChange → updatePosition()
rm->>pred: calcFlowPower(x) → cFlow, cPower
rm->>rm: calcEfficiency / cog / distance-BEP
rm->>rm: drift refresh on every measured tick
rm->>out: msg{topic, payload} (delta-compressed)
parent->>rm: execsequence ('startup' | 'shutdown')
rm->>fsm: transitionToState('starting' | 'stopping')
fsm-->>rm: stateChange → _updateState()
```
## 8. Data model — `getOutput()`
Composed in `io/output.js → buildOutput(this)`, then delta-compressed.
<!-- BEGIN AUTOGEN: data-model -->
| Key | Type | Unit | Sample |
|---|---|---|---|
| `NCog` | number | — | `0` |
| `NCogPercent` | number | — | `0` |
| `atmPressure.measured.atequipment.wikigen-rotatingmachine-id` | number | — | `101325` |
| `cog` | number | — | `0` |
| `ctrl` | number | — | `0` |
| `effDistFromPeak` | number | — | `0` |
| `effRelDistFromPeak` | number | — | `0` |
| `flow.predicted.max.wikigen-rotatingmachine-id` | number | m3/s | `0` |
| `flow.predicted.min.wikigen-rotatingmachine-id` | number | m3/s | `0` |
| `maintenanceTime` | number | — | `0` |
| `mode` | string | — | `"auto"` |
| `moveTimeleft` | number | — | `0` |
| `predictionConfidence` | number | — | `0` |
| `predictionFlags` | array | — | `[…]` |
| `predictionPressureSource` | null | — | `null` |
| `predictionQuality` | string | — | `"invalid"` |
| `pressureDriftFlags` | array | — | `[…]` |
| `pressureDriftLevel` | number | — | `0` |
| `pressureDriftSource` | null | — | `null` |
| `runtime` | number | — | `0` |
| `state` | string | — | `"idle"` |
| `temperature.measured.atequipment.wikigen-rotatingmachine-id` | number | K | `15` |
<!-- END AUTOGEN: data-model -->
**Concrete sample** (live, from a known-good test run — pump warming up with simulated upstream/downstream pressure):
~~~json
{
"state": "warmingup",
"ctrl": 42.5,
"mode": "auto",
"runtime": 0.0014,
"flow.predicted.downstream.default": 12.4,
"flow.predicted.atequipment.default": 12.4,
"flow.predicted.max.dashboard-sim-upstream": 22.1,
"flow.predicted.min.dashboard-sim-upstream": 0,
"power.predicted.atequipment.default": 18.2,
"pressure.measured.upstream.dashboard-sim-upstream": 101325,
"pressure.measured.downstream.dashboard-sim-downstream": 145000,
"temperature.measured.atequipment.dashboard-sim-upstream": 15,
"atmPressure.measured.atequipment.dashboard-sim-upstream": 101325,
"predictionQuality": "warming",
"predictionConfidence": 0.35,
"predictionPressureSource": "dashboard-sim",
"predictionFlags": ["pressure_init_warming"],
"pressureDriftLevel": 0,
"pressureDriftSource": null,
"pressureDriftFlags": ["nominal"],
"cog": 0.62, "NCog": 0.71, "NCogPercent": 62,
"effDistFromPeak": 0.04, "effRelDistFromPeak": 0.12,
"moveTimeleft": 0, "maintenanceTime": 0
}
~~~
Position labels are normalised to lowercase in MeasurementContainer keys (`atequipment`, `downstream`, `upstream`, `max`, `min`). The trailing `<childId>` segment is the registering child's id (or `default` for own predictions / virtuals tagged via `dashboard-sim-*`).
## 9. Configuration — editor form ↔ config keys
```mermaid
flowchart TB
subgraph editor["Node-RED editor form"]
f1[Asset — supplier / category / model / unit]
f2[Position vs parent]
f3[State times: startup / warmup / shutdown / cooldown]
f4[Movement mode + reaction speed]
f5[Process output format]
f6[Database output format]
f7[Logger — level / enabled]
end
subgraph cfg["Domain config slice"]
c1[asset.model / asset.unit / asset.supplier / asset.category]
c2[functionality.positionVsParent]
c3[time.starting / warmingup / stopping / coolingdown]
c4[movement.mode / movement.speed]
c5[output.process]
c6[output.dbase]
c7[general.logging]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
f6 --> c6
f7 --> c7
```
| Form field | Config key | Default | Range | Where used |
|---|---|---|---|---|
| Asset model | `asset.model` | `Unknown` | string (must resolve in curve loader) | `_setupCurves` |
| Asset flow unit | `asset.unit` | `m3/h` | unit string | unit policy `output.flow` |
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | enum (`upstream`, `atEquipment`, `downstream`) | child-register payload + event suffix |
| State time — starting | `time.starting` | `10` (s) | ≥ 0 | FSM timing |
| State time — warmingup | `time.warmingup` | `5` (s) | ≥ 0 | FSM timing |
| State time — stopping | `time.stopping` | `5` (s) | ≥ 0 | FSM timing |
| State time — coolingdown | `time.coolingdown` | `10` (s) | ≥ 0 | FSM timing |
| Movement mode | `movement.mode` | `staticspeed` | enum (`staticspeed`, `dynspeed`) | position trajectory |
| Reaction speed | `movement.speed` | `1` | ≤ `maxSpeed` | trajectory ramp rate (%/s) |
| Process output format | `output.process` | `process` | enum (`process`, `json`, `csv`) | Port 0 formatter |
| Database output format | `output.dbase` | `influxdb` | enum (`influxdb`, `json`, `csv`) | Port 1 formatter |
## 10. State chart
The FSM is the canonical state set declared in `generalFunctions/src/state/stateConfig.json`. `emergencystop` is reachable from *every* state. Allowed transitions per `stateConfig.allowedTransitions`.
```mermaid
stateDiagram-v2
[*] --> idle
idle --> starting: execsequence(startup)
idle --> off: off
idle --> maintenance: maintenance
starting --> warmingup: timer
warmingup --> operational: timer
operational --> accelerating: flowmovement / setpoint up
operational --> decelerating: flowmovement / setpoint down
accelerating --> operational: target reached
decelerating --> operational: target reached
operational --> stopping: execsequence(shutdown)
stopping --> coolingdown: timer
stopping --> idle: timer
coolingdown --> idle: timer
coolingdown --> off: off
off --> idle: execsequence(startup)
off --> maintenance: maintenance
maintenance --> idle: maintenance done
maintenance --> off: off
note right of operational
any state -> emergencystop
via cmd.estop
end note
```
`accelerating` / `decelerating` are abortable on new demand via `abortMovement(reason)`; the controller does **not** auto-transition back to `operational` after an abort (see `state.js` comment "Abort path"). `warmingup` and `coolingdown` are **protected** — abort signals are dropped for safety. `activeStates = { operational, starting, warmingup, accelerating, decelerating }` is the set MGC treats as "machine alive".
## 11. Examples
| Tier | File | What it shows | Status |
|---|---|---|---|
| Basic | `examples/01 - Basic Manual Control.json` | Inject + dashboard, simulated pressure, manual startup/shutdown | ✅ validated |
| Integration | `examples/02 - Integration with Machine Group.json` | rotatingMachine wired under MGC | ⏳ pending validation |
| Dashboard | `examples/03 - Dashboard Visualization.json` | FlowFuse charts: flow / power / pressure trends | ✅ in repo |
| Legacy | `examples/basic.flow.json` / `integration.flow.json` / `edge.flow.json` | Pre-refactor flows | ⚠️ kept until new Tier 2 is validated |
Screenshots will land under `wiki/_partial-screenshots/rotatingMachine/` once captured from the live demo.
## 12. Debug recipes
| Symptom | First thing to check | Where to look |
|---|---|---|
| `state` stuck on `idle`, no startup | Source not in `mode.allowedSources[currentMode]`. Check `flowController` warn log. | `_setupState` + `isValidSourceForMode`. |
| `flow.predicted.*` is 0 or `NaN` | Pressure not initialised — `predictionHealth.flags` will say `pressure_init_warming`. Inject pressure via `data.simulate-measurement` or wire real measurement children. | `getMeasuredPressure` + `pressureSelector`. |
| `predictionHealth.quality='invalid'` | Curve normalisation failed at startup — null predictors installed. Check container log for `Curve normalization failed for model …`. | `_setupCurves`. |
| Drift `level=3` after startup | Less than 10 paired samples (`minSamplesForLongTerm`) — wait a few ticks before judging. | `driftProfiles.minSamplesForLongTerm`. |
| `cmd.estop` doesn't recover | After `emergencystop`, only `idle` / `off` / `maintenance` are allowed. Send `cmd.shutdown` then `cmd.startup`, or reset via maintenance. | `stateConfig.allowedTransitions.emergencystop`. |
| Position bounces around target | Movement mode `dynspeed` ease-in/out may overshoot at high speed; try `staticspeed`. | `movement.mode`. |
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors.
## 13. When you would NOT use this node
- Use rotatingMachine for a **single** pump / compressor / blower. For groups of 2+ with load sharing, wire `machineGroupControl` as the parent.
- Don't use rotatingMachine to model a **passive non-return valve** — use `valve` (no curve, no FSM-driven motor).
- Don't use rotatingMachine without a **curve model** — flow / power predictions degrade to zero and drift is meaningless.
## 14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | Drift confidence drops to 0 when pressure source is missing > 30 s — health flips to `invalid` silently. | `pressure/pressureInitialization.js`. |
| 2 | Multi-parent registration accepted by `childRegistrationUtils` but ordering of teardown is not test-covered. | Open question — `OPEN_QUESTIONS.md`. |
| 3 | `data.simulate-measurement` does not unset previous values on missing keys — stale sim data can persist after toggling off. | `measurementHandlers.updateSimulatedMeasurement`. |
| 4 | `execSequence` legacy umbrella topic kept alive in registry; planned removal in Phase 7. | `commands/index.js` `_legacy: true`. |