Compare commits
6 Commits
ea33b3bba3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d196f363 | ||
|
|
510a4233e6 | ||
|
|
26e253d030 | ||
|
|
c464b66b27 | ||
|
|
17b88870bb | ||
|
|
07af7cef40 |
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# rotatingMachine — Claude Code context
|
||||||
|
|
||||||
|
Individual pump / compressor / blower control.
|
||||||
|
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||||
|
|
||||||
|
## S88 classification
|
||||||
|
|
||||||
|
| Level | Colour | Placement lane |
|
||||||
|
|---|---|---|
|
||||||
|
| **Equipment Module** | `#86bbdd` | L3 |
|
||||||
|
|
||||||
|
## Flow layout rules
|
||||||
|
|
||||||
|
When wiring this node into a multi-node demo or production flow, follow the
|
||||||
|
placement rule set in the **EVOLV superproject**:
|
||||||
|
|
||||||
|
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
|
||||||
|
|
||||||
|
Key points for this node:
|
||||||
|
- Place on lane **L3** (x-position per the lane table in the rule).
|
||||||
|
- 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).
|
||||||
117
README.md
117
README.md
@@ -1 +1,116 @@
|
|||||||
# rotating machine
|
# rotatingMachine
|
||||||
|
|
||||||
|
Node-RED custom node for individual rotating-machine control — pumps, compressors, blowers. Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform developed by R&D at Waterschap Brabantse Delta.
|
||||||
|
|
||||||
|
Models a single asset with an S88 state machine, curve-backed flow/power prediction, and parent/child registration for orchestration by `machineGroupControl` or `pumpingStation`.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
In a Node-RED user directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.node-red
|
||||||
|
npm install github:gitea.wbd-rd.nl/RnD/rotatingMachine
|
||||||
|
```
|
||||||
|
|
||||||
|
Or consume the whole platform:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install github:gitea.wbd-rd.nl/RnD/EVOLV
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `node-red` and the node appears in the editor palette under the **EVOLV** category.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Drop a `rotatingMachine` onto a flow, fill the Asset menu (supplier, model — must match a curve in `generalFunctions/datasets`), and wire three debug nodes to the three output ports. Inject these in order:
|
||||||
|
|
||||||
|
| Topic | Payload | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `setMode` | `"virtualControl"` | allow manual commands |
|
||||||
|
| `simulateMeasurement` | `{type:"pressure",position:"upstream",value:200,unit:"mbar"}` | seed upstream pressure |
|
||||||
|
| `simulateMeasurement` | `{type:"pressure",position:"downstream",value:1100,unit:"mbar"}` | seed downstream pressure |
|
||||||
|
| `execSequence` | `{source:"GUI",action:"execSequence",parameter:"startup"}` | start the machine |
|
||||||
|
| `execMovement` | `{source:"GUI",action:"execMovement",setpoint:60}` | ramp to 60 % controller position |
|
||||||
|
| `execSequence` | `{source:"GUI",action:"execSequence",parameter:"shutdown"}` | shut down |
|
||||||
|
|
||||||
|
Ready-made example flows are in `examples/`:
|
||||||
|
|
||||||
|
- `01 - Basic Manual Control.json` — inject-only smoke test
|
||||||
|
- `02 - Integration with Machine Group.json` — parent/child registration with `machineGroupControl`
|
||||||
|
- `03 - Dashboard Visualization.json` — FlowFuse dashboard with live charts
|
||||||
|
|
||||||
|
Import via Node-RED **Import ▸ Examples ▸ EVOLV**.
|
||||||
|
|
||||||
|
## Input topics
|
||||||
|
|
||||||
|
| Topic | Payload | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `setMode` | `"auto"` \| `"virtualControl"` \| `"fysicalControl"` | mode gates which sources may command the machine |
|
||||||
|
| `execSequence` | `{source, action:"execSequence", parameter}` — parameter: `"startup"` \| `"shutdown"` \| `"entermaintenance"` \| `"exitmaintenance"` | runs an S88 sequence |
|
||||||
|
| `execMovement` | `{source, action:"execMovement", setpoint}` — setpoint in controller % | moves controller position |
|
||||||
|
| `flowMovement` | `{source, action:"flowMovement", setpoint}` — setpoint in configured flow unit | converts flow → controller %, then moves |
|
||||||
|
| `emergencystop` | `{source, action:"emergencystop"}` | aborts any active movement and drives state to `off` |
|
||||||
|
| `simulateMeasurement` | `{type, position, value, unit}` — type: `pressure` \| `flow` \| `temperature` \| `power` | dashboard-side measurement injection |
|
||||||
|
| `showWorkingCurves` | — | diagnostic — reply on port 0 |
|
||||||
|
| `CoG` | — | diagnostic — reply on port 0 |
|
||||||
|
|
||||||
|
Topic case is preserved; sequence parameter and action names are normalized to lowercase internally (so `"emergencyStop"`, `"EmergencyStop"`, `"emergencystop"` all work).
|
||||||
|
|
||||||
|
## Output ports
|
||||||
|
|
||||||
|
| Port | Label | Payload |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | `process` | delta-compressed process payload; keys are `type.variant.position.childId` (e.g. `flow.predicted.downstream.default`). Consumers must cache and merge each tick. |
|
||||||
|
| 1 | `dbase` | InfluxDB line-protocol telemetry |
|
||||||
|
| 2 | `parent` | `{topic:"registerChild", payload:<nodeId>, positionVsParent}` emitted once on deploy for parent group/station registration |
|
||||||
|
|
||||||
|
## State machine
|
||||||
|
|
||||||
|
```
|
||||||
|
idle ─► starting ─► warmingup ─► operational ◄─┐
|
||||||
|
▲ │
|
||||||
|
│ ▼
|
||||||
|
│ accelerating / decelerating
|
||||||
|
│ │
|
||||||
|
└──────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
stopping ─► coolingdown ─► idle
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
emergencystop ─► off
|
||||||
|
```
|
||||||
|
|
||||||
|
- `warmingup` and `coolingdown` are **protected** — new commands cannot abort them.
|
||||||
|
- `accelerating` and `decelerating` **are** interruptible. If a `shutdown` or `emergencystop` sequence is requested mid-ramp, the active movement is aborted automatically and the sequence proceeds once the FSM has returned to `operational`.
|
||||||
|
- Timings come from the `Startup` / `Warmup` / `Shutdown` / `Cooldown` fields in the editor (seconds).
|
||||||
|
|
||||||
|
## Predictions
|
||||||
|
|
||||||
|
Flow and power outputs are curve-backed predictions driven by the controller position and the differential pressure across the machine. Inject both upstream and downstream pressures for best accuracy. With only one side present the node warns and falls back to the available side. With no pressure, predictions use the minimum pressure dimension (flow/power will look unrealistic).
|
||||||
|
|
||||||
|
The active curve is selected from `machineCurve.nq` and `machineCurve.np`, keyed by the closest matching pressure level. Curve units are declared in the Asset menu (default: `mbar`, `m³/h`, `kW`, `%`).
|
||||||
|
|
||||||
|
## Units
|
||||||
|
|
||||||
|
Canonical units are used internally (Pa / m³/s / W / K). All inputs and outputs convert at the boundary via the configured unit for each measurement type. The `speed` field in the editor is a ramp rate in controller-position units per second (so `speed: 1` → 1 %/s → a setpoint of 60 % from idle completes in ~60 s).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nodes/rotatingMachine
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
79 tests cover construction, mode/input routing, config loading, sequences, emergency stop, shutdown, interruptible movement, movement lifecycle, prediction health, pressure initialization, CoolProp efficiency, registration, negative/null guards, output format, listener cleanup. Run the full suite in ~2 seconds.
|
||||||
|
|
||||||
|
For end-to-end verification, see `../../docker-compose.yml` — a Docker stack (Node-RED + InfluxDB + Grafana) that hosts the live node. The scripts in `../../../memory/` and `examples/` document the E2E protocol used for production-readiness benchmarks.
|
||||||
|
|
||||||
|
## Production status
|
||||||
|
|
||||||
|
Last reviewed **2026-04-13** — trial-ready. See the project memory file `node_rotatingMachine.md` for the latest benchmarks, known caveats, and wishlist.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
SEE LICENSE. Author: Rene De Ren, Waterschap Brabantse Delta R&D.
|
||||||
|
|||||||
@@ -67,11 +67,15 @@
|
|||||||
|
|
||||||
oneditprepare: function() {
|
oneditprepare: function() {
|
||||||
// wait for the menu scripts to load
|
// wait for the menu scripts to load
|
||||||
|
let menuRetries = 0;
|
||||||
|
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(this);
|
||||||
} else {
|
} else if (++menuRetries < maxMenuRetries) {
|
||||||
setTimeout(waitForMenuData, 50);
|
setTimeout(waitForMenuData, 50);
|
||||||
|
} else {
|
||||||
|
console.warn("rotatingMachine: menu scripts failed to load within 5 seconds");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
waitForMenuData();
|
waitForMenuData();
|
||||||
@@ -124,23 +128,28 @@
|
|||||||
<!-- Machine-specific controls -->
|
<!-- Machine-specific controls -->
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-speed"><i class="fa fa-clock-o"></i> Reaction Speed</label>
|
<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%;" />
|
<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>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-startup"><i class="fa fa-clock-o"></i> Startup Time</label>
|
<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%;" />
|
<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>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-warmup"><i class="fa fa-clock-o"></i> Warmup Time</label>
|
<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%;" />
|
<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>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-shutdown"><i class="fa fa-clock-o"></i> Shutdown Time</label>
|
<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%;" />
|
<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>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-cooldown"><i class="fa fa-clock-o"></i> Cooldown Time</label>
|
<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%;" />
|
<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>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-movementMode"><i class="fa fa-exchange"></i> Movement Mode</label>
|
<label for="node-input-movementMode"><i class="fa fa-exchange"></i> Movement Mode</label>
|
||||||
@@ -180,11 +189,40 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="text/html" data-help-name="rotatingMachine">
|
<script type="text/html" data-help-name="rotatingMachine">
|
||||||
<p><b>Rotating Machine Node</b>: Configure a rotating‐machine asset.</p>
|
<p><b>Rotating Machine</b>: individual pump / compressor / blower control module. Runs a 10-state S88 sequence, predicts flow and power from a supplier curve, and publishes process + telemetry outputs each second.</p>
|
||||||
|
|
||||||
|
<h3>Configuration</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><b>Reaction Speed, Startup, Warmup, Shutdown, Cooldown:</b> timing parameters.</li>
|
<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>Supplier / SubType / Model / Unit:</b> choose via Asset menu.</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>Enable Log / Log Level:</b> toggle via Logger menu.</li>
|
<li><b>Movement Mode</b>: <code>staticspeed</code> = linear ramp; <code>dynspeed</code> = ease-in/out.</li>
|
||||||
<li><b>Position:</b> set Upstream / At Equipment / Downstream via Position menu.</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>Position</b> (menu): <code>upstream</code> / <code>atEquipment</code> / <code>downstream</code> relative to a parent group/station.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h3>Input topics (<code>msg.topic</code>)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>setMode</code> — <code>payload</code> = <code>auto</code> | <code>virtualControl</code> | <code>fysicalControl</code></li>
|
||||||
|
<li><code>execSequence</code> — <code>payload</code> = <code>{source, action:"execSequence", parameter: "startup"|"shutdown"|"entermaintenance"|"exitmaintenance"}</code></li>
|
||||||
|
<li><code>execMovement</code> — <code>payload</code> = <code>{source, action:"execMovement", setpoint: 0..100}</code> (controller %)</li>
|
||||||
|
<li><code>flowMovement</code> — <code>payload</code> = <code>{source, action:"flowMovement", setpoint: <flow in configured unit>}</code></li>
|
||||||
|
<li><code>emergencystop</code> — <code>payload</code> = <code>{source, action:"emergencystop"}</code>. Aborts any active movement.</li>
|
||||||
|
<li><code>simulateMeasurement</code> — <code>payload</code> = <code>{type:"pressure"|"flow"|"temperature"|"power", position, value, unit}</code>. Injects dashboard-side measurement.</li>
|
||||||
|
<li><code>showWorkingCurves</code>, <code>CoG</code> — diagnostics, reply arrives on port 0.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Output ports</h3>
|
||||||
|
<ol>
|
||||||
|
<li><b>process</b> — delta-compressed process payload. Consumers must cache and merge each tick. Keys use 4-segment format <code>type.variant.position.childId</code> (e.g. <code>flow.predicted.downstream.default</code>).</li>
|
||||||
|
<li><b>dbase</b> — InfluxDB telemetry.</li>
|
||||||
|
<li><b>parent</b> — <code>registerChild</code> handshake for a parent <code>machineGroupControl</code> / <code>pumpingStation</code>.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>State machine</h3>
|
||||||
|
<p>States: <code>idle → starting → warmingup → operational → (accelerating ⇄ decelerating) → operational → stopping → coolingdown → idle</code>. <code>emergencystop → off</code> is reachable from every active state.</p>
|
||||||
|
<p>If a <code>shutdown</code> or <code>emergencystop</code> sequence is requested while a setpoint move is in flight (<code>accelerating</code> / <code>decelerating</code>), the move is aborted automatically and the sequence proceeds once the FSM returns to <code>operational</code>.</p>
|
||||||
|
|
||||||
|
<h3>Predictions</h3>
|
||||||
|
<p>Flow and power predictions only produce meaningful values once at least one pressure child is reporting (or a <code>simulateMeasurement</code> pressure is injected). Inject BOTH upstream and downstream for best accuracy.</p>
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -254,10 +254,11 @@ class nodeClass {
|
|||||||
* Start the periodic tick loop.
|
* Start the periodic tick loop.
|
||||||
*/
|
*/
|
||||||
_startTickLoop() {
|
_startTickLoop() {
|
||||||
setTimeout(() => {
|
this._startupTimeout = setTimeout(() => {
|
||||||
|
this._startupTimeout = null;
|
||||||
this._tickInterval = setInterval(() => this._tick(), 1000);
|
this._tickInterval = setInterval(() => this._tick(), 1000);
|
||||||
|
|
||||||
// Update node status on nodered screen every second ( this is not the best way to do this, but it works for now)
|
// Update node status on nodered screen every second
|
||||||
this._statusInterval = setInterval(() => {
|
this._statusInterval = setInterval(() => {
|
||||||
const status = this._updateNodeStatus();
|
const status = this._updateNodeStatus();
|
||||||
this.node.status(status);
|
this.node.status(status);
|
||||||
@@ -284,15 +285,13 @@ class nodeClass {
|
|||||||
* Attach the node's input handler, routing control messages to the class.
|
* Attach the node's input handler, routing control messages to the class.
|
||||||
*/
|
*/
|
||||||
_attachInputHandler() {
|
_attachInputHandler() {
|
||||||
this.node.on('input', (msg, send, done) => {
|
this.node.on('input', async (msg, send, done) => {
|
||||||
/* Update to complete event based node by putting the tick function after an input event */
|
|
||||||
const m = this.source;
|
const m = this.source;
|
||||||
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
|
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch(msg.topic) {
|
switch(msg.topic) {
|
||||||
case 'registerChild': {
|
case 'registerChild': {
|
||||||
// Register this node as a child of the parent node
|
|
||||||
const childId = msg.payload;
|
const childId = msg.payload;
|
||||||
const childObj = this.RED.nodes.getNode(childId);
|
const childObj = this.RED.nodes.getNode(childId);
|
||||||
if (!childObj || !childObj.source) {
|
if (!childObj || !childObj.source) {
|
||||||
@@ -307,22 +306,22 @@ class nodeClass {
|
|||||||
break;
|
break;
|
||||||
case 'execSequence': {
|
case 'execSequence': {
|
||||||
const { source, action, parameter } = msg.payload;
|
const { source, action, parameter } = msg.payload;
|
||||||
m.handleInput(source, action, parameter);
|
await m.handleInput(source, action, parameter);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'execMovement': {
|
case 'execMovement': {
|
||||||
const { source: mvSource, action: mvAction, setpoint } = msg.payload;
|
const { source: mvSource, action: mvAction, setpoint } = msg.payload;
|
||||||
m.handleInput(mvSource, mvAction, Number(setpoint));
|
await m.handleInput(mvSource, mvAction, Number(setpoint));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'flowMovement': {
|
case 'flowMovement': {
|
||||||
const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload;
|
const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload;
|
||||||
m.handleInput(fmSource, fmAction, Number(fmSetpoint));
|
await m.handleInput(fmSource, fmAction, Number(fmSetpoint));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'emergencystop': {
|
case 'emergencystop': {
|
||||||
const { source: esSource, action: esAction } = msg.payload;
|
const { source: esSource, action: esAction } = msg.payload;
|
||||||
m.handleInput(esSource, esAction);
|
await m.handleInput(esSource, esAction);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'simulateMeasurement':
|
case 'simulateMeasurement':
|
||||||
@@ -403,8 +402,28 @@ class nodeClass {
|
|||||||
*/
|
*/
|
||||||
_attachCloseHandler() {
|
_attachCloseHandler() {
|
||||||
this.node.on('close', (done) => {
|
this.node.on('close', (done) => {
|
||||||
|
clearTimeout(this._startupTimeout);
|
||||||
clearInterval(this._tickInterval);
|
clearInterval(this._tickInterval);
|
||||||
clearInterval(this._statusInterval);
|
clearInterval(this._statusInterval);
|
||||||
|
|
||||||
|
// Clean up child measurement listeners
|
||||||
|
const m = this.source;
|
||||||
|
if (m?.childMeasurementListeners) {
|
||||||
|
for (const [, entry] of m.childMeasurementListeners) {
|
||||||
|
if (typeof entry.emitter?.off === 'function') {
|
||||||
|
entry.emitter.off(entry.eventName, entry.handler);
|
||||||
|
} else if (typeof entry.emitter?.removeListener === 'function') {
|
||||||
|
entry.emitter.removeListener(entry.eventName, entry.handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.childMeasurementListeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up state emitter listeners
|
||||||
|
if (m?.state?.emitter) {
|
||||||
|
m.state.emitter.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof done === 'function') done();
|
if (typeof done === 'function') done();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,6 +343,38 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
//---------------- END child stuff -------------//
|
//---------------- END child stuff -------------//
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until the state machine reaches 'operational', or until a timeout.
|
||||||
|
* Used after an aborted movement to ensure subsequent sequence transitions
|
||||||
|
* (stopping/emergencystop) will be accepted by the FSM.
|
||||||
|
* @param {number} timeoutMs - maximum time to wait in milliseconds
|
||||||
|
* @returns {Promise<string>} the state observed when the wait ends
|
||||||
|
*/
|
||||||
|
async _waitForOperational(timeoutMs = 2000) {
|
||||||
|
if (this.state.getCurrentState() === "operational") {
|
||||||
|
return "operational";
|
||||||
|
}
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
this.state.emitter.off("stateChange", onChange);
|
||||||
|
resolve(this.state.getCurrentState());
|
||||||
|
}, timeoutMs);
|
||||||
|
const onChange = (newState) => {
|
||||||
|
if (done) return;
|
||||||
|
if (newState === "operational") {
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.state.emitter.off("stateChange", onChange);
|
||||||
|
resolve("operational");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.state.emitter.on("stateChange", onChange);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_buildUnitPolicy(config) {
|
_buildUnitPolicy(config) {
|
||||||
const flowOutputUnit = this._resolveUnitOrFallback(
|
const flowOutputUnit = this._resolveUnitOrFallback(
|
||||||
config?.general?.unit,
|
config?.general?.unit,
|
||||||
@@ -438,7 +470,10 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
_normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName) {
|
_normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName) {
|
||||||
const normalized = {};
|
const normalized = {};
|
||||||
for (const [pressureKey, pair] of Object.entries(section || {})) {
|
const pressureEntries = Object.entries(section || {});
|
||||||
|
let prevMedianY = null;
|
||||||
|
|
||||||
|
for (const [pressureKey, pair] of pressureEntries) {
|
||||||
const canonicalPressure = this._convertUnitValue(
|
const canonicalPressure = this._convertUnitValue(
|
||||||
Number(pressureKey),
|
Number(pressureKey),
|
||||||
fromPressureUnit,
|
fromPressureUnit,
|
||||||
@@ -450,6 +485,21 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
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}'.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-pressure anomaly detection: flag sudden jumps in median y between adjacent pressure levels
|
||||||
|
const sortedY = [...yArray].sort((a, b) => a - b);
|
||||||
|
const medianY = sortedY[Math.floor(sortedY.length / 2)];
|
||||||
|
if (prevMedianY != null && prevMedianY > 0) {
|
||||||
|
const ratio = medianY / prevMedianY;
|
||||||
|
if (ratio > 3 || ratio < 0.33) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Curve anomaly in ${sectionName} at pressure ${pressureKey}: median y=${medianY.toFixed(2)} ` +
|
||||||
|
`deviates ${(ratio).toFixed(1)}x from adjacent level (${prevMedianY.toFixed(2)}). Check curve data.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevMedianY = medianY;
|
||||||
|
|
||||||
normalized[String(canonicalPressure)] = {
|
normalized[String(canonicalPressure)] = {
|
||||||
x: xArray,
|
x: xArray,
|
||||||
y: yArray,
|
y: yArray,
|
||||||
@@ -772,7 +822,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
case "emergencystop":
|
case "emergencystop":
|
||||||
this.logger.warn(`Emergency stop activated by '${source}'.`);
|
this.logger.warn(`Emergency stop activated by '${source}'.`);
|
||||||
return await this.executeSequence("emergencyStop");
|
return await this.executeSequence("emergencystop");
|
||||||
|
|
||||||
case "statuscheck":
|
case "statuscheck":
|
||||||
this.logger.info(`Status Check: Mode = '${this.currentMode}', Source = '${source}'.`);
|
this.logger.info(`Status Check: Mode = '${this.currentMode}', Source = '${source}'.`);
|
||||||
@@ -810,6 +860,12 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
// -------- Sequence Handlers -------- //
|
// -------- Sequence Handlers -------- //
|
||||||
async executeSequence(sequenceName) {
|
async executeSequence(sequenceName) {
|
||||||
|
|
||||||
|
// Defensive: sequence keys in the config are lowercase. Accept any casing
|
||||||
|
// from callers (parent orchestrators, tests, legacy flows) and normalize.
|
||||||
|
if (typeof sequenceName === 'string') {
|
||||||
|
sequenceName = sequenceName.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
const sequence = this.config.sequences[sequenceName];
|
const sequence = this.config.sequences[sequenceName];
|
||||||
|
|
||||||
if (!sequence || sequence.size === 0) {
|
if (!sequence || sequence.size === 0) {
|
||||||
@@ -817,6 +873,20 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interruptible movement: if a shutdown or emergency-stop is requested
|
||||||
|
// while a setpoint move is mid-flight (accelerating/decelerating), abort
|
||||||
|
// the move first and wait briefly for the FSM to return to 'operational'.
|
||||||
|
// Without this, transitions like accelerating->stopping are rejected by
|
||||||
|
// stateManager.isValidTransition, leaving the machine running.
|
||||||
|
const currentState = this.state.getCurrentState();
|
||||||
|
const interruptible = new Set(["shutdown", "emergencystop"]);
|
||||||
|
if (interruptible.has(sequenceName) &&
|
||||||
|
(currentState === "accelerating" || currentState === "decelerating")) {
|
||||||
|
this.logger.warn(`Sequence '${sequenceName}' requested during '${currentState}'. Aborting active movement.`);
|
||||||
|
this.state.abortCurrentMovement(`${sequenceName} sequence requested`, { returnToOperational: true });
|
||||||
|
await this._waitForOperational(2000);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.state.getCurrentState() == "operational" && sequenceName == "shutdown") {
|
if (this.state.getCurrentState() == "operational" && sequenceName == "shutdown") {
|
||||||
this.logger.info(`Machine will ramp down to position 0 before performing ${sequenceName} sequence`);
|
this.logger.info(`Machine will ramp down to position 0 before performing ${sequenceName} sequence`);
|
||||||
await this.setpoint(0);
|
await this.setpoint(0);
|
||||||
@@ -897,10 +967,11 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cFlow = this.predictFlow.y(x);
|
const rawFlow = this.predictFlow.y(x);
|
||||||
|
// Clamp: at position ≤ 0 the pump isn't rotating — physical flow is 0.
|
||||||
|
const cFlow = (x <= 0) ? 0 : Math.max(0, rawFlow);
|
||||||
this.measurements.type("flow").variant("predicted").position("downstream").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
|
this.measurements.type("flow").variant("predicted").position("downstream").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
|
||||||
this.measurements.type("flow").variant("predicted").position("atEquipment").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
|
this.measurements.type("flow").variant("predicted").position("atEquipment").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
|
||||||
//this.logger.debug(`Calculated flow: ${cFlow} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
|
|
||||||
return cFlow;
|
return cFlow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,10 +992,9 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
//this.predictPower.currentX = x; Decrepated
|
const rawPower = this.predictPower.y(x);
|
||||||
const cPower = this.predictPower.y(x);
|
const cPower = (x <= 0) ? 0 : Math.max(0, rawPower);
|
||||||
this.measurements.type("power").variant("predicted").position('atEquipment').value(cPower, Date.now(), this.unitPolicy.canonical.power);
|
this.measurements.type("power").variant("predicted").position('atEquipment').value(cPower, Date.now(), this.unitPolicy.canonical.power);
|
||||||
//this.logger.debug(`Calculated power: ${cPower} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
|
|
||||||
return cPower;
|
return cPower;
|
||||||
}
|
}
|
||||||
// If no curve data is available, log a warning and return 0
|
// If no curve data is available, log a warning and return 0
|
||||||
@@ -972,7 +1042,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
// returns the best available pressure measurement to use in the prediction calculation
|
// returns the best available pressure measurement to use in the prediction calculation
|
||||||
// this will be either the differential pressure, downstream or upstream pressure
|
// this will be either the differential pressure, downstream or upstream pressure
|
||||||
getMeasuredPressure() {
|
getMeasuredPressure() {
|
||||||
if(this.hasCurve === false){
|
if(!this.hasCurve || !this.predictFlow || !this.predictPower || !this.predictCtrl){
|
||||||
this.logger.error(`No valid curve available to calculate prediction using last known pressure`);
|
this.logger.error(`No valid curve available to calculate prediction using last known pressure`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -999,7 +1069,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
// Only downstream => use it, warn that it's partial
|
// Only downstream => use it, warn that it's partial
|
||||||
if (downstreamPressure != null) {
|
if (downstreamPressure != null) {
|
||||||
this.logger.warn(`Using downstream pressure only for prediction: ${downstreamPressure} This is less acurate!!`);
|
this.logger.warn(`Using downstream pressure only for prediction: ${downstreamPressure}. Prediction accuracy is degraded; inject upstream pressure too.`);
|
||||||
this.predictFlow.fDimension = downstreamPressure;
|
this.predictFlow.fDimension = downstreamPressure;
|
||||||
this.predictPower.fDimension = downstreamPressure;
|
this.predictPower.fDimension = downstreamPressure;
|
||||||
this.predictCtrl.fDimension = downstreamPressure;
|
this.predictCtrl.fDimension = downstreamPressure;
|
||||||
@@ -1014,7 +1084,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
// Only upstream => use it, warn that it's partial
|
// Only upstream => use it, warn that it's partial
|
||||||
if (upstreamPressure != null) {
|
if (upstreamPressure != null) {
|
||||||
this.logger.warn(`Using upstream pressure only for prediction: ${upstreamPressure} This is less acurate!!`);
|
this.logger.warn(`Using upstream pressure only for prediction: ${upstreamPressure}. Prediction accuracy is degraded; inject downstream pressure too.`);
|
||||||
this.predictFlow.fDimension = upstreamPressure;
|
this.predictFlow.fDimension = upstreamPressure;
|
||||||
this.predictPower.fDimension = upstreamPressure;
|
this.predictPower.fDimension = upstreamPressure;
|
||||||
this.predictCtrl.fDimension = upstreamPressure;
|
this.predictCtrl.fDimension = upstreamPressure;
|
||||||
@@ -1321,13 +1391,33 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
calcRelativeDistanceFromPeak(currentEfficiency,maxEfficiency,minEfficiency){
|
calcRelativeDistanceFromPeak(currentEfficiency,maxEfficiency,minEfficiency){
|
||||||
let distance = 1;
|
let distance = 1;
|
||||||
if(currentEfficiency != null){
|
if(currentEfficiency != null && maxEfficiency !== minEfficiency){
|
||||||
distance = this.interpolation.interpolate_lin_single_point(currentEfficiency,maxEfficiency, minEfficiency, 0, 1);
|
distance = this.interpolation.interpolate_lin_single_point(currentEfficiency,maxEfficiency, minEfficiency, 0, 1);
|
||||||
}
|
}
|
||||||
return distance;
|
return distance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showCoG() {
|
||||||
|
if (!this.hasCurve) {
|
||||||
|
return { error: 'No curve data available', cog: 0, NCog: 0, cogIndex: 0 };
|
||||||
|
}
|
||||||
|
const { cog, cogIndex, NCog, minEfficiency } = this.calcCog();
|
||||||
|
return {
|
||||||
|
cog,
|
||||||
|
cogIndex,
|
||||||
|
NCog,
|
||||||
|
NCogPercent: Math.round(NCog * 100 * 100) / 100,
|
||||||
|
minEfficiency,
|
||||||
|
currentEfficiencyCurve: this.currentEfficiencyCurve,
|
||||||
|
absDistFromPeak: this.absDistFromPeak,
|
||||||
|
relDistFromPeak: this.relDistFromPeak,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
showWorkingCurves() {
|
showWorkingCurves() {
|
||||||
|
if (!this.hasCurve) {
|
||||||
|
return { error: 'No curve data available' };
|
||||||
|
}
|
||||||
// Show the current curves for debugging
|
// Show the current curves for debugging
|
||||||
const { powerCurve, flowCurve } = this.getCurrentCurves();
|
const { powerCurve, flowCurve } = this.getCurrentCurves();
|
||||||
return {
|
return {
|
||||||
@@ -1345,6 +1435,9 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
// Calculate the center of gravity for current pressure
|
// Calculate the center of gravity for current pressure
|
||||||
calcCog() {
|
calcCog() {
|
||||||
|
if (!this.hasCurve || !this.predictFlow || !this.predictPower) {
|
||||||
|
return { cog: 0, cogIndex: 0, NCog: 0, minEfficiency: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
//fetch current curve data for power and flow
|
//fetch current curve data for power and flow
|
||||||
const { powerCurve, flowCurve } = this.getCurrentCurves();
|
const { powerCurve, flowCurve } = this.getCurrentCurves();
|
||||||
@@ -1370,24 +1463,32 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
const efficiencyCurve = [];
|
const efficiencyCurve = [];
|
||||||
let peak = 0;
|
let peak = 0;
|
||||||
let peakIndex = 0;
|
let peakIndex = 0;
|
||||||
let minEfficiency = 0;
|
let minEfficiency = Infinity;
|
||||||
|
|
||||||
// Calculate efficiency curve based on power and flow curves
|
if (!powerCurve?.y?.length || !flowCurve?.y?.length) {
|
||||||
|
return { efficiencyCurve: [], peak: 0, peakIndex: 0, minEfficiency: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific flow ratio (Q/P): for variable-speed centrifugal pumps this is
|
||||||
|
// monotonically decreasing (P scales ~Q³ by affinity laws), so the peak is
|
||||||
|
// always at minimum flow and NCog = 0. The MGC BEP-Gravitation algorithm
|
||||||
|
// compensates via slope-based redistribution which IS sensitive to curve shape.
|
||||||
powerCurve.y.forEach((power, index) => {
|
powerCurve.y.forEach((power, index) => {
|
||||||
|
|
||||||
// Get flow for the current power
|
|
||||||
const flow = flowCurve.y[index];
|
const flow = flowCurve.y[index];
|
||||||
|
const eff = (power > 0 && flow >= 0) ? flow / power : 0;
|
||||||
|
efficiencyCurve.push(eff);
|
||||||
|
|
||||||
// higher efficiency is better
|
if (eff > peak) {
|
||||||
efficiencyCurve.push( Math.round( ( flow / power ) * 100 ) / 100);
|
peak = eff;
|
||||||
|
peakIndex = index;
|
||||||
// Keep track of peak efficiency
|
}
|
||||||
peak = Math.max(peak, efficiencyCurve[index]);
|
if (eff < minEfficiency) {
|
||||||
peakIndex = peak == efficiencyCurve[index] ? index : peakIndex;
|
minEfficiency = eff;
|
||||||
minEfficiency = Math.min(...efficiencyCurve);
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!Number.isFinite(minEfficiency)) minEfficiency = 0;
|
||||||
|
|
||||||
return { efficiencyCurve, peak, peakIndex, minEfficiency };
|
return { efficiencyCurve, peak, peakIndex, minEfficiency };
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1424,11 +1525,11 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
|
|
||||||
this.logger.debug(`temp: ${temp} atmPressure : ${atmPressure} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`);
|
this.logger.debug(`temp: ${temp} atmPressure : ${atmPressure} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`);
|
||||||
const flowM3s = this.measurements.type('flow').variant('predicted').position('atEquipment').getCurrentValue('m3/s');
|
const flowM3s = this.measurements.type('flow').variant(variant).position('atEquipment').getCurrentValue('m3/s');
|
||||||
const powerWatt = this.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('W');
|
const powerWatt = this.measurements.type('power').variant(variant).position('atEquipment').getCurrentValue('W');
|
||||||
this.logger.debug(`Flow : ${flowM3s} power: ${powerWatt}`);
|
this.logger.debug(`Flow : ${flowM3s} power: ${powerWatt}`);
|
||||||
|
|
||||||
if (power != 0 && flow != 0) {
|
if (power > 0 && flow > 0) {
|
||||||
const specificFlow = flow / power;
|
const specificFlow = flow / power;
|
||||||
const specificEnergyConsumption = power / flow;
|
const specificEnergyConsumption = power / flow;
|
||||||
|
|
||||||
@@ -1470,18 +1571,31 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
this.config = this.configUtils.updateConfig(this.config, newConfig);
|
this.config = this.configUtils.updateConfig(this.config, newConfig);
|
||||||
|
|
||||||
//After we passed validation load the curves into their predictors
|
//After we passed validation load the curves into their predictors
|
||||||
|
if (!this.predictFlow || !this.predictPower || !this.predictCtrl) {
|
||||||
|
this.predictFlow = new predict({ curve: this.config.asset.machineCurve.nq });
|
||||||
|
this.predictPower = new predict({ curve: this.config.asset.machineCurve.np });
|
||||||
|
this.predictCtrl = new predict({ curve: this.reverseCurve(this.config.asset.machineCurve.nq) });
|
||||||
|
this.hasCurve = true;
|
||||||
|
} else {
|
||||||
this.predictFlow.updateCurve(this.config.asset.machineCurve.nq);
|
this.predictFlow.updateCurve(this.config.asset.machineCurve.nq);
|
||||||
this.predictPower.updateCurve(this.config.asset.machineCurve.np);
|
this.predictPower.updateCurve(this.config.asset.machineCurve.np);
|
||||||
this.predictCtrl.updateCurve(this.reverseCurve(this.config.asset.machineCurve.nq));
|
this.predictCtrl.updateCurve(this.reverseCurve(this.config.asset.machineCurve.nq));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getCompleteCurve() {
|
getCompleteCurve() {
|
||||||
|
if (!this.hasCurve || !this.predictPower || !this.predictFlow) {
|
||||||
|
return { powerCurve: null, flowCurve: null };
|
||||||
|
}
|
||||||
const powerCurve = this.predictPower.inputCurveData;
|
const powerCurve = this.predictPower.inputCurveData;
|
||||||
const flowCurve = this.predictFlow.inputCurveData;
|
const flowCurve = this.predictFlow.inputCurveData;
|
||||||
return { powerCurve, flowCurve };
|
return { powerCurve, flowCurve };
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentCurves() {
|
getCurrentCurves() {
|
||||||
|
if (!this.hasCurve || !this.predictPower || !this.predictFlow) {
|
||||||
|
return { powerCurve: { x: [], y: [] }, flowCurve: { x: [], y: [] } };
|
||||||
|
}
|
||||||
const powerCurve = this.predictPower.currentFxyCurve[this.predictPower.currentF];
|
const powerCurve = this.predictPower.currentFxyCurve[this.predictPower.currentF];
|
||||||
const flowCurve = this.predictFlow.currentFxyCurve[this.predictFlow.currentF];
|
const flowCurve = this.predictFlow.currentFxyCurve[this.predictFlow.currentF];
|
||||||
|
|
||||||
|
|||||||
48
test/e2e/README.md
Normal file
48
test/e2e/README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# rotatingMachine — End-to-End Benchmarks
|
||||||
|
|
||||||
|
These are live-deploy benchmarks, not unit tests. They require a running Docker-hosted Node-RED with the EVOLV package mounted, and they drive the node through its real runtime: admin-API deploy, debug websocket capture, inject-triggered commands, 1-second tick loop.
|
||||||
|
|
||||||
|
Unit tests live in `../basic/`, `../integration/`, `../edge/`. Run those with `npm test`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/d/gitea/EVOLV
|
||||||
|
docker compose up -d nodered influxdb
|
||||||
|
# wait for http://localhost:1880/nodes to return 200
|
||||||
|
pip install --user --break-system-packages websocket-client requests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benchmarks
|
||||||
|
|
||||||
|
### `curve-prediction-benchmark.py`
|
||||||
|
|
||||||
|
Deploys one rotatingMachine per shipped pump curve (`hidrostal-H05K-S03R`, `hidrostal-C5-D03R-SHN1`) and runs a per-pump (pressure × ctrl) sweep. For each pump the sweep covers its own low / mid / high pressure slices with controller setpoints of 20 / 40 / 60 / 80 %.
|
||||||
|
|
||||||
|
Reports:
|
||||||
|
|
||||||
|
- Count of samples inside the curve envelope ("good") vs out-of-range ("bad").
|
||||||
|
- Monotonicity of flow across the ctrl sweep at fixed pressure.
|
||||||
|
- Full sample table with state, ctrl, flow, power, NCog, cog.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 nodes/rotatingMachine/test/e2e/curve-prediction-benchmark.py
|
||||||
|
cat /tmp/rm_curve_bench.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Expected output (green run, 2026-04-13)
|
||||||
|
|
||||||
|
| Pump | Samples | Flow range | Power range | Pressures | Envelope OK | Monotonic |
|
||||||
|
|-------|---------|-----------:|------------:|----------:|:-----------:|:---------:|
|
||||||
|
| H05K | 12 | 10.3–208.3 m³/h | 12.3–50.3 kW | 700–3900 mbar | ✅ | ✅ |
|
||||||
|
| C5 | 12 | 8.7–45.6 m³/h | 0.69–13.0 kW | 400–2900 mbar | ✅ | ✅ |
|
||||||
|
|
||||||
|
#### Known limitation — out-of-envelope pressure extrapolation
|
||||||
|
|
||||||
|
Feeding a pressure **below** the curve's lowest slice produces extrapolated flow values that can exceed the envelope by orders of magnitude. Example: H05K at 400 mbar (curve min 700 mbar), ctrl=20% → flow ≈ 30 000 m³/h (envelope max 227 m³/h).
|
||||||
|
|
||||||
|
The node does not clamp pressure to the curve envelope; in production this is defended by upstream `measurement` nodes with realistic ranges. Operators deploying a machine should confirm the sensor range matches the curve.
|
||||||
|
|
||||||
|
### `../../../../memory/` companion benchmarks
|
||||||
|
|
||||||
|
The earlier shutdown, interruptibility, and clean-path benchmarks (`rm_e2e_benchmark.py`, `rm_clean.py`, `rm_e2e_verify.py`) live in `/tmp/` during a review session. Promote them into this directory when they need to become permanent smoke tests.
|
||||||
449
test/e2e/curve-prediction-benchmark.py
Normal file
449
test/e2e/curve-prediction-benchmark.py
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Dual-curve E2E prediction benchmark for rotatingMachine.
|
||||||
|
|
||||||
|
Deploys a Node-RED flow containing TWO rotatingMachine nodes, one per pump
|
||||||
|
curve shipped in generalFunctions/datasets/assetData/curves/. For each curve
|
||||||
|
we run a controlled ctrl x pressure sweep and record the predicted flow and
|
||||||
|
power, plus the efficiency / CoG metrics. Output is a table the team can
|
||||||
|
compare against supplier data sheets.
|
||||||
|
|
||||||
|
This is a live-deploy benchmark (not a unit test) — it exercises the full
|
||||||
|
Node-RED runtime path including delta compression on port 0, curve loading
|
||||||
|
via generalFunctions, and output formatting.
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import websocket
|
||||||
|
|
||||||
|
BASE = "http://localhost:1880"
|
||||||
|
WS = "ws://localhost:1880/comms"
|
||||||
|
CURVES_DIR = "/mnt/d/gitea/EVOLV/nodes/generalFunctions/datasets/assetData/curves"
|
||||||
|
|
||||||
|
PUMPS = [
|
||||||
|
{
|
||||||
|
"id": "H05K",
|
||||||
|
"model": "hidrostal-H05K-S03R",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "C5",
|
||||||
|
"model": "hidrostal-C5-D03R-SHN1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
events = []
|
||||||
|
start = None
|
||||||
|
lock = threading.Lock()
|
||||||
|
ready = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def on_message(ws, msg):
|
||||||
|
try:
|
||||||
|
data = json.loads(msg)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
for item in (data if isinstance(data, list) else [data]):
|
||||||
|
if str(item.get("topic", "")).startswith("debug"):
|
||||||
|
d = item.get("data", {}) or {}
|
||||||
|
with lock:
|
||||||
|
events.append({
|
||||||
|
"t": round(time.time() - start, 3),
|
||||||
|
"name": d.get("name"),
|
||||||
|
"msg": d.get("msg"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def on_open(ws):
|
||||||
|
ws.send(json.dumps({"subscribe": "debug"}))
|
||||||
|
ready.set()
|
||||||
|
|
||||||
|
|
||||||
|
def ws_thread():
|
||||||
|
websocket.WebSocketApp(WS, on_message=on_message, on_open=on_open).run_forever()
|
||||||
|
|
||||||
|
|
||||||
|
def deploy(flow):
|
||||||
|
r = requests.post(
|
||||||
|
f"{BASE}/flows",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Node-RED-Deployment-Type": "full",
|
||||||
|
},
|
||||||
|
data=json.dumps(flow),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.text
|
||||||
|
|
||||||
|
|
||||||
|
def inject(node_id):
|
||||||
|
r = requests.post(f"{BASE}/inject/{node_id}", timeout=5)
|
||||||
|
return r.status_code
|
||||||
|
|
||||||
|
|
||||||
|
def port0(node_tag):
|
||||||
|
"""Return the most recent parsed port-0 payload for a given pump tag."""
|
||||||
|
debug_name = f"P0-{node_tag}"
|
||||||
|
with lock:
|
||||||
|
for e in reversed(events):
|
||||||
|
if e["name"] == debug_name:
|
||||||
|
try:
|
||||||
|
return json.loads(e["msg"])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def curve_envelope(model):
|
||||||
|
d = json.load(open(os.path.join(CURVES_DIR, f"{model}.json")))
|
||||||
|
pressures = sorted(int(k) for k in d["nq"].keys() if re.fullmatch(r"-?\d+", k))
|
||||||
|
flow_vals = [v for p in pressures for v in d["nq"][str(p)]["y"]]
|
||||||
|
power_vals = [v for p in pressures for v in d["np"][str(p)]["y"]]
|
||||||
|
return {
|
||||||
|
"pressures": pressures,
|
||||||
|
"p_low": pressures[0],
|
||||||
|
"p_mid": pressures[len(pressures) // 2],
|
||||||
|
"p_high": pressures[-1],
|
||||||
|
"flow_range": (min(flow_vals), max(flow_vals)),
|
||||||
|
"power_range": (min(power_vals), max(power_vals)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_flow():
|
||||||
|
"""Construct a Node-RED flow with one tab holding both pumps + injects + function nodes."""
|
||||||
|
flow = [{"id": "curve_bench_tab", "type": "tab", "label": "Curve Benchmark", "disabled": False}]
|
||||||
|
|
||||||
|
# Generate an id-pool for injects and function nodes
|
||||||
|
def nid(prefix, i=0):
|
||||||
|
return f"{prefix}-{i}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
for pump in PUMPS:
|
||||||
|
pid = pump["id"]
|
||||||
|
tab = "curve_bench_tab"
|
||||||
|
|
||||||
|
# rotatingMachine node
|
||||||
|
rm_id = f"rm_{pid}"
|
||||||
|
flow.append({
|
||||||
|
"id": rm_id,
|
||||||
|
"type": "rotatingMachine",
|
||||||
|
"z": tab,
|
||||||
|
"name": f"Pump-{pid}",
|
||||||
|
"speed": "50", # fast ramp for benchmark
|
||||||
|
"startup": "0",
|
||||||
|
"warmup": "0",
|
||||||
|
"shutdown": "0",
|
||||||
|
"cooldown": "0",
|
||||||
|
"movementMode": "staticspeed",
|
||||||
|
"machineCurve": "",
|
||||||
|
"uuid": f"bench-{pid}",
|
||||||
|
"supplier": "hidrostal",
|
||||||
|
"category": "pump",
|
||||||
|
"assetType": "pump-centrifugal",
|
||||||
|
"model": pump["model"],
|
||||||
|
"unit": "m3/h",
|
||||||
|
"curvePressureUnit": "mbar",
|
||||||
|
"curveFlowUnit": "m3/h",
|
||||||
|
"curvePowerUnit": "kW",
|
||||||
|
"curveControlUnit": "%",
|
||||||
|
"enableLog": False,
|
||||||
|
"logLevel": "error",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": False,
|
||||||
|
"distance": 0,
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"distanceDescription": "",
|
||||||
|
"x": 500, "y": 100 + PUMPS.index(pump) * 400,
|
||||||
|
"wires": [[f"fmt_{pid}"], [], []],
|
||||||
|
})
|
||||||
|
|
||||||
|
# function node to merge deltas
|
||||||
|
fmt_id = f"fmt_{pid}"
|
||||||
|
flow.append({
|
||||||
|
"id": fmt_id,
|
||||||
|
"type": "function",
|
||||||
|
"z": tab,
|
||||||
|
"name": f"merge-{pid}",
|
||||||
|
"func": (
|
||||||
|
"const p = msg.payload || {};\n"
|
||||||
|
"const c = context.get('c') || {};\n"
|
||||||
|
"Object.assign(c, p);\n"
|
||||||
|
"context.set('c', c);\n"
|
||||||
|
"function find(prefix) {\n"
|
||||||
|
" for (var k in c) if (k.indexOf(prefix) === 0) return c[k];\n"
|
||||||
|
" return null;\n"
|
||||||
|
"}\n"
|
||||||
|
"msg.payload = {\n"
|
||||||
|
" state: c.state || 'idle',\n"
|
||||||
|
" mode: c.mode || 'auto',\n"
|
||||||
|
" ctrl: c.ctrl != null ? Number(c.ctrl) : null,\n"
|
||||||
|
" flow: find('flow.predicted.downstream.'),\n"
|
||||||
|
" power: find('power.predicted.atequipment.'),\n"
|
||||||
|
" NCog: c.NCog != null ? Number(c.NCog) : null,\n"
|
||||||
|
" cog: c.cog != null ? Number(c.cog) : null,\n"
|
||||||
|
" pU: find('pressure.measured.upstream.'),\n"
|
||||||
|
" pD: find('pressure.measured.downstream.')\n"
|
||||||
|
"};\n"
|
||||||
|
"return msg;"
|
||||||
|
),
|
||||||
|
"outputs": 1,
|
||||||
|
"x": 760, "y": 100 + PUMPS.index(pump) * 400,
|
||||||
|
"wires": [[f"dbg_{pid}"]],
|
||||||
|
})
|
||||||
|
|
||||||
|
# debug node
|
||||||
|
flow.append({
|
||||||
|
"id": f"dbg_{pid}",
|
||||||
|
"type": "debug",
|
||||||
|
"z": tab,
|
||||||
|
"name": f"P0-{pid}",
|
||||||
|
"active": True, "tosidebar": True, "console": False, "tostatus": False,
|
||||||
|
"complete": "payload", "targetType": "msg",
|
||||||
|
"x": 1000, "y": 100 + PUMPS.index(pump) * 400,
|
||||||
|
"wires": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# injects
|
||||||
|
def mk_inject(name, topic, payload, y_offset):
|
||||||
|
return {
|
||||||
|
"id": f"inj_{pid}_{name.replace(' ', '_')}",
|
||||||
|
"type": "inject",
|
||||||
|
"z": tab,
|
||||||
|
"name": name,
|
||||||
|
"props": [
|
||||||
|
{"p": "topic", "vt": "str"},
|
||||||
|
{"p": "payload"},
|
||||||
|
],
|
||||||
|
"topic": topic,
|
||||||
|
"payload": payload,
|
||||||
|
"payloadType": "json",
|
||||||
|
"repeat": "", "crontab": "", "once": False, "onceDelay": "",
|
||||||
|
"x": 200, "y": y_offset,
|
||||||
|
"wires": [[rm_id]],
|
||||||
|
}
|
||||||
|
|
||||||
|
base_y = 100 + PUMPS.index(pump) * 400
|
||||||
|
flow.append({
|
||||||
|
**mk_inject("setMode-virtual", "setMode", "\"virtualControl\"", base_y + 40),
|
||||||
|
"payloadType": "str",
|
||||||
|
"payload": "virtualControl",
|
||||||
|
})
|
||||||
|
flow.append(mk_inject(
|
||||||
|
"Startup", "execSequence",
|
||||||
|
json.dumps({"source": "GUI", "action": "execSequence", "parameter": "startup"}),
|
||||||
|
base_y + 80,
|
||||||
|
))
|
||||||
|
|
||||||
|
return flow
|
||||||
|
|
||||||
|
|
||||||
|
def run_sweep(pump_id, model, envelope):
|
||||||
|
"""For one pump, sweep (pressure, ctrl) and collect predictions."""
|
||||||
|
results = []
|
||||||
|
# Use 3 pressures (low/mid/high) and 4 ctrl levels
|
||||||
|
pressures = [envelope["p_low"], envelope["p_mid"], envelope["p_high"]]
|
||||||
|
ctrls = [20, 40, 60, 80]
|
||||||
|
|
||||||
|
for p in pressures:
|
||||||
|
# Inject pressures via the simulateMeasurement topic -- we'll do this
|
||||||
|
# via the Node-RED admin API using a raw msg injection helper: send
|
||||||
|
# via a synthetic inject. Easiest: create ephemeral inject? Simpler:
|
||||||
|
# just POST directly to the node using the admin API is not possible
|
||||||
|
# without a pre-wired inject. Instead we call the node via websocket
|
||||||
|
# notify? Simpler: deploy a pair of dedicated 'sim' injects per pump.
|
||||||
|
# But we want a dynamic sweep. Workaround: use the Node-RED http-in?
|
||||||
|
# Best path: spawn a temporary inject at deploy time. Not trivial.
|
||||||
|
#
|
||||||
|
# Alternative that works with the deployed flow: post a message by
|
||||||
|
# using the /inject admin endpoint with an inject node whose payload
|
||||||
|
# we rewrite via PUT /flow. Simplest in practice: keep the flow
|
||||||
|
# static but use the programmable approach: send msg via socket.
|
||||||
|
# Here we'll just use 3 simulate injects per pump (low/mid/high).
|
||||||
|
# Since we haven't built those, we fall back to modifying the flow
|
||||||
|
# dynamically for each pressure.
|
||||||
|
pass # <-- replaced below with alt strategy
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def build_sweep_flow(pressure):
|
||||||
|
"""Build a flow where pressures for both pumps are pinned to `pressure`."""
|
||||||
|
flow = build_flow()
|
||||||
|
for pump in PUMPS:
|
||||||
|
pid = pump["id"]
|
||||||
|
rm_id = f"rm_{pid}"
|
||||||
|
tab = "curve_bench_tab"
|
||||||
|
base_y = 100 + PUMPS.index(pump) * 400
|
||||||
|
|
||||||
|
def inj(name, topic, payload_json, y):
|
||||||
|
return {
|
||||||
|
"id": f"sim_{pid}_{name}",
|
||||||
|
"type": "inject",
|
||||||
|
"z": tab,
|
||||||
|
"name": name,
|
||||||
|
"props": [{"p": "topic", "vt": "str"}, {"p": "payload"}],
|
||||||
|
"topic": topic,
|
||||||
|
"payload": payload_json,
|
||||||
|
"payloadType": "json",
|
||||||
|
"repeat": "", "crontab": "", "once": True, "onceDelay": "1",
|
||||||
|
"x": 200, "y": y,
|
||||||
|
"wires": [[rm_id]],
|
||||||
|
}
|
||||||
|
|
||||||
|
flow.append(inj(
|
||||||
|
"sim-pU", "simulateMeasurement",
|
||||||
|
json.dumps({"type": "pressure", "position": "upstream", "value": 0, "unit": "mbar"}),
|
||||||
|
base_y + 160,
|
||||||
|
))
|
||||||
|
flow.append(inj(
|
||||||
|
"sim-pD", "simulateMeasurement",
|
||||||
|
json.dumps({"type": "pressure", "position": "downstream", "value": pressure, "unit": "mbar"}),
|
||||||
|
base_y + 200,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Setpoint injects (20/40/60/80)
|
||||||
|
for k, val in enumerate([20, 40, 60, 80]):
|
||||||
|
flow.append({
|
||||||
|
"id": f"mv_{pid}_{val}",
|
||||||
|
"type": "inject",
|
||||||
|
"z": tab,
|
||||||
|
"name": f"Set {val}%",
|
||||||
|
"props": [{"p": "topic", "vt": "str"}, {"p": "payload"}],
|
||||||
|
"topic": "execMovement",
|
||||||
|
"payload": json.dumps({"source": "GUI", "action": "execMovement", "setpoint": val}),
|
||||||
|
"payloadType": "json",
|
||||||
|
"repeat": "", "crontab": "", "once": False, "onceDelay": "",
|
||||||
|
"x": 200, "y": base_y + 240 + k * 40,
|
||||||
|
"wires": [[rm_id]],
|
||||||
|
})
|
||||||
|
|
||||||
|
return flow
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global start
|
||||||
|
start = time.time()
|
||||||
|
threading.Thread(target=ws_thread, daemon=True).start()
|
||||||
|
ready.wait(5)
|
||||||
|
|
||||||
|
results_by_pump = {p["id"]: {"model": p["model"], "envelope": curve_envelope(p["model"]), "sweeps": []} for p in PUMPS}
|
||||||
|
|
||||||
|
# Per-pump pressure plan: each pump sees only pressures inside its own
|
||||||
|
# curve envelope. Out-of-range extrapolation is a known limitation
|
||||||
|
# (see rm memory / known-issues) and is tested separately below.
|
||||||
|
pressure_plan = []
|
||||||
|
seen = set()
|
||||||
|
for p in PUMPS:
|
||||||
|
env = results_by_pump[p["id"]]["envelope"]
|
||||||
|
for label, val in (("low", env["p_low"]), ("mid", env["p_mid"]), ("high", env["p_high"])):
|
||||||
|
key = (p["id"], val)
|
||||||
|
if key not in seen:
|
||||||
|
pressure_plan.append({"pump_id": p["id"], "pressure": val, "label": label})
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
# Group by pressure so both pumps share a sweep when pressures overlap.
|
||||||
|
pressures = sorted({row["pressure"] for row in pressure_plan})
|
||||||
|
pump_allowed_at = {p: [row["pump_id"] for row in pressure_plan if row["pressure"] == p] for p in pressures}
|
||||||
|
|
||||||
|
for pressure in pressures:
|
||||||
|
allowed = pump_allowed_at[pressure]
|
||||||
|
flow = build_sweep_flow(pressure)
|
||||||
|
print(f"\n=== Deploying sweep at pressure={pressure} mbar (pumps in range: {allowed}) ===")
|
||||||
|
with lock:
|
||||||
|
events.clear()
|
||||||
|
deploy(flow)
|
||||||
|
# allow pumps to register and reach operational
|
||||||
|
time.sleep(4)
|
||||||
|
# startup both pumps
|
||||||
|
for pump in PUMPS:
|
||||||
|
pid = pump["id"]
|
||||||
|
inject(f"inj_{pid}_setMode-virtual")
|
||||||
|
time.sleep(0.2)
|
||||||
|
inject(f"inj_{pid}_Startup")
|
||||||
|
time.sleep(3) # reach operational (startup=0, warmup=0 -> immediate)
|
||||||
|
|
||||||
|
# pressure injects were set to once=True so they fire on deploy. Wait.
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
for val in [20, 40, 60, 80]:
|
||||||
|
for pump in PUMPS:
|
||||||
|
if pump["id"] not in allowed:
|
||||||
|
continue
|
||||||
|
inject(f"mv_{pump['id']}_{val}")
|
||||||
|
# ramp takes (val)/(speed=50) = val/50 s; plus a safety tick
|
||||||
|
time.sleep(max(2.5, val / 50 + 1.5))
|
||||||
|
for pump in PUMPS:
|
||||||
|
if pump["id"] not in allowed:
|
||||||
|
continue
|
||||||
|
pid = pump["id"]
|
||||||
|
data = port0(pid)
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
entry = {
|
||||||
|
"pressure": pressure,
|
||||||
|
"setpoint": val,
|
||||||
|
"state": data.get("state"),
|
||||||
|
"ctrl": data.get("ctrl"),
|
||||||
|
"flow": data.get("flow"),
|
||||||
|
"power": data.get("power"),
|
||||||
|
"NCog": data.get("NCog"),
|
||||||
|
"cog": data.get("cog"),
|
||||||
|
}
|
||||||
|
results_by_pump[pump["id"]]["sweeps"].append(entry)
|
||||||
|
print(f" [{pump['id']}] p={pressure} setpoint={val} ctrl={entry['ctrl']} flow={entry['flow']} power={entry['power']} NCog={entry['NCog']}")
|
||||||
|
|
||||||
|
# Envelope sanity check
|
||||||
|
print("\n======== SUMMARY ========")
|
||||||
|
out = {}
|
||||||
|
for pid, info in results_by_pump.items():
|
||||||
|
env = info["envelope"]
|
||||||
|
good = 0; bad = 0; notes = []
|
||||||
|
prior_flow_by_p = {}
|
||||||
|
for row in info["sweeps"]:
|
||||||
|
if row["flow"] is None or row["power"] is None:
|
||||||
|
bad += 1; continue
|
||||||
|
if row["flow"] < -1:
|
||||||
|
bad += 1; notes.append(f"negative flow: {row}")
|
||||||
|
elif row["power"] < -1:
|
||||||
|
bad += 1; notes.append(f"negative power: {row}")
|
||||||
|
elif row["flow"] > env["flow_range"][1] * 2:
|
||||||
|
bad += 1; notes.append(f"flow above envelope {env['flow_range'][1]}: {row}")
|
||||||
|
else:
|
||||||
|
good += 1
|
||||||
|
# monotonicity in ctrl at fixed pressure
|
||||||
|
by_p = {}
|
||||||
|
for row in info["sweeps"]:
|
||||||
|
by_p.setdefault(row["pressure"], []).append(row)
|
||||||
|
mono_ok = True
|
||||||
|
for p, rows in by_p.items():
|
||||||
|
rows.sort(key=lambda r: r["setpoint"])
|
||||||
|
flows = [r["flow"] for r in rows if r["flow"] is not None]
|
||||||
|
for i in range(1, len(flows)):
|
||||||
|
if flows[i] < flows[i-1] * 0.95:
|
||||||
|
mono_ok = False
|
||||||
|
notes.append(f"flow drops at p={p}: {flows}")
|
||||||
|
break
|
||||||
|
print(f"\n[{pid}] model={info['model']}")
|
||||||
|
print(f" envelope flow {env['flow_range']} power {env['power_range']} pressures {env['p_low']}..{env['p_high']} mbar")
|
||||||
|
print(f" sweep samples: good={good} bad={bad}")
|
||||||
|
print(f" ctrl-monotonic: {mono_ok}")
|
||||||
|
if notes:
|
||||||
|
print(f" notes: {notes[:3]}")
|
||||||
|
out[pid] = {
|
||||||
|
"model": info["model"],
|
||||||
|
"envelope": env,
|
||||||
|
"samples": info["sweeps"],
|
||||||
|
"good": good, "bad": bad, "mono_ok": mono_ok,
|
||||||
|
}
|
||||||
|
json.dump(out, open("/tmp/rm_curve_bench.json", "w"), indent=2, default=str)
|
||||||
|
print("\nfull results -> /tmp/rm_curve_bench.json")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
63
test/edge/listener-cleanup.edge.test.js
Normal file
63
test/edge/listener-cleanup.edge.test.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig, makeChildMeasurement } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('childMeasurementListeners are cleared and state emitter cleaned on simulated close', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
// Register a child measurement — this adds listeners
|
||||||
|
const child = makeChildMeasurement({ id: 'pt-1', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
|
||||||
|
machine.registerChild(child, 'measurement');
|
||||||
|
|
||||||
|
assert.ok(machine.childMeasurementListeners.size > 0, 'Should have listeners after registration');
|
||||||
|
|
||||||
|
const stateEmitterListenerCount = machine.state.emitter.listenerCount('positionChange') +
|
||||||
|
machine.state.emitter.listenerCount('stateChange');
|
||||||
|
assert.ok(stateEmitterListenerCount > 0, 'State emitter should have listeners');
|
||||||
|
|
||||||
|
// Simulate the cleanup that nodeClass close handler does
|
||||||
|
for (const [, entry] of machine.childMeasurementListeners) {
|
||||||
|
if (typeof entry.emitter?.off === 'function') {
|
||||||
|
entry.emitter.off(entry.eventName, entry.handler);
|
||||||
|
} else if (typeof entry.emitter?.removeListener === 'function') {
|
||||||
|
entry.emitter.removeListener(entry.eventName, entry.handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
machine.childMeasurementListeners.clear();
|
||||||
|
machine.state.emitter.removeAllListeners();
|
||||||
|
|
||||||
|
assert.equal(machine.childMeasurementListeners.size, 0, 'Listeners map should be empty after cleanup');
|
||||||
|
assert.equal(machine.state.emitter.listenerCount('positionChange'), 0);
|
||||||
|
assert.equal(machine.state.emitter.listenerCount('stateChange'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-registration does not accumulate listeners', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
const child = makeChildMeasurement({ id: 'pt-1', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
|
||||||
|
|
||||||
|
// Register 3 times
|
||||||
|
machine.registerChild(child, 'measurement');
|
||||||
|
machine.registerChild(child, 'measurement');
|
||||||
|
machine.registerChild(child, 'measurement');
|
||||||
|
|
||||||
|
// Should only have 1 listener entry per child+event combo
|
||||||
|
const eventName = 'pressure.measured.downstream';
|
||||||
|
const listenerCount = child.measurements.emitter.listenerCount(eventName);
|
||||||
|
assert.equal(listenerCount, 1, `Should have exactly 1 listener, got ${listenerCount}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('virtual pressure children have their listeners managed', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
// Virtual children are created in constructor — verify listeners exist
|
||||||
|
const upstreamChild = machine.virtualPressureChildren.upstream;
|
||||||
|
const downstreamChild = machine.virtualPressureChildren.downstream;
|
||||||
|
|
||||||
|
assert.ok(upstreamChild, 'Upstream virtual child should exist');
|
||||||
|
assert.ok(downstreamChild, 'Downstream virtual child should exist');
|
||||||
|
assert.ok(upstreamChild.measurements, 'Upstream should have measurements container');
|
||||||
|
assert.ok(downstreamChild.measurements, 'Downstream should have measurements container');
|
||||||
|
});
|
||||||
132
test/edge/negative-zero-guards.edge.test.js
Normal file
132
test/edge/negative-zero-guards.edge.test.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('calcEfficiency with zero power and flow does not produce efficiency value', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
machine.measurements.type('pressure').variant('measured').position('downstream').value(1000, Date.now(), 'mbar');
|
||||||
|
machine.measurements.type('pressure').variant('measured').position('upstream').value(800, Date.now(), 'mbar');
|
||||||
|
machine.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), 'm3/h');
|
||||||
|
machine.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), 'kW');
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
assert.doesNotThrow(() => machine.calcEfficiency(0, 0, 'predicted'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calcEfficiency with negative power does not produce corrupt efficiency', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
machine.measurements.type('pressure').variant('measured').position('downstream').value(1000, Date.now(), 'mbar');
|
||||||
|
machine.measurements.type('pressure').variant('measured').position('upstream').value(800, Date.now(), 'mbar');
|
||||||
|
machine.measurements.type('flow').variant('predicted').position('atEquipment').value(100, Date.now(), 'm3/h');
|
||||||
|
machine.measurements.type('power').variant('predicted').position('atEquipment').value(-5, Date.now(), 'kW');
|
||||||
|
|
||||||
|
// Should not crash or produce negative efficiency
|
||||||
|
assert.doesNotThrow(() => machine.calcEfficiency(-5, 100, 'predicted'));
|
||||||
|
|
||||||
|
const eff = machine.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue();
|
||||||
|
// Efficiency should not have been updated with negative power (guard: power > 0)
|
||||||
|
assert.ok(eff === undefined || eff === null || eff >= 0, 'Efficiency should not be negative');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calcCog returns safe defaults when no curve data available', () => {
|
||||||
|
const machine = new Machine(
|
||||||
|
makeMachineConfig({ asset: { model: null } }),
|
||||||
|
makeStateConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = machine.calcCog();
|
||||||
|
|
||||||
|
assert.equal(result.cog, 0);
|
||||||
|
assert.equal(result.cogIndex, 0);
|
||||||
|
assert.equal(result.NCog, 0);
|
||||||
|
assert.equal(result.minEfficiency, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCurrentCurves returns empty arrays when no curve data available', () => {
|
||||||
|
const machine = new Machine(
|
||||||
|
makeMachineConfig({ asset: { model: null } }),
|
||||||
|
makeStateConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { powerCurve, flowCurve } = machine.getCurrentCurves();
|
||||||
|
|
||||||
|
assert.deepEqual(powerCurve, { x: [], y: [] });
|
||||||
|
assert.deepEqual(flowCurve, { x: [], y: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCompleteCurve returns null when no curve data available', () => {
|
||||||
|
const machine = new Machine(
|
||||||
|
makeMachineConfig({ asset: { model: null } }),
|
||||||
|
makeStateConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { powerCurve, flowCurve } = machine.getCompleteCurve();
|
||||||
|
|
||||||
|
assert.equal(powerCurve, null);
|
||||||
|
assert.equal(flowCurve, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calcFlow returns 0 when no curve data available', () => {
|
||||||
|
const machine = new Machine(
|
||||||
|
makeMachineConfig({ asset: { model: null } }),
|
||||||
|
makeStateConfig({ state: { current: 'operational' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const flow = machine.calcFlow(50);
|
||||||
|
assert.equal(flow, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calcPower returns 0 when no curve data available', () => {
|
||||||
|
const machine = new Machine(
|
||||||
|
makeMachineConfig({ asset: { model: null } }),
|
||||||
|
makeStateConfig({ state: { current: 'operational' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const power = machine.calcPower(50);
|
||||||
|
assert.equal(power, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inputFlowCalcPower returns 0 when no curve data available', () => {
|
||||||
|
const machine = new Machine(
|
||||||
|
makeMachineConfig({ asset: { model: null } }),
|
||||||
|
makeStateConfig({ state: { current: 'operational' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const power = machine.inputFlowCalcPower(100);
|
||||||
|
assert.equal(power, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getMeasuredPressure returns 0 when no curve data available', () => {
|
||||||
|
const machine = new Machine(
|
||||||
|
makeMachineConfig({ asset: { model: null } }),
|
||||||
|
makeStateConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
const pressure = machine.getMeasuredPressure();
|
||||||
|
assert.equal(pressure, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateCurve bootstraps predictors when they were null', () => {
|
||||||
|
const machine = new Machine(
|
||||||
|
makeMachineConfig({ asset: { model: null } }),
|
||||||
|
makeStateConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(machine.hasCurve, false);
|
||||||
|
assert.equal(machine.predictFlow, null);
|
||||||
|
|
||||||
|
// Load a real curve into a machine that started without one
|
||||||
|
const { loadCurve } = require('generalFunctions');
|
||||||
|
const realCurve = loadCurve('hidrostal-H05K-S03R');
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => machine.updateCurve(realCurve));
|
||||||
|
|
||||||
|
assert.equal(machine.hasCurve, true);
|
||||||
|
assert.ok(machine.predictFlow !== null);
|
||||||
|
assert.ok(machine.predictPower !== null);
|
||||||
|
assert.ok(machine.predictCtrl !== null);
|
||||||
|
});
|
||||||
121
test/edge/output-format.edge.test.js
Normal file
121
test/edge/output-format.edge.test.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('getOutput contains all required fields in idle state', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
|
||||||
|
// Core state fields
|
||||||
|
assert.equal(output.state, 'idle');
|
||||||
|
assert.ok('runtime' in output);
|
||||||
|
assert.ok('ctrl' in output);
|
||||||
|
assert.ok('moveTimeleft' in output);
|
||||||
|
assert.ok('mode' in output);
|
||||||
|
assert.ok('maintenanceTime' in output);
|
||||||
|
|
||||||
|
// Efficiency fields
|
||||||
|
assert.ok('cog' in output);
|
||||||
|
assert.ok('NCog' in output);
|
||||||
|
assert.ok('NCogPercent' in output);
|
||||||
|
assert.ok('effDistFromPeak' in output);
|
||||||
|
assert.ok('effRelDistFromPeak' in output);
|
||||||
|
|
||||||
|
// Prediction health fields
|
||||||
|
assert.ok('predictionQuality' in output);
|
||||||
|
assert.ok('predictionConfidence' in output);
|
||||||
|
assert.ok('predictionPressureSource' in output);
|
||||||
|
assert.ok('predictionFlags' in output);
|
||||||
|
|
||||||
|
// Pressure drift fields
|
||||||
|
assert.ok('pressureDriftLevel' in output);
|
||||||
|
assert.ok('pressureDriftSource' in output);
|
||||||
|
assert.ok('pressureDriftFlags' in output);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOutput flow drift fields appear after sufficient measured flow samples', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||||
|
await machine.handleInput('parent', 'execMovement', 50);
|
||||||
|
|
||||||
|
// Provide multiple measured flow samples to trigger valid drift assessment
|
||||||
|
const baseTime = Date.now();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
machine.updateMeasuredFlow(100 + i, 'downstream', {
|
||||||
|
timestamp: baseTime + (i * 1000),
|
||||||
|
unit: 'm3/h',
|
||||||
|
childId: 'flow-sensor',
|
||||||
|
childName: 'FT-1',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
|
||||||
|
// Drift fields should appear once enough samples provide a valid assessment
|
||||||
|
if ('flowNrmse' in output) {
|
||||||
|
assert.ok(typeof output.flowNrmse === 'number');
|
||||||
|
assert.ok('flowDriftValid' in output);
|
||||||
|
}
|
||||||
|
// At minimum, prediction health fields should always be present
|
||||||
|
assert.ok('predictionQuality' in output);
|
||||||
|
assert.ok('predictionConfidence' in output);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOutput prediction confidence is 0 in non-operational state', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
|
||||||
|
assert.equal(output.predictionConfidence, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOutput prediction confidence reflects differential pressure', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
// Differential pressure → high confidence
|
||||||
|
machine.updateMeasuredPressure(800, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
|
||||||
|
machine.updateMeasuredPressure(1200, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
|
||||||
|
assert.ok(output.predictionConfidence >= 0.8, `Confidence ${output.predictionConfidence} should be >= 0.8 with differential pressure`);
|
||||||
|
assert.equal(output.predictionPressureSource, 'differential');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOutput values are in configured output units not canonical', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||||
|
machine.updatePosition();
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
|
||||||
|
// Flow keys should contain values in m3/h (configured), not m3/s (canonical)
|
||||||
|
// Predicted flow at minimum pressure should be in a reasonable m3/h range, not ~0.003 m3/s
|
||||||
|
const flowKey = Object.keys(output).find(k => k.startsWith('flow.predicted.downstream'));
|
||||||
|
if (flowKey) {
|
||||||
|
const flowVal = output[flowKey];
|
||||||
|
assert.ok(typeof flowVal === 'number', 'Flow output should be a number');
|
||||||
|
// m3/h values are typically 0-300, m3/s values are 0-0.08
|
||||||
|
// If in canonical units it would be very small
|
||||||
|
if (flowVal > 0) {
|
||||||
|
assert.ok(flowVal > 0.1, `Flow value ${flowVal} looks like canonical m3/s, should be m3/h`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOutput NCogPercent is correctly derived from NCog', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||||
|
machine.updatePosition();
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
const expected = Math.round(output.NCog * 100 * 100) / 100;
|
||||||
|
assert.equal(output.NCogPercent, expected, 'NCogPercent should be NCog * 100, rounded to 2 decimals');
|
||||||
|
});
|
||||||
@@ -48,7 +48,12 @@ test('predictions use initialized medium pressure and not the minimum-pressure f
|
|||||||
assert.equal(pressureStatus.initialized, true);
|
assert.equal(pressureStatus.initialized, true);
|
||||||
assert.equal(pressureStatus.hasDifferential, true);
|
assert.equal(pressureStatus.hasDifferential, true);
|
||||||
|
|
||||||
const expectedDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa canonical
|
const rawDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa = 40000
|
||||||
assert.equal(Math.round(machine.predictFlow.fDimension), expectedDiff);
|
// fDimension is clamped to [fValues.min, fValues.max]. The H05K curve's
|
||||||
|
// minimum pressure slice is 70000 Pa (700 mbar). A 40000 Pa differential
|
||||||
|
// is below the curve minimum, so it gets clamped to 70000.
|
||||||
|
const curveMinPressure = 70000;
|
||||||
|
const expected = Math.max(rawDiff, curveMinPressure);
|
||||||
|
assert.equal(Math.round(machine.predictFlow.fDimension), expected);
|
||||||
assert.ok(machine.predictFlow.fDimension > 0);
|
assert.ok(machine.predictFlow.fDimension > 0);
|
||||||
});
|
});
|
||||||
|
|||||||
180
test/integration/curve-prediction.integration.test.js
Normal file
180
test/integration/curve-prediction.integration.test.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
const { loadCurve } = require('generalFunctions');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prediction benchmarks across all rotatingMachine curves currently shipped
|
||||||
|
* with generalFunctions. This guards the curve-backed prediction path against
|
||||||
|
* regressions in the loader, the reverse-nq inversion, and the pressure
|
||||||
|
* slicing logic — across machines of very different sizes.
|
||||||
|
*
|
||||||
|
* Ranges are derived from the curve data itself (loaded at test time) plus
|
||||||
|
* physical sanity properties (monotonicity in ctrl, inverse-monotonicity in
|
||||||
|
* pressure for flow, non-negative power, curve-backed CoG non-zero).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Curves the node is expected to support. Add new entries here as soon as a
|
||||||
|
// new curve file lands in generalFunctions/datasets/assetData/curves/.
|
||||||
|
const PUMP_CURVES = [
|
||||||
|
{ model: 'hidrostal-H05K-S03R', unit: 'm3/h', pUnit: 'mbar', powUnit: 'kW' },
|
||||||
|
{ model: 'hidrostal-C5-D03R-SHN1', unit: 'm3/h', pUnit: 'mbar', powUnit: 'kW' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function curveExtents(curveData) {
|
||||||
|
const pressures = Object.keys(curveData.nq)
|
||||||
|
.filter((k) => /^-?\d+$/.test(k))
|
||||||
|
.map(Number)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const slice = (set, p) => curveData[set][String(p)];
|
||||||
|
const lowP = pressures[0];
|
||||||
|
const midP = pressures[Math.floor(pressures.length / 2)];
|
||||||
|
const highP = pressures[pressures.length - 1];
|
||||||
|
const allFlowY = pressures.flatMap((p) => slice('nq', p).y);
|
||||||
|
const allPowerY = pressures.flatMap((p) => slice('np', p).y);
|
||||||
|
return {
|
||||||
|
pressures,
|
||||||
|
lowP, midP, highP,
|
||||||
|
flowMin: Math.min(...allFlowY), flowMax: Math.max(...allFlowY),
|
||||||
|
powerMin: Math.min(...allPowerY), powerMax: Math.max(...allPowerY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeRunningMachine({ model, unit }) {
|
||||||
|
const cfg = makeMachineConfig({
|
||||||
|
general: { id: `rm-${model}`, name: model, unit, logging: { enabled: false, logLevel: 'error' } },
|
||||||
|
asset: {
|
||||||
|
supplier: 'hidrostal', category: 'pump', type: 'Centrifugal', model, unit,
|
||||||
|
curveUnits: { pressure: 'mbar', flow: unit, power: 'kW', control: '%' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const m = new Machine(cfg, makeStateConfig());
|
||||||
|
await m.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
assert.equal(m.state.getCurrentState(), 'operational', `${model}: should reach operational`);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const curve of PUMP_CURVES) {
|
||||||
|
const { model, unit, pUnit, powUnit } = curve;
|
||||||
|
|
||||||
|
test(`[${model}] curve loads and has both nq and np slices`, () => {
|
||||||
|
const raw = loadCurve(model);
|
||||||
|
assert.ok(raw, `loadCurve('${model}') must return data`);
|
||||||
|
assert.ok(raw.nq && Object.keys(raw.nq).length > 0, `${model}: nq has pressure slices`);
|
||||||
|
assert.ok(raw.np && Object.keys(raw.np).length > 0, `${model}: np has pressure slices`);
|
||||||
|
// Same pressure slices in both
|
||||||
|
const nqP = Object.keys(raw.nq).filter((k) => /^-?\d+$/.test(k)).sort();
|
||||||
|
const npP = Object.keys(raw.np).filter((k) => /^-?\d+$/.test(k)).sort();
|
||||||
|
assert.deepEqual(nqP, npP, `${model}: nq and np must share pressure slices`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`[${model}] predicted flow and power at mid-pressure, mid-ctrl are finite and in-range`, async () => {
|
||||||
|
const raw = loadCurve(model);
|
||||||
|
const ext = curveExtents(raw);
|
||||||
|
const m = await makeRunningMachine(curve);
|
||||||
|
|
||||||
|
// Feed differential pressure = midP (upstream 0, downstream = midP)
|
||||||
|
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||||
|
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||||
|
|
||||||
|
await m.handleInput('parent', 'execMovement', 50);
|
||||||
|
|
||||||
|
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||||
|
const power = m.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue(powUnit);
|
||||||
|
|
||||||
|
assert.ok(Number.isFinite(flow), `${model}: flow must be finite`);
|
||||||
|
assert.ok(Number.isFinite(power), `${model}: power must be finite`);
|
||||||
|
// Flow can be negative at the low-end slice of some curves due to spline extrapolation,
|
||||||
|
// but at mid-pressure mid-ctrl it must be positive.
|
||||||
|
assert.ok(flow > 0, `${model}: flow ${flow} ${unit} must be > 0 at mid-pressure mid-ctrl`);
|
||||||
|
assert.ok(power >= 0, `${model}: power ${power} ${powUnit} must be >= 0`);
|
||||||
|
// Loose bracket against curve envelope (2x margin accommodates interpolation overshoot)
|
||||||
|
assert.ok(flow <= ext.flowMax * 2, `${model}: flow ${flow} exceeds curve envelope ${ext.flowMax}`);
|
||||||
|
assert.ok(power <= ext.powerMax * 2, `${model}: power ${power} exceeds curve envelope ${ext.powerMax}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`[${model}] flow is monotonically non-decreasing in ctrl at fixed pressure`, async () => {
|
||||||
|
const raw = loadCurve(model);
|
||||||
|
const ext = curveExtents(raw);
|
||||||
|
const m = await makeRunningMachine(curve);
|
||||||
|
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||||
|
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||||
|
|
||||||
|
const samples = [];
|
||||||
|
for (const setpoint of [10, 30, 50, 70, 90]) {
|
||||||
|
await m.handleInput('parent', 'execMovement', setpoint);
|
||||||
|
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||||
|
samples.push({ setpoint, flow });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < samples.length; i++) {
|
||||||
|
// Allow 1% tolerance for spline wiggle but reject any clear regression.
|
||||||
|
assert.ok(
|
||||||
|
samples[i].flow >= samples[i - 1].flow - Math.abs(samples[i - 1].flow) * 0.01,
|
||||||
|
`${model}: flow not monotonic across ctrl sweep: ${JSON.stringify(samples)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`[${model}] flow decreases (or stays level) when pressure rises at fixed ctrl`, async () => {
|
||||||
|
const raw = loadCurve(model);
|
||||||
|
const ext = curveExtents(raw);
|
||||||
|
const m = await makeRunningMachine(curve);
|
||||||
|
|
||||||
|
const samples = [];
|
||||||
|
for (const p of [ext.lowP, ext.midP, ext.highP]) {
|
||||||
|
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||||
|
m.updateMeasuredPressure(p, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||||
|
await m.handleInput('parent', 'execMovement', 60);
|
||||||
|
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||||
|
samples.push({ pressure: p, flow });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highest pressure must not exceed lowest pressure flow by more than 1%.
|
||||||
|
// (Centrifugal pump: head up -> flow down at a given speed.)
|
||||||
|
const first = samples[0].flow;
|
||||||
|
const last = samples[samples.length - 1].flow;
|
||||||
|
assert.ok(
|
||||||
|
last <= first * 1.01,
|
||||||
|
`${model}: flow at p=${samples[samples.length - 1].pressure} (${last}) exceeds flow at p=${samples[0].pressure} (${first}); samples=${JSON.stringify(samples)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`[${model}] cog and NCog are computed and finite after an operational move`, async () => {
|
||||||
|
const raw = loadCurve(model);
|
||||||
|
const ext = curveExtents(raw);
|
||||||
|
const m = await makeRunningMachine(curve);
|
||||||
|
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||||
|
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||||
|
await m.handleInput('parent', 'execMovement', 50);
|
||||||
|
|
||||||
|
assert.ok(Number.isFinite(m.cog), `${model}: cog must be finite, got ${m.cog}`);
|
||||||
|
assert.ok(Number.isFinite(m.NCog), `${model}: NCog must be finite, got ${m.NCog}`);
|
||||||
|
// CoG is a controller-% location of peak efficiency; must fall inside the ctrl range of the curve.
|
||||||
|
assert.ok(m.cog >= 0 && m.cog <= 100, `${model}: cog=${m.cog} must be within [0,100]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`[${model}] reverse predictor (ctrl for requested flow) round-trips within tolerance`, async () => {
|
||||||
|
const raw = loadCurve(model);
|
||||||
|
const ext = curveExtents(raw);
|
||||||
|
const m = await makeRunningMachine(curve);
|
||||||
|
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||||
|
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||||
|
|
||||||
|
// Move to a known controller position and read the flow.
|
||||||
|
await m.handleInput('parent', 'execMovement', 60);
|
||||||
|
const observedFlow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||||
|
assert.ok(observedFlow > 0, `${model}: need non-zero flow to invert`);
|
||||||
|
|
||||||
|
// Convert flow back to ctrl via calcCtrl (uses reversed nq internally) —
|
||||||
|
// note calcCtrl takes canonical flow (m3/s), so convert.
|
||||||
|
const canonicalFlow = observedFlow / 3600; // m3/h -> m3/s
|
||||||
|
const predictedCtrl = m.calcCtrl(canonicalFlow);
|
||||||
|
assert.ok(
|
||||||
|
Number.isFinite(predictedCtrl) && Math.abs(predictedCtrl - 60) <= 10,
|
||||||
|
`${model}: reverse predictor ctrl=${predictedCtrl} should be within 10 of 60 for flow=${observedFlow}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
147
test/integration/efficiency-cog.integration.test.js
Normal file
147
test/integration/efficiency-cog.integration.test.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
|
||||||
|
function makePressurizedOperationalMachine() {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
machine.updateMeasuredPressure(800, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
|
||||||
|
machine.updateMeasuredPressure(1200, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||||
|
return machine;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('calcCog returns valid peak efficiency and index', () => {
|
||||||
|
const machine = makePressurizedOperationalMachine();
|
||||||
|
|
||||||
|
const result = machine.calcCog();
|
||||||
|
|
||||||
|
assert.ok(Number.isFinite(result.cog), 'cog should be finite');
|
||||||
|
assert.ok(result.cog > 0, 'peak efficiency should be positive');
|
||||||
|
assert.ok(Number.isFinite(result.cogIndex), 'cogIndex should be finite');
|
||||||
|
assert.ok(result.cogIndex >= 0, 'cogIndex should be non-negative');
|
||||||
|
assert.ok(Number.isFinite(result.NCog), 'NCog should be finite');
|
||||||
|
assert.ok(result.NCog >= 0 && result.NCog <= 1, 'NCog should be between 0 and 1');
|
||||||
|
assert.ok(Number.isFinite(result.minEfficiency), 'minEfficiency should be finite');
|
||||||
|
assert.ok(result.minEfficiency >= 0, 'minEfficiency should be non-negative');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calcCog peak is always >= minEfficiency', () => {
|
||||||
|
const machine = makePressurizedOperationalMachine();
|
||||||
|
|
||||||
|
const result = machine.calcCog();
|
||||||
|
assert.ok(result.cog >= result.minEfficiency, 'Peak must be >= min');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calcEfficiencyCurve produces correct specific flow ratio', () => {
|
||||||
|
const machine = makePressurizedOperationalMachine();
|
||||||
|
const { powerCurve, flowCurve } = machine.getCurrentCurves();
|
||||||
|
|
||||||
|
const { efficiencyCurve, peak, peakIndex, minEfficiency } = machine.calcEfficiencyCurve(powerCurve, flowCurve);
|
||||||
|
|
||||||
|
assert.ok(efficiencyCurve.length > 0, 'Efficiency curve should not be empty');
|
||||||
|
assert.equal(efficiencyCurve.length, powerCurve.y.length, 'Should match curve length');
|
||||||
|
|
||||||
|
// Verify each point: efficiency = flow / power (unrounded, canonical units)
|
||||||
|
for (let i = 0; i < efficiencyCurve.length; i++) {
|
||||||
|
const power = powerCurve.y[i];
|
||||||
|
const flow = flowCurve.y[i];
|
||||||
|
if (power > 0 && flow >= 0) {
|
||||||
|
const expected = flow / power;
|
||||||
|
assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak should be the max
|
||||||
|
const actualMax = Math.max(...efficiencyCurve);
|
||||||
|
assert.equal(peak, actualMax, 'Peak should match max of efficiency curve');
|
||||||
|
assert.equal(efficiencyCurve[peakIndex], peak, 'peakIndex should point to peak value');
|
||||||
|
assert.equal(minEfficiency, Math.min(...efficiencyCurve), 'minEfficiency should match min');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calcEfficiencyCurve handles empty curves gracefully', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
const result = machine.calcEfficiencyCurve({ x: [], y: [] }, { x: [], y: [] });
|
||||||
|
|
||||||
|
assert.deepEqual(result.efficiencyCurve, []);
|
||||||
|
assert.equal(result.peak, 0);
|
||||||
|
assert.equal(result.peakIndex, 0);
|
||||||
|
assert.equal(result.minEfficiency, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calcDistanceBEP returns absolute and relative distances', () => {
|
||||||
|
const machine = makePressurizedOperationalMachine();
|
||||||
|
|
||||||
|
const efficiency = 5;
|
||||||
|
const maxEfficiency = 10;
|
||||||
|
const minEfficiency = 2;
|
||||||
|
|
||||||
|
const result = machine.calcDistanceBEP(efficiency, maxEfficiency, minEfficiency);
|
||||||
|
|
||||||
|
assert.ok(Number.isFinite(result.absDistFromPeak), 'abs distance should be finite');
|
||||||
|
assert.equal(result.absDistFromPeak, Math.abs(efficiency - maxEfficiency));
|
||||||
|
assert.ok(Number.isFinite(result.relDistFromPeak), 'rel distance should be finite');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calcRelativeDistanceFromPeak returns 1 when maxEfficiency equals minEfficiency', () => {
|
||||||
|
const machine = makePressurizedOperationalMachine();
|
||||||
|
|
||||||
|
const result = machine.calcRelativeDistanceFromPeak(5, 5, 5);
|
||||||
|
assert.equal(result, 1, 'Should return default distance when max==min (division by zero guard)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showCoG returns structured data with curve guards', () => {
|
||||||
|
const machine = makePressurizedOperationalMachine();
|
||||||
|
|
||||||
|
const result = machine.showCoG();
|
||||||
|
|
||||||
|
assert.ok('cog' in result);
|
||||||
|
assert.ok('cogIndex' in result);
|
||||||
|
assert.ok('NCog' in result);
|
||||||
|
assert.ok('NCogPercent' in result);
|
||||||
|
assert.ok('minEfficiency' in result);
|
||||||
|
assert.ok('currentEfficiencyCurve' in result);
|
||||||
|
assert.ok(result.cog > 0);
|
||||||
|
assert.equal(result.NCogPercent, Math.round(result.NCog * 100 * 100) / 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showCoG returns safe fallback when no curve is available', () => {
|
||||||
|
const machine = new Machine(
|
||||||
|
makeMachineConfig({ asset: { model: null } }),
|
||||||
|
makeStateConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = machine.showCoG();
|
||||||
|
assert.equal(result.cog, 0);
|
||||||
|
assert.ok('error' in result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showWorkingCurves returns safe fallback when no curve is available', () => {
|
||||||
|
const machine = new Machine(
|
||||||
|
makeMachineConfig({ asset: { model: null } }),
|
||||||
|
makeStateConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = machine.showWorkingCurves();
|
||||||
|
assert.ok('error' in result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('efficiency output fields are present in getOutput', () => {
|
||||||
|
const machine = makePressurizedOperationalMachine();
|
||||||
|
|
||||||
|
// Move to a position so predictions produce values
|
||||||
|
machine.state.transitionToState('operational');
|
||||||
|
machine.updatePosition();
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
|
||||||
|
assert.ok('cog' in output);
|
||||||
|
assert.ok('NCog' in output);
|
||||||
|
assert.ok('NCogPercent' in output);
|
||||||
|
assert.ok('effDistFromPeak' in output);
|
||||||
|
assert.ok('effRelDistFromPeak' in output);
|
||||||
|
assert.ok('predictionQuality' in output);
|
||||||
|
assert.ok('predictionConfidence' in output);
|
||||||
|
assert.ok('predictionPressureSource' in output);
|
||||||
|
});
|
||||||
59
test/integration/emergency-stop.integration.test.js
Normal file
59
test/integration/emergency-stop.integration.test.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('emergencystop sequence reaches off state from operational', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
// First start the machine
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||||
|
|
||||||
|
// Execute emergency stop
|
||||||
|
await machine.handleInput('GUI', 'emergencystop');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emergencystop sequence reaches off state from idle', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||||
|
|
||||||
|
await machine.handleInput('GUI', 'emergencystop');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emergencystop clears predicted flow and power to zero', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
// Start and set a position so predictions are non-zero
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||||
|
await machine.handleInput('parent', 'execMovement', 50);
|
||||||
|
|
||||||
|
const flowBefore = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
|
||||||
|
assert.ok(flowBefore > 0, 'Flow should be positive before emergency stop');
|
||||||
|
|
||||||
|
// Emergency stop
|
||||||
|
await machine.handleInput('GUI', 'emergencystop');
|
||||||
|
|
||||||
|
const flowAfter = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
|
||||||
|
const powerAfter = machine.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue();
|
||||||
|
assert.equal(flowAfter, 0, 'Flow should be zero after emergency stop');
|
||||||
|
assert.equal(powerAfter, 0, 'Power should be zero after emergency stop');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emergencystop is rejected when source is not allowed in current mode', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
// In auto mode, only 'parent' source is typically allowed for sequences
|
||||||
|
machine.setMode('auto');
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||||
|
|
||||||
|
// GUI source attempting emergency stop in auto mode — should still work
|
||||||
|
// because emergencystop is allowed from all sources in config
|
||||||
|
await machine.handleInput('GUI', 'emergencystop');
|
||||||
|
// If we get here without throwing, action was either accepted or safely rejected
|
||||||
|
});
|
||||||
93
test/integration/interruptible-movement.integration.test.js
Normal file
93
test/integration/interruptible-movement.integration.test.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for the FSM interruptible-movement fix (2026-04-13).
|
||||||
|
*
|
||||||
|
* Before the fix, `executeSequence("shutdown")` was silently rejected by the
|
||||||
|
* state manager if the machine was mid-move (accelerating/decelerating),
|
||||||
|
* because allowedTransitions for those states only permits returning to
|
||||||
|
* `operational` or `emergencystop`. Operators pressing Stop during a ramp
|
||||||
|
* would see the transition error-logged but no actual stop.
|
||||||
|
*
|
||||||
|
* The fix aborts the active movement, waits for the FSM to return to
|
||||||
|
* `operational`, then runs the normal shutdown / emergency-stop sequence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
function makeSlowMoveMachine() {
|
||||||
|
// Slow movement so the test can reliably interrupt during accelerating.
|
||||||
|
// speed=20%/s, interval=10ms -> 80% setpoint takes ~4s of real movement.
|
||||||
|
return new Machine(
|
||||||
|
makeMachineConfig(),
|
||||||
|
makeStateConfig({
|
||||||
|
movement: { mode: 'staticspeed', speed: 20, maxSpeed: 1000, interval: 10 },
|
||||||
|
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shutdown during accelerating aborts the move and reaches idle', async () => {
|
||||||
|
const machine = makeSlowMoveMachine();
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||||
|
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||||
|
machine.updateMeasuredPressure(200, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
|
||||||
|
|
||||||
|
// Fire a setpoint that needs ~4 seconds. Do NOT await it.
|
||||||
|
const movePromise = machine.handleInput('parent', 'execMovement', 80);
|
||||||
|
|
||||||
|
// Wait a moment for the FSM to enter accelerating.
|
||||||
|
await sleep(100);
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'accelerating');
|
||||||
|
|
||||||
|
// Issue shutdown while the move is still accelerating.
|
||||||
|
await machine.handleInput('GUI', 'execSequence', 'shutdown');
|
||||||
|
|
||||||
|
// Let the aborted move unwind.
|
||||||
|
await movePromise.catch(() => {});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
machine.state.getCurrentState(),
|
||||||
|
'idle',
|
||||||
|
'shutdown issued mid-ramp must still drive FSM back to idle',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emergency stop during accelerating reaches off', async () => {
|
||||||
|
const machine = makeSlowMoveMachine();
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||||
|
|
||||||
|
const movePromise = machine.handleInput('parent', 'execMovement', 80);
|
||||||
|
|
||||||
|
await sleep(100);
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'accelerating');
|
||||||
|
|
||||||
|
await machine.handleInput('GUI', 'emergencystop');
|
||||||
|
await movePromise.catch(() => {});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
machine.state.getCurrentState(),
|
||||||
|
'off',
|
||||||
|
'emergency stop issued mid-ramp must still drive FSM to off',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeSequence accepts mixed-case sequence names', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||||
|
|
||||||
|
// Parent orchestrators (e.g. machineGroupControl) use "emergencyStop" with
|
||||||
|
// a capital S in their configs. The sequence key in rotatingMachine.json
|
||||||
|
// is lowercase. Normalization must bridge that gap without a warn.
|
||||||
|
await machine.executeSequence('EmergencyStop');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'off');
|
||||||
|
});
|
||||||
75
test/integration/movement-lifecycle.integration.test.js
Normal file
75
test/integration/movement-lifecycle.integration.test.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('movement from 0 to 50% updates position and predictions', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||||
|
|
||||||
|
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execMovement', 50);
|
||||||
|
|
||||||
|
const pos = machine.state.getCurrentPosition();
|
||||||
|
const { min, max } = machine._resolveSetpointBounds();
|
||||||
|
// Position should be constrained to bounds
|
||||||
|
assert.ok(pos >= min && pos <= max, `Position ${pos} should be within [${min}, ${max}]`);
|
||||||
|
|
||||||
|
const flow = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
|
||||||
|
assert.ok(flow > 0, 'Predicted flow should be positive at non-zero position');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flowmovement sets position based on flow setpoint', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||||
|
|
||||||
|
// Request 100 m3/h flow — the machine should calculate the control position
|
||||||
|
await machine.handleInput('parent', 'flowMovement', 100);
|
||||||
|
|
||||||
|
const pos = machine.state.getCurrentPosition();
|
||||||
|
assert.ok(pos > 0, 'Position should be non-zero for a non-zero flow setpoint');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sequential movements update position correctly', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execMovement', 30);
|
||||||
|
const pos30 = machine.state.getCurrentPosition();
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execMovement', 60);
|
||||||
|
const pos60 = machine.state.getCurrentPosition();
|
||||||
|
|
||||||
|
assert.ok(pos60 > pos30, 'Position at 60 should be greater than at 30');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('movement to 0 sets flow and power predictions to minimum curve values', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execMovement', 0);
|
||||||
|
|
||||||
|
const pos = machine.state.getCurrentPosition();
|
||||||
|
assert.equal(pos, 0, 'Position should be at 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('movement is rejected in non-operational state', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||||
|
|
||||||
|
// Attempt movement in idle state — handleInput should process but no movement happens
|
||||||
|
await machine.handleInput('parent', 'execMovement', 50);
|
||||||
|
|
||||||
|
// Machine should still be idle (movement requires operational state via sequence first)
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||||
|
});
|
||||||
@@ -14,7 +14,10 @@ test('pressure initialization combinations are handled explicitly', () => {
|
|||||||
assert.equal(status.source, null);
|
assert.equal(status.source, null);
|
||||||
const noPressureValue = machine.getMeasuredPressure();
|
const noPressureValue = machine.getMeasuredPressure();
|
||||||
assert.equal(noPressureValue, 0);
|
assert.equal(noPressureValue, 0);
|
||||||
assert.ok(machine.predictFlow.fDimension <= 1);
|
// With no pressure injected, fDimension is clamped to the curve minimum
|
||||||
|
// (70000 Pa for H05K). Previously a schema default at pressure "1" made
|
||||||
|
// fValues.min=1 — that was a data-poisoning bug, now fixed.
|
||||||
|
assert.ok(machine.predictFlow.fDimension >= 70000);
|
||||||
|
|
||||||
// upstream only
|
// upstream only
|
||||||
machine = createMachine();
|
machine = createMachine();
|
||||||
@@ -44,9 +47,11 @@ test('pressure initialization combinations are handled explicitly', () => {
|
|||||||
assert.equal(Math.round(downstreamValue), downstreamOnly * 100);
|
assert.equal(Math.round(downstreamValue), downstreamOnly * 100);
|
||||||
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100);
|
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100);
|
||||||
|
|
||||||
// downstream and upstream
|
// downstream and upstream — pick values whose differential (Pa) is above
|
||||||
|
// the curve's minimum pressure slice (70000 Pa = 700 mbar for H05K).
|
||||||
|
// 200 mbar upstream + 1100 mbar downstream → diff = 900 mbar = 90000 Pa.
|
||||||
machine = createMachine();
|
machine = createMachine();
|
||||||
const upstream = 700;
|
const upstream = 200;
|
||||||
const downstream = 1100;
|
const downstream = 1100;
|
||||||
machine.measurements.type('pressure').variant('measured').position('upstream').value(upstream, Date.now(), 'mbar');
|
machine.measurements.type('pressure').variant('measured').position('upstream').value(upstream, Date.now(), 'mbar');
|
||||||
machine.measurements.type('pressure').variant('measured').position('downstream').value(downstream, Date.now(), 'mbar');
|
machine.measurements.type('pressure').variant('measured').position('downstream').value(downstream, Date.now(), 'mbar');
|
||||||
|
|||||||
@@ -14,11 +14,16 @@ test('execSequence startup reaches operational with zero transition times', asyn
|
|||||||
|
|
||||||
test('execMovement constrains controller position to safe bounds in operational state', async () => {
|
test('execMovement constrains controller position to safe bounds in operational state', async () => {
|
||||||
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
const { max } = machine._resolveSetpointBounds();
|
const { min, max } = machine._resolveSetpointBounds();
|
||||||
|
|
||||||
|
// Test upper constraint: setpoint above max gets clamped to max
|
||||||
|
await machine.handleInput('parent', 'execMovement', max + 50);
|
||||||
|
let pos = machine.state.getCurrentPosition();
|
||||||
|
assert.equal(pos, max, `setpoint above max should be clamped to ${max}`);
|
||||||
|
|
||||||
|
// Test that a valid setpoint within bounds is applied as-is
|
||||||
await machine.handleInput('parent', 'execMovement', 10);
|
await machine.handleInput('parent', 'execMovement', 10);
|
||||||
|
pos = machine.state.getCurrentPosition();
|
||||||
const pos = machine.state.getCurrentPosition();
|
assert.equal(pos, 10, 'setpoint within bounds should be applied as-is');
|
||||||
assert.ok(pos <= max);
|
assert.ok(pos >= min && pos <= max);
|
||||||
assert.equal(pos, max);
|
|
||||||
});
|
});
|
||||||
|
|||||||
72
test/integration/shutdown-sequence.integration.test.js
Normal file
72
test/integration/shutdown-sequence.integration.test.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('shutdown sequence from operational reaches idle', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shutdown from operational ramps down position before stopping', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
await machine.handleInput('parent', 'execMovement', 50);
|
||||||
|
|
||||||
|
const posBefore = machine.state.getCurrentPosition();
|
||||||
|
assert.ok(posBefore > 0, 'Machine should be at non-zero position');
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||||
|
|
||||||
|
const posAfter = machine.state.getCurrentPosition();
|
||||||
|
assert.ok(posAfter <= posBefore, 'Position should have decreased after shutdown');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shutdown clears predicted flow and power', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||||
|
await machine.handleInput('parent', 'execMovement', 50);
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||||
|
|
||||||
|
const flow = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
|
||||||
|
const power = machine.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue();
|
||||||
|
assert.equal(flow, 0, 'Flow should be zero after shutdown');
|
||||||
|
assert.equal(power, 0, 'Power should be zero after shutdown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entermaintenance sequence from operational reaches maintenance state', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'enterMaintenance', 'entermaintenance');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'maintenance');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exitmaintenance requires mode with exitmaintenance action allowed', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
// Use auto mode (has execsequence + entermaintenance) to reach maintenance
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'enterMaintenance', 'entermaintenance');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'maintenance');
|
||||||
|
|
||||||
|
// Switch to fysicalControl which allows exitmaintenance
|
||||||
|
machine.setMode('fysicalControl');
|
||||||
|
await machine.handleInput('fysical', 'exitMaintenance', 'exitmaintenance');
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user