Compare commits

12 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

Full suite: 214/214 pass.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:11 +02:00
31 changed files with 2360 additions and 642 deletions

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ module.exports = [
topic: 'set.mode',
aliases: ['setMode'],
payloadSchema: { type: 'string' },
description: 'Switch the machine between auto / manual control modes.',
description: 'Switch the operating mode. Allowed: `auto`, `virtualControl`, `fysicalControl`, `maintenance` (schema-validated in `rotatingMachine.json` → `mode.current`).',
handler: handlers.setMode,
},
{

View File

@@ -1,39 +1,24 @@
const { convert } = require('generalFunctions');
/**
* Strict numeric unit conversion. Mirrors specificClass._convertUnitValue
* so the curve normalizer is testable without a Machine instance.
*/
function convertUnitValue(value, fromUnit, toUnit, contextLabel = 'unit conversion') {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
throw new Error(`${contextLabel}: value '${value}' is not finite`);
}
if (!fromUnit || !toUnit || fromUnit === toUnit) return numeric;
return convert(numeric).from(fromUnit).to(toUnit);
}
/**
* Convert one curve section (nq or np) from supplied units to canonical
* units. Logs a warning when the per-pressure median y jumps by more than
* 3x relative to the previous pressure level — that almost always means the
* curve file is corrupt (mixed units, swapped rows) and the predict module
* would otherwise silently produce nonsense values.
* 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, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName, logger) {
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 = convertUnitValue(
const canonicalPressure = unitPolicy.convert(
Number(pressureKey),
fromPressureUnit,
toPressureUnit,
`${sectionName} pressure axis`
`${sectionName} pressure axis`,
);
const xArray = Array.isArray(pair?.x) ? pair.x.map(Number) : [];
const yArray = Array.isArray(pair?.y)
? pair.y.map((v) => convertUnitValue(v, fromYUnit, toYUnit, `${sectionName} output`))
? 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}'.`);
@@ -74,21 +59,23 @@ function normalizeMachineCurve(rawCurve, unitPolicy, logger) {
return {
nq: normalizeCurveSection(
rawCurve.nq,
unitPolicy,
curveUnits.flow,
canonicalFlow,
curveUnits.pressure,
canonicalPressure,
'nq',
logger
logger,
),
np: normalizeCurveSection(
rawCurve.np,
unitPolicy,
curveUnits.power,
canonicalPower,
curveUnits.pressure,
canonicalPressure,
'np',
logger
logger,
),
};
}
@@ -114,4 +101,4 @@ function readCanonical(unitPolicy, type) {
return (unitPolicy.canonical || {})[type] || null;
}
module.exports = { normalizeMachineCurve, normalizeCurveSection, convertUnitValue };
module.exports = { normalizeMachineCurve, normalizeCurveSection };

View File

@@ -58,4 +58,65 @@ function showWorkingCurves(predictors) {
};
}
module.exports = { showWorkingCurves, showCoG };
/**
* 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 };

View File

@@ -50,7 +50,7 @@ class FlowController {
return await host.executeSequence(parameter);
case 'flowmovement': {
const canonicalFlowSetpoint = host._convertUnitValue(
const canonicalFlowSetpoint = host.unitPolicy.convert(
parameter,
host.unitPolicy.output.flow,
host.unitPolicy.canonical.flow,

View File

@@ -11,8 +11,14 @@ class nodeClass extends BaseNodeAdapter {
static commands = commands;
static tickInterval = null;
static statusInterval = 1000;
// Realized control position holds constant in steady state, so delta
// compression would emit it ~once and the Grafana "% Control" line goes
// invisible. Force it every tick so the pump's movement always traces.
static alwaysEmitFields = ['ctrl'];
buildDomainConfig(uiConfig) {
_rejectLegacyAssetFields(uiConfig);
const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h');
// Stash extras on the Machine class so its constructor (called by
// BaseNodeAdapter via DomainClass) picks them up alongside the
@@ -33,6 +39,7 @@ class nodeClass extends BaseNodeAdapter {
uuid: uiConfig.assetUuid || uiConfig.uuid || null,
tagCode: uiConfig.assetTagCode || uiConfig.assetTagNumber || null,
tagNumber: uiConfig.assetTagNumber || null,
model: uiConfig.model || null,
unit: flowUnit,
curveUnits: {
pressure: _resolveUnit(uiConfig.curvePressureUnit, 'pressure', 'mbar'),
@@ -47,6 +54,23 @@ class nodeClass extends BaseNodeAdapter {
}
}
// 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();

View File

@@ -5,19 +5,32 @@
* 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) {
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];
const eff = (power > 0 && flow >= 0) ? flow / power : 0;
// η = (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;
@@ -31,10 +44,11 @@ function calcCog(host) {
return { cog: 0, cogIndex: 0, NCog: 0, minEfficiency: 0 };
}
const { powerCurve, flowCurve } = getCurrentCurves(host);
const { efficiencyCurve, peak, peakIndex, minEfficiency } = calcEfficiencyCurve(powerCurve, flowCurve);
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 = (flowCurve.y[peakIndex] - yMin) / (yMax - yMin);
const NCog = (yMax > yMin) ? (flowCurve.y[peakIndex] - yMin) / (yMax - yMin) : 0;
host.currentEfficiencyCurve = efficiencyCurve;
host.cog = peak;
host.cogIndex = peakIndex;
@@ -86,14 +100,28 @@ function calcEfficiency(host, power, flow, variant) {
const flowM3s = host.measurements.type('flow').variant(variant).position('atEquipment').getCurrentValue('m3/s');
const powerW = host.measurements.type('power').variant(variant).position('atEquipment').getCurrentValue('W');
host.logger.debug(`temp: ${temp} atmPressure : ${atm} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`);
// 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) {
host.measurements.type('efficiency').variant(variant).position('atEquipment').value(flow / power);
// η_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 (pressureDiff?.value != null && Number.isFinite(flowM3s) && Number.isFinite(powerW) && powerW > 0) {
const diffPa = Number(pressureDiff.value);
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');

View File

@@ -2,33 +2,44 @@
/**
* PressureRouter — routes a measured pressure value into the right
* MeasurementContainer slot and triggers downstream side-effects
* (position recompute + drift/health refresh) only when the source
* is a real child (not a dashboard-sim virtual one).
* MeasurementContainer slot and triggers the downstream cascade
* (preferred-pressure resolve → predicted recompute drifthealth)
* on every pressure write, matching the pre-refactor
* `updateMeasuredPressure` semantics.
*
* Extracted from rotatingMachine specificClass.updateMeasuredPressure.
* 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 }
* - virtualPressureChildIds: { upstream, downstream } (kept for debug only)
* - resolveMeasurementUnit(type, unit) -> canonical unit string (throws on invalid)
* - updatePosition?(): called after a real-source write
* - refreshDrift?(): called after a real-source write (e.g. _updatePressureDriftStatus)
* - refreshHealth?(): called after a real-source write (e.g. _updatePredictionHealth)
* - getPressure?(): optional, returns the current preferred pressure (for logging)
* - 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.getPressure = ctx.getPressure;
this.logger = ctx.logger || { warn() {}, debug() {} };
}
@@ -54,16 +65,19 @@ class PressureRouter {
const isVirtual = this._isVirtual(childId);
this.logger.debug(`Pressure routed: ${value} ${unit} at ${pos} from ${context.childName || 'child'} (${childId || 'unknown-id'}) virtual=${isVirtual}`);
if (!isVirtual) {
if (typeof this.updatePosition === 'function') this.updatePosition();
if (typeof this.refreshDrift === 'function') this.refreshDrift();
if (typeof this.refreshHealth === 'function') this.refreshHealth();
}
// 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') {
const p = this.getPressure();
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;
}

View File

@@ -7,7 +7,7 @@
// stitches them together and preserves the public API the existing test
// suite + sibling nodes (MGC, pumpingStation) depend on.
const { BaseDomain, UnitPolicy, state, nrmse, interpolation, convert } = require('generalFunctions');
const { BaseDomain, UnitPolicy, state, nrmse, interpolation, convert, assetResolver } = require('generalFunctions');
const { loadModelCurve } = require('./curves/curveLoader');
const { normalizeMachineCurve } = require('./curves/curveNormalizer');
@@ -43,8 +43,24 @@ class Machine extends BaseDomain {
// ES6 forbids `this` before super(). Single-threaded JS means stashing
// on the class itself between the caller's args and super() is race-free;
// configure() picks the extras up immediately after.
constructor(machineConfig = {}, stateConfig = {}, errorMetricsConfig = {}) {
Machine._pendingExtras = { stateConfig, errorMetricsConfig };
//
// Two call sites exist:
// - nodeClass.buildDomainConfig() pre-sets Machine._pendingExtras and
// then BaseNodeAdapter calls `new Machine(this.config)` (single-arg).
// - Tests / direct callers pass (machineConfig, stateConfig, errMetrics)
// explicitly.
// With default-param `stateConfig={}`, the single-arg path was silently
// clobbering the pre-set extras with an empty object, so the state machine
// booted with schema defaults (warmingup=5s, speed=1%/s, mode=dynspeed)
// regardless of what the editor saved. Only overwrite when an explicit
// value is provided.
constructor(machineConfig = {}, stateConfig, errorMetricsConfig) {
if (stateConfig !== undefined || errorMetricsConfig !== undefined) {
Machine._pendingExtras = {
stateConfig: stateConfig ?? {},
errorMetricsConfig: errorMetricsConfig ?? {},
};
}
super(machineConfig);
}
@@ -67,6 +83,54 @@ class Machine extends BaseDomain {
_setupCurves() {
this.model = this.config.asset?.model;
// Resolve derived metadata (supplier / type / allowed units) from the asset
// registry. Source of truth lives in generalFunctions/datasets/assetData/.
// If the registry has no entry for this model, assetMetadata is null and
// we'll error out with a clear message below.
this.assetMetadata = this.model
? assetResolver.resolveAssetMetadata('rotatingmachine', this.model)
: null;
if (!this.model) {
this.logger.error(`rotatingMachine: asset.model is required. Open the node, pick a model from the asset menu, and save.`);
this._installNullPredictors();
return;
}
if (!this.assetMetadata) {
this.logger.error(`rotatingMachine: model '${this.model}' not found in asset registry (datasets/assetData/rotatingmachine.json). Cannot derive supplier/type/units.`);
this._installNullPredictors();
return;
}
// Validate the chosen deployment unit. Hard check: it must be a recognised
// flow unit (convert() can describe it). Soft check: warn if it isn't in
// the registry's allowed-set for this model — the list is the editor's
// recommended dropdown, not an exhaustive whitelist.
const chosenUnit = this.config.asset?.unit;
if (!chosenUnit) {
this.logger.error(`rotatingMachine: asset.unit is required for model '${this.model}'. Re-save the node from the editor.`);
this._installNullPredictors();
return;
}
try {
const desc = convert().describe(chosenUnit);
if (desc.measure !== 'volumeFlowRate') {
this.logger.error(`rotatingMachine: asset.unit '${chosenUnit}' is not a flow unit (got measure '${desc.measure}').`);
this._installNullPredictors();
return;
}
} catch (_) {
this.logger.error(`rotatingMachine: asset.unit '${chosenUnit}' is not a recognised unit.`);
this._installNullPredictors();
return;
}
const allowedUnits = this.assetMetadata.units || [];
if (allowedUnits.length > 0 && !allowedUnits.includes(chosenUnit)) {
this.logger.warn(
`rotatingMachine: asset.unit '${chosenUnit}' is not in the registry's recommended list ` +
`for model '${this.model}' (allowed: [${allowedUnits.join(', ')}]). Continuing — the unit is a valid flow unit.`,
);
}
const { rawCurve, error } = loadModelCurve(this.model);
this.rawCurve = rawCurve;
if (error) { this.logger.error(`${error} in machineConfig. Cannot make predictions.`); this._installNullPredictors(); return; }
@@ -165,10 +229,18 @@ class Machine extends BaseDomain {
this.measurements.type('temperature').variant('measured').position('atEquipment').value(15, Date.now(), tu);
this.measurements.type('atmPressure').variant('measured').position('atEquipment').value(101325, Date.now(), 'Pa');
const fu = this.unitPolicy.canonical.flow;
const pu = this.unitPolicy.canonical.power;
const fmin = this.predictFlow ? this.predictFlow.currentFxyYMin : 0;
const fmax = this.predictFlow ? this.predictFlow.currentFxyYMax : 0;
this.measurements.type('flow').variant('predicted').position('max').value(fmax, Date.now(), fu);
this.measurements.type('flow').variant('predicted').position('min').value(fmin, Date.now(), fu);
// Seed the operating-point series at boot so telemetry always carries them
// (0 while idle, real values once calcFlow/calcPower run when operational).
// Without this an idle-from-boot machine never emits these keys — the
// dashboard can't even show the off/0 state. Mirrors max/min above.
this.measurements.type('flow').variant('predicted').position('downstream').value(0, Date.now(), fu);
this.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), fu);
this.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), pu);
}
_callMeasurementHandler(measurementType, value, position, context = {}) {
@@ -183,12 +255,6 @@ class Machine extends BaseDomain {
if (!this.isUnitValidForType(type, u)) throw new Error(`Unsupported unit '${u}' for ${type} measurement.`);
return u;
}
_convertUnitValue(value, from, to, ctx = 'unit conversion') {
const n = Number(value);
if (!Number.isFinite(n)) throw new Error(`${ctx}: value '${value}' is not finite`);
if (!from || !to || from === to) return n;
return convert(n).from(from).to(to);
}
_measurementPositionForMetric(metricId) { return metricId === 'power' ? 'atEquipment' : 'downstream'; }
_resolveProcessRangeForMetric(metricId, predicted, measured) {
let processMin = NaN; let processMax = NaN;
@@ -243,6 +309,10 @@ class Machine extends BaseDomain {
this.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), pu);
}
this._updatePredictionHealth();
// Push port 0 deltas so downstream dashboards / probes see state +
// predicted-flow updates as they happen. BaseNodeAdapter listens for
// 'output-changed' on this.emitter to fire _emitOutputs().
this.notifyOutputChanged();
}
updatePosition() {
@@ -254,6 +324,7 @@ class Machine extends BaseDomain {
this.calcDistanceBEP(efficiency, cog, minEfficiency);
}
this._updatePredictionHealth();
this.notifyOutputChanged();
}
// ── mode + input dispatch ──────────────────────────────────────────
@@ -323,7 +394,8 @@ class Machine extends BaseDomain {
const powerCurve = this.groupPredictPower.currentFxyCurve[this.groupPredictPower.currentF];
const flowCurve = this.groupPredictFlow.currentFxyCurve[this.groupPredictFlow.currentF];
if (!powerCurve?.y?.length || !flowCurve?.y?.length) return 0;
const { peakIndex } = this.calcEfficiencyCurve(powerCurve, flowCurve);
const dP = this.groupPredictFlow.currentF;
const { peakIndex } = this.calcEfficiencyCurve(powerCurve, flowCurve, dP);
const yMin = this.groupPredictFlow.currentFxyYMin;
const yMax = this.groupPredictFlow.currentFxyYMax;
if (yMax <= yMin) return 0;
@@ -333,7 +405,7 @@ class Machine extends BaseDomain {
// ── efficiency math (delegates) ────────────────────────────────────
calcCog() { return eff.calcCog(this); }
calcEfficiencyCurve(p, f) { return eff.calcEfficiencyCurve(p, f); }
calcEfficiencyCurve(p, f, dP) { return eff.calcEfficiencyCurve(p, f, dP); }
calcEfficiency(power, flow, variant) { return eff.calcEfficiency(this, power, flow, variant); }
calcDistanceBEP(e, max, min) { return eff.calcDistanceBEP(this, e, max, min); }
calcDistanceFromPeak(e, peak) { return eff.calcDistanceFromPeak(e, peak); }

View File

@@ -63,6 +63,15 @@ async function executeSequence(host, rawName) {
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();
@@ -74,9 +83,18 @@ async function executeSequence(host, rawName) {
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; }
}

View File

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

View File

@@ -5,7 +5,6 @@ const { UnitPolicy } = require('generalFunctions');
const {
normalizeMachineCurve,
normalizeCurveSection,
convertUnitValue,
} = require('../../src/curves/curveNormalizer');
function makePolicy() {
@@ -50,39 +49,33 @@ test('normalizeMachineCurve: converts pressure mbar -> Pa and flow m3/h -> m3/s'
});
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, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger);
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, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger);
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] } }, 'm3/h', 'm3/s', 'mbar', 'Pa', 'nq', null),
() => normalizeCurveSection({ 1000: { x: [0, 50], y: [0, 5, 10] } }, policy, 'm3/h', 'm3/s', 'mbar', 'Pa', 'nq', null),
/Invalid nq section/
);
});
test('convertUnitValue: identity when units match or missing', () => {
assert.equal(convertUnitValue(42, 'm3/h', 'm3/h'), 42);
assert.equal(convertUnitValue(42, null, null), 42);
});
test('convertUnitValue: throws on non-finite input', () => {
assert.throws(() => convertUnitValue('not-a-number', 'm3/h', 'm3/s', 'test'), /not finite/);
});

View File

@@ -27,6 +27,10 @@ function makeHost({
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,
@@ -38,10 +42,6 @@ function makeHost({
return { moved: sp };
},
calcCtrl: (canonicalFlow) => { host.calls.calcCtrl.push(canonicalFlow); return canonicalFlow / 2; },
_convertUnitValue: (val, from, to, label) => {
host.calls.convertUnit.push({ val, from, to, label });
return val * 1000; // pretend m3/h -> m3/s factor
},
};
return host;
}

View File

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

View File

@@ -35,42 +35,63 @@ test('route("upstream", 1, ctx) writes to the upstream pressure slot', () => {
assert.equal(meas.writes[0].u, 'mbar');
});
test('virtual source: refresh hooks NOT called', () => {
test('virtual source: full cascade still runs (dashboard-sim must update predictions)', () => {
const meas = makeFakeMeasurements();
let posCalled = 0, driftCalled = 0, healthCalled = 0;
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(posCalled, 0);
assert.equal(driftCalled, 0);
assert.equal(healthCalled, 0);
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 posCalled = 0, driftCalled = 0, healthCalled = 0;
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 = [];

View File

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

View File

@@ -2,149 +2,208 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
const commands = require('../../src/commands');
const { createRegistry } = require('generalFunctions');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
// Post-BaseNodeAdapter, dispatch is the commands-registry. These tests
// drive the same surface from a prototype-derived nodeClass instance to
// keep the routing covered without booting Node-RED.
// Drive routing through the public BaseNodeAdapter surface only. We
// construct a full nodeClass instance and invoke the input handler
// installed by the base on `node.on('input', ...)`. Side-effects are
// observed via `node._sent`, the registered child registry on the
// source, and instrumented domain methods.
function makeSourceStub() {
const calls = [];
function makeUiConfig(overrides = {}) {
// Post-AssetResolver: editor saves only model + unit + uuid/tagCode.
// supplier/category/assetType are derived at runtime.
return {
calls,
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
childRegistrationUtils: { registerChild(childSource, pos) { calls.push(['registerChild', childSource, pos]); } },
setMode(mode) { calls.push(['setMode', mode]); },
handleInput(source, action, parameter) { calls.push(['handleInput', source, action, parameter]); return Promise.resolve(); },
showWorkingCurves() { return { ok: true }; },
showCoG() { return { cog: 1 }; },
updateSimulatedMeasurement(type, position, value) { calls.push(['updateSimulatedMeasurement', type, position, value]); },
updateMeasuredPressure(value, position) { calls.push(['updateMeasuredPressure', value, position]); },
updateMeasuredFlow(value, position) { calls.push(['updateMeasuredFlow', value, position]); },
updateMeasuredPower(value, position) { calls.push(['updateMeasuredPower', value, position]); },
updateMeasuredTemperature(value, position) { calls.push(['updateMeasuredTemperature', value, position]); },
isUnitValidForType() { return true; },
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,
};
}
test('input handler routes topics to source methods via commands registry', async () => {
const inst = Object.create(NodeClass.prototype);
// 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 source = makeSourceStub();
inst.node = node;
inst.RED = makeREDStub({ child1: { source: { id: 'child-source' } } });
inst.source = source;
inst._commands = createRegistry(commands, { logger: source.logger });
inst._attachInputHandler();
const onInput = node._handlers.input;
await onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
await onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } }, () => {}, () => {});
await onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
await onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
await onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
await onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
assert.deepEqual(source.calls[0], ['setMode', 'auto']);
assert.deepEqual(source.calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
assert.deepEqual(source.calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
// estop handler defaults action to 'emergencystop' even without one
// supplied, so the trailing arg is undefined — passed as positional.
assert.deepEqual(source.calls[3].slice(0, 3), ['handleInput', 'GUI', 'emergencystop']);
assert.deepEqual(source.calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
assert.deepEqual(source.calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
assert.deepEqual(source.calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
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 */ }
}
});
test('simulateMeasurement warns and ignores invalid payloads', async () => {
const warns = [];
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
// Capture every call to source.handleInput so the test can assert which
// canonical action the dispatch produced.
function instrumentHandleInput(source) {
const calls = [];
inst.node = node;
inst.RED = makeREDStub();
inst.source = {
logger: { warn: (m) => warns.push(m), info: () => {}, debug: () => {}, error: () => {} },
childRegistrationUtils: { registerChild() {} },
setMode() {},
handleInput() { return Promise.resolve(); },
showWorkingCurves() { return {}; },
showCoG() { return {}; },
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
updateMeasuredPressure() { calls.push('updateMeasuredPressure'); },
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
updateMeasuredPower() { calls.push('updateMeasuredPower'); },
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
isUnitValidForType() { return true; },
const orig = source.handleInput.bind(source);
source.handleInput = async (...args) => {
calls.push(args);
return orig(...args);
};
inst._commands = createRegistry(commands, { logger: inst.source.logger });
inst._attachInputHandler();
const onInput = node._handlers.input;
return calls;
}
await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
await onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
await onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
async function fireInput(node, msg) {
await node._handlers.input(msg, (out) => node._sent.push(out), () => {});
}
assert.equal(calls.length, 0);
// Filter out the one-time deprecation warning for the legacy
// 'simulateMeasurement' alias — only the three invalid-payload warns
// matter for this assertion.
const payloadWarns = warns.filter((w) => !/deprecated/i.test(String(w)));
assert.equal(payloadWarns.length, 3);
assert.match(String(payloadWarns[0]), /finite number/i);
assert.match(String(payloadWarns[1]), /payload\.unit is required/i);
assert.match(String(payloadWarns[2]), /unsupported simulatemeasurement type/i);
test('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('source.getStatusBadge shows warning when pressure inputs are not initialized', () => {
// Status badge now lives on the domain (Machine). Build a tiny stub.
const source = {
currentMode: 'virtualControl',
state: { getCurrentState: () => 'operational', getCurrentPosition: () => 50 },
pressureInit: { getStatus: () => ({ initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false }) },
measurements: { type() { return { variant() { return { position() { return { getCurrentValue() { return 0; } }; } }; } }; } },
unitPolicy: { output: { flow: 'm3/h' } },
logger: { error: () => {} },
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]);
};
// Import the buildStatusBadge helper directly — it's the same code the
// domain's getStatusBadge() invokes.
const { buildStatusBadge } = require('../../src/io/output');
const status = buildStatusBadge(source);
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' },
});
assert.equal(dispatched.length, 1);
assert.equal(dispatched[0][0], 7.5);
assert.equal(dispatched[0][1], 'atEquipment');
});
test('query.curves / query.cog send a reply on the process output port', async () => {
const { inst, node } = buildAdapter();
inst.source.showWorkingCurves = () => ({ curve: [1, 2, 3] });
inst.source.showCoG = () => ({ cog: 0.77 });
// Drop earlier non-reply emissions so the assertion has a clean slice.
node._sent.length = 0;
await fireInput(node, { topic: 'query.curves', payload: { request: true } });
await fireInput(node, { topic: 'query.cog', payload: { request: true } });
assert.equal(node._sent.length, 2);
assert.ok(Array.isArray(node._sent[0]));
assert.equal(node._sent[0].length, 3);
assert.equal(node._sent[0][0].topic, 'showWorkingCurves');
assert.equal(node._sent[0][1], null);
assert.equal(node._sent[0][2], null);
assert.deepEqual(node._sent[0][0].payload, { curve: [1, 2, 3] });
assert.equal(node._sent[1][0].topic, 'showCoG');
assert.deepEqual(node._sent[1][0].payload, { cog: 0.77 });
});
test('status badge: source.getStatusBadge() warns when pressure is not initialized', () => {
const { inst } = buildAdapter();
// Drive into an operational state that requires pressure initialisation;
// then assert the badge reflects the warning.
inst.source.state.stateManager.currentState = 'operational';
// Force pressureInit to report uninitialised, regardless of construction.
inst.source.pressureInit.getStatus = () => ({
initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false,
});
const status = inst.source.getStatusBadge();
assert.equal(status.fill, 'yellow');
assert.equal(status.shape, 'ring');
assert.match(status.text, /pressure not initialized/i);
});
test('showWorkingCurves and CoG route reply messages to process output index', async () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
const source = {
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
childRegistrationUtils: { registerChild() {} },
setMode() {}, handleInput() { return Promise.resolve(); },
showWorkingCurves() { return { curve: [1, 2, 3] }; },
showCoG() { return { cog: 0.77 }; },
};
inst.node = node;
inst.RED = makeREDStub();
inst.source = source;
inst._commands = createRegistry(commands, { logger: source.logger });
inst._attachInputHandler();
const onInput = node._handlers.input;
const sent = [];
const send = (out) => sent.push(out);
await onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
await onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
assert.equal(sent.length, 2);
assert.equal(Array.isArray(sent[0]), true);
assert.equal(sent[0].length, 3);
assert.equal(sent[0][0].topic, 'showWorkingCurves');
assert.equal(sent[0][1], null);
assert.equal(sent[0][2], null);
assert.equal(sent[1][0].topic, 'showCoG');
test('unknown topic dispatched to the input handler does not throw', async () => {
const { node } = buildAdapter();
await assert.doesNotReject(async () => {
await fireInput(node, { topic: 'totally.unknown.topic', payload: 42 });
});
});

View File

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

View File

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

View File

@@ -36,8 +36,7 @@ function machineConfig() {
general: { id: 'p1', name: 'p1', unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal',
model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,32 @@
# rotatingMachine
> **Reflects code as of `afc304b` · regenerated `2026-05-11` via `npm run wiki:all`**
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
![code-ref](https://img.shields.io/badge/code--ref-394a972-blue) ![s88](https://img.shields.io/badge/S88-Equipment_Module-86bbdd) ![status](https://img.shields.io/badge/status-trial--ready-brightgreen)
## 1. What this node is
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.
**rotatingMachine** models a single pump, compressor, or blower. It loads a supplier characteristic curve, takes upstream + downstream pressure measurements (or simulated values), predicts the resulting flow + power, drives a startup/shutdown state machine, and assesses prediction drift against measured flow / power. Used as a child of `machineGroupControl` when grouped, or directly under a `pumpingStation`.
---
## 2. Position in the platform
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | One rotating asset on a curve &mdash; pump, blower, compressor |
| S88 level | Equipment Module |
| Use it when | You have a curve-modelled asset whose flow / power varies with header differential and you want predictions + drift |
| Don't use it for | Passive non-return valves (`valve`), curveless assets (will silently emit zeros), groups (parent under `machineGroupControl`) |
| Children it accepts | `measurement` (pressure / flow / power / temperature) |
| Parents it talks to | `machineGroupControl`, `pumpingStation`, or any node that issues `flowmovement` / `execsequence` |
---
## How it fits
```mermaid
flowchart LR
parent[machineGroupControl /<br/>pumpingStation]:::unit -->|flowmovement<br/>execsequence| rm[rotatingMachine<br/>Equipment]:::equip
m_up[measurement<br/>pressure upstream]:::ctrl -.data.-> rm
m_dn[measurement<br/>pressure downstream]:::ctrl -.data.-> rm
sim[dashboard-sim<br/>virtual pressure children]:::ctrl -.data.-> rm
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
@@ -22,327 +34,119 @@ flowchart LR
classDef ctrl fill:#a9daee,color:#000
```
S88 colours: Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`.
## 3. Capability matrix
---
| Capability | Status | Notes |
|---|---|---|
| Curve-based flow prediction | ✅ | Built from `asset.model` via `curves/curveLoader`. |
| Curve-based power prediction | ✅ | Reverse curve composed inside `buildPredictors`. |
| FSM (startup / shutdown / movement) | ✅ | Shared `state/state.js` from generalFunctions. |
| Interruptible movements | ✅ | `abortMovement` from MGC overrides on new demand. |
| Drift assessment (flow + power) | ✅ | `DriftAssessor` with EWMA + alignment tolerance. |
| Virtual pressure children for sim | ✅ | `dashboard-sim-upstream / -downstream`. |
| Real-pressure child preference | ✅ | `pressureSelector` prefers real over virtual. |
| Group operating-point prediction | ✅ | `setGroupOperatingPoint` for MGC integration. |
| `cmd.estop` hard cut | ✅ | Forces `emergencystop` state. |
| `data.simulate-measurement` injection | ✅ | Pressure / flow / power / temperature. |
| Auto-recovery from prediction loss | ⚠️ | Reverts to null predictors silently — health falls to `invalid`. |
| Multi-parent registration | ⚠️ | Accepted but not exercised in production. |
## Try it &mdash; 3-minute demo
## 4. Code map
Import the basic example flow, deploy, and drive a single pump through the full state machine.
```mermaid
flowchart TB
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
nc["buildDomainConfig()<br/>static DomainClass, commands"]
end
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
sc["Machine.configure()<br/>_setupCurves / _setupState /<br/>_setupDrift / _setupPressure /<br/>_setupChildren"]
end
subgraph concerns["src/ concern modules"]
curves["curves/<br/>loadModelCurve + normalize"]
prediction["prediction/<br/>buildPredictors + math"]
drift["drift/<br/>DriftAssessor + healthRefresh"]
pressure["pressure/<br/>init + router + selector + virtual"]
state["state/<br/>FSM bindings + sequenceController"]
measurement["measurement/<br/>handlers + childRegistrar"]
flow["flow/<br/>flowController (handleInput)"]
display["display/<br/>workingCurves + CoG"]
io["io/<br/>output + status"]
commands["commands/<br/>topic registry + handlers"]
end
nc --> sc
sc --> curves
sc --> prediction
sc --> drift
sc --> pressure
sc --> state
sc --> measurement
sc --> flow
sc --> display
sc --> io
nc --> commands
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/rotatingMachine/examples/01\ -\ Basic\ Manual\ Control.json \
http://localhost:1880/flow
```
| Module | Owns | Read first if you're changing… |
|---|---|---|
| `curves/` | Supplier curve loader + normaliser + reverse | Curve fitting, unit mismatches, fallback. |
| `prediction/` | Per-machine + group predictors, math helpers | Predicted flow / power values. |
| `drift/` | DriftAssessor (EWMA, alignment), healthRefresh | Prediction quality, flags, confidence. |
| `pressure/` | init + router + selector + virtual children | Pressure plumbing, sim vs real preference. |
| `state/` | FSM bindings + setpoint / sequence orchestration | Startup / shutdown sequences. |
| `measurement/` | Measurement handlers + child registrar | Measured value plumbing per type. |
| `flow/` | `flowController.handle(source, action, parameter)` | Top-level input dispatch. |
| `display/` | `showWorkingCurves`, `showCoG` | `query.curves` / `query.cog` outputs. |
| `io/` | `getOutput`, `getStatusBadge` | Output shape, badge text. |
| `commands/` | Input-topic registry and handlers | New input topics, payload validation. |
What to click after deploy (the inject buttons map one-to-one to topics in [Reference &mdash; Contracts](Reference-Contracts#topic-contract)):
## 5. Topic contract
1. `data.simulate-measurement` (upstream + downstream) &mdash; injects ~0 mbar suction and ~1100 mbar discharge so the predictor has something to work with.
2. `set.mode = virtualControl` &mdash; lets the GUI source drive the pump (parent path is for grouped use).
3. `cmd.startup` &mdash; FSM runs `idle &rarr; starting &rarr; warmingup &rarr; operational`. `runtime` starts accumulating.
4. `set.setpoint = 60` (control %) &mdash; pump ramps from `0` to `60` at the configured `Reaction Speed`; state goes `operational &rarr; accelerating &rarr; operational`.
5. `set.flow-setpoint = {value: 80, unit: "m3/h"}` &mdash; same path, but the setpoint is a flow value; the node converts via `predictCtrl` to a control %.
6. `cmd.shutdown` &mdash; `operational &rarr; decelerating &rarr; stopping &rarr; coolingdown &rarr; idle`.
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
> [!IMPORTANT]
> **GIF needed.** Demo recording of steps 1&ndash;6 with the live status panel. Save as `wiki/_partial-gifs/rotatingMachine/01-basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
<!-- BEGIN AUTOGEN: topic-contract -->
---
| Canonical topic | Aliases | Payload | Effect |
|---|---|---|---|
| `set.mode` | `setMode` | `string` | Replaces the named state value with the supplied payload. |
| `cmd.startup` | _(none)_ | `any` | Triggers an action / sequence — not idempotent. |
| `cmd.shutdown` | _(none)_ | `any` | Triggers an action / sequence — not idempotent. |
| `cmd.estop` | `emergencystop` | `any` | Triggers an action / sequence — not idempotent. |
| `execSequence` | _(none)_ | `object` | _(see handler)_ |
| `set.setpoint` | `execMovement` | `object` | Replaces the named state value with the supplied payload. |
| `set.flow-setpoint` | `flowMovement` | `object` | Replaces the named state value with the supplied payload. |
| `data.simulate-measurement` | `simulateMeasurement` | `object` | Pushes a value into the node's measurement stream. |
| `query.curves` | `showWorkingCurves` | `any` | Read-only query; node replies on the same msg. |
| `query.cog` | `CoG` | `any` | Read-only query; node replies on the same msg. |
| `child.register` | `registerChild` | `string` | Parent/child plumbing — registers or unregisters a child node. |
## The seven things you'll send
<!-- END AUTOGEN: topic-contract -->
| Topic | Aliases | Payload | What it does |
|:---|:---|:---|:---|
| `set.mode` | `setMode` | `"auto"` \| `"virtualControl"` \| `"fysicalControl"` | Switch between parent-controlled, GUI-controlled, and physical-source-only. Each mode has its own allow-list for actions and sources. |
| `cmd.startup` | &mdash; | any | Run the configured startup sequence (default `[starting, warmingup, operational]`). |
| `cmd.shutdown` | &mdash; | any | Run the configured shutdown sequence (default `[stopping, coolingdown, idle]`). `operational` triggers a ramp-to-zero first. |
| `cmd.estop` | `emergencystop` | any | Hard cut: runs the `emergencystop` sequence (default `[emergencystop, off]`). Reachable from every state. |
| `set.setpoint` | `execMovement` | `{setpoint: number}` (control %) | Move to a control-% setpoint. |
| `set.flow-setpoint` | `flowMovement` | `{setpoint: number}` (flow, unit per `units`) | Move to a flow setpoint. Converted to canonical m³/s, then to control % via `predictCtrl`. |
| `data.simulate-measurement` | `simulateMeasurement` | `{asset: {type, unit}, value, position, childId?}` | Inject a virtual sensor reading (pressure / flow / power / temperature). |
## 6. Child registration
Plus two query topics for dashboards:
`measurement` children register through `childRegistrationUtils`; the machine subscribes to the matching `<asset.type>.measured.<positionVsParent>` event.
| 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. |
```mermaid
flowchart LR
subgraph kids["accepted children (softwareType)"]
m_pu["measurement<br/>type=pressure<br/>position=upstream"]:::ctrl
m_pd["measurement<br/>type=pressure<br/>position=downstream"]:::ctrl
m_f["measurement<br/>type=flow"]:::ctrl
m_pw["measurement<br/>type=power"]:::ctrl
m_t["measurement<br/>type=temperature"]:::ctrl
end
m_pu -->|pressure.measured.upstream| router[pressureRouter.route]
m_pd -->|pressure.measured.downstream| router
m_f -->|flow.measured.<pos>| mh[measurementHandlers]
m_pw -->|power.measured.atequipment| mh
m_t -->|temperature.measured.<pos>| mh
router --> upd[updatePosition + drift refresh]
mh --> upd
classDef ctrl fill:#a9daee,color:#000
```
---
| softwareType | filter | wired to | side-effect |
|---|---|---|---|
| `measurement` | `type=pressure, position=upstream` | `pressureRouter.route('upstream', ...)` | Sets upstream pressure; refresh prediction + drift. |
| `measurement` | `type=pressure, position=downstream` | `pressureRouter.route('downstream', ...)` | Sets downstream pressure; refresh prediction + drift. |
| `measurement` | `type=flow, position=*` | `measurementHandlers.updateMeasuredFlow` | Stored; drift assessed against predicted. |
| `measurement` | `type=power, position=atEquipment` | `measurementHandlers.updateMeasuredPower` | Stored; drift assessed against predicted. |
| `measurement` | `type=temperature, position=*` | `measurementHandlers.updateMeasuredTemperature` | Stored; used by power correction if relevant. |
## What you'll see come out
Two **virtual children** are auto-registered at startup: `dashboard-sim-upstream` and `dashboard-sim-downstream`. `data.simulate-measurement` payloads land on these. Real pressure children, when registered, are preferred over the virtuals by `pressureSelector`.
Sample Port 0 message (delta-compressed, while operational at ~60 % control):
## 7. Lifecycle — what one event does
```mermaid
sequenceDiagram
participant parent as MGC / pumpingStation
participant rm as rotatingMachine
participant fsm as state FSM
participant pred as predictors
participant out as Port-0 output
parent->>rm: flowmovement (Q)
rm->>rm: flowController.handle('parent', 'flowmovement', Q)
rm->>fsm: setpoint(Q) → maybe transitionToState('accelerating')
Note over fsm: state.emitter 'positionChange' per tick
fsm-->>rm: positionChange → updatePosition()
rm->>pred: calcFlowPower(x) → cFlow, cPower
rm->>rm: calcEfficiency / cog / distance-BEP
rm->>rm: drift refresh on every measured tick
rm->>out: msg{topic, payload} (delta-compressed)
parent->>rm: execsequence ('startup' | 'shutdown')
rm->>fsm: transitionToState('starting' | 'stopping')
fsm-->>rm: stateChange → _updateState()
```
## 8. Data model — `getOutput()`
Composed in `io/output.js → buildOutput(this)`, then delta-compressed.
<!-- BEGIN AUTOGEN: data-model -->
| Key | Type | Unit | Sample |
|---|---|---|---|
| `NCog` | number | — | `0` |
| `NCogPercent` | number | — | `0` |
| `atmPressure.measured.atequipment.wikigen-rotatingmachine-id` | number | — | `101325` |
| `cog` | number | — | `0` |
| `ctrl` | number | — | `0` |
| `effDistFromPeak` | number | — | `0` |
| `effRelDistFromPeak` | number | — | `0` |
| `flow.predicted.max.wikigen-rotatingmachine-id` | number | m3/s | `0` |
| `flow.predicted.min.wikigen-rotatingmachine-id` | number | m3/s | `0` |
| `maintenanceTime` | number | — | `0` |
| `mode` | string | — | `"auto"` |
| `moveTimeleft` | number | — | `0` |
| `predictionConfidence` | number | — | `0` |
| `predictionFlags` | array | — | `[…]` |
| `predictionPressureSource` | null | — | `null` |
| `predictionQuality` | string | — | `"invalid"` |
| `pressureDriftFlags` | array | — | `[…]` |
| `pressureDriftLevel` | number | — | `0` |
| `pressureDriftSource` | null | — | `null` |
| `runtime` | number | — | `0` |
| `state` | string | — | `"idle"` |
| `temperature.measured.atequipment.wikigen-rotatingmachine-id` | number | K | `15` |
<!-- END AUTOGEN: data-model -->
**Concrete sample** (live, from a known-good test run — pump warming up with simulated upstream/downstream pressure):
~~~json
```json
{
"state": "warmingup",
"flow.predicted.downstream.default": 0.00345,
"flow.predicted.atequipment.default": 0.00345,
"power.predicted.atequipment.default": 1820,
"pressure.measured.upstream.dashboard-sim-upstream": 101325,
"pressure.measured.downstream.dashboard-sim-downstream": 145000,
"temperature.measured.atequipment.default": 15,
"atmPressure.measured.atequipment.default": 101325,
"predictionHealth": {
"quality": "warming",
"confidence": 0.35,
"pressureSource": "dashboard-sim",
"flags": ["pressure_init_warming"]
},
"cog": 0.62, "NCog": 0.71,
"absDistFromPeak": 0.04, "relDistFromPeak": 0.12
"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
}
}
~~~
Position labels are normalised to lowercase in MeasurementContainer keys (`atequipment`, `downstream`, `upstream`, `max`, `min`). The trailing `<childId>` segment is the registering child's id (or `default` for own predictions / virtuals tagged via `dashboard-sim-*`).
## 9. Configuration — editor form ↔ config keys
```mermaid
flowchart TB
subgraph editor["Node-RED editor form"]
f1[Asset model dropdown]
f2[Mode current]
f3[Position vs parent]
f4[State times: starting, warmingup, ...]
f5[Movement mode + speed]
f6[Position min / max / initial]
f7[Allowed sources / actions per mode]
f8[Output unit (flow, pressure, power)]
end
subgraph cfg["Domain config slice"]
c1[asset.model]
c2[mode.current]
c3[functionality.positionVsParent]
c4[time.starting / warmingup / stopping / coolingdown]
c5[movement.mode / speed / maxSpeed / interval]
c6[position.min / max / initial]
c7[mode.allowedSources / allowedActions]
c8[general.unit / asset.unit]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
f6 --> c6
f7 --> c7
f8 --> c8
```
| Form field | Config key | Default | Range | Where used |
|---|---|---|---|---|
| Asset model | `asset.model` | `Unknown` | string (must resolve in curve loader) | `_setupCurves` |
| Mode | `mode.current` | `auto` | enum (`auto`, `manual`) | `flowController.handle` source check |
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | enum | child-register payload + event suffix |
| State time — starting | `time.starting` | `10` (s) | ≥ 0 | FSM timing |
| State time — warmingup | `time.warmingup` | `5` (s) | ≥ 0 | FSM timing |
| State time — stopping | `time.stopping` | `5` (s) | ≥ 0 | FSM timing |
| State time — coolingdown | `time.coolingdown` | `10` (s) | ≥ 0 | FSM timing |
| Movement mode | `movement.mode` | `dynspeed` | enum (`staticspeed`, `dynspeed`) | position trajectory |
| Movement speed | `movement.speed` | `1` | ≤ `maxSpeed` | trajectory rate |
| Position min/max | `position.min` / `position.max` | `0` / `100` | numeric | setpoint clamp |
| Output unit (flow) | `general.unit` | `l/s` | unit string | unit policy `output.flow` |
Key shape: **`<type>.<variant>.<position>.<childId>`** &mdash; the inverse of MGC's key shape, because rotatingMachine emits per-measurement snapshots. The trailing `<childId>` is the registering child's id (`dashboard-sim-upstream`, `dashboard-sim-downstream`, or `default` for own predictions). Position labels are normalised to lowercase in keys.
## 10. State chart
| Field | Meaning |
|:---|:---|
| `state` | Current FSM state. See [Architecture &mdash; FSM](Reference-Architecture#fsm). |
| `ctrl` | Control-axis position (`0..100`). |
| `mode` | One of `auto` / `virtualControl` / `fysicalControl`. |
| `runtime` | Accumulated hours in active states (operational and movement variants). |
| `flow.predicted.{downstream,atequipment}.default` | Predicted flow at the current operating point (canonical m³/s; renders to `m3/h`). |
| `power.predicted.atequipment.default` | Predicted shaft power (canonical W; renders to `kW`). |
| `predictionQuality` | `good` / `warming` / `degraded` / `invalid` &mdash; derived by `predictionHealth` from drift + pressure availability. |
| `predictionPressureSource` | `dashboard-sim` (virtual children active) or a real-child id (real children preferred). |
| `predictionFlags` | Reason codes when health < `good` (e.g. `pressure_init_warming`, `flow_high_drift`). |
| `cog` / `NCog` / `NCogPercent` | Centre-of-gravity metric on the η curve. `NCog` is normalised 0..1. |
| `effDistFromPeak` / `effRelDistFromPeak` | Distance from the η peak (absolute and 0..1 relative). |
The FSM is the canonical state set declared in `generalFunctions/src/state/stateConfig.json`. `emergencystop` is reachable from *every* state. Allowed transitions per `stateConfig.allowedTransitions`.
---
```mermaid
stateDiagram-v2
[*] --> idle
idle --> starting: execsequence(startup)
idle --> off: off
idle --> maintenance: maintenance
starting --> warmingup: timer
warmingup --> operational: timer
operational --> accelerating: flowmovement / setpoint up
operational --> decelerating: flowmovement / setpoint down
accelerating --> operational: target reached
decelerating --> operational: target reached
operational --> stopping: execsequence(shutdown)
stopping --> coolingdown: timer
stopping --> idle: timer
coolingdown --> idle: timer
coolingdown --> off: off
off --> idle: execsequence(startup)
off --> maintenance: maintenance
maintenance --> idle: maintenance done
maintenance --> off: off
## The new bit &mdash; sequence-abort token
note right of operational
any state -> emergencystop
via cmd.estop
end note
```
When a parent MGC sends a new demand, it calls `abortMovement` to interrupt any in-flight `accelerating` / `decelerating` movement. Before 2026-05-15 that abort only stopped the moveTo &mdash; an in-flight `executeSequence('shutdown')` for-loop would keep transitioning the FSM through `stopping &rarr; coolingdown &rarr; idle`, fighting the new dispatch's residue-handler.
`accelerating` / `decelerating` are abortable on new demand via `abortMovement(reason)`; the controller does **not** auto-transition back to `operational` after an abort (see `state.js` comment "Abort path"). `warmingup` and `coolingdown` are **protected** — abort signals are dropped for safety. `activeStates = { operational, starting, warmingup, accelerating, decelerating }` is the set MGC treats as "machine alive".
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.
## 11. Examples
Net effect: a mid-decel re-engage takes the pump cleanly back to operational, without the orphaned shutdown completing in the background. `warmingup` and `coolingdown` remain protected at the stateManager layer &mdash; safety guarantees are unchanged.
| Tier | File | What it shows | Status |
|---|---|---|---|
| Basic | `examples/01 - Basic Manual Control.json` | Inject + dashboard, simulated pressure, manual startup/shutdown | ✅ validated |
| Integration | `examples/02 - Integration with Machine Group.json` | rotatingMachine wired under MGC | ⏳ pending validation |
| Dashboard | `examples/03 - Dashboard Visualization.json` | FlowFuse charts: flow / power / pressure trends | ✅ in repo |
| Legacy | `examples/basic.flow.json` / `integration.flow.json` / `edge.flow.json` | Pre-refactor flows | ⚠️ kept until new Tier 2 is validated |
See [Architecture &mdash; FSM](Reference-Architecture#fsm) for the full mechanism.
Screenshots will land under `wiki/_partial-screenshots/rotatingMachine/` once captured from the live demo.
---
## 12. Debug recipes
## Need more?
| Symptom | First thing to check | Where to look |
|---|---|---|
| `state` stuck on `idle`, no startup | Source not in `mode.allowedSources[currentMode]`. Check `flowController` warn log. | `_setupState` + `isValidSourceForMode`. |
| `flow.predicted.*` is 0 or `NaN` | Pressure not initialised — `predictionHealth.flags` will say `pressure_init_warming`. Inject pressure via `data.simulate-measurement` or wire real measurement children. | `getMeasuredPressure` + `pressureSelector`. |
| `predictionHealth.quality='invalid'` | Curve normalisation failed at startup — null predictors installed. Check container log for `Curve normalization failed for model …`. | `_setupCurves`. |
| Drift `level=3` after startup | Less than 10 paired samples (`minSamplesForLongTerm`) — wait a few ticks before judging. | `driftProfiles.minSamplesForLongTerm`. |
| `cmd.estop` doesn't recover | After `emergencystop`, only `idle` / `off` / `maintenance` are allowed. Send `cmd.shutdown` then `cmd.startup`, or reset via maintenance. | `stateConfig.allowedTransitions.emergencystop`. |
| Position bounces around target | Movement mode `dynspeed` ease-in/out may overshoot at high speed; try `staticspeed`. | `movement.mode`. |
| Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Full topic contract, config schema, child registration filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, FSM, prediction pipeline, drift, lifecycle |
| [Reference &mdash; Examples](Reference-Examples) | Shipped example flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | When not to use, known limitations, open questions |
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors.
## 13. When you would NOT use this node
- Use rotatingMachine for a **single** pump / compressor / blower. For groups of 2+ with load sharing, wire `machineGroupControl` as the parent.
- Don't use rotatingMachine to model a **passive non-return valve** — use `valve` (no curve, no FSM-driven motor).
- Don't use rotatingMachine without a **curve model** — flow / power predictions degrade to zero and drift is meaningless.
## 14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | Drift confidence drops to 0 when pressure source is missing > 30 s — health flips to `invalid` silently. | `pressure/pressureInitialization.js`. |
| 2 | Multi-parent registration accepted by `childRegistrationUtils` but ordering of teardown is not test-covered. | Open question — `OPEN_QUESTIONS.md`. |
| 3 | `data.simulate-measurement` does not unset previous values on missing keys — stale sim data can persist after toggling off. | `measurementHandlers.updateSimulatedMeasurement`. |
| 4 | `execSequence` legacy umbrella topic kept alive in registry; planned removal in Phase 7. | `commands/index.js` `_legacy: true`. |
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)

View File

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

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

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

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

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

View File

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

19
wiki/_Sidebar.md Normal file
View File

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