Compare commits
32 Commits
99b45c87e4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d196f363 | ||
|
|
510a4233e6 | ||
|
|
26e253d030 | ||
|
|
c464b66b27 | ||
|
|
17b88870bb | ||
|
|
07af7cef40 | ||
|
|
ea33b3bba3 | ||
|
|
f363ee53ef | ||
|
|
4cf46f33c9 | ||
|
|
7b9fdd7342 | ||
|
|
bb986c2dc8 | ||
|
|
46dd2ca37a | ||
|
|
ccfa90394b | ||
|
|
6b2a8239f2 | ||
|
|
33f3c2ef61 | ||
|
|
b5137ba9c2 | ||
|
|
405be33626 | ||
|
|
c63701db38 | ||
|
|
e236cccfd6 | ||
|
|
108d2e23ca | ||
|
|
446ef81f24 | ||
|
|
966ba06faa | ||
|
|
e8c96c4b1e | ||
|
|
f083e7596a | ||
|
|
6ca6e536a5 | ||
|
|
fb75fb8a11 | ||
|
|
6528c966d8 | ||
|
|
994cf641a3 | ||
|
|
6ae622b6bf | ||
|
|
4b5ec33c1d | ||
|
|
51f966cfb9 | ||
|
|
4ae6beba37 |
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.
|
||||||
|
|||||||
345
examples/01 - Basic Manual Control.json
Normal file
345
examples/01 - Basic Manual Control.json
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "rm_basic_tab",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "RotatingMachine - Basic Manual Control",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "Demonstrates basic manual control of a single rotatingMachine using inject nodes only. No dashboard dependencies."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_comment_title",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "RotatingMachine - Basic Manual Control\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nDemonstrates startup/shutdown sequences, speed setpoint\ncontrol, and pressure simulation for a single pump.\n\nPrerequisites: EVOLV package installed.",
|
||||||
|
"info": "",
|
||||||
|
"x": 360,
|
||||||
|
"y": 40,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_comment_howto",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "HOW TO USE:\n1. Deploy flow\n2. Click 'Set virtualControl' to enable manual control\n3. Click 'Startup' to run the startup sequence\n4. Click 'Set 60%' to move to 60% speed\n5. Inject BOTH pressure values to see flow/power predictions\n6. Click 'Shutdown' when done\n\nNOTE: Output uses delta compression - only changed\nfields are sent each tick. The formatter merges\ndeltas into a running cache for display.\n\nIMPORTANT: Flow and power predictions require at least\none pressure value to be injected. Without pressure,\npredictions use fDimension=0 (unrealistic values).\nOutput keys use 4-segment format:\ntype.variant.position.childId (e.g. flow.predicted.downstream.default)",
|
||||||
|
"info": "",
|
||||||
|
"x": 360,
|
||||||
|
"y": 100,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_node",
|
||||||
|
"type": "rotatingMachine",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Pump 1",
|
||||||
|
"speed": "1",
|
||||||
|
"startup": "3",
|
||||||
|
"warmup": "2",
|
||||||
|
"shutdown": "3",
|
||||||
|
"cooldown": "2",
|
||||||
|
"movementMode": "staticspeed",
|
||||||
|
"machineCurve": "",
|
||||||
|
"uuid": "example-pump-001",
|
||||||
|
"supplier": "hidrostal",
|
||||||
|
"category": "pump",
|
||||||
|
"assetType": "pump-centrifugal",
|
||||||
|
"model": "hidrostal-H05K-S03R",
|
||||||
|
"unit": "m3/h",
|
||||||
|
"curvePressureUnit": "mbar",
|
||||||
|
"curveFlowUnit": "m3/h",
|
||||||
|
"curvePowerUnit": "kW",
|
||||||
|
"curveControlUnit": "%",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"distanceDescription": "",
|
||||||
|
"x": 560,
|
||||||
|
"y": 340,
|
||||||
|
"wires": [
|
||||||
|
["rm_basic_format_output"],
|
||||||
|
["rm_basic_debug_port1"],
|
||||||
|
["rm_basic_debug_port2"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_mode",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Set virtualControl",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "virtualControl", "vt": "str" }
|
||||||
|
],
|
||||||
|
"topic": "setMode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 190,
|
||||||
|
"y": 200,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_auto",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Set auto mode",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "auto", "vt": "str" }
|
||||||
|
],
|
||||||
|
"topic": "setMode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 180,
|
||||||
|
"y": 240,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_startup",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Startup",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"startup\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 170,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_shutdown",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Shutdown",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"shutdown\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 170,
|
||||||
|
"y": 340,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_emergency",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Emergency Stop",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"emergencystop\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "emergencystop",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 190,
|
||||||
|
"y": 380,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_setpoint60",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Set 60%",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execMovement\",\"setpoint\":60}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execMovement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 170,
|
||||||
|
"y": 440,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_setpoint30",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Set 30%",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execMovement\",\"setpoint\":30}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execMovement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 170,
|
||||||
|
"y": 480,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_setpoint100",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Set 100%",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execMovement\",\"setpoint\":100}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execMovement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 170,
|
||||||
|
"y": 520,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_pressure_down",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Sim downstream 1100 mbar",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"type\":\"pressure\",\"position\":\"downstream\",\"value\":1100,\"unit\":\"mbar\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "simulateMeasurement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 230,
|
||||||
|
"y": 600,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_pressure_up",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Sim upstream 200 mbar",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"type\":\"pressure\",\"position\":\"upstream\",\"value\":200,\"unit\":\"mbar\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "simulateMeasurement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 220,
|
||||||
|
"y": 640,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_maintenance",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Enter Maintenance",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"entermaintenance\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 700,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_leavemaint",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Leave Maintenance",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"leavemaintenance\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 740,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_format_output",
|
||||||
|
"type": "function",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Merge deltas and format",
|
||||||
|
"func": "const p = msg.payload || {};\nconst cache = context.get('c') || {};\nObject.assign(cache, p);\ncontext.set('c', cache);\nfunction find(prefix) {\n for (var k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\nconst fl = find('flow.predicted.downstream.');\nconst pw = find('power.predicted.atequipment.');\nconst pD = find('pressure.measured.downstream.');\nconst pU = find('pressure.measured.upstream.');\nmsg.payload = {\n state: cache.state || 'idle',\n mode: cache.mode || 'auto',\n ctrl: cache.ctrl != null ? Number(cache.ctrl).toFixed(1) + '%' : 'n/a',\n flow: fl != null ? Number(fl).toFixed(2) + ' m3/h' : 'n/a',\n power: pw != null ? Number(pw).toFixed(2) + ' kW' : 'n/a',\n NCog: cache.NCog != null ? Number(cache.NCog).toFixed(1) + '%' : 'n/a',\n pDown: pD != null ? Number(pD).toFixed(0) + ' mbar' : 'n/a',\n pUp: pU != null ? Number(pU).toFixed(0) + ' mbar' : 'n/a',\n runtime: cache.runtime != null ? Number(cache.runtime).toFixed(3) + ' h' : '0'\n};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"noerr": 0,
|
||||||
|
"initialize": "",
|
||||||
|
"finalize": "",
|
||||||
|
"libs": [],
|
||||||
|
"x": 790,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [["rm_basic_debug_port0"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_debug_port0",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Port 0: Process Data",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": true,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"statusVal": "payload.state",
|
||||||
|
"statusType": "auto",
|
||||||
|
"x": 1020,
|
||||||
|
"y": 300,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_debug_port1",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Port 1: InfluxDB Telemetry",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1040,
|
||||||
|
"y": 360,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_debug_port2",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Port 2: Parent Registration",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1040,
|
||||||
|
"y": 420,
|
||||||
|
"wires": []
|
||||||
|
}
|
||||||
|
]
|
||||||
368
examples/02 - Integration with Machine Group.json
Normal file
368
examples/02 - Integration with Machine Group.json
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "rm_int_tab",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "RotatingMachine - Integration with Machine Group",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "Demonstrates a machineGroupControl parent with two rotatingMachine children and a measurement node."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_comment_title",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "RotatingMachine - Integration with Parent\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nShows how rotatingMachine registers with a\nmachineGroupControl parent via Port 2.\nAlso shows a measurement node providing\npressure data to a pump.\n\nPrerequisites: EVOLV package installed.",
|
||||||
|
"info": "",
|
||||||
|
"x": 380,
|
||||||
|
"y": 40,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_comment_howto",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "HOW TO USE:\n1. Deploy flow - pumps auto-register with MGC via Port 2\n2. Set Pump 1 to virtualControl, then Startup\n3. Set speed setpoints on individual pumps\n4. Observe MGC aggregating child state on Port 0\n5. Inject pressure measurement to see curve predictions",
|
||||||
|
"info": "",
|
||||||
|
"x": 380,
|
||||||
|
"y": 110,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_mgc",
|
||||||
|
"type": "machineGroupControl",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "Machine Group",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"x": 570,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [
|
||||||
|
["rm_int_debug_mgc_port0"],
|
||||||
|
["rm_int_debug_mgc_port1"],
|
||||||
|
["rm_int_debug_mgc_port2"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_pump1",
|
||||||
|
"type": "rotatingMachine",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "Pump 1",
|
||||||
|
"speed": "1",
|
||||||
|
"startup": "2",
|
||||||
|
"warmup": "1",
|
||||||
|
"shutdown": "2",
|
||||||
|
"cooldown": "1",
|
||||||
|
"movementMode": "staticspeed",
|
||||||
|
"machineCurve": "",
|
||||||
|
"uuid": "example-pump-001",
|
||||||
|
"supplier": "hidrostal",
|
||||||
|
"category": "pump",
|
||||||
|
"assetType": "pump-centrifugal",
|
||||||
|
"model": "hidrostal-H05K-S03R",
|
||||||
|
"unit": "m3/h",
|
||||||
|
"curvePressureUnit": "mbar",
|
||||||
|
"curveFlowUnit": "m3/h",
|
||||||
|
"curvePowerUnit": "kW",
|
||||||
|
"curveControlUnit": "%",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"distanceDescription": "",
|
||||||
|
"x": 570,
|
||||||
|
"y": 480,
|
||||||
|
"wires": [
|
||||||
|
["rm_int_debug_p1_port0"],
|
||||||
|
[],
|
||||||
|
["rm_int_mgc"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_pump2",
|
||||||
|
"type": "rotatingMachine",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "Pump 2",
|
||||||
|
"speed": "1",
|
||||||
|
"startup": "2",
|
||||||
|
"warmup": "1",
|
||||||
|
"shutdown": "2",
|
||||||
|
"cooldown": "1",
|
||||||
|
"movementMode": "staticspeed",
|
||||||
|
"machineCurve": "",
|
||||||
|
"uuid": "example-pump-002",
|
||||||
|
"supplier": "hidrostal",
|
||||||
|
"category": "pump",
|
||||||
|
"assetType": "pump-centrifugal",
|
||||||
|
"model": "hidrostal-H05K-S03R",
|
||||||
|
"unit": "m3/h",
|
||||||
|
"curvePressureUnit": "mbar",
|
||||||
|
"curveFlowUnit": "m3/h",
|
||||||
|
"curvePowerUnit": "kW",
|
||||||
|
"curveControlUnit": "%",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"distanceDescription": "",
|
||||||
|
"x": 570,
|
||||||
|
"y": 620,
|
||||||
|
"wires": [
|
||||||
|
["rm_int_debug_p2_port0"],
|
||||||
|
[],
|
||||||
|
["rm_int_mgc"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_mode_p1",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1: virtualControl",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "virtualControl", "vt": "str" }
|
||||||
|
],
|
||||||
|
"topic": "setMode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 440,
|
||||||
|
"wires": [["rm_int_pump1"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_start_p1",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1: Startup",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"startup\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 180,
|
||||||
|
"y": 480,
|
||||||
|
"wires": [["rm_int_pump1"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_setpoint_p1",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1: Set 75%",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execMovement\",\"setpoint\":75}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execMovement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 180,
|
||||||
|
"y": 520,
|
||||||
|
"wires": [["rm_int_pump1"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_mode_p2",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P2: virtualControl",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "virtualControl", "vt": "str" }
|
||||||
|
],
|
||||||
|
"topic": "setMode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 580,
|
||||||
|
"wires": [["rm_int_pump2"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_start_p2",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P2: Startup",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"startup\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 180,
|
||||||
|
"y": 620,
|
||||||
|
"wires": [["rm_int_pump2"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_setpoint_p2",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P2: Set 50%",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execMovement\",\"setpoint\":50}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execMovement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 180,
|
||||||
|
"y": 660,
|
||||||
|
"wires": [["rm_int_pump2"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_pressure",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1: Sim downstream 900 mbar",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"type\":\"pressure\",\"position\":\"downstream\",\"value\":900,\"unit\":\"mbar\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "simulateMeasurement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 240,
|
||||||
|
"y": 740,
|
||||||
|
"wires": [["rm_int_pump1"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_shutdown_p1",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1: Shutdown",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"shutdown\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 190,
|
||||||
|
"y": 780,
|
||||||
|
"wires": [["rm_int_pump1"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_shutdown_p2",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P2: Shutdown",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"shutdown\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 190,
|
||||||
|
"y": 820,
|
||||||
|
"wires": [["rm_int_pump2"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_mgc_port0",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "MGC Port 0: Group State",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": true,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"statusVal": "payload",
|
||||||
|
"statusType": "auto",
|
||||||
|
"x": 830,
|
||||||
|
"y": 260,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_mgc_port1",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "MGC Port 1: InfluxDB",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 820,
|
||||||
|
"y": 300,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_mgc_port2",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "MGC Port 2: Parent",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 810,
|
||||||
|
"y": 340,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_p1_port0",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1 Port 0: Process",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": true,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"statusVal": "payload.state",
|
||||||
|
"statusType": "auto",
|
||||||
|
"x": 810,
|
||||||
|
"y": 460,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_p2_port0",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P2 Port 0: Process",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": true,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"statusVal": "payload.state",
|
||||||
|
"statusType": "auto",
|
||||||
|
"x": 810,
|
||||||
|
"y": 600,
|
||||||
|
"wires": []
|
||||||
|
}
|
||||||
|
]
|
||||||
1026
examples/03 - Dashboard Visualization.json
Normal file
1026
examples/03 - Dashboard Visualization.json
Normal file
File diff suppressed because it is too large
Load Diff
53
examples/README.md
Normal file
53
examples/README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# RotatingMachine Example Flows
|
||||||
|
|
||||||
|
These flows are import-ready Node-RED examples for the `rotatingMachine` node.
|
||||||
|
In Node-RED: **Import > Examples > EVOLV** to find them.
|
||||||
|
|
||||||
|
## Example Flows
|
||||||
|
|
||||||
|
### 01 - Basic Manual Control
|
||||||
|
**Dependencies:** EVOLV only (no dashboard)
|
||||||
|
|
||||||
|
Inject-based flow demonstrating all core functionality:
|
||||||
|
- Mode switching (auto / virtualControl / fysicalControl)
|
||||||
|
- Startup/shutdown/emergency sequences
|
||||||
|
- Speed setpoint control (30%, 60%, 100%)
|
||||||
|
- Pressure simulation (upstream + downstream)
|
||||||
|
- Maintenance mode enter/leave
|
||||||
|
- Debug outputs on all 3 ports
|
||||||
|
|
||||||
|
### 02 - Integration with Machine Group
|
||||||
|
**Dependencies:** EVOLV only (no dashboard)
|
||||||
|
|
||||||
|
Parent-child relationship demo:
|
||||||
|
- machineGroupControl parent with 2x rotatingMachine children
|
||||||
|
- Auto-registration via Port 2 on deploy
|
||||||
|
- Independent pump control with group-level aggregation
|
||||||
|
- Pressure simulation on individual pumps
|
||||||
|
|
||||||
|
### 03 - Dashboard Visualization
|
||||||
|
**Dependencies:** EVOLV + @flowfuse/node-red-dashboard
|
||||||
|
|
||||||
|
Interactive FlowFuse dashboard with:
|
||||||
|
- Mode dropdown, startup/shutdown/emergency buttons
|
||||||
|
- Speed setpoint input, pressure simulation inputs
|
||||||
|
- Real-time charts: flow, power, ctrl%, NCog, state code, pressure
|
||||||
|
|
||||||
|
## Legacy Files
|
||||||
|
|
||||||
|
The following files are from the original flow set and will be removed in a future release:
|
||||||
|
- `basic.flow.json` → replaced by `01 - Basic Manual Control.json`
|
||||||
|
- `integration.flow.json` → replaced by `02 - Integration with Machine Group.json`
|
||||||
|
- `edge.flow.json` → edge-case testing (inject-based)
|
||||||
|
|
||||||
|
## Import
|
||||||
|
|
||||||
|
1. In Node-RED, use **Import > Examples > EVOLV** (auto-discovered)
|
||||||
|
2. Or manually: **Import > Clipboard** and paste the `.json` file contents
|
||||||
|
3. Deploy
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tier 1 and 2 examples have zero dashboard dependencies — they work on any Node-RED install with EVOLV
|
||||||
|
- Tier 3 requires `@flowfuse/node-red-dashboard` (included in EVOLV's package.json dependencies)
|
||||||
|
- All examples use `enableLog: true` so you can observe behavior in the Node-RED debug panel
|
||||||
1016
examples/basic.flow.json
Normal file
1016
examples/basic.flow.json
Normal file
File diff suppressed because it is too large
Load Diff
327
examples/edge.flow.json
Normal file
327
examples/edge.flow.json
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "91a88f212fb34de8",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "RotatingMachine Edge Cases",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "Manual edge-case driving for rotatingMachine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui_base_rm_edge",
|
||||||
|
"type": "ui-base",
|
||||||
|
"name": "EVOLV Demo",
|
||||||
|
"path": "/dashboard",
|
||||||
|
"includeClientData": true,
|
||||||
|
"acceptsClientConfig": [
|
||||||
|
"ui-notification",
|
||||||
|
"ui-control"
|
||||||
|
],
|
||||||
|
"showPathInSidebar": false,
|
||||||
|
"headerContent": "page",
|
||||||
|
"navigationStyle": "default",
|
||||||
|
"titleBarStyle": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui_theme_rm_edge",
|
||||||
|
"type": "ui-theme",
|
||||||
|
"name": "EVOLV Edge Theme",
|
||||||
|
"colors": {
|
||||||
|
"surface": "#ffffff",
|
||||||
|
"primary": "#0094ce",
|
||||||
|
"bgPage": "#eeeeee",
|
||||||
|
"groupBg": "#ffffff",
|
||||||
|
"groupOutline": "#cccccc"
|
||||||
|
},
|
||||||
|
"sizes": {
|
||||||
|
"density": "default",
|
||||||
|
"pagePadding": "12px",
|
||||||
|
"groupGap": "12px",
|
||||||
|
"groupBorderRadius": "4px",
|
||||||
|
"widgetGap": "12px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui_page_rm_edge",
|
||||||
|
"type": "ui-page",
|
||||||
|
"name": "RotatingMachine Edge",
|
||||||
|
"ui": "ui_base_rm_edge",
|
||||||
|
"path": "/rotating-machine-edge",
|
||||||
|
"icon": "report_problem",
|
||||||
|
"layout": "grid",
|
||||||
|
"theme": "ui_theme_rm_edge",
|
||||||
|
"breakpoints": [
|
||||||
|
{
|
||||||
|
"name": "Default",
|
||||||
|
"px": "0",
|
||||||
|
"cols": "12"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"order": 3,
|
||||||
|
"className": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui_group_rm_edge_inputs",
|
||||||
|
"type": "ui-group",
|
||||||
|
"name": "Edge Input Generators",
|
||||||
|
"page": "ui_page_rm_edge",
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"order": 1,
|
||||||
|
"showTitle": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui_group_rm_edge_obs",
|
||||||
|
"type": "ui-group",
|
||||||
|
"name": "Observed Responses",
|
||||||
|
"page": "ui_page_rm_edge",
|
||||||
|
"width": "6",
|
||||||
|
"height": "8",
|
||||||
|
"order": 2,
|
||||||
|
"showTitle": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_node_edge",
|
||||||
|
"type": "rotatingMachine",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"name": "RM Edge",
|
||||||
|
"speed": "1",
|
||||||
|
"startup": "3",
|
||||||
|
"warmup": "3",
|
||||||
|
"shutdown": "3",
|
||||||
|
"cooldown": "3",
|
||||||
|
"movementMode": "staticspeed",
|
||||||
|
"machineCurve": "",
|
||||||
|
"uuid": "",
|
||||||
|
"supplier": "hidrostal",
|
||||||
|
"category": "machine",
|
||||||
|
"assetType": "pump-centrifugal",
|
||||||
|
"model": "hidrostal-H05K-S03R",
|
||||||
|
"unit": "m3/h",
|
||||||
|
"enableLog": false,
|
||||||
|
"logLevel": "error",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"x": 930,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_edge_parse",
|
||||||
|
"rm_edge_process_debug"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"rm_edge_debug_influx"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"rm_edge_debug_parent"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"hasDistance": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_invalid_mode_btn",
|
||||||
|
"type": "ui-button",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"group": "ui_group_rm_edge_inputs",
|
||||||
|
"name": "Invalid Mode",
|
||||||
|
"label": "Send invalid mode",
|
||||||
|
"order": 1,
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"payload": "invalidMode",
|
||||||
|
"payloadType": "str",
|
||||||
|
"topic": "setMode",
|
||||||
|
"x": 190,
|
||||||
|
"y": 120,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_node_edge"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_neg_setpoint",
|
||||||
|
"type": "ui-number-input",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"group": "ui_group_rm_edge_inputs",
|
||||||
|
"name": "Negative Setpoint",
|
||||||
|
"label": "Negative setpoint",
|
||||||
|
"order": 2,
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"passthru": true,
|
||||||
|
"min": -100,
|
||||||
|
"max": 0,
|
||||||
|
"step": 1,
|
||||||
|
"x": 190,
|
||||||
|
"y": 170,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_edge_exec_movement"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_exec_movement",
|
||||||
|
"type": "function",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"name": "Build execMovement",
|
||||||
|
"func": "msg.topic = 'execMovement';\nmsg.payload = {source:'GUI', action:'execMovement', setpoint:Number(msg.payload)};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"x": 450,
|
||||||
|
"y": 170,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_node_edge"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_bad_source_btn",
|
||||||
|
"type": "ui-button",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"group": "ui_group_rm_edge_inputs",
|
||||||
|
"name": "Bad Source",
|
||||||
|
"label": "Disallowed source action",
|
||||||
|
"order": 3,
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"payload": "{}",
|
||||||
|
"payloadType": "str",
|
||||||
|
"x": 210,
|
||||||
|
"y": 220,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_edge_bad_source_builder"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_bad_source_builder",
|
||||||
|
"type": "function",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"name": "Build blocked execSequence",
|
||||||
|
"func": "msg.topic='execSequence';\nmsg.payload={source:'bad-source', action:'execSequence', parameter:'startup'};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"x": 500,
|
||||||
|
"y": 220,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_node_edge"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_bad_sim_type",
|
||||||
|
"type": "ui-button",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"group": "ui_group_rm_edge_inputs",
|
||||||
|
"name": "Unsupported Sim Type",
|
||||||
|
"label": "simulateMeasurement (bad type)",
|
||||||
|
"order": 4,
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"payload": "{\"type\":\"unknown\",\"position\":\"downstream\",\"value\":123,\"unit\":\"mbar\"}",
|
||||||
|
"payloadType": "json",
|
||||||
|
"topic": "simulateMeasurement",
|
||||||
|
"x": 220,
|
||||||
|
"y": 270,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_node_edge"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_parse",
|
||||||
|
"type": "function",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"name": "Summarize process output",
|
||||||
|
"func": "const incoming = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst merged = { ...(context.get('lastPayload') || {}), ...incoming };\ncontext.set('lastPayload', merged);\n\nconst state = merged.state == null ? 'n/a' : String(merged.state);\nconst mode = merged.mode == null ? 'n/a' : String(merged.mode);\nconst ctrl = Number(merged.ctrl);\nconst stateCodeMap = { off: 0, idle: 1, starting: 2, warmingup: 3, operational: 4, accelerating: 5, decelerating: 6, stopping: 7, coolingdown: 8, maintenance: 9 };\nconst stateCode = stateCodeMap[state] ?? -1;\n\nreturn [\n { payload: `state=${state}, mode=${mode}, ctrl=${Number.isFinite(ctrl) ? ctrl.toFixed(2) : 'n/a'}%` },\n { topic: 'stateCode', payload: stateCode }\n];",
|
||||||
|
"outputs": 2,
|
||||||
|
"x": 1230,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_edge_state_text"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"rm_edge_state_chart"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_state_text",
|
||||||
|
"type": "ui-text",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"group": "ui_group_rm_edge_obs",
|
||||||
|
"name": "State Text",
|
||||||
|
"label": "Machine summary",
|
||||||
|
"order": 2,
|
||||||
|
"format": "{{msg.payload}}",
|
||||||
|
"layout": "row-spread",
|
||||||
|
"x": 1450,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [],
|
||||||
|
"width": 6,
|
||||||
|
"height": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_process_debug",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"name": "Process Output",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1220,
|
||||||
|
"y": 340,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_debug_influx",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"name": "Influx Output",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1220,
|
||||||
|
"y": 380,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_debug_parent",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"name": "Parent Output",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1220,
|
||||||
|
"y": 420,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_edge_state_chart",
|
||||||
|
"type": "ui-chart",
|
||||||
|
"z": "91a88f212fb34de8",
|
||||||
|
"group": "ui_group_rm_edge_obs",
|
||||||
|
"name": "State Code",
|
||||||
|
"label": "State Code",
|
||||||
|
"order": 1,
|
||||||
|
"width": 6,
|
||||||
|
"height": 4,
|
||||||
|
"chartType": "line",
|
||||||
|
"category": "topic",
|
||||||
|
"categoryType": "msg",
|
||||||
|
"xAxisType": "time",
|
||||||
|
"yAxisLabel": "state",
|
||||||
|
"removeOlder": "1",
|
||||||
|
"removeOlderUnit": "3600",
|
||||||
|
"x": 1230,
|
||||||
|
"y": 300,
|
||||||
|
"wires": []
|
||||||
|
}
|
||||||
|
]
|
||||||
759
examples/integration.flow.json
Normal file
759
examples/integration.flow.json
Normal file
@@ -0,0 +1,759 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "12f41a7b538c40db",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "RotatingMachine Integration",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "Manual integration-style scenario builder for rotatingMachine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui_base_rm_int",
|
||||||
|
"type": "ui-base",
|
||||||
|
"name": "EVOLV Demo",
|
||||||
|
"path": "/dashboard",
|
||||||
|
"includeClientData": true,
|
||||||
|
"acceptsClientConfig": [
|
||||||
|
"ui-notification",
|
||||||
|
"ui-control"
|
||||||
|
],
|
||||||
|
"showPathInSidebar": false,
|
||||||
|
"headerContent": "page",
|
||||||
|
"navigationStyle": "default",
|
||||||
|
"titleBarStyle": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui_theme_rm_int",
|
||||||
|
"type": "ui-theme",
|
||||||
|
"name": "EVOLV Integration Theme",
|
||||||
|
"colors": {
|
||||||
|
"surface": "#ffffff",
|
||||||
|
"primary": "#0094ce",
|
||||||
|
"bgPage": "#eeeeee",
|
||||||
|
"groupBg": "#ffffff",
|
||||||
|
"groupOutline": "#cccccc"
|
||||||
|
},
|
||||||
|
"sizes": {
|
||||||
|
"density": "default",
|
||||||
|
"pagePadding": "14px",
|
||||||
|
"groupGap": "14px",
|
||||||
|
"groupBorderRadius": "6px",
|
||||||
|
"widgetGap": "12px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui_page_rm_int",
|
||||||
|
"type": "ui-page",
|
||||||
|
"name": "RotatingMachine Integration",
|
||||||
|
"ui": "ui_base_rm_int",
|
||||||
|
"path": "/rotating-machine-integration",
|
||||||
|
"icon": "lan",
|
||||||
|
"layout": "grid",
|
||||||
|
"theme": "ui_theme_rm_int",
|
||||||
|
"breakpoints": [
|
||||||
|
{
|
||||||
|
"name": "Default",
|
||||||
|
"px": "0",
|
||||||
|
"cols": "12"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"order": 2,
|
||||||
|
"className": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui_group_rm_int_ctrl",
|
||||||
|
"type": "ui-group",
|
||||||
|
"name": "Control Sequences",
|
||||||
|
"page": "ui_page_rm_int",
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"order": 1,
|
||||||
|
"showTitle": true,
|
||||||
|
"className": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui_group_rm_int_sim",
|
||||||
|
"type": "ui-group",
|
||||||
|
"name": "Process Simulation",
|
||||||
|
"page": "ui_page_rm_int",
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"order": 2,
|
||||||
|
"showTitle": true,
|
||||||
|
"className": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui_group_rm_int_vis",
|
||||||
|
"type": "ui-group",
|
||||||
|
"name": "Observed Behaviour",
|
||||||
|
"page": "ui_page_rm_int",
|
||||||
|
"width": "12",
|
||||||
|
"height": "24",
|
||||||
|
"order": 3,
|
||||||
|
"showTitle": true,
|
||||||
|
"className": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_node_int",
|
||||||
|
"type": "rotatingMachine",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"name": "RM Integration",
|
||||||
|
"speed": "1",
|
||||||
|
"startup": "3",
|
||||||
|
"warmup": "3",
|
||||||
|
"shutdown": "3",
|
||||||
|
"cooldown": "3",
|
||||||
|
"movementMode": "staticspeed",
|
||||||
|
"machineCurve": "",
|
||||||
|
"uuid": "",
|
||||||
|
"supplier": "hidrostal",
|
||||||
|
"category": "machine",
|
||||||
|
"assetType": "pump-centrifugal",
|
||||||
|
"model": "hidrostal-H05K-S03R",
|
||||||
|
"unit": "m3/h",
|
||||||
|
"enableLog": false,
|
||||||
|
"logLevel": "error",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"x": 1040,
|
||||||
|
"y": 360,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_int_parse"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"rm_int_debug_influx"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"rm_int_debug_parent"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"hasDistance": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_startup",
|
||||||
|
"type": "ui-button",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_ctrl",
|
||||||
|
"name": "Startup",
|
||||||
|
"label": "Startup",
|
||||||
|
"order": 1,
|
||||||
|
"width": "3",
|
||||||
|
"height": "1",
|
||||||
|
"icon": "play_arrow",
|
||||||
|
"payload": "startup",
|
||||||
|
"payloadType": "str",
|
||||||
|
"x": 190,
|
||||||
|
"y": 120,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_int_exec_seq"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_shutdown",
|
||||||
|
"type": "ui-button",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_ctrl",
|
||||||
|
"name": "Shutdown",
|
||||||
|
"label": "Shutdown",
|
||||||
|
"order": 2,
|
||||||
|
"width": "3",
|
||||||
|
"height": "1",
|
||||||
|
"icon": "stop",
|
||||||
|
"payload": "shutdown",
|
||||||
|
"payloadType": "str",
|
||||||
|
"x": 190,
|
||||||
|
"y": 160,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_int_exec_seq"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_maint",
|
||||||
|
"type": "ui-button",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_ctrl",
|
||||||
|
"name": "Maintenance",
|
||||||
|
"label": "Enter Maintenance",
|
||||||
|
"order": 3,
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"icon": "build",
|
||||||
|
"payload": "entermaintenance",
|
||||||
|
"payloadType": "str",
|
||||||
|
"x": 220,
|
||||||
|
"y": 200,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_int_exec_seq"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_exec_seq",
|
||||||
|
"type": "function",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"name": "Build execSequence",
|
||||||
|
"func": "msg.topic = 'execSequence';\nmsg.payload = {source:'GUI', action:'execSequence', parameter: msg.payload};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"x": 450,
|
||||||
|
"y": 160,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_node_int"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_setpoint",
|
||||||
|
"type": "ui-number-input",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_ctrl",
|
||||||
|
"name": "Setpoint",
|
||||||
|
"label": "Setpoint %",
|
||||||
|
"order": 4,
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"passthru": true,
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"step": 1,
|
||||||
|
"x": 190,
|
||||||
|
"y": 260,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_int_exec_movement",
|
||||||
|
"rm_int_ctrl_setpoint_for_chart"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_exec_movement",
|
||||||
|
"type": "function",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"name": "Build execMovement",
|
||||||
|
"func": "msg.topic='execMovement';\nmsg.payload={source:'GUI', action:'execMovement', setpoint:Number(msg.payload)};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"x": 460,
|
||||||
|
"y": 260,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_node_int"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_flow_target",
|
||||||
|
"type": "ui-number-input",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_sim",
|
||||||
|
"name": "Flow target",
|
||||||
|
"label": "Flow target (m3/h)",
|
||||||
|
"order": 1,
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"passthru": true,
|
||||||
|
"min": 0,
|
||||||
|
"max": 1000,
|
||||||
|
"step": 5,
|
||||||
|
"x": 200,
|
||||||
|
"y": 340,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_int_flow_move",
|
||||||
|
"rm_int_flow_setpoint_for_chart"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_flow_move",
|
||||||
|
"type": "function",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"name": "Build flowMovement",
|
||||||
|
"func": "msg.topic='flowMovement';\nmsg.payload={source:'GUI', action:'flowMovement', setpoint:Number(msg.payload)};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"x": 450,
|
||||||
|
"y": 340,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_node_int"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_pressure_up",
|
||||||
|
"type": "ui-number-input",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_sim",
|
||||||
|
"name": "Upstream Pressure",
|
||||||
|
"label": "Upstream pressure (mbar)",
|
||||||
|
"order": 2,
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"passthru": true,
|
||||||
|
"min": 0,
|
||||||
|
"max": 3000,
|
||||||
|
"step": 10,
|
||||||
|
"x": 210,
|
||||||
|
"y": 390,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_int_sim_up"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_pressure_down",
|
||||||
|
"type": "ui-number-input",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_sim",
|
||||||
|
"name": "Downstream Pressure",
|
||||||
|
"label": "Downstream pressure (mbar)",
|
||||||
|
"order": 3,
|
||||||
|
"width": "6",
|
||||||
|
"height": "1",
|
||||||
|
"passthru": true,
|
||||||
|
"min": 0,
|
||||||
|
"max": 3000,
|
||||||
|
"step": 10,
|
||||||
|
"x": 220,
|
||||||
|
"y": 430,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_int_sim_down"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_sim_up",
|
||||||
|
"type": "function",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"name": "simulate upstream pressure",
|
||||||
|
"func": "msg.topic='simulateMeasurement';\nmsg.payload={type:'pressure', position:'upstream', value:Number(msg.payload), unit:'mbar'};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"x": 500,
|
||||||
|
"y": 390,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_node_int"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_sim_down",
|
||||||
|
"type": "function",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"name": "simulate downstream pressure",
|
||||||
|
"func": "msg.topic='simulateMeasurement';\nmsg.payload={type:'pressure', position:'downstream', value:Number(msg.payload), unit:'mbar'};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"x": 510,
|
||||||
|
"y": 430,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_node_int"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_parse",
|
||||||
|
"type": "function",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"name": "Parse RM output",
|
||||||
|
"func": "const incoming = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst lastPayload = context.get('lastPayload') || {};\nconst merged = { ...lastPayload, ...incoming };\ncontext.set('lastPayload', merged);\n\nconst cache = context.get('metricCache') || {\n flow: 0,\n power: 0,\n ctrl: 0,\n nCog: 0,\n stateCode: 0,\n state: 'idle',\n mode: 'auto',\n runtime: 0,\n moveTimeleft: 0,\n maintenanceTime: 0,\n pressureUp: null,\n pressureDown: null,\n};\n\nconst pickNumber = (...keys) => {\n for (const key of keys) {\n const value = Number(merged[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\n\nconst pickByPrefix = (...prefixes) => {\n const keys = Object.keys(merged);\n for (const prefix of prefixes) {\n const direct = Number(merged[prefix]);\n if (Number.isFinite(direct)) return direct;\n\n const dynamicKey = keys.find((k) => k === prefix || k.startsWith(prefix + '.'));\n if (!dynamicKey) continue;\n\n const value = Number(merged[dynamicKey]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\n\nconst pickString = (key, fallback = null) => {\n const value = merged[key];\n if (value === undefined || value === null || value === '') return fallback;\n return String(value);\n};\n\nconst flowValue = pickByPrefix('flow.predicted.downstream');\nconst power = pickByPrefix('power.predicted.atequipment', 'power.predicted.atEquipment');\nconst ctrl = pickNumber('ctrl') ?? pickByPrefix('ctrl.predicted.atequipment', 'ctrl.predicted.atEquipment');\nconst nCog = pickNumber('NCogPercent', 'NCog');\nconst runtime = pickNumber('runtime');\nconst moveTimeleft = pickNumber('moveTimeleft');\nconst maintenanceTime = pickNumber('maintenanceTime');\nconst pressureDownIncoming = pickByPrefix('pressure.measured.downstream');\nconst pressureUpIncoming = pickByPrefix('pressure.measured.upstream');\nconst state = pickString('state', cache.state);\nconst mode = pickString('mode', cache.mode);\n\nconst stateCodeMap = { off: 0, idle: 1, starting: 2, warmingup: 3, operational: 4, accelerating: 5, decelerating: 6, stopping: 7, coolingdown: 8, maintenance: 9 };\nconst stateCode = stateCodeMap[state] ?? cache.stateCode;\n\nif (flowValue !== null) cache.flow = flowValue;\nif (power !== null) cache.power = power;\nif (ctrl !== null) cache.ctrl = ctrl;\nif (nCog !== null) cache.nCog = nCog;\nif (runtime !== null) cache.runtime = runtime;\nif (moveTimeleft !== null) cache.moveTimeleft = moveTimeleft;\nif (maintenanceTime !== null) cache.maintenanceTime = maintenanceTime;\nif (pressureUpIncoming !== null) cache.pressureUp = pressureUpIncoming;\nif (pressureDownIncoming !== null) cache.pressureDown = pressureDownIncoming;\ncache.state = state;\ncache.mode = mode;\ncache.stateCode = stateCode;\ncontext.set('metricCache', cache);\n\nconst pressureUp = Number.isFinite(cache.pressureUp) ? cache.pressureUp : null;\nconst pressureDown = Number.isFinite(cache.pressureDown) ? cache.pressureDown : null;\nconst pressureDelta = (pressureDown !== null && pressureUp !== null) ? (pressureDown - pressureUp) : null;\n\nconst now = Date.now();\nreturn [\n { topic: 'actual_flow', payload: cache.flow, timestamp: now },\n { topic: 'predicted_power', payload: cache.power, timestamp: now },\n { topic: 'nCog', payload: cache.nCog, timestamp: now },\n { topic: 'actual_ctrl', payload: cache.ctrl, timestamp: now },\n { topic: 'stateCode', payload: cache.stateCode, timestamp: now },\n { payload: JSON.stringify({ state: cache.state, mode: cache.mode, ctrl: cache.ctrl, runtime: cache.runtime, moveTimeleft: cache.moveTimeleft, maintenanceTime: cache.maintenanceTime, pressureUp, pressureDown, pressureDelta }) }\n];",
|
||||||
|
"outputs": 6,
|
||||||
|
"x": 1260,
|
||||||
|
"y": 360,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_int_chart_flow"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"rm_int_chart_power"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"rm_int_chart_nCog"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"rm_int_chart_ctrl"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"rm_int_chart_statecode"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"rm_int_state_text"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_chart_flow",
|
||||||
|
"type": "ui-chart",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_vis",
|
||||||
|
"name": "Flow",
|
||||||
|
"label": "Flow (m3/h)",
|
||||||
|
"order": 1,
|
||||||
|
"width": 6,
|
||||||
|
"height": 4,
|
||||||
|
"chartType": "line",
|
||||||
|
"xAxisType": "time",
|
||||||
|
"yAxisLabel": "m3/h",
|
||||||
|
"removeOlder": "1",
|
||||||
|
"removeOlderUnit": "3600",
|
||||||
|
"x": 1510,
|
||||||
|
"y": 280,
|
||||||
|
"wires": [],
|
||||||
|
"showLegend": true,
|
||||||
|
"category": "topic",
|
||||||
|
"categoryType": "msg",
|
||||||
|
"xAxisProperty": "",
|
||||||
|
"xAxisPropertyType": "timestamp",
|
||||||
|
"xAxisFormat": "",
|
||||||
|
"xAxisFormatType": "auto",
|
||||||
|
"yAxisProperty": "payload",
|
||||||
|
"yAxisPropertyType": "msg",
|
||||||
|
"xmin": "",
|
||||||
|
"xmax": "",
|
||||||
|
"ymin": "",
|
||||||
|
"ymax": "",
|
||||||
|
"bins": 10,
|
||||||
|
"action": "append",
|
||||||
|
"stackSeries": false,
|
||||||
|
"pointShape": "circle",
|
||||||
|
"pointRadius": 4,
|
||||||
|
"interpolation": "linear",
|
||||||
|
"className": "",
|
||||||
|
"colors": [
|
||||||
|
"#0095FF",
|
||||||
|
"#FF0000",
|
||||||
|
"#FF7F0E",
|
||||||
|
"#2CA02C",
|
||||||
|
"#A347E1",
|
||||||
|
"#D62728",
|
||||||
|
"#FF9896",
|
||||||
|
"#9467BD",
|
||||||
|
"#C5B0D5"
|
||||||
|
],
|
||||||
|
"textColor": [
|
||||||
|
"#666666"
|
||||||
|
],
|
||||||
|
"textColorDefault": true,
|
||||||
|
"gridColor": [
|
||||||
|
"#e5e5e5"
|
||||||
|
],
|
||||||
|
"gridColorDefault": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_chart_power",
|
||||||
|
"type": "ui-chart",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_vis",
|
||||||
|
"name": "Power",
|
||||||
|
"label": "Power (kW)",
|
||||||
|
"order": 2,
|
||||||
|
"width": 6,
|
||||||
|
"height": 4,
|
||||||
|
"chartType": "line",
|
||||||
|
"xAxisType": "time",
|
||||||
|
"yAxisLabel": "kW",
|
||||||
|
"removeOlder": "1",
|
||||||
|
"removeOlderUnit": "3600",
|
||||||
|
"x": 1510,
|
||||||
|
"y": 340,
|
||||||
|
"wires": [],
|
||||||
|
"showLegend": true,
|
||||||
|
"category": "topic",
|
||||||
|
"categoryType": "msg",
|
||||||
|
"xAxisProperty": "",
|
||||||
|
"xAxisPropertyType": "timestamp",
|
||||||
|
"xAxisFormat": "",
|
||||||
|
"xAxisFormatType": "auto",
|
||||||
|
"yAxisProperty": "payload",
|
||||||
|
"yAxisPropertyType": "msg",
|
||||||
|
"xmin": "",
|
||||||
|
"xmax": "",
|
||||||
|
"ymin": "",
|
||||||
|
"ymax": "",
|
||||||
|
"bins": 10,
|
||||||
|
"action": "append",
|
||||||
|
"stackSeries": false,
|
||||||
|
"pointShape": "circle",
|
||||||
|
"pointRadius": 4,
|
||||||
|
"interpolation": "linear",
|
||||||
|
"className": "",
|
||||||
|
"colors": [
|
||||||
|
"#0095FF",
|
||||||
|
"#FF0000",
|
||||||
|
"#FF7F0E",
|
||||||
|
"#2CA02C",
|
||||||
|
"#A347E1",
|
||||||
|
"#D62728",
|
||||||
|
"#FF9896",
|
||||||
|
"#9467BD",
|
||||||
|
"#C5B0D5"
|
||||||
|
],
|
||||||
|
"textColor": [
|
||||||
|
"#666666"
|
||||||
|
],
|
||||||
|
"textColorDefault": true,
|
||||||
|
"gridColor": [
|
||||||
|
"#e5e5e5"
|
||||||
|
],
|
||||||
|
"gridColorDefault": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_chart_nCog",
|
||||||
|
"type": "ui-chart",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_vis",
|
||||||
|
"name": "NCog",
|
||||||
|
"label": "NCog (%)",
|
||||||
|
"order": 3,
|
||||||
|
"width": 6,
|
||||||
|
"height": 4,
|
||||||
|
"chartType": "line",
|
||||||
|
"xAxisType": "time",
|
||||||
|
"yAxisLabel": "%",
|
||||||
|
"removeOlder": "1",
|
||||||
|
"removeOlderUnit": "3600",
|
||||||
|
"x": 1510,
|
||||||
|
"y": 400,
|
||||||
|
"wires": [],
|
||||||
|
"showLegend": true,
|
||||||
|
"category": "topic",
|
||||||
|
"categoryType": "msg",
|
||||||
|
"xAxisProperty": "",
|
||||||
|
"xAxisPropertyType": "timestamp",
|
||||||
|
"xAxisFormat": "",
|
||||||
|
"xAxisFormatType": "auto",
|
||||||
|
"yAxisProperty": "payload",
|
||||||
|
"yAxisPropertyType": "msg",
|
||||||
|
"xmin": "",
|
||||||
|
"xmax": "",
|
||||||
|
"ymin": "",
|
||||||
|
"ymax": "",
|
||||||
|
"bins": 10,
|
||||||
|
"action": "append",
|
||||||
|
"stackSeries": false,
|
||||||
|
"pointShape": "circle",
|
||||||
|
"pointRadius": 4,
|
||||||
|
"interpolation": "linear",
|
||||||
|
"className": "",
|
||||||
|
"colors": [
|
||||||
|
"#0095FF",
|
||||||
|
"#FF0000",
|
||||||
|
"#FF7F0E",
|
||||||
|
"#2CA02C",
|
||||||
|
"#A347E1",
|
||||||
|
"#D62728",
|
||||||
|
"#FF9896",
|
||||||
|
"#9467BD",
|
||||||
|
"#C5B0D5"
|
||||||
|
],
|
||||||
|
"textColor": [
|
||||||
|
"#666666"
|
||||||
|
],
|
||||||
|
"textColorDefault": true,
|
||||||
|
"gridColor": [
|
||||||
|
"#e5e5e5"
|
||||||
|
],
|
||||||
|
"gridColorDefault": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_state_text",
|
||||||
|
"type": "ui-text",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_vis",
|
||||||
|
"name": "State",
|
||||||
|
"label": "State Snapshot",
|
||||||
|
"order": 6,
|
||||||
|
"width": 12,
|
||||||
|
"height": 1,
|
||||||
|
"format": "{{msg.payload}}",
|
||||||
|
"layout": "row-spread",
|
||||||
|
"x": 1510,
|
||||||
|
"y": 460,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_influx",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"name": "Influx",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1250,
|
||||||
|
"y": 520,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_parent",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"name": "Parent",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1240,
|
||||||
|
"y": 560,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_chart_ctrl",
|
||||||
|
"type": "ui-chart",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_vis",
|
||||||
|
"name": "Ctrl",
|
||||||
|
"label": "Ctrl (%)",
|
||||||
|
"order": 4,
|
||||||
|
"width": 6,
|
||||||
|
"height": 4,
|
||||||
|
"chartType": "line",
|
||||||
|
"xAxisType": "time",
|
||||||
|
"yAxisLabel": "%",
|
||||||
|
"removeOlder": "1",
|
||||||
|
"removeOlderUnit": "3600",
|
||||||
|
"x": 1510,
|
||||||
|
"y": 460,
|
||||||
|
"wires": [],
|
||||||
|
"showLegend": true,
|
||||||
|
"category": "topic",
|
||||||
|
"categoryType": "msg",
|
||||||
|
"xAxisProperty": "",
|
||||||
|
"xAxisPropertyType": "timestamp",
|
||||||
|
"xAxisFormat": "",
|
||||||
|
"xAxisFormatType": "auto",
|
||||||
|
"yAxisProperty": "payload",
|
||||||
|
"yAxisPropertyType": "msg",
|
||||||
|
"xmin": "",
|
||||||
|
"xmax": "",
|
||||||
|
"ymin": "",
|
||||||
|
"ymax": "",
|
||||||
|
"bins": 10,
|
||||||
|
"action": "append",
|
||||||
|
"stackSeries": false,
|
||||||
|
"pointShape": "circle",
|
||||||
|
"pointRadius": 4,
|
||||||
|
"interpolation": "linear",
|
||||||
|
"className": "",
|
||||||
|
"colors": [
|
||||||
|
"#0095FF",
|
||||||
|
"#FF0000",
|
||||||
|
"#FF7F0E",
|
||||||
|
"#2CA02C",
|
||||||
|
"#A347E1",
|
||||||
|
"#D62728",
|
||||||
|
"#FF9896",
|
||||||
|
"#9467BD",
|
||||||
|
"#C5B0D5"
|
||||||
|
],
|
||||||
|
"textColor": [
|
||||||
|
"#666666"
|
||||||
|
],
|
||||||
|
"textColorDefault": true,
|
||||||
|
"gridColor": [
|
||||||
|
"#e5e5e5"
|
||||||
|
],
|
||||||
|
"gridColorDefault": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_chart_statecode",
|
||||||
|
"type": "ui-chart",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"group": "ui_group_rm_int_vis",
|
||||||
|
"name": "State Code",
|
||||||
|
"label": "State Code (off=0 .. maint=9)",
|
||||||
|
"order": 5,
|
||||||
|
"width": 6,
|
||||||
|
"height": 4,
|
||||||
|
"chartType": "line",
|
||||||
|
"xAxisType": "time",
|
||||||
|
"yAxisLabel": "state",
|
||||||
|
"removeOlder": "1",
|
||||||
|
"removeOlderUnit": "3600",
|
||||||
|
"x": 1510,
|
||||||
|
"y": 520,
|
||||||
|
"wires": [],
|
||||||
|
"showLegend": true,
|
||||||
|
"category": "topic",
|
||||||
|
"categoryType": "msg",
|
||||||
|
"xAxisProperty": "",
|
||||||
|
"xAxisPropertyType": "timestamp",
|
||||||
|
"xAxisFormat": "",
|
||||||
|
"xAxisFormatType": "auto",
|
||||||
|
"yAxisProperty": "payload",
|
||||||
|
"yAxisPropertyType": "msg",
|
||||||
|
"xmin": "",
|
||||||
|
"xmax": "",
|
||||||
|
"ymin": "",
|
||||||
|
"ymax": "",
|
||||||
|
"bins": 10,
|
||||||
|
"action": "append",
|
||||||
|
"stackSeries": false,
|
||||||
|
"pointShape": "circle",
|
||||||
|
"pointRadius": 4,
|
||||||
|
"interpolation": "linear",
|
||||||
|
"className": "",
|
||||||
|
"colors": [
|
||||||
|
"#0095FF",
|
||||||
|
"#FF0000",
|
||||||
|
"#FF7F0E",
|
||||||
|
"#2CA02C",
|
||||||
|
"#A347E1",
|
||||||
|
"#D62728",
|
||||||
|
"#FF9896",
|
||||||
|
"#9467BD",
|
||||||
|
"#C5B0D5"
|
||||||
|
],
|
||||||
|
"textColor": [
|
||||||
|
"#666666"
|
||||||
|
],
|
||||||
|
"textColorDefault": true,
|
||||||
|
"gridColor": [
|
||||||
|
"#e5e5e5"
|
||||||
|
],
|
||||||
|
"gridColorDefault": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_ctrl_setpoint_for_chart",
|
||||||
|
"type": "function",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"name": "ctrl setpoint series",
|
||||||
|
"func": "msg.topic = 'setpoint_ctrl';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
|
||||||
|
"outputs": 1,
|
||||||
|
"noerr": 0,
|
||||||
|
"initialize": "",
|
||||||
|
"finalize": "",
|
||||||
|
"libs": [],
|
||||||
|
"x": 560,
|
||||||
|
"y": 220,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_int_chart_ctrl"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_flow_setpoint_for_chart",
|
||||||
|
"type": "function",
|
||||||
|
"z": "12f41a7b538c40db",
|
||||||
|
"name": "flow setpoint series",
|
||||||
|
"func": "msg.topic = 'setpoint_flow';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
|
||||||
|
"outputs": 1,
|
||||||
|
"noerr": 0,
|
||||||
|
"initialize": "",
|
||||||
|
"finalize": "",
|
||||||
|
"libs": [],
|
||||||
|
"x": 570,
|
||||||
|
"y": 330,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"rm_int_chart_flow"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "Control module rotatingMachine",
|
"description": "Control module rotatingMachine",
|
||||||
"main": "rotatingMachine.js",
|
"main": "rotatingMachine.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node rotatingMachine.js"
|
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -24,16 +24,23 @@
|
|||||||
warmup: { value: 0 },
|
warmup: { value: 0 },
|
||||||
shutdown: { value: 0 },
|
shutdown: { value: 0 },
|
||||||
cooldown: { value: 0 },
|
cooldown: { value: 0 },
|
||||||
machineCurve: { value: {}},
|
movementMode : { value: "staticspeed" }, // static or dynamic
|
||||||
flowNumber: { value: 1, required: true },
|
machineCurve : { value: {}},
|
||||||
|
processOutputFormat: { value: "process" },
|
||||||
|
dbaseOutputFormat: { value: "influxdb" },
|
||||||
|
|
||||||
//define asset properties
|
//define asset properties
|
||||||
uuid: { value: "" },
|
uuid: { value: "" },
|
||||||
|
assetTagNumber: { value: "" },
|
||||||
supplier: { value: "" },
|
supplier: { value: "" },
|
||||||
category: { value: "" },
|
category: { value: "" },
|
||||||
assetType: { value: "" },
|
assetType: { value: "" },
|
||||||
model: { value: "" },
|
model: { value: "" },
|
||||||
unit: { value: "" },
|
unit: { value: "" },
|
||||||
|
curvePressureUnit: { value: "mbar" },
|
||||||
|
curveFlowUnit: { value: "" },
|
||||||
|
curvePowerUnit: { value: "kW" },
|
||||||
|
curveControlUnit: { value: "%" },
|
||||||
|
|
||||||
//logger properties
|
//logger properties
|
||||||
enableLog: { value: false },
|
enableLog: { value: false },
|
||||||
@@ -55,16 +62,20 @@
|
|||||||
icon: "font-awesome/fa-cog",
|
icon: "font-awesome/fa-cog",
|
||||||
|
|
||||||
label: function () {
|
label: function () {
|
||||||
return this.positionIcon + " " + this.category.slice(0, -1) || "Machine";
|
return (this.positionIcon || "") + " " + (this.category || "Machine");
|
||||||
},
|
},
|
||||||
|
|
||||||
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();
|
||||||
@@ -75,6 +86,10 @@
|
|||||||
document.getElementById("node-input-warmup");
|
document.getElementById("node-input-warmup");
|
||||||
document.getElementById("node-input-shutdown");
|
document.getElementById("node-input-shutdown");
|
||||||
document.getElementById("node-input-cooldown");
|
document.getElementById("node-input-cooldown");
|
||||||
|
const movementMode = document.getElementById("node-input-movementMode");
|
||||||
|
if (movementMode) {
|
||||||
|
movementMode.value = this.movementMode || "staticspeed";
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
oneditsave: function() {
|
oneditsave: function() {
|
||||||
@@ -100,6 +115,9 @@
|
|||||||
node[field] = value;
|
node[field] = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
node.movementMode = document.getElementById("node-input-movementMode").value;
|
||||||
|
console.log(`----------------> Saving movementMode: ${node.movementMode}`);
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -110,27 +128,53 @@
|
|||||||
<!-- 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-flowNumber"><i class="fa fa-clock-o"></i> Flow Number</label>
|
<label for="node-input-movementMode"><i class="fa fa-exchange"></i> Movement Mode</label>
|
||||||
<input type="number" id="node-input-flowNumber" style="width:60%;" />
|
<select id="node-input-movementMode" style="width:60%;">
|
||||||
|
<option value="staticspeed">Static</option>
|
||||||
|
<option value="dynspeed">Dynamic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Output Formats</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||||
|
<select id="node-input-processOutputFormat" style="width:60%;">
|
||||||
|
<option value="process">process</option>
|
||||||
|
<option value="json">json</option>
|
||||||
|
<option value="csv">csv</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||||
|
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||||
|
<option value="influxdb">influxdb</option>
|
||||||
|
<option value="json">json</option>
|
||||||
|
<option value="csv">csv</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Asset fields injected here -->
|
<!-- Asset fields injected here -->
|
||||||
@@ -145,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>
|
||||||
259
src/nodeClass.js
259
src/nodeClass.js
@@ -4,7 +4,7 @@
|
|||||||
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
|
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
|
||||||
* This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers.
|
* This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers.
|
||||||
*/
|
*/
|
||||||
const { outputUtils, configManager } = require('generalFunctions');
|
const { outputUtils, configManager, convert } = require('generalFunctions');
|
||||||
const Specific = require("./specificClass");
|
const Specific = require("./specificClass");
|
||||||
|
|
||||||
class nodeClass {
|
class nodeClass {
|
||||||
@@ -21,6 +21,7 @@ class nodeClass {
|
|||||||
this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED
|
this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED
|
||||||
this.source = null; // Will hold the specific class instance
|
this.source = null; // Will hold the specific class instance
|
||||||
this.config = null; // Will hold the merged configuration
|
this.config = null; // Will hold the merged configuration
|
||||||
|
this._pressureInitWarned = false;
|
||||||
|
|
||||||
// Load default & UI config
|
// Load default & UI config
|
||||||
this._loadConfig(uiConfig,this.node);
|
this._loadConfig(uiConfig,this.node);
|
||||||
@@ -41,36 +42,62 @@ class nodeClass {
|
|||||||
* @param {object} uiConfig - Raw config from Node-RED UI.
|
* @param {object} uiConfig - Raw config from Node-RED UI.
|
||||||
*/
|
*/
|
||||||
_loadConfig(uiConfig,node) {
|
_loadConfig(uiConfig,node) {
|
||||||
|
const cfgMgr = new configManager();
|
||||||
// Merge UI config over defaults
|
const resolvedAssetUuid = uiConfig.assetUuid || uiConfig.uuid || null;
|
||||||
this.config = {
|
const resolvedAssetTagCode = uiConfig.assetTagCode || uiConfig.assetTagNumber || null;
|
||||||
general: {
|
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
|
||||||
id: node.id, // node.id is for the child registration process
|
const curveUnits = {
|
||||||
unit: uiConfig.unit, // add converter options later to convert to default units (need like a model that defines this which units we are going to use and then conver to those standards)
|
pressure: this._resolveUnitOrFallback(uiConfig.curvePressureUnit, 'pressure', 'mbar', 'curve pressure'),
|
||||||
logging: {
|
flow: this._resolveUnitOrFallback(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit, 'curve flow'),
|
||||||
enabled: uiConfig.enableLog,
|
power: this._resolveUnitOrFallback(uiConfig.curvePowerUnit, 'power', 'kW', 'curve power'),
|
||||||
logLevel: uiConfig.logLevel
|
control: this._resolveControlUnitOrFallback(uiConfig.curveControlUnit, '%'),
|
||||||
}
|
|
||||||
},
|
|
||||||
asset: {
|
|
||||||
uuid: uiConfig.assetUuid, //need to add this later to the asset model
|
|
||||||
tagCode: uiConfig.assetTagCode, //need to add this later to the asset model
|
|
||||||
supplier: uiConfig.supplier,
|
|
||||||
category: uiConfig.category, //add later to define as the software type
|
|
||||||
type: uiConfig.assetType,
|
|
||||||
model: uiConfig.model,
|
|
||||||
unit: uiConfig.unit
|
|
||||||
},
|
|
||||||
functionality: {
|
|
||||||
positionVsParent: uiConfig.positionVsParent
|
|
||||||
},
|
|
||||||
flowNumber: uiConfig.flowNumber
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build config: base sections + rotatingMachine-specific domain config
|
||||||
|
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
|
||||||
|
flowNumber: uiConfig.flowNumber
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override asset with rotatingMachine-specific fields
|
||||||
|
this.config.asset = {
|
||||||
|
...this.config.asset,
|
||||||
|
uuid: resolvedAssetUuid,
|
||||||
|
tagCode: resolvedAssetTagCode,
|
||||||
|
tagNumber: uiConfig.assetTagNumber || null,
|
||||||
|
unit: flowUnit,
|
||||||
|
curveUnits
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure general unit uses resolved flow unit
|
||||||
|
this.config.general.unit = flowUnit;
|
||||||
|
|
||||||
// Utility for formatting outputs
|
// Utility for formatting outputs
|
||||||
this._output = new outputUtils();
|
this._output = new outputUtils();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) {
|
||||||
|
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||||
|
const fallback = String(fallbackUnit || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const desc = convert().describe(raw);
|
||||||
|
if (expectedMeasure && desc.measure !== expectedMeasure) {
|
||||||
|
throw new Error(`expected '${expectedMeasure}' but got '${desc.measure}'`);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
} catch (error) {
|
||||||
|
this.node?.warn?.(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveControlUnitOrFallback(candidate, fallback = '%') {
|
||||||
|
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||||
|
return raw || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiate the core Measurement logic and store as source.
|
* Instantiate the core Measurement logic and store as source.
|
||||||
*/
|
*/
|
||||||
@@ -81,12 +108,13 @@ class nodeClass {
|
|||||||
const stateConfig = {
|
const stateConfig = {
|
||||||
general: {
|
general: {
|
||||||
logging: {
|
logging: {
|
||||||
enabled: machineConfig.eneableLog,
|
enabled: machineConfig.general.logging.enabled,
|
||||||
logLevel: machineConfig.logLevel
|
logLevel: machineConfig.general.logging.logLevel
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
movement: {
|
movement: {
|
||||||
speed: Number(uiConfig.speed)
|
speed: Number(uiConfig.speed),
|
||||||
|
mode: uiConfig.movementMode
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
starting: Number(uiConfig.startup),
|
starting: Number(uiConfig.startup),
|
||||||
@@ -115,8 +143,25 @@ class nodeClass {
|
|||||||
try {
|
try {
|
||||||
const mode = m.currentMode;
|
const mode = m.currentMode;
|
||||||
const state = m.state.getCurrentState();
|
const state = m.state.getCurrentState();
|
||||||
const flow = Math.round(m.measurements.type("flow").variant("predicted").position('downstream').getCurrentValue());
|
const requiresPressurePrediction = ["operational", "warmingup", "accelerating", "decelerating"].includes(state);
|
||||||
const power = Math.round(m.measurements.type("power").variant("predicted").position('upstream').getCurrentValue());
|
const pressureStatus = typeof m.getPressureInitializationStatus === "function"
|
||||||
|
? m.getPressureInitializationStatus()
|
||||||
|
: { initialized: true };
|
||||||
|
|
||||||
|
if (requiresPressurePrediction && !pressureStatus.initialized) {
|
||||||
|
if (!this._pressureInitWarned) {
|
||||||
|
this.node.warn("Pressure input is not initialized (upstream/downstream missing). Predictions are using minimum pressure.");
|
||||||
|
this._pressureInitWarned = true;
|
||||||
|
}
|
||||||
|
return { fill: "yellow", shape: "ring", text: `${mode}: pressure not initialized` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pressureStatus.initialized) {
|
||||||
|
this._pressureInitWarned = false;
|
||||||
|
}
|
||||||
|
const flowUnit = m?.config?.general?.unit || 'm3/h';
|
||||||
|
const flow = Math.round(m.measurements.type("flow").variant("predicted").position('downstream').getCurrentValue(flowUnit));
|
||||||
|
const power = Math.round(m.measurements.type("power").variant("predicted").position('atEquipment').getCurrentValue('kW'));
|
||||||
let symbolState;
|
let symbolState;
|
||||||
switch(state){
|
switch(state){
|
||||||
case "off":
|
case "off":
|
||||||
@@ -146,6 +191,9 @@ class nodeClass {
|
|||||||
case "decelerating":
|
case "decelerating":
|
||||||
symbolState = "⏪";
|
symbolState = "⏪";
|
||||||
break;
|
break;
|
||||||
|
case "maintenance":
|
||||||
|
symbolState = "🔧";
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
const position = m.state.getCurrentPosition();
|
const position = m.state.getCurrentPosition();
|
||||||
const roundedPosition = Math.round(position * 100) / 100;
|
const roundedPosition = Math.round(position * 100) / 100;
|
||||||
@@ -159,16 +207,16 @@ class nodeClass {
|
|||||||
status = { fill: "blue", shape: "dot", text: `${mode}: ${symbolState}` };
|
status = { fill: "blue", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||||
break;
|
break;
|
||||||
case "operational":
|
case "operational":
|
||||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}m³/h | ⚡${power}kW` };
|
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||||
break;
|
break;
|
||||||
case "starting":
|
case "starting":
|
||||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||||
break;
|
break;
|
||||||
case "warmingup":
|
case "warmingup":
|
||||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}m³/h | ⚡${power}kW` };
|
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||||
break;
|
break;
|
||||||
case "accelerating":
|
case "accelerating":
|
||||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}%| 💨${flow}m³/h | ⚡${power}kW` };
|
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}%| 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||||
break;
|
break;
|
||||||
case "stopping":
|
case "stopping":
|
||||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||||
@@ -177,14 +225,14 @@ class nodeClass {
|
|||||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||||
break;
|
break;
|
||||||
case "decelerating":
|
case "decelerating":
|
||||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} - ${roundedPosition}% | 💨${flow}m³/h | ⚡${power}kW` };
|
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} - ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` };
|
status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||||
}
|
}
|
||||||
return status;
|
return status;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
node.error("Error in updateNodeStatus: " + error.message);
|
this.node.error("Error in updateNodeStatus: " + error.message);
|
||||||
return { fill: "red", shape: "ring", text: "Status Error" };
|
return { fill: "red", shape: "ring", text: "Status Error" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +245,7 @@ class nodeClass {
|
|||||||
this.node.send([
|
this.node.send([
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
{ topic: 'registerChild', payload: this.config.general.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' },
|
{ topic: 'registerChild', payload: this.node.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' },
|
||||||
]);
|
]);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
@@ -206,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);
|
||||||
@@ -225,56 +274,126 @@ class nodeClass {
|
|||||||
//this.source.tick();
|
//this.source.tick();
|
||||||
|
|
||||||
const raw = this.source.getOutput();
|
const raw = this.source.getOutput();
|
||||||
const processMsg = this._output.formatMsg(raw, this.config, 'process');
|
const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
|
||||||
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
|
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
|
||||||
|
|
||||||
// Send only updated outputs on ports 0 & 1
|
// Send only updated outputs on ports 0 & 1
|
||||||
this.node.send([processMsg, influxMsg]);
|
this.node.send([processMsg, influxMsg, null]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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;
|
||||||
switch(msg.topic) {
|
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
|
||||||
case 'registerChild':
|
|
||||||
// Register this node as a child of the parent node
|
try {
|
||||||
|
switch(msg.topic) {
|
||||||
|
case 'registerChild': {
|
||||||
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) {
|
||||||
|
this.node.warn(`registerChild failed: child '${childId}' not found or has no source`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
m.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
|
m.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'setMode':
|
case 'setMode':
|
||||||
m.setMode(msg.payload);
|
m.setMode(msg.payload);
|
||||||
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;
|
||||||
|
}
|
||||||
|
case 'simulateMeasurement':
|
||||||
|
{
|
||||||
|
const payload = msg.payload || {};
|
||||||
|
const type = String(payload.type || '').toLowerCase();
|
||||||
|
const position = payload.position || 'atEquipment';
|
||||||
|
const value = Number(payload.value);
|
||||||
|
const unit = typeof payload.unit === 'string' ? payload.unit.trim() : '';
|
||||||
|
const supportedTypes = new Set(['pressure', 'flow', 'temperature', 'power']);
|
||||||
|
const context = {
|
||||||
|
timestamp: payload.timestamp || Date.now(),
|
||||||
|
unit,
|
||||||
|
childName: 'dashboard-sim',
|
||||||
|
childId: 'dashboard-sim',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
this.node.warn('simulateMeasurement payload.value must be a finite number');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportedTypes.has(type)) {
|
||||||
|
this.node.warn(`Unsupported simulateMeasurement type: ${type}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
this.node.warn('simulateMeasurement payload.unit is required');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof m.isUnitValidForType === 'function' && !m.isUnitValidForType(type, unit)) {
|
||||||
|
this.node.warn(`simulateMeasurement payload.unit '${unit}' is invalid for type '${type}'`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'pressure':
|
||||||
|
if (typeof m.updateSimulatedMeasurement === "function") {
|
||||||
|
m.updateSimulatedMeasurement(type, position, value, context);
|
||||||
|
} else {
|
||||||
|
m.updateMeasuredPressure(value, position, context);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'flow':
|
||||||
|
m.updateMeasuredFlow(value, position, context);
|
||||||
|
break;
|
||||||
|
case 'temperature':
|
||||||
|
m.updateMeasuredTemperature(value, position, context);
|
||||||
|
break;
|
||||||
|
case 'power':
|
||||||
|
m.updateMeasuredPower(value, position, context);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'showWorkingCurves':
|
case 'showWorkingCurves':
|
||||||
m.showWorkingCurves();
|
nodeSend([{ ...msg, topic : "showWorkingCurves" , payload: m.showWorkingCurves() }, null, null]);
|
||||||
send({ topic : "Showing curve" , payload: m.showWorkingCurves() });
|
|
||||||
break;
|
break;
|
||||||
case 'CoG':
|
case 'CoG':
|
||||||
m.showCoG();
|
nodeSend([{ ...msg, topic : "showCoG" , payload: m.showCoG() }, null, null]);
|
||||||
send({ topic : "Showing CoG" , payload: m.showCoG() });
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (typeof done === 'function') done();
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof done === 'function') {
|
||||||
|
done(error);
|
||||||
|
} else {
|
||||||
|
this.node.error(error, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,9 +402,29 @@ 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);
|
||||||
done();
|
|
||||||
|
// 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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1415
src/specificClass.js
1415
src/specificClass.js
File diff suppressed because it is too large
Load Diff
31
test/basic/constructor.basic.test.js
Normal file
31
test/basic/constructor.basic.test.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('constructor initializes with valid curve model', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
assert.equal(machine.hasCurve, true);
|
||||||
|
assert.ok(machine.predictFlow);
|
||||||
|
assert.ok(machine.predictPower);
|
||||||
|
assert.ok(machine.predictCtrl);
|
||||||
|
|
||||||
|
const out = machine.getOutput();
|
||||||
|
assert.ok('state' in out);
|
||||||
|
assert.ok('mode' in out);
|
||||||
|
assert.ok('ctrl' in out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('constructor handles missing curve model without throwing', () => {
|
||||||
|
const cfg = makeMachineConfig({ asset: { supplier: 'x', category: 'machine', type: 'pump', model: 'not-existing-model', unit: 'm3/h' } });
|
||||||
|
const machine = new Machine(cfg, makeStateConfig());
|
||||||
|
|
||||||
|
assert.equal(machine.hasCurve, false);
|
||||||
|
assert.equal(machine.predictFlow, null);
|
||||||
|
assert.equal(machine.predictPower, null);
|
||||||
|
assert.equal(machine.predictCtrl, null);
|
||||||
|
|
||||||
|
const out = machine.getOutput();
|
||||||
|
assert.ok('state' in out);
|
||||||
|
});
|
||||||
44
test/basic/mode-and-input.basic.test.js
Normal file
44
test/basic/mode-and-input.basic.test.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('setMode changes mode only for allowed values', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
const original = machine.currentMode;
|
||||||
|
|
||||||
|
machine.setMode('virtualControl');
|
||||||
|
assert.equal(machine.currentMode, 'virtualControl');
|
||||||
|
|
||||||
|
machine.setMode('invalid-mode');
|
||||||
|
assert.equal(machine.currentMode, 'virtualControl');
|
||||||
|
assert.notEqual(machine.currentMode, original);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleInput rejects non-string action safely', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
await assert.doesNotReject(async () => {
|
||||||
|
await machine.handleInput('GUI', 123, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleInput ignores disallowed source/action combination', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
machine.setMode('fysicalControl');
|
||||||
|
|
||||||
|
const before = machine.state.getCurrentState();
|
||||||
|
await machine.handleInput('GUI', 'execSequence', 'startup');
|
||||||
|
const after = machine.state.getCurrentState();
|
||||||
|
|
||||||
|
assert.equal(before, after);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warmingup is treated as active for prediction updates', () => {
|
||||||
|
const machine = new Machine(
|
||||||
|
makeMachineConfig(),
|
||||||
|
makeStateConfig({ state: { current: 'warmingup' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(machine._isOperationalState(), true);
|
||||||
|
});
|
||||||
109
test/basic/nodeClass-config.basic.test.js
Normal file
109
test/basic/nodeClass-config.basic.test.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const NodeClass = require('../../src/nodeClass');
|
||||||
|
const { makeNodeStub } = require('../helpers/factories');
|
||||||
|
|
||||||
|
function makeUiConfig(overrides = {}) {
|
||||||
|
return {
|
||||||
|
unit: 'm3/h',
|
||||||
|
enableLog: true,
|
||||||
|
logLevel: 'debug',
|
||||||
|
supplier: 'hidrostal',
|
||||||
|
category: 'machine',
|
||||||
|
assetType: 'pump',
|
||||||
|
model: 'hidrostal-H05K-S03R',
|
||||||
|
curvePressureUnit: 'mbar',
|
||||||
|
curveFlowUnit: 'm3/h',
|
||||||
|
curvePowerUnit: 'kW',
|
||||||
|
curveControlUnit: '%',
|
||||||
|
positionVsParent: 'atEquipment',
|
||||||
|
speed: 1,
|
||||||
|
movementMode: 'staticspeed',
|
||||||
|
startup: 0,
|
||||||
|
warmup: 0,
|
||||||
|
shutdown: 0,
|
||||||
|
cooldown: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('_loadConfig maps legacy editor fields for asset identity', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
inst.node = makeNodeStub();
|
||||||
|
inst.name = 'rotatingMachine';
|
||||||
|
|
||||||
|
inst._loadConfig(
|
||||||
|
makeUiConfig({
|
||||||
|
uuid: 'uuid-from-editor',
|
||||||
|
assetTagNumber: 'TAG-123',
|
||||||
|
}),
|
||||||
|
inst.node
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(inst.config.asset.uuid, 'uuid-from-editor');
|
||||||
|
assert.equal(inst.config.asset.tagCode, 'TAG-123');
|
||||||
|
assert.equal(inst.config.asset.tagNumber, 'TAG-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_loadConfig prefers explicit assetUuid/assetTagCode when present', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
inst.node = makeNodeStub();
|
||||||
|
inst.name = 'rotatingMachine';
|
||||||
|
|
||||||
|
inst._loadConfig(
|
||||||
|
makeUiConfig({
|
||||||
|
uuid: 'legacy-uuid',
|
||||||
|
assetUuid: 'explicit-uuid',
|
||||||
|
assetTagNumber: 'legacy-tag',
|
||||||
|
assetTagCode: 'explicit-tag',
|
||||||
|
}),
|
||||||
|
inst.node
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(inst.config.asset.uuid, 'explicit-uuid');
|
||||||
|
assert.equal(inst.config.asset.tagCode, 'explicit-tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_loadConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
inst.node = makeNodeStub();
|
||||||
|
inst.name = 'rotatingMachine';
|
||||||
|
|
||||||
|
inst._loadConfig(
|
||||||
|
makeUiConfig({
|
||||||
|
unit: 'not-a-unit',
|
||||||
|
curvePressureUnit: 'mbar',
|
||||||
|
curveFlowUnit: 'm3/h',
|
||||||
|
curvePowerUnit: 'kW',
|
||||||
|
curveControlUnit: '%',
|
||||||
|
}),
|
||||||
|
inst.node
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(inst.config.general.unit, 'm3/h');
|
||||||
|
assert.equal(inst.config.asset.unit, 'm3/h');
|
||||||
|
assert.equal(inst.config.asset.curveUnits.pressure, 'mbar');
|
||||||
|
assert.equal(inst.config.asset.curveUnits.flow, 'm3/h');
|
||||||
|
assert.equal(inst.config.asset.curveUnits.power, 'kW');
|
||||||
|
assert.equal(inst.config.asset.curveUnits.control, '%');
|
||||||
|
assert.ok(inst.node._warns.length >= 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_setupSpecificClass propagates logging settings into state config', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
inst.node = makeNodeStub();
|
||||||
|
inst.name = 'rotatingMachine';
|
||||||
|
const uiConfig = makeUiConfig({
|
||||||
|
enableLog: true,
|
||||||
|
logLevel: 'warn',
|
||||||
|
uuid: 'uuid-test',
|
||||||
|
assetTagNumber: 'TAG-9',
|
||||||
|
});
|
||||||
|
|
||||||
|
inst._loadConfig(uiConfig, inst.node);
|
||||||
|
inst._setupSpecificClass(uiConfig);
|
||||||
|
|
||||||
|
assert.equal(inst.source.state.config.general.logging.enabled, true);
|
||||||
|
assert.equal(inst.source.state.config.general.logging.logLevel, 'warn');
|
||||||
|
});
|
||||||
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()
|
||||||
74
test/edge/error-paths.edge.test.js
Normal file
74
test/edge/error-paths.edge.test.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const NodeClass = require('../../src/nodeClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig, makeNodeStub } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('setpoint rejects negative inputs without throwing', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
await assert.doesNotReject(async () => {
|
||||||
|
await machine.setpoint(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setpoint is constrained to safe movement/curve bounds', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
const requested = [];
|
||||||
|
machine.state.moveTo = async (target) => {
|
||||||
|
requested.push(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateMin = machine.state.movementManager.minPosition;
|
||||||
|
const stateMax = machine.state.movementManager.maxPosition;
|
||||||
|
const curveMin = machine.predictFlow.currentFxyXMin;
|
||||||
|
const curveMax = machine.predictFlow.currentFxyXMax;
|
||||||
|
const min = Math.max(stateMin, curveMin);
|
||||||
|
const max = Math.min(stateMax, curveMax);
|
||||||
|
|
||||||
|
await machine.setpoint(min - 100);
|
||||||
|
await machine.setpoint(max + 100);
|
||||||
|
|
||||||
|
assert.equal(requested.length, 2);
|
||||||
|
assert.equal(requested[0], min);
|
||||||
|
assert.equal(requested[1], max);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nodeClass _updateNodeStatus returns error status on internal failure', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
const node = makeNodeStub();
|
||||||
|
inst.node = node;
|
||||||
|
inst.source = {
|
||||||
|
currentMode: 'auto',
|
||||||
|
state: {
|
||||||
|
getCurrentState() {
|
||||||
|
throw new Error('boom');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = inst._updateNodeStatus();
|
||||||
|
assert.equal(status.text, 'Status Error');
|
||||||
|
assert.equal(node._errors.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('measurement handlers reject incompatible units', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
assert.equal(machine.isUnitValidForType('flow', 'm3/h'), true);
|
||||||
|
assert.equal(machine.isUnitValidForType('flow', 'mbar'), false);
|
||||||
|
|
||||||
|
machine.updateMeasuredFlow(100, 'downstream', {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
unit: 'mbar',
|
||||||
|
childName: 'bad-ft',
|
||||||
|
});
|
||||||
|
|
||||||
|
const measuredFlow = machine.measurements
|
||||||
|
.type('flow')
|
||||||
|
.variant('measured')
|
||||||
|
.position('downstream')
|
||||||
|
.getCurrentValue();
|
||||||
|
|
||||||
|
assert.equal(measuredFlow, null);
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
187
test/edge/nodeClass-routing.edge.test.js
Normal file
187
test/edge/nodeClass-routing.edge.test.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const NodeClass = require('../../src/nodeClass');
|
||||||
|
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('input handler routes topics to source methods', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
const node = makeNodeStub();
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
inst.node = node;
|
||||||
|
inst.RED = makeREDStub({
|
||||||
|
child1: {
|
||||||
|
source: { id: 'child-source' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
inst.source = {
|
||||||
|
childRegistrationUtils: {
|
||||||
|
registerChild(childSource, pos) {
|
||||||
|
calls.push(['registerChild', childSource, pos]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setMode(mode) {
|
||||||
|
calls.push(['setMode', mode]);
|
||||||
|
},
|
||||||
|
handleInput(source, action, parameter) {
|
||||||
|
calls.push(['handleInput', source, action, parameter]);
|
||||||
|
},
|
||||||
|
showWorkingCurves() {
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
showCoG() {
|
||||||
|
return { cog: 1 };
|
||||||
|
},
|
||||||
|
updateSimulatedMeasurement(type, position, value) {
|
||||||
|
calls.push(['updateSimulatedMeasurement', type, position, value]);
|
||||||
|
},
|
||||||
|
updateMeasuredPressure(value, position) {
|
||||||
|
calls.push(['updateMeasuredPressure', value, position]);
|
||||||
|
},
|
||||||
|
updateMeasuredFlow(value, position) {
|
||||||
|
calls.push(['updateMeasuredFlow', value, position]);
|
||||||
|
},
|
||||||
|
updateMeasuredPower(value, position) {
|
||||||
|
calls.push(['updateMeasuredPower', value, position]);
|
||||||
|
},
|
||||||
|
updateMeasuredTemperature(value, position) {
|
||||||
|
calls.push(['updateMeasuredTemperature', value, position]);
|
||||||
|
},
|
||||||
|
isUnitValidForType() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
inst._attachInputHandler();
|
||||||
|
const onInput = node._handlers.input;
|
||||||
|
|
||||||
|
onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
|
||||||
|
onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } }, () => {}, () => {});
|
||||||
|
onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
|
||||||
|
onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
|
||||||
|
onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
|
||||||
|
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
|
||||||
|
onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
|
||||||
|
|
||||||
|
assert.deepEqual(calls[0], ['setMode', 'auto']);
|
||||||
|
assert.deepEqual(calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
|
||||||
|
assert.deepEqual(calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
|
||||||
|
assert.deepEqual(calls[3], ['handleInput', 'GUI', 'emergencystop', undefined]);
|
||||||
|
assert.deepEqual(calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
|
||||||
|
assert.deepEqual(calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
|
||||||
|
assert.deepEqual(calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('simulateMeasurement warns and ignores invalid payloads', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
const node = makeNodeStub();
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
inst.node = node;
|
||||||
|
inst.RED = makeREDStub();
|
||||||
|
inst.source = {
|
||||||
|
childRegistrationUtils: { registerChild() {} },
|
||||||
|
setMode() {},
|
||||||
|
handleInput() {},
|
||||||
|
showWorkingCurves() { return {}; },
|
||||||
|
showCoG() { return {}; },
|
||||||
|
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
|
||||||
|
updateMeasuredPressure() { calls.push('updateMeasuredPressure'); },
|
||||||
|
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
|
||||||
|
updateMeasuredPower() { calls.push('updateMeasuredPower'); },
|
||||||
|
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
|
||||||
|
};
|
||||||
|
|
||||||
|
inst._attachInputHandler();
|
||||||
|
const onInput = node._handlers.input;
|
||||||
|
|
||||||
|
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
|
||||||
|
onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
|
||||||
|
onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
|
||||||
|
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
assert.equal(node._warns.length, 3);
|
||||||
|
assert.match(String(node._warns[0]), /finite number/i);
|
||||||
|
assert.match(String(node._warns[1]), /payload\.unit is required/i);
|
||||||
|
assert.match(String(node._warns[2]), /unsupported simulatemeasurement type/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status shows warning when pressure inputs are not initialized', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
const node = makeNodeStub();
|
||||||
|
|
||||||
|
inst.node = node;
|
||||||
|
inst.source = {
|
||||||
|
currentMode: 'virtualControl',
|
||||||
|
state: {
|
||||||
|
getCurrentState() {
|
||||||
|
return 'operational';
|
||||||
|
},
|
||||||
|
getCurrentPosition() {
|
||||||
|
return 50;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getPressureInitializationStatus() {
|
||||||
|
return { initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false };
|
||||||
|
},
|
||||||
|
measurements: {
|
||||||
|
type() {
|
||||||
|
return {
|
||||||
|
variant() {
|
||||||
|
return {
|
||||||
|
position() {
|
||||||
|
return { getCurrentValue() { return 0; } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = inst._updateNodeStatus();
|
||||||
|
const statusAgain = inst._updateNodeStatus();
|
||||||
|
|
||||||
|
assert.equal(status.fill, 'yellow');
|
||||||
|
assert.equal(status.shape, 'ring');
|
||||||
|
assert.match(status.text, /pressure not initialized/i);
|
||||||
|
assert.equal(statusAgain.fill, 'yellow');
|
||||||
|
assert.equal(node._warns.length, 1);
|
||||||
|
assert.match(String(node._warns[0]), /Pressure input is not initialized/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showWorkingCurves and CoG route reply messages to process output index', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
const node = makeNodeStub();
|
||||||
|
inst.node = node;
|
||||||
|
inst.RED = makeREDStub();
|
||||||
|
inst.source = {
|
||||||
|
childRegistrationUtils: { registerChild() {} },
|
||||||
|
setMode() {},
|
||||||
|
handleInput() {},
|
||||||
|
showWorkingCurves() {
|
||||||
|
return { curve: [1, 2, 3] };
|
||||||
|
},
|
||||||
|
showCoG() {
|
||||||
|
return { cog: 0.77 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
inst._attachInputHandler();
|
||||||
|
const onInput = node._handlers.input;
|
||||||
|
const sent = [];
|
||||||
|
const send = (out) => sent.push(out);
|
||||||
|
|
||||||
|
onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
|
||||||
|
onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
|
||||||
|
|
||||||
|
assert.equal(sent.length, 2);
|
||||||
|
assert.equal(Array.isArray(sent[0]), true);
|
||||||
|
assert.equal(sent[0].length, 3);
|
||||||
|
assert.equal(sent[0][0].topic, 'showWorkingCurves');
|
||||||
|
assert.equal(sent[0][1], null);
|
||||||
|
assert.equal(sent[0][2], null);
|
||||||
|
assert.equal(sent[1][0].topic, 'showCoG');
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
116
test/helpers/factories.js
Normal file
116
test/helpers/factories.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
const { MeasurementContainer } = require('generalFunctions');
|
||||||
|
|
||||||
|
function makeMachineConfig(overrides = {}) {
|
||||||
|
return {
|
||||||
|
general: {
|
||||||
|
id: 'rm-test-1',
|
||||||
|
name: 'rotating-machine-test',
|
||||||
|
unit: 'm3/h',
|
||||||
|
logging: { enabled: false, logLevel: 'error' },
|
||||||
|
},
|
||||||
|
functionality: {
|
||||||
|
positionVsParent: 'atEquipment',
|
||||||
|
},
|
||||||
|
asset: {
|
||||||
|
supplier: 'hidrostal',
|
||||||
|
category: 'machine',
|
||||||
|
type: 'pump',
|
||||||
|
model: 'hidrostal-H05K-S03R',
|
||||||
|
unit: 'm3/h',
|
||||||
|
curveUnits: {
|
||||||
|
pressure: 'mbar',
|
||||||
|
flow: 'm3/h',
|
||||||
|
power: 'kW',
|
||||||
|
control: '%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStateConfig(overrides = {}) {
|
||||||
|
return {
|
||||||
|
general: {
|
||||||
|
logging: { enabled: false, logLevel: 'error' },
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
current: 'idle',
|
||||||
|
},
|
||||||
|
movement: {
|
||||||
|
mode: 'staticspeed',
|
||||||
|
speed: 1000,
|
||||||
|
maxSpeed: 1000,
|
||||||
|
interval: 10,
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
starting: 0,
|
||||||
|
warmingup: 0,
|
||||||
|
stopping: 0,
|
||||||
|
coolingdown: 0,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChildMeasurement({ id = 'child-1', name = 'PT-1', positionVsParent = 'downstream', type = 'pressure', unit = 'mbar' } = {}) {
|
||||||
|
const measurements = new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
defaultUnits: {
|
||||||
|
pressure: 'mbar',
|
||||||
|
flow: 'm3/h',
|
||||||
|
temperature: 'C',
|
||||||
|
power: 'kW',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id, name },
|
||||||
|
functionality: { positionVsParent },
|
||||||
|
asset: { type, unit },
|
||||||
|
},
|
||||||
|
measurements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNodeStub() {
|
||||||
|
const handlers = {};
|
||||||
|
const sent = [];
|
||||||
|
const statuses = [];
|
||||||
|
const errors = [];
|
||||||
|
const warns = [];
|
||||||
|
return {
|
||||||
|
id: 'node-1',
|
||||||
|
source: null,
|
||||||
|
send(msg) { sent.push(msg); },
|
||||||
|
status(s) { statuses.push(s); },
|
||||||
|
error(e) { errors.push(e); },
|
||||||
|
warn(w) { warns.push(w); },
|
||||||
|
on(event, cb) { handlers[event] = cb; },
|
||||||
|
_handlers: handlers,
|
||||||
|
_sent: sent,
|
||||||
|
_statuses: statuses,
|
||||||
|
_errors: errors,
|
||||||
|
_warns: warns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeREDStub(nodeMap = {}) {
|
||||||
|
return {
|
||||||
|
nodes: {
|
||||||
|
getNode(id) {
|
||||||
|
return nodeMap[id] || null;
|
||||||
|
},
|
||||||
|
createNode() {},
|
||||||
|
registerType() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
makeMachineConfig,
|
||||||
|
makeStateConfig,
|
||||||
|
makeChildMeasurement,
|
||||||
|
makeNodeStub,
|
||||||
|
makeREDStub,
|
||||||
|
};
|
||||||
107
test/integration/basic-flow-dashboard.integration.test.js
Normal file
107
test/integration/basic-flow-dashboard.integration.test.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
function loadBasicFlow() {
|
||||||
|
const flowPath = path.join(__dirname, '../../examples/basic.flow.json');
|
||||||
|
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeContextStub() {
|
||||||
|
const store = {};
|
||||||
|
return {
|
||||||
|
get(key) {
|
||||||
|
return store[key];
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
store[key] = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('basic flow parser routes predicted_power to output index 2 with numeric payload', () => {
|
||||||
|
const flow = loadBasicFlow();
|
||||||
|
const parser = flow.find((n) => n.id === 'rm_parse_output');
|
||||||
|
assert.ok(parser, 'rm_parse_output node should exist');
|
||||||
|
assert.equal(parser.outputs, 11);
|
||||||
|
|
||||||
|
const func = new Function('msg', 'context', 'node', parser.func);
|
||||||
|
const context = makeContextStub();
|
||||||
|
const node = { send() {} };
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
payload: {
|
||||||
|
'flow.predicted.downstream.default': 220,
|
||||||
|
'power.predicted.atequipment.default': 50,
|
||||||
|
ctrl: 40,
|
||||||
|
NCogPercent: 72,
|
||||||
|
state: 'operational',
|
||||||
|
mode: 'virtualControl',
|
||||||
|
runtime: 10.2,
|
||||||
|
moveTimeleft: 0,
|
||||||
|
maintenanceTime: 150.5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = func(msg, context, node);
|
||||||
|
assert.ok(Array.isArray(out));
|
||||||
|
assert.equal(out.length, 11);
|
||||||
|
assert.equal(out[1].topic, 'predicted_power');
|
||||||
|
assert.equal(typeof out[1].payload, 'number');
|
||||||
|
assert.ok(Number.isFinite(out[1].payload));
|
||||||
|
assert.equal(out[1].payload, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('basic flow parser output index wiring matches chart nodes', () => {
|
||||||
|
const flow = loadBasicFlow();
|
||||||
|
const parser = flow.find((n) => n.id === 'rm_parse_output');
|
||||||
|
const powerChart = flow.find((n) => n.id === 'rm_chart_power');
|
||||||
|
assert.ok(parser, 'rm_parse_output node should exist');
|
||||||
|
assert.ok(powerChart, 'rm_chart_power node should exist');
|
||||||
|
|
||||||
|
assert.equal(parser.wires[1][0], 'rm_chart_power');
|
||||||
|
assert.equal(powerChart.type, 'ui-chart');
|
||||||
|
assert.equal(powerChart.chartType, 'line');
|
||||||
|
assert.equal(powerChart.xAxisType, 'time');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('basic flow parser routes pressure series to explicit pressure charts', () => {
|
||||||
|
const flow = loadBasicFlow();
|
||||||
|
const parser = flow.find((n) => n.id === 'rm_parse_output');
|
||||||
|
const upChart = flow.find((n) => n.id === 'rm_chart_pressure_up');
|
||||||
|
const downChart = flow.find((n) => n.id === 'rm_chart_pressure_down');
|
||||||
|
const deltaChart = flow.find((n) => n.id === 'rm_chart_pressure_delta');
|
||||||
|
|
||||||
|
assert.ok(parser, 'rm_parse_output node should exist');
|
||||||
|
assert.ok(upChart, 'rm_chart_pressure_up node should exist');
|
||||||
|
assert.ok(downChart, 'rm_chart_pressure_down node should exist');
|
||||||
|
assert.ok(deltaChart, 'rm_chart_pressure_delta node should exist');
|
||||||
|
|
||||||
|
assert.equal(parser.wires[5][0], 'rm_chart_pressure_up');
|
||||||
|
assert.equal(parser.wires[6][0], 'rm_chart_pressure_down');
|
||||||
|
assert.equal(parser.wires[7][0], 'rm_chart_pressure_delta');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('basic flow parser suppresses pressure chart messages when pressure inputs are incomplete', () => {
|
||||||
|
const flow = loadBasicFlow();
|
||||||
|
const parser = flow.find((n) => n.id === 'rm_parse_output');
|
||||||
|
assert.ok(parser, 'rm_parse_output node should exist');
|
||||||
|
|
||||||
|
const func = new Function('msg', 'context', 'node', parser.func);
|
||||||
|
const context = makeContextStub();
|
||||||
|
const node = { send() {} };
|
||||||
|
|
||||||
|
// Only upstream present: downstream/delta chart outputs should be null
|
||||||
|
let out = func({ payload: { 'pressure.measured.upstream.default': 950 } }, context, node);
|
||||||
|
assert.equal(out[5]?.topic, 'pressure_upstream');
|
||||||
|
assert.equal(out[6], null);
|
||||||
|
assert.equal(out[7], null);
|
||||||
|
|
||||||
|
// Once downstream arrives, delta should be emitted as finite numeric payload
|
||||||
|
out = func({ payload: { 'pressure.measured.downstream.default': 1200 } }, context, node);
|
||||||
|
assert.equal(out[6]?.topic, 'pressure_downstream');
|
||||||
|
assert.equal(out[7]?.topic, 'pressure_delta');
|
||||||
|
assert.equal(typeof out[7].payload, 'number');
|
||||||
|
assert.ok(Number.isFinite(out[7].payload));
|
||||||
|
});
|
||||||
59
test/integration/coolprop.integration.test.js
Normal file
59
test/integration/coolprop.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('calcEfficiency runs through coolprop path without mocks', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
machine.measurements.type('pressure').variant('measured').position('downstream').value(1200, 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(120, Date.now(), 'm3/h');
|
||||||
|
machine.measurements.type('power').variant('predicted').position('atEquipment').value(12, Date.now(), 'kW');
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
machine.calcEfficiency(12, 120, 'predicted');
|
||||||
|
});
|
||||||
|
|
||||||
|
const eff = machine.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue();
|
||||||
|
assert.equal(typeof eff, 'number');
|
||||||
|
assert.ok(eff > 0);
|
||||||
|
|
||||||
|
const pressureDiffPa = (1200 - 800) * 100; // mbar -> Pa
|
||||||
|
const flowM3s = 120 / 3600; // m3/h -> m3/s
|
||||||
|
const expectedHydraulicPower = pressureDiffPa * flowM3s;
|
||||||
|
const expectedHydraulicEfficiency = expectedHydraulicPower / 12000; // 12kW -> W
|
||||||
|
|
||||||
|
const hydraulicPower = machine.measurements.type('hydraulicPower').variant('predicted').position('atEquipment').getCurrentValue('W');
|
||||||
|
const hydraulicEfficiency = machine.measurements.type('nHydraulicEfficiency').variant('predicted').position('atEquipment').getCurrentValue();
|
||||||
|
const head = machine.measurements.type('pumpHead').variant('predicted').position('atEquipment').getCurrentValue('m');
|
||||||
|
|
||||||
|
assert.ok(Number.isFinite(hydraulicPower));
|
||||||
|
assert.ok(Number.isFinite(hydraulicEfficiency));
|
||||||
|
assert.ok(Number.isFinite(head));
|
||||||
|
assert.ok(Math.abs(hydraulicPower - expectedHydraulicPower) < 1);
|
||||||
|
assert.ok(Math.abs(hydraulicEfficiency - expectedHydraulicEfficiency) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('predictions use initialized medium pressure and not the minimum-pressure fallback', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
const mediumUpstreamMbar = 700;
|
||||||
|
const mediumDownstreamMbar = 1100;
|
||||||
|
machine.updateMeasuredPressure(mediumUpstreamMbar, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'test-pt-up' });
|
||||||
|
machine.updateMeasuredPressure(mediumDownstreamMbar, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'test-pt-down' });
|
||||||
|
|
||||||
|
const pressureStatus = machine.getPressureInitializationStatus();
|
||||||
|
assert.equal(pressureStatus.initialized, true);
|
||||||
|
assert.equal(pressureStatus.hasDifferential, true);
|
||||||
|
|
||||||
|
const rawDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa = 40000
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
75
test/integration/prediction-health.integration.test.js
Normal file
75
test/integration/prediction-health.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('flow drift is assessed with NRMSE and exposed in output', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
machine.updateMeasuredPressure(700, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
|
||||||
|
machine.updateMeasuredPressure(1100, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||||
|
machine.updatePosition();
|
||||||
|
|
||||||
|
const predictedFlow = machine.measurements
|
||||||
|
.type('flow')
|
||||||
|
.variant('predicted')
|
||||||
|
.position('downstream')
|
||||||
|
.getCurrentValue('m3/h');
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
machine.updateMeasuredFlow(predictedFlow * 0.92, 'downstream', {
|
||||||
|
timestamp: Date.now() + i,
|
||||||
|
unit: 'm3/h',
|
||||||
|
childName: 'ft-down',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
assert.ok(Number.isFinite(output.flowNrmse));
|
||||||
|
assert.equal(typeof output.flowImmediateLevel, 'number');
|
||||||
|
assert.equal(typeof output.flowLongTermLevel, 'number');
|
||||||
|
assert.ok(['high', 'medium', 'low', 'invalid'].includes(output.predictionQuality));
|
||||||
|
assert.ok(Number.isFinite(output.predictionConfidence));
|
||||||
|
assert.equal(output.predictionPressureSource, 'differential');
|
||||||
|
assert.ok(Array.isArray(output.predictionFlags));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('power drift is assessed when measured power is provided', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
machine.updateMeasuredPressure(700, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
|
||||||
|
machine.updateMeasuredPressure(1100, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||||
|
machine.updatePosition();
|
||||||
|
|
||||||
|
const predictedPower = machine.measurements
|
||||||
|
.type('power')
|
||||||
|
.variant('predicted')
|
||||||
|
.position('atEquipment')
|
||||||
|
.getCurrentValue('kW');
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
machine.updateMeasuredPower(predictedPower * 1.08, 'atEquipment', {
|
||||||
|
timestamp: Date.now() + i,
|
||||||
|
unit: 'kW',
|
||||||
|
childName: 'power-meter',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
assert.ok(Number.isFinite(output.powerNrmse));
|
||||||
|
assert.equal(typeof output.powerImmediateLevel, 'number');
|
||||||
|
assert.equal(typeof output.powerLongTermLevel, 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single-side pressure lowers prediction confidence category', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
machine.updateMeasuredPressure(950, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
assert.equal(output.predictionPressureSource, 'downstream');
|
||||||
|
assert.ok(output.predictionConfidence < 0.9);
|
||||||
|
assert.equal(output.pressureDriftLevel, 1);
|
||||||
|
assert.ok(Array.isArray(output.predictionFlags));
|
||||||
|
assert.ok(output.predictionFlags.includes('single_side_pressure'));
|
||||||
|
});
|
||||||
89
test/integration/pressure-initialization.integration.test.js
Normal file
89
test/integration/pressure-initialization.integration.test.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig, makeChildMeasurement } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('pressure initialization combinations are handled explicitly', () => {
|
||||||
|
const createMachine = () => new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
// nothing
|
||||||
|
let machine = createMachine();
|
||||||
|
let status = machine.getPressureInitializationStatus();
|
||||||
|
assert.equal(status.initialized, false);
|
||||||
|
assert.equal(status.source, null);
|
||||||
|
const noPressureValue = machine.getMeasuredPressure();
|
||||||
|
assert.equal(noPressureValue, 0);
|
||||||
|
// 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
|
||||||
|
machine = createMachine();
|
||||||
|
const upstreamOnly = 850;
|
||||||
|
machine.measurements.type('pressure').variant('measured').position('upstream').value(upstreamOnly, Date.now(), 'mbar');
|
||||||
|
status = machine.getPressureInitializationStatus();
|
||||||
|
assert.equal(status.initialized, true);
|
||||||
|
assert.equal(status.hasUpstream, true);
|
||||||
|
assert.equal(status.hasDownstream, false);
|
||||||
|
assert.equal(status.hasDifferential, false);
|
||||||
|
assert.equal(status.source, 'upstream');
|
||||||
|
const upstreamValue = machine.getMeasuredPressure();
|
||||||
|
assert.equal(Math.round(upstreamValue), upstreamOnly * 100);
|
||||||
|
assert.equal(Math.round(machine.predictFlow.fDimension), upstreamOnly * 100);
|
||||||
|
|
||||||
|
// downstream only
|
||||||
|
machine = createMachine();
|
||||||
|
const downstreamOnly = 1150;
|
||||||
|
machine.measurements.type('pressure').variant('measured').position('downstream').value(downstreamOnly, Date.now(), 'mbar');
|
||||||
|
status = machine.getPressureInitializationStatus();
|
||||||
|
assert.equal(status.initialized, true);
|
||||||
|
assert.equal(status.hasUpstream, false);
|
||||||
|
assert.equal(status.hasDownstream, true);
|
||||||
|
assert.equal(status.hasDifferential, false);
|
||||||
|
assert.equal(status.source, 'downstream');
|
||||||
|
const downstreamValue = machine.getMeasuredPressure();
|
||||||
|
assert.equal(Math.round(downstreamValue), downstreamOnly * 100);
|
||||||
|
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
const upstream = 200;
|
||||||
|
const downstream = 1100;
|
||||||
|
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');
|
||||||
|
status = machine.getPressureInitializationStatus();
|
||||||
|
assert.equal(status.initialized, true);
|
||||||
|
assert.equal(status.hasUpstream, true);
|
||||||
|
assert.equal(status.hasDownstream, true);
|
||||||
|
assert.equal(status.hasDifferential, true);
|
||||||
|
assert.equal(status.source, 'differential');
|
||||||
|
const differentialValue = machine.getMeasuredPressure();
|
||||||
|
assert.equal(Math.round(differentialValue), (downstream - upstream) * 100);
|
||||||
|
assert.equal(Math.round(machine.predictFlow.fDimension), (downstream - upstream) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real pressure child data has priority over simulated dashboard pressure', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
machine.updateSimulatedMeasurement('pressure', 'upstream', 900, { unit: 'mbar', timestamp: Date.now() });
|
||||||
|
machine.updateSimulatedMeasurement('pressure', 'downstream', 1200, { unit: 'mbar', timestamp: Date.now() });
|
||||||
|
assert.equal(Math.round(machine.getMeasuredPressure()), 30000);
|
||||||
|
|
||||||
|
const upstreamChild = makeChildMeasurement({ id: 'pt-up-real', name: 'PT Up', positionVsParent: 'upstream', type: 'pressure', unit: 'mbar' });
|
||||||
|
const downstreamChild = makeChildMeasurement({ id: 'pt-down-real', name: 'PT Down', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
|
||||||
|
|
||||||
|
await machine.childRegistrationUtils.registerChild(upstreamChild, 'upstream');
|
||||||
|
await machine.childRegistrationUtils.registerChild(downstreamChild, 'downstream');
|
||||||
|
|
||||||
|
upstreamChild.measurements.type('pressure').variant('measured').position('upstream').value(700, Date.now(), 'mbar');
|
||||||
|
downstreamChild.measurements.type('pressure').variant('measured').position('downstream').value(1300, Date.now(), 'mbar');
|
||||||
|
|
||||||
|
assert.equal(Math.round(machine.getMeasuredPressure()), 60000);
|
||||||
|
const status = machine.getPressureInitializationStatus();
|
||||||
|
assert.equal(status.source, 'differential');
|
||||||
|
assert.equal(status.initialized, true);
|
||||||
|
});
|
||||||
53
test/integration/registration.integration.test.js
Normal file
53
test/integration/registration.integration.test.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig, makeChildMeasurement } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('registerChild listens to measurement events and stores measured pressure', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
const child = makeChildMeasurement({ positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
|
||||||
|
|
||||||
|
machine.registerChild(child, 'measurement');
|
||||||
|
|
||||||
|
child.measurements
|
||||||
|
.type('pressure')
|
||||||
|
.variant('measured')
|
||||||
|
.position('downstream')
|
||||||
|
.value(123, Date.now(), 'mbar');
|
||||||
|
|
||||||
|
const stored = machine.measurements
|
||||||
|
.type('pressure')
|
||||||
|
.variant('measured')
|
||||||
|
.position('downstream')
|
||||||
|
.getCurrentValue('mbar');
|
||||||
|
|
||||||
|
assert.equal(typeof stored, 'number');
|
||||||
|
assert.equal(Math.round(stored), 123);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerChild deduplicates listeners on re-registration', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
const child = makeChildMeasurement({ id: 'pt-dup', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
|
||||||
|
const eventName = 'pressure.measured.downstream';
|
||||||
|
|
||||||
|
let handlerCalls = 0;
|
||||||
|
const originalUpdatePressure = machine.updateMeasuredPressure.bind(machine);
|
||||||
|
machine.updateMeasuredPressure = (...args) => {
|
||||||
|
handlerCalls += 1;
|
||||||
|
return originalUpdatePressure(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
machine.registerChild(child, 'measurement');
|
||||||
|
machine.registerChild(child, 'measurement');
|
||||||
|
|
||||||
|
assert.equal(child.measurements.emitter.listenerCount(eventName), 1);
|
||||||
|
|
||||||
|
child.measurements
|
||||||
|
.type('pressure')
|
||||||
|
.variant('measured')
|
||||||
|
.position('downstream')
|
||||||
|
.value(321, Date.now(), 'mbar');
|
||||||
|
|
||||||
|
assert.equal(handlerCalls, 1);
|
||||||
|
});
|
||||||
29
test/integration/sequences.integration.test.js
Normal file
29
test/integration/sequences.integration.test.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('execSequence startup reaches operational with zero transition times', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||||
|
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
|
||||||
|
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('execMovement constrains controller position to safe bounds in operational state', async () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
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);
|
||||||
|
pos = machine.state.getCurrentPosition();
|
||||||
|
assert.equal(pos, 10, 'setpoint within bounds should be applied as-is');
|
||||||
|
assert.ok(pos >= min && 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