Compare commits

...

12 Commits

Author SHA1 Message Date
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
64 changed files with 6004 additions and 2126 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

@@ -17,6 +17,7 @@
category: "EVOLV",
color: "#86bbdd",
defaults: {
name: { value: "" },
// Define specific properties
speed: { value: 1, required: true },
@@ -67,11 +68,15 @@
oneditprepare: function() {
// wait for the menu scripts to load
let menuRetries = 0;
const maxMenuRetries = 100; // 5 seconds at 50ms intervals
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.rotatingMachine?.initEditor) {
window.EVOLV.nodes.rotatingMachine.initEditor(this);
} else {
} else if (++menuRetries < maxMenuRetries) {
setTimeout(waitForMenuData, 50);
} else {
console.warn("rotatingMachine: menu scripts failed to load within 5 seconds");
}
};
waitForMenuData();
@@ -124,23 +129,28 @@
<!-- Machine-specific controls -->
<div class="form-row">
<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 class="form-row">
<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 class="form-row">
<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 class="form-row">
<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 class="form-row">
<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 class="form-row">
<label for="node-input-movementMode"><i class="fa fa-exchange"></i> Movement Mode</label>
@@ -180,11 +190,40 @@
</script>
<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>
<li><b>Reaction Speed, Startup, Warmup, Shutdown, Cooldown:</b> timing parameters.</li>
<li><b>Supplier / SubType / Model / Unit:</b> choose via Asset menu.</li>
<li><b>Enable Log / Log Level:</b> toggle via Logger menu.</li>
<li><b>Position:</b> set Upstream / At Equipment / Downstream via Position menu.</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>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>Movement Mode</b>: <code>staticspeed</code> = linear ramp; <code>dynspeed</code> = ease-in/out.</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>
<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>

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);
};

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

@@ -0,0 +1,85 @@
'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' },
handler: handlers.setMode,
},
{
topic: 'cmd.startup',
payloadSchema: { type: 'any' },
handler: handlers.startup,
},
{
topic: 'cmd.shutdown',
payloadSchema: { type: 'any' },
handler: handlers.shutdown,
},
{
topic: 'cmd.estop',
aliases: ['emergencystop'],
payloadSchema: { type: 'any' },
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' },
handler: handlers.execSequenceAlias,
_legacy: true,
},
{
topic: 'set.setpoint',
aliases: ['execMovement'],
payloadSchema: { type: 'object' },
handler: handlers.setSetpoint,
},
{
topic: 'set.flow-setpoint',
aliases: ['flowMovement'],
payloadSchema: { type: 'object' },
handler: handlers.setFlowSetpoint,
},
{
topic: 'data.simulate-measurement',
aliases: ['simulateMeasurement'],
payloadSchema: { type: 'object' },
handler: handlers.simulateMeasurement,
},
{
topic: 'query.curves',
aliases: ['showWorkingCurves'],
payloadSchema: { type: 'any' },
handler: handlers.queryCurves,
},
{
topic: 'query.cog',
aliases: ['CoG'],
payloadSchema: { type: 'any' },
handler: handlers.queryCog,
},
{
topic: 'child.register',
aliases: ['registerChild'],
payloadSchema: { type: 'string' },
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,61 @@
/**
* 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,
};
}
module.exports = { showWorkingCurves, showCoG };

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.unitPolicyView.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.unitPolicyView.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,61 @@
/**
* 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");
'use strict';
class nodeClass {
/**
* Create a Node.
* @param {object} uiConfig - Node-RED node configuration.
* @param {object} RED - Node-RED runtime API.
*/
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
const { BaseNodeAdapter, convert } = require('generalFunctions');
const Machine = require('./specificClass');
const commands = require('./commands');
// Preserve RED reference for HTTP endpoints if needed
this.node = nodeInstance; // This is the Node-RED node instance, we can use this to send messages and update status
this.RED = RED; // This is the Node-RED runtime API, we can use this to create endpoints if needed
this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED
this.source = null; // Will hold the specific class instance
this.config = null; // Will hold the merged configuration
this._pressureInitWarned = false;
// Event-driven: state + measurement events drive recomputes via the
// domain emitter. No tick loop. Status badge polled every second.
class nodeClass extends BaseNodeAdapter {
static DomainClass = Machine;
static commands = commands;
static tickInterval = null;
static statusInterval = 1000;
// Load default & UI config
this._loadConfig(uiConfig,this.node);
// Instantiate core class
this._setupSpecificClass(uiConfig);
// Wire up event and lifecycle handlers
this._bindEvents();
this._registerChild();
this._startTickLoop();
this._attachInputHandler();
this._attachCloseHandler();
}
/**
* Load and merge default config with user-defined settings.
* @param {object} uiConfig - Raw config from Node-RED UI.
*/
_loadConfig(uiConfig,node) {
const cfgMgr = new configManager();
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
},
buildDomainConfig(uiConfig) {
const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h');
// Stash extras on the Machine class so its constructor (called by
// BaseNodeAdapter via DomainClass) picks them up alongside the
// machineConfig. Single-threaded JS makes the hand-off race-free.
Machine._pendingExtras = {
stateConfig: {
general: { logging: { enabled: uiConfig.enableLog, logLevel: uiConfig.logLevel } },
movement: { speed: Number(uiConfig.speed), mode: uiConfig.movementMode },
time: {
starting: Number(uiConfig.startup),
warmingup: Number(uiConfig.warmup),
stopping: Number(uiConfig.shutdown),
coolingdown: Number(uiConfig.cooldown)
}
};
this.source = new Specific(machineConfig, stateConfig);
//store in node
this.node.source = this.source; // Store the source in the node instance for easy access
}
/**
* Bind events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES
*/
_bindEvents() {
}
_updateNodeStatus() {
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();
});
starting: Number(uiConfig.startup), warmingup: Number(uiConfig.warmup),
stopping: Number(uiConfig.shutdown), coolingdown: Number(uiConfig.cooldown),
},
},
errorMetricsConfig: {},
};
return {
asset: {
uuid: uiConfig.assetUuid || uiConfig.uuid || null,
tagCode: uiConfig.assetTagCode || uiConfig.assetTagNumber || null,
tagNumber: uiConfig.assetTagNumber || null,
unit: flowUnit,
curveUnits: {
pressure: _resolveUnit(uiConfig.curvePressureUnit, 'pressure', 'mbar'),
flow: _resolveUnit(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit),
power: _resolveUnit(uiConfig.curvePowerUnit, 'power', 'kW'),
control: (typeof uiConfig.curveControlUnit === 'string' && uiConfig.curveControlUnit.trim()) || '%',
},
},
general: { unit: flowUnit },
flowNumber: uiConfig.flowNumber,
};
}
}
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;

View File

@@ -0,0 +1,111 @@
/**
* 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.
*/
const { gravity, coolprop } = require('generalFunctions');
function calcEfficiencyCurve(powerCurve, flowCurve) {
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 };
}
powerCurve.y.forEach((power, i) => {
const flow = flowCurve.y[i];
const eff = (power > 0 && flow >= 0) ? flow / 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 { efficiencyCurve, peak, peakIndex, minEfficiency } = calcEfficiencyCurve(powerCurve, flowCurve);
const yMin = host.predictFlow.currentFxyYMin;
const yMax = host.predictFlow.currentFxyYMax;
const NCog = (flowCurve.y[peakIndex] - yMin) / (yMax - yMin);
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');
host.logger.debug(`temp: ${temp} atmPressure : ${atm} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`);
host.logger.debug(`Flow : ${flowM3s} power: ${powerW}`);
if (power > 0 && flow > 0) {
host.measurements.type('efficiency').variant(variant).position('atEquipment').value(flow / power);
host.measurements.type('specificEnergyConsumption').variant(variant).position('atEquipment').value(power / flow);
if (pressureDiff?.value != null && Number.isFinite(flowM3s) && Number.isFinite(powerW) && powerW > 0) {
const diffPa = Number(pressureDiff.value);
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.unitPolicyView.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.unitPolicyView.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.unitPolicyView.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,80 @@
'use strict';
/**
* PressureRouter — routes a measured pressure value into the right
* MeasurementContainer slot and triggers downstream side-effects
* (position recompute + drift/health refresh) only when the source
* is a real child (not a dashboard-sim virtual one).
*
* Extracted from rotatingMachine specificClass.updateMeasuredPressure.
*/
class PressureRouter {
/**
* @param {object} ctx
* - measurements: MeasurementContainer
* - virtualPressureChildIds: { upstream, downstream }
* - resolveMeasurementUnit(type, unit) -> canonical unit string (throws on invalid)
* - updatePosition?(): called after a real-source write
* - refreshDrift?(): called after a real-source write (e.g. _updatePressureDriftStatus)
* - refreshHealth?(): called after a real-source write (e.g. _updatePredictionHealth)
* - getPressure?(): optional, returns the current preferred pressure (for logging)
* - logger
*/
constructor(ctx = {}) {
this.measurements = ctx.measurements;
this.virtualPressureChildIds = ctx.virtualPressureChildIds || {};
this.resolveMeasurementUnit = ctx.resolveMeasurementUnit || ((_t, u) => u);
this.updatePosition = ctx.updatePosition;
this.refreshDrift = ctx.refreshDrift;
this.refreshHealth = ctx.refreshHealth;
this.getPressure = ctx.getPressure;
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}`);
if (!isVirtual) {
if (typeof this.updatePosition === 'function') this.updatePosition();
if (typeof this.refreshDrift === 'function') this.refreshDrift();
if (typeof this.refreshHealth === 'function') this.refreshHealth();
}
if (typeof this.getPressure === 'function') {
const p = this.getPressure();
this.logger.debug(`Using pressure: ${p} for calculations`);
}
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.unitPolicyView.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,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,13 +2,19 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
const Machine = require('../../src/specificClass');
const { makeNodeStub } = require('../helpers/factories');
// After the BaseNodeAdapter migration, _loadConfig + _setupSpecificClass
// are gone — config building lives in buildDomainConfig(). These tests
// drive that contract through a prototype-derived nodeClass instance so
// we exercise the surface without booting Node-RED.
function makeUiConfig(overrides = {}) {
return {
unit: 'm3/h',
enableLog: true,
logLevel: 'debug',
enableLog: false,
logLevel: 'error',
supplier: 'hidrostal',
category: 'machine',
assetType: 'pump',
@@ -28,82 +34,53 @@ function makeUiConfig(overrides = {}) {
};
}
test('_loadConfig maps legacy editor fields for asset identity', () => {
function callBuildDomainConfig(ui) {
const inst = Object.create(NodeClass.prototype);
inst.node = makeNodeStub();
inst.name = 'rotatingMachine';
// Clear any leftover pending extras so this test's call is the only one
// that stamps Machine._pendingExtras.
Machine._pendingExtras = null;
return inst.buildDomainConfig(ui);
}
inst._loadConfig(
makeUiConfig({
uuid: 'uuid-from-editor',
assetTagNumber: 'TAG-123',
}),
inst.node
);
assert.equal(inst.config.asset.uuid, 'uuid-from-editor');
assert.equal(inst.config.asset.tagCode, 'TAG-123');
assert.equal(inst.config.asset.tagNumber, 'TAG-123');
test('buildDomainConfig maps legacy editor fields for asset identity', () => {
const cfg = callBuildDomainConfig(makeUiConfig({ uuid: 'uuid-from-editor', assetTagNumber: 'TAG-123' }));
assert.equal(cfg.asset.uuid, 'uuid-from-editor');
assert.equal(cfg.asset.tagCode, 'TAG-123');
assert.equal(cfg.asset.tagNumber, 'TAG-123');
});
test('_loadConfig prefers explicit assetUuid/assetTagCode when present', () => {
const inst = Object.create(NodeClass.prototype);
inst.node = makeNodeStub();
inst.name = 'rotatingMachine';
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('buildDomainConfig prefers explicit assetUuid/assetTagCode when present', () => {
const cfg = callBuildDomainConfig(makeUiConfig({
uuid: 'legacy-uuid', assetUuid: 'explicit-uuid',
assetTagNumber: 'legacy-tag', assetTagCode: 'explicit-tag',
}));
assert.equal(cfg.asset.uuid, 'explicit-uuid');
assert.equal(cfg.asset.tagCode, 'explicit-tag');
});
test('_loadConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
const inst = Object.create(NodeClass.prototype);
inst.node = makeNodeStub();
inst.name = 'rotatingMachine';
inst._loadConfig(
makeUiConfig({
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('buildDomainConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
const cfg = callBuildDomainConfig(makeUiConfig({
unit: 'not-a-unit',
curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
curvePowerUnit: 'kW', curveControlUnit: '%',
}));
assert.equal(cfg.general.unit, 'm3/h');
assert.equal(cfg.asset.unit, 'm3/h');
assert.equal(cfg.asset.curveUnits.pressure, 'mbar');
assert.equal(cfg.asset.curveUnits.flow, 'm3/h');
assert.equal(cfg.asset.curveUnits.power, 'kW');
assert.equal(cfg.asset.curveUnits.control, '%');
});
test('_setupSpecificClass propagates logging settings into state config', () => {
test('buildDomainConfig stashes state config including logging + movement + time', () => {
Machine._pendingExtras = null;
const inst = Object.create(NodeClass.prototype);
inst.node = makeNodeStub();
inst.name = 'rotatingMachine';
const uiConfig = makeUiConfig({
enableLog: true,
logLevel: 'warn',
uuid: 'uuid-test',
assetTagNumber: 'TAG-9',
});
inst._loadConfig(uiConfig, inst.node);
inst._setupSpecificClass(uiConfig);
assert.equal(inst.source.state.config.general.logging.enabled, true);
assert.equal(inst.source.state.config.general.logging.logLevel, 'warn');
inst.buildDomainConfig(makeUiConfig({ enableLog: true, logLevel: 'warn', speed: 5, startup: 3 }));
const extras = Machine._pendingExtras;
assert.ok(extras, 'Machine._pendingExtras should be set by buildDomainConfig');
assert.equal(extras.stateConfig.general.logging.enabled, true);
assert.equal(extras.stateConfig.general.logging.logLevel, 'warn');
assert.equal(extras.stateConfig.movement.speed, 5);
assert.equal(extras.stateConfig.time.starting, 3);
Machine._pendingExtras = null;
});

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,101 @@
'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: refresh hooks NOT called', () => {
const meas = makeFakeMeasurements();
let posCalled = 0, driftCalled = 0, healthCalled = 0;
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
resolveMeasurementUnit: () => 'mbar',
updatePosition: () => { posCalled++; },
refreshDrift: () => { driftCalled++; },
refreshHealth: () => { healthCalled++; },
logger: SILENT,
});
router.route('upstream', 7, { childId: 'sim-u', unit: 'mbar' });
assert.equal(posCalled, 0);
assert.equal(driftCalled, 0);
assert.equal(healthCalled, 0);
});
test('real source: all refresh hooks called', () => {
const meas = makeFakeMeasurements();
let posCalled = 0, driftCalled = 0, healthCalled = 0;
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
resolveMeasurementUnit: () => 'mbar',
updatePosition: () => { posCalled++; },
refreshDrift: () => { driftCalled++; },
refreshHealth: () => { healthCalled++; },
logger: SILENT,
});
router.route('upstream', 7, { childId: 'real-pt-1', unit: 'mbar' });
assert.equal(posCalled, 1);
assert.equal(driftCalled, 1);
assert.equal(healthCalled, 1);
});
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

@@ -34,22 +34,20 @@ test('setpoint is constrained to safe movement/curve bounds', async () => {
assert.equal(requested[1], max);
});
test('nodeClass _updateNodeStatus returns error status on internal failure', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
inst.node = node;
inst.source = {
test('source.getStatusBadge returns error status on internal failure', () => {
// Status badge lives on the domain post-refactor. Build a tiny stub
// that throws to verify the error-path returns an error badge.
const errors = [];
const source = {
currentMode: 'auto',
state: {
getCurrentState() {
throw new Error('boom');
},
},
state: { getCurrentState() { throw new Error('boom'); } },
logger: { error: (m) => errors.push(m) },
};
const status = inst._updateNodeStatus();
assert.equal(status.text, 'Status Error');
assert.equal(node._errors.length, 1);
const { buildStatusBadge } = require('../../src/io/output');
const status = buildStatusBadge(source);
assert.match(status.text, /Status Error/);
assert.equal(status.fill, 'red');
assert.equal(errors.length, 1);
});
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

@@ -2,89 +2,75 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
const commands = require('../../src/commands');
const { createRegistry } = require('generalFunctions');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
test('input handler routes topics to source methods', () => {
// Post-BaseNodeAdapter, dispatch is the commands-registry. These tests
// drive the same surface from a prototype-derived nodeClass instance to
// keep the routing covered without booting Node-RED.
function makeSourceStub() {
const calls = [];
return {
calls,
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
childRegistrationUtils: { registerChild(childSource, pos) { calls.push(['registerChild', childSource, pos]); } },
setMode(mode) { calls.push(['setMode', mode]); },
handleInput(source, action, parameter) { calls.push(['handleInput', source, action, parameter]); return Promise.resolve(); },
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; },
};
}
test('input handler routes topics to source methods via commands registry', async () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
const calls = [];
const source = makeSourceStub();
inst.node = node;
inst.RED = makeREDStub({
child1: {
source: { id: 'child-source' },
},
});
inst.source = {
childRegistrationUtils: {
registerChild(childSource, pos) {
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.RED = makeREDStub({ child1: { source: { id: 'child-source' } } });
inst.source = source;
inst._commands = createRegistry(commands, { logger: source.logger });
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' } }, () => {}, () => {});
await onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
await onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } }, () => {}, () => {});
await onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
await onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
await onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
await 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']);
assert.deepEqual(source.calls[0], ['setMode', 'auto']);
assert.deepEqual(source.calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
assert.deepEqual(source.calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
// estop handler defaults action to 'emergencystop' even without one
// supplied, so the trailing arg is undefined — passed as positional.
assert.deepEqual(source.calls[3].slice(0, 3), ['handleInput', 'GUI', 'emergencystop']);
assert.deepEqual(source.calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
assert.deepEqual(source.calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
assert.deepEqual(source.calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
});
test('simulateMeasurement warns and ignores invalid payloads', () => {
test('simulateMeasurement warns and ignores invalid payloads', async () => {
const warns = [];
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
const calls = [];
inst.node = node;
inst.RED = makeREDStub();
inst.source = {
logger: { warn: (m) => warns.push(m), info: () => {}, debug: () => {}, error: () => {} },
childRegistrationUtils: { registerChild() {} },
setMode() {},
handleInput() {},
handleInput() { return Promise.resolve(); },
showWorkingCurves() { return {}; },
showCoG() { return {}; },
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
@@ -92,90 +78,67 @@ test('simulateMeasurement warns and ignores invalid payloads', () => {
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
updateMeasuredPower() { calls.push('updateMeasuredPower'); },
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
isUnitValidForType() { return true; },
};
inst._commands = createRegistry(commands, { logger: inst.source.logger });
inst._attachInputHandler();
const onInput = node._handlers.input;
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
await onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
await onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
assert.equal(calls.length, 0);
assert.equal(node._warns.length, 3);
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);
// Filter out the one-time deprecation warning for the legacy
// 'simulateMeasurement' alias — only the three invalid-payload warns
// matter for this assertion.
const payloadWarns = warns.filter((w) => !/deprecated/i.test(String(w)));
assert.equal(payloadWarns.length, 3);
assert.match(String(payloadWarns[0]), /finite number/i);
assert.match(String(payloadWarns[1]), /payload\.unit is required/i);
assert.match(String(payloadWarns[2]), /unsupported simulatemeasurement type/i);
});
test('status shows warning when pressure inputs are not initialized', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
inst.node = node;
inst.source = {
test('source.getStatusBadge shows warning when pressure inputs are not initialized', () => {
// Status badge now lives on the domain (Machine). Build a tiny stub.
const source = {
currentMode: 'virtualControl',
state: {
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; } };
},
};
},
};
},
},
state: { getCurrentState: () => 'operational', getCurrentPosition: () => 50 },
pressureInit: { getStatus: () => ({ initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false }) },
measurements: { type() { return { variant() { return { position() { return { getCurrentValue() { return 0; } }; } }; } }; } },
unitPolicyView: { output: { flow: 'm3/h' } },
logger: { error: () => {} },
};
const status = inst._updateNodeStatus();
const statusAgain = inst._updateNodeStatus();
// Import the buildStatusBadge helper directly — it's the same code the
// domain's getStatusBadge() invokes.
const { buildStatusBadge } = require('../../src/io/output');
const status = buildStatusBadge(source);
assert.equal(status.fill, 'yellow');
assert.equal(status.shape, 'ring');
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('showWorkingCurves and CoG route reply messages to process output index', async () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
const source = {
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
childRegistrationUtils: { registerChild() {} },
setMode() {}, handleInput() { return Promise.resolve(); },
showWorkingCurves() { return { curve: [1, 2, 3] }; },
showCoG() { return { cog: 0.77 }; },
};
inst.node = node;
inst.RED = makeREDStub();
inst.source = {
childRegistrationUtils: { registerChild() {} },
setMode() {},
handleInput() {},
showWorkingCurves() {
return { curve: [1, 2, 3] };
},
showCoG() {
return { cog: 0.77 };
},
};
inst.source = source;
inst._commands = createRegistry(commands, { logger: source.logger });
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, () => {});
await onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
await onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
assert.equal(sent.length, 2);
assert.equal(Array.isArray(sent[0]), true);

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

@@ -0,0 +1,164 @@
// 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: { category: 'pump', type: 'centrifugal',
model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
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

@@ -48,7 +48,12 @@ test('predictions use initialized medium pressure and not the minimum-pressure f
assert.equal(pressureStatus.initialized, true);
assert.equal(pressureStatus.hasDifferential, true);
const expectedDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa canonical
assert.equal(Math.round(machine.predictFlow.fDimension), expectedDiff);
const rawDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa = 40000
// 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);
});

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,147 @@
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 correct specific flow ratio', () => {
const machine = makePressurizedOperationalMachine();
const { powerCurve, flowCurve } = machine.getCurrentCurves();
const { efficiencyCurve, peak, peakIndex, minEfficiency } = machine.calcEfficiencyCurve(powerCurve, flowCurve);
assert.ok(efficiencyCurve.length > 0, 'Efficiency curve should not be empty');
assert.equal(efficiencyCurve.length, powerCurve.y.length, 'Should match curve length');
// Verify each point: efficiency = flow / power (unrounded, canonical units)
for (let i = 0; i < efficiencyCurve.length; i++) {
const power = powerCurve.y[i];
const flow = flowCurve.y[i];
if (power > 0 && flow >= 0) {
const expected = flow / power;
assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${i}`);
}
}
// 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);
const noPressureValue = machine.getMeasuredPressure();
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
machine = createMachine();
@@ -44,9 +47,11 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(Math.round(downstreamValue), 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();
const upstream = 700;
const upstream = 200;
const downstream = 1100;
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');

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 () => {
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);
const pos = machine.state.getCurrentPosition();
assert.ok(pos <= max);
assert.equal(pos, max);
pos = machine.state.getCurrentPosition();
assert.equal(pos, 10, 'setpoint within bounds should be applied as-is');
assert.ok(pos >= min && 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');
});