Compare commits

9 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
25 changed files with 1935 additions and 460 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: "" },
@@ -69,12 +69,14 @@
},
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 {
@@ -83,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;
@@ -114,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>
@@ -128,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>
@@ -196,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,6 +11,10 @@ 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);

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

@@ -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);
}
@@ -72,7 +88,7 @@ class Machine extends BaseDomain {
// 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('machine', this.model)
? assetResolver.resolveAssetMetadata('rotatingmachine', this.model)
: null;
if (!this.model) {
@@ -81,7 +97,7 @@ class Machine extends BaseDomain {
return;
}
if (!this.assetMetadata) {
this.logger.error(`rotatingMachine: model '${this.model}' not found in asset registry (datasets/assetData/machine.json). Cannot derive supplier/type/units.`);
this.logger.error(`rotatingMachine: model '${this.model}' not found in asset registry (datasets/assetData/rotatingmachine.json). Cannot derive supplier/type/units.`);
this._installNullPredictors();
return;
}
@@ -213,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 = {}) {
@@ -231,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;
@@ -291,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() {
@@ -302,6 +324,7 @@ class Machine extends BaseDomain {
this.calcDistanceBEP(efficiency, cog, minEfficiency);
}
this._updatePredictionHealth();
this.notifyOutputChanged();
}
// ── mode + input dispatch ──────────────────────────────────────────
@@ -371,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;
@@ -381,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

@@ -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

@@ -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

@@ -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

@@ -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 `1a9f533` · regenerated `2026-05-11` via `npm run wiki:all`**
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
![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,331 +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 | Unit | Effect |
|---|---|---|---|---|
| `set.mode` | `setMode` | `string` | — | Switch the machine between auto / manual control modes. |
| `cmd.startup` | _(none)_ | `any` | — | Initiate the machine startup sequence. |
| `cmd.shutdown` | _(none)_ | `any` | — | Initiate the machine shutdown sequence. |
| `cmd.estop` | `emergencystop` | `any` | — | Trigger an emergency stop. |
| `execSequence` | _(none)_ | `object` | — | Legacy umbrella that demuxes payload.action to startup / shutdown. |
| `set.setpoint` | `execMovement` | `object` | — | Move the machine to a control-% setpoint via execMovement. |
| `set.flow-setpoint` | `flowMovement` | `object` | `volumeFlowRate` (default `m3/h`) | Move the machine to a flow setpoint via flowMovement. |
| `data.simulate-measurement` | `simulateMeasurement` | `object` | — | Inject a simulated sensor reading (pressure/flow/temperature/power). |
| `query.curves` | `showWorkingCurves` | `any` | — | Return the working curves for the machine on the reply port. |
| `query.cog` | `CoG` | `any` | — | Return the centre-of-gravity (CoG) point on the reply port. |
| `child.register` | `registerChild` | `string` | — | Register a child measurement with this machine. |
## 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",
"ctrl": 42.5,
"mode": "auto",
"runtime": 0.0014,
"flow.predicted.downstream.default": 12.4,
"flow.predicted.atequipment.default": 12.4,
"flow.predicted.max.dashboard-sim-upstream": 22.1,
"flow.predicted.min.dashboard-sim-upstream": 0,
"power.predicted.atequipment.default": 18.2,
"pressure.measured.upstream.dashboard-sim-upstream": 101325,
"pressure.measured.downstream.dashboard-sim-downstream": 145000,
"temperature.measured.atequipment.dashboard-sim-upstream": 15,
"atmPressure.measured.atequipment.dashboard-sim-upstream": 101325,
"predictionQuality": "warming",
"predictionConfidence": 0.35,
"predictionPressureSource": "dashboard-sim",
"predictionFlags": ["pressure_init_warming"],
"pressureDriftLevel": 0,
"pressureDriftSource": null,
"pressureDriftFlags": ["nominal"],
"cog": 0.62, "NCog": 0.71, "NCogPercent": 62,
"effDistFromPeak": 0.04, "effRelDistFromPeak": 0.12,
"moveTimeleft": 0, "maintenanceTime": 0
"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 — supplier / category / model / unit]
f2[Position vs parent]
f3[State times: startup / warmup / shutdown / cooldown]
f4[Movement mode + reaction speed]
f5[Process output format]
f6[Database output format]
f7[Logger — level / enabled]
end
subgraph cfg["Domain config slice"]
c1[asset.model / asset.unit / asset.supplier / asset.category]
c2[functionality.positionVsParent]
c3[time.starting / warmingup / stopping / coolingdown]
c4[movement.mode / movement.speed]
c5[output.process]
c6[output.dbase]
c7[general.logging]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
f6 --> c6
f7 --> c7
```
| Form field | Config key | Default | Range | Where used |
|---|---|---|---|---|
| Asset model | `asset.model` | `Unknown` | string (must resolve in curve loader) | `_setupCurves` |
| Asset flow unit | `asset.unit` | `m3/h` | unit string | unit policy `output.flow` |
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | enum (`upstream`, `atEquipment`, `downstream`) | child-register payload + event suffix |
| State time — starting | `time.starting` | `10` (s) | ≥ 0 | FSM timing |
| State time — warmingup | `time.warmingup` | `5` (s) | ≥ 0 | FSM timing |
| State time — stopping | `time.stopping` | `5` (s) | ≥ 0 | FSM timing |
| State time — coolingdown | `time.coolingdown` | `10` (s) | ≥ 0 | FSM timing |
| Movement mode | `movement.mode` | `staticspeed` | enum (`staticspeed`, `dynspeed`) | position trajectory |
| Reaction speed | `movement.speed` | `1` | ≤ `maxSpeed` | trajectory ramp rate (%/s) |
| Process output format | `output.process` | `process` | enum (`process`, `json`, `csv`) | Port 0 formatter |
| Database output format | `output.dbase` | `influxdb` | enum (`influxdb`, `json`, `csv`) | Port 1 formatter |
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)