Compare commits
9 Commits
5ea0b0bda6
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00e35302b4 | ||
|
|
0a3a0be15b | ||
|
|
889221fffd | ||
|
|
a8d9895cbf | ||
|
|
455f15dc55 | ||
|
|
a18aec32b9 | ||
|
|
8c5822c853 | ||
|
|
c9970c0c57 | ||
|
|
426c1a606b |
17
CLAUDE.md
17
CLAUDE.md
@@ -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.
|
||||
|
||||
@@ -322,6 +322,22 @@
|
||||
"removeOlderUnit": "3600",
|
||||
"x": 1230,
|
||||
"y": 300,
|
||||
"wires": []
|
||||
"wires": [],
|
||||
"interpolation": "linear",
|
||||
"xAxisPropertyType": "timestamp",
|
||||
"yAxisProperty": "payload",
|
||||
"yAxisPropertyType": "msg",
|
||||
"action": "append",
|
||||
"colors": [
|
||||
"#0095FF",
|
||||
"#FF0000",
|
||||
"#FF7F0E",
|
||||
"#2CA02C",
|
||||
"#A347E1",
|
||||
"#D62728",
|
||||
"#FF9896",
|
||||
"#9467BD",
|
||||
"#C5B0D5"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 project‐settings & 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 (0–100% 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">m³/h (configurable)</text>
|
||||
<text x="484" y="130" fill="#C0392B" font-size="11" font-weight="bold">↑ Δp head</text>
|
||||
<text x="484" y="146" fill="#C0392B" font-size="10" font-style="italic">predicted from curve</text>
|
||||
|
||||
<!-- Hint footer -->
|
||||
<text x="300" y="194" text-anchor="middle" fill="#777" font-size="10" font-style="italic">
|
||||
Flow direction → Pressure rises across the impeller Performance follows the Q-H / Q-P curves of the selected asset
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SEQUENCE & REACTION TIMING -->
|
||||
<!-- Side-panel inputs hover-coupled to a timeline of FSM phases. -->
|
||||
<!-- Bar widths grow with the entered seconds. Protected phases -->
|
||||
<!-- (warmingup / coolingdown) carry a 🛡 marker. The reaction- -->
|
||||
<!-- speed value tilts the slope inside the operational bar. -->
|
||||
<!-- ============================================================ -->
|
||||
<h4>Sequence & reaction timing</h4>
|
||||
<p style="font-size:12px;color:#777;margin:0 0 6px 0;">Each timing input on the left sizes its phase on the timeline. <b>🛡 protected</b> phases (warm-up & cool-down) cannot be aborted by a new command. Hover an input row to highlight the phase it controls.</p>
|
||||
|
||||
<style>
|
||||
.rm-diag { display:flex; gap:20px; align-items:flex-start; margin: 0 0 14px 0; }
|
||||
.rm-diag-side { width: 230px; flex: 0 0 230px; display:flex; flex-direction:column; gap:6px; }
|
||||
/* SVG height is set at runtime by syncSvgHeight() in oneditprepare to
|
||||
match the side-panel's computed height exactly. Width follows the
|
||||
viewBox aspect ratio. The hard-coded fallback height covers the brief
|
||||
window before the first sync runs. */
|
||||
.rm-diag-svg { height:195px; width:auto; max-width:100%; display:block; }
|
||||
.rm-diag-side .rm-row {
|
||||
display:grid; grid-template-columns: minmax(0,1fr) 70px 18px; align-items:center;
|
||||
gap:6px; padding:4px 6px 4px 10px; border-left:4px solid #ccc;
|
||||
background:#fafafa; border-radius:3px; font-size:11px; cursor:pointer; min-width:0;
|
||||
}
|
||||
.rm-diag-side .rm-row:hover { background:#f0f0f0; }
|
||||
.rm-diag-side .rm-row label { font-weight:600; margin:0; line-height:1.2; }
|
||||
.rm-diag-side .rm-row .rm-sub { grid-column:1; font-size:10px; color:#888; font-weight:400; }
|
||||
.rm-diag-side .rm-row input[type=number] {
|
||||
width:100%; height:22px; box-sizing:border-box; font-size:11px;
|
||||
padding:1px 4px; margin:0; border:1px solid #ccc; border-radius:3px; background:#fff;
|
||||
}
|
||||
.rm-diag-side .rm-row input[type=number]:focus { outline:1px solid #0c99d9; border-color:#0c99d9; }
|
||||
.rm-diag-side .rm-row .rm-unit { color:#888; font-size:10px; text-align:right; }
|
||||
/* Border colours matched to arc fills. */
|
||||
.rm-row[data-stroke="#0c99d9"] { border-left-color:#0c99d9; }
|
||||
.rm-row[data-stroke="#f39c12"] { border-left-color:#f39c12; }
|
||||
.rm-row[data-stroke="#e67e22"] { border-left-color:#e67e22; }
|
||||
.rm-row[data-stroke="#0c99d9"] label { color:#0c99d9; }
|
||||
.rm-row[data-stroke="#f39c12"] label { color:#b9770e; }
|
||||
.rm-row[data-stroke="#e67e22"] label { color:#af601a; }
|
||||
/* Highlight class applied to a state's arc path on input-row hover. */
|
||||
.rm-arc-highlight { stroke:#1F4E79 !important; stroke-width:3 !important; filter:brightness(1.08); }
|
||||
|
||||
/* Movement-mode cards — same compact 94×86 sizing as machineGroupControl. */
|
||||
.rm-mode-cards { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 4px 0; }
|
||||
.rm-mode-card {
|
||||
width:94px; height:86px; box-sizing:border-box;
|
||||
border:2px solid #d0d0d0; border-radius:4px; background:#fafafa;
|
||||
padding:4px; cursor:pointer; user-select:none;
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px;
|
||||
transition:border-color 80ms ease-out, background 80ms ease-out;
|
||||
}
|
||||
.rm-mode-card:hover { border-color:#86bbdd; background:#f5fafd; }
|
||||
.rm-mode-card:focus { outline:2px solid #1F4E79; outline-offset:2px; }
|
||||
.rm-mode-card-on { border-color:#50a8d9; background:#eaf4fb; }
|
||||
.rm-mode-card-svg { width:100%; height:54px; display:flex; align-items:center; justify-content:center; }
|
||||
.rm-mode-card-svg svg { width:100%; height:100%; display:block; }
|
||||
.rm-mode-card-label { font-size:10px; line-height:1; font-weight:600; color:#333; white-space:nowrap; letter-spacing:0; }
|
||||
.rm-mode-card:not(.rm-mode-card-on) .rm-mode-card-label { color:#888; }
|
||||
|
||||
/* Output-format rows mirror the mgc layout: nowrap label, native select
|
||||
hidden, icon picker rendered alongside by iconHelpers. */
|
||||
.rm-output-row > label { white-space:nowrap; width:130px; }
|
||||
</style>
|
||||
|
||||
<div class="rm-diag">
|
||||
<!-- LEFT: stacked colour-coded inputs. Hover a row → matching SVG bar highlights. -->
|
||||
<div class="rm-diag-side">
|
||||
<div class="rm-row" data-stroke="#0c99d9" data-couples="rm-tl-operational">
|
||||
<div><label>Reaction speed</label><div class="rm-sub">controller ramp rate (slope inside operational)</div></div>
|
||||
<input type="number" id="node-input-speed" min="0.1" step="0.1" />
|
||||
<span class="rm-unit">%/s</span>
|
||||
</div>
|
||||
<div class="rm-row" data-stroke="#f39c12" data-couples="rm-tl-starting">
|
||||
<div><label>Startup time</label><div class="rm-sub">idle → starting → warmingup</div></div>
|
||||
<input type="number" id="node-input-startup" min="0" step="1" />
|
||||
<span class="rm-unit">s</span>
|
||||
</div>
|
||||
<div class="rm-row" data-stroke="#e67e22" data-couples="rm-tl-warmingup">
|
||||
<div><label>Warm-up time 🛡︎</label><div class="rm-sub">protected — cannot be aborted</div></div>
|
||||
<input type="number" id="node-input-warmup" min="0" step="1" />
|
||||
<span class="rm-unit">s</span>
|
||||
</div>
|
||||
<div class="rm-row" data-stroke="#f39c12" data-couples="rm-tl-stopping">
|
||||
<div><label>Shutdown time</label><div class="rm-sub">operational → stopping → coolingdown</div></div>
|
||||
<input type="number" id="node-input-shutdown" min="0" step="1" />
|
||||
<span class="rm-unit">s</span>
|
||||
</div>
|
||||
<div class="rm-row" data-stroke="#e67e22" data-couples="rm-tl-coolingdown">
|
||||
<div><label>Cool-down time 🛡︎</label><div class="rm-sub">protected — cannot be aborted</div></div>
|
||||
<input type="number" id="node-input-cooldown" min="0" step="1" />
|
||||
<span class="rm-unit">s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: circular state-machine donut. All arc `d` and label x/y
|
||||
values are written by redrawTimeline(). Each state is a wedge of
|
||||
the ring; arc angle is proportional to its seconds.
|
||||
Idle sits at the top (small fixed slice, the loop-around);
|
||||
operational sits at the bottom (fixed dominant arc). -->
|
||||
<svg id="rm-timeline" class="rm-diag-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 340 260"
|
||||
style="background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
font-family="Arial,sans-serif" font-size="11">
|
||||
|
||||
<!-- Title -->
|
||||
<text x="170" y="14" text-anchor="middle" fill="#1F4E79" font-size="11" font-weight="bold">State machine — sequence loop</text>
|
||||
|
||||
<!-- State arc wedges. Order in DOM = clockwise from top.
|
||||
`d` attribute populated by redrawTimeline(). -->
|
||||
<path id="rm-tl-idle" fill="#bdc3c7" stroke="#7f8c8d" stroke-width="1" />
|
||||
<path id="rm-tl-starting" fill="#f39c12" stroke="#b9770e" stroke-width="1" />
|
||||
<path id="rm-tl-warmingup" fill="#e67e22" stroke="#af601a" stroke-width="1" />
|
||||
<path id="rm-tl-operational" fill="#2ecc71" stroke="#239b56" stroke-width="1" />
|
||||
<path id="rm-tl-stopping" fill="#f39c12" stroke="#b9770e" stroke-width="1" />
|
||||
<path id="rm-tl-coolingdown" fill="#e67e22" stroke="#af601a" stroke-width="1" />
|
||||
|
||||
<!-- State-name labels OUTSIDE the ring. x/y/text-anchor/dy set in JS. -->
|
||||
<text id="rm-tl-lbl-idle" fill="#555" font-size="11" font-weight="bold"></text>
|
||||
<text id="rm-tl-lbl-starting" fill="#b9770e" font-size="11" font-weight="bold"></text>
|
||||
<text id="rm-tl-lbl-warmingup" fill="#af601a" font-size="11" font-weight="bold"></text>
|
||||
<text id="rm-tl-lbl-operational" fill="#239b56" font-size="11" font-weight="bold"></text>
|
||||
<text id="rm-tl-lbl-stopping" fill="#b9770e" font-size="11" font-weight="bold"></text>
|
||||
<text id="rm-tl-lbl-coolingdown" fill="#af601a" font-size="11" font-weight="bold"></text>
|
||||
|
||||
<!-- Duration values INSIDE each arc. x/y set in JS. -->
|
||||
<text id="rm-tl-time-idle" fill="#fff" font-size="10" font-weight="bold"></text>
|
||||
<text id="rm-tl-time-starting" fill="#fff" font-size="10" font-weight="bold"></text>
|
||||
<text id="rm-tl-time-warmingup" fill="#fff" font-size="10" font-weight="bold"></text>
|
||||
<text id="rm-tl-time-operational" fill="#fff" font-size="10" font-weight="bold"></text>
|
||||
<text id="rm-tl-time-stopping" fill="#fff" font-size="10" font-weight="bold"></text>
|
||||
<text id="rm-tl-time-coolingdown" fill="#fff" font-size="10" font-weight="bold"></text>
|
||||
|
||||
<!-- Centre: reaction-speed value (no slope line — donut hole stays clean). -->
|
||||
<text x="170" y="125" text-anchor="middle" fill="#1F4E79" font-size="10" font-weight="bold">Reaction speed</text>
|
||||
<text id="rm-tl-ramp-value" x="170" y="146" text-anchor="middle" fill="#0c99d9" font-size="16" font-weight="bold">1 %/s</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MOVEMENT MODE — visual cards (was a <select>) -->
|
||||
<!-- Hidden #node-input-movementMode keeps the save path working. -->
|
||||
<!-- ============================================================ -->
|
||||
<h4>Movement mode</h4>
|
||||
<p style="font-size:12px;color:#777;margin:0 0 6px 0;">How the controller travels between setpoints during <code>accelerating</code> / <code>decelerating</code>.</p>
|
||||
<div class="rm-mode-cards" role="radiogroup" aria-label="Movement mode">
|
||||
|
||||
<div class="rm-mode-card" data-value="staticspeed" tabindex="0" role="radio" aria-checked="false" aria-label="Static — constant ramp rate" title="Static — constant ramp rate">
|
||||
<div class="rm-mode-card-svg">
|
||||
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<line x1="12" y1="48" x2="70" y2="48" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<line x1="12" y1="48" x2="12" y2="8" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<line x1="14" y1="46" x2="68" y2="12" stroke="#1F4E79" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="14" cy="46" r="2.6" fill="#1F4E79"/>
|
||||
<circle cx="68" cy="12" r="2.6" fill="#1F4E79"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="rm-mode-card-label">Static</div>
|
||||
</div>
|
||||
|
||||
<div class="rm-mode-card" data-value="dynspeed" tabindex="0" role="radio" aria-checked="false" aria-label="Dynamic — ease in/out" title="Dynamic — ease in/out">
|
||||
<div class="rm-mode-card-svg">
|
||||
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<line x1="12" y1="48" x2="70" y2="48" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<line x1="12" y1="48" x2="12" y2="8" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<!-- More pronounced sigmoid: control points pull the mid-section nearly flat
|
||||
(y≈29 mid) so the S-shape reads clearly at thumbnail size. -->
|
||||
<path d="M 14 46 C 22 46, 26 30, 41 29 C 56 28, 60 12, 68 12" fill="none" stroke="#1F4E79" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="14" cy="46" r="2.6" fill="#1F4E79"/>
|
||||
<circle cx="68" cy="12" r="2.6" fill="#1F4E79"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="rm-mode-card-label">Dynamic</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- Hidden field — kept for the save path, written by the cards above. -->
|
||||
<input type="hidden" id="node-input-movementMode" />
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- OUTPUT FORMATS — same shared widget as machineGroupControl. -->
|
||||
<!-- Native selects stay in the DOM (hidden) as save targets; the -->
|
||||
<!-- icon-picker divs are upgraded by iconHelpers. -->
|
||||
<!-- ============================================================ -->
|
||||
<h3>Output Formats</h3>
|
||||
<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 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 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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -16,6 +16,16 @@ function _logger(source, ctx) {
|
||||
return ctx?.logger || source?.logger || null;
|
||||
}
|
||||
|
||||
// Resolve the command origin (control authority: parent | GUI | fysical).
|
||||
// The shared commandRegistry stamps msg.origin (default 'parent'); legacy flows
|
||||
// carried the origin as payload.source. Prefer the legacy field when present so
|
||||
// existing flows keep working, otherwise use the registry-stamped msg.origin.
|
||||
function _origin(msg) {
|
||||
const p = msg && msg.payload;
|
||||
if (p && typeof p === 'object' && typeof p.source === 'string' && p.source) return p.source;
|
||||
return (typeof msg?.origin === 'string' && msg.origin) ? msg.origin : 'parent';
|
||||
}
|
||||
|
||||
function _send(ctx, ports) {
|
||||
if (typeof ctx?.send === 'function') ctx.send(ports);
|
||||
}
|
||||
@@ -28,19 +38,19 @@ exports.setMode = (source, msg) => {
|
||||
// forwards to these directly so behaviour is identical.
|
||||
exports.startup = async (source, msg) => {
|
||||
const p = msg.payload || {};
|
||||
await source.handleInput(p.source ?? 'parent', 'execSequence', 'startup');
|
||||
await source.handleInput(_origin(msg), 'execSequence', 'startup');
|
||||
};
|
||||
|
||||
exports.shutdown = async (source, msg) => {
|
||||
const p = msg.payload || {};
|
||||
await source.handleInput(p.source ?? 'parent', 'execSequence', 'shutdown');
|
||||
await source.handleInput(_origin(msg), 'execSequence', 'shutdown');
|
||||
};
|
||||
|
||||
exports.estop = async (source, msg) => {
|
||||
const p = msg.payload || {};
|
||||
// Legacy emergencystop carried { source, action } — action defaults to
|
||||
// 'emergencystop' when only source is supplied via the canonical topic.
|
||||
await source.handleInput(p.source ?? 'parent', p.action ?? 'emergencystop');
|
||||
await source.handleInput(_origin(msg), p.action ?? 'emergencystop');
|
||||
};
|
||||
|
||||
// Content-based alias router: legacy `execSequence` carried payload.action in
|
||||
@@ -57,13 +67,13 @@ exports.execSequenceAlias = async (source, msg, ctx) => {
|
||||
exports.setSetpoint = async (source, msg) => {
|
||||
const p = msg.payload || {};
|
||||
const action = p.action ?? 'execMovement';
|
||||
await source.handleInput(p.source ?? 'parent', action, Number(p.setpoint));
|
||||
await source.handleInput(_origin(msg), action, Number(p.setpoint));
|
||||
};
|
||||
|
||||
exports.setFlowSetpoint = async (source, msg) => {
|
||||
const p = msg.payload || {};
|
||||
const action = p.action ?? 'flowMovement';
|
||||
await source.handleInput(p.source ?? 'parent', action, Number(p.setpoint));
|
||||
await source.handleInput(_origin(msg), action, Number(p.setpoint));
|
||||
};
|
||||
|
||||
exports.simulateMeasurement = (source, msg, ctx) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
@@ -63,7 +63,7 @@ module.exports = [
|
||||
topic: 'set.flow-setpoint',
|
||||
aliases: ['flowMovement'],
|
||||
payloadSchema: { type: 'object' },
|
||||
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||
unit: 'm3/h',
|
||||
description: 'Move the machine to a flow setpoint via flowMovement.',
|
||||
handler: handlers.setFlowSetpoint,
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -79,6 +79,12 @@ function buildQHCurve(predictors, ctrlPct, options = {}) {
|
||||
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²
|
||||
@@ -103,7 +109,8 @@ function buildQHCurve(predictors, ctrlPct, options = {}) {
|
||||
for (const p of pressures) {
|
||||
pf.fDimension = p;
|
||||
const QM3s = pf.y(x);
|
||||
points.push({ Q: QM3s * 3600, H: p / (RHO * G), dpPa: p });
|
||||
const Q = policy.convert(QM3s, flowFrom, flowTo, 'buildQHCurve Q-axis');
|
||||
points.push({ Q, H: p / (RHO * G), dpPa: p });
|
||||
}
|
||||
} finally {
|
||||
pf.fDimension = originalF;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -229,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 = {}) {
|
||||
@@ -247,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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user