17 Commits

Author SHA1 Message Date
znetsixe
889221fffd fix(rm): force-emit ctrl every tick (static alwaysEmitFields)
Realized control position is constant in steady state, so delta compression
emitted it ~once and the Grafana "% Control" line went invisible. Exempt
`ctrl` from delta compression so the pump's movement always traces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:09:24 +02:00
znetsixe
a8d9895cbf fix(rotatingmachine): seed operating-point flow/power telemetry at boot
The operating-point series (flow.predicted.{downstream,atequipment},
power.predicted.atequipment) were only written by calcFlow/calcPower while
operational, or by _updateState on a state transition. A machine that boots
into idle and never runs therefore emitted these keys NEVER — so InfluxDB
carried only the flow envelope (max/min) and dashboard panels querying the
operating point rendered blank, unable to show even the off/0 state.

Seed them to 0 in _init() alongside max/min, so telemetry always carries the
operating point: 0 while idle, real values once the pump runs. Verified end to
end: keys now present in InfluxDB, the Grafana flow panel resolves, and the
real prediction path produces non-zero values (~98 m3/h, ~13 kW) that flow
through getOutput to Port 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:07:25 +02:00
znetsixe
455f15dc55 refactor(units): route all conversions through UnitPolicy.convert
Delete the legacy _convertUnitValue helper on the domain and the
duplicate convertUnitValue export on curveNormalizer; both were
identical to UnitPolicy.convert. Callers in flowController, the
curve normalizer, and buildQHCurve now go through this.unitPolicy.
The contract in .claude/refactor/CONTRACTS.md §6 named these as the
target migration; this finishes the rollout for rotatingMachine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:43:26 +02:00
znetsixe
a18aec32b9 style: palette swatch → (domain-hue redesign 2026-05-21)
Sidebar swatch now follows function family rather than S88 level, so the
palette is visually identifiable instead of monochromatically blue. Editor-group
rectangles in flow.json still follow S88 — only the registerType color changed.
Full table + rationale: superproject .claude/rules/node-red-flow-layout.md §10.0
and .claude/refactor/OPEN_QUESTIONS.md (2026-05-21 entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:05:52 +02:00
znetsixe
8c5822c853 style(editor): drop fixed max-width on rotor SVG — let it fill the panel
Was capped at 600 px and horizontally centred. Removing both lets the SVG
expand to the editor column width, which on wider screens stops the
diagram from sitting in a narrow stripe with empty margins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:31:22 +02:00
znetsixe
c9970c0c57 fix(commands): point set.mode description at the schema enum
Old description said "auto / manual" but the schema declares four modes.
New description enumerates the allowed values and refers readers to the
schema. RM's wiki/Reference-Contracts.md is hand-maintained (no AUTOGEN
markers) and already says "one of the allowed mode names" — no
regeneration needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:05:43 +02:00
znetsixe
426c1a606b feat(editor): pump banner, circular state diagram, shared picker
Editor UI overhaul:
* Pump banner — SVG of a generic centrifugal pump (volute, impeller,
  motor stub, suction + discharge pipes) at the top for visual orientation.
* Sequence-timing: side-panel inputs hover-coupled to a circular FSM donut.
  Arc angle proportional to phase seconds; idle a small loop slice at the
  top, operational the dominant arc at the bottom. Protected phases mark
  warm-up / cool-down with text-style shield (VS-15) inheriting arc colour.
  Donut height measured at runtime against the side-panel column so the
  bounding box lines up with the row stack.
* Movement mode: dropdown replaced with two compact 94x86 icon cards
  (Static linear ramp, Dynamic sigmoid).
* Output formats: switched to the shared evolv-icon-picker pattern (now
  also auto-applied platform-wide by generalFunctions/menu/iconHelpers).
* CLAUDE.md: Folder & File Layout section per EVOLV convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:31:01 +02:00
znetsixe
5ea0b0bda6 feat(state): honor sequenceAbortToken so external aborts cleanly break sequences
Consumer half of the abort-token mechanism added in generalFunctions
state.js. executeSequence captures host.state.sequenceAbortToken at
entry, then re-checks before every state transition and after the
optional ramp-down. If MGC (or any external caller) bumps the token
mid-sequence, the loop bails out cleanly — no more barge-through where
a pre-empted shutdown advances through stopping → coolingdown after a
fresh demand has already engaged the pump.

Without this the MGC rendezvous planner can't reliably re-dispatch a
pump that's mid-shutdown: the new flowmovement claims the gate, but
the old shutdown's for-loop keeps running on microtasks and steps the
FSM into idle/off underneath it.

Also: wiki regen following the same visual-first 14-section template as
the other EVOLV nodes — Reference-{Architecture,Contracts,Examples,
Limitations}.md split with _Sidebar.md index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:44:48 +02:00
znetsixe
394a972d10 hydraulic efficiency η = (Q·ΔP)/P + asset registry rename
The pre-existing efficiency formula `η = flow/power` produced tiny SI-unit
values (m³/J ≈ 1e-5), was monotonic in ctrl for centrifugal-pump curves
(no interior peak), and made NCog collapse to 0 — which cascaded into MGC
reporting BEP-position 0.0% always. Replaced with hydraulic efficiency
η = (Q·ΔP)/P_shaft, the dimensionless 0..1 ratio that has a real BEP and
matches the form MGC's group-level math uses.

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

Full suite: 214/214 pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:38:45 +02:00
63 changed files with 6268 additions and 2418 deletions

View File

@@ -21,3 +21,20 @@ Key points for this node:
- Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `#86bbdd` (Equipment Module).
## Folder & File Layout
Every per-node file MUST use the folder name (`rotatingMachine`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
| Path | Required name |
|---|---|
| Entry file | `rotatingMachine.js` |
| Editor HTML | `rotatingMachine.html` |
| Node adapter | `src/nodeClass.js` |
| Domain logic | `src/specificClass.js` |
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
When adding new files, read the rule above first to avoid drift.

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).

View File

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

View File

@@ -15,7 +15,7 @@
<script>
RED.nodes.registerType("rotatingMachine", {
category: "EVOLV",
color: "#86bbdd",
color: "#E89B3A",
defaults: {
name: { value: "" },
@@ -30,12 +30,11 @@
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
//define asset properties
// Asset identifier surface. supplier/category/assetType are
// derived at runtime via assetResolver.resolveAssetMetadata(model);
// do NOT add them back here. See src/registry/README.md.
uuid: { value: "" },
assetTagNumber: { value: "" },
supplier: { value: "" },
category: { value: "" },
assetType: { value: "" },
model: { value: "" },
unit: { value: "" },
curvePressureUnit: { value: "mbar" },
@@ -63,16 +62,21 @@
icon: "font-awesome/fa-cog",
label: function () {
return (this.positionIcon || "") + " " + (this.category || "Machine");
// No more `this.category` on the node — fall back to model id, then a
// generic name. supplier/category/type live in the registry now.
const stem = this.model ? this.model : "Machine";
return (this.positionIcon || "") + " " + stem;
},
oneditprepare: function() {
// wait for the menu scripts to load
const node = this;
// wait for the menu scripts to load (asset/logger/position injected via menu.js)
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);
window.EVOLV.nodes.rotatingMachine.initEditor(node);
} else if (++menuRetries < maxMenuRetries) {
setTimeout(waitForMenuData, 50);
} else {
@@ -81,17 +85,189 @@
};
waitForMenuData();
// your existing projectsettings & asset dropdown logic can remain here
document.getElementById("node-input-speed");
document.getElementById("node-input-startup");
document.getElementById("node-input-warmup");
document.getElementById("node-input-shutdown");
document.getElementById("node-input-cooldown");
const movementMode = document.getElementById("node-input-movementMode");
if (movementMode) {
movementMode.value = this.movementMode || "staticspeed";
// -----------------------------------------------------------
// Movement-mode visual cards (replaces the old <select>).
// Same compact 94×86 card sizing as machineGroupControl.
// -----------------------------------------------------------
const modeInput = document.getElementById("node-input-movementMode");
const cards = document.querySelectorAll(".rm-mode-card");
const setMode = (val) => {
if (modeInput) modeInput.value = val;
cards.forEach((c) => {
const on = c.dataset.value === val;
c.classList.toggle("rm-mode-card-on", on);
c.setAttribute("aria-checked", String(on));
});
};
const initialMode = (node.movementMode === "dynspeed") ? "dynspeed" : "staticspeed";
setMode(initialMode);
cards.forEach((card) => {
card.addEventListener("click", () => setMode(card.dataset.value));
card.addEventListener("keydown", (e) => {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); setMode(card.dataset.value); }
});
});
// -----------------------------------------------------------
// Output-format pickers (shared widget from iconHelpers).
// Hidden <select>s carry the value; the icon-picker divs are
// upgraded in place. Same visuals as machineGroupControl.
// -----------------------------------------------------------
const helpers = window.EVOLV?.iconHelpers;
if (helpers && typeof helpers.renderOutputFormatPicker === "function") {
helpers.renderOutputFormatPicker(
document.getElementById("node-input-processOutputFormat"),
document.getElementById("rm-process-output-picker")
);
helpers.renderOutputFormatPicker(
document.getElementById("node-input-dbaseOutputFormat"),
document.getElementById("rm-dbase-output-picker")
);
}
// -----------------------------------------------------------
// Circular state-machine diagram (replaces the linear bars).
// Idle is a small fixed slice at the top; operational is a
// fixed dominant arc at the bottom; starting+warmingup and
// stopping+coolingdown each share one of the two side bands
// proportional to their seconds. Reaction speed shown as a
// small slope inside the donut hole.
// -----------------------------------------------------------
const TL = {
cx: 170, cy: 130,
innerR: 46, outerR: 80,
idleDeg: 30, // fixed slice at top, the loop-around
operationalDeg: 100, // fixed dominant arc at the bottom
sideMinDeg: 28 // each timed phase keeps at least this so labels fit
};
TL.sideDeg = (360 - TL.idleDeg - TL.operationalDeg) / 2; // 115° per side
function p2c(r, deg) {
const rad = deg * Math.PI / 180;
return [TL.cx + r * Math.sin(rad), TL.cy - r * Math.cos(rad)];
}
function arcPath(rIn, rOut, startDeg, endDeg) {
const [x1, y1] = p2c(rOut, startDeg);
const [x2, y2] = p2c(rOut, endDeg);
const [x3, y3] = p2c(rIn, endDeg);
const [x4, y4] = p2c(rIn, startDeg);
const largeArc = (endDeg - startDeg) > 180 ? 1 : 0;
return "M " + x1.toFixed(2) + " " + y1.toFixed(2) +
" A " + rOut + " " + rOut + " 0 " + largeArc + " 1 " + x2.toFixed(2) + " " + y2.toFixed(2) +
" L " + x3.toFixed(2) + " " + y3.toFixed(2) +
" A " + rIn + " " + rIn + " 0 " + largeArc + " 0 " + x4.toFixed(2) + " " + y4.toFixed(2) +
" Z";
}
function splitPair(a, b, total, minDeg) {
let aDeg, bDeg;
if (a + b === 0) { aDeg = bDeg = total / 2; }
else { aDeg = total * a / (a + b); bDeg = total - aDeg; }
if (aDeg < minDeg) { aDeg = minDeg; bDeg = total - minDeg; }
else if (bDeg < minDeg) { bDeg = minDeg; aDeg = total - minDeg; }
return [aDeg, bDeg];
}
function redrawTimeline() {
const speed = Math.max(0.01, parseFloat(document.getElementById("node-input-speed").value) || 1);
const startup = Math.max(0, parseFloat(document.getElementById("node-input-startup").value) || 0);
const warmup = Math.max(0, parseFloat(document.getElementById("node-input-warmup").value) || 0);
const shutdown = Math.max(0, parseFloat(document.getElementById("node-input-shutdown").value) || 0);
const cooldown = Math.max(0, parseFloat(document.getElementById("node-input-cooldown").value) || 0);
const [startingDeg, warmingupDeg] = splitPair(startup, warmup, TL.sideDeg, TL.sideMinDeg);
const [stoppingDeg, coolingdownDeg] = splitPair(shutdown, cooldown, TL.sideDeg, TL.sideMinDeg);
// Clockwise from top (0° = idle centre). Wrap idle across ±idleDeg/2.
const idleHalf = TL.idleDeg / 2;
const states = [
{ id: "idle", startDeg: -idleHalf, endDeg: idleHalf, label: "idle", time: null, above: true },
{ id: "starting", startDeg: idleHalf, endDeg: idleHalf + startingDeg, label: "starting", time: startup, above: false },
{ id: "warmingup", startDeg: idleHalf + startingDeg, endDeg: idleHalf + startingDeg + warmingupDeg, label: "\u{1F6E1} warm-up", time: warmup, above: false },
{ id: "operational", startDeg: idleHalf + TL.sideDeg, endDeg: idleHalf + TL.sideDeg + TL.operationalDeg, label: "operational", time: null, above: false },
{ id: "stopping", startDeg: idleHalf + TL.sideDeg + TL.operationalDeg, endDeg: idleHalf + TL.sideDeg + TL.operationalDeg + stoppingDeg, label: "stopping", time: shutdown, above: false },
{ id: "coolingdown", startDeg: idleHalf + TL.sideDeg + TL.operationalDeg + stoppingDeg, endDeg: idleHalf + TL.sideDeg + TL.operationalDeg + stoppingDeg + coolingdownDeg, label: "\u{1F6E1} cool-down", time: cooldown, above: false }
];
const labelR = (TL.innerR + TL.outerR) / 2;
const titleR = TL.outerR + 22;
states.forEach((s) => {
const arc = document.getElementById("rm-tl-" + s.id);
if (arc) arc.setAttribute("d", arcPath(TL.innerR, TL.outerR, s.startDeg, s.endDeg));
const midDeg = (s.startDeg + s.endDeg) / 2;
const normMid = ((midDeg % 360) + 360) % 360;
// State name OUTSIDE the ring.
const lbl = document.getElementById("rm-tl-lbl-" + s.id);
if (lbl) {
const [lx, ly] = p2c(titleR, midDeg);
lbl.setAttribute("x", lx.toFixed(2));
lbl.setAttribute("y", ly.toFixed(2));
let ta;
if (Math.abs(normMid) < 12 || Math.abs(normMid - 180) < 12 || normMid > 348) ta = "middle";
else if (normMid > 0 && normMid < 180) ta = "start";
else ta = "end";
lbl.setAttribute("text-anchor", ta);
const dy = (normMid < 12 || normMid > 348) ? "-4"
: (Math.abs(normMid - 180) < 12) ? "14"
: "4";
lbl.setAttribute("dy", dy);
lbl.textContent = s.label;
}
// Time value INSIDE arc.
const t = document.getElementById("rm-tl-time-" + s.id);
if (t) {
const [tx, ty] = p2c(labelR, midDeg);
t.setAttribute("x", tx.toFixed(2));
t.setAttribute("y", ty.toFixed(2));
t.setAttribute("text-anchor", "middle");
t.setAttribute("dy", "4");
t.textContent = (s.time == null) ? "" : (s.time + "s");
}
});
// Reaction-speed value in the donut hole.
const rampVal = document.getElementById("rm-tl-ramp-value");
if (rampVal) rampVal.textContent = speed + " %/s";
}
// Hover-couple: hover an input row → glow its arc.
document.querySelectorAll(".rm-row[data-couples]").forEach((row) => {
const targetId = row.dataset.couples;
row.addEventListener("mouseenter", () => {
document.getElementById(targetId)?.classList.add("rm-arc-highlight");
});
row.addEventListener("mouseleave", () => {
document.getElementById(targetId)?.classList.remove("rm-arc-highlight");
});
});
["speed", "startup", "warmup", "shutdown", "cooldown"].forEach((field) => {
const el = document.getElementById("node-input-" + field);
if (el) el.addEventListener("input", redrawTimeline);
});
// Size the donut SVG so its top/bottom line up with the side panel:
// measure the side-panel's computed height and apply it to the SVG.
// Re-runs on every dialog open (oneditprepare is per-edit).
function syncSvgHeight() {
const sidePanel = document.querySelector(".rm-diag-side");
const svg = document.getElementById("rm-timeline");
if (!sidePanel || !svg) return;
const h = sidePanel.getBoundingClientRect().height;
if (h > 0) svg.style.height = h + "px";
}
// First paint (next tick so the dialog is in the DOM).
// Use requestAnimationFrame chain so the side-panel height is measured
// AFTER the dialog has actually laid out — getBoundingClientRect on a
// freshly-created element returns 0 inside the same synchronous tick.
setTimeout(() => {
redrawTimeline();
requestAnimationFrame(() => requestAnimationFrame(syncSvgHeight));
}, 0);
},
oneditsave: function() {
const node = this;
@@ -112,13 +288,11 @@
["speed", "startup", "warmup", "shutdown", "cooldown"].forEach((field) => {
const element = document.getElementById(`node-input-${field}`);
const value = parseFloat(element?.value) || 0;
console.log(`----------------> Saving ${field}: ${value}`);
node[field] = value;
});
node.movementMode = document.getElementById("node-input-movementMode").value;
console.log(`----------------> Saving movementMode: ${node.movementMode}`);
const modeEl = document.getElementById("node-input-movementMode");
node.movementMode = (modeEl && modeEl.value) ? modeEl.value : "staticspeed";
}
});
</script>
@@ -126,65 +300,276 @@
<!-- Main UI Template -->
<script type="text/html" data-template-name="rotatingMachine">
<!-- 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%;" 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%;" 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%;" 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%;" 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%;" 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>
<select id="node-input-movementMode" style="width:60%;">
<option value="staticspeed">Static</option>
<option value="dynspeed">Dynamic</option>
</select>
<!-- ============================================================ -->
<!-- PUMP / ROTATING MACHINE BANNER -->
<!-- Visual orientation only no inputs. Shows what the node -->
<!-- represents (centrifugal pump with suction + discharge). -->
<!-- ============================================================ -->
<div style="margin: 4px 0 14px 0; background: #fafcff; border: 1px solid #d9e6f2; border-radius: 4px; padding: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 200"
style="display:block;width:100%;"
font-family="Arial,sans-serif" font-size="11">
<defs>
<marker id="rm-arrow-flow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1F4E79"/>
</marker>
<marker id="rm-arrow-rot" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#0c99d9"/>
</marker>
</defs>
<!-- Title -->
<text x="300" y="18" text-anchor="middle" fill="#1F4E79" font-size="13" font-weight="bold">Rotating machine pump / compressor / blower</text>
<!-- Suction pipe (left in) -->
<rect x="20" y="100" width="160" height="38" fill="#dde7f0" stroke="#1F4E79" stroke-width="2"/>
<line x1="40" y1="119" x2="170" y2="119" stroke="#1F4E79" stroke-width="2" marker-end="url(#rm-arrow-flow)"/>
<text x="100" y="92" text-anchor="middle" fill="#1F4E79" font-weight="bold">Suction</text>
<text x="100" y="156" text-anchor="middle" fill="#777" font-size="10" font-style="italic">upstream / inlet pressure</text>
<!-- Motor housing (top) + shaft -->
<rect x="220" y="30" width="44" height="40" rx="3" fill="#7f8c8d" stroke="#333" stroke-width="1.5"/>
<text x="242" y="55" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">M</text>
<line x1="242" y1="70" x2="242" y2="90" stroke="#333" stroke-width="2"/>
<text x="295" y="50" fill="#555" font-size="10" font-style="italic">motor / drive</text>
<!-- Volute (pump body) -->
<circle cx="242" cy="119" r="40" fill="#fff" stroke="#333" stroke-width="2"/>
<!-- Impeller curves (decorative) -->
<path d="M 242 95 Q 268 105 268 119 Q 268 133 242 143 Q 216 133 216 119 Q 216 105 242 95" fill="none" stroke="#86bbdd" stroke-width="1.5"/>
<path d="M 234 100 Q 258 110 258 119 Q 258 128 234 138" fill="none" stroke="#a9daee" stroke-width="1"/>
<!-- Rotation arrow inside volute -->
<path d="M 222 109 A 22 22 0 0 1 262 109" fill="none" stroke="#0c99d9" stroke-width="2" marker-end="url(#rm-arrow-rot)"/>
<text x="242" y="175" text-anchor="middle" fill="#333" font-size="10">impeller</text>
<!-- Discharge pipe (right out) -->
<rect x="304" y="100" width="160" height="38" fill="#dde7f0" stroke="#1F4E79" stroke-width="2"/>
<line x1="314" y1="119" x2="454" y2="119" stroke="#1F4E79" stroke-width="2" marker-end="url(#rm-arrow-flow)"/>
<text x="384" y="92" text-anchor="middle" fill="#1F4E79" font-weight="bold">Discharge</text>
<text x="384" y="156" text-anchor="middle" fill="#777" font-size="10" font-style="italic">downstream / outlet pressure</text>
<!-- Hint band right -->
<text x="484" y="92" fill="#1E8449" font-size="11" font-weight="bold"> flow Q</text>
<text x="484" y="108" fill="#1E8449" font-size="10" font-style="italic">/h (configurable)</text>
<text x="484" y="130" fill="#C0392B" font-size="11" font-weight="bold"> Δp head</text>
<text x="484" y="146" fill="#C0392B" font-size="10" font-style="italic">predicted from curve</text>
<!-- Hint footer -->
<text x="300" y="194" text-anchor="middle" fill="#777" font-size="10" font-style="italic">
Flow direction Pressure rises across the impeller Performance follows the Q-H / Q-P curves of the selected asset
</text>
</svg>
</div>
<!-- ============================================================ -->
<!-- SEQUENCE & REACTION TIMING -->
<!-- Side-panel inputs hover-coupled to a timeline of FSM phases. -->
<!-- Bar widths grow with the entered seconds. Protected phases -->
<!-- (warmingup / coolingdown) carry a 🛡 marker. The reaction- -->
<!-- speed value tilts the slope inside the operational bar. -->
<!-- ============================================================ -->
<h4>Sequence &amp; reaction timing</h4>
<p style="font-size:12px;color:#777;margin:0 0 6px 0;">Each timing input on the left sizes its phase on the timeline. <b>🛡 protected</b> phases (warm-up &amp; cool-down) cannot be aborted by a new command. Hover an input row to highlight the phase it controls.</p>
<style>
.rm-diag { display:flex; gap:20px; align-items:flex-start; margin: 0 0 14px 0; }
.rm-diag-side { width: 230px; flex: 0 0 230px; display:flex; flex-direction:column; gap:6px; }
/* SVG height is set at runtime by syncSvgHeight() in oneditprepare to
match the side-panel's computed height exactly. Width follows the
viewBox aspect ratio. The hard-coded fallback height covers the brief
window before the first sync runs. */
.rm-diag-svg { height:195px; width:auto; max-width:100%; display:block; }
.rm-diag-side .rm-row {
display:grid; grid-template-columns: minmax(0,1fr) 70px 18px; align-items:center;
gap:6px; padding:4px 6px 4px 10px; border-left:4px solid #ccc;
background:#fafafa; border-radius:3px; font-size:11px; cursor:pointer; min-width:0;
}
.rm-diag-side .rm-row:hover { background:#f0f0f0; }
.rm-diag-side .rm-row label { font-weight:600; margin:0; line-height:1.2; }
.rm-diag-side .rm-row .rm-sub { grid-column:1; font-size:10px; color:#888; font-weight:400; }
.rm-diag-side .rm-row input[type=number] {
width:100%; height:22px; box-sizing:border-box; font-size:11px;
padding:1px 4px; margin:0; border:1px solid #ccc; border-radius:3px; background:#fff;
}
.rm-diag-side .rm-row input[type=number]:focus { outline:1px solid #0c99d9; border-color:#0c99d9; }
.rm-diag-side .rm-row .rm-unit { color:#888; font-size:10px; text-align:right; }
/* Border colours matched to arc fills. */
.rm-row[data-stroke="#0c99d9"] { border-left-color:#0c99d9; }
.rm-row[data-stroke="#f39c12"] { border-left-color:#f39c12; }
.rm-row[data-stroke="#e67e22"] { border-left-color:#e67e22; }
.rm-row[data-stroke="#0c99d9"] label { color:#0c99d9; }
.rm-row[data-stroke="#f39c12"] label { color:#b9770e; }
.rm-row[data-stroke="#e67e22"] label { color:#af601a; }
/* Highlight class applied to a state's arc path on input-row hover. */
.rm-arc-highlight { stroke:#1F4E79 !important; stroke-width:3 !important; filter:brightness(1.08); }
/* Movement-mode cards — same compact 94×86 sizing as machineGroupControl. */
.rm-mode-cards { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 4px 0; }
.rm-mode-card {
width:94px; height:86px; box-sizing:border-box;
border:2px solid #d0d0d0; border-radius:4px; background:#fafafa;
padding:4px; cursor:pointer; user-select:none;
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px;
transition:border-color 80ms ease-out, background 80ms ease-out;
}
.rm-mode-card:hover { border-color:#86bbdd; background:#f5fafd; }
.rm-mode-card:focus { outline:2px solid #1F4E79; outline-offset:2px; }
.rm-mode-card-on { border-color:#50a8d9; background:#eaf4fb; }
.rm-mode-card-svg { width:100%; height:54px; display:flex; align-items:center; justify-content:center; }
.rm-mode-card-svg svg { width:100%; height:100%; display:block; }
.rm-mode-card-label { font-size:10px; line-height:1; font-weight:600; color:#333; white-space:nowrap; letter-spacing:0; }
.rm-mode-card:not(.rm-mode-card-on) .rm-mode-card-label { color:#888; }
/* Output-format rows mirror the mgc layout: nowrap label, native select
hidden, icon picker rendered alongside by iconHelpers. */
.rm-output-row > label { white-space:nowrap; width:130px; }
</style>
<div class="rm-diag">
<!-- LEFT: stacked colour-coded inputs. Hover a row matching SVG bar highlights. -->
<div class="rm-diag-side">
<div class="rm-row" data-stroke="#0c99d9" data-couples="rm-tl-operational">
<div><label>Reaction speed</label><div class="rm-sub">controller ramp rate (slope inside operational)</div></div>
<input type="number" id="node-input-speed" min="0.1" step="0.1" />
<span class="rm-unit">%/s</span>
</div>
<div class="rm-row" data-stroke="#f39c12" data-couples="rm-tl-starting">
<div><label>Startup time</label><div class="rm-sub">idle starting warmingup</div></div>
<input type="number" id="node-input-startup" min="0" step="1" />
<span class="rm-unit">s</span>
</div>
<div class="rm-row" data-stroke="#e67e22" data-couples="rm-tl-warmingup">
<div><label>Warm-up time 🛡</label><div class="rm-sub">protected cannot be aborted</div></div>
<input type="number" id="node-input-warmup" min="0" step="1" />
<span class="rm-unit">s</span>
</div>
<div class="rm-row" data-stroke="#f39c12" data-couples="rm-tl-stopping">
<div><label>Shutdown time</label><div class="rm-sub">operational stopping coolingdown</div></div>
<input type="number" id="node-input-shutdown" min="0" step="1" />
<span class="rm-unit">s</span>
</div>
<div class="rm-row" data-stroke="#e67e22" data-couples="rm-tl-coolingdown">
<div><label>Cool-down time 🛡</label><div class="rm-sub">protected cannot be aborted</div></div>
<input type="number" id="node-input-cooldown" min="0" step="1" />
<span class="rm-unit">s</span>
</div>
</div>
<!-- RIGHT: circular state-machine donut. All arc `d` and label x/y
values are written by redrawTimeline(). Each state is a wedge of
the ring; arc angle is proportional to its seconds.
Idle sits at the top (small fixed slice, the loop-around);
operational sits at the bottom (fixed dominant arc). -->
<svg id="rm-timeline" class="rm-diag-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 340 260"
style="background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
preserveAspectRatio="xMidYMid meet"
font-family="Arial,sans-serif" font-size="11">
<!-- Title -->
<text x="170" y="14" text-anchor="middle" fill="#1F4E79" font-size="11" font-weight="bold">State machine sequence loop</text>
<!-- State arc wedges. Order in DOM = clockwise from top.
`d` attribute populated by redrawTimeline(). -->
<path id="rm-tl-idle" fill="#bdc3c7" stroke="#7f8c8d" stroke-width="1" />
<path id="rm-tl-starting" fill="#f39c12" stroke="#b9770e" stroke-width="1" />
<path id="rm-tl-warmingup" fill="#e67e22" stroke="#af601a" stroke-width="1" />
<path id="rm-tl-operational" fill="#2ecc71" stroke="#239b56" stroke-width="1" />
<path id="rm-tl-stopping" fill="#f39c12" stroke="#b9770e" stroke-width="1" />
<path id="rm-tl-coolingdown" fill="#e67e22" stroke="#af601a" stroke-width="1" />
<!-- State-name labels OUTSIDE the ring. x/y/text-anchor/dy set in JS. -->
<text id="rm-tl-lbl-idle" fill="#555" font-size="11" font-weight="bold"></text>
<text id="rm-tl-lbl-starting" fill="#b9770e" font-size="11" font-weight="bold"></text>
<text id="rm-tl-lbl-warmingup" fill="#af601a" font-size="11" font-weight="bold"></text>
<text id="rm-tl-lbl-operational" fill="#239b56" font-size="11" font-weight="bold"></text>
<text id="rm-tl-lbl-stopping" fill="#b9770e" font-size="11" font-weight="bold"></text>
<text id="rm-tl-lbl-coolingdown" fill="#af601a" font-size="11" font-weight="bold"></text>
<!-- Duration values INSIDE each arc. x/y set in JS. -->
<text id="rm-tl-time-idle" fill="#fff" font-size="10" font-weight="bold"></text>
<text id="rm-tl-time-starting" fill="#fff" font-size="10" font-weight="bold"></text>
<text id="rm-tl-time-warmingup" fill="#fff" font-size="10" font-weight="bold"></text>
<text id="rm-tl-time-operational" fill="#fff" font-size="10" font-weight="bold"></text>
<text id="rm-tl-time-stopping" fill="#fff" font-size="10" font-weight="bold"></text>
<text id="rm-tl-time-coolingdown" fill="#fff" font-size="10" font-weight="bold"></text>
<!-- Centre: reaction-speed value (no slope line donut hole stays clean). -->
<text x="170" y="125" text-anchor="middle" fill="#1F4E79" font-size="10" font-weight="bold">Reaction speed</text>
<text id="rm-tl-ramp-value" x="170" y="146" text-anchor="middle" fill="#0c99d9" font-size="16" font-weight="bold">1 %/s</text>
</svg>
</div>
<!-- ============================================================ -->
<!-- MOVEMENT MODE visual cards (was a <select>) -->
<!-- Hidden #node-input-movementMode keeps the save path working. -->
<!-- ============================================================ -->
<h4>Movement mode</h4>
<p style="font-size:12px;color:#777;margin:0 0 6px 0;">How the controller travels between setpoints during <code>accelerating</code> / <code>decelerating</code>.</p>
<div class="rm-mode-cards" role="radiogroup" aria-label="Movement mode">
<div class="rm-mode-card" data-value="staticspeed" tabindex="0" role="radio" aria-checked="false" aria-label="Static — constant ramp rate" title="Static — constant ramp rate">
<div class="rm-mode-card-svg">
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="12" y1="48" x2="70" y2="48" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="12" y1="48" x2="12" y2="8" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="14" y1="46" x2="68" y2="12" stroke="#1F4E79" stroke-width="3" stroke-linecap="round"/>
<circle cx="14" cy="46" r="2.6" fill="#1F4E79"/>
<circle cx="68" cy="12" r="2.6" fill="#1F4E79"/>
</svg>
</div>
<div class="rm-mode-card-label">Static</div>
</div>
<div class="rm-mode-card" data-value="dynspeed" tabindex="0" role="radio" aria-checked="false" aria-label="Dynamic — ease in/out" title="Dynamic — ease in/out">
<div class="rm-mode-card-svg">
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="12" y1="48" x2="70" y2="48" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="12" y1="48" x2="12" y2="8" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<!-- More pronounced sigmoid: control points pull the mid-section nearly flat
(y29 mid) so the S-shape reads clearly at thumbnail size. -->
<path d="M 14 46 C 22 46, 26 30, 41 29 C 56 28, 60 12, 68 12" fill="none" stroke="#1F4E79" stroke-width="3" stroke-linecap="round"/>
<circle cx="14" cy="46" r="2.6" fill="#1F4E79"/>
<circle cx="68" cy="12" r="2.6" fill="#1F4E79"/>
</svg>
</div>
<div class="rm-mode-card-label">Dynamic</div>
</div>
</div>
<!-- Hidden field kept for the save path, written by the cards above. -->
<input type="hidden" id="node-input-movementMode" />
<!-- ============================================================ -->
<!-- OUTPUT FORMATS same shared widget as machineGroupControl. -->
<!-- Native selects stay in the DOM (hidden) as save targets; the -->
<!-- icon-picker divs are upgraded by iconHelpers. -->
<!-- ============================================================ -->
<h3>Output Formats</h3>
<div class="form-row">
<div class="form-row rm-output-row">
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
<select id="node-input-processOutputFormat" style="width:60%;">
<select id="node-input-processOutputFormat" class="evolv-native-hidden" style="width:60%;">
<option value="process">process</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
<div id="rm-process-output-picker" class="evolv-icon-picker"
role="radiogroup" aria-label="Process output format"></div>
</div>
<div class="form-row">
<div class="form-row rm-output-row">
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;">
<select id="node-input-dbaseOutputFormat" class="evolv-native-hidden" style="width:60%;">
<option value="influxdb">influxdb</option>
<option value="frost">frost</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
<div id="rm-dbase-output-picker" class="evolv-icon-picker"
role="radiogroup" aria-label="Database output format"></div>
</div>
<!-- Asset fields injected here -->
<!-- Asset / Logger / Position menus injected by menu.js -->
<div id="asset-fields-placeholder"></div>
<!-- Logger fields injected here -->
<div id="logger-fields-placeholder"></div>
<!-- Position fields injected here -->
<div id="position-fields-placeholder"></div>
</script>
@@ -194,11 +579,11 @@
<h3>Configuration</h3>
<ul>
<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>Reaction speed</b>: controller ramp rate (position units / second). E.g. <code>1</code> = 1%/s, so a setpoint of 60% from idle reaches 60% in ~60&nbsp;s. Visualised as the slope inside the <i>operational</i> bar.</li>
<li><b>Startup / Warm-up / Shutdown / Cool-down</b>: seconds per FSM phase. Warm-up &amp; cool-down are <b>protected</b> they cannot be aborted by a new command (shown with 🛡 in the timeline).</li>
<li><b>Movement mode</b>: <code>staticspeed</code> = linear ramp; <code>dynspeed</code> = ease-in/out. Pick a card.</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>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>

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1,104 @@
/**
* Convert one curve section (nq or np) from supplied units to canonical
* units using the host UnitPolicy. 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.
*/
function normalizeCurveSection(section, unitPolicy, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName, logger) {
const normalized = {};
let prevMedianY = null;
for (const [pressureKey, pair] of Object.entries(section || {})) {
const canonicalPressure = unitPolicy.convert(
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) => unitPolicy.convert(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,
unitPolicy,
curveUnits.flow,
canonicalFlow,
curveUnits.pressure,
canonicalPressure,
'nq',
logger,
),
np: normalizeCurveSection(
rawCurve.np,
unitPolicy,
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 };

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -1,433 +1,85 @@
/**
* 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;
// Realized control position holds constant in steady state, so delta
// compression would emit it ~once and the Grafana "% Control" line goes
// invisible. Force it every tick so the pump's movement always traces.
static alwaysEmitFields = ['ctrl'];
// Load default & UI config
this._loadConfig(uiConfig,this.node);
buildDomainConfig(uiConfig) {
_rejectLegacyAssetFields(uiConfig);
// 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
},
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() {
this._startupTimeout = setTimeout(() => {
this._startupTimeout = null;
this._tickInterval = setInterval(() => this._tick(), 1000);
// Update node status on nodered screen every second
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', async (msg, send, done) => {
const m = this.source;
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
try {
switch(msg.topic) {
case 'registerChild': {
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;
await m.handleInput(source, action, parameter);
break;
}
case 'execMovement': {
const { source: mvSource, action: mvAction, setpoint } = msg.payload;
await m.handleInput(mvSource, mvAction, Number(setpoint));
break;
}
case 'flowMovement': {
const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload;
await m.handleInput(fmSource, fmAction, Number(fmSetpoint));
break;
}
case 'emergencystop': {
const { source: esSource, action: esAction } = msg.payload;
await 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) => {
clearTimeout(this._startupTimeout);
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
this.node.status({}); // clear node status badge
// Clean up child measurement listeners
const m = this.source;
if (m?.childMeasurementListeners) {
for (const [, entry] of m.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);
}
}
m.childMeasurementListeners.clear();
}
// Clean up state emitter listeners
if (m?.state?.emitter) {
m.state.emitter.removeAllListeners();
}
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,
model: uiConfig.model || 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,
};
}
}
// Strict cutover: with the AssetResolver in place, supplier/category/assetType
// are no longer node config — they're derived from the registry by model id.
// Old flows that still have them saved must be re-saved through the editor.
function _rejectLegacyAssetFields(uiConfig) {
const offenders = ['supplier', 'category', 'assetType'].filter((k) => {
const v = uiConfig[k];
return typeof v === 'string' && v.trim() !== '';
});
if (offenders.length > 0) {
throw new Error(
`rotatingMachine: legacy asset field(s) [${offenders.join(', ')}] are saved on this node. ` +
`After the AssetResolver refactor these are derived from the model id. ` +
`Open the node in the editor, re-select the model, and save to migrate.`,
);
}
}
function _resolveUnit(candidate, expectedMeasure, fallback) {
const raw = typeof candidate === 'string' ? candidate.trim() : '';
const fb = String(fallback || '').trim();
if (!raw) return fb;
try {
const desc = convert().describe(raw);
if (expectedMeasure && desc.measure !== expectedMeasure) return fb;
return raw;
} catch (_) { return fb; }
}
module.exports = nodeClass;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
/**
* 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;
}
// Snapshot the sequence-abort token at entry, BEFORE any awaits. If an
// external abort advances the counter while we're inside this call
// (setpoint ramp-down, waitForOperational, or the state transition
// loop), every check below sees the mismatch and breaks out so the
// new dispatch can claim the FSM. Capturing later would conflate the
// abort that fired during setpoint(0) with the initial entry state.
const startToken = host.state.sequenceAbortToken ?? 0;
const aborted = () => (host.state.sequenceAbortToken ?? 0) !== startToken;
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);
if (aborted()) {
host.logger.warn(`Sequence '${name}' interrupted during ramp-down by external abort; not entering shutdown loop.`);
host.updatePosition();
return;
}
}
host.logger.info(` --------- Executing sequence: ${name} -------------`);
for (const s of sequence) {
if (aborted()) {
host.logger.warn(`Sequence '${name}' interrupted at step '${s}' by external abort; stopping further transitions.`);
break;
}
try { await host.state.transitionToState(s); }
catch (e) { host.logger.error(`Error during sequence '${name}': ${e}`); break; }
}
host.updatePosition();
}
module.exports = { setpoint, executeSequence, resolveSetpointBounds, waitForOperational };

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { UnitPolicy } = require('generalFunctions');
const {
normalizeMachineCurve,
normalizeCurveSection,
} = 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 policy = makePolicy();
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, policy, '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 policy = makePolicy();
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, policy, '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', () => {
const policy = makePolicy();
assert.throws(
() => normalizeCurveSection({ 1000: { x: [0, 50], y: [0, 5, 10] } }, policy, 'm3/h', 'm3/s', 'mbar', 'Pa', 'nq', null),
/Invalid nq section/
);
});

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' },
convert: (val, from, to, label) => {
host.calls.convertUnit.push({ val, from, to, label });
return val * 1000; // pretend m3/h -> m3/s factor
},
},
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; },
};
return host;
}
test('handle("parent","execSequence","startup") triggers executeSequence', async () => {
const host = makeHost();
const fc = new FlowController({ host });
const result = await fc.handle('parent', 'execSequence', 'startup');
assert.deepEqual(host.calls.executeSequence, ['startup']);
assert.deepEqual(result, { ran: 'startup' });
});
test('handle("parent","execMovement",50) invokes setpoint(50)', async () => {
const host = makeHost();
const fc = new FlowController({ host });
const result = await fc.handle('parent', 'execMovement', 50);
assert.deepEqual(host.calls.setpoint, [50]);
assert.deepEqual(result, { moved: 50 });
});
test('handle("parent","flowMovement",X) converts unit -> calcCtrl -> setpoint', async () => {
const host = makeHost();
const fc = new FlowController({ host });
await fc.handle('parent', 'flowMovement', 36);
assert.equal(host.calls.convertUnit.length, 1);
assert.equal(host.calls.convertUnit[0].from, 'm3/h');
assert.equal(host.calls.convertUnit[0].to, 'm3/s');
assert.deepEqual(host.calls.calcCtrl, [36 * 1000]);
assert.deepEqual(host.calls.setpoint, [(36 * 1000) / 2]);
});
test('handle("parent","emergencyStop") fires executeSequence("emergencystop") and logs warn', async () => {
const host = makeHost();
const fc = new FlowController({ host });
await fc.handle('parent', 'emergencyStop');
assert.deepEqual(host.calls.executeSequence, ['emergencystop']);
assert.ok(host.logger.calls.warn.some((m) => /Emergency stop activated/.test(m)));
});
test('handle rejects non-string action', async () => {
const host = makeHost();
const fc = new FlowController({ host });
await fc.handle('parent', 123, 'x');
assert.deepEqual(host.calls.executeSequence, []);
assert.deepEqual(host.calls.setpoint, []);
assert.ok(host.logger.calls.error.some((m) => /Action must be string/.test(m)));
});
test('handle bails out when action not allowed for mode', async () => {
const host = makeHost({ allowedActions: new Set(['statuscheck']) });
const fc = new FlowController({ host });
await fc.handle('parent', 'execSequence', 'startup');
assert.deepEqual(host.calls.executeSequence, []);
});
test('handle bails out when source not allowed for mode', async () => {
const host = makeHost({ allowedSources: false });
const fc = new FlowController({ host });
await fc.handle('externalApi', 'execSequence', 'startup');
assert.deepEqual(host.calls.executeSequence, []);
});
test('handle catches downstream errors and logs them (does not propagate)', async () => {
const host = makeHost({ setpointError: new Error('boom') });
const fc = new FlowController({ host });
const result = await fc.handle('parent', 'execMovement', 12);
assert.equal(result, undefined);
assert.ok(host.logger.calls.error.some((m) => /Error handling input/.test(m)));
});
test('handle returns a success envelope for statuscheck', async () => {
const host = makeHost();
const fc = new FlowController({ host });
const out = await fc.handle('parent', 'statusCheck');
assert.equal(out.status, true);
assert.ok(out.feedback.includes('statuscheck'));
});
test('handle warns on unimplemented action', async () => {
const host = makeHost({ allowedActions: new Set(['weirdaction']) });
const fc = new FlowController({ host });
await fc.handle('parent', 'weirdAction');
assert.ok(host.logger.calls.warn.some((m) => /is not implemented/.test(m)));
});
test('constructor validates host', () => {
assert.throws(() => new FlowController({}), /ctx\.host is required/);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,31 @@ test('getOutput contains all required fields in idle state', () => {
assert.ok('pressureDriftFlags' in output);
});
test('getOutput seeds operating-point flow/power telemetry at boot (idle = 0, not absent)', () => {
// Regression: an idle-from-boot machine must still emit the operating-point
// series so dashboards can show the off/0 state. These keys are otherwise
// only written once the pump runs (calcFlow/calcPower) or on a state
// transition, leaving them absent in telemetry for a pump that never starts.
const machine = new Machine(makeMachineConfig(), makeStateConfig());
const output = machine.getOutput();
const hasPrefix = (p) => Object.keys(output).some((k) => k.startsWith(p));
const valueFor = (p) => output[Object.keys(output).find((k) => k.startsWith(p))];
for (const prefix of [
'flow.predicted.downstream',
'flow.predicted.atequipment',
'power.predicted.atequipment',
]) {
assert.ok(hasPrefix(prefix), `${prefix}.* must be present at boot (idle)`);
assert.equal(valueFor(prefix), 0, `${prefix}.* should be 0 while idle`);
}
// The envelope keys remain present too.
assert.ok(hasPrefix('flow.predicted.max'));
assert.ok(hasPrefix('flow.predicted.min'));
});
test('getOutput flow drift fields appear after sufficient measured flow samples', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());

View File

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

View File

@@ -36,8 +36,7 @@ function machineConfig() {
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' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },

View File

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

View File

@@ -33,22 +33,25 @@ test('calcCog peak is always >= minEfficiency', () => {
assert.ok(result.cog >= result.minEfficiency, 'Peak must be >= min');
});
test('calcEfficiencyCurve produces correct specific flow ratio', () => {
test('calcEfficiencyCurve produces hydraulic efficiency η = (Q·ΔP)/P at every point', () => {
const machine = makePressurizedOperationalMachine();
const { powerCurve, flowCurve } = machine.getCurrentCurves();
const dP = machine.predictFlow.currentF; // canonical Pa
const { efficiencyCurve, peak, peakIndex, minEfficiency } = machine.calcEfficiencyCurve(powerCurve, flowCurve);
const { efficiencyCurve, peak, peakIndex, minEfficiency } = machine.calcEfficiencyCurve(powerCurve, flowCurve, dP);
assert.ok(efficiencyCurve.length > 0, 'Efficiency curve should not be empty');
assert.equal(efficiencyCurve.length, powerCurve.y.length, 'Should match curve length');
// Verify each point: efficiency = flow / power (unrounded, canonical units)
// η = (Q·ΔP)/P. flow and power are in canonical SI (m³/s and W), so η is
// a dimensionless 0..1 ratio. dP is the pressure differential the slice
// represents (host.predictFlow.currentF).
for (let i = 0; i < efficiencyCurve.length; i++) {
const power = powerCurve.y[i];
const flow = flowCurve.y[i];
if (power > 0 && flow >= 0) {
const expected = flow / power;
assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${i}`);
if (power > 0 && flow >= 0 && dP > 0) {
const expected = (flow * dP) / power;
assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${i}: got ${efficiencyCurve[i]}, expected ${expected}`);
}
}

View File

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

152
wiki/Home.md Normal file
View File

@@ -0,0 +1,152 @@
# rotatingMachine
![code-ref](https://img.shields.io/badge/code--ref-394a972-blue) ![s88](https://img.shields.io/badge/S88-Equipment_Module-86bbdd) ![status](https://img.shields.io/badge/status-trial--ready-brightgreen)
A `rotatingMachine` models a single pump, compressor, or blower. It loads a supplier characteristic curve, takes upstream + downstream pressure measurements (real or simulated), predicts the resulting flow + power, drives a startup / shutdown state machine, and assesses prediction drift against measured flow / power. Used as a child of `machineGroupControl` when grouped, or directly under `pumpingStation` for a one-pump station.
---
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | One rotating asset on a curve &mdash; pump, blower, compressor |
| S88 level | Equipment Module |
| Use it when | You have a curve-modelled asset whose flow / power varies with header differential and you want predictions + drift |
| Don't use it for | Passive non-return valves (`valve`), curveless assets (will silently emit zeros), groups (parent under `machineGroupControl`) |
| Children it accepts | `measurement` (pressure / flow / power / temperature) |
| Parents it talks to | `machineGroupControl`, `pumpingStation`, or any node that issues `flowmovement` / `execsequence` |
---
## How it fits
```mermaid
flowchart LR
parent[machineGroupControl /<br/>pumpingStation]:::unit -->|flowmovement<br/>execsequence| rm[rotatingMachine<br/>Equipment]:::equip
m_up[measurement<br/>pressure upstream]:::ctrl -.measured.-> rm
m_dn[measurement<br/>pressure downstream]:::ctrl -.measured.-> rm
sim[dashboard-sim-upstream /<br/>dashboard-sim-downstream<br/>(auto-registered virtual children)]:::ctrl -.measured.-> rm
rm -->|child.register| parent
rm -.->|flow.predicted.*<br/>power.predicted.atequipment| parent
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
```
S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`.
---
## Try it &mdash; 3-minute demo
Import the basic example flow, deploy, and drive a single pump through the full state machine.
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/rotatingMachine/examples/01\ -\ Basic\ Manual\ Control.json \
http://localhost:1880/flow
```
What to click after deploy (the inject buttons map one-to-one to topics in [Reference &mdash; Contracts](Reference-Contracts#topic-contract)):
1. `data.simulate-measurement` (upstream + downstream) &mdash; injects ~0 mbar suction and ~1100 mbar discharge so the predictor has something to work with.
2. `set.mode = virtualControl` &mdash; lets the GUI source drive the pump (parent path is for grouped use).
3. `cmd.startup` &mdash; FSM runs `idle &rarr; starting &rarr; warmingup &rarr; operational`. `runtime` starts accumulating.
4. `set.setpoint = 60` (control %) &mdash; pump ramps from `0` to `60` at the configured `Reaction Speed`; state goes `operational &rarr; accelerating &rarr; operational`.
5. `set.flow-setpoint = {value: 80, unit: "m3/h"}` &mdash; same path, but the setpoint is a flow value; the node converts via `predictCtrl` to a control %.
6. `cmd.shutdown` &mdash; `operational &rarr; decelerating &rarr; stopping &rarr; coolingdown &rarr; idle`.
> [!IMPORTANT]
> **GIF needed.** Demo recording of steps 1&ndash;6 with the live status panel. Save as `wiki/_partial-gifs/rotatingMachine/01-basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
---
## The seven things you'll send
| Topic | Aliases | Payload | What it does |
|:---|:---|:---|:---|
| `set.mode` | `setMode` | `"auto"` \| `"virtualControl"` \| `"fysicalControl"` | Switch between parent-controlled, GUI-controlled, and physical-source-only. Each mode has its own allow-list for actions and sources. |
| `cmd.startup` | &mdash; | any | Run the configured startup sequence (default `[starting, warmingup, operational]`). |
| `cmd.shutdown` | &mdash; | any | Run the configured shutdown sequence (default `[stopping, coolingdown, idle]`). `operational` triggers a ramp-to-zero first. |
| `cmd.estop` | `emergencystop` | any | Hard cut: runs the `emergencystop` sequence (default `[emergencystop, off]`). Reachable from every state. |
| `set.setpoint` | `execMovement` | `{setpoint: number}` (control %) | Move to a control-% setpoint. |
| `set.flow-setpoint` | `flowMovement` | `{setpoint: number}` (flow, unit per `units`) | Move to a flow setpoint. Converted to canonical m³/s, then to control % via `predictCtrl`. |
| `data.simulate-measurement` | `simulateMeasurement` | `{asset: {type, unit}, value, position, childId?}` | Inject a virtual sensor reading (pressure / flow / power / temperature). |
Plus two query topics for dashboards:
| Topic | Aliases | Returns on the reply port |
|:---|:---|:---|
| `query.curves` | `showWorkingCurves` | The working curves (flow / power / efficiency) at the current operating point. |
| `query.cog` | `CoG` | The centre-of-gravity (CoG) of the η curve. |
---
## What you'll see come out
Sample Port 0 message (delta-compressed, while operational at ~60 % control):
```json
{
"topic": "rotatingMachine#pump_a",
"payload": {
"state": "operational",
"ctrl": 60.0,
"mode": "auto",
"runtime": 0.024,
"flow.predicted.downstream.default": 12.4,
"flow.predicted.atequipment.default": 12.4,
"power.predicted.atequipment.default": 18.2,
"pressure.measured.upstream.dashboard-sim-upstream": 0,
"pressure.measured.downstream.dashboard-sim-downstream": 1100,
"predictionQuality": "good",
"predictionConfidence": 0.92,
"predictionPressureSource": "dashboard-sim",
"predictionFlags": [],
"cog": 0.62, "NCog": 0.71, "NCogPercent": 62,
"effDistFromPeak": 0.04, "effRelDistFromPeak": 0.12
}
}
```
Key shape: **`<type>.<variant>.<position>.<childId>`** &mdash; the inverse of MGC's key shape, because rotatingMachine emits per-measurement snapshots. The trailing `<childId>` is the registering child's id (`dashboard-sim-upstream`, `dashboard-sim-downstream`, or `default` for own predictions). Position labels are normalised to lowercase in keys.
| Field | Meaning |
|:---|:---|
| `state` | Current FSM state. See [Architecture &mdash; FSM](Reference-Architecture#fsm). |
| `ctrl` | Control-axis position (`0..100`). |
| `mode` | One of `auto` / `virtualControl` / `fysicalControl`. |
| `runtime` | Accumulated hours in active states (operational and movement variants). |
| `flow.predicted.{downstream,atequipment}.default` | Predicted flow at the current operating point (canonical m³/s; renders to `m3/h`). |
| `power.predicted.atequipment.default` | Predicted shaft power (canonical W; renders to `kW`). |
| `predictionQuality` | `good` / `warming` / `degraded` / `invalid` &mdash; derived by `predictionHealth` from drift + pressure availability. |
| `predictionPressureSource` | `dashboard-sim` (virtual children active) or a real-child id (real children preferred). |
| `predictionFlags` | Reason codes when health < `good` (e.g. `pressure_init_warming`, `flow_high_drift`). |
| `cog` / `NCog` / `NCogPercent` | Centre-of-gravity metric on the η curve. `NCog` is normalised 0..1. |
| `effDistFromPeak` / `effRelDistFromPeak` | Distance from the η peak (absolute and 0..1 relative). |
---
## The new bit &mdash; sequence-abort token
When a parent MGC sends a new demand, it calls `abortMovement` to interrupt any in-flight `accelerating` / `decelerating` movement. Before 2026-05-15 that abort only stopped the moveTo &mdash; an in-flight `executeSequence('shutdown')` for-loop would keep transitioning the FSM through `stopping &rarr; coolingdown &rarr; idle`, fighting the new dispatch's residue-handler.
The pump now carries a monotonic `sequenceAbortToken` on its state object. External aborts (the kind MGC fires) advance it; sequence-internal aborts (e.g. shutdown's own pre-empt of its ramp-down step) do not. `executeSequence` captures the token at entry and bails out before its next transition if the counter has advanced.
Net effect: a mid-decel re-engage takes the pump cleanly back to operational, without the orphaned shutdown completing in the background. `warmingup` and `coolingdown` remain protected at the stateManager layer &mdash; safety guarantees are unchanged.
See [Architecture &mdash; FSM](Reference-Architecture#fsm) for the full mechanism.
---
## Need more?
| Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Full topic contract, config schema, child registration filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, FSM, prediction pipeline, drift, lifecycle |
| [Reference &mdash; Examples](Reference-Examples) | Shipped example flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | When not to use, known limitations, open questions |
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)

View File

@@ -0,0 +1,340 @@
# Reference &mdash; Architecture
![code-ref](https://img.shields.io/badge/code--ref-394a972-blue)
> [!NOTE]
> Code structure for `rotatingMachine`: the three-tier sandwich, the `src/` layout, the FSM (with the new sequence-abort token), the prediction + drift pipeline, the lifecycle, and the output-port pipeline. For an intuitive overview, return to [Home](Home).
---
## Three-tier code layout
```
nodes/rotatingMachine/
|
+-- rotatingMachine.js entry: RED.nodes.registerType('rotatingMachine', NodeClass)
|
+-- src/
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
| specificClass.js extends BaseDomain (orchestration only)
| |
| +-- commands/
| | index.js topic descriptors
| | handlers.js pure handler functions
| |
| +-- curves/
| | curveLoader.js load supplier curve by model id
| | curveNormalizer.js unit + shape normalisation
| | reverseCurve.js invert flow → ctrl for predictCtrl
| |
| +-- prediction/
| | predictors.js buildPredictors(curve) → predictFlow / Power / Ctrl
| | groupPredictors.js buildGroupPredictors() for MGC integration
| | predictionMath.js calcFlow / calcPower / calcCtrl / inputFlowCalcPower
| | efficiencyMath.js calcCog / calcEfficiency / calcDistanceBEP
| | operatingPoint.js legacy hook kept for migrations
| |
| +-- drift/
| | driftAssessor.js per-metric drift pipeline (EWMA + alignment)
| | healthRefresh.js updates predictionHealth + pressureDrift
| | predictionHealth.js derives quality / confidence / flags
| |
| +-- pressure/
| | pressureInitialization.js pressure-source readiness tracker
| | pressureRouter.js routes upstream / downstream measurements
| | pressureSelector.js pushes fDimension onto predictors
| | virtualChildren.js auto-registered dashboard-sim children
| |
| +-- state/
| | stateBindings.js wires state.emitter to host callbacks
| | sequenceController.js setpoint / executeSequence / waitForOperational
| |
| +-- measurement/
| | measurementHandlers.js per-type handlers (flow / power / temperature)
| | childRegistrar.js filter-aware listener attach / detach
| |
| +-- flow/
| | flowController.js action dispatch (handleInput)
| |
| +-- display/
| | workingCurves.js query.curves / query.cog reply shape
| |
| +-- io/
| output.js getOutput() shape + status badge
```
### Tier responsibilities
| Tier | File | What it owns | Touches `RED.*` |
|:---|:---|:---|:---:|
| entry | `rotatingMachine.js` | Type registration | Yes |
| nodeClass | `src/nodeClass.js` | Input routing, output ports, status-badge polling (`statusInterval=1000`). Stashes `stateConfig` and `errorMetricsConfig` on the class for the constructor. No tick loop &mdash; event-driven. | Yes |
| specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; expose the same public surface MGC + pumpingStation already call (`handleInput`, `abortMovement`, `setGroupOperatingPoint`, `registerChild`, &hellip;); delegate everything else. | No |
`specificClass` is stitching. All real work lives in the concern modules: pure math in `prediction/`, `drift/`; live-state-touching in `pressure/`, `state/`, `measurement/`, `flow/`.
---
## FSM
The state machine is declared in `generalFunctions/src/state/stateConfig.json`. Allowed transitions (relevant subset):
```mermaid
stateDiagram-v2
[*] --> idle
idle --> starting: startup
idle --> off
idle --> maintenance
starting --> warmingup: timer (time.starting)
warmingup --> operational: timer (time.warmingup) [protected]
operational --> accelerating: setpoint up
operational --> decelerating: setpoint down
operational --> stopping: shutdown
accelerating --> operational: target reached
decelerating --> operational: target reached
stopping --> coolingdown: timer (time.stopping)
coolingdown --> idle: timer (time.coolingdown) [protected]
coolingdown --> off
off --> idle: boot (first step)
off --> maintenance
maintenance --> off: exitmaintenance (step 1)
maintenance --> idle
note right of operational
any state -> emergencystop via cmd.estop
from emergencystop: idle / off / maintenance
end note
```
Allowed transitions are declared in `generalFunctions/src/state/stateConfig.json` `allowedTransitions`. The diagram omits the `emergencystop` arrows for readability &mdash; every state has one. Self-edges (`starting → starting`, `maintenance → maintenance`) exist in the config for re-entrancy but aren't load-bearing.
### Protected states
`warmingup` and `coolingdown` are **protected** in `state.js` `transitionToState`. When the FROM-state is one of these, the abort signal passed to `stateManager.transitionTo` is nulled out:
```js
const protectedStates = ['warmingup', 'coolingdown'];
const isProtectedTransition = protectedStates.includes(fromState);
if (isProtectedTransition) {
signal = null;
this.logger.warn(`Transition from ${fromState} to ${targetState} is protected and cannot be aborted.`);
}
```
So `abortCurrentMovement` cannot interrupt a warmup or cooldown. This is a deliberate safety guarantee &mdash; aborting a motor warmup risks burn-up.
### Routine vs sequence-internal aborts
`state.abortCurrentMovement(reason, options)` accepts:
| Option | Default | Used by | Effect |
|:---|:---|:---|:---|
| `returnToOperational: false` | yes (default) | MGC's `abortActiveMovements` &mdash; new-demand aborts | Aborts the moveTo. Does NOT auto-transition to operational (avoids a bounce loop on per-tick aborts). **Advances `sequenceAbortToken`** so any in-flight `executeSequence` bails out. |
| `returnToOperational: true` | &mdash; | `executeSequence` itself when a fresher shutdown / e-stop pre-empts its own setpoint-to-zero step | Aborts the moveTo and auto-transitions back to operational so the sequence can proceed. Does NOT advance `sequenceAbortToken`. |
### Sequence-abort token &mdash; what it does
`state.sequenceAbortToken` is a monotonic counter, advanced on every external (non-internal) abort. `sequenceController.executeSequence` captures the value at entry:
```js
const startToken = host.state.sequenceAbortToken ?? 0;
const aborted = () => (host.state.sequenceAbortToken ?? 0) !== startToken;
```
and checks before:
1. Entering the for-loop (after the optional `setpoint(host, 0)` ramp-down step).
2. Every iteration of the state-transition for-loop.
A mismatch breaks the loop early with `Sequence '<name>' interrupted ... by external abort`. The pump's `updatePosition` runs anyway so output state stays consistent.
Why this matters: without the token, a shutdown's for-loop continues to run after `abortMovement` rejects its `setpoint(host, 0)`. The pump can transition `operational → stopping → coolingdown → idle` even when a new dispatch has already taken the FSM back to operational via the residue handler. The token snapshot ensures only **one** of those two paths wins per dispatch.
### Residue-state handling in `moveTo`
`state.moveTo` recognises `accelerating` and `decelerating` as **post-abort residue states**. If a setpoint arrives in either, it transitions back to `operational` first, then proceeds with the new move:
```js
const movementResidueStates = ['accelerating', 'decelerating'];
if (movementResidueStates.includes(this.stateManager.getCurrentState())) {
await this.transitionToState("operational");
// Fall through — state is now operational, proceed with new move.
}
```
This is what makes mid-flight retargets work without parking the new setpoint in `delayedMove`.
### `delayedMove` &mdash; deferred setpoint
When a setpoint arrives while the FSM is in a genuinely non-operational, non-residue state (`starting`, `warmingup`, `stopping`, `coolingdown`, `idle`, `off`, `emergencystop`, `maintenance`) AND mode is `auto`, the value is stashed in `state.delayedMove`. The next transition INTO `operational` picks it up and fires `moveTo(delayedMove)`. So a flow setpoint sent during startup is queued, not lost.
### State-entry timestamp + remaining transition
`stateManager.stateEnteredAt` is wall-clock-stamped on every state assignment (constructor + both transition branches). `stateManager.getRemainingTransitionS()` returns `max(0, transitionTimes[currentState] elapsed)`. The MGC movement planner calls this through `machineProfile.buildProfile` to compute exact rendezvous time for pumps currently in `warmingup` / `starting`.
---
## Prediction + drift pipeline
```mermaid
flowchart TB
sim[data.simulate-measurement]:::input --> pi[pressureInitialization]
real[measurement child<br/>pressure.measured.up/down]:::input --> pi
pi --> ps[pressureSelector<br/>prefers real over virtual]
ps --> fd[fDimension push:<br/>predictFlow / predictPower / predictCtrl]
fd --> upd[updatePosition&#40;&#41;]
upd --> calc[calcFlowPower&#40;ctrl&#41;]
calc --> meas[MeasurementContainer<br/>flow.predicted.*<br/>power.predicted.atequipment]
measFlow[flow.measured.*]:::input --> drift[DriftAssessor<br/>EWMA + alignment]
measPower[power.measured.atequipment]:::input --> drift
meas --> drift
drift --> health[predictionHealth.refresh<br/>quality / confidence / flags]
health --> out[Port 0]
upd --> out
classDef input fill:#a9daee,color:#000
```
### Curve loading
At `configure()` startup:
1. `assetResolver.resolveAssetMetadata('rotatingmachine', model)` resolves supplier / type / allowed units from `generalFunctions/datasets/assetData/`.
2. `asset.unit` is validated (must be a flow unit) and soft-warned if not in the registry's recommended list.
3. `loadModelCurve(model)` reads the raw supplier curve.
4. `normalizeMachineCurve(rawCurve, unitPolicy, logger)` unit-converts and shape-normalises.
5. `buildPredictors(curve)` returns `{predictFlow, predictPower, predictCtrl}` where `predictCtrl` is the reverse curve (flow → control %).
Any failure installs **null predictors** (the asset still loads but emits zeros). The status badge falls through to a `predictionQuality: 'invalid'` state on Port 0.
### Drift
`DriftAssessor` wraps `generalFunctions/nrmse` into per-metric drift profiles. Defaults (`flow` and `power`):
| Field | Value | Notes |
|:---|:---|:---|
| `windowSize` | `30` | Sample count for long-term NRMSE |
| `minSamplesForLongTerm` | `10` | Below this, long-term level stays at 3 (=invalid) |
| `ewmaAlpha` | `0.15` | Immediate-level smoothing |
| `alignmentToleranceMs` | `2500` | Predicted ↔ measured timestamps must align within this |
| `strictValidation` | `true` | Reject samples on alignment failure |
Drift feeds `predictionHealth.refresh` &mdash; immediate-level and long-term-level reduce `predictionConfidence` and append `flow_*_drift` / `power_*_drift` flags. Pressure drift is computed separately (real vs virtual divergence).
### Virtual pressure children
Two `measurement`-typed children are auto-registered at startup:
| ID | Position |
|:---|:---|
| `dashboard-sim-upstream` | `upstream` |
| `dashboard-sim-downstream` | `downstream` |
`data.simulate-measurement` payloads land on these. `pressureSelector` prefers any **real** pressure child over the virtuals once one registers; the virtuals stay live so the dashboard can keep injecting test values.
---
## Lifecycle &mdash; what one event does
```mermaid
sequenceDiagram
autonumber
participant parent as MGC / pumpingStation / GUI
participant rm as rotatingMachine
participant fc as flowController
participant fsm as state (FSM)
participant pred as predictors
participant out as Port 0 / 1
parent->>rm: flowmovement (Q, unit)
rm->>fc: flowController.handle('parent', 'flowmovement', Q)
fc->>fc: mode/source allow-list check
fc->>fc: convert Q (output unit → canonical m³/s)
fc->>fc: pos = host.calcCtrl(Q)
fc->>fsm: setpoint(pos) → state.moveTo(pos)
Note over fsm: residue handler may re-enter operational first
fsm-->>rm: positionChange events per move tick
rm->>pred: calcFlowPower(pos) → cFlow, cPower
rm->>rm: calcEfficiency / cog / distance-BEP
rm->>out: notifyOutputChanged (Port 0/1 delta)
parent->>rm: execsequence ('startup' | 'shutdown')
rm->>fsm: executeSequence → state transitions
fsm-->>rm: stateChange events → _updateState
```
### Mode + source allow-lists
Each input is gated twice in `flowController.handle`:
1. `host.isValidActionForMode(action, currentMode)` &mdash; matrix lives in `config.mode.allowedActions`.
2. `host.isValidSourceForMode(source, currentMode)` &mdash; matrix in `config.mode.allowedSources`.
Defaults (per `generalFunctions/src/configs/rotatingMachine.json`):
| Mode | Allowed actions | Allowed sources |
|:---|:---|:---|
| `auto` | `statuscheck, execmovement, execsequence, flowmovement, emergencystop, entermaintenance` | `parent, GUI, fysical` |
| `virtualControl` | `statuscheck, execmovement, flowmovement, execsequence, emergencystop, exitmaintenance` | `GUI, fysical` |
| `fysicalControl` | `statuscheck, emergencystop, entermaintenance, exitmaintenance` | `fysical` |
A rejected action logs at warn (`<source> is not allowed in mode <mode>` or `<action> is not allowed in mode <mode>`) and short-circuits.
---
## Output ports
| Port | Carries | Sample shape |
|:---|:---|:---|
| 0 (process) | Delta-compressed state snapshot &mdash; FSM state, predictions, drift, prediction health | `{topic, payload: {state, ctrl, flow.predicted.*, power.predicted.*, predictionQuality, ...}}` |
| 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | `rotatingMachine,id=pump_a state="operational",ctrl=60,flow_predicted_downstream_default=12.4,...` |
| 2 (register / control) | `child.register` upward at init | `{topic: 'child.register', payload: {ref, softwareType, config}}` |
Port-0 key shape is **`<type>.<variant>.<position>.<childId>`**. The trailing `<childId>` lets dashboards distinguish the same measurement type / position registered from different sources (real sensor vs `dashboard-sim`).
See [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the full InfluxDB layout.
---
## Event sources
| Source | Where it fires | What it triggers |
|:---|:---|:---|
| `state.emitter` `'positionChange'` | `movementManager` setInterval during a move | `updatePosition()` &mdash; recompute predictions + Port 0 |
| `state.emitter` `'stateChange'` | `stateManager.transitionTo` resolve | `_updateState()` &mdash; zero predictions if non-operational, refresh health, Port 0 |
| `state.emitter` `'movementComplete'` | `state.moveTo` after a successful move | (subscribed but currently unused by orchestrator) |
| `state.emitter` `'movementAborted'` | `state.moveTo` catch on aborted move | (subscribed but currently unused) |
| Child measurement emitter | `child.measurements.emitter` per type / position | `pressureRouter.route` or `measurementHandlers.dispatch` |
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch |
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render |
No per-second tick on the domain itself. The movementManager's inner setInterval (50 ms by default) only runs while a position move is in flight.
---
## Where to start reading
| If you're changing... | Read first |
|:---|:---|
| Curve loading, normalisation, fallback | `src/curves/{curveLoader, curveNormalizer, reverseCurve}.js` |
| Per-machine + group predictors | `src/prediction/predictors.js`, `groupPredictors.js`, `predictionMath.js` |
| Drift detection (EWMA, alignment) | `src/drift/{driftAssessor, healthRefresh, predictionHealth}.js` |
| Pressure plumbing, virtual vs real preference | `src/pressure/{pressureInitialization, pressureRouter, pressureSelector, virtualChildren}.js` |
| FSM bindings, setpoint, sequence orchestration | `src/state/{stateBindings, sequenceController}.js` + `generalFunctions/src/state/{state, stateManager, movementManager}.js` |
| Sequence-abort token (the cooperating change for MGC's planner) | `generalFunctions/src/state/state.js` `abortCurrentMovement` + `src/state/sequenceController.js` `executeSequence` |
| Per-type measurement handlers | `src/measurement/{measurementHandlers, childRegistrar}.js` |
| Top-level action dispatch | `src/flow/flowController.js` |
| `query.curves` / `query.cog` outputs | `src/display/workingCurves.js` |
| Output shape, status badge | `src/io/output.js` |
| Topic registration, payload validation | `src/commands/{index, handlers}.js` |
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Home) | The grouped-control parent: planner, optimizer, rendezvous |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |

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

@@ -0,0 +1,279 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-394a972-blue)
> [!NOTE]
> Full topic contract, configuration schema, and child-registration filters for `rotatingMachine`. Source of truth: `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/rotatingMachine.json`.
>
> For an intuitive overview, return to the [Home](Home).
---
## Topic contract
The registry lives in `src/commands/index.js`. Each descriptor maps a canonical `msg.topic` to its handler; aliases emit a one-time deprecation warning the first time they fire.
| Canonical topic | Aliases | Payload | Unit | Effect |
|:---|:---|:---|:---|:---|
| `set.mode` | `setMode` | `string` (`auto` / `virtualControl` / `fysicalControl`) | &mdash; | Switch operational mode. Each mode has its own allow-list of actions and sources. |
| `cmd.startup` | &mdash; | any | &mdash; | Run the configured `startup` sequence (default `[starting, warmingup, operational]`). |
| `cmd.shutdown` | &mdash; | any | &mdash; | Run the `shutdown` sequence. If currently `operational`, `executeSequence` first ramps the setpoint to 0 (interruptible). |
| `cmd.estop` | `emergencystop` | any | &mdash; | Run the `emergencystop` sequence (default `[emergencystop, off]`). Reachable from every state. |
| `set.setpoint` | `execMovement` | `{setpoint: number}` | control % (no `units` &mdash; convert has no `percent` measure) | Move to a control-axis setpoint via `state.moveTo`. |
| `set.flow-setpoint` | `flowMovement` | `{setpoint: number}` or bare number | `volumeFlowRate` (default `m3/h`) | Convert to canonical m³/s, then to control % via `predictCtrl.y`, then `state.moveTo`. |
| `data.simulate-measurement` | `simulateMeasurement` | `{asset: {type, unit}, value, position, childName?, childId?}` | type-specific | Inject a virtual sensor reading. The two virtual children (`dashboard-sim-upstream` / `-downstream`) auto-handle pressure; other types use the registering child's id. |
| `query.curves` | `showWorkingCurves` | any | &mdash; | Reply on Port 0 with the current working curves (flow / power / efficiency). |
| `query.cog` | `CoG` | any | &mdash; | Reply on Port 0 with the centre-of-gravity (CoG) point. |
| `child.register` | `registerChild` | `string` (child node id) | &mdash; | Register a `measurement` child with this machine. Port 2 wiring does this automatically in normal flows. |
| `execSequence` | &mdash; | `{action: "startup" \| "shutdown"}` | &mdash; | Legacy umbrella: demuxes `payload.action` to the canonical `cmd.startup` / `cmd.shutdown` handler. Marked `_legacy: true`; scheduled for removal. |
### Mode / source / action allow-lists
A topic that survives the registry still passes through `flowController.handle`:
```js
if (!host.isValidActionForMode(action, host.currentMode)) return;
if (!host.isValidSourceForMode(source, host.currentMode)) return;
```
Defaults from the schema:
| Mode | `allowedActions` | `allowedSources` |
|:---|:---|:---|
| `auto` | `statuscheck, execmovement, execsequence, flowmovement, emergencystop, entermaintenance` | `parent, GUI, fysical` |
| `virtualControl` | `statuscheck, execmovement, flowmovement, execsequence, emergencystop, exitmaintenance` | `GUI, fysical` |
| `fysicalControl` | `statuscheck, emergencystop, entermaintenance, exitmaintenance` | `fysical` |
A rejected request logs at warn and short-circuits; nothing reaches the FSM.
---
## Data model &mdash; `getOutput()` shape
Composed each tick by `src/io/output.js` `buildOutput()`. Delta-compressed: consumers see only the keys that changed.
### Per-measurement keys
For every `(type, variant, position)` stored in MeasurementContainer, the flattened output emits:
```
<type>.<variant>.<position>.<childId>
```
Position labels are normalised to lowercase in the keys (`atequipment`, `downstream`, `upstream`, `max`, `min`). The trailing `<childId>` is:
| `<childId>` | When |
|:---|:---|
| `default` | The node's own predictions (flow / power / efficiency / Ncog). |
| `dashboard-sim-upstream` / `dashboard-sim-downstream` | The two auto-registered virtual pressure children. |
| The real child's `general.id` | When a registered measurement child wrote the value. |
Sample keys (operational pump, simulated pressure):
| Key | Type | Unit | Notes |
|:---|:---|:---|:---|
| `flow.predicted.downstream.default` | number | m³/h | Live predicted flow. |
| `flow.predicted.atequipment.default` | number | m³/h | Same number, equipment-side label. |
| `flow.predicted.max.default` / `.min.default` | number | m³/h | Curve envelope at the current `fDimension`. |
| `power.predicted.atequipment.default` | number | kW | Predicted shaft power. |
| `pressure.measured.upstream.dashboard-sim-upstream` | number | mbar | Last simulated suction pressure. |
| `pressure.measured.downstream.dashboard-sim-downstream` | number | mbar | Last simulated discharge pressure. |
| `temperature.measured.atequipment.dashboard-sim-upstream` | number | °C | Default 15°C until overwritten. |
| `atmPressure.measured.atequipment.dashboard-sim-upstream` | number | Pa | Default 101325 Pa until overwritten. |
### Scalar keys
| Key | Type | Source | Notes |
|:---|:---|:---|:---|
| `state` | string | `host.state.getCurrentState()` | One of the FSM states (`idle`, `starting`, `warmingup`, &hellip;). |
| `ctrl` | number | `host.state.getCurrentPosition()` | Control-axis position 0..100. |
| `mode` | string | `host.currentMode` | `auto` / `virtualControl` / `fysicalControl`. |
| `runtime` | number | `host.state.getRunTimeHours()` | Cumulative hours in active states. |
| `moveTimeleft` | number | `host.state.getMoveTimeLeft()` | Seconds remaining on the current move (0 when idle). |
| `maintenanceTime` | number | `host.state.getMaintenanceTimeHours()` | Cumulative hours in maintenance. |
| `cog` / `NCog` / `NCogPercent` | number | `host.cog` etc. | CoG metric on the η curve. `NCog` 0..1; `NCogPercent` is `NCog * 100`, rounded to 2 dp. |
| `effDistFromPeak` | number | `host.absDistFromPeak` | Absolute η distance to peak. |
| `effRelDistFromPeak` | number | `host.relDistFromPeak` | Normalised 0..1; `undefined` when η band collapses. |
| `predictionQuality` | string | `host.predictionHealth.quality` | `good` / `warming` / `degraded` / `invalid`. |
| `predictionConfidence` | number | `host.predictionHealth.confidence` | 0..1, rounded to 3 dp. |
| `predictionPressureSource` | string \| null | `host.predictionHealth.pressureSource` | `dashboard-sim` or a real child id; null until pressure landed. |
| `predictionFlags` | array | `host.predictionHealth.flags` | Reason codes (e.g. `pressure_init_warming`). |
| `pressureDriftLevel` | number | `host.pressureDrift.level` | 0..3. |
| `pressureDriftSource` | string \| null | `host.pressureDrift.source` | Source whose drift is worst. |
| `pressureDriftFlags` | array | `host.pressureDrift.flags` | `nominal` when no drift detected. |
| `flowNrmse` / `flowLongTermNRMSD` / `flowImmediateLevel` / `flowLongTermLevel` / `flowDriftValid` | numbers / number / number / boolean | `host.flowDrift` | Only present once `flowDrift != null`. |
| `powerNrmse` / `powerLongTermNRMSD` / `powerImmediateLevel` / `powerLongTermLevel` / `powerDriftValid` | same | `host.powerDrift` | Same. |
### Status badge
`buildStatusBadge` in `io/output.js`:
```
<mode>: <state-symbol> <ctrl%>% 💨<flow><unit> ⚡<power>kW
```
State symbols (per `STATE_SYMBOLS` map):
| State | Symbol | Fill |
|:---|:---:|:---|
| `off` | ⬛ | red |
| `idle` | ⏸️ | blue |
| `operational` | ⏵️ | green |
| `starting` | ⏯️ | yellow |
| `warmingup` | 🔄 | green |
| `accelerating` | ⏩ | yellow |
| `decelerating` | ⏪ | yellow |
| `stopping` | ⏹️ | yellow |
| `coolingdown` | ❄️ | yellow |
| `maintenance` | 🔧 | grey |
Pressure-not-initialised states (`operational`, `warmingup`, `accelerating`, `decelerating`) override the badge to a yellow ring `'<mode>: pressure not initialized'` until at least one pressure source has been written.
---
## Configuration schema &mdash; editor form to config keys
Source of truth: `generalFunctions/src/configs/rotatingMachine.json` plus `nodeClass.buildDomainConfig`.
### General (`config.general`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Name | `general.name` | derived: `<softwareType>_<id>` | Re-derived in `configure()`. |
| (auto-assigned) | `general.id` | `null` | Node-RED node id. |
| Default unit | `general.unit` | `l/s` (schema) / `m3/h` (nodeClass) | `buildDomainConfig` resolves `uiConfig.unit` via `convert` and overrides to a valid flow unit. |
| Enable logging | `general.logging.enabled` | `true` | Master switch. |
| Log level | `general.logging.logLevel` | `info` | `debug` / `info` / `warn` / `error`. |
### Functionality (`config.functionality`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | One of `atEquipment` / `upstream` / `downstream`. Used in the child-register payload that goes UP to MGC / pumpingStation. |
| (hidden) | `functionality.softwareType` | `rotatingmachine` | Constant. |
| (hidden) | `functionality.role` | `RotationalDeviceController` | Constant. |
| Distance offset | `functionality.distance` | `null` | Optional spatial offset; populated when `hasDistance` is enabled. |
| Distance unit | `functionality.distanceUnit` | `m` | |
| Distance description | `functionality.distanceDescription` | `""` | Free-text. |
### Asset (`config.asset`)
Resolved derived metadata (supplier / category / type / allowed units) lives in `generalFunctions/datasets/assetData/rotatingmachine.json` keyed by `asset.model`. The editor's asset menu reads from that registry.
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Asset UUID | `asset.uuid` | `null` | Globally-unique identifier. |
| Tag code | `asset.tagCode` | `null` | |
| Tag number | `asset.tagNumber` | `null` | Legacy column. |
| Geolocation | `asset.geoLocation` | `{x:0, y:0, z:0}` | |
| Model | `asset.model` | `null` | **Required.** Resolves curve + supplier / type / allowed units via the registry. |
| Deployment unit | `asset.unit` | `null` | **Required.** Must be a flow unit; soft-warned if not in the registry's recommended list for the model. |
| Curve units | `asset.curveUnits` | `{pressure:'mbar', flow:'m3/h', power:'kW', control:'%'}` | Carried for curve normalisation. |
| Accuracy | `asset.accuracy` | `null` | Optional sensor accuracy %. |
| (derived) | `asset.machineCurve` | `{nq:{}, np:{}}` | Loaded from `loadModelCurve(model)`, then normalised. |
> [!WARNING]
> **Legacy fields removed.** `supplier`, `category`, and `assetType` are no longer node config &mdash; the registry derives them from the model. Flows saved before the AssetResolver refactor will throw a startup error with a clear migration message. Re-open the node, re-select the model from the asset menu, and save.
### State times (`stateConfig.time`)
Set on the state machine via `nodeClass.buildDomainConfig` from editor fields:
| Form field | Config key | Default (schema) | Notes |
|:---|:---|:---|:---|
| Startup Time | `time.starting` | configured in s | Time spent in `starting` before transitioning to `warmingup`. |
| Warmup Time | `time.warmingup` | configured in s | Time in `warmingup` &mdash; **non-interruptible** safety. |
| Shutdown Time | `time.stopping` | configured in s | Time in `stopping`. |
| Cooldown Time | `time.coolingdown` | configured in s | Time in `coolingdown` &mdash; **non-interruptible** safety. |
### Movement (`stateConfig.movement`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Reaction Speed | `movement.speed` | configured in %/s | Controller ramp rate. E.g. `1` means 1%/s → setpoint 60 from idle reaches 60 in ~60 s. |
| Movement Mode | `movement.mode` | `staticspeed` | `staticspeed` (linear ramp) or `dynspeed` (cubic ease-in-out). Both yield the same total duration; only the curve differs. |
| (internal) | `movement.maxSpeed` | from schema | Hard cap honoured by `movementManager.getNormalizedSpeed`. |
| (internal) | `movement.interval` | from schema | Inner-loop tick of the move animation (ms). |
### Sequences (`config.sequences`)
State-transition lists per sequence name. Defaults:
| Sequence | States |
|:---|:---|
| `startup` | `[starting, warmingup, operational]` |
| `shutdown` | `[stopping, coolingdown, idle]` |
| `emergencystop` | `[emergencystop, off]` |
| `boot` | `[idle, starting, warmingup, operational]` |
| `entermaintenance` | `[stopping, coolingdown, idle, maintenance]` |
| `exitmaintenance` | `[off, idle]` |
Custom sequences are accepted as long as every step is a known FSM state and the transitions between them are allowed by `stateConfig.allowedTransitions`.
### Output (`config.output`)
| Form field | Config key | Default | Range | Notes |
|:---|:---|:---|:---|:---|
| Process Output | `output.process` | `process` | `process` / `json` / `csv` | Port-0 formatter. |
| Database Output | `output.dbase` | `influxdb` | `influxdb` / `json` / `csv` | Port-1 formatter. |
### Mode (`config.mode`)
| Form field | Config key | Default | Range | Notes |
|:---|:---|:---|:---|:---|
| Mode | `mode.current` | `auto` | `auto` / `virtualControl` / `fysicalControl` | The active operational mode. |
| (defaults) | `mode.allowedActions.<mode>` | see [Architecture](Reference-Architecture#mode--source-allow-lists) | enforced by `flowController.handle` |
| (defaults) | `mode.allowedSources.<mode>` | see [Architecture](Reference-Architecture#mode--source-allow-lists) | enforced by `flowController.handle` |
### Unit policy
Source: `src/specificClass.js` lines 36&ndash;41.
| Quantity | Canonical (internal) | Output (rendered) | Curve (supplier) | Required-unit |
|:---|:---|:---|:---|:---:|
| Pressure | `Pa` | `mbar` | `mbar` | ✓ |
| Atmospheric pressure | `Pa` | `Pa` | &mdash; | ✓ |
| Flow | `m3/s` | `m3/h` | `m3/h` | ✓ |
| Power | `W` | `kW` | `kW` | ✓ |
| Temperature | `K` | `°C` | &mdash; | ✓ |
| Control | &mdash; | &mdash; | `%` | &mdash; |
`requireUnitForTypes` means MeasurementContainer rejects writes that omit `unit` for these types.
---
## Child registration
Source: `src/measurement/childRegistrar.js` `registerMeasurementChild`. The registrar reads `asset.type` and `positionVsParent` from the child's config and subscribes to `<type>.measured.<position>` on the child's measurement emitter.
| Software type | Filter | Wired to | Side-effect |
|:---|:---|:---|:---|
| `measurement` | `asset.type='pressure', position=upstream` | `pressureRouter.route('upstream', value, ctx)` | Stored as upstream pressure; refresh prediction + drift. `pressureInitialization` tracks readiness. |
| `measurement` | `asset.type='pressure', position=downstream` | `pressureRouter.route('downstream', value, ctx)` | Same on the discharge side. |
| `measurement` | `asset.type='flow', position=*` | `measurementHandlers.updateMeasuredFlow` | Stored; drift assessed against predicted. |
| `measurement` | `asset.type='power', position=atEquipment` | `measurementHandlers.updateMeasuredPower` | Stored; drift assessed against predicted. |
| `measurement` | `asset.type='temperature', position=*` | `measurementHandlers.updateMeasuredTemperature` | Stored; surfaced on Port 0. |
### Virtual pressure children &mdash; auto-registered
At startup `specificClass` registers two `measurement`-typed children:
| Child id | Position | Default value | Use |
|:---|:---|:---|:---|
| `dashboard-sim-upstream` | `upstream` | 0 mbar | Receives `data.simulate-measurement` payloads with position `upstream`. |
| `dashboard-sim-downstream` | `downstream` | 0 mbar | Same for `downstream`. |
`pressureSelector` prefers a real registered child over the virtuals once one shows up &mdash; the virtuals keep listening so dashboards can still inject sim values during real-pressure outages.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, FSM, prediction + drift pipeline |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [EVOLV &mdash; Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
| [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |

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

@@ -0,0 +1,169 @@
# Reference &mdash; Examples
![code-ref](https://img.shields.io/badge/code--ref-394a972-blue)
> [!NOTE]
> Every example flow shipped under `nodes/rotatingMachine/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/rotatingMachine/examples/`.
---
## Shipped examples
| File | Tier | Dependencies | What it shows |
|:---|:---:|:---|:---|
| `01 - Basic Manual Control.json` | 1 | EVOLV only | Single pump driven by inject buttons &mdash; mode switching, startup / shutdown / e-stop, control-% and flow-unit setpoints, simulated pressures, maintenance enter / leave. Debug taps on all three ports. |
| `02 - Integration with Machine Group.json` | 2 | EVOLV only | Parent-child demo &mdash; one `machineGroupControl` with 2 `rotatingMachine` children. Auto-registration via Port 2 on deploy. Per-pump simulated pressures. |
| `03 - Dashboard Visualization.json` | 3 | EVOLV + `@flowfuse/node-red-dashboard` | FlowFuse charts: flow / power / pressure trends, status panel, per-pump controls. |
Three legacy files (`basic.flow.json`, `integration.flow.json`, `edge.flow.json`) are kept until the new Tier-2 has been fully Docker-validated; they predate the AssetResolver refactor and may need re-save in the editor before they deploy.
---
## Loading a flow
### Via the editor
1. Open the Node-RED editor at `http://localhost:1880`.
2. Menu &rarr; Import &rarr; drag the JSON file.
3. Click Deploy.
(The numbered files contain spaces; in the editor's import dialog the filename is purely cosmetic.)
### Via the Admin API
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @"nodes/rotatingMachine/examples/01 - Basic Manual Control.json" \
http://localhost:1880/flows
```
---
## Example 01 &mdash; Basic Manual Control
Single-pump flow with one of every input you'd ever send. Validated against a live Node-RED instance (2026-03-05).
### Nodes on the tab
| Type | Purpose |
|:---|:---|
| `comment` | Tab header / driver-group labels |
| `inject` &times; 9 | Mode (auto / virtualControl), startup, shutdown, e-stop, setpoint = 30 / 60 / 100 %, simulated upstream + downstream pressures, simulate flow / power for drift |
| `rotatingMachine` | The unit under test |
| `debug` &times; 3 | Port 0 (process), Port 1 (telemetry), Port 2 (registration) |
### What to do after deploy
1. Click the two pressure simulations (upstream = 0 mbar, downstream = 1100 mbar). Once both land, `predictionPressureSource` flips from `null` to `dashboard-sim` and `predictionFlags` drops the `pressure_init_warming` flag.
2. Click `set.mode = virtualControl` so the GUI source is allowed.
3. Click `cmd.startup`. Watch Port 0 in the debug pane: `state` walks `idle &rarr; starting &rarr; warmingup &rarr; operational`. `runtime` starts accumulating.
4. Click `set.setpoint = 60` (control %). `state` goes `operational &rarr; accelerating &rarr; operational`; `ctrl` rises from 0 to 60 at the configured `Reaction Speed`. `flow.predicted.downstream.default` and `power.predicted.atequipment.default` update at every position tick.
5. Click `set.flow-setpoint = {value: 80, unit: 'm3/h'}` &mdash; same path, but the setpoint is a flow value; the node converts via `predictCtrl` to a control %.
6. Click `cmd.shutdown`. State: `operational &rarr; decelerating &rarr; stopping &rarr; coolingdown &rarr; idle`. The ramp-to-zero step is interruptible; the subsequent transitions are timed by `time.stopping` and `time.coolingdown`.
> [!IMPORTANT]
> **GIF needed.** Demo recording of steps 1&ndash;6 + the status badge progression. Save as `wiki/_partial-gifs/rotatingMachine/01-basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
### Try the residue handler
After the pump reaches `operational` at 60 %:
1. Send `set.setpoint = 20`. `state` goes `operational &rarr; decelerating &rarr; …`.
2. While `decelerating`, send `set.setpoint = 80`.
3. `state.moveTo` sees the residue, transitions back to `operational` synchronously, then ramps up to 80. No setpoint is lost.
This is the same mechanism the MGC planner relies on for fast retargets.
### Try the sequence-abort token
After the pump reaches `operational` at 60 %, simulate the Scenario-5 race:
1. Send `cmd.shutdown`. The pump begins ramping to zero.
2. *Within the ramp window*, send `set.setpoint = 60`. The new setpoint's residue-handler claims the FSM back to `operational`.
3. Watch the log: instead of the shutdown's for-loop continuing through `stopping &rarr; coolingdown &rarr; idle`, you'll see `Sequence 'shutdown' interrupted during ramp-down by external abort; not entering shutdown loop.`
Without the token (pre-2026-05-15), the pump would have ended at `idle` despite the new setpoint &mdash; with `delayedMove = 60` sitting unused.
---
## Example 02 &mdash; Integration with Machine Group
> [!IMPORTANT]
> **Screenshot needed.** Editor capture of `02 - Integration with Machine Group.json`. Save as `wiki/_partial-screenshots/rotatingMachine/02-integration.png`. Replace this callout with the image link.
One MGC + two rotatingMachine children. Demonstrates:
- Auto-registration via Port 2 at deploy (each pump's `child.register` reaches the MGC; no manual wiring needed).
- Independent per-pump controls (the injects still target each pump's input by id).
- Group-level aggregation: MGC's Port 0 sums the children's predicted flow + power into the group aggregate.
The MGC planner is exercised when MGC's `set.demand` fires (not in this example by default; add an inject if you want to see it).
---
## Example 03 &mdash; Dashboard Visualization
> [!IMPORTANT]
> **Screenshots needed.** Two captures: the editor tab and the rendered dashboard. Save as `wiki/_partial-screenshots/rotatingMachine/03-dashboard-editor.png` and `04-dashboard-rendered.png`.
A single pump on a FlowFuse Dashboard 2.0 page with:
- Control buttons (mode, startup, shutdown, e-stop)
- A setpoint slider
- Live status (state badge, ctrl%, predicted flow / power / efficiency)
- Trend charts: flow, power, pressure, drift level
Required: `@flowfuse/node-red-dashboard` installed in the Node-RED instance.
---
## Docker compose snippet
To bring up Node-RED + InfluxDB with EVOLV nodes pre-loaded:
```yaml
# docker-compose.yml (extract)
services:
nodered:
build: ./docker/nodered
ports: ['1880:1880']
volumes:
- ./docker/nodered/data:/data/evolv
influxdb:
image: influxdb:2.7
ports: ['8086:8086']
```
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
---
## Debug recipes
| Symptom | First thing to check | Where to look |
|:---|:---|:---|
| Editor throws `legacy asset field(s) [supplier]` on deploy | Flow predates the AssetResolver refactor. Re-open the node, pick the model from the asset menu, save. The registry derives supplier / category / type. | `src/nodeClass.js` `_rejectLegacyAssetFields`. |
| `state` stuck on `idle` after `cmd.startup` | The action isn't allowed for this mode / source combination. Check `flowController` warn log for `<source> is not allowed in mode <mode>` or `<action> is not allowed in mode <mode>`. | `_setupState`, `isValidSourceForMode`, `isValidActionForMode`. |
| `flow.predicted.*` reads `0` or `NaN` | Pressure hasn't initialised. `predictionFlags` will include `pressure_init_warming`. Inject pressure via `data.simulate-measurement` or wire real measurement children. | `getMeasuredPressure` + `pressureSelector`. |
| `predictionQuality: 'invalid'` from startup | Curve normalisation failed &mdash; null predictors installed. Look for `Curve normalization failed for model …` in the log. The asset / model is unrecognised, the unit isn't a flow unit, or the registry entry is missing. | `_setupCurves`. |
| Drift level stays at `3` after startup | Fewer than `minSamplesForLongTerm = 10` paired samples have landed. Wait ~10 ticks; the level falls automatically. | `driftProfiles.minSamplesForLongTerm`. |
| `cmd.estop` and then the pump won't restart | Allowed transitions out of `emergencystop` are `idle` / `off` / `maintenance`. Send `cmd.shutdown` to drop into `idle`, then `cmd.startup`. | `stateConfig.allowedTransitions.emergencystop`. |
| Position bounces near the target | `dynspeed` (cubic ease-in-out) can overshoot at high speed. Try `staticspeed` (linear). Both modes have the same total duration. | `movement.mode`. |
| Pump still drifts to `idle` after a mid-shutdown re-engage | Verify the submodule is at `394a972` or newer &mdash; the sequence-abort token in `state.js` + `sequenceController.js` is what closes that race. | `state.sequenceAbortToken`. |
| `data.simulate-measurement` payloads aren't reflected on Port 0 | Payload shape: `{asset: {type: 'pressure', unit: 'mbar'}, value: 1100, position: 'downstream', childId: 'dashboard-sim-downstream'}`. Missing `asset.type` or `position` gets a `Unsupported simulateMeasurement type:` warn and is dropped. | `measurementHandlers.updateSimulatedMeasurement`. |
| Per-pump Port 0 key names differ from what your dashboard expects | rotatingMachine uses `<type>.<variant>.<position>.<childId>` (e.g. `flow.predicted.downstream.default`). MGC uses `<position>_<variant>_<type>`. Don't mix them. | `io/output.js`, `MeasurementContainer.getFlattenedOutput`. |
> Never ship `enableLog: 'debug'` in a demo &mdash; fills the container log within seconds and obscures real errors.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, FSM, prediction + drift pipeline |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [machineGroupControl &mdash; Examples](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Reference-Examples) | Group-control demo flows |
| [EVOLV &mdash; Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where rotatingMachine fits in a larger plant |

View File

@@ -0,0 +1,105 @@
# Reference &mdash; Limitations
![code-ref](https://img.shields.io/badge/code--ref-394a972-blue)
> [!NOTE]
> What `rotatingMachine` does not do, current rough edges, and open questions. Open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` in the superproject.
---
## When you would not use this node
| Scenario | Use instead |
|:---|:---|
| A passive non-return / check valve (no motor) | `valve` &mdash; no curve, no FSM-driven motor. |
| A valve actuator (motorised, no characteristic curve) | `valve` (and `valveGroupControl` if grouped). |
| A group of 2&nbsp;+ pumps load-sharing on a header | `machineGroupControl` &mdash; instantiate this as a child. |
| A curve-less asset | Predictions degrade to zero, drift becomes meaningless, status badge falls into `predictionQuality: 'invalid'`. There is no fallback model. |
| A compressor with significant gas compressibility | Predictor uses an incompressible-flow curve; output is qualitatively right but quantitatively biased. Tracked. |
---
## Known limitations
### Single-side pressure degrades silently
`pressureSelector.getMeasuredPressure` accepts only-upstream or only-downstream readings as a fallback when the differential is unknown. It logs a warn (`Using downstream pressure only for prediction: …. Prediction accuracy is degraded; inject upstream pressure too.`) but proceeds. The predictor uses the absolute pressure as a surrogate differential, which can materially bias flow predictions under varying suction conditions. The warn is one-shot per state transition, not per tick &mdash; it can be missed in long-running deployments. Tracked.
### Multi-parent registration
`childRegistrationUtils` accepts registration under multiple parents. The pump emits child-register messages to each, and parents listen in parallel. Teardown ordering (parent gone first vs pump gone first) is not test-covered; observed behaviour in production is "fine, mostly". If you wire one pump to two MGCs and remove one MGC mid-deployment, the pump's listener set may keep a stale reference. Open question.
### `data.simulate-measurement` doesn't clear stale values
If you toggle a virtual pressure off (stop sending the inject), the last-known value persists in the MeasurementContainer. There is no TTL and no explicit clear topic. Workaround: send `value: null` or `0` explicitly. Tracked.
### `execSequence` legacy umbrella
The `execSequence` topic (with `payload.action = "startup" | "shutdown"`) is kept alive for legacy flows. The handler demuxes to the canonical topic; both emit a one-time deprecation warning. Scheduled for removal in a later phase. Use `cmd.startup` / `cmd.shutdown` instead.
### Drift confidence collapses on long pressure-source outages
`predictionHealth.refresh` reduces `predictionConfidence` to 0 when no pressure source has produced a reading in &gt; 30 s. The quality string flips to `invalid` &mdash; downstream consumers should treat this as "predictor is offline, ignore values" rather than "predictor is broken". The recovery is automatic: as soon as a pressure measurement lands, health climbs back. Open question whether to model this as a discrete "stale" quality state instead.
### `state` stays in residue after a routine abort
`abortCurrentMovement` with default options (the kind MGC fires) does **not** auto-transition the FSM back to `operational`. The pump stays parked in `accelerating` / `decelerating` until the next `moveTo` arrives &mdash; at which point the residue handler in `state.moveTo` runs the transition synchronously. By design (a previous version auto-transitioned and created a bounce loop where every tick aborted, returned, re-moved, aborted again). See the comment in `state.js` `moveTo` line 76 for the historical detail.
### Editor cosmetics don't reflect `asset` derivation
The editor form still has visual sections for supplier / category / type even though the registry derives them. They're read-only and informational; some fields render as blank until you select a model. Cosmetic; the registry is the source of truth.
---
## Open questions (tracked)
| Question | Where it lives |
|:---|:---|
| Should the predictor use an explicit "stale" quality state instead of collapsing to `invalid` when pressure data dries up? | Internal &mdash; not yet ticketed |
| Multi-parent teardown ordering | Internal |
| Add an explicit `data.clear-simulated-measurement` topic for sim cleanup | Internal |
| Compressor / gas-flow curve handling | Internal (long-term) |
| Phase 7 removal of `execSequence` umbrella + legacy aliases | Internal |
| Curve loader robustness: warn / refuse mismatched curve units instead of best-effort normalising | `OPEN_QUESTIONS.md` (rotatingMachine entry) |
---
## Migration notes
### From pre-AssetResolver
Old flows saved with `supplier`, `category`, or `assetType` fields will throw on deploy:
```
rotatingMachine: legacy asset field(s) [supplier, category] are saved on this node.
After the AssetResolver refactor these are derived from the model id.
Open the node in the editor, re-select the model, and save to migrate.
```
The fix is mechanical: open each rotatingMachine node, re-pick the model from the asset menu, save. No data is lost &mdash; the registry has the same supplier / category / type the old flow carried.
### From pre-sequence-abort-token
Before 2026-05-15 a mid-decel re-engage was a race &mdash; sometimes the shutdown's for-loop won and parked the pump at `idle` with an orphaned `delayedMove`. With the `sequenceAbortToken` mechanism in `state.js` + `sequenceController.js` (from `394a972` onward), the new-dispatch's `abortCurrentMovement` always wins: the shutdown's for-loop breaks out before its next transition.
If you have an integration test that relied on the older "shutdown always completes" behaviour, expect to see `Sequence 'shutdown' interrupted ... by external abort` warnings instead. That's the intended new state.
### From `setpoint` topic name (pre-canonical)
The old `setpoint` topic without a `set.` prefix has been retired. Use `set.setpoint` (alias `execMovement`) for control-% setpoints and `set.flow-setpoint` (alias `flowMovement`) for flow setpoints.
### From `execMovement` payload shape change
Legacy payloads were `{source, action: "execMovement", setpoint: number}`. The current shape is the same minus `action` (the handler dispatches via topic). Both are accepted.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters (alias map at the end) |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, FSM (including sequence-abort token), prediction + drift |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [machineGroupControl &mdash; Limitations](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Reference-Limitations) | Where the parent's planner currently bypasses priority mode |

19
wiki/_Sidebar.md Normal file
View File

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