2025-10-14 13:52:39 +02:00
<!--
| S88-niveau | Primair (blokkleur) | Tekstkleur |
| ---------------------- | ------------------- | ---------- |
| **Area** | `#0f52a5` | wit |
| **Process Cell** | `#0c99d9` | wit |
| **Unit** | `#50a8d9` | zwart |
| **Equipment (Module)** | `#86bbdd` | zwart |
| **Control Module** | `#a9daee` | zwart |
-->
2025-06-25 17:26:13 +02:00
<!-- Load the dynamic menu & config endpoints -->
< script src = "/rotatingMachine/menu.js" > < / script >
< script src = "/rotatingMachine/configData.js" > < / script >
< script >
RED.nodes.registerType("rotatingMachine", {
category: "EVOLV",
2026-05-21 15:05:52 +02:00
color: "#E89B3A",
2025-06-25 17:26:13 +02:00
defaults: {
2026-04-22 17:50:50 +02:00
name: { value: "" },
2025-06-25 17:26:13 +02:00
// Define specific properties
speed: { value: 1, required: true },
startup: { value: 0 },
warmup: { value: 0 },
shutdown: { value: 0 },
2025-07-02 10:53:03 +02:00
cooldown: { value: 0 },
2025-11-20 11:09:44 +01:00
movementMode : { value: "staticspeed" }, // static or dynamic
2025-06-25 17:26:13 +02:00
machineCurve : { value: {}},
2026-03-12 16:39:25 +01:00
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
2025-06-25 17:26:13 +02:00
2026-05-12 17:12:33 +02:00
// Asset identifier surface. supplier/category/assetType are
// derived at runtime via assetResolver.resolveAssetMetadata(model);
// do NOT add them back here. See src/registry/README.md.
2025-06-25 17:26:13 +02:00
uuid: { value: "" },
2026-03-11 11:13:26 +01:00
assetTagNumber: { value: "" },
2025-06-25 17:26:13 +02:00
model: { value: "" },
unit: { value: "" },
2026-03-11 11:13:26 +01:00
curvePressureUnit: { value: "mbar" },
curveFlowUnit: { value: "" },
curvePowerUnit: { value: "kW" },
curveControlUnit: { value: "%" },
2025-06-25 17:26:13 +02:00
//logger properties
enableLog: { value: false },
logLevel: { value: "error" },
//physicalAspect
positionVsParent: { value: "" },
2025-07-01 15:25:07 +02:00
positionIcon: { value: "" },
2025-09-05 16:20:27 +02:00
hasDistance: { value: false },
distance: { value: 0 },
distanceUnit: { value: "m" },
distanceDescription: { value: "" }
2025-06-02 16:56:36 +02:00
2025-05-14 10:07:27 +02:00
},
2025-06-25 17:26:13 +02:00
inputs: 1,
outputs: 3,
inputLabels: ["Input"],
outputLabels: ["process", "dbase", "parent"],
2025-10-14 13:52:39 +02:00
icon: "font-awesome/fa-cog",
2025-06-25 17:26:13 +02:00
2025-07-01 15:25:07 +02:00
label: function () {
2026-05-12 17:12:33 +02:00
// No more `this.category` on the node — fall back to model id, then a
// generic name. supplier/category/type live in the registry now.
const stem = this.model ? this.model : "Machine";
return (this.positionIcon || "") + " " + stem;
2025-06-25 17:26:13 +02:00
},
2025-05-14 10:07:27 +02:00
2025-06-25 17:26:13 +02:00
oneditprepare: function() {
2026-05-18 21:31:01 +02:00
const node = this;
// wait for the menu scripts to load (asset/logger/position injected via menu.js)
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
let menuRetries = 0;
const maxMenuRetries = 100; // 5 seconds at 50ms intervals
2025-06-25 17:26:13 +02:00
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.rotatingMachine?.initEditor) {
2026-05-18 21:31:01 +02:00
window.EVOLV.nodes.rotatingMachine.initEditor(node);
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
} else if (++menuRetries < maxMenuRetries ) {
2025-06-25 17:26:13 +02:00
setTimeout(waitForMenuData, 50);
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
} else {
console.warn("rotatingMachine: menu scripts failed to load within 5 seconds");
2025-05-14 10:07:27 +02:00
}
2025-06-25 17:26:13 +02:00
};
waitForMenuData();
2025-05-14 10:07:27 +02:00
2026-05-18 21:31:01 +02:00
// -----------------------------------------------------------
// 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";
2025-11-20 11:09:44 +01:00
}
2025-05-14 10:07:27 +02:00
2026-05-18 21:31:01 +02:00
// 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);
2025-06-25 17:26:13 +02:00
},
oneditsave: function() {
const node = this;
2025-10-02 17:09:24 +02:00
2025-06-25 17:26:13 +02:00
// save asset fields
if (window.EVOLV?.nodes?.rotatingMachine?.assetMenu?.saveEditor) {
window.EVOLV.nodes.rotatingMachine.assetMenu.saveEditor(this);
2025-05-14 10:07:27 +02:00
}
2025-06-25 17:26:13 +02:00
// save logger fields
if (window.EVOLV?.nodes?.rotatingMachine?.loggerMenu?.saveEditor) {
window.EVOLV.nodes.rotatingMachine.loggerMenu.saveEditor(this);
2025-05-14 10:07:27 +02:00
}
2025-06-25 17:26:13 +02:00
// save position field
if (window.EVOLV?.nodes?.rotatingMachine?.positionMenu?.saveEditor) {
window.EVOLV.nodes.rotatingMachine.positionMenu.saveEditor(this);
2025-05-14 10:07:27 +02:00
}
2025-07-02 10:53:03 +02:00
["speed", "startup", "warmup", "shutdown", "cooldown"].forEach((field) => {
const element = document.getElementById(`node-input-${field}`);
const value = parseFloat(element?.value) || 0;
node[field] = value;
});
2025-05-14 10:07:27 +02:00
2026-05-18 21:31:01 +02:00
const modeEl = document.getElementById("node-input-movementMode");
node.movementMode = (modeEl & & modeEl.value) ? modeEl.value : "staticspeed";
2025-06-25 17:26:13 +02:00
}
2025-05-14 10:07:27 +02:00
});
2025-06-25 17:26:13 +02:00
< / script >
2025-05-14 10:07:27 +02:00
2025-06-25 17:26:13 +02:00
<!-- Main UI Template -->
< script type = "text/html" data-template-name = "rotatingMachine" >
2026-05-18 21:31:01 +02:00
<!-- ============================================================ -->
<!-- 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"
2026-05-19 22:31:22 +02:00
style="display:block;width:100%;"
2026-05-18 21:31:01 +02:00
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" > m³/h (configurable)< / text >
< text x = "484" y = "130" fill = "#C0392B" font-size = "11" font-weight = "bold" > ↑ Δp head< / text >
< text x = "484" y = "146" fill = "#C0392B" font-size = "10" font-style = "italic" > predicted from curve< / text >
<!-- Hint footer -->
< text x = "300" y = "194" text-anchor = "middle" fill = "#777" font-size = "10" font-style = "italic" >
Flow direction → Pressure rises across the impeller Performance follows the Q-H / Q-P curves of the selected asset
< / text >
< / svg >
2025-06-25 17:26:13 +02:00
< / div >
2026-05-18 21:31:01 +02:00
<!-- ============================================================ -->
<!-- SEQUENCE & REACTION TIMING -->
<!-- Side - panel inputs hover - coupled to a timeline of FSM phases. -->
<!-- Bar widths grow with the entered seconds. Protected phases -->
<!-- (warmingup / coolingdown) carry a 🛡 marker. The reaction - -->
<!-- speed value tilts the slope inside the operational bar. -->
<!-- ============================================================ -->
< h4 > Sequence & reaction timing< / h4 >
< p style = "font-size:12px;color:#777;margin:0 0 6px 0;" > Each timing input on the left sizes its phase on the timeline. < b > 🛡 protected< / b > phases (warm-up & cool-down) cannot be aborted by a new command. Hover an input row to highlight the phase it controls.< / p >
< style >
.rm-diag { display:flex; gap:20px; align-items:flex-start; margin: 0 0 14px 0; }
.rm-diag-side { width: 230px; flex: 0 0 230px; display:flex; flex-direction:column; gap:6px; }
/* SVG height is set at runtime by syncSvgHeight() in oneditprepare to
match the side-panel's computed height exactly. Width follows the
viewBox aspect ratio. The hard-coded fallback height covers the brief
window before the first sync runs. */
.rm-diag-svg { height:195px; width:auto; max-width:100%; display:block; }
.rm-diag-side .rm-row {
display:grid; grid-template-columns: minmax(0,1fr) 70px 18px; align-items:center;
gap:6px; padding:4px 6px 4px 10px; border-left:4px solid #ccc;
background:#fafafa; border-radius:3px; font-size:11px; cursor:pointer; min-width:0;
}
.rm-diag-side .rm-row:hover { background:#f0f0f0; }
.rm-diag-side .rm-row label { font-weight:600; margin:0; line-height:1.2; }
.rm-diag-side .rm-row .rm-sub { grid-column:1; font-size:10px; color:#888; font-weight:400; }
.rm-diag-side .rm-row input[type=number] {
width:100%; height:22px; box-sizing:border-box; font-size:11px;
padding:1px 4px; margin:0; border:1px solid #ccc; border-radius:3px; background:#fff;
}
.rm-diag-side .rm-row input[type=number]:focus { outline:1px solid #0c99d9; border-color:#0c99d9; }
.rm-diag-side .rm-row .rm-unit { color:#888; font-size:10px; text-align:right; }
/* Border colours matched to arc fills. */
.rm-row[data-stroke="#0c99d9"] { border-left-color:#0c99d9; }
.rm-row[data-stroke="#f39c12"] { border-left-color:#f39c12; }
.rm-row[data-stroke="#e67e22"] { border-left-color:#e67e22; }
.rm-row[data-stroke="#0c99d9"] label { color:#0c99d9; }
.rm-row[data-stroke="#f39c12"] label { color:#b9770e; }
.rm-row[data-stroke="#e67e22"] label { color:#af601a; }
/* Highlight class applied to a state's arc path on input-row hover. */
.rm-arc-highlight { stroke:#1F4E79 !important; stroke-width:3 !important; filter:brightness(1.08); }
/* Movement-mode cards — same compact 94× 86 sizing as machineGroupControl. */
.rm-mode-cards { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 4px 0; }
.rm-mode-card {
width:94px; height:86px; box-sizing:border-box;
border:2px solid #d0d0d0; border-radius:4px; background:#fafafa;
padding:4px; cursor:pointer; user-select:none;
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px;
transition:border-color 80ms ease-out, background 80ms ease-out;
}
.rm-mode-card:hover { border-color:#86bbdd; background:#f5fafd; }
.rm-mode-card:focus { outline:2px solid #1F4E79; outline-offset:2px; }
.rm-mode-card-on { border-color:#50a8d9; background:#eaf4fb; }
.rm-mode-card-svg { width:100%; height:54px; display:flex; align-items:center; justify-content:center; }
.rm-mode-card-svg svg { width:100%; height:100%; display:block; }
.rm-mode-card-label { font-size:10px; line-height:1; font-weight:600; color:#333; white-space:nowrap; letter-spacing:0; }
.rm-mode-card:not(.rm-mode-card-on) .rm-mode-card-label { color:#888; }
/* Output-format rows mirror the mgc layout: nowrap label, native select
hidden, icon picker rendered alongside by iconHelpers. */
.rm-output-row > label { white-space:nowrap; width:130px; }
< / style >
< div class = "rm-diag" >
<!-- LEFT: stacked colour - coded inputs. Hover a row → matching SVG bar highlights. -->
< div class = "rm-diag-side" >
< div class = "rm-row" data-stroke = "#0c99d9" data-couples = "rm-tl-operational" >
< div > < label > Reaction speed< / label > < div class = "rm-sub" > controller ramp rate (slope inside operational)< / div > < / div >
< input type = "number" id = "node-input-speed" min = "0.1" step = "0.1" / >
< span class = "rm-unit" > %/s< / span >
< / div >
< div class = "rm-row" data-stroke = "#f39c12" data-couples = "rm-tl-starting" >
< div > < label > Startup time< / label > < div class = "rm-sub" > idle → starting → warmingup< / div > < / div >
< input type = "number" id = "node-input-startup" min = "0" step = "1" / >
< span class = "rm-unit" > s< / span >
< / div >
< div class = "rm-row" data-stroke = "#e67e22" data-couples = "rm-tl-warmingup" >
< div > < label > Warm-up time 🛡︎< / label > < div class = "rm-sub" > protected — cannot be aborted< / div > < / div >
< input type = "number" id = "node-input-warmup" min = "0" step = "1" / >
< span class = "rm-unit" > s< / span >
< / div >
< div class = "rm-row" data-stroke = "#f39c12" data-couples = "rm-tl-stopping" >
< div > < label > Shutdown time< / label > < div class = "rm-sub" > operational → stopping → coolingdown< / div > < / div >
< input type = "number" id = "node-input-shutdown" min = "0" step = "1" / >
< span class = "rm-unit" > s< / span >
< / div >
< div class = "rm-row" data-stroke = "#e67e22" data-couples = "rm-tl-coolingdown" >
< div > < label > Cool-down time 🛡︎< / label > < div class = "rm-sub" > protected — cannot be aborted< / div > < / div >
< input type = "number" id = "node-input-cooldown" min = "0" step = "1" / >
< span class = "rm-unit" > s< / span >
< / div >
< / div >
<!-- RIGHT: circular state - machine donut. All arc `d` and label x/y
values are written by redrawTimeline(). Each state is a wedge of
the ring; arc angle is proportional to its seconds.
Idle sits at the top (small fixed slice, the loop-around);
operational sits at the bottom (fixed dominant arc). -->
< svg id = "rm-timeline" class = "rm-diag-svg" xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 340 260"
style="background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
preserveAspectRatio="xMidYMid meet"
font-family="Arial,sans-serif" font-size="11">
<!-- Title -->
< text x = "170" y = "14" text-anchor = "middle" fill = "#1F4E79" font-size = "11" font-weight = "bold" > State machine — sequence loop< / text >
<!-- State arc wedges. Order in DOM = clockwise from top.
`d` attribute populated by redrawTimeline(). -->
< path id = "rm-tl-idle" fill = "#bdc3c7" stroke = "#7f8c8d" stroke-width = "1" / >
< path id = "rm-tl-starting" fill = "#f39c12" stroke = "#b9770e" stroke-width = "1" / >
< path id = "rm-tl-warmingup" fill = "#e67e22" stroke = "#af601a" stroke-width = "1" / >
< path id = "rm-tl-operational" fill = "#2ecc71" stroke = "#239b56" stroke-width = "1" / >
< path id = "rm-tl-stopping" fill = "#f39c12" stroke = "#b9770e" stroke-width = "1" / >
< path id = "rm-tl-coolingdown" fill = "#e67e22" stroke = "#af601a" stroke-width = "1" / >
<!-- State - name labels OUTSIDE the ring. x/y/text - anchor/dy set in JS. -->
< text id = "rm-tl-lbl-idle" fill = "#555" font-size = "11" font-weight = "bold" > < / text >
< text id = "rm-tl-lbl-starting" fill = "#b9770e" font-size = "11" font-weight = "bold" > < / text >
< text id = "rm-tl-lbl-warmingup" fill = "#af601a" font-size = "11" font-weight = "bold" > < / text >
< text id = "rm-tl-lbl-operational" fill = "#239b56" font-size = "11" font-weight = "bold" > < / text >
< text id = "rm-tl-lbl-stopping" fill = "#b9770e" font-size = "11" font-weight = "bold" > < / text >
< text id = "rm-tl-lbl-coolingdown" fill = "#af601a" font-size = "11" font-weight = "bold" > < / text >
<!-- Duration values INSIDE each arc. x/y set in JS. -->
< text id = "rm-tl-time-idle" fill = "#fff" font-size = "10" font-weight = "bold" > < / text >
< text id = "rm-tl-time-starting" fill = "#fff" font-size = "10" font-weight = "bold" > < / text >
< text id = "rm-tl-time-warmingup" fill = "#fff" font-size = "10" font-weight = "bold" > < / text >
< text id = "rm-tl-time-operational" fill = "#fff" font-size = "10" font-weight = "bold" > < / text >
< text id = "rm-tl-time-stopping" fill = "#fff" font-size = "10" font-weight = "bold" > < / text >
< text id = "rm-tl-time-coolingdown" fill = "#fff" font-size = "10" font-weight = "bold" > < / text >
<!-- Centre: reaction - speed value (no slope line — donut hole stays clean). -->
< text x = "170" y = "125" text-anchor = "middle" fill = "#1F4E79" font-size = "10" font-weight = "bold" > Reaction speed< / text >
< text id = "rm-tl-ramp-value" x = "170" y = "146" text-anchor = "middle" fill = "#0c99d9" font-size = "16" font-weight = "bold" > 1 %/s< / text >
< / svg >
2025-06-25 17:26:13 +02:00
< / div >
2026-05-18 21:31:01 +02:00
<!-- ============================================================ -->
<!-- MOVEMENT MODE — visual cards (was a <select>) -->
<!-- Hidden #node - input - movementMode keeps the save path working. -->
<!-- ============================================================ -->
< h4 > Movement mode< / h4 >
< p style = "font-size:12px;color:#777;margin:0 0 6px 0;" > How the controller travels between setpoints during < code > accelerating< / code > / < code > decelerating< / code > .< / p >
< div class = "rm-mode-cards" role = "radiogroup" aria-label = "Movement mode" >
< div class = "rm-mode-card" data-value = "staticspeed" tabindex = "0" role = "radio" aria-checked = "false" aria-label = "Static — constant ramp rate" title = "Static — constant ramp rate" >
< div class = "rm-mode-card-svg" >
< svg viewBox = "0 0 80 58" xmlns = "http://www.w3.org/2000/svg" aria-hidden = "true" >
< line x1 = "12" y1 = "48" x2 = "70" y2 = "48" stroke = "#888" stroke-width = "1.4" stroke-linecap = "round" / >
< line x1 = "12" y1 = "48" x2 = "12" y2 = "8" stroke = "#888" stroke-width = "1.4" stroke-linecap = "round" / >
< line x1 = "14" y1 = "46" x2 = "68" y2 = "12" stroke = "#1F4E79" stroke-width = "3" stroke-linecap = "round" / >
< circle cx = "14" cy = "46" r = "2.6" fill = "#1F4E79" / >
< circle cx = "68" cy = "12" r = "2.6" fill = "#1F4E79" / >
< / svg >
< / div >
< div class = "rm-mode-card-label" > Static< / div >
< / div >
< div class = "rm-mode-card" data-value = "dynspeed" tabindex = "0" role = "radio" aria-checked = "false" aria-label = "Dynamic — ease in/out" title = "Dynamic — ease in/out" >
< div class = "rm-mode-card-svg" >
< svg viewBox = "0 0 80 58" xmlns = "http://www.w3.org/2000/svg" aria-hidden = "true" >
< line x1 = "12" y1 = "48" x2 = "70" y2 = "48" stroke = "#888" stroke-width = "1.4" stroke-linecap = "round" / >
< line x1 = "12" y1 = "48" x2 = "12" y2 = "8" stroke = "#888" stroke-width = "1.4" stroke-linecap = "round" / >
<!-- More pronounced sigmoid: control points pull the mid - section nearly flat
(y≈29 mid) so the S-shape reads clearly at thumbnail size. -->
< path d = "M 14 46 C 22 46, 26 30, 41 29 C 56 28, 60 12, 68 12" fill = "none" stroke = "#1F4E79" stroke-width = "3" stroke-linecap = "round" / >
< circle cx = "14" cy = "46" r = "2.6" fill = "#1F4E79" / >
< circle cx = "68" cy = "12" r = "2.6" fill = "#1F4E79" / >
< / svg >
< / div >
< div class = "rm-mode-card-label" > Dynamic< / div >
< / div >
2025-11-20 11:09:44 +01:00
< / div >
2026-05-18 21:31:01 +02:00
<!-- Hidden field — kept for the save path, written by the cards above. -->
< input type = "hidden" id = "node-input-movementMode" / >
2025-05-14 10:07:27 +02:00
2026-05-18 21:31:01 +02:00
<!-- ============================================================ -->
<!-- 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. -->
<!-- ============================================================ -->
2026-03-12 16:39:25 +01:00
< h3 > Output Formats< / h3 >
2026-05-18 21:31:01 +02:00
< div class = "form-row rm-output-row" >
2026-03-12 16:39:25 +01:00
< label for = "node-input-processOutputFormat" > < i class = "fa fa-random" > < / i > Process Output< / label >
2026-05-18 21:31:01 +02:00
< select id = "node-input-processOutputFormat" class = "evolv-native-hidden" style = "width:60%;" >
2026-03-12 16:39:25 +01:00
< option value = "process" > process< / option >
< option value = "json" > json< / option >
< option value = "csv" > csv< / option >
< / select >
2026-05-18 21:31:01 +02:00
< div id = "rm-process-output-picker" class = "evolv-icon-picker"
role="radiogroup" aria-label="Process output format">< / div >
2026-03-12 16:39:25 +01:00
< / div >
2026-05-18 21:31:01 +02:00
< div class = "form-row rm-output-row" >
2026-03-12 16:39:25 +01:00
< label for = "node-input-dbaseOutputFormat" > < i class = "fa fa-database" > < / i > Database Output< / label >
2026-05-18 21:31:01 +02:00
< select id = "node-input-dbaseOutputFormat" class = "evolv-native-hidden" style = "width:60%;" >
2026-03-12 16:39:25 +01:00
< option value = "influxdb" > influxdb< / option >
2026-05-21 15:05:52 +02:00
< option value = "frost" > frost< / option >
2026-03-12 16:39:25 +01:00
< option value = "json" > json< / option >
< option value = "csv" > csv< / option >
< / select >
2026-05-18 21:31:01 +02:00
< div id = "rm-dbase-output-picker" class = "evolv-icon-picker"
role="radiogroup" aria-label="Database output format">< / div >
2026-03-12 16:39:25 +01:00
< / div >
2026-05-18 21:31:01 +02:00
<!-- Asset / Logger / Position menus injected by menu.js -->
2025-06-25 17:26:13 +02:00
< div id = "asset-fields-placeholder" > < / div >
< div id = "logger-fields-placeholder" > < / div >
< div id = "position-fields-placeholder" > < / div >
2025-05-14 10:07:27 +02:00
2025-06-25 17:26:13 +02:00
< / script >
2025-05-14 10:07:27 +02:00
2025-06-25 17:26:13 +02:00
< script type = "text/html" data-help-name = "rotatingMachine" >
fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
orchestrators that use 'emergencyStop' (capital S) route correctly to
the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
in accelerating/decelerating, the active movement is aborted via
state.abortCurrentMovement() and the sequence waits (up to 2s) for the
FSM to return to 'operational' before proceeding. New helper
_waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
actionable.
Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
shutdown during accelerating -> idle; emergencystop during accelerating
-> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
parametrized across both shipped pump curves (hidrostal-H05K-S03R and
hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
finiteness, and reverse-predictor round-trip.
E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
benchmark that deploys one rotatingMachine per curve and runs a per-pump
(pressure x ctrl) sweep inside each curve's envelope. Reports envelope
compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
pressure below the curve's minimum slice extrapolates wildly
(defended by upstream measurement-node clamping in production).
UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
help panel with a topic reference, port documentation, state diagram,
and prediction rules.
Docs:
- README.md rewritten (was a single line) with install, quick start,
topic/port reference, state machine, predictions, testing, production
status.
Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
< p > < b > Rotating Machine< / b > : individual pump / compressor / blower control module. Runs a 10-state S88 sequence, predicts flow and power from a supplier curve, and publishes process + telemetry outputs each second.< / p >
< h3 > Configuration< / h3 >
< ul >
2026-05-18 21:31:01 +02:00
< li > < b > Reaction speed< / b > : controller ramp rate (position units / second). E.g. < code > 1< / code > = 1%/s, so a setpoint of 60% from idle reaches 60% in ~60 s. Visualised as the slope inside the < i > operational< / i > bar.< / li >
< li > < b > Startup / Warm-up / Shutdown / Cool-down< / b > : seconds per FSM phase. Warm-up & cool-down are < b > protected< / b > — they cannot be aborted by a new command (shown with 🛡 in the timeline).< / li >
< li > < b > Movement mode< / b > : < code > staticspeed< / code > = linear ramp; < code > dynspeed< / code > = ease-in/out. Pick a card.< / li >
fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
orchestrators that use 'emergencyStop' (capital S) route correctly to
the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
in accelerating/decelerating, the active movement is aborted via
state.abortCurrentMovement() and the sequence waits (up to 2s) for the
FSM to return to 'operational' before proceeding. New helper
_waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
actionable.
Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
shutdown during accelerating -> idle; emergencystop during accelerating
-> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
parametrized across both shipped pump curves (hidrostal-H05K-S03R and
hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
finiteness, and reverse-predictor round-trip.
E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
benchmark that deploys one rotatingMachine per curve and runs a per-pump
(pressure x ctrl) sweep inside each curve's envelope. Reports envelope
compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
pressure below the curve's minimum slice extrapolates wildly
(defended by upstream measurement-node clamping in production).
UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
help panel with a topic reference, port documentation, state diagram,
and prediction rules.
Docs:
- README.md rewritten (was a single line) with install, quick start,
topic/port reference, state machine, predictions, testing, production
status.
Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
< 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 >
2026-05-18 21:31:01 +02:00
< 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 >
fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
orchestrators that use 'emergencyStop' (capital S) route correctly to
the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
in accelerating/decelerating, the active movement is aborted via
state.abortCurrentMovement() and the sequence waits (up to 2s) for the
FSM to return to 'operational' before proceeding. New helper
_waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
actionable.
Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
shutdown during accelerating -> idle; emergencystop during accelerating
-> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
parametrized across both shipped pump curves (hidrostal-H05K-S03R and
hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
finiteness, and reverse-predictor round-trip.
E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
benchmark that deploys one rotatingMachine per curve and runs a per-pump
(pressure x ctrl) sweep inside each curve's envelope. Reports envelope
compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
pressure below the curve's minimum slice extrapolates wildly
(defended by upstream measurement-node clamping in production).
UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
help panel with a topic reference, port documentation, state diagram,
and prediction rules.
Docs:
- README.md rewritten (was a single line) with install, quick start,
topic/port reference, state machine, predictions, testing, production
status.
Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
< li > < b > Position< / b > (menu): < code > upstream< / code > / < code > atEquipment< / code > / < code > downstream< / code > relative to a parent group/station.< / li >
< / ul >
< h3 > Input topics (< code > msg.topic< / code > )< / h3 >
2025-06-25 17:26:13 +02:00
< ul >
fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
orchestrators that use 'emergencyStop' (capital S) route correctly to
the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
in accelerating/decelerating, the active movement is aborted via
state.abortCurrentMovement() and the sequence waits (up to 2s) for the
FSM to return to 'operational' before proceeding. New helper
_waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
actionable.
Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
shutdown during accelerating -> idle; emergencystop during accelerating
-> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
parametrized across both shipped pump curves (hidrostal-H05K-S03R and
hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
finiteness, and reverse-predictor round-trip.
E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
benchmark that deploys one rotatingMachine per curve and runs a per-pump
(pressure x ctrl) sweep inside each curve's envelope. Reports envelope
compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
pressure below the curve's minimum slice extrapolates wildly
(defended by upstream measurement-node clamping in production).
UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
help panel with a topic reference, port documentation, state diagram,
and prediction rules.
Docs:
- README.md rewritten (was a single line) with install, quick start,
topic/port reference, state machine, predictions, testing, production
status.
Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
< li > < code > setMode< / code > — < code > payload< / code > = < code > auto< / code > | < code > virtualControl< / code > | < code > fysicalControl< / code > < / li >
< li > < code > execSequence< / code > — < code > payload< / code > = < code > {source, action:"execSequence", parameter: "startup"|"shutdown"|"entermaintenance"|"exitmaintenance"}< / code > < / li >
< li > < code > execMovement< / code > — < code > payload< / code > = < code > {source, action:"execMovement", setpoint: 0..100}< / code > (controller %)< / li >
< li > < code > flowMovement< / code > — < code > payload< / code > = < code > {source, action:"flowMovement", setpoint: < flow in configured unit> }< / code > < / li >
< li > < code > emergencystop< / code > — < code > payload< / code > = < code > {source, action:"emergencystop"}< / code > . Aborts any active movement.< / li >
< li > < code > simulateMeasurement< / code > — < code > payload< / code > = < code > {type:"pressure"|"flow"|"temperature"|"power", position, value, unit}< / code > . Injects dashboard-side measurement.< / li >
< li > < code > showWorkingCurves< / code > , < code > CoG< / code > — diagnostics, reply arrives on port 0.< / li >
2025-06-25 17:26:13 +02:00
< / ul >
fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
orchestrators that use 'emergencyStop' (capital S) route correctly to
the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
in accelerating/decelerating, the active movement is aborted via
state.abortCurrentMovement() and the sequence waits (up to 2s) for the
FSM to return to 'operational' before proceeding. New helper
_waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
actionable.
Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
shutdown during accelerating -> idle; emergencystop during accelerating
-> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
parametrized across both shipped pump curves (hidrostal-H05K-S03R and
hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
finiteness, and reverse-predictor round-trip.
E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
benchmark that deploys one rotatingMachine per curve and runs a per-pump
(pressure x ctrl) sweep inside each curve's envelope. Reports envelope
compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
pressure below the curve's minimum slice extrapolates wildly
(defended by upstream measurement-node clamping in production).
UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
help panel with a topic reference, port documentation, state diagram,
and prediction rules.
Docs:
- README.md rewritten (was a single line) with install, quick start,
topic/port reference, state machine, predictions, testing, production
status.
Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
< h3 > Output ports< / h3 >
< ol >
< li > < b > process< / b > — delta-compressed process payload. Consumers must cache and merge each tick. Keys use 4-segment format < code > type.variant.position.childId< / code > (e.g. < code > flow.predicted.downstream.default< / code > ).< / li >
< li > < b > dbase< / b > — InfluxDB telemetry.< / li >
< li > < b > parent< / b > — < code > registerChild< / code > handshake for a parent < code > machineGroupControl< / code > / < code > pumpingStation< / code > .< / li >
< / ol >
< h3 > State machine< / h3 >
< p > States: < code > idle → starting → warmingup → operational → (accelerating ⇄ decelerating) → operational → stopping → coolingdown → idle< / code > . < code > emergencystop → off< / code > is reachable from every active state.< / p >
< p > If a < code > shutdown< / code > or < code > emergencystop< / code > sequence is requested while a setpoint move is in flight (< code > accelerating< / code > / < code > decelerating< / code > ), the move is aborted automatically and the sequence proceeds once the FSM returns to < code > operational< / code > .< / p >
< h3 > Predictions< / h3 >
< p > Flow and power predictions only produce meaningful values once at least one pressure child is reporting (or a < code > simulateMeasurement< / code > pressure is injected). Inject BOTH upstream and downstream for best accuracy.< / p >
2026-03-11 11:13:26 +01:00
< / script >