Compare commits

...

9 Commits

Author SHA1 Message Date
znetsixe
00e35302b4 fix(examples): complete ui-chart required props in edge.flow.json (flow-lint)
State-code chart was missing interpolation, axis property types, action, colors.
Lint-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:18:44 +02:00
znetsixe
0a3a0be15b feat(commands): adopt unified command envelope — msg.origin + unit shorthand
- Provenance resolved via msg.origin (registry-stamped, default parent) with a
  legacy fallback to payload.source; feeds handleInput's mode/source gating.
- set.flow-setpoint: units:{measure,default} -> unit:'m3/h' shorthand.

181/181 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:41:14 +02:00
znetsixe
889221fffd fix(rm): force-emit ctrl every tick (static alwaysEmitFields)
Realized control position is constant in steady state, so delta compression
emitted it ~once and the Grafana "% Control" line went invisible. Exempt
`ctrl` from delta compression so the pump's movement always traces.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:31:01 +02:00
13 changed files with 564 additions and 120 deletions

View File

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

View File

@@ -322,6 +322,22 @@
"removeOlderUnit": "3600", "removeOlderUnit": "3600",
"x": 1230, "x": 1230,
"y": 300, "y": 300,
"wires": [] "wires": [],
"interpolation": "linear",
"xAxisPropertyType": "timestamp",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"action": "append",
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
]
} }
] ]

View File

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

View File

@@ -16,6 +16,16 @@ function _logger(source, ctx) {
return ctx?.logger || source?.logger || null; 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) { function _send(ctx, ports) {
if (typeof ctx?.send === 'function') ctx.send(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. // forwards to these directly so behaviour is identical.
exports.startup = async (source, msg) => { exports.startup = async (source, msg) => {
const p = msg.payload || {}; const p = msg.payload || {};
await source.handleInput(p.source ?? 'parent', 'execSequence', 'startup'); await source.handleInput(_origin(msg), 'execSequence', 'startup');
}; };
exports.shutdown = async (source, msg) => { exports.shutdown = async (source, msg) => {
const p = msg.payload || {}; const p = msg.payload || {};
await source.handleInput(p.source ?? 'parent', 'execSequence', 'shutdown'); await source.handleInput(_origin(msg), 'execSequence', 'shutdown');
}; };
exports.estop = async (source, msg) => { exports.estop = async (source, msg) => {
const p = msg.payload || {}; const p = msg.payload || {};
// Legacy emergencystop carried { source, action } — action defaults to // Legacy emergencystop carried { source, action } — action defaults to
// 'emergencystop' when only source is supplied via the canonical topic. // '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 // 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) => { exports.setSetpoint = async (source, msg) => {
const p = msg.payload || {}; const p = msg.payload || {};
const action = p.action ?? 'execMovement'; 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) => { exports.setFlowSetpoint = async (source, msg) => {
const p = msg.payload || {}; const p = msg.payload || {};
const action = p.action ?? 'flowMovement'; 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) => { exports.simulateMeasurement = (source, msg, ctx) => {

View File

@@ -19,7 +19,7 @@ module.exports = [
topic: 'set.mode', topic: 'set.mode',
aliases: ['setMode'], aliases: ['setMode'],
payloadSchema: { type: 'string' }, 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, handler: handlers.setMode,
}, },
{ {
@@ -63,7 +63,7 @@ module.exports = [
topic: 'set.flow-setpoint', topic: 'set.flow-setpoint',
aliases: ['flowMovement'], aliases: ['flowMovement'],
payloadSchema: { type: 'object' }, payloadSchema: { type: 'object' },
units: { measure: 'volumeFlowRate', default: 'm3/h' }, unit: 'm3/h',
description: 'Move the machine to a flow setpoint via flowMovement.', description: 'Move the machine to a flow setpoint via flowMovement.',
handler: handlers.setFlowSetpoint, handler: handlers.setFlowSetpoint,
}, },

View File

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

View File

@@ -79,6 +79,12 @@ function buildQHCurve(predictors, ctrlPct, options = {}) {
if (!pf.inputCurve || typeof pf.inputCurve !== 'object') { if (!pf.inputCurve || typeof pf.inputCurve !== 'object') {
return { error: NO_CURVE_ERROR, points: [] }; 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 x = Number.isFinite(+ctrlPct) ? +ctrlPct : (pf.currentX ?? 0);
const RHO = 999.1; // kg/m³ — water at ~15 °C const RHO = 999.1; // kg/m³ — water at ~15 °C
const G = 9.80665; // m/s² const G = 9.80665; // m/s²
@@ -103,7 +109,8 @@ function buildQHCurve(predictors, ctrlPct, options = {}) {
for (const p of pressures) { for (const p of pressures) {
pf.fDimension = p; pf.fDimension = p;
const QM3s = pf.y(x); 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 { } finally {
pf.fDimension = originalF; pf.fDimension = originalF;

View File

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

View File

@@ -11,6 +11,10 @@ class nodeClass extends BaseNodeAdapter {
static commands = commands; static commands = commands;
static tickInterval = null; static tickInterval = null;
static statusInterval = 1000; 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) { buildDomainConfig(uiConfig) {
_rejectLegacyAssetFields(uiConfig); _rejectLegacyAssetFields(uiConfig);

View File

@@ -229,10 +229,18 @@ class Machine extends BaseDomain {
this.measurements.type('temperature').variant('measured').position('atEquipment').value(15, Date.now(), tu); 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'); this.measurements.type('atmPressure').variant('measured').position('atEquipment').value(101325, Date.now(), 'Pa');
const fu = this.unitPolicy.canonical.flow; const fu = this.unitPolicy.canonical.flow;
const pu = this.unitPolicy.canonical.power;
const fmin = this.predictFlow ? this.predictFlow.currentFxyYMin : 0; const fmin = this.predictFlow ? this.predictFlow.currentFxyYMin : 0;
const fmax = this.predictFlow ? this.predictFlow.currentFxyYMax : 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('max').value(fmax, Date.now(), fu);
this.measurements.type('flow').variant('predicted').position('min').value(fmin, 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 = {}) { _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.`); if (!this.isUnitValidForType(type, u)) throw new Error(`Unsupported unit '${u}' for ${type} measurement.`);
return u; 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'; } _measurementPositionForMetric(metricId) { return metricId === 'power' ? 'atEquipment' : 'downstream'; }
_resolveProcessRangeForMetric(metricId, predicted, measured) { _resolveProcessRangeForMetric(metricId, predicted, measured) {
let processMin = NaN; let processMax = NaN; let processMin = NaN; let processMax = NaN;

View File

@@ -5,7 +5,6 @@ const { UnitPolicy } = require('generalFunctions');
const { const {
normalizeMachineCurve, normalizeMachineCurve,
normalizeCurveSection, normalizeCurveSection,
convertUnitValue,
} = require('../../src/curves/curveNormalizer'); } = require('../../src/curves/curveNormalizer');
function makePolicy() { 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', () => { test('normalizeCurveSection: warns on cross-pressure median > 3x jump', () => {
const policy = makePolicy();
const logger = captureLogger(); const logger = captureLogger();
const section = { const section = {
1000: { x: [0, 50, 100], y: [0, 5, 10] }, // median 5 1000: { x: [0, 50, 100], y: [0, 5, 10] }, // median 5
1100: { x: [0, 50, 100], y: [0, 50, 100] }, // median 50 (10x jump) 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)); const hit = logger.warns.find((w) => /Curve anomaly/.test(w));
assert.ok(hit, `expected a Curve anomaly warning, got: ${JSON.stringify(logger.warns)}`); assert.ok(hit, `expected a Curve anomaly warning, got: ${JSON.stringify(logger.warns)}`);
assert.match(hit, /pressure 1100/); assert.match(hit, /pressure 1100/);
}); });
test('normalizeCurveSection: does not warn on smooth progressions', () => { test('normalizeCurveSection: does not warn on smooth progressions', () => {
const policy = makePolicy();
const logger = captureLogger(); const logger = captureLogger();
const section = { const section = {
1000: { x: [0, 50, 100], y: [0, 5, 10] }, 1000: { x: [0, 50, 100], y: [0, 5, 10] },
1100: { x: [0, 50, 100], y: [0, 6, 11] }, 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); assert.equal(logger.warns.filter((w) => /Curve anomaly/.test(w)).length, 0);
}); });
test('normalizeCurveSection: throws when x/y length mismatch', () => { test('normalizeCurveSection: throws when x/y length mismatch', () => {
const policy = makePolicy();
assert.throws( 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/ /Invalid nq section/
); );
}); });
test('convertUnitValue: identity when units match or missing', () => {
assert.equal(convertUnitValue(42, 'm3/h', 'm3/h'), 42);
assert.equal(convertUnitValue(42, null, null), 42);
});
test('convertUnitValue: throws on non-finite input', () => {
assert.throws(() => convertUnitValue('not-a-number', 'm3/h', 'm3/s', 'test'), /not finite/);
});

View File

@@ -27,6 +27,10 @@ function makeHost({
unitPolicy: { unitPolicy: {
canonical: { flow: 'm3/s' }, canonical: { flow: 'm3/s' },
output: { flow: 'm3/h' }, 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), isValidActionForMode: (action) => allowedActions.has(action),
isValidSourceForMode: () => allowedSources, isValidSourceForMode: () => allowedSources,
@@ -38,10 +42,6 @@ function makeHost({
return { moved: sp }; return { moved: sp };
}, },
calcCtrl: (canonicalFlow) => { host.calls.calcCtrl.push(canonicalFlow); return canonicalFlow / 2; }, 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; return host;
} }

View File

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