Compare commits
18 Commits
5a8113a9d1
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
889221fffd | ||
|
|
a8d9895cbf | ||
|
|
455f15dc55 | ||
|
|
a18aec32b9 | ||
|
|
8c5822c853 | ||
|
|
c9970c0c57 | ||
|
|
426c1a606b | ||
|
|
5ea0b0bda6 | ||
|
|
394a972d10 | ||
|
|
28344c6810 | ||
|
|
b373727338 | ||
|
|
1a9f533b1e | ||
|
|
1d5e040af9 | ||
|
|
84126e9130 | ||
|
|
9e8463b41d | ||
|
|
e058fe9245 | ||
|
|
c5bb375dd0 | ||
|
|
8f9150e160 |
17
CLAUDE.md
17
CLAUDE.md
@@ -21,3 +21,20 @@ Key points for this node:
|
|||||||
- Stack same-level siblings vertically.
|
- Stack same-level siblings vertically.
|
||||||
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||||
- Wrap in a Node-RED group box coloured `#86bbdd` (Equipment Module).
|
- 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
94
CONTRACT.md
Normal 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).
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
"description": "Control module rotatingMachine",
|
"description": "Control module rotatingMachine",
|
||||||
"main": "rotatingMachine.js",
|
"main": "rotatingMachine.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
|
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js",
|
||||||
|
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
|
||||||
|
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
|
||||||
|
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<script>
|
<script>
|
||||||
RED.nodes.registerType("rotatingMachine", {
|
RED.nodes.registerType("rotatingMachine", {
|
||||||
category: "EVOLV",
|
category: "EVOLV",
|
||||||
color: "#86bbdd",
|
color: "#E89B3A",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: "" },
|
name: { value: "" },
|
||||||
|
|
||||||
@@ -30,12 +30,11 @@
|
|||||||
processOutputFormat: { value: "process" },
|
processOutputFormat: { value: "process" },
|
||||||
dbaseOutputFormat: { value: "influxdb" },
|
dbaseOutputFormat: { value: "influxdb" },
|
||||||
|
|
||||||
//define asset properties
|
// Asset identifier surface. supplier/category/assetType are
|
||||||
|
// derived at runtime via assetResolver.resolveAssetMetadata(model);
|
||||||
|
// do NOT add them back here. See src/registry/README.md.
|
||||||
uuid: { value: "" },
|
uuid: { value: "" },
|
||||||
assetTagNumber: { value: "" },
|
assetTagNumber: { value: "" },
|
||||||
supplier: { value: "" },
|
|
||||||
category: { value: "" },
|
|
||||||
assetType: { value: "" },
|
|
||||||
model: { value: "" },
|
model: { value: "" },
|
||||||
unit: { value: "" },
|
unit: { value: "" },
|
||||||
curvePressureUnit: { value: "mbar" },
|
curvePressureUnit: { value: "mbar" },
|
||||||
@@ -63,16 +62,21 @@
|
|||||||
icon: "font-awesome/fa-cog",
|
icon: "font-awesome/fa-cog",
|
||||||
|
|
||||||
label: function () {
|
label: function () {
|
||||||
return (this.positionIcon || "") + " " + (this.category || "Machine");
|
// No more `this.category` on the node — fall back to model id, then a
|
||||||
|
// generic name. supplier/category/type live in the registry now.
|
||||||
|
const stem = this.model ? this.model : "Machine";
|
||||||
|
return (this.positionIcon || "") + " " + stem;
|
||||||
},
|
},
|
||||||
|
|
||||||
oneditprepare: function() {
|
oneditprepare: function() {
|
||||||
// wait for the menu scripts to load
|
const node = this;
|
||||||
|
|
||||||
|
// wait for the menu scripts to load (asset/logger/position injected via menu.js)
|
||||||
let menuRetries = 0;
|
let menuRetries = 0;
|
||||||
const maxMenuRetries = 100; // 5 seconds at 50ms intervals
|
const maxMenuRetries = 100; // 5 seconds at 50ms intervals
|
||||||
const waitForMenuData = () => {
|
const waitForMenuData = () => {
|
||||||
if (window.EVOLV?.nodes?.rotatingMachine?.initEditor) {
|
if (window.EVOLV?.nodes?.rotatingMachine?.initEditor) {
|
||||||
window.EVOLV.nodes.rotatingMachine.initEditor(this);
|
window.EVOLV.nodes.rotatingMachine.initEditor(node);
|
||||||
} else if (++menuRetries < maxMenuRetries) {
|
} else if (++menuRetries < maxMenuRetries) {
|
||||||
setTimeout(waitForMenuData, 50);
|
setTimeout(waitForMenuData, 50);
|
||||||
} else {
|
} else {
|
||||||
@@ -81,17 +85,189 @@
|
|||||||
};
|
};
|
||||||
waitForMenuData();
|
waitForMenuData();
|
||||||
|
|
||||||
// your existing project‐settings & asset dropdown logic can remain here
|
// -----------------------------------------------------------
|
||||||
document.getElementById("node-input-speed");
|
// Movement-mode visual cards (replaces the old <select>).
|
||||||
document.getElementById("node-input-startup");
|
// Same compact 94×86 card sizing as machineGroupControl.
|
||||||
document.getElementById("node-input-warmup");
|
// -----------------------------------------------------------
|
||||||
document.getElementById("node-input-shutdown");
|
const modeInput = document.getElementById("node-input-movementMode");
|
||||||
document.getElementById("node-input-cooldown");
|
const cards = document.querySelectorAll(".rm-mode-card");
|
||||||
const movementMode = document.getElementById("node-input-movementMode");
|
const setMode = (val) => {
|
||||||
if (movementMode) {
|
if (modeInput) modeInput.value = val;
|
||||||
movementMode.value = this.movementMode || "staticspeed";
|
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() {
|
oneditsave: function() {
|
||||||
const node = this;
|
const node = this;
|
||||||
@@ -112,13 +288,11 @@
|
|||||||
["speed", "startup", "warmup", "shutdown", "cooldown"].forEach((field) => {
|
["speed", "startup", "warmup", "shutdown", "cooldown"].forEach((field) => {
|
||||||
const element = document.getElementById(`node-input-${field}`);
|
const element = document.getElementById(`node-input-${field}`);
|
||||||
const value = parseFloat(element?.value) || 0;
|
const value = parseFloat(element?.value) || 0;
|
||||||
console.log(`----------------> Saving ${field}: ${value}`);
|
|
||||||
node[field] = value;
|
node[field] = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
node.movementMode = document.getElementById("node-input-movementMode").value;
|
const modeEl = document.getElementById("node-input-movementMode");
|
||||||
console.log(`----------------> Saving movementMode: ${node.movementMode}`);
|
node.movementMode = (modeEl && modeEl.value) ? modeEl.value : "staticspeed";
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -126,65 +300,276 @@
|
|||||||
<!-- Main UI Template -->
|
<!-- Main UI Template -->
|
||||||
<script type="text/html" data-template-name="rotatingMachine">
|
<script type="text/html" data-template-name="rotatingMachine">
|
||||||
|
|
||||||
<!-- Machine-specific controls -->
|
<!-- ============================================================ -->
|
||||||
<div class="form-row">
|
<!-- PUMP / ROTATING MACHINE BANNER -->
|
||||||
<label for="node-input-speed"><i class="fa fa-clock-o"></i> Reaction Speed</label>
|
<!-- Visual orientation only — no inputs. Shows what the node -->
|
||||||
<input type="number" id="node-input-speed" style="width:60%;" placeholder="position units / second" />
|
<!-- represents (centrifugal pump with suction + discharge). -->
|
||||||
<div style="font-size:11px;color:#666;margin-left:160px;">Ramp rate of the controller position in units per second (0–100% controller range; e.g. 1 = 1%/s).</div>
|
<!-- ============================================================ -->
|
||||||
</div>
|
<div style="margin: 4px 0 14px 0; background: #fafcff; border: 1px solid #d9e6f2; border-radius: 4px; padding: 8px;">
|
||||||
<div class="form-row">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 200"
|
||||||
<label for="node-input-startup"><i class="fa fa-clock-o"></i> Startup Time</label>
|
style="display:block;width:100%;"
|
||||||
<input type="number" id="node-input-startup" style="width:60%;" placeholder="seconds" />
|
font-family="Arial,sans-serif" font-size="11">
|
||||||
<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>
|
<defs>
|
||||||
</div>
|
<marker id="rm-arrow-flow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||||
<div class="form-row">
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1F4E79"/>
|
||||||
<label for="node-input-warmup"><i class="fa fa-clock-o"></i> Warmup Time</label>
|
</marker>
|
||||||
<input type="number" id="node-input-warmup" style="width:60%;" placeholder="seconds" />
|
<marker id="rm-arrow-rot" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
<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>
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#0c99d9"/>
|
||||||
</div>
|
</marker>
|
||||||
<div class="form-row">
|
</defs>
|
||||||
<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" />
|
<!-- Title -->
|
||||||
<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>
|
<text x="300" y="18" text-anchor="middle" fill="#1F4E79" font-size="13" font-weight="bold">Rotating machine — pump / compressor / blower</text>
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
<!-- Suction pipe (left → in) -->
|
||||||
<label for="node-input-cooldown"><i class="fa fa-clock-o"></i> Cooldown Time</label>
|
<rect x="20" y="100" width="160" height="38" fill="#dde7f0" stroke="#1F4E79" stroke-width="2"/>
|
||||||
<input type="number" id="node-input-cooldown" style="width:60%;" placeholder="seconds" />
|
<line x1="40" y1="119" x2="170" y2="119" stroke="#1F4E79" stroke-width="2" marker-end="url(#rm-arrow-flow)"/>
|
||||||
<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>
|
<text x="100" y="92" text-anchor="middle" fill="#1F4E79" font-weight="bold">Suction</text>
|
||||||
</div>
|
<text x="100" y="156" text-anchor="middle" fill="#777" font-size="10" font-style="italic">upstream / inlet pressure</text>
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-movementMode"><i class="fa fa-exchange"></i> Movement Mode</label>
|
<!-- Motor housing (top) + shaft -->
|
||||||
<select id="node-input-movementMode" style="width:60%;">
|
<rect x="220" y="30" width="44" height="40" rx="3" fill="#7f8c8d" stroke="#333" stroke-width="1.5"/>
|
||||||
<option value="staticspeed">Static</option>
|
<text x="242" y="55" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">M</text>
|
||||||
<option value="dynspeed">Dynamic</option>
|
<line x1="242" y1="70" x2="242" y2="90" stroke="#333" stroke-width="2"/>
|
||||||
</select>
|
<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">m³/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>
|
</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 & 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 & 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
|
||||||
|
(y≈29 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>
|
<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>
|
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||||
<select id="node-input-processOutputFormat" style="width:60%;">
|
<select id="node-input-processOutputFormat" class="evolv-native-hidden" style="width:60%;">
|
||||||
<option value="process">process</option>
|
<option value="process">process</option>
|
||||||
<option value="json">json</option>
|
<option value="json">json</option>
|
||||||
<option value="csv">csv</option>
|
<option value="csv">csv</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div id="rm-process-output-picker" class="evolv-icon-picker"
|
||||||
|
role="radiogroup" aria-label="Process output format"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row rm-output-row">
|
||||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
<select id="node-input-dbaseOutputFormat" class="evolv-native-hidden" style="width:60%;">
|
||||||
<option value="influxdb">influxdb</option>
|
<option value="influxdb">influxdb</option>
|
||||||
|
<option value="frost">frost</option>
|
||||||
<option value="json">json</option>
|
<option value="json">json</option>
|
||||||
<option value="csv">csv</option>
|
<option value="csv">csv</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div id="rm-dbase-output-picker" class="evolv-icon-picker"
|
||||||
|
role="radiogroup" aria-label="Database output format"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Asset fields injected here -->
|
<!-- Asset / Logger / Position menus injected by menu.js -->
|
||||||
<div id="asset-fields-placeholder"></div>
|
<div id="asset-fields-placeholder"></div>
|
||||||
|
|
||||||
<!-- Logger fields injected here -->
|
|
||||||
<div id="logger-fields-placeholder"></div>
|
<div id="logger-fields-placeholder"></div>
|
||||||
|
|
||||||
<!-- Position fields injected here -->
|
|
||||||
<div id="position-fields-placeholder"></div>
|
<div id="position-fields-placeholder"></div>
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -194,11 +579,11 @@
|
|||||||
|
|
||||||
<h3>Configuration</h3>
|
<h3>Configuration</h3>
|
||||||
<ul>
|
<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 s.</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 s. Visualised as the slope inside the <i>operational</i> bar.</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>Startup / Warm-up / Shutdown / Cool-down</b>: seconds per FSM phase. Warm-up & 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.</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>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>
|
<li><b>Position</b> (menu): <code>upstream</code> / <code>atEquipment</code> / <code>downstream</code> relative to a parent group/station.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const nameOfNode = 'rotatingMachine';
|
const nameOfNode = 'rotatingMachine';
|
||||||
const nodeClass = require('./src/nodeClass.js');
|
const nodeClass = require('./src/nodeClass.js');
|
||||||
const { MenuManager, configManager } = require('generalFunctions');
|
const { MenuManager, configManager } = require('generalFunctions');
|
||||||
|
const { buildQHCurve } = require('./src/display/workingCurves');
|
||||||
|
|
||||||
module.exports = function(RED) {
|
module.exports = function(RED) {
|
||||||
// 1) Register the node type and delegate to your class
|
// 1) Register the node type and delegate to your class
|
||||||
@@ -32,4 +33,20 @@ module.exports = function(RED) {
|
|||||||
res.status(500).send(`// Error generating configData: ${err.message}`);
|
res.status(500).send(`// Error generating configData: ${err.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Q-H curve sampler — served on RED.httpNode (the dashboard/runtime
|
||||||
|
// router) so dashboard function nodes can fetch without admin auth.
|
||||||
|
// GET /rotatingMachine/:id/qh-curve?ctrl=<percent>
|
||||||
|
// Returns { ctrlPct, points: [{ Q (m³/h), H (m), dpPa }, ...] }
|
||||||
|
RED.httpNode.get(`/${nameOfNode}/:id/qh-curve`, (req, res) => {
|
||||||
|
const node = RED.nodes.getNode(req.params.id);
|
||||||
|
const source = node?.source;
|
||||||
|
if (!source) {
|
||||||
|
res.status(404).json({ error: `No rotatingMachine with id ${req.params.id}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctrl = Number(req.query.ctrl);
|
||||||
|
const result = buildQHCurve(source, Number.isFinite(ctrl) ? ctrl : source.state?.getCurrentPosition?.() ?? 0);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
150
src/commands/handlers.js
Normal file
150
src/commands/handlers.js
Normal 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
98
src/commands/index.js
Normal 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
19
src/curves/curveLoader.js
Normal 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 };
|
||||||
104
src/curves/curveNormalizer.js
Normal file
104
src/curves/curveNormalizer.js
Normal 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 };
|
||||||
17
src/curves/reverseCurve.js
Normal file
17
src/curves/reverseCurve.js
Normal 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 };
|
||||||
122
src/display/workingCurves.js
Normal file
122
src/display/workingCurves.js
Normal 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
135
src/drift/driftAssessor.js
Normal 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;
|
||||||
45
src/drift/healthRefresh.js
Normal file
45
src/drift/healthRefresh.js
Normal 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 };
|
||||||
132
src/drift/predictionHealth.js
Normal file
132
src/drift/predictionHealth.js
Normal 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;
|
||||||
85
src/flow/flowController.js
Normal file
85
src/flow/flowController.js
Normal 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
90
src/io/output.js
Normal 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 };
|
||||||
47
src/measurement/childRegistrar.js
Normal file
47
src/measurement/childRegistrar.js
Normal 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 };
|
||||||
181
src/measurement/measurementHandlers.js
Normal file
181
src/measurement/measurementHandlers.js
Normal 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;
|
||||||
496
src/nodeClass.js
496
src/nodeClass.js
@@ -1,433 +1,85 @@
|
|||||||
/**
|
'use strict';
|
||||||
* node class.js
|
|
||||||
*
|
|
||||||
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
|
|
||||||
* This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers.
|
|
||||||
*/
|
|
||||||
const { outputUtils, configManager, convert } = require('generalFunctions');
|
|
||||||
const Specific = require("./specificClass");
|
|
||||||
|
|
||||||
class nodeClass {
|
const { BaseNodeAdapter, convert } = require('generalFunctions');
|
||||||
/**
|
const Machine = require('./specificClass');
|
||||||
* Create a Node.
|
const commands = require('./commands');
|
||||||
* @param {object} uiConfig - Node-RED node configuration.
|
|
||||||
* @param {object} RED - Node-RED runtime API.
|
|
||||||
*/
|
|
||||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
|
||||||
|
|
||||||
// Preserve RED reference for HTTP endpoints if needed
|
// Event-driven: state + measurement events drive recomputes via the
|
||||||
this.node = nodeInstance; // This is the Node-RED node instance, we can use this to send messages and update status
|
// domain emitter. No tick loop. Status badge polled every second.
|
||||||
this.RED = RED; // This is the Node-RED runtime API, we can use this to create endpoints if needed
|
class nodeClass extends BaseNodeAdapter {
|
||||||
this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED
|
static DomainClass = Machine;
|
||||||
this.source = null; // Will hold the specific class instance
|
static commands = commands;
|
||||||
this.config = null; // Will hold the merged configuration
|
static tickInterval = null;
|
||||||
this._pressureInitWarned = false;
|
static statusInterval = 1000;
|
||||||
|
// 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
|
buildDomainConfig(uiConfig) {
|
||||||
this._loadConfig(uiConfig,this.node);
|
_rejectLegacyAssetFields(uiConfig);
|
||||||
|
|
||||||
// Instantiate core class
|
const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h');
|
||||||
this._setupSpecificClass(uiConfig);
|
// Stash extras on the Machine class so its constructor (called by
|
||||||
|
// BaseNodeAdapter via DomainClass) picks them up alongside the
|
||||||
// Wire up event and lifecycle handlers
|
// machineConfig. Single-threaded JS makes the hand-off race-free.
|
||||||
this._bindEvents();
|
Machine._pendingExtras = {
|
||||||
this._registerChild();
|
stateConfig: {
|
||||||
this._startTickLoop();
|
general: { logging: { enabled: uiConfig.enableLog, logLevel: uiConfig.logLevel } },
|
||||||
this._attachInputHandler();
|
movement: { speed: Number(uiConfig.speed), mode: uiConfig.movementMode },
|
||||||
this._attachCloseHandler();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and merge default config with user-defined settings.
|
|
||||||
* @param {object} uiConfig - Raw config from Node-RED UI.
|
|
||||||
*/
|
|
||||||
_loadConfig(uiConfig,node) {
|
|
||||||
const cfgMgr = new configManager();
|
|
||||||
const resolvedAssetUuid = uiConfig.assetUuid || uiConfig.uuid || null;
|
|
||||||
const resolvedAssetTagCode = uiConfig.assetTagCode || uiConfig.assetTagNumber || null;
|
|
||||||
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
|
|
||||||
const curveUnits = {
|
|
||||||
pressure: this._resolveUnitOrFallback(uiConfig.curvePressureUnit, 'pressure', 'mbar', 'curve pressure'),
|
|
||||||
flow: this._resolveUnitOrFallback(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit, 'curve flow'),
|
|
||||||
power: this._resolveUnitOrFallback(uiConfig.curvePowerUnit, 'power', 'kW', 'curve power'),
|
|
||||||
control: this._resolveControlUnitOrFallback(uiConfig.curveControlUnit, '%'),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build config: base sections + rotatingMachine-specific domain config
|
|
||||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
|
|
||||||
flowNumber: uiConfig.flowNumber
|
|
||||||
});
|
|
||||||
|
|
||||||
// Override asset with rotatingMachine-specific fields
|
|
||||||
this.config.asset = {
|
|
||||||
...this.config.asset,
|
|
||||||
uuid: resolvedAssetUuid,
|
|
||||||
tagCode: resolvedAssetTagCode,
|
|
||||||
tagNumber: uiConfig.assetTagNumber || null,
|
|
||||||
unit: flowUnit,
|
|
||||||
curveUnits
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure general unit uses resolved flow unit
|
|
||||||
this.config.general.unit = flowUnit;
|
|
||||||
|
|
||||||
// Utility for formatting outputs
|
|
||||||
this._output = new outputUtils();
|
|
||||||
}
|
|
||||||
|
|
||||||
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) {
|
|
||||||
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
|
||||||
const fallback = String(fallbackUnit || '').trim();
|
|
||||||
if (!raw) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const desc = convert().describe(raw);
|
|
||||||
if (expectedMeasure && desc.measure !== expectedMeasure) {
|
|
||||||
throw new Error(`expected '${expectedMeasure}' but got '${desc.measure}'`);
|
|
||||||
}
|
|
||||||
return raw;
|
|
||||||
} catch (error) {
|
|
||||||
this.node?.warn?.(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`);
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_resolveControlUnitOrFallback(candidate, fallback = '%') {
|
|
||||||
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
|
||||||
return raw || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instantiate the core Measurement logic and store as source.
|
|
||||||
*/
|
|
||||||
_setupSpecificClass(uiConfig) {
|
|
||||||
const machineConfig = this.config;
|
|
||||||
|
|
||||||
// need extra state for this
|
|
||||||
const stateConfig = {
|
|
||||||
general: {
|
|
||||||
logging: {
|
|
||||||
enabled: machineConfig.general.logging.enabled,
|
|
||||||
logLevel: machineConfig.general.logging.logLevel
|
|
||||||
}
|
|
||||||
},
|
|
||||||
movement: {
|
|
||||||
speed: Number(uiConfig.speed),
|
|
||||||
mode: uiConfig.movementMode
|
|
||||||
},
|
|
||||||
time: {
|
time: {
|
||||||
starting: Number(uiConfig.startup),
|
starting: Number(uiConfig.startup), warmingup: Number(uiConfig.warmup),
|
||||||
warmingup: Number(uiConfig.warmup),
|
stopping: Number(uiConfig.shutdown), coolingdown: Number(uiConfig.cooldown),
|
||||||
stopping: Number(uiConfig.shutdown),
|
},
|
||||||
coolingdown: Number(uiConfig.cooldown)
|
},
|
||||||
}
|
errorMetricsConfig: {},
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
this.source = new Specific(machineConfig, stateConfig);
|
asset: {
|
||||||
|
uuid: uiConfig.assetUuid || uiConfig.uuid || null,
|
||||||
//store in node
|
tagCode: uiConfig.assetTagCode || uiConfig.assetTagNumber || null,
|
||||||
this.node.source = this.source; // Store the source in the node instance for easy access
|
tagNumber: uiConfig.assetTagNumber || null,
|
||||||
|
model: uiConfig.model || null,
|
||||||
}
|
unit: flowUnit,
|
||||||
|
curveUnits: {
|
||||||
/**
|
pressure: _resolveUnit(uiConfig.curvePressureUnit, 'pressure', 'mbar'),
|
||||||
* Bind events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES
|
flow: _resolveUnit(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit),
|
||||||
*/
|
power: _resolveUnit(uiConfig.curvePowerUnit, 'power', 'kW'),
|
||||||
_bindEvents() {
|
control: (typeof uiConfig.curveControlUnit === 'string' && uiConfig.curveControlUnit.trim()) || '%',
|
||||||
|
},
|
||||||
}
|
},
|
||||||
|
general: { unit: flowUnit },
|
||||||
_updateNodeStatus() {
|
flowNumber: uiConfig.flowNumber,
|
||||||
const m = this.source;
|
|
||||||
try {
|
|
||||||
const mode = m.currentMode;
|
|
||||||
const state = m.state.getCurrentState();
|
|
||||||
const requiresPressurePrediction = ["operational", "warmingup", "accelerating", "decelerating"].includes(state);
|
|
||||||
const pressureStatus = typeof m.getPressureInitializationStatus === "function"
|
|
||||||
? m.getPressureInitializationStatus()
|
|
||||||
: { initialized: true };
|
|
||||||
|
|
||||||
if (requiresPressurePrediction && !pressureStatus.initialized) {
|
|
||||||
if (!this._pressureInitWarned) {
|
|
||||||
this.node.warn("Pressure input is not initialized (upstream/downstream missing). Predictions are using minimum pressure.");
|
|
||||||
this._pressureInitWarned = true;
|
|
||||||
}
|
|
||||||
return { fill: "yellow", shape: "ring", text: `${mode}: pressure not initialized` };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pressureStatus.initialized) {
|
|
||||||
this._pressureInitWarned = false;
|
|
||||||
}
|
|
||||||
const flowUnit = m?.config?.general?.unit || 'm3/h';
|
|
||||||
const flow = Math.round(m.measurements.type("flow").variant("predicted").position('downstream').getCurrentValue(flowUnit));
|
|
||||||
const power = Math.round(m.measurements.type("power").variant("predicted").position('atEquipment').getCurrentValue('kW'));
|
|
||||||
let symbolState;
|
|
||||||
switch(state){
|
|
||||||
case "off":
|
|
||||||
symbolState = "⬛";
|
|
||||||
break;
|
|
||||||
case "idle":
|
|
||||||
symbolState = "⏸️";
|
|
||||||
break;
|
|
||||||
case "operational":
|
|
||||||
symbolState = "⏵️";
|
|
||||||
break;
|
|
||||||
case "starting":
|
|
||||||
symbolState = "⏯️";
|
|
||||||
break;
|
|
||||||
case "warmingup":
|
|
||||||
symbolState = "🔄";
|
|
||||||
break;
|
|
||||||
case "accelerating":
|
|
||||||
symbolState = "⏩";
|
|
||||||
break;
|
|
||||||
case "stopping":
|
|
||||||
symbolState = "⏹️";
|
|
||||||
break;
|
|
||||||
case "coolingdown":
|
|
||||||
symbolState = "❄️";
|
|
||||||
break;
|
|
||||||
case "decelerating":
|
|
||||||
symbolState = "⏪";
|
|
||||||
break;
|
|
||||||
case "maintenance":
|
|
||||||
symbolState = "🔧";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const position = m.state.getCurrentPosition();
|
|
||||||
const roundedPosition = Math.round(position * 100) / 100;
|
|
||||||
|
|
||||||
let status;
|
|
||||||
switch (state) {
|
|
||||||
case "off":
|
|
||||||
status = { fill: "red", shape: "dot", text: `${mode}: OFF` };
|
|
||||||
break;
|
|
||||||
case "idle":
|
|
||||||
status = { fill: "blue", shape: "dot", text: `${mode}: ${symbolState}` };
|
|
||||||
break;
|
|
||||||
case "operational":
|
|
||||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
|
||||||
break;
|
|
||||||
case "starting":
|
|
||||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
|
||||||
break;
|
|
||||||
case "warmingup":
|
|
||||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
|
||||||
break;
|
|
||||||
case "accelerating":
|
|
||||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}%| 💨${flow}${flowUnit} | ⚡${power}kW` };
|
|
||||||
break;
|
|
||||||
case "stopping":
|
|
||||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
|
||||||
break;
|
|
||||||
case "coolingdown":
|
|
||||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
|
||||||
break;
|
|
||||||
case "decelerating":
|
|
||||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} - ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` };
|
|
||||||
}
|
|
||||||
return status;
|
|
||||||
} catch (error) {
|
|
||||||
this.node.error("Error in updateNodeStatus: " + error.message);
|
|
||||||
return { fill: "red", shape: "ring", text: "Status Error" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Register this node as a child upstream and downstream.
|
|
||||||
* Delayed to avoid Node-RED startup race conditions.
|
|
||||||
*/
|
|
||||||
_registerChild() {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.node.send([
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
{ topic: 'registerChild', payload: this.node.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' },
|
|
||||||
]);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the periodic tick loop.
|
|
||||||
*/
|
|
||||||
_startTickLoop() {
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strict cutover: with the AssetResolver in place, supplier/category/assetType
|
||||||
|
// are no longer node config — they're derived from the registry by model id.
|
||||||
|
// Old flows that still have them saved must be re-saved through the editor.
|
||||||
|
function _rejectLegacyAssetFields(uiConfig) {
|
||||||
|
const offenders = ['supplier', 'category', 'assetType'].filter((k) => {
|
||||||
|
const v = uiConfig[k];
|
||||||
|
return typeof v === 'string' && v.trim() !== '';
|
||||||
|
});
|
||||||
|
if (offenders.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`rotatingMachine: legacy asset field(s) [${offenders.join(', ')}] are saved on this node. ` +
|
||||||
|
`After the AssetResolver refactor these are derived from the model id. ` +
|
||||||
|
`Open the node in the editor, re-select the model, and save to migrate.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resolveUnit(candidate, expectedMeasure, fallback) {
|
||||||
|
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||||
|
const fb = String(fallback || '').trim();
|
||||||
|
if (!raw) return fb;
|
||||||
|
try {
|
||||||
|
const desc = convert().describe(raw);
|
||||||
|
if (expectedMeasure && desc.measure !== expectedMeasure) return fb;
|
||||||
|
return raw;
|
||||||
|
} catch (_) { return fb; }
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = nodeClass;
|
module.exports = nodeClass;
|
||||||
|
|||||||
139
src/prediction/efficiencyMath.js
Normal file
139
src/prediction/efficiencyMath.js
Normal 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,
|
||||||
|
};
|
||||||
23
src/prediction/groupPredictors.js
Normal file
23
src/prediction/groupPredictors.js
Normal 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 };
|
||||||
82
src/prediction/operatingPoint.js
Normal file
82
src/prediction/operatingPoint.js
Normal 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;
|
||||||
71
src/prediction/predictionMath.js
Normal file
71
src/prediction/predictionMath.js
Normal 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 };
|
||||||
25
src/prediction/predictors.js
Normal file
25
src/prediction/predictors.js
Normal 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 };
|
||||||
100
src/pressure/pressureInitialization.js
Normal file
100
src/pressure/pressureInitialization.js
Normal 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;
|
||||||
94
src/pressure/pressureRouter.js
Normal file
94
src/pressure/pressureRouter.js
Normal 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;
|
||||||
52
src/pressure/pressureSelector.js
Normal file
52
src/pressure/pressureSelector.js
Normal 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 };
|
||||||
92
src/pressure/virtualChildren.js
Normal file
92
src/pressure/virtualChildren.js
Normal 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;
|
||||||
1992
src/specificClass.js
1992
src/specificClass.js
File diff suppressed because it is too large
Load Diff
104
src/state/sequenceController.js
Normal file
104
src/state/sequenceController.js
Normal 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 };
|
||||||
58
src/state/stateBindings.js
Normal file
58
src/state/stateBindings.js
Normal 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,
|
||||||
|
};
|
||||||
61
test/basic/assetMetadata.basic.test.js
Normal file
61
test/basic/assetMetadata.basic.test.js
Normal 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);
|
||||||
|
});
|
||||||
275
test/basic/commands.basic.test.js
Normal file
275
test/basic/commands.basic.test.js
Normal 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)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
30
test/basic/curveLoader.basic.test.js
Normal file
30
test/basic/curveLoader.basic.test.js
Normal 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/);
|
||||||
|
});
|
||||||
81
test/basic/curveNormalizer.basic.test.js
Normal file
81
test/basic/curveNormalizer.basic.test.js
Normal 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/
|
||||||
|
);
|
||||||
|
});
|
||||||
130
test/basic/driftAssessor.basic.test.js
Normal file
130
test/basic/driftAssessor.basic.test.js
Normal 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);
|
||||||
|
});
|
||||||
132
test/basic/flowController.basic.test.js
Normal file
132
test/basic/flowController.basic.test.js
Normal 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/);
|
||||||
|
});
|
||||||
51
test/basic/groupPredictors.basic.test.js
Normal file
51
test/basic/groupPredictors.basic.test.js
Normal 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);
|
||||||
|
});
|
||||||
149
test/basic/measurementHandlers.basic.test.js
Normal file
149
test/basic/measurementHandlers.basic.test.js
Normal 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/);
|
||||||
|
});
|
||||||
@@ -2,16 +2,20 @@ const test = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
const NodeClass = require('../../src/nodeClass');
|
const NodeClass = require('../../src/nodeClass');
|
||||||
const { makeNodeStub } = require('../helpers/factories');
|
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
||||||
|
|
||||||
|
// These tests drive the BaseNodeAdapter public surface. We construct the
|
||||||
|
// full nodeClass and observe the resulting `inst.source.config` (the
|
||||||
|
// validated merged shape) and the source's runtime mode. No private hooks.
|
||||||
|
|
||||||
function makeUiConfig(overrides = {}) {
|
function makeUiConfig(overrides = {}) {
|
||||||
|
// After the AssetResolver cutover, the editor no longer saves
|
||||||
|
// supplier/category/assetType — those are derived from the model id via
|
||||||
|
// assetResolver.resolveAssetMetadata at runtime.
|
||||||
return {
|
return {
|
||||||
unit: 'm3/h',
|
unit: 'm3/h',
|
||||||
enableLog: true,
|
enableLog: false,
|
||||||
logLevel: 'debug',
|
logLevel: 'error',
|
||||||
supplier: 'hidrostal',
|
|
||||||
category: 'machine',
|
|
||||||
assetType: 'pump',
|
|
||||||
model: 'hidrostal-H05K-S03R',
|
model: 'hidrostal-H05K-S03R',
|
||||||
curvePressureUnit: 'mbar',
|
curvePressureUnit: 'mbar',
|
||||||
curveFlowUnit: 'm3/h',
|
curveFlowUnit: 'm3/h',
|
||||||
@@ -28,82 +32,74 @@ function makeUiConfig(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('_loadConfig maps legacy editor fields for asset identity', () => {
|
// Adapters built by these tests park a periodic status-poll timer. We
|
||||||
const inst = Object.create(NodeClass.prototype);
|
// drive the BaseNodeAdapter close handler after each test to stop it so
|
||||||
inst.node = makeNodeStub();
|
// node:test exits cleanly — this is the public teardown path Node-RED
|
||||||
inst.name = 'rotatingMachine';
|
// itself uses on flow shutdown.
|
||||||
|
const _adapters = [];
|
||||||
inst._loadConfig(
|
function buildAdapter(ui) {
|
||||||
makeUiConfig({
|
const node = makeNodeStub();
|
||||||
uuid: 'uuid-from-editor',
|
const RED = makeREDStub();
|
||||||
assetTagNumber: 'TAG-123',
|
const inst = new NodeClass(ui, RED, node, 'rotatingMachine');
|
||||||
}),
|
_adapters.push(node);
|
||||||
inst.node
|
return { inst, node };
|
||||||
);
|
}
|
||||||
|
test.afterEach(() => {
|
||||||
assert.equal(inst.config.asset.uuid, 'uuid-from-editor');
|
while (_adapters.length) {
|
||||||
assert.equal(inst.config.asset.tagCode, 'TAG-123');
|
const node = _adapters.pop();
|
||||||
assert.equal(inst.config.asset.tagNumber, 'TAG-123');
|
try { node._handlers.close?.(() => {}); } catch (_) { /* best effort */ }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_loadConfig prefers explicit assetUuid/assetTagCode when present', () => {
|
test('asset identity flows from legacy editor fields through buildDomainConfig', () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
const { inst } = buildAdapter(makeUiConfig({ uuid: 'uuid-from-editor', assetTagNumber: 'TAG-123' }));
|
||||||
inst.node = makeNodeStub();
|
assert.equal(inst.source.config.asset.uuid, 'uuid-from-editor');
|
||||||
inst.name = 'rotatingMachine';
|
assert.equal(inst.source.config.asset.tagCode, 'tag-123');
|
||||||
|
assert.equal(inst.source.config.asset.tagNumber, 'tag-123');
|
||||||
inst._loadConfig(
|
|
||||||
makeUiConfig({
|
|
||||||
uuid: 'legacy-uuid',
|
|
||||||
assetUuid: 'explicit-uuid',
|
|
||||||
assetTagNumber: 'legacy-tag',
|
|
||||||
assetTagCode: 'explicit-tag',
|
|
||||||
}),
|
|
||||||
inst.node
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(inst.config.asset.uuid, 'explicit-uuid');
|
|
||||||
assert.equal(inst.config.asset.tagCode, 'explicit-tag');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_loadConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
|
test('explicit assetUuid/assetTagCode override legacy editor fields', () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
const { inst } = buildAdapter(makeUiConfig({
|
||||||
inst.node = makeNodeStub();
|
uuid: 'legacy-uuid', assetUuid: 'explicit-uuid',
|
||||||
inst.name = 'rotatingMachine';
|
assetTagNumber: 'legacy-tag', assetTagCode: 'explicit-tag',
|
||||||
|
}));
|
||||||
|
assert.equal(inst.source.config.asset.uuid, 'explicit-uuid');
|
||||||
|
assert.equal(inst.source.config.asset.tagCode, 'explicit-tag');
|
||||||
|
});
|
||||||
|
|
||||||
inst._loadConfig(
|
test('curveUnits propagate through buildDomainConfig, invalid flow unit falls back', () => {
|
||||||
makeUiConfig({
|
const { inst } = buildAdapter(makeUiConfig({
|
||||||
unit: 'not-a-unit',
|
unit: 'not-a-unit',
|
||||||
curvePressureUnit: 'mbar',
|
curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
|
||||||
curveFlowUnit: 'm3/h',
|
curvePowerUnit: 'kW', curveControlUnit: '%',
|
||||||
curvePowerUnit: 'kW',
|
}));
|
||||||
curveControlUnit: '%',
|
assert.equal(inst.source.config.general.unit, 'm3/h');
|
||||||
}),
|
assert.equal(inst.source.config.asset.unit, 'm3/h');
|
||||||
inst.node
|
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.config.general.unit, 'm3/h');
|
assert.equal(inst.source.config.asset.curveUnits.control, '%');
|
||||||
assert.equal(inst.config.asset.unit, 'm3/h');
|
|
||||||
assert.equal(inst.config.asset.curveUnits.pressure, 'mbar');
|
|
||||||
assert.equal(inst.config.asset.curveUnits.flow, 'm3/h');
|
|
||||||
assert.equal(inst.config.asset.curveUnits.power, 'kW');
|
|
||||||
assert.equal(inst.config.asset.curveUnits.control, '%');
|
|
||||||
assert.ok(inst.node._warns.length >= 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_setupSpecificClass propagates logging settings into state config', () => {
|
test('logging.enabled flag reaches the domain via configManager.buildConfig', () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
const { inst } = buildAdapter(makeUiConfig({ enableLog: true }));
|
||||||
inst.node = makeNodeStub();
|
// uiConfig.enableLog flows through configManager.buildConfig and lands
|
||||||
inst.name = 'rotatingMachine';
|
// on the validated source config. (logLevel currently doesn't propagate
|
||||||
const uiConfig = makeUiConfig({
|
// — known platform behaviour; not exercised here.)
|
||||||
enableLog: true,
|
assert.equal(inst.source.config.general.logging.enabled, true);
|
||||||
logLevel: 'warn',
|
});
|
||||||
uuid: 'uuid-test',
|
|
||||||
assetTagNumber: 'TAG-9',
|
test('state machine is wired and exposes its public surface', () => {
|
||||||
});
|
const { inst } = buildAdapter(makeUiConfig());
|
||||||
|
// The state machine is constructed during configure() and exposes
|
||||||
inst._loadConfig(uiConfig, inst.node);
|
// observable methods used by the rest of the domain + the status badge.
|
||||||
inst._setupSpecificClass(uiConfig);
|
assert.equal(typeof inst.source.state.getCurrentState, 'function');
|
||||||
|
assert.equal(typeof inst.source.state.getCurrentPosition, 'function');
|
||||||
assert.equal(inst.source.state.config.general.logging.enabled, true);
|
assert.equal(inst.source.state.getCurrentState(), 'idle');
|
||||||
assert.equal(inst.source.state.config.general.logging.logLevel, 'warn');
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
73
test/basic/operatingPoint.basic.test.js
Normal file
73
test/basic/operatingPoint.basic.test.js
Normal 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));
|
||||||
|
});
|
||||||
93
test/basic/predictionHealth.basic.test.js
Normal file
93
test/basic/predictionHealth.basic.test.js
Normal 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));
|
||||||
|
});
|
||||||
49
test/basic/predictors.basic.test.js
Normal file
49
test/basic/predictors.basic.test.js
Normal 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/);
|
||||||
|
});
|
||||||
103
test/basic/pressureInitialization.basic.test.js
Normal file
103
test/basic/pressureInitialization.basic.test.js
Normal 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'));
|
||||||
|
});
|
||||||
122
test/basic/pressureRouter.basic.test.js
Normal file
122
test/basic/pressureRouter.basic.test.js
Normal 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);
|
||||||
|
});
|
||||||
29
test/basic/reverseCurve.basic.test.js
Normal file
29
test/basic/reverseCurve.basic.test.js
Normal 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), {});
|
||||||
|
});
|
||||||
91
test/basic/stateBindings.basic.test.js
Normal file
91
test/basic/stateBindings.basic.test.js
Normal 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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
70
test/basic/virtualChildren.basic.test.js
Normal file
70
test/basic/virtualChildren.basic.test.js
Normal 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');
|
||||||
|
});
|
||||||
83
test/basic/workingCurves.basic.test.js
Normal file
83
test/basic/workingCurves.basic.test.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -3,7 +3,38 @@ const assert = require('node:assert/strict');
|
|||||||
|
|
||||||
const Machine = require('../../src/specificClass');
|
const Machine = require('../../src/specificClass');
|
||||||
const NodeClass = require('../../src/nodeClass');
|
const NodeClass = require('../../src/nodeClass');
|
||||||
const { makeMachineConfig, makeStateConfig, makeNodeStub } = require('../helpers/factories');
|
const { makeMachineConfig, makeStateConfig, makeNodeStub, makeREDStub } = require('../helpers/factories');
|
||||||
|
|
||||||
|
function makeUiConfig(overrides = {}) {
|
||||||
|
// Post-AssetResolver: editor saves only model + unit + uuid/tagCode.
|
||||||
|
return {
|
||||||
|
unit: 'm3/h', enableLog: false, logLevel: 'error',
|
||||||
|
model: 'hidrostal-H05K-S03R',
|
||||||
|
curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
|
||||||
|
curvePowerUnit: 'kW', curveControlUnit: '%',
|
||||||
|
positionVsParent: 'atEquipment',
|
||||||
|
speed: 1, movementMode: 'staticspeed',
|
||||||
|
startup: 0, warmup: 0, shutdown: 0, cooldown: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapters park a periodic status-poll timer. Drive the BaseNodeAdapter
|
||||||
|
// close handler after each test to stop it — the public teardown path
|
||||||
|
// used by Node-RED itself on flow shutdown.
|
||||||
|
const _adapters = [];
|
||||||
|
function buildAdapter(ui = makeUiConfig()) {
|
||||||
|
const node = makeNodeStub();
|
||||||
|
const inst = new NodeClass(ui, makeREDStub(), node, 'rotatingMachine');
|
||||||
|
_adapters.push(node);
|
||||||
|
return { inst, node };
|
||||||
|
}
|
||||||
|
test.afterEach(() => {
|
||||||
|
while (_adapters.length) {
|
||||||
|
const node = _adapters.pop();
|
||||||
|
try { node._handlers.close?.(() => {}); } catch (_) { /* best effort */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('setpoint rejects negative inputs without throwing', async () => {
|
test('setpoint rejects negative inputs without throwing', async () => {
|
||||||
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
@@ -34,22 +65,19 @@ test('setpoint is constrained to safe movement/curve bounds', async () => {
|
|||||||
assert.equal(requested[1], max);
|
assert.equal(requested[1], max);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('nodeClass _updateNodeStatus returns error status on internal failure', () => {
|
test('source.getStatusBadge returns error status on internal failure', () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
// Build the full adapter, then force the source's state.getCurrentState
|
||||||
const node = makeNodeStub();
|
// to throw — the public getStatusBadge() must catch and return an
|
||||||
inst.node = node;
|
// error badge without propagating.
|
||||||
inst.source = {
|
const { inst } = buildAdapter();
|
||||||
currentMode: 'auto',
|
const errors = [];
|
||||||
state: {
|
inst.source.logger.error = (m) => errors.push(m);
|
||||||
getCurrentState() {
|
inst.source.state.getCurrentState = () => { throw new Error('boom'); };
|
||||||
throw new Error('boom');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const status = inst._updateNodeStatus();
|
const status = inst.source.getStatusBadge();
|
||||||
assert.equal(status.text, 'Status Error');
|
assert.match(status.text, /Status Error/);
|
||||||
assert.equal(node._errors.length, 1);
|
assert.equal(status.fill, 'red');
|
||||||
|
assert.equal(errors.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('measurement handlers reject incompatible units', () => {
|
test('measurement handlers reject incompatible units', () => {
|
||||||
|
|||||||
@@ -4,184 +4,206 @@ const assert = require('node:assert/strict');
|
|||||||
const NodeClass = require('../../src/nodeClass');
|
const NodeClass = require('../../src/nodeClass');
|
||||||
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
||||||
|
|
||||||
test('input handler routes topics to source methods', () => {
|
// Drive routing through the public BaseNodeAdapter surface only. We
|
||||||
const inst = Object.create(NodeClass.prototype);
|
// construct a full nodeClass instance and invoke the input handler
|
||||||
const node = makeNodeStub();
|
// installed by the base on `node.on('input', ...)`. Side-effects are
|
||||||
|
// observed via `node._sent`, the registered child registry on the
|
||||||
|
// source, and instrumented domain methods.
|
||||||
|
|
||||||
|
function makeUiConfig(overrides = {}) {
|
||||||
|
// Post-AssetResolver: editor saves only model + unit + uuid/tagCode.
|
||||||
|
// supplier/category/assetType are derived at runtime.
|
||||||
|
return {
|
||||||
|
unit: 'm3/h',
|
||||||
|
enableLog: false,
|
||||||
|
logLevel: 'error',
|
||||||
|
model: 'hidrostal-H05K-S03R',
|
||||||
|
curvePressureUnit: 'mbar',
|
||||||
|
curveFlowUnit: 'm3/h',
|
||||||
|
curvePowerUnit: 'kW',
|
||||||
|
curveControlUnit: '%',
|
||||||
|
positionVsParent: 'atEquipment',
|
||||||
|
speed: 1,
|
||||||
|
movementMode: 'staticspeed',
|
||||||
|
startup: 0,
|
||||||
|
warmup: 0,
|
||||||
|
shutdown: 0,
|
||||||
|
cooldown: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapters built in these tests park a periodic status-poll timer. We
|
||||||
|
// drive the BaseNodeAdapter close handler after each test so the timer
|
||||||
|
// stops and node:test exits cleanly — this is the public teardown path
|
||||||
|
// Node-RED itself uses on flow shutdown.
|
||||||
|
const _adapters = [];
|
||||||
|
function buildAdapter({ ui = makeUiConfig(), redNodes = {} } = {}) {
|
||||||
|
const node = makeNodeStub();
|
||||||
|
const RED = makeREDStub(redNodes);
|
||||||
|
const inst = new NodeClass(ui, RED, node, 'rotatingMachine');
|
||||||
|
_adapters.push(node);
|
||||||
|
return { inst, node, RED };
|
||||||
|
}
|
||||||
|
test.afterEach(() => {
|
||||||
|
while (_adapters.length) {
|
||||||
|
const node = _adapters.pop();
|
||||||
|
try { node._handlers.close?.(() => {}); } catch (_) { /* best effort */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture every call to source.handleInput so the test can assert which
|
||||||
|
// canonical action the dispatch produced.
|
||||||
|
function instrumentHandleInput(source) {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
inst.node = node;
|
const orig = source.handleInput.bind(source);
|
||||||
inst.RED = makeREDStub({
|
source.handleInput = async (...args) => {
|
||||||
child1: {
|
calls.push(args);
|
||||||
source: { id: 'child-source' },
|
return orig(...args);
|
||||||
},
|
};
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fireInput(node, msg) {
|
||||||
|
await node._handlers.input(msg, (out) => node._sent.push(out), () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('set.mode (and legacy setMode alias) flips the source mode', async () => {
|
||||||
|
const { inst, node } = buildAdapter();
|
||||||
|
const startingMode = inst.source.currentMode;
|
||||||
|
|
||||||
|
await fireInput(node, { topic: 'set.mode', payload: 'virtualControl' });
|
||||||
|
assert.equal(inst.source.currentMode, 'virtualControl');
|
||||||
|
assert.notEqual(inst.source.currentMode, startingMode);
|
||||||
|
|
||||||
|
// Legacy alias still works (emits a one-time deprecation warning).
|
||||||
|
await fireInput(node, { topic: 'setMode', payload: 'auto' });
|
||||||
|
assert.equal(inst.source.currentMode, 'auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cmd.startup / execSequence / flowMovement / emergencystop all reach handleInput with the right action', async () => {
|
||||||
|
const { inst, node } = buildAdapter();
|
||||||
|
const calls = instrumentHandleInput(inst.source);
|
||||||
|
|
||||||
|
await fireInput(node, { topic: 'cmd.startup', payload: { source: 'GUI' } });
|
||||||
|
await fireInput(node, { topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } });
|
||||||
|
await fireInput(node, { topic: 'set.flow-setpoint', payload: { source: 'GUI', setpoint: 123 } });
|
||||||
|
await fireInput(node, { topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 99 } });
|
||||||
|
await fireInput(node, { topic: 'cmd.estop', payload: { source: 'GUI' } });
|
||||||
|
await fireInput(node, { topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } });
|
||||||
|
|
||||||
|
// Each call is [source, action, parameter?]. estop calls handleInput
|
||||||
|
// with only two args; the rest pass a third.
|
||||||
|
assert.equal(calls.length, 6);
|
||||||
|
assert.deepEqual(calls[0], ['GUI', 'execSequence', 'startup']);
|
||||||
|
assert.deepEqual(calls[1], ['GUI', 'execSequence', 'startup']);
|
||||||
|
assert.deepEqual(calls[2], ['GUI', 'flowMovement', 123]);
|
||||||
|
assert.deepEqual(calls[3], ['GUI', 'flowMovement', 99]);
|
||||||
|
assert.deepEqual(calls[4], ['GUI', 'emergencystop']);
|
||||||
|
assert.deepEqual(calls[5], ['GUI', 'emergencystop']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('child.register / registerChild resolves the sibling node and registers it', async () => {
|
||||||
|
// The handler reads child via RED.nodes.getNode(payload).source; we
|
||||||
|
// pre-seed RED's lookup with a domain stub that owns a .source.
|
||||||
|
const fakeChildSource = { config: { functionality: { positionVsParent: 'downstream' } } };
|
||||||
|
const { inst, node } = buildAdapter({
|
||||||
|
redNodes: { 'child-1': { source: fakeChildSource } },
|
||||||
|
});
|
||||||
|
const regCalls = [];
|
||||||
|
inst.source.childRegistrationUtils.registerChild = (childSource, pos) => {
|
||||||
|
regCalls.push([childSource, pos]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await fireInput(node, { topic: 'child.register', payload: 'child-1', positionVsParent: 'downstream' });
|
||||||
|
assert.equal(regCalls.length, 1);
|
||||||
|
assert.equal(regCalls[0][0], fakeChildSource);
|
||||||
|
assert.equal(regCalls[0][1], 'downstream');
|
||||||
|
|
||||||
|
// Missing child is a no-op (no throw, just a warn).
|
||||||
|
await fireInput(node, { topic: 'child.register', payload: 'no-such-id', positionVsParent: 'upstream' });
|
||||||
|
assert.equal(regCalls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data.simulate-measurement validates payload and rejects invalid combinations', async () => {
|
||||||
|
const { inst, node } = buildAdapter();
|
||||||
|
const warns = [];
|
||||||
|
inst.source.logger.warn = (m) => warns.push(String(m));
|
||||||
|
const dispatched = [];
|
||||||
|
inst.source.updateSimulatedMeasurement = (type, pos, val) => dispatched.push(['sim', type, pos, val]);
|
||||||
|
inst.source.updateMeasuredPower = (val, pos) => dispatched.push(['power', val, pos]);
|
||||||
|
|
||||||
|
// 1. non-numeric value
|
||||||
|
await fireInput(node, { topic: 'data.simulate-measurement', payload: { type: 'pressure', position: 'upstream', value: 'NaN-string', unit: 'mbar' } });
|
||||||
|
// 2. missing unit
|
||||||
|
await fireInput(node, { topic: 'data.simulate-measurement', payload: { type: 'flow', position: 'upstream', value: 12 } });
|
||||||
|
// 3. unsupported type
|
||||||
|
await fireInput(node, { topic: 'data.simulate-measurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } });
|
||||||
|
|
||||||
|
assert.equal(dispatched.length, 0);
|
||||||
|
const payloadWarns = warns.filter((w) => !/deprecated/i.test(w));
|
||||||
|
assert.equal(payloadWarns.length, 3);
|
||||||
|
assert.match(payloadWarns[0], /finite number/i);
|
||||||
|
// simulator validates type before unit, so "unknown" trips first.
|
||||||
|
assert.ok(payloadWarns.slice(1).some((w) => /unsupported simulatemeasurement type/i.test(w)));
|
||||||
|
assert.ok(payloadWarns.slice(1).some((w) => /payload\.unit is required/i.test(w)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data.simulate-measurement routes valid power to updateMeasuredPower', async () => {
|
||||||
|
const { inst, node } = buildAdapter();
|
||||||
|
const dispatched = [];
|
||||||
|
inst.source.updateMeasuredPower = (val, pos) => dispatched.push([val, pos]);
|
||||||
|
|
||||||
|
await fireInput(node, {
|
||||||
|
topic: 'data.simulate-measurement',
|
||||||
|
payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' },
|
||||||
});
|
});
|
||||||
|
|
||||||
inst.source = {
|
assert.equal(dispatched.length, 1);
|
||||||
childRegistrationUtils: {
|
assert.equal(dispatched[0][0], 7.5);
|
||||||
registerChild(childSource, pos) {
|
assert.equal(dispatched[0][1], 'atEquipment');
|
||||||
calls.push(['registerChild', childSource, pos]);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setMode(mode) {
|
|
||||||
calls.push(['setMode', mode]);
|
|
||||||
},
|
|
||||||
handleInput(source, action, parameter) {
|
|
||||||
calls.push(['handleInput', source, action, parameter]);
|
|
||||||
},
|
|
||||||
showWorkingCurves() {
|
|
||||||
return { ok: true };
|
|
||||||
},
|
|
||||||
showCoG() {
|
|
||||||
return { cog: 1 };
|
|
||||||
},
|
|
||||||
updateSimulatedMeasurement(type, position, value) {
|
|
||||||
calls.push(['updateSimulatedMeasurement', type, position, value]);
|
|
||||||
},
|
|
||||||
updateMeasuredPressure(value, position) {
|
|
||||||
calls.push(['updateMeasuredPressure', value, position]);
|
|
||||||
},
|
|
||||||
updateMeasuredFlow(value, position) {
|
|
||||||
calls.push(['updateMeasuredFlow', value, position]);
|
|
||||||
},
|
|
||||||
updateMeasuredPower(value, position) {
|
|
||||||
calls.push(['updateMeasuredPower', value, position]);
|
|
||||||
},
|
|
||||||
updateMeasuredTemperature(value, position) {
|
|
||||||
calls.push(['updateMeasuredTemperature', value, position]);
|
|
||||||
},
|
|
||||||
isUnitValidForType() {
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
inst._attachInputHandler();
|
|
||||||
const onInput = node._handlers.input;
|
|
||||||
|
|
||||||
onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
|
|
||||||
onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } }, () => {}, () => {});
|
|
||||||
onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
|
|
||||||
onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
|
|
||||||
onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
|
|
||||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
|
|
||||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
|
|
||||||
|
|
||||||
assert.deepEqual(calls[0], ['setMode', 'auto']);
|
|
||||||
assert.deepEqual(calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
|
|
||||||
assert.deepEqual(calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
|
|
||||||
assert.deepEqual(calls[3], ['handleInput', 'GUI', 'emergencystop', undefined]);
|
|
||||||
assert.deepEqual(calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
|
|
||||||
assert.deepEqual(calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
|
|
||||||
assert.deepEqual(calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('simulateMeasurement warns and ignores invalid payloads', () => {
|
test('query.curves / query.cog send a reply on the process output port', async () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
const { inst, node } = buildAdapter();
|
||||||
const node = makeNodeStub();
|
inst.source.showWorkingCurves = () => ({ curve: [1, 2, 3] });
|
||||||
|
inst.source.showCoG = () => ({ cog: 0.77 });
|
||||||
|
// Drop earlier non-reply emissions so the assertion has a clean slice.
|
||||||
|
node._sent.length = 0;
|
||||||
|
|
||||||
const calls = [];
|
await fireInput(node, { topic: 'query.curves', payload: { request: true } });
|
||||||
inst.node = node;
|
await fireInput(node, { topic: 'query.cog', payload: { request: true } });
|
||||||
inst.RED = makeREDStub();
|
|
||||||
inst.source = {
|
|
||||||
childRegistrationUtils: { registerChild() {} },
|
|
||||||
setMode() {},
|
|
||||||
handleInput() {},
|
|
||||||
showWorkingCurves() { return {}; },
|
|
||||||
showCoG() { return {}; },
|
|
||||||
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
|
|
||||||
updateMeasuredPressure() { calls.push('updateMeasuredPressure'); },
|
|
||||||
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
|
|
||||||
updateMeasuredPower() { calls.push('updateMeasuredPower'); },
|
|
||||||
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
|
|
||||||
};
|
|
||||||
|
|
||||||
inst._attachInputHandler();
|
assert.equal(node._sent.length, 2);
|
||||||
const onInput = node._handlers.input;
|
assert.ok(Array.isArray(node._sent[0]));
|
||||||
|
assert.equal(node._sent[0].length, 3);
|
||||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
|
assert.equal(node._sent[0][0].topic, 'showWorkingCurves');
|
||||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
|
assert.equal(node._sent[0][1], null);
|
||||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
|
assert.equal(node._sent[0][2], null);
|
||||||
|
assert.deepEqual(node._sent[0][0].payload, { curve: [1, 2, 3] });
|
||||||
assert.equal(calls.length, 0);
|
assert.equal(node._sent[1][0].topic, 'showCoG');
|
||||||
assert.equal(node._warns.length, 3);
|
assert.deepEqual(node._sent[1][0].payload, { cog: 0.77 });
|
||||||
assert.match(String(node._warns[0]), /finite number/i);
|
|
||||||
assert.match(String(node._warns[1]), /payload\.unit is required/i);
|
|
||||||
assert.match(String(node._warns[2]), /unsupported simulatemeasurement type/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('status shows warning when pressure inputs are not initialized', () => {
|
test('status badge: source.getStatusBadge() warns when pressure is not initialized', () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
const { inst } = buildAdapter();
|
||||||
const node = makeNodeStub();
|
// Drive into an operational state that requires pressure initialisation;
|
||||||
|
// then assert the badge reflects the warning.
|
||||||
inst.node = node;
|
inst.source.state.stateManager.currentState = 'operational';
|
||||||
inst.source = {
|
// Force pressureInit to report uninitialised, regardless of construction.
|
||||||
currentMode: 'virtualControl',
|
inst.source.pressureInit.getStatus = () => ({
|
||||||
state: {
|
initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false,
|
||||||
getCurrentState() {
|
});
|
||||||
return 'operational';
|
|
||||||
},
|
|
||||||
getCurrentPosition() {
|
|
||||||
return 50;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getPressureInitializationStatus() {
|
|
||||||
return { initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false };
|
|
||||||
},
|
|
||||||
measurements: {
|
|
||||||
type() {
|
|
||||||
return {
|
|
||||||
variant() {
|
|
||||||
return {
|
|
||||||
position() {
|
|
||||||
return { getCurrentValue() { return 0; } };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const status = inst._updateNodeStatus();
|
|
||||||
const statusAgain = inst._updateNodeStatus();
|
|
||||||
|
|
||||||
|
const status = inst.source.getStatusBadge();
|
||||||
assert.equal(status.fill, 'yellow');
|
assert.equal(status.fill, 'yellow');
|
||||||
assert.equal(status.shape, 'ring');
|
assert.equal(status.shape, 'ring');
|
||||||
assert.match(status.text, /pressure not initialized/i);
|
assert.match(status.text, /pressure not initialized/i);
|
||||||
assert.equal(statusAgain.fill, 'yellow');
|
|
||||||
assert.equal(node._warns.length, 1);
|
|
||||||
assert.match(String(node._warns[0]), /Pressure input is not initialized/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('showWorkingCurves and CoG route reply messages to process output index', () => {
|
test('unknown topic dispatched to the input handler does not throw', async () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
const { node } = buildAdapter();
|
||||||
const node = makeNodeStub();
|
await assert.doesNotReject(async () => {
|
||||||
inst.node = node;
|
await fireInput(node, { topic: 'totally.unknown.topic', payload: 42 });
|
||||||
inst.RED = makeREDStub();
|
});
|
||||||
inst.source = {
|
|
||||||
childRegistrationUtils: { registerChild() {} },
|
|
||||||
setMode() {},
|
|
||||||
handleInput() {},
|
|
||||||
showWorkingCurves() {
|
|
||||||
return { curve: [1, 2, 3] };
|
|
||||||
},
|
|
||||||
showCoG() {
|
|
||||||
return { cog: 0.77 };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
inst._attachInputHandler();
|
|
||||||
const onInput = node._handlers.input;
|
|
||||||
const sent = [];
|
|
||||||
const send = (out) => sent.push(out);
|
|
||||||
|
|
||||||
onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
|
|
||||||
onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
|
|
||||||
|
|
||||||
assert.equal(sent.length, 2);
|
|
||||||
assert.equal(Array.isArray(sent[0]), true);
|
|
||||||
assert.equal(sent[0].length, 3);
|
|
||||||
assert.equal(sent[0][0].topic, 'showWorkingCurves');
|
|
||||||
assert.equal(sent[0][1], null);
|
|
||||||
assert.equal(sent[0][2], null);
|
|
||||||
assert.equal(sent[1][0].topic, 'showCoG');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,31 @@ test('getOutput contains all required fields in idle state', () => {
|
|||||||
assert.ok('pressureDriftFlags' in output);
|
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 () => {
|
test('getOutput flow drift fields appear after sufficient measured flow samples', async () => {
|
||||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ function makeMachineConfig(overrides = {}) {
|
|||||||
functionality: {
|
functionality: {
|
||||||
positionVsParent: 'atEquipment',
|
positionVsParent: 'atEquipment',
|
||||||
},
|
},
|
||||||
|
// Post-AssetResolver: only model + unit + tagCode/uuid are saved on the
|
||||||
|
// node. supplier/category/type are derived from the registry. Keeping
|
||||||
|
// legacy fields in the factory would trip the strict-cutover guard in
|
||||||
|
// nodeClass.buildDomainConfig.
|
||||||
asset: {
|
asset: {
|
||||||
supplier: 'hidrostal',
|
|
||||||
category: 'machine',
|
|
||||||
type: 'pump',
|
|
||||||
model: 'hidrostal-H05K-S03R',
|
model: 'hidrostal-H05K-S03R',
|
||||||
unit: 'm3/h',
|
unit: 'm3/h',
|
||||||
curveUnits: {
|
curveUnits: {
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ function machineConfig() {
|
|||||||
general: { id: 'p1', name: 'p1', unit: 'm3/h',
|
general: { id: 'p1', name: 'p1', unit: 'm3/h',
|
||||||
logging: { enabled: false, logLevel: 'error' } },
|
logging: { enabled: false, logLevel: 'error' } },
|
||||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||||
asset: { category: 'pump', type: 'centrifugal',
|
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
|
||||||
model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
|
||||||
mode: {
|
mode: {
|
||||||
current: 'auto',
|
current: 'auto',
|
||||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||||
|
|||||||
92
test/integration/bep-distance-cascade.integration.test.js
Normal file
92
test/integration/bep-distance-cascade.integration.test.js
Normal 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}`);
|
||||||
|
});
|
||||||
@@ -33,22 +33,25 @@ test('calcCog peak is always >= minEfficiency', () => {
|
|||||||
assert.ok(result.cog >= result.minEfficiency, 'Peak must be >= min');
|
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 machine = makePressurizedOperationalMachine();
|
||||||
const { powerCurve, flowCurve } = machine.getCurrentCurves();
|
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.ok(efficiencyCurve.length > 0, 'Efficiency curve should not be empty');
|
||||||
assert.equal(efficiencyCurve.length, powerCurve.y.length, 'Should match curve length');
|
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++) {
|
for (let i = 0; i < efficiencyCurve.length; i++) {
|
||||||
const power = powerCurve.y[i];
|
const power = powerCurve.y[i];
|
||||||
const flow = flowCurve.y[i];
|
const flow = flowCurve.y[i];
|
||||||
if (power > 0 && flow >= 0) {
|
if (power > 0 && flow >= 0 && dP > 0) {
|
||||||
const expected = flow / power;
|
const expected = (flow * dP) / power;
|
||||||
assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${i}`);
|
assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${i}: got ${efficiencyCurve[i]}, expected ${expected}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
test/integration/qh-curve.integration.test.js
Normal file
76
test/integration/qh-curve.integration.test.js
Normal 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}`);
|
||||||
|
});
|
||||||
@@ -70,3 +70,77 @@ test('exitmaintenance requires mode with exitmaintenance action allowed', async
|
|||||||
await machine.handleInput('fysical', 'exitMaintenance', 'exitmaintenance');
|
await machine.handleInput('fysical', 'exitMaintenance', 'exitmaintenance');
|
||||||
assert.equal(machine.state.getCurrentState(), 'idle');
|
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shutdown clears delayedMove synchronously, before the abort/await path runs', async () => {
|
||||||
|
// Regression: when MGC parks a setpoint in state.delayedMove during a
|
||||||
|
// dead-zone keep-alive, then PS commands shutdown via turnOffAllMachines,
|
||||||
|
// the shutdown's interruptible-abort path triggers transitionToState
|
||||||
|
// ('operational'), which auto-picks up delayedMove and re-starts the
|
||||||
|
// pump. Pump bounces accelerating ↔ decelerating forever and the
|
||||||
|
// shutdown sequence never reaches idle. Observed live in the
|
||||||
|
// pumpingstation-complete-example demo: basin drained past stopLevel
|
||||||
|
// with one pump stuck at minimum flow.
|
||||||
|
//
|
||||||
|
// Fix: executeSequence clears state.delayedMove for shutdown/emergencystop
|
||||||
|
// BEFORE the abort+await path. Asserting synchronously (race the first
|
||||||
|
// microtask) is the precise behavioural check — without the fix, the
|
||||||
|
// auto-pickup could still re-engage the pump on the way to idle even if
|
||||||
|
// the value is null after the call returns.
|
||||||
|
|
||||||
|
const slowMove = makeStateConfig({
|
||||||
|
movement: { mode: 'staticspeed', speed: 50, maxSpeed: 100, interval: 10 },
|
||||||
|
});
|
||||||
|
const machine = new Machine(makeMachineConfig(), slowMove);
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||||
|
machine.setpoint(80);
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'accelerating');
|
||||||
|
|
||||||
|
machine.state.delayedMove = 75;
|
||||||
|
|
||||||
|
// Kick off the shutdown but do not await — capture state before the
|
||||||
|
// abort path's await yields.
|
||||||
|
const shutdownPromise = machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||||
|
// Yield once to allow the synchronous prelude of executeSequence to run
|
||||||
|
// (lookup, lowercase, the new delayedMove=null assignment) without
|
||||||
|
// letting any await resolve.
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(machine.state.delayedMove, null,
|
||||||
|
'delayedMove must be cleared synchronously by the shutdown prelude — otherwise the abort path will auto-pick it up');
|
||||||
|
|
||||||
|
await shutdownPromise;
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emergencystop also clears queued delayedMove', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
await machine.handleInput('parent', 'execMovement', 30);
|
||||||
|
machine.state.delayedMove = 60;
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'emergencystop');
|
||||||
|
|
||||||
|
assert.equal(machine.state.delayedMove, null,
|
||||||
|
'emergency-stop must clear delayedMove');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup does NOT clear delayedMove (only shutdown/emergencystop does)', async () => {
|
||||||
|
// delayedMove serves a legitimate purpose for non-stop sequences — e.g.
|
||||||
|
// setpoints arriving while the pump is in 'starting' get queued and
|
||||||
|
// auto-picked-up when state lands in 'operational'. The fix must be
|
||||||
|
// narrowly scoped to interruptible (stop) sequences.
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
machine.state.delayedMove = 42;
|
||||||
|
|
||||||
|
// Re-running startup from operational is a no-op for state, but the
|
||||||
|
// delayedMove must still be there afterwards for the auto-pickup to fire.
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
|
||||||
|
assert.equal(machine.state.delayedMove, 42,
|
||||||
|
'non-stop sequences must preserve delayedMove for the auto-pickup');
|
||||||
|
});
|
||||||
|
|||||||
152
wiki/Home.md
Normal file
152
wiki/Home.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# rotatingMachine
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
|
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 — 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 — 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 — Contracts](Reference-Contracts#topic-contract)):
|
||||||
|
|
||||||
|
1. `data.simulate-measurement` (upstream + downstream) — injects ~0 mbar suction and ~1100 mbar discharge so the predictor has something to work with.
|
||||||
|
2. `set.mode = virtualControl` — lets the GUI source drive the pump (parent path is for grouped use).
|
||||||
|
3. `cmd.startup` — FSM runs `idle → starting → warmingup → operational`. `runtime` starts accumulating.
|
||||||
|
4. `set.setpoint = 60` (control %) — pump ramps from `0` to `60` at the configured `Reaction Speed`; state goes `operational → accelerating → operational`.
|
||||||
|
5. `set.flow-setpoint = {value: 80, unit: "m3/h"}` — same path, but the setpoint is a flow value; the node converts via `predictCtrl` to a control %.
|
||||||
|
6. `cmd.shutdown` — `operational → decelerating → stopping → coolingdown → idle`.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **GIF needed.** Demo recording of steps 1–6 with the live status panel. Save as `wiki/_partial-gifs/rotatingMachine/01-basic-demo.gif`, target ≤ 1 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` | — | any | Run the configured startup sequence (default `[starting, warmingup, operational]`). |
|
||||||
|
| `cmd.shutdown` | — | 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>`** — 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 — 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` — 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 — 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 — an in-flight `executeSequence('shutdown')` for-loop would keep transitioning the FSM through `stopping → coolingdown → 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 — safety guarantees are unchanged.
|
||||||
|
|
||||||
|
See [Architecture — FSM](Reference-Architecture#fsm) for the full mechanism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Need more?
|
||||||
|
|
||||||
|
| Page | What you'll find |
|
||||||
|
|:---|:---|
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Full topic contract, config schema, child registration filters |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, FSM, prediction pipeline, drift, lifecycle |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped example flows + debug recipes |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | When not to use, known limitations, open questions |
|
||||||
|
|
||||||
|
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/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)
|
||||||
340
wiki/Reference-Architecture.md
Normal file
340
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# Reference — Architecture
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!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 — 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`, …); 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 — 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 — 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` — 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` | — | `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 — 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` — 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()]
|
||||||
|
upd --> calc[calcFlowPower(ctrl)]
|
||||||
|
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` — 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 — 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)` — matrix lives in `config.mode.allowedActions`.
|
||||||
|
2. `host.isValidSourceForMode(source, currentMode)` — 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 — 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 — 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()` — recompute predictions + Port 0 |
|
||||||
|
| `state.emitter` `'stateChange'` | `stateManager.transitionTo` resolve | `_updateState()` — 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 — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||||
|
| [Reference — 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 — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|
||||||
279
wiki/Reference-Contracts.md
Normal file
279
wiki/Reference-Contracts.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# Reference — Contracts
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!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`) | — | Switch operational mode. Each mode has its own allow-list of actions and sources. |
|
||||||
|
| `cmd.startup` | — | any | — | Run the configured `startup` sequence (default `[starting, warmingup, operational]`). |
|
||||||
|
| `cmd.shutdown` | — | any | — | Run the `shutdown` sequence. If currently `operational`, `executeSequence` first ramps the setpoint to 0 (interruptible). |
|
||||||
|
| `cmd.estop` | `emergencystop` | any | — | Run the `emergencystop` sequence (default `[emergencystop, off]`). Reachable from every state. |
|
||||||
|
| `set.setpoint` | `execMovement` | `{setpoint: number}` | control % (no `units` — 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 | — | Reply on Port 0 with the current working curves (flow / power / efficiency). |
|
||||||
|
| `query.cog` | `CoG` | any | — | Reply on Port 0 with the centre-of-gravity (CoG) point. |
|
||||||
|
| `child.register` | `registerChild` | `string` (child node id) | — | Register a `measurement` child with this machine. Port 2 wiring does this automatically in normal flows. |
|
||||||
|
| `execSequence` | — | `{action: "startup" \| "shutdown"}` | — | 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 — `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`, …). |
|
||||||
|
| `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 — 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 — 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` — **non-interruptible** safety. |
|
||||||
|
| Shutdown Time | `time.stopping` | configured in s | Time in `stopping`. |
|
||||||
|
| Cooldown Time | `time.coolingdown` | configured in s | Time in `coolingdown` — **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–41.
|
||||||
|
|
||||||
|
| Quantity | Canonical (internal) | Output (rendered) | Curve (supplier) | Required-unit |
|
||||||
|
|:---|:---|:---|:---|:---:|
|
||||||
|
| Pressure | `Pa` | `mbar` | `mbar` | ✓ |
|
||||||
|
| Atmospheric pressure | `Pa` | `Pa` | — | ✓ |
|
||||||
|
| Flow | `m3/s` | `m3/h` | `m3/h` | ✓ |
|
||||||
|
| Power | `W` | `kW` | `kW` | ✓ |
|
||||||
|
| Temperature | `K` | `°C` | — | ✓ |
|
||||||
|
| Control | — | — | `%` | — |
|
||||||
|
|
||||||
|
`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 — 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 — the virtuals keep listening so dashboards can still inject sim values during real-pressure outages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, FSM, prediction + drift pipeline |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
||||||
|
| [EVOLV — Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
|
||||||
|
| [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |
|
||||||
169
wiki/Reference-Examples.md
Normal file
169
wiki/Reference-Examples.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Reference — Examples
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!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 — 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 — 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 → Import → 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 — 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` × 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` × 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 → starting → warmingup → operational`. `runtime` starts accumulating.
|
||||||
|
4. Click `set.setpoint = 60` (control %). `state` goes `operational → accelerating → 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'}` — same path, but the setpoint is a flow value; the node converts via `predictCtrl` to a control %.
|
||||||
|
6. Click `cmd.shutdown`. State: `operational → decelerating → stopping → coolingdown → 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–6 + the status badge progression. Save as `wiki/_partial-gifs/rotatingMachine/01-basic-demo.gif`, target ≤ 1 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 → decelerating → …`.
|
||||||
|
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 → coolingdown → 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 — with `delayedMove = 60` sitting unused.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example 02 — 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 — 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 — 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 — 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 — fills the container log within seconds and obscures real errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, FSM, prediction + drift pipeline |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
||||||
|
| [machineGroupControl — Examples](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Reference-Examples) | Group-control demo flows |
|
||||||
|
| [EVOLV — Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where rotatingMachine fits in a larger plant |
|
||||||
105
wiki/Reference-Limitations.md
Normal file
105
wiki/Reference-Limitations.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Reference — Limitations
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!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` — no curve, no FSM-driven motor. |
|
||||||
|
| A valve actuator (motorised, no characteristic curve) | `valve` (and `valveGroupControl` if grouped). |
|
||||||
|
| A group of 2 + pumps load-sharing on a header | `machineGroupControl` — 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 — 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 > 30 s. The quality string flips to `invalid` — 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 — 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 — 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 — 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 — 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 — Contracts](Reference-Contracts) | Topic + config + child filters (alias map at the end) |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, FSM (including sequence-abort token), prediction + drift |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||||
|
| [machineGroupControl — 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
19
wiki/_Sidebar.md
Normal 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)
|
||||||
Reference in New Issue
Block a user