Compare commits

...

22 Commits

Author SHA1 Message Date
znetsixe
ea33b3bba3 fix: add missing closing brace in emergencystop case block
The emergencystop case was missing its closing } before the
simulateMeasurement case, causing a SyntaxError on load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:44:49 +02:00
znetsixe
f363ee53ef Merge commit '4cf46f3' into HEAD
# Conflicts:
#	src/nodeClass.js
#	src/specificClass.js
2026-03-31 18:23:38 +02:00
Rene De Ren
4cf46f33c9 Expose output format selectors in editor 2026-03-12 16:39:25 +01:00
Rene De Ren
7b9fdd7342 fix: correct logging config path and child registration ID
Fixed eneableLog typo accessing wrong config path — now uses
machineConfig.general.logging.enabled/logLevel. Changed _registerChild
to use this.node.id consistent with all other nodes. Removed debug console.log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:33:28 +01:00
Rene De Ren
bb986c2dc8 refactor: adopt POSITIONS constants and fix ESLint warnings
Replace hardcoded position strings with POSITIONS.* constants.
Prefix unused variables with _ to resolve no-unused-vars warnings.
Fix no-prototype-builtins where applicable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:35:28 +01:00
Rene De Ren
46dd2ca37a Migrate _loadConfig to use ConfigManager.buildConfig()
Replaces manual base config construction with shared buildConfig() method.
Node now only specifies domain-specific config sections.

Part of #1: Extract base config schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:59:35 +01:00
Rene De Ren
ccfa90394b Fix ESLint errors and bugs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:39:57 +01:00
znetsixe
6b2a8239f2 updates 2026-03-11 11:13:26 +01:00
znetsixe
33f3c2ef61 update 2026-02-23 13:17:18 +01:00
znetsixe
b5137ba9c2 before functional changes by Codex 2026-02-19 17:36:44 +01:00
znetsixe
e236cccfd6 Merge branch 'dev-Rene' 2025-12-19 10:23:25 +01:00
p.vanderwilt
99b45c87e4 Rename _updateSourceSink to updateSourceSink for outside access 2025-11-14 12:55:11 +01:00
p.vanderwilt
0a98b12224 Merge pull request 'Implement reactor recirculation' (#4) from dev-Pieter into main
Reviewed-on: https://gitea.centraal.wbd-rd.nl/RnD/rotatingMachine/pulls/4
2025-11-06 13:58:25 +00:00
p.vanderwilt
b6d268659a Refactor flow handling: rename reactor references to source and sink and fix config minor bug 2025-11-06 14:50:40 +01:00
p.vanderwilt
303dfc477d Add flow number configuration and UI input for rotating machine 2025-10-31 14:16:00 +01:00
p.vanderwilt
ac40a93ef1 Simplify child registration error handling 2025-10-31 13:07:52 +01:00
p.vanderwilt
a8fb56bfb8 Add upstream and downstream reactor handling; improve error logging 2025-10-22 14:41:35 +02:00
HorriblePerson555
d7cc6a4a8b Enhance child registration logging and add validation for measurement child 2025-10-17 13:38:05 +02:00
HorriblePerson555
37e6523c55 Refactor child registration to use dedicated connection methods for measurement and reactor types 2025-10-16 16:32:20 +02:00
5a14f44fdd Merge pull request 'dev-Rene' (#2) from dev-Rene into main
Reviewed-on: https://gitea.centraal.wbd-rd.nl/RnD/rotatingMachine/pulls/2
2025-10-16 13:21:38 +00:00
p.vanderwilt
c081acae4e Remove non-implemented temperature handling function 2025-10-10 13:27:31 +02:00
08185243bc Merge pull request 'dev-Rene' (#1) from dev-Rene into main
Reviewed-on: https://gitea.centraal.wbd-rd.nl/RnD/rotatingMachine/pulls/1
2025-10-06 14:16:18 +00:00
21 changed files with 4003 additions and 379 deletions

View 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": []
}
]

View 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": []
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +1,53 @@
# RotatingMachine Example Flows # RotatingMachine Example Flows
These flows are import-ready Node-RED examples focused on the `rotatingMachine` node. These flows are import-ready Node-RED examples for the `rotatingMachine` node.
In Node-RED: **Import > Examples > EVOLV** to find them.
## Files ## Example Flows
- `basic.flow.json` ### 01 - Basic Manual Control
Purpose: quick manual control + local visualization. **Dependencies:** EVOLV only (no dashboard)
Includes:
- mode selection
- startup/shutdown/emergency buttons
- setpoint control
- simulated upstream/downstream pressure input
- local charts for predicted flow/power/ctrl
- `integration.flow.json` Inject-based flow demonstrating all core functionality:
Purpose: richer scenario testing from dashboard controls. - Mode switching (auto / virtualControl / fysicalControl)
Includes: - Startup/shutdown/emergency sequences
- sequence controls (startup/shutdown/maintenance) - Speed setpoint control (30%, 60%, 100%)
- direct setpoint + flowMovement commands - Pressure simulation (upstream + downstream)
- simulated pressure inputs - Maintenance mode enter/leave
- charts for flow, power, NCog% - Debug outputs on all 3 ports
- state snapshot text
- `edge.flow.json` ### 02 - Integration with Machine Group
Purpose: intentionally send invalid/boundary inputs and observe behavior. **Dependencies:** EVOLV only (no dashboard)
Includes:
- invalid mode command
- negative setpoint command
- disallowed source sequence command
- unsupported simulated measurement type
- debug outputs for process/influx/parent channels
## Requirements 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
- EVOLV rotatingMachine node installed/available in Node-RED. ### 03 - Dashboard Visualization
- FlowFuse Dashboard 2 nodes installed (`ui-base`, `ui-page`, `ui-group`, etc.). **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 ## Import
1. In Node-RED, use `Import` and select one of the `*.flow.json` files. 1. In Node-RED, use **Import > Examples > EVOLV** (auto-discovered)
2. Deploy. 2. Or manually: **Import > Clipboard** and paste the `.json` file contents
3. Open the dashboard page path configured in the flow. 3. Deploy
## Notes ## Notes
- These examples are manual by design: no auto-start on deploy. - Tier 1 and 2 examples have zero dashboard dependencies — they work on any Node-RED install with EVOLV
- Pressure simulation uses `msg.topic = "simulateMeasurement"` handled by the rotatingMachine wrapper. - Tier 3 requires `@flowfuse/node-red-dashboard` (included in EVOLV's package.json dependencies)
- Output graphs are based on the rotatingMachine process output payload fields. - All examples use `enableLog: true` so you can observe behavior in the Node-RED debug panel

View File

@@ -35,9 +35,9 @@
}, },
"sizes": { "sizes": {
"density": "default", "density": "default",
"pagePadding": "12px", "pagePadding": "14px",
"groupGap": "12px", "groupGap": "14px",
"groupBorderRadius": "4px", "groupBorderRadius": "6px",
"widgetGap": "12px" "widgetGap": "12px"
} }
}, },
@@ -409,8 +409,8 @@
"type": "function", "type": "function",
"z": "f1e8a6c8b2a4477f", "z": "f1e8a6c8b2a4477f",
"name": "Parse RM process output", "name": "Parse RM process 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};\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 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 flow = pickNumber('flow.predicted.downstream.default', 'flow.predicted.downstream');\nconst power = pickNumber('power.predicted.atequipment.default', 'power.predicted.atequipment', 'power.predicted.atEquipment.default', 'power.predicted.atEquipment');\nconst ctrl = pickNumber('ctrl', 'ctrl.predicted.atequipment.default', 'ctrl.predicted.atequipment', 'ctrl.predicted.atEquipment.default', 'ctrl.predicted.atEquipment');\nconst nCog = pickNumber('NCogPercent', 'NCog');\nconst runtime = pickNumber('runtime');\nconst moveTimeleft = pickNumber('moveTimeleft');\nconst maintenanceTime = pickNumber('maintenanceTime');\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 (flow !== null) cache.flow = flow;\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;\ncache.state = state;\ncache.mode = mode;\ncache.stateCode = stateCode;\ncontext.set('metricCache', cache);\n\nreturn [\n { topic: 'actual_flow', payload: cache.flow },\n { topic: 'predicted_power', payload: cache.power },\n { topic: 'actual_ctrl', payload: cache.ctrl },\n { topic: 'nCog', payload: cache.nCog },\n { topic: 'stateCode', payload: cache.stateCode },\n { payload: 'state=' + cache.state + ', mode=' + cache.mode + ', ctrl=' + cache.ctrl.toFixed(2) + '%' },\n { payload: 'runtime=' + cache.runtime.toFixed(3) + ' h | moveTimeLeft=' + cache.moveTimeleft.toFixed(0) + ' s | maintenance=' + cache.maintenanceTime.toFixed(3) + ' h' },\n { payload: JSON.stringify(merged) }\n];", "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();\nconst compactSnapshot = [\n `Q=${cache.flow.toFixed(1)} m3/h`,\n `P=${cache.power.toFixed(2)} kW`,\n `Ctrl=${cache.ctrl.toFixed(1)}%`,\n `NCog=${cache.nCog.toFixed(1)}%`,\n `Pup=${pressureUp == null ? 'n/a' : pressureUp.toFixed(0)} mbar`,\n `Pdown=${pressureDown == null ? 'n/a' : pressureDown.toFixed(0)} mbar`\n].join(' | ');\n\nreturn [\n { topic: 'actual_flow', payload: cache.flow, timestamp: now },\n { topic: 'predicted_power', payload: cache.power, timestamp: now },\n { topic: 'actual_ctrl', payload: cache.ctrl, timestamp: now },\n { topic: 'nCog', payload: cache.nCog, timestamp: now },\n { topic: 'stateCode', payload: cache.stateCode, timestamp: now },\n pressureUp === null ? null : { topic: 'pressure_upstream', payload: pressureUp, timestamp: now },\n pressureDown === null ? null : { topic: 'pressure_downstream', payload: pressureDown, timestamp: now },\n pressureDelta === null ? null : { topic: 'pressure_delta', payload: pressureDelta, timestamp: now },\n { topic: 'stateMode', payload: `state=${cache.state} | mode=${cache.mode} | ctrl=${cache.ctrl.toFixed(1)}%` },\n { topic: 'timing', payload: `runtime=${cache.runtime.toFixed(2)} h | moveLeft=${cache.moveTimeleft.toFixed(0)} s | maint=${cache.maintenanceTime.toFixed(2)} h` },\n { topic: 'snapshot', payload: compactSnapshot }\n];",
"outputs": 8, "outputs": 11,
"noerr": 0, "noerr": 0,
"x": 1310, "x": 1310,
"y": 420, "y": 420,
@@ -430,6 +430,15 @@
[ [
"rm_chart_statecode" "rm_chart_statecode"
], ],
[
"rm_chart_pressure_up"
],
[
"rm_chart_pressure_down"
],
[
"rm_chart_pressure_delta"
],
[ [
"rm_text_state" "rm_text_state"
], ],
@@ -449,20 +458,57 @@
"name": "Predicted Flow", "name": "Predicted Flow",
"label": "Flow (m3/h)", "label": "Flow (m3/h)",
"order": 1, "order": 1,
"width": 12, "width": 6,
"height": 4, "height": 4,
"chartType": "line", "chartType": "line",
"category": "", "category": "topic",
"xAxisLabel": "time", "xAxisLabel": "time",
"xAxisType": "time", "xAxisType": "time",
"yAxisLabel": "m3/h", "yAxisLabel": "m3/h",
"removeOlder": "1", "removeOlder": "15",
"removeOlderUnit": "3600", "removeOlderUnit": "60",
"removeOlderPoints": "", "removeOlderPoints": "",
"x": 1560, "x": 1560,
"y": 340, "y": 340,
"wires": [], "wires": [],
"showLegend": true "showLegend": false,
"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_chart_power", "id": "rm_chart_power",
@@ -472,17 +518,55 @@
"name": "Predicted Power", "name": "Predicted Power",
"label": "Power (kW)", "label": "Power (kW)",
"order": 2, "order": 2,
"width": 12, "width": 6,
"height": 4, "height": 4,
"chartType": "line", "chartType": "line",
"xAxisType": "time", "xAxisType": "time",
"yAxisLabel": "kW", "yAxisLabel": "kW",
"removeOlder": "1", "removeOlder": "15",
"removeOlderUnit": "3600", "removeOlderUnit": "60",
"x": 1560, "x": 1560,
"y": 400, "y": 400,
"wires": [], "wires": [],
"showLegend": true "showLegend": false,
"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_chart_ctrl", "id": "rm_chart_ctrl",
@@ -492,17 +576,55 @@
"name": "Control Position", "name": "Control Position",
"label": "Ctrl (%)", "label": "Ctrl (%)",
"order": 3, "order": 3,
"width": 12, "width": 6,
"height": 4, "height": 4,
"chartType": "line", "chartType": "line",
"xAxisType": "time", "xAxisType": "time",
"yAxisLabel": "%", "yAxisLabel": "%",
"removeOlder": "1", "removeOlder": "15",
"removeOlderUnit": "3600", "removeOlderUnit": "60",
"x": 1560, "x": 1560,
"y": 460, "y": 460,
"wires": [], "wires": [],
"showLegend": true "showLegend": false,
"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_text_state", "id": "rm_text_state",
@@ -511,7 +633,7 @@
"group": "ui_group_rm_obs", "group": "ui_group_rm_obs",
"name": "State/Mode", "name": "State/Mode",
"label": "Current State", "label": "Current State",
"order": 6, "order": 9,
"width": 12, "width": 12,
"height": 1, "height": 1,
"format": "{{msg.payload}}", "format": "{{msg.payload}}",
@@ -558,17 +680,55 @@
"name": "NCog", "name": "NCog",
"label": "NCog (%)", "label": "NCog (%)",
"order": 4, "order": 4,
"width": 12, "width": 6,
"height": 4, "height": 4,
"chartType": "line", "chartType": "line",
"xAxisType": "time", "xAxisType": "time",
"yAxisLabel": "%", "yAxisLabel": "%",
"removeOlder": "1", "removeOlder": "15",
"removeOlderUnit": "3600", "removeOlderUnit": "60",
"x": 1560, "x": 1560,
"y": 520, "y": 520,
"wires": [], "wires": [],
"showLegend": true "showLegend": false,
"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_chart_statecode", "id": "rm_chart_statecode",
@@ -578,17 +738,55 @@
"name": "Machine State Code", "name": "Machine State Code",
"label": "State Code (off=0 .. maint=9)", "label": "State Code (off=0 .. maint=9)",
"order": 5, "order": 5,
"width": 12, "width": 6,
"height": 4, "height": 4,
"chartType": "line", "chartType": "line",
"xAxisType": "time", "xAxisType": "time",
"yAxisLabel": "state", "yAxisLabel": "state",
"removeOlder": "1", "removeOlder": "15",
"removeOlderUnit": "3600", "removeOlderUnit": "60",
"x": 1560, "x": 1560,
"y": 580, "y": 580,
"wires": [], "wires": [],
"showLegend": true "showLegend": false,
"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_text_timing", "id": "rm_text_timing",
@@ -597,7 +795,7 @@
"group": "ui_group_rm_obs", "group": "ui_group_rm_obs",
"name": "Timing", "name": "Timing",
"label": "Timing", "label": "Timing",
"order": 7, "order": 10,
"width": 12, "width": 12,
"height": 1, "height": 1,
"format": "{{msg.payload}}", "format": "{{msg.payload}}",
@@ -611,9 +809,9 @@
"type": "ui-text", "type": "ui-text",
"z": "f1e8a6c8b2a4477f", "z": "f1e8a6c8b2a4477f",
"group": "ui_group_rm_obs", "group": "ui_group_rm_obs",
"name": "Latest Payload", "name": "Snapshot",
"label": "Latest Payload", "label": "Snapshot",
"order": 8, "order": 11,
"width": 12, "width": 12,
"height": 1, "height": 1,
"format": "{{msg.payload}}", "format": "{{msg.payload}}",
@@ -640,5 +838,179 @@
"rm_chart_ctrl" "rm_chart_ctrl"
] ]
] ]
},
{
"id": "rm_chart_pressure_up",
"type": "ui-chart",
"z": "f1e8a6c8b2a4477f",
"group": "ui_group_rm_obs",
"name": "Pressure Upstream",
"label": "Upstream Pressure (mbar)",
"order": 6,
"width": 6,
"height": 4,
"chartType": "line",
"xAxisType": "time",
"yAxisLabel": "mbar",
"removeOlder": "15",
"removeOlderUnit": "60",
"x": 1560,
"y": 640,
"wires": [],
"showLegend": false,
"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": [
"#2CA02C",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "rm_chart_pressure_down",
"type": "ui-chart",
"z": "f1e8a6c8b2a4477f",
"group": "ui_group_rm_obs",
"name": "Pressure Downstream",
"label": "Downstream Pressure (mbar)",
"order": 7,
"width": 6,
"height": 4,
"chartType": "line",
"xAxisType": "time",
"yAxisLabel": "mbar",
"removeOlder": "15",
"removeOlderUnit": "60",
"x": 1560,
"y": 700,
"wires": [],
"showLegend": false,
"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": [
"#FF7F0E",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "rm_chart_pressure_delta",
"type": "ui-chart",
"z": "f1e8a6c8b2a4477f",
"group": "ui_group_rm_obs",
"name": "Pressure Differential",
"label": "Pressure Delta (mbar)",
"order": 8,
"width": 6,
"height": 4,
"chartType": "line",
"xAxisType": "time",
"yAxisLabel": "mbar",
"removeOlder": "15",
"removeOlderUnit": "60",
"x": 1560,
"y": 760,
"wires": [],
"showLegend": false,
"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
} }
] ]

View File

@@ -314,6 +314,8 @@
"width": 6, "width": 6,
"height": 4, "height": 4,
"chartType": "line", "chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time", "xAxisType": "time",
"yAxisLabel": "state", "yAxisLabel": "state",
"removeOlder": "1", "removeOlder": "1",

View File

@@ -34,9 +34,9 @@
}, },
"sizes": { "sizes": {
"density": "default", "density": "default",
"pagePadding": "12px", "pagePadding": "14px",
"groupGap": "12px", "groupGap": "14px",
"groupBorderRadius": "4px", "groupBorderRadius": "6px",
"widgetGap": "12px" "widgetGap": "12px"
} }
}, },
@@ -87,7 +87,7 @@
"name": "Observed Behaviour", "name": "Observed Behaviour",
"page": "ui_page_rm_int", "page": "ui_page_rm_int",
"width": "12", "width": "12",
"height": "20", "height": "24",
"order": 3, "order": 3,
"showTitle": true, "showTitle": true,
"className": "" "className": ""
@@ -361,7 +361,7 @@
"type": "function", "type": "function",
"z": "12f41a7b538c40db", "z": "12f41a7b538c40db",
"name": "Parse RM output", "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 nCog: 0,\n ctrl: 0,\n stateCode: 0,\n state: 'idle',\n mode: 'auto',\n runtime: 0,\n moveTimeleft: 0,\n maintenanceTime: 0\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 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 flow = pickNumber('flow.predicted.downstream.default', 'flow.predicted.downstream');\nconst power = pickNumber('power.predicted.atequipment.default', 'power.predicted.atequipment', 'power.predicted.atEquipment.default', 'power.predicted.atEquipment');\nconst nCog = pickNumber('NCogPercent', 'NCog');\nconst ctrl = pickNumber('ctrl', 'ctrl.predicted.atequipment.default', 'ctrl.predicted.atequipment', 'ctrl.predicted.atEquipment.default', 'ctrl.predicted.atEquipment');\nconst runtime = pickNumber('runtime');\nconst moveTimeleft = pickNumber('moveTimeleft');\nconst maintenanceTime = pickNumber('maintenanceTime');\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 (flow !== null) cache.flow = flow;\nif (power !== null) cache.power = power;\nif (nCog !== null) cache.nCog = nCog;\nif (ctrl !== null) cache.ctrl = ctrl;\nif (runtime !== null) cache.runtime = runtime;\nif (moveTimeleft !== null) cache.moveTimeleft = moveTimeleft;\nif (maintenanceTime !== null) cache.maintenanceTime = maintenanceTime;\ncache.state = state;\ncache.mode = mode;\ncache.stateCode = stateCode;\ncontext.set('metricCache', cache);\n\nreturn [\n { topic: 'actual_flow', payload: cache.flow },\n { topic: 'predicted_power', payload: cache.power },\n { topic: 'nCog', payload: cache.nCog },\n { topic: 'actual_ctrl', payload: cache.ctrl },\n { topic: 'stateCode', payload: cache.stateCode },\n { payload: JSON.stringify({ state: cache.state, mode: cache.mode, ctrl: cache.ctrl, runtime: cache.runtime, moveTimeleft: cache.moveTimeleft, maintenanceTime: cache.maintenanceTime }) }\n];", "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, "outputs": 6,
"x": 1260, "x": 1260,
"y": 360, "y": 360,
@@ -394,7 +394,7 @@
"name": "Flow", "name": "Flow",
"label": "Flow (m3/h)", "label": "Flow (m3/h)",
"order": 1, "order": 1,
"width": 12, "width": 6,
"height": 4, "height": 4,
"chartType": "line", "chartType": "line",
"xAxisType": "time", "xAxisType": "time",
@@ -404,7 +404,45 @@
"x": 1510, "x": 1510,
"y": 280, "y": 280,
"wires": [], "wires": [],
"showLegend": true "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", "id": "rm_int_chart_power",
@@ -414,7 +452,7 @@
"name": "Power", "name": "Power",
"label": "Power (kW)", "label": "Power (kW)",
"order": 2, "order": 2,
"width": 12, "width": 6,
"height": 4, "height": 4,
"chartType": "line", "chartType": "line",
"xAxisType": "time", "xAxisType": "time",
@@ -424,7 +462,45 @@
"x": 1510, "x": 1510,
"y": 340, "y": 340,
"wires": [], "wires": [],
"showLegend": true "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", "id": "rm_int_chart_nCog",
@@ -434,7 +510,7 @@
"name": "NCog", "name": "NCog",
"label": "NCog (%)", "label": "NCog (%)",
"order": 3, "order": 3,
"width": 12, "width": 6,
"height": 4, "height": 4,
"chartType": "line", "chartType": "line",
"xAxisType": "time", "xAxisType": "time",
@@ -444,7 +520,45 @@
"x": 1510, "x": 1510,
"y": 400, "y": 400,
"wires": [], "wires": [],
"showLegend": true "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", "id": "rm_int_state_text",
@@ -496,7 +610,7 @@
"name": "Ctrl", "name": "Ctrl",
"label": "Ctrl (%)", "label": "Ctrl (%)",
"order": 4, "order": 4,
"width": 12, "width": 6,
"height": 4, "height": 4,
"chartType": "line", "chartType": "line",
"xAxisType": "time", "xAxisType": "time",
@@ -506,7 +620,45 @@
"x": 1510, "x": 1510,
"y": 460, "y": 460,
"wires": [], "wires": [],
"showLegend": true "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", "id": "rm_int_chart_statecode",
@@ -516,7 +668,7 @@
"name": "State Code", "name": "State Code",
"label": "State Code (off=0 .. maint=9)", "label": "State Code (off=0 .. maint=9)",
"order": 5, "order": 5,
"width": 12, "width": 6,
"height": 4, "height": 4,
"chartType": "line", "chartType": "line",
"xAxisType": "time", "xAxisType": "time",
@@ -526,7 +678,45 @@
"x": 1510, "x": 1510,
"y": 520, "y": 520,
"wires": [], "wires": [],
"showLegend": true "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", "id": "rm_int_ctrl_setpoint_for_chart",

View File

@@ -26,14 +26,21 @@
cooldown: { value: 0 }, cooldown: { value: 0 },
movementMode : { value: "staticspeed" }, // static or dynamic movementMode : { value: "staticspeed" }, // static or dynamic
machineCurve : { value: {}}, 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,7 +62,7 @@
icon: "font-awesome/fa-cog", icon: "font-awesome/fa-cog",
label: function () { label: function () {
return this.positionIcon + " " + this.category || "Machine"; return (this.positionIcon || "") + " " + (this.category || "Machine");
}, },
oneditprepare: function() { oneditprepare: function() {
@@ -143,6 +150,24 @@
</select> </select>
</div> </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>
<!-- Asset fields injected here --> <!-- Asset fields injected here -->
<div id="asset-fields-placeholder"></div> <div id="asset-fields-placeholder"></div>
@@ -162,4 +187,4 @@
<li><b>Enable Log / Log Level:</b> toggle via Logger menu.</li> <li><b>Enable Log / Log Level:</b> toggle via Logger menu.</li>
<li><b>Position:</b> set Upstream / At Equipment / Downstream via Position menu.</li> <li><b>Position:</b> set Upstream / At Equipment / Downstream via Position menu.</li>
</ul> </ul>
</script> </script>

View File

@@ -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,49 +42,74 @@ 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
}
}; };
// 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.
*/ */
_setupSpecificClass(uiConfig) { _setupSpecificClass(uiConfig) {
const machineConfig = this.config; const machineConfig = this.config;
console.log(`----------------> Loaded movementMode in nodeClass: ${uiConfig.movementMode}`);
// need extra state for this // need extra state for this
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: {
@@ -117,7 +143,24 @@ 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('m3/h')); const requiresPressurePrediction = ["operational", "warmingup", "accelerating", "decelerating"].includes(state);
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')); const power = Math.round(m.measurements.type("power").variant("predicted").position('atEquipment').getCurrentValue('kW'));
let symbolState; let symbolState;
switch(state){ switch(state){
@@ -164,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}` };
@@ -182,7 +225,7 @@ 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}` };
@@ -202,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);
} }
@@ -234,7 +277,7 @@ class nodeClass {
const influxMsg = this._output.formatMsg(raw, this.source.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]);
} }
/** /**
@@ -242,42 +285,54 @@ class nodeClass {
*/ */
_attachInputHandler() { _attachInputHandler() {
this.node.on('input', (msg, send, done) => { this.node.on('input', (msg, send, done) => {
/* Update to complete event based node by putting the tick function after an input event */ /* 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':
try {
switch(msg.topic) {
case 'registerChild': {
// Register this node as a child of the parent node // Register this node as a child of the parent node
const childId = msg.payload; const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId); const childObj = this.RED.nodes.getNode(childId);
if (!childObj || !childObj.source) {
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); 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)); 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)); 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); m.handleInput(esSource, esAction);
break; break;
}
case 'simulateMeasurement': case 'simulateMeasurement':
{ {
const payload = msg.payload || {}; const payload = msg.payload || {};
const type = String(payload.type || '').toLowerCase(); const type = String(payload.type || '').toLowerCase();
const position = payload.position || 'atEquipment'; const position = payload.position || 'atEquipment';
const value = Number(payload.value); const value = Number(payload.value);
const unit = payload.unit; const unit = typeof payload.unit === 'string' ? payload.unit.trim() : '';
const supportedTypes = new Set(['pressure', 'flow', 'temperature', 'power']);
const context = { const context = {
timestamp: payload.timestamp || Date.now(), timestamp: payload.timestamp || Date.now(),
unit, unit,
@@ -290,9 +345,28 @@ class nodeClass {
break; 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) { switch (type) {
case 'pressure': case 'pressure':
m.updateMeasuredPressure(value, position, context); if (typeof m.updateSimulatedMeasurement === "function") {
m.updateSimulatedMeasurement(type, position, value, context);
} else {
m.updateMeasuredPressure(value, position, context);
}
break; break;
case 'flow': case 'flow':
m.updateMeasuredFlow(value, position, context); m.updateMeasuredFlow(value, position, context);
@@ -300,20 +374,27 @@ class nodeClass {
case 'temperature': case 'temperature':
m.updateMeasuredTemperature(value, position, context); m.updateMeasuredTemperature(value, position, context);
break; break;
default: case 'power':
this.node.warn(`Unsupported simulateMeasurement type: ${type}`); 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);
}
}
}); });
} }
@@ -324,7 +405,7 @@ class nodeClass {
this.node.on('close', (done) => { this.node.on('close', (done) => {
clearInterval(this._tickInterval); clearInterval(this._tickInterval);
clearInterval(this._statusInterval); clearInterval(this._statusInterval);
done(); if (typeof done === 'function') done();
}); });
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -33,3 +33,12 @@ test('handleInput ignores disallowed source/action combination', async () => {
assert.equal(before, after); 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);
});

View 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');
});

View File

@@ -12,6 +12,28 @@ test('setpoint rejects negative inputs without throwing', async () => {
}); });
}); });
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', () => { test('nodeClass _updateNodeStatus returns error status on internal failure', () => {
const inst = Object.create(NodeClass.prototype); const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub(); const node = makeNodeStub();
@@ -29,3 +51,24 @@ test('nodeClass _updateNodeStatus returns error status on internal failure', ()
assert.equal(status.text, 'Status Error'); assert.equal(status.text, 'Status Error');
assert.equal(node._errors.length, 1); 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);
});

View File

@@ -34,15 +34,24 @@ test('input handler routes topics to source methods', () => {
showCoG() { showCoG() {
return { cog: 1 }; return { cog: 1 };
}, },
updateSimulatedMeasurement(type, position, value) {
calls.push(['updateSimulatedMeasurement', type, position, value]);
},
updateMeasuredPressure(value, position) { updateMeasuredPressure(value, position) {
calls.push(['updateMeasuredPressure', value, position]); calls.push(['updateMeasuredPressure', value, position]);
}, },
updateMeasuredFlow(value, position) { updateMeasuredFlow(value, position) {
calls.push(['updateMeasuredFlow', value, position]); calls.push(['updateMeasuredFlow', value, position]);
}, },
updateMeasuredPower(value, position) {
calls.push(['updateMeasuredPower', value, position]);
},
updateMeasuredTemperature(value, position) { updateMeasuredTemperature(value, position) {
calls.push(['updateMeasuredTemperature', value, position]); calls.push(['updateMeasuredTemperature', value, position]);
}, },
isUnitValidForType() {
return true;
},
}; };
inst._attachInputHandler(); inst._attachInputHandler();
@@ -50,11 +59,129 @@ test('input handler routes topics to source methods', () => {
onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {}); onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } }, () => {}, () => {}); 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: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {}); 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[0], ['setMode', 'auto']);
assert.deepEqual(calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']); assert.deepEqual(calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
assert.deepEqual(calls[2], ['registerChild', { id: 'child-source' }, 'downstream']); assert.deepEqual(calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
assert.deepEqual(calls[3], ['updateMeasuredPressure', 250, 'upstream']); 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');
}); });

View File

@@ -17,6 +17,12 @@ function makeMachineConfig(overrides = {}) {
type: 'pump', type: 'pump',
model: 'hidrostal-H05K-S03R', model: 'hidrostal-H05K-S03R',
unit: 'm3/h', unit: 'm3/h',
curveUnits: {
pressure: 'mbar',
flow: 'm3/h',
power: 'kW',
control: '%',
},
}, },
...overrides, ...overrides,
}; };

View 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));
});

View File

@@ -19,4 +19,36 @@ test('calcEfficiency runs through coolprop path without mocks', () => {
const eff = machine.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue(); const eff = machine.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue();
assert.equal(typeof eff, 'number'); assert.equal(typeof eff, 'number');
assert.ok(eff > 0); 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 expectedDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa canonical
assert.equal(Math.round(machine.predictFlow.fDimension), expectedDiff);
assert.ok(machine.predictFlow.fDimension > 0);
}); });

View 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'));
});

View File

@@ -0,0 +1,84 @@
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);
assert.ok(machine.predictFlow.fDimension <= 1);
// 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
machine = createMachine();
const upstream = 700;
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);
});

View File

@@ -25,3 +25,29 @@ test('registerChild listens to measurement events and stores measured pressure',
assert.equal(typeof stored, 'number'); assert.equal(typeof stored, 'number');
assert.equal(Math.round(stored), 123); 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);
});

View File

@@ -12,11 +12,13 @@ test('execSequence startup reaches operational with zero transition times', asyn
assert.equal(machine.state.getCurrentState(), 'operational'); assert.equal(machine.state.getCurrentState(), 'operational');
}); });
test('execMovement updates controller position in operational state', async () => { test('execMovement constrains controller position to safe bounds in operational state', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } })); const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
const { max } = machine._resolveSetpointBounds();
await machine.handleInput('parent', 'execMovement', 10); await machine.handleInput('parent', 'execMovement', 10);
const pos = machine.state.getCurrentPosition(); const pos = machine.state.getCurrentPosition();
assert.ok(pos >= 9.9 && pos <= 10); assert.ok(pos <= max);
assert.equal(pos, max);
}); });