Compare commits
1 Commits
33f3c2ef61
...
6b2a8239f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b2a8239f2 |
345
examples/01 - Basic Manual Control.json
Normal file
345
examples/01 - Basic Manual Control.json
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "rm_basic_tab",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "RotatingMachine - Basic Manual Control",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "Demonstrates basic manual control of a single rotatingMachine using inject nodes only. No dashboard dependencies."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_comment_title",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "RotatingMachine - Basic Manual Control\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nDemonstrates startup/shutdown sequences, speed setpoint\ncontrol, and pressure simulation for a single pump.\n\nPrerequisites: EVOLV package installed.",
|
||||||
|
"info": "",
|
||||||
|
"x": 360,
|
||||||
|
"y": 40,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_comment_howto",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "HOW TO USE:\n1. Deploy flow\n2. Click 'Set virtualControl' to enable manual control\n3. Click 'Startup' to run the startup sequence\n4. Click 'Set 60%' to move to 60% speed\n5. Inject BOTH pressure values to see flow/power predictions\n6. Click 'Shutdown' when done\n\nNOTE: Output uses delta compression - only changed\nfields are sent each tick. The formatter merges\ndeltas into a running cache for display.\n\nIMPORTANT: Flow and power predictions require at least\none pressure value to be injected. Without pressure,\npredictions use fDimension=0 (unrealistic values).\nOutput keys use 4-segment format:\ntype.variant.position.childId (e.g. flow.predicted.downstream.default)",
|
||||||
|
"info": "",
|
||||||
|
"x": 360,
|
||||||
|
"y": 100,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_node",
|
||||||
|
"type": "rotatingMachine",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Pump 1",
|
||||||
|
"speed": "1",
|
||||||
|
"startup": "3",
|
||||||
|
"warmup": "2",
|
||||||
|
"shutdown": "3",
|
||||||
|
"cooldown": "2",
|
||||||
|
"movementMode": "staticspeed",
|
||||||
|
"machineCurve": "",
|
||||||
|
"uuid": "example-pump-001",
|
||||||
|
"supplier": "hidrostal",
|
||||||
|
"category": "pump",
|
||||||
|
"assetType": "pump-centrifugal",
|
||||||
|
"model": "hidrostal-H05K-S03R",
|
||||||
|
"unit": "m3/h",
|
||||||
|
"curvePressureUnit": "mbar",
|
||||||
|
"curveFlowUnit": "m3/h",
|
||||||
|
"curvePowerUnit": "kW",
|
||||||
|
"curveControlUnit": "%",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"distanceDescription": "",
|
||||||
|
"x": 560,
|
||||||
|
"y": 340,
|
||||||
|
"wires": [
|
||||||
|
["rm_basic_format_output"],
|
||||||
|
["rm_basic_debug_port1"],
|
||||||
|
["rm_basic_debug_port2"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_mode",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Set virtualControl",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "virtualControl", "vt": "str" }
|
||||||
|
],
|
||||||
|
"topic": "setMode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 190,
|
||||||
|
"y": 200,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_auto",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Set auto mode",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "auto", "vt": "str" }
|
||||||
|
],
|
||||||
|
"topic": "setMode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 180,
|
||||||
|
"y": 240,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_startup",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Startup",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"startup\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 170,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_shutdown",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Shutdown",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"shutdown\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 170,
|
||||||
|
"y": 340,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_emergency",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Emergency Stop",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"emergencystop\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "emergencystop",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 190,
|
||||||
|
"y": 380,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_setpoint60",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Set 60%",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execMovement\",\"setpoint\":60}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execMovement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 170,
|
||||||
|
"y": 440,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_setpoint30",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Set 30%",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execMovement\",\"setpoint\":30}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execMovement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 170,
|
||||||
|
"y": 480,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_setpoint100",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Set 100%",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execMovement\",\"setpoint\":100}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execMovement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 170,
|
||||||
|
"y": 520,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_pressure_down",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Sim downstream 1100 mbar",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"type\":\"pressure\",\"position\":\"downstream\",\"value\":1100,\"unit\":\"mbar\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "simulateMeasurement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 230,
|
||||||
|
"y": 600,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_pressure_up",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Sim upstream 200 mbar",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"type\":\"pressure\",\"position\":\"upstream\",\"value\":200,\"unit\":\"mbar\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "simulateMeasurement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 220,
|
||||||
|
"y": 640,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_maintenance",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Enter Maintenance",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"entermaintenance\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 700,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_inject_leavemaint",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Leave Maintenance",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"leavemaintenance\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 740,
|
||||||
|
"wires": [["rm_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_format_output",
|
||||||
|
"type": "function",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Merge deltas and format",
|
||||||
|
"func": "const p = msg.payload || {};\nconst cache = context.get('c') || {};\nObject.assign(cache, p);\ncontext.set('c', cache);\nfunction find(prefix) {\n for (var k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\nconst fl = find('flow.predicted.downstream.');\nconst pw = find('power.predicted.atequipment.');\nconst pD = find('pressure.measured.downstream.');\nconst pU = find('pressure.measured.upstream.');\nmsg.payload = {\n state: cache.state || 'idle',\n mode: cache.mode || 'auto',\n ctrl: cache.ctrl != null ? Number(cache.ctrl).toFixed(1) + '%' : 'n/a',\n flow: fl != null ? Number(fl).toFixed(2) + ' m3/h' : 'n/a',\n power: pw != null ? Number(pw).toFixed(2) + ' kW' : 'n/a',\n NCog: cache.NCog != null ? Number(cache.NCog).toFixed(1) + '%' : 'n/a',\n pDown: pD != null ? Number(pD).toFixed(0) + ' mbar' : 'n/a',\n pUp: pU != null ? Number(pU).toFixed(0) + ' mbar' : 'n/a',\n runtime: cache.runtime != null ? Number(cache.runtime).toFixed(3) + ' h' : '0'\n};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"noerr": 0,
|
||||||
|
"initialize": "",
|
||||||
|
"finalize": "",
|
||||||
|
"libs": [],
|
||||||
|
"x": 790,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [["rm_basic_debug_port0"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_debug_port0",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Port 0: Process Data",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": true,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"statusVal": "payload.state",
|
||||||
|
"statusType": "auto",
|
||||||
|
"x": 1020,
|
||||||
|
"y": 300,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_debug_port1",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Port 1: InfluxDB Telemetry",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1040,
|
||||||
|
"y": 360,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_basic_debug_port2",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_basic_tab",
|
||||||
|
"name": "Port 2: Parent Registration",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1040,
|
||||||
|
"y": 420,
|
||||||
|
"wires": []
|
||||||
|
}
|
||||||
|
]
|
||||||
368
examples/02 - Integration with Machine Group.json
Normal file
368
examples/02 - Integration with Machine Group.json
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "rm_int_tab",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "RotatingMachine - Integration with Machine Group",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "Demonstrates a machineGroupControl parent with two rotatingMachine children and a measurement node."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_comment_title",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "RotatingMachine - Integration with Parent\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nShows how rotatingMachine registers with a\nmachineGroupControl parent via Port 2.\nAlso shows a measurement node providing\npressure data to a pump.\n\nPrerequisites: EVOLV package installed.",
|
||||||
|
"info": "",
|
||||||
|
"x": 380,
|
||||||
|
"y": 40,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_comment_howto",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "HOW TO USE:\n1. Deploy flow - pumps auto-register with MGC via Port 2\n2. Set Pump 1 to virtualControl, then Startup\n3. Set speed setpoints on individual pumps\n4. Observe MGC aggregating child state on Port 0\n5. Inject pressure measurement to see curve predictions",
|
||||||
|
"info": "",
|
||||||
|
"x": 380,
|
||||||
|
"y": 110,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_mgc",
|
||||||
|
"type": "machineGroupControl",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "Machine Group",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"x": 570,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [
|
||||||
|
["rm_int_debug_mgc_port0"],
|
||||||
|
["rm_int_debug_mgc_port1"],
|
||||||
|
["rm_int_debug_mgc_port2"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_pump1",
|
||||||
|
"type": "rotatingMachine",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "Pump 1",
|
||||||
|
"speed": "1",
|
||||||
|
"startup": "2",
|
||||||
|
"warmup": "1",
|
||||||
|
"shutdown": "2",
|
||||||
|
"cooldown": "1",
|
||||||
|
"movementMode": "staticspeed",
|
||||||
|
"machineCurve": "",
|
||||||
|
"uuid": "example-pump-001",
|
||||||
|
"supplier": "hidrostal",
|
||||||
|
"category": "pump",
|
||||||
|
"assetType": "pump-centrifugal",
|
||||||
|
"model": "hidrostal-H05K-S03R",
|
||||||
|
"unit": "m3/h",
|
||||||
|
"curvePressureUnit": "mbar",
|
||||||
|
"curveFlowUnit": "m3/h",
|
||||||
|
"curvePowerUnit": "kW",
|
||||||
|
"curveControlUnit": "%",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"distanceDescription": "",
|
||||||
|
"x": 570,
|
||||||
|
"y": 480,
|
||||||
|
"wires": [
|
||||||
|
["rm_int_debug_p1_port0"],
|
||||||
|
[],
|
||||||
|
["rm_int_mgc"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_pump2",
|
||||||
|
"type": "rotatingMachine",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "Pump 2",
|
||||||
|
"speed": "1",
|
||||||
|
"startup": "2",
|
||||||
|
"warmup": "1",
|
||||||
|
"shutdown": "2",
|
||||||
|
"cooldown": "1",
|
||||||
|
"movementMode": "staticspeed",
|
||||||
|
"machineCurve": "",
|
||||||
|
"uuid": "example-pump-002",
|
||||||
|
"supplier": "hidrostal",
|
||||||
|
"category": "pump",
|
||||||
|
"assetType": "pump-centrifugal",
|
||||||
|
"model": "hidrostal-H05K-S03R",
|
||||||
|
"unit": "m3/h",
|
||||||
|
"curvePressureUnit": "mbar",
|
||||||
|
"curveFlowUnit": "m3/h",
|
||||||
|
"curvePowerUnit": "kW",
|
||||||
|
"curveControlUnit": "%",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"distanceDescription": "",
|
||||||
|
"x": 570,
|
||||||
|
"y": 620,
|
||||||
|
"wires": [
|
||||||
|
["rm_int_debug_p2_port0"],
|
||||||
|
[],
|
||||||
|
["rm_int_mgc"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_mode_p1",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1: virtualControl",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "virtualControl", "vt": "str" }
|
||||||
|
],
|
||||||
|
"topic": "setMode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 440,
|
||||||
|
"wires": [["rm_int_pump1"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_start_p1",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1: Startup",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"startup\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 180,
|
||||||
|
"y": 480,
|
||||||
|
"wires": [["rm_int_pump1"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_setpoint_p1",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1: Set 75%",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execMovement\",\"setpoint\":75}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execMovement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 180,
|
||||||
|
"y": 520,
|
||||||
|
"wires": [["rm_int_pump1"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_mode_p2",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P2: virtualControl",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "virtualControl", "vt": "str" }
|
||||||
|
],
|
||||||
|
"topic": "setMode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 580,
|
||||||
|
"wires": [["rm_int_pump2"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_start_p2",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P2: Startup",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"startup\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 180,
|
||||||
|
"y": 620,
|
||||||
|
"wires": [["rm_int_pump2"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_setpoint_p2",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P2: Set 50%",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execMovement\",\"setpoint\":50}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execMovement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 180,
|
||||||
|
"y": 660,
|
||||||
|
"wires": [["rm_int_pump2"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_pressure",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1: Sim downstream 900 mbar",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"type\":\"pressure\",\"position\":\"downstream\",\"value\":900,\"unit\":\"mbar\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "simulateMeasurement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 240,
|
||||||
|
"y": 740,
|
||||||
|
"wires": [["rm_int_pump1"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_shutdown_p1",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1: Shutdown",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"shutdown\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 190,
|
||||||
|
"y": 780,
|
||||||
|
"wires": [["rm_int_pump1"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_inject_shutdown_p2",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P2: Shutdown",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"source\":\"GUI\",\"action\":\"execSequence\",\"parameter\":\"shutdown\"}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "execSequence",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 190,
|
||||||
|
"y": 820,
|
||||||
|
"wires": [["rm_int_pump2"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_mgc_port0",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "MGC Port 0: Group State",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": true,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"statusVal": "payload",
|
||||||
|
"statusType": "auto",
|
||||||
|
"x": 830,
|
||||||
|
"y": 260,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_mgc_port1",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "MGC Port 1: InfluxDB",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 820,
|
||||||
|
"y": 300,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_mgc_port2",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "MGC Port 2: Parent",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 810,
|
||||||
|
"y": 340,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_p1_port0",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P1 Port 0: Process",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": true,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"statusVal": "payload.state",
|
||||||
|
"statusType": "auto",
|
||||||
|
"x": 810,
|
||||||
|
"y": 460,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rm_int_debug_p2_port0",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "rm_int_tab",
|
||||||
|
"name": "P2 Port 0: Process",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": true,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"statusVal": "payload.state",
|
||||||
|
"statusType": "auto",
|
||||||
|
"x": 810,
|
||||||
|
"y": 600,
|
||||||
|
"wires": []
|
||||||
|
}
|
||||||
|
]
|
||||||
1026
examples/03 - Dashboard Visualization.json
Normal file
1026
examples/03 - Dashboard Visualization.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -29,11 +29,16 @@
|
|||||||
|
|
||||||
//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 +60,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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -42,25 +42,37 @@ 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 resolvedAssetUuid = uiConfig.assetUuid || uiConfig.uuid || null;
|
||||||
|
const resolvedAssetTagCode = uiConfig.assetTagCode || uiConfig.assetTagNumber || null;
|
||||||
|
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
|
||||||
|
const curveUnits = {
|
||||||
|
pressure: this._resolveUnitOrFallback(uiConfig.curvePressureUnit, 'pressure', 'mbar', 'curve pressure'),
|
||||||
|
flow: this._resolveUnitOrFallback(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit, 'curve flow'),
|
||||||
|
power: this._resolveUnitOrFallback(uiConfig.curvePowerUnit, 'power', 'kW', 'curve power'),
|
||||||
|
control: this._resolveControlUnitOrFallback(uiConfig.curveControlUnit, '%'),
|
||||||
|
};
|
||||||
|
|
||||||
// Merge UI config over defaults
|
// Merge UI config over defaults
|
||||||
this.config = {
|
this.config = {
|
||||||
general: {
|
general: {
|
||||||
|
name: this.name,
|
||||||
id: node.id, // node.id is for the child registration process
|
id: node.id, // node.id is for the child registration process
|
||||||
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)
|
unit: flowUnit,
|
||||||
logging: {
|
logging: {
|
||||||
enabled: uiConfig.enableLog,
|
enabled: uiConfig.enableLog,
|
||||||
logLevel: uiConfig.logLevel
|
logLevel: uiConfig.logLevel
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
asset: {
|
asset: {
|
||||||
uuid: uiConfig.assetUuid, //need to add this later to the asset model
|
uuid: resolvedAssetUuid, // support both legacy and current editor field names
|
||||||
tagCode: uiConfig.assetTagCode, //need to add this later to the asset model
|
tagCode: resolvedAssetTagCode, // support both legacy and current editor field names
|
||||||
|
tagNumber: uiConfig.assetTagNumber || null,
|
||||||
supplier: uiConfig.supplier,
|
supplier: uiConfig.supplier,
|
||||||
category: uiConfig.category, //add later to define as the software type
|
category: uiConfig.category, //add later to define as the software type
|
||||||
type: uiConfig.assetType,
|
type: uiConfig.assetType,
|
||||||
model: uiConfig.model,
|
model: uiConfig.model,
|
||||||
unit: uiConfig.unit
|
unit: flowUnit,
|
||||||
|
curveUnits
|
||||||
},
|
},
|
||||||
functionality: {
|
functionality: {
|
||||||
positionVsParent: uiConfig.positionVsParent
|
positionVsParent: uiConfig.positionVsParent
|
||||||
@@ -71,6 +83,29 @@ class nodeClass {
|
|||||||
this._output = new outputUtils();
|
this._output = new outputUtils();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) {
|
||||||
|
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||||
|
const fallback = String(fallbackUnit || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const desc = convert().describe(raw);
|
||||||
|
if (expectedMeasure && desc.measure !== expectedMeasure) {
|
||||||
|
throw new Error(`expected '${expectedMeasure}' but got '${desc.measure}'`);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
} catch (error) {
|
||||||
|
this.node?.warn?.(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveControlUnitOrFallback(candidate, fallback = '%') {
|
||||||
|
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||||
|
return raw || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiate the core Measurement logic and store as source.
|
* Instantiate the core Measurement logic and store as source.
|
||||||
*/
|
*/
|
||||||
@@ -81,8 +116,8 @@ class nodeClass {
|
|||||||
const stateConfig = {
|
const stateConfig = {
|
||||||
general: {
|
general: {
|
||||||
logging: {
|
logging: {
|
||||||
enabled: machineConfig.eneableLog,
|
enabled: machineConfig.general.logging.enabled,
|
||||||
logLevel: machineConfig.logLevel
|
logLevel: machineConfig.general.logging.logLevel
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
movement: {
|
movement: {
|
||||||
@@ -132,7 +167,8 @@ class nodeClass {
|
|||||||
if (pressureStatus.initialized) {
|
if (pressureStatus.initialized) {
|
||||||
this._pressureInitWarned = false;
|
this._pressureInitWarned = false;
|
||||||
}
|
}
|
||||||
const flow = Math.round(m.measurements.type("flow").variant("predicted").position('downstream').getCurrentValue('m3/h'));
|
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){
|
||||||
@@ -179,16 +215,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}` };
|
||||||
@@ -197,7 +233,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}` };
|
||||||
@@ -299,7 +335,8 @@ class nodeClass {
|
|||||||
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,
|
||||||
@@ -312,6 +349,21 @@ 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':
|
||||||
if (typeof m.updateSimulatedMeasurement === "function") {
|
if (typeof m.updateSimulatedMeasurement === "function") {
|
||||||
@@ -326,8 +378,9 @@ 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;
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const {loadCurve,gravity,logger,configUtils,configManager,state, nrmse, MeasurementContainer, predict, interpolation , childRegistrationUtils,coolprop} = require('generalFunctions');
|
const {loadCurve,gravity,logger,configUtils,configManager,state, nrmse, MeasurementContainer, predict, interpolation , childRegistrationUtils,coolprop, convert} = require('generalFunctions');
|
||||||
|
|
||||||
|
const CANONICAL_UNITS = Object.freeze({
|
||||||
|
pressure: 'Pa',
|
||||||
|
atmPressure: 'Pa',
|
||||||
|
flow: 'm3/s',
|
||||||
|
power: 'W',
|
||||||
|
temperature: 'K',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_IO_UNITS = Object.freeze({
|
||||||
|
pressure: 'mbar',
|
||||||
|
flow: 'm3/h',
|
||||||
|
power: 'kW',
|
||||||
|
temperature: 'C',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_CURVE_UNITS = Object.freeze({
|
||||||
|
pressure: 'mbar',
|
||||||
|
flow: 'm3/h',
|
||||||
|
power: 'kW',
|
||||||
|
control: '%',
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotating machine domain model.
|
* Rotating machine domain model.
|
||||||
@@ -21,15 +43,25 @@ class Machine {
|
|||||||
|
|
||||||
// Load a specific curve
|
// Load a specific curve
|
||||||
this.model = machineConfig.asset.model; // Get the model from the machineConfig
|
this.model = machineConfig.asset.model; // Get the model from the machineConfig
|
||||||
this.curve = this.model ? loadCurve(this.model) : null; // we need to convert the curve and add units to the curve information
|
this.rawCurve = this.model ? loadCurve(this.model) : null;
|
||||||
|
this.curve = null;
|
||||||
|
|
||||||
//Init config and check if it is valid
|
//Init config and check if it is valid
|
||||||
this.config = this.configUtils.initConfig(machineConfig);
|
this.config = this.configUtils.initConfig(machineConfig);
|
||||||
|
|
||||||
//add unique name for this node.
|
//add unique name for this node.
|
||||||
this.config = this.configUtils.updateConfig(this.config, {general:{name: this.config.functionality?.softwareType + "_" + machineConfig.general.id}}); // add unique name if not present
|
this.config = this.configUtils.updateConfig(this.config, {general:{name: this.config.functionality?.softwareType + "_" + machineConfig.general.id}}); // add unique name if not present
|
||||||
|
this.unitPolicy = this._buildUnitPolicy(this.config);
|
||||||
|
this.config = this.configUtils.updateConfig(this.config, {
|
||||||
|
general: { unit: this.unitPolicy.output.flow },
|
||||||
|
asset: {
|
||||||
|
...this.config.asset,
|
||||||
|
unit: this.unitPolicy.output.flow,
|
||||||
|
curveUnits: this.unitPolicy.curve,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!this.model || !this.curve) {
|
if (!this.model || !this.rawCurve) {
|
||||||
this.logger.error(`${!this.model ? 'Model not specified' : 'Curve not found for model ' + this.model} in machineConfig. Cannot make predictions.`);
|
this.logger.error(`${!this.model ? 'Model not specified' : 'Curve not found for model ' + this.model} in machineConfig. Cannot make predictions.`);
|
||||||
// Set prediction objects to null to prevent method calls
|
// Set prediction objects to null to prevent method calls
|
||||||
this.predictFlow = null;
|
this.predictFlow = null;
|
||||||
@@ -38,12 +70,21 @@ class Machine {
|
|||||||
this.hasCurve = false;
|
this.hasCurve = false;
|
||||||
}
|
}
|
||||||
else{
|
else{
|
||||||
this.hasCurve = true;
|
try {
|
||||||
this.config = this.configUtils.updateConfig(this.config, { asset: { ...this.config.asset, machineCurve: this.curve } });
|
this.hasCurve = true;
|
||||||
//machineConfig = { ...machineConfig, asset: { ...machineConfig.asset, machineCurve: this.curve } }; // Merge curve into machineConfig
|
this.curve = this._normalizeMachineCurve(this.rawCurve);
|
||||||
this.predictFlow = new predict({ curve: this.config.asset.machineCurve.nq }); // load nq (x : ctrl , y : flow relationship)
|
this.config = this.configUtils.updateConfig(this.config, { asset: { ...this.config.asset, machineCurve: this.curve } });
|
||||||
this.predictPower = new predict({ curve: this.config.asset.machineCurve.np }); // load np (x : ctrl , y : power relationship)
|
//machineConfig = { ...machineConfig, asset: { ...machineConfig.asset, machineCurve: this.curve } }; // Merge curve into machineConfig
|
||||||
this.predictCtrl = new predict({ curve: this.reverseCurve(this.config.asset.machineCurve.nq) }); // load reversed nq (x: flow, y: ctrl relationship)
|
this.predictFlow = new predict({ curve: this.config.asset.machineCurve.nq }); // load nq (x : ctrl , y : flow relationship)
|
||||||
|
this.predictPower = new predict({ curve: this.config.asset.machineCurve.np }); // load np (x : ctrl , y : power relationship)
|
||||||
|
this.predictCtrl = new predict({ curve: this.reverseCurve(this.config.asset.machineCurve.nq) }); // load reversed nq (x: flow, y: ctrl relationship)
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Curve normalization failed for model '${this.model}': ${error.message}`);
|
||||||
|
this.predictFlow = null;
|
||||||
|
this.predictPower = null;
|
||||||
|
this.predictCtrl = null;
|
||||||
|
this.hasCurve = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state = new state(stateConfig, this.logger); // Init State manager and pass logger
|
this.state = new state(stateConfig, this.logger); // Init State manager and pass logger
|
||||||
@@ -54,16 +95,55 @@ class Machine {
|
|||||||
autoConvert: true,
|
autoConvert: true,
|
||||||
windowSize: 50,
|
windowSize: 50,
|
||||||
defaultUnits: {
|
defaultUnits: {
|
||||||
pressure: 'mbar',
|
pressure: this.unitPolicy.output.pressure,
|
||||||
flow: this.config.general.unit,
|
flow: this.unitPolicy.output.flow,
|
||||||
power: 'kW',
|
power: this.unitPolicy.output.power,
|
||||||
temperature: 'C'
|
temperature: this.unitPolicy.output.temperature,
|
||||||
}
|
atmPressure: 'Pa',
|
||||||
});
|
},
|
||||||
|
preferredUnits: {
|
||||||
|
pressure: this.unitPolicy.output.pressure,
|
||||||
|
flow: this.unitPolicy.output.flow,
|
||||||
|
power: this.unitPolicy.output.power,
|
||||||
|
temperature: this.unitPolicy.output.temperature,
|
||||||
|
atmPressure: 'Pa',
|
||||||
|
},
|
||||||
|
canonicalUnits: this.unitPolicy.canonical,
|
||||||
|
storeCanonical: true,
|
||||||
|
strictUnitValidation: true,
|
||||||
|
throwOnInvalidUnit: true,
|
||||||
|
requireUnitForTypes: ['pressure', 'flow', 'power', 'temperature', 'atmPressure'],
|
||||||
|
}, this.logger);
|
||||||
|
|
||||||
this.interpolation = new interpolation();
|
this.interpolation = new interpolation();
|
||||||
|
|
||||||
this.flowDrift = null;
|
this.flowDrift = null;
|
||||||
|
this.powerDrift = null;
|
||||||
|
this.pressureDrift = { level: 0, flags: ["nominal"], source: null };
|
||||||
|
this.driftProfiles = {
|
||||||
|
flow: {
|
||||||
|
windowSize: 30,
|
||||||
|
minSamplesForLongTerm: 10,
|
||||||
|
ewmaAlpha: 0.15,
|
||||||
|
alignmentToleranceMs: 2500,
|
||||||
|
strictValidation: true,
|
||||||
|
},
|
||||||
|
power: {
|
||||||
|
windowSize: 30,
|
||||||
|
minSamplesForLongTerm: 10,
|
||||||
|
ewmaAlpha: 0.15,
|
||||||
|
alignmentToleranceMs: 2500,
|
||||||
|
strictValidation: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.errorMetrics.registerMetric("flow", this.driftProfiles.flow);
|
||||||
|
this.errorMetrics.registerMetric("power", this.driftProfiles.power);
|
||||||
|
this.predictionHealth = {
|
||||||
|
quality: "invalid",
|
||||||
|
confidence: 0,
|
||||||
|
pressureSource: null,
|
||||||
|
flags: ["not_initialized"],
|
||||||
|
};
|
||||||
|
|
||||||
this.currentMode = this.config.mode.current;
|
this.currentMode = this.config.mode.current;
|
||||||
this.currentEfficiencyCurve = {};
|
this.currentEfficiencyCurve = {};
|
||||||
@@ -101,6 +181,7 @@ class Machine {
|
|||||||
upstream: new Set(),
|
upstream: new Set(),
|
||||||
downstream: new Set(),
|
downstream: new Set(),
|
||||||
};
|
};
|
||||||
|
this.childMeasurementListeners = new Map();
|
||||||
this._initVirtualPressureChildren();
|
this._initVirtualPressureChildren();
|
||||||
|
|
||||||
|
|
||||||
@@ -113,12 +194,23 @@ class Machine {
|
|||||||
const measurements = new MeasurementContainer({
|
const measurements = new MeasurementContainer({
|
||||||
autoConvert: true,
|
autoConvert: true,
|
||||||
defaultUnits: {
|
defaultUnits: {
|
||||||
pressure: "mbar",
|
pressure: this.unitPolicy.output.pressure,
|
||||||
flow: this.config.general.unit,
|
flow: this.unitPolicy.output.flow,
|
||||||
power: "kW",
|
power: this.unitPolicy.output.power,
|
||||||
temperature: "C",
|
temperature: this.unitPolicy.output.temperature,
|
||||||
},
|
},
|
||||||
});
|
preferredUnits: {
|
||||||
|
pressure: this.unitPolicy.output.pressure,
|
||||||
|
flow: this.unitPolicy.output.flow,
|
||||||
|
power: this.unitPolicy.output.power,
|
||||||
|
temperature: this.unitPolicy.output.temperature,
|
||||||
|
},
|
||||||
|
canonicalUnits: this.unitPolicy.canonical,
|
||||||
|
storeCanonical: true,
|
||||||
|
strictUnitValidation: true,
|
||||||
|
throwOnInvalidUnit: true,
|
||||||
|
requireUnitForTypes: ['pressure'],
|
||||||
|
}, this.logger);
|
||||||
|
|
||||||
measurements.setChildId(id);
|
measurements.setChildId(id);
|
||||||
measurements.setChildName(name);
|
measurements.setChildName(name);
|
||||||
@@ -133,7 +225,7 @@ class Machine {
|
|||||||
},
|
},
|
||||||
asset: {
|
asset: {
|
||||||
type: "pressure",
|
type: "pressure",
|
||||||
unit: "mbar",
|
unit: this.unitPolicy.output.pressure,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
measurements,
|
measurements,
|
||||||
@@ -151,14 +243,14 @@ class Machine {
|
|||||||
|
|
||||||
_init(){
|
_init(){
|
||||||
//assume standard temperature is 20degrees
|
//assume standard temperature is 20degrees
|
||||||
this.measurements.type('temperature').variant('measured').position('atEquipment').value(15).unit('C');
|
this.measurements.type('temperature').variant('measured').position('atEquipment').value(15, Date.now(), this.unitPolicy.output.temperature);
|
||||||
//assume standard atm pressure is at sea level
|
//assume standard atm pressure is at sea level
|
||||||
this.measurements.type('atmPressure').variant('measured').position('atEquipment').value(101325).unit('Pa');
|
this.measurements.type('atmPressure').variant('measured').position('atEquipment').value(101325, Date.now(), 'Pa');
|
||||||
//populate min and max when curve data is available
|
//populate min and max when curve data is available
|
||||||
const flowunit = this.config.general.unit;
|
const flowunit = this.unitPolicy.canonical.flow;
|
||||||
if (this.predictFlow) {
|
if (this.predictFlow) {
|
||||||
this.measurements.type('flow').variant('predicted').position('max').value(this.predictFlow.currentFxyYMax, Date.now() , flowunit);
|
this.measurements.type('flow').variant('predicted').position('max').value(this.predictFlow.currentFxyYMax, Date.now() , flowunit);
|
||||||
this.measurements.type('flow').variant('predicted').position('min').value(this.predictFlow.currentFxyYMin).unit(this.config.general.unit);
|
this.measurements.type('flow').variant('predicted').position('min').value(this.predictFlow.currentFxyYMin, Date.now(), flowunit);
|
||||||
} else {
|
} else {
|
||||||
this.measurements.type('flow').variant('predicted').position('max').value(0, Date.now(), flowunit);
|
this.measurements.type('flow').variant('predicted').position('max').value(0, Date.now(), flowunit);
|
||||||
this.measurements.type('flow').variant('predicted').position('min').value(0, Date.now(), flowunit);
|
this.measurements.type('flow').variant('predicted').position('min').value(0, Date.now(), flowunit);
|
||||||
@@ -169,9 +261,11 @@ class Machine {
|
|||||||
const isOperational = this._isOperationalState();
|
const isOperational = this._isOperationalState();
|
||||||
if(!isOperational){
|
if(!isOperational){
|
||||||
//overrule the last prediction this should be 0 now
|
//overrule the last prediction this should be 0 now
|
||||||
this.measurements.type("flow").variant("predicted").position("downstream").value(0,Date.now(),this.config.general.unit);
|
this.measurements.type("flow").variant("predicted").position("downstream").value(0,Date.now(),this.unitPolicy.canonical.flow);
|
||||||
this.measurements.type("flow").variant("predicted").position("atEquipment").value(0,Date.now(),this.config.general.unit);
|
this.measurements.type("flow").variant("predicted").position("atEquipment").value(0,Date.now(),this.unitPolicy.canonical.flow);
|
||||||
|
this.measurements.type("power").variant("predicted").position("atEquipment").value(0,Date.now(),this.unitPolicy.canonical.power);
|
||||||
}
|
}
|
||||||
|
this._updatePredictionHealth();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*------------------- Register child events -------------------*/
|
/*------------------- Register child events -------------------*/
|
||||||
@@ -191,24 +285,31 @@ class Machine {
|
|||||||
|
|
||||||
//rebuild to measurementype.variant no position and then switch based on values not strings or names.
|
//rebuild to measurementype.variant no position and then switch based on values not strings or names.
|
||||||
const eventName = `${measurementType}.measured.${position}`;
|
const eventName = `${measurementType}.measured.${position}`;
|
||||||
|
const listenerKey = `${childId}:${eventName}`;
|
||||||
|
const existingListener = this.childMeasurementListeners.get(listenerKey);
|
||||||
|
if (existingListener) {
|
||||||
|
if (typeof existingListener.emitter.off === "function") {
|
||||||
|
existingListener.emitter.off(existingListener.eventName, existingListener.handler);
|
||||||
|
} else if (typeof existingListener.emitter.removeListener === "function") {
|
||||||
|
existingListener.emitter.removeListener(existingListener.eventName, existingListener.handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.debug(`Setting up listener for ${eventName} from child ${child.config.general.name}`);
|
this.logger.debug(`Setting up listener for ${eventName} from child ${child.config.general.name}`);
|
||||||
// Register event listener for measurement updates
|
// Register event listener for measurement updates
|
||||||
child.measurements.emitter.on(eventName, (eventData) => {
|
const listener = (eventData) => {
|
||||||
this.logger.debug(`🔄 ${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`);
|
this.logger.debug(`🔄 ${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`);
|
||||||
|
|
||||||
|
|
||||||
this.logger.debug(` Emitting... ${eventName} with data:`);
|
this.logger.debug(` Emitting... ${eventName} with data:`);
|
||||||
// Store directly in parent's measurement container
|
// Route through centralized handlers so unit validation/conversion is applied once.
|
||||||
this.measurements
|
|
||||||
.type(measurementType)
|
|
||||||
.variant("measured")
|
|
||||||
.position(position)
|
|
||||||
.child(childId)
|
|
||||||
.value(eventData.value, eventData.timestamp, eventData.unit);
|
|
||||||
|
|
||||||
// Call the appropriate handler
|
|
||||||
this._callMeasurementHandler(measurementType, eventData.value, position, eventData);
|
this._callMeasurementHandler(measurementType, eventData.value, position, eventData);
|
||||||
|
};
|
||||||
|
child.measurements.emitter.on(eventName, listener);
|
||||||
|
this.childMeasurementListeners.set(listenerKey, {
|
||||||
|
emitter: child.measurements.emitter,
|
||||||
|
eventName,
|
||||||
|
handler: listener,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +325,10 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
this.updateMeasuredFlow(value, position, context);
|
this.updateMeasuredFlow(value, position, context);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'power':
|
||||||
|
this.updateMeasuredPower(value, position, context);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'temperature':
|
case 'temperature':
|
||||||
this.updateMeasuredTemperature(value, position, context);
|
this.updateMeasuredTemperature(value, position, context);
|
||||||
break;
|
break;
|
||||||
@@ -238,21 +343,352 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
//---------------- END child stuff -------------//
|
//---------------- END child stuff -------------//
|
||||||
|
|
||||||
// Method to assess drift using errorMetrics
|
_buildUnitPolicy(config) {
|
||||||
assessDrift(measurement, processMin, processMax) {
|
const flowOutputUnit = this._resolveUnitOrFallback(
|
||||||
this.logger.debug(`Assessing drift for measurement: ${measurement} processMin: ${processMin} processMax: ${processMax}`);
|
config?.general?.unit,
|
||||||
const predictedMeasurement = this.measurements.type(measurement).variant("predicted").position("downstream").getAllValues().values;
|
'volumeFlowRate',
|
||||||
const measuredMeasurement = this.measurements.type(measurement).variant("measured").position("downstream").getAllValues().values;
|
DEFAULT_IO_UNITS.flow,
|
||||||
|
'general.flow'
|
||||||
|
);
|
||||||
|
const pressureOutputUnit = this._resolveUnitOrFallback(
|
||||||
|
config?.asset?.pressureUnit,
|
||||||
|
'pressure',
|
||||||
|
DEFAULT_IO_UNITS.pressure,
|
||||||
|
'asset.pressure'
|
||||||
|
);
|
||||||
|
const powerOutputUnit = this._resolveUnitOrFallback(
|
||||||
|
config?.asset?.powerUnit,
|
||||||
|
'power',
|
||||||
|
DEFAULT_IO_UNITS.power,
|
||||||
|
'asset.power'
|
||||||
|
);
|
||||||
|
const temperatureOutputUnit = this._resolveUnitOrFallback(
|
||||||
|
config?.asset?.temperatureUnit,
|
||||||
|
'temperature',
|
||||||
|
DEFAULT_IO_UNITS.temperature,
|
||||||
|
'asset.temperature'
|
||||||
|
);
|
||||||
|
const curveUnits = this._resolveCurveUnits(config?.asset?.curveUnits || {}, flowOutputUnit);
|
||||||
|
|
||||||
if (!predictedMeasurement || !measuredMeasurement) return null;
|
return {
|
||||||
|
canonical: { ...CANONICAL_UNITS },
|
||||||
|
output: {
|
||||||
|
pressure: pressureOutputUnit,
|
||||||
|
flow: flowOutputUnit,
|
||||||
|
power: powerOutputUnit,
|
||||||
|
temperature: temperatureOutputUnit,
|
||||||
|
atmPressure: 'Pa',
|
||||||
|
},
|
||||||
|
curve: curveUnits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return this.errorMetrics.assessDrift(
|
_resolveCurveUnits(curveUnits = {}, fallbackFlowUnit = DEFAULT_CURVE_UNITS.flow) {
|
||||||
predictedMeasurement,
|
const pressure = this._resolveUnitOrFallback(
|
||||||
measuredMeasurement,
|
curveUnits.pressure,
|
||||||
processMin,
|
'pressure',
|
||||||
processMax
|
DEFAULT_CURVE_UNITS.pressure,
|
||||||
);
|
'asset.curveUnits.pressure'
|
||||||
|
);
|
||||||
|
const flow = this._resolveUnitOrFallback(
|
||||||
|
curveUnits.flow,
|
||||||
|
'volumeFlowRate',
|
||||||
|
fallbackFlowUnit || DEFAULT_CURVE_UNITS.flow,
|
||||||
|
'asset.curveUnits.flow'
|
||||||
|
);
|
||||||
|
const power = this._resolveUnitOrFallback(
|
||||||
|
curveUnits.power,
|
||||||
|
'power',
|
||||||
|
DEFAULT_CURVE_UNITS.power,
|
||||||
|
'asset.curveUnits.power'
|
||||||
|
);
|
||||||
|
const control = typeof curveUnits.control === 'string' && curveUnits.control.trim()
|
||||||
|
? curveUnits.control.trim()
|
||||||
|
: DEFAULT_CURVE_UNITS.control;
|
||||||
|
|
||||||
|
return { pressure, flow, power, control };
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) {
|
||||||
|
const fallback = String(fallbackUnit || '').trim();
|
||||||
|
const raw = typeof candidate === 'string' ? candidate.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.logger.warn(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`);
|
||||||
|
return fallback;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_convertUnitValue(value, fromUnit, toUnit, contextLabel = 'unit conversion') {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) {
|
||||||
|
throw new Error(`${contextLabel}: value '${value}' is not finite`);
|
||||||
|
}
|
||||||
|
if (!fromUnit || !toUnit || fromUnit === toUnit) {
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
return convert(numeric).from(fromUnit).to(toUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName) {
|
||||||
|
const normalized = {};
|
||||||
|
for (const [pressureKey, pair] of Object.entries(section || {})) {
|
||||||
|
const canonicalPressure = this._convertUnitValue(
|
||||||
|
Number(pressureKey),
|
||||||
|
fromPressureUnit,
|
||||||
|
toPressureUnit,
|
||||||
|
`${sectionName} pressure axis`
|
||||||
|
);
|
||||||
|
const xArray = Array.isArray(pair?.x) ? pair.x.map(Number) : [];
|
||||||
|
const yArray = Array.isArray(pair?.y) ? pair.y.map((v) => this._convertUnitValue(v, fromYUnit, toYUnit, `${sectionName} output`)) : [];
|
||||||
|
if (!xArray.length || !yArray.length || xArray.length !== yArray.length) {
|
||||||
|
throw new Error(`Invalid ${sectionName} section at pressure '${pressureKey}'.`);
|
||||||
|
}
|
||||||
|
normalized[String(canonicalPressure)] = {
|
||||||
|
x: xArray,
|
||||||
|
y: yArray,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeMachineCurve(rawCurve, curveUnits = this.unitPolicy.curve) {
|
||||||
|
if (!rawCurve || typeof rawCurve !== 'object' || !rawCurve.nq || !rawCurve.np) {
|
||||||
|
throw new Error('Machine curve is missing required nq/np sections.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nq: this._normalizeCurveSection(
|
||||||
|
rawCurve.nq,
|
||||||
|
curveUnits.flow,
|
||||||
|
this.unitPolicy.canonical.flow,
|
||||||
|
curveUnits.pressure,
|
||||||
|
this.unitPolicy.canonical.pressure,
|
||||||
|
'nq'
|
||||||
|
),
|
||||||
|
np: this._normalizeCurveSection(
|
||||||
|
rawCurve.np,
|
||||||
|
curveUnits.power,
|
||||||
|
this.unitPolicy.canonical.power,
|
||||||
|
curveUnits.pressure,
|
||||||
|
this.unitPolicy.canonical.pressure,
|
||||||
|
'np'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnitValidForType(type, unit) {
|
||||||
|
return this.measurements?.isUnitCompatible?.(type, unit) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveMeasurementUnit(type, providedUnit) {
|
||||||
|
const unit = typeof providedUnit === 'string' ? providedUnit.trim() : '';
|
||||||
|
if (!unit) {
|
||||||
|
throw new Error(`Missing unit for ${type} measurement.`);
|
||||||
|
}
|
||||||
|
if (!this.isUnitValidForType(type, unit)) {
|
||||||
|
throw new Error(`Unsupported unit '${unit}' for ${type} measurement.`);
|
||||||
|
}
|
||||||
|
return unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
_measurementPositionForMetric(metricId) {
|
||||||
|
if (metricId === "power") return "atEquipment";
|
||||||
|
return "downstream";
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveProcessRangeForMetric(metricId, predictedValue, measuredValue) {
|
||||||
|
let processMin = NaN;
|
||||||
|
let processMax = NaN;
|
||||||
|
|
||||||
|
if (metricId === "flow") {
|
||||||
|
processMin = Number(this.predictFlow?.currentFxyYMin);
|
||||||
|
processMax = Number(this.predictFlow?.currentFxyYMax);
|
||||||
|
} else if (metricId === "power") {
|
||||||
|
processMin = Number(this.predictPower?.currentFxyYMin);
|
||||||
|
processMax = Number(this.predictPower?.currentFxyYMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(processMin) || !Number.isFinite(processMax) || processMax <= processMin) {
|
||||||
|
const p = Number(predictedValue);
|
||||||
|
const m = Number(measuredValue);
|
||||||
|
const localMin = Math.min(p, m);
|
||||||
|
const localMax = Math.max(p, m);
|
||||||
|
processMin = Number.isFinite(localMin) ? localMin : 0;
|
||||||
|
processMax = Number.isFinite(localMax) && localMax > processMin ? localMax : processMin + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processMin, processMax };
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateMetricDrift(metricId, measuredValue, context = {}) {
|
||||||
|
const position = this._measurementPositionForMetric(metricId);
|
||||||
|
const predictedValue = Number(
|
||||||
|
this.measurements
|
||||||
|
.type(metricId)
|
||||||
|
.variant("predicted")
|
||||||
|
.position(position)
|
||||||
|
.getCurrentValue()
|
||||||
|
);
|
||||||
|
const measured = Number(measuredValue);
|
||||||
|
if (!Number.isFinite(predictedValue) || !Number.isFinite(measured)) return null;
|
||||||
|
|
||||||
|
const { processMin, processMax } = this._resolveProcessRangeForMetric(metricId, predictedValue, measured);
|
||||||
|
const timestamp = Number(context.timestamp || Date.now());
|
||||||
|
const profile = this.driftProfiles[metricId] || {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const drift = this.errorMetrics.assessPoint(metricId, predictedValue, measured, {
|
||||||
|
...profile,
|
||||||
|
processMin,
|
||||||
|
processMax,
|
||||||
|
predictedTimestamp: timestamp,
|
||||||
|
measuredTimestamp: timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (drift && drift.valid) {
|
||||||
|
if (metricId === "flow") this.flowDrift = drift;
|
||||||
|
if (metricId === "power") this.powerDrift = drift;
|
||||||
|
}
|
||||||
|
|
||||||
|
return drift;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Drift update failed for metric '${metricId}': ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatePressureDriftStatus() {
|
||||||
|
const status = this.getPressureInitializationStatus();
|
||||||
|
const flags = [];
|
||||||
|
let level = 0;
|
||||||
|
|
||||||
|
if (!status.initialized) {
|
||||||
|
level = 2;
|
||||||
|
flags.push("no_pressure_input");
|
||||||
|
} else if (!status.hasDifferential) {
|
||||||
|
level = 1;
|
||||||
|
flags.push("single_side_pressure");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.hasDifferential) {
|
||||||
|
const upstream = this._getPreferredPressureValue("upstream");
|
||||||
|
const downstream = this._getPreferredPressureValue("downstream");
|
||||||
|
const diff = Number(downstream) - Number(upstream);
|
||||||
|
if (Number.isFinite(diff) && diff < 0) {
|
||||||
|
level = Math.max(level, 3);
|
||||||
|
flags.push("negative_pressure_differential");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pressureDrift = {
|
||||||
|
level,
|
||||||
|
source: status.source,
|
||||||
|
flags: flags.length ? flags : ["nominal"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.pressureDrift;
|
||||||
|
}
|
||||||
|
|
||||||
|
assessDrift(measurement, processMin, processMax) {
|
||||||
|
const metricId = String(measurement || "").toLowerCase();
|
||||||
|
const position = this._measurementPositionForMetric(metricId);
|
||||||
|
const predictedMeasurement = this.measurements.type(metricId).variant("predicted").position(position).getAllValues();
|
||||||
|
const measuredMeasurement = this.measurements.type(metricId).variant("measured").position(position).getAllValues();
|
||||||
|
|
||||||
|
if (!predictedMeasurement?.values || !measuredMeasurement?.values) return null;
|
||||||
|
|
||||||
|
return this.errorMetrics.assessDrift(
|
||||||
|
predictedMeasurement.values,
|
||||||
|
measuredMeasurement.values,
|
||||||
|
processMin,
|
||||||
|
processMax,
|
||||||
|
{
|
||||||
|
metricId,
|
||||||
|
predictedTimestamps: predictedMeasurement.timestamps,
|
||||||
|
measuredTimestamps: measuredMeasurement.timestamps,
|
||||||
|
...(this.driftProfiles[metricId] || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyDriftPenalty(drift, confidence, flags, prefix) {
|
||||||
|
if (!drift || !drift.valid || !Number.isFinite(drift.nrmse)) return confidence;
|
||||||
|
if (drift.immediateLevel >= 3) {
|
||||||
|
confidence -= 0.3;
|
||||||
|
flags.push(`${prefix}_high_immediate_drift`);
|
||||||
|
} else if (drift.immediateLevel === 2) {
|
||||||
|
confidence -= 0.2;
|
||||||
|
flags.push(`${prefix}_medium_immediate_drift`);
|
||||||
|
} else if (drift.immediateLevel === 1) {
|
||||||
|
confidence -= 0.1;
|
||||||
|
flags.push(`${prefix}_low_immediate_drift`);
|
||||||
|
}
|
||||||
|
if (drift.longTermLevel >= 2) {
|
||||||
|
confidence -= 0.1;
|
||||||
|
flags.push(`${prefix}_long_term_drift`);
|
||||||
|
}
|
||||||
|
return confidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatePredictionHealth() {
|
||||||
|
const status = this.getPressureInitializationStatus();
|
||||||
|
const pressureDrift = this._updatePressureDriftStatus();
|
||||||
|
const flags = [...pressureDrift.flags];
|
||||||
|
let confidence = 0;
|
||||||
|
|
||||||
|
const pressureSource = status.source;
|
||||||
|
if (pressureSource === "differential") {
|
||||||
|
confidence = 0.9;
|
||||||
|
} else if (pressureSource === "upstream" || pressureSource === "downstream") {
|
||||||
|
confidence = 0.55;
|
||||||
|
} else {
|
||||||
|
confidence = 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._isOperationalState()) {
|
||||||
|
confidence = 0;
|
||||||
|
flags.push("not_operational");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pressureDrift.level >= 3) confidence -= 0.35;
|
||||||
|
else if (pressureDrift.level === 2) confidence -= 0.2;
|
||||||
|
else if (pressureDrift.level === 1) confidence -= 0.1;
|
||||||
|
|
||||||
|
const currentPosition = Number(this.state?.getCurrentPosition?.());
|
||||||
|
const { min, max } = this._resolveSetpointBounds();
|
||||||
|
if (Number.isFinite(currentPosition) && Number.isFinite(min) && Number.isFinite(max) && max > min) {
|
||||||
|
const span = max - min;
|
||||||
|
const edgeDistance = Math.min(Math.abs(currentPosition - min), Math.abs(max - currentPosition));
|
||||||
|
if (edgeDistance < span * 0.05) {
|
||||||
|
confidence -= 0.1;
|
||||||
|
flags.push("near_curve_edge");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confidence = this._applyDriftPenalty(this.flowDrift, confidence, flags, "flow");
|
||||||
|
confidence = this._applyDriftPenalty(this.powerDrift, confidence, flags, "power");
|
||||||
|
|
||||||
|
confidence = Math.max(0, Math.min(1, confidence));
|
||||||
|
let quality = "invalid";
|
||||||
|
if (confidence >= 0.8) quality = "high";
|
||||||
|
else if (confidence >= 0.55) quality = "medium";
|
||||||
|
else if (confidence >= 0.3) quality = "low";
|
||||||
|
|
||||||
|
this.predictionHealth = {
|
||||||
|
quality,
|
||||||
|
confidence,
|
||||||
|
pressureSource,
|
||||||
|
flags: flags.length ? Array.from(new Set(flags)) : ["nominal"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.predictionHealth;
|
||||||
|
}
|
||||||
|
|
||||||
reverseCurve(curve) {
|
reverseCurve(curve) {
|
||||||
const reversedCurve = {};
|
const reversedCurve = {};
|
||||||
@@ -322,8 +758,15 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
return await this.executeSequence(parameter);
|
return await this.executeSequence(parameter);
|
||||||
|
|
||||||
case "flowmovement":
|
case "flowmovement":
|
||||||
|
// External flow setpoint is interpreted in configured output flow unit.
|
||||||
|
const canonicalFlowSetpoint = this._convertUnitValue(
|
||||||
|
parameter,
|
||||||
|
this.unitPolicy.output.flow,
|
||||||
|
this.unitPolicy.canonical.flow,
|
||||||
|
'flowmovement setpoint'
|
||||||
|
);
|
||||||
// Calculate the control value for a desired flow
|
// Calculate the control value for a desired flow
|
||||||
const pos = this.calcCtrl(parameter);
|
const pos = this.calcCtrl(canonicalFlowSetpoint);
|
||||||
// Move to the desired setpoint
|
// Move to the desired setpoint
|
||||||
return await this.setpoint(pos);
|
return await this.setpoint(pos);
|
||||||
|
|
||||||
@@ -399,42 +842,72 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
async setpoint(setpoint) {
|
async setpoint(setpoint) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate setpoint
|
// Validate and normalize setpoint
|
||||||
if (typeof setpoint !== 'number' || setpoint < 0) {
|
if (!Number.isFinite(setpoint)) {
|
||||||
throw new Error("Invalid setpoint: Setpoint must be a non-negative number.");
|
this.logger.error("Invalid setpoint: Setpoint must be a finite number.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { min, max } = this._resolveSetpointBounds();
|
||||||
|
const constrainedSetpoint = Math.min(Math.max(setpoint, min), max);
|
||||||
|
if (constrainedSetpoint !== setpoint) {
|
||||||
|
this.logger.warn(`Requested setpoint ${setpoint} constrained to ${constrainedSetpoint} (min=${min}, max=${max})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Setting setpoint to ${setpoint}. Current position: ${this.state.getCurrentPosition()}`);
|
this.logger.info(`Setting setpoint to ${constrainedSetpoint}. Current position: ${this.state.getCurrentPosition()}`);
|
||||||
|
|
||||||
// Move to the desired setpoint
|
// Move to the desired setpoint
|
||||||
await this.state.moveTo(setpoint);
|
await this.state.moveTo(constrainedSetpoint);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error setting setpoint: ${error}`);
|
this.logger.error(`Error setting setpoint: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_resolveSetpointBounds() {
|
||||||
|
const stateMin = Number(this.state?.movementManager?.minPosition);
|
||||||
|
const stateMax = Number(this.state?.movementManager?.maxPosition);
|
||||||
|
const curveMin = Number(this.predictFlow?.currentFxyXMin);
|
||||||
|
const curveMax = Number(this.predictFlow?.currentFxyXMax);
|
||||||
|
|
||||||
|
const minCandidates = [stateMin, curveMin].filter(Number.isFinite);
|
||||||
|
const maxCandidates = [stateMax, curveMax].filter(Number.isFinite);
|
||||||
|
|
||||||
|
const fallbackMin = Number.isFinite(stateMin) ? stateMin : 0;
|
||||||
|
const fallbackMax = Number.isFinite(stateMax) ? stateMax : 100;
|
||||||
|
|
||||||
|
let min = minCandidates.length ? Math.max(...minCandidates) : fallbackMin;
|
||||||
|
let max = maxCandidates.length ? Math.min(...maxCandidates) : fallbackMax;
|
||||||
|
|
||||||
|
if (min > max) {
|
||||||
|
this.logger.warn(`Invalid setpoint bounds detected (min=${min}, max=${max}). Falling back to movement bounds.`);
|
||||||
|
min = fallbackMin;
|
||||||
|
max = fallbackMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate flow based on current pressure and position
|
// Calculate flow based on current pressure and position
|
||||||
calcFlow(x) {
|
calcFlow(x) {
|
||||||
if(this.hasCurve) {
|
if(this.hasCurve) {
|
||||||
if (!this._isOperationalState()) {
|
if (!this._isOperationalState()) {
|
||||||
this.measurements.type("flow").variant("predicted").position("downstream").value(0,Date.now(),this.config.general.unit);
|
this.measurements.type("flow").variant("predicted").position("downstream").value(0,Date.now(),this.unitPolicy.canonical.flow);
|
||||||
this.measurements.type("flow").variant("predicted").position("atEquipment").value(0,Date.now(),this.config.general.unit);
|
this.measurements.type("flow").variant("predicted").position("atEquipment").value(0,Date.now(),this.unitPolicy.canonical.flow);
|
||||||
this.logger.debug(`Machine is not operational. Setting predicted flow to 0.`);
|
this.logger.debug(`Machine is not operational. Setting predicted flow to 0.`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cFlow = this.predictFlow.y(x);
|
const cFlow = this.predictFlow.y(x);
|
||||||
this.measurements.type("flow").variant("predicted").position("downstream").value(cFlow,Date.now(),this.config.general.unit);
|
this.measurements.type("flow").variant("predicted").position("downstream").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
|
||||||
this.measurements.type("flow").variant("predicted").position("atEquipment").value(cFlow,Date.now(),this.config.general.unit);
|
this.measurements.type("flow").variant("predicted").position("atEquipment").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
|
||||||
//this.logger.debug(`Calculated flow: ${cFlow} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
|
//this.logger.debug(`Calculated flow: ${cFlow} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
|
||||||
return cFlow;
|
return cFlow;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no curve data is available, log a warning and return 0
|
// If no curve data is available, log a warning and return 0
|
||||||
this.logger.warn(`No curve data available for flow calculation. Returning 0.`);
|
this.logger.warn(`No curve data available for flow calculation. Returning 0.`);
|
||||||
this.measurements.type("flow").variant("predicted").position("downstream").value(0, Date.now(),this.config.general.unit);
|
this.measurements.type("flow").variant("predicted").position("downstream").value(0, Date.now(),this.unitPolicy.canonical.flow);
|
||||||
this.measurements.type("flow").variant("predicted").position("atEquipment").value(0, Date.now(),this.config.general.unit);
|
this.measurements.type("flow").variant("predicted").position("atEquipment").value(0, Date.now(),this.unitPolicy.canonical.flow);
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -443,20 +916,20 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
calcPower(x) {
|
calcPower(x) {
|
||||||
if(this.hasCurve) {
|
if(this.hasCurve) {
|
||||||
if (!this._isOperationalState()) {
|
if (!this._isOperationalState()) {
|
||||||
this.measurements.type("power").variant("predicted").position('atEquipment').value(0);
|
this.measurements.type("power").variant("predicted").position('atEquipment').value(0, Date.now(), this.unitPolicy.canonical.power);
|
||||||
this.logger.debug(`Machine is not operational. Setting predicted power to 0.`);
|
this.logger.debug(`Machine is not operational. Setting predicted power to 0.`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
//this.predictPower.currentX = x; Decrepated
|
//this.predictPower.currentX = x; Decrepated
|
||||||
const cPower = this.predictPower.y(x);
|
const cPower = this.predictPower.y(x);
|
||||||
this.measurements.type("power").variant("predicted").position('atEquipment').value(cPower);
|
this.measurements.type("power").variant("predicted").position('atEquipment').value(cPower, Date.now(), this.unitPolicy.canonical.power);
|
||||||
//this.logger.debug(`Calculated power: ${cPower} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
|
//this.logger.debug(`Calculated power: ${cPower} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
|
||||||
return cPower;
|
return cPower;
|
||||||
}
|
}
|
||||||
// If no curve data is available, log a warning and return 0
|
// If no curve data is available, log a warning and return 0
|
||||||
this.logger.warn(`No curve data available for power calculation. Returning 0.`);
|
this.logger.warn(`No curve data available for power calculation. Returning 0.`);
|
||||||
this.measurements.type("power").variant("predicted").position('atEquipment').value(0);
|
this.measurements.type("power").variant("predicted").position('atEquipment').value(0, Date.now(), this.unitPolicy.canonical.power);
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -474,7 +947,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
// If no curve data is available, log a warning and return 0
|
// If no curve data is available, log a warning and return 0
|
||||||
this.logger.warn(`No curve data available for power calculation. Returning 0.`);
|
this.logger.warn(`No curve data available for power calculation. Returning 0.`);
|
||||||
this.measurements.type("power").variant("predicted").position('atEquipment').value(0);
|
this.measurements.type("power").variant("predicted").position('atEquipment').value(0, Date.now(), this.unitPolicy.canonical.power);
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -491,7 +964,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
// If no curve data is available, log a warning and return 0
|
// If no curve data is available, log a warning and return 0
|
||||||
this.logger.warn(`No curve data available for control calculation. Returning 0.`);
|
this.logger.warn(`No curve data available for control calculation. Returning 0.`);
|
||||||
this.measurements.type("ctrl").variant("predicted").position('atEquipment').value(0);
|
this.measurements.type("ctrl").variant("predicted").position('atEquipment').value(0, Date.now());
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -567,8 +1040,8 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
//update the distance from peak
|
//update the distance from peak
|
||||||
this.calcDistanceBEP(efficiency,cog,minEfficiency);
|
this.calcDistanceBEP(efficiency,cog,minEfficiency);
|
||||||
//place min and max flow capabilities in containerthis.predictFlow.currentFxyYMax - this.predictFlow.currentFxyYMin
|
//place min and max flow capabilities in containerthis.predictFlow.currentFxyYMax - this.predictFlow.currentFxyYMin
|
||||||
this.measurements.type('flow').variant('predicted').position('max').value(this.predictFlow.currentFxyYMax).unit(this.config.general.unit);
|
this.measurements.type('flow').variant('predicted').position('max').value(this.predictFlow.currentFxyYMax, Date.now(), this.unitPolicy.canonical.flow);
|
||||||
this.measurements.type('flow').variant('predicted').position('min').value(this.predictFlow.currentFxyYMin).unit(this.config.general.unit);
|
this.measurements.type('flow').variant('predicted').position('min').value(this.predictFlow.currentFxyYMin, Date.now(), this.unitPolicy.canonical.flow);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,11 +1112,19 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let measurementUnit;
|
||||||
|
try {
|
||||||
|
measurementUnit = this._resolveMeasurementUnit('pressure', context.unit);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Rejected simulated pressure measurement: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
child.measurements
|
child.measurements
|
||||||
.type("pressure")
|
.type("pressure")
|
||||||
.variant("measured")
|
.variant("measured")
|
||||||
.position(normalizedPosition)
|
.position(normalizedPosition)
|
||||||
.value(value, context.timestamp || Date.now(), context.unit || "mbar");
|
.value(value, context.timestamp || Date.now(), measurementUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMeasuredFlow() {
|
handleMeasuredFlow() {
|
||||||
@@ -702,19 +1183,36 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
updateMeasuredTemperature(value, position, context = {}) {
|
updateMeasuredTemperature(value, position, context = {}) {
|
||||||
this.logger.debug(`Temperature update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`);
|
this.logger.debug(`Temperature update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`);
|
||||||
|
let measurementUnit;
|
||||||
|
try {
|
||||||
|
measurementUnit = this._resolveMeasurementUnit('temperature', context.unit);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Rejected temperature update: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.measurements.type("temperature").variant("measured").position(position || 'atEquipment').child(context.childId).value(value, context.timestamp, measurementUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// context handler for pressure updates
|
// context handler for pressure updates
|
||||||
updateMeasuredPressure(value, position, context = {}) {
|
updateMeasuredPressure(value, position, context = {}) {
|
||||||
|
|
||||||
this.logger.debug(`Pressure update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`);
|
this.logger.debug(`Pressure update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`);
|
||||||
|
let measurementUnit;
|
||||||
|
try {
|
||||||
|
measurementUnit = this._resolveMeasurementUnit('pressure', context.unit);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Rejected pressure update: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Store in parent's measurement container
|
// Store in parent's measurement container
|
||||||
this.measurements.type("pressure").variant("measured").position(position).value(value, context.timestamp, context.unit);
|
this.measurements.type("pressure").variant("measured").position(position).child(context.childId).value(value, context.timestamp, measurementUnit);
|
||||||
|
|
||||||
// Determine what kind of value to use as pressure (upstream , downstream or difference)
|
// Determine what kind of value to use as pressure (upstream , downstream or difference)
|
||||||
const pressure = this.getMeasuredPressure();
|
const pressure = this.getMeasuredPressure();
|
||||||
this.updatePosition();
|
this.updatePosition();
|
||||||
|
this._updatePressureDriftStatus();
|
||||||
|
this._updatePredictionHealth();
|
||||||
|
|
||||||
this.logger.debug(`Using pressure: ${pressure} for calculations`);
|
this.logger.debug(`Using pressure: ${pressure} for calculations`);
|
||||||
}
|
}
|
||||||
@@ -727,15 +1225,61 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Flow update: ${value} at ${position} from ${context.childName || 'child'}`);
|
this.logger.debug(`Flow update: ${value} at ${position} from ${context.childName || 'child'}`);
|
||||||
|
let measurementUnit;
|
||||||
|
try {
|
||||||
|
measurementUnit = this._resolveMeasurementUnit('flow', context.unit);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Rejected flow update: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Store in parent's measurement container
|
// Store in parent's measurement container
|
||||||
this.measurements.type("flow").variant("measured").position(position).value(value, context.timestamp, context.unit);
|
this.measurements.type("flow").variant("measured").position(position).child(context.childId).value(value, context.timestamp, measurementUnit);
|
||||||
|
|
||||||
// Update predicted flow if you have prediction capability
|
// Update predicted flow if you have prediction capability
|
||||||
if (this.predictFlow) {
|
if (this.predictFlow) {
|
||||||
this.measurements.type("flow").variant("predicted").position("downstream").value(this.predictFlow.outputY || 0);
|
this.measurements.type("flow").variant("predicted").position("downstream").value(this.predictFlow.outputY || 0, Date.now(), this.unitPolicy.canonical.flow);
|
||||||
this.measurements.type("flow").variant("predicted").position("atEquipment").value(this.predictFlow.outputY || 0);
|
this.measurements.type("flow").variant("predicted").position("atEquipment").value(this.predictFlow.outputY || 0, Date.now(), this.unitPolicy.canonical.flow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const measuredCanonical = this.measurements
|
||||||
|
.type("flow")
|
||||||
|
.variant("measured")
|
||||||
|
.position(position)
|
||||||
|
.getCurrentValue(this.unitPolicy.canonical.flow);
|
||||||
|
|
||||||
|
this._updateMetricDrift("flow", measuredCanonical, context);
|
||||||
|
this._updatePredictionHealth();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMeasuredPower(value, position, context = {}) {
|
||||||
|
if (!this._isOperationalState()) {
|
||||||
|
this.logger.warn(`Machine not operational, skipping power update from ${context.childName || 'unknown'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Power update: ${value} at ${position} from ${context.childName || 'child'}`);
|
||||||
|
let measurementUnit;
|
||||||
|
try {
|
||||||
|
measurementUnit = this._resolveMeasurementUnit('power', context.unit);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Rejected power update: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.measurements.type("power").variant("measured").position(position).child(context.childId).value(value, context.timestamp, measurementUnit);
|
||||||
|
|
||||||
|
if (this.predictPower) {
|
||||||
|
this.measurements.type("power").variant("predicted").position("atEquipment").value(this.predictPower.outputY || 0, Date.now(), this.unitPolicy.canonical.power);
|
||||||
|
}
|
||||||
|
|
||||||
|
const measuredCanonical = this.measurements
|
||||||
|
.type("power")
|
||||||
|
.variant("measured")
|
||||||
|
.position(position)
|
||||||
|
.getCurrentValue(this.unitPolicy.canonical.power);
|
||||||
|
|
||||||
|
this._updateMetricDrift("power", measuredCanonical, context);
|
||||||
|
this._updatePredictionHealth();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method for operational state check
|
// Helper method for operational state check
|
||||||
@@ -767,6 +1311,8 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._updatePredictionHealth();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
calcDistanceFromPeak(currentEfficiency,peakEfficiency){
|
calcDistanceFromPeak(currentEfficiency,peakEfficiency){
|
||||||
@@ -889,9 +1435,17 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
this.measurements.type("efficiency").variant(variant).position('atEquipment').value(specificFlow);
|
this.measurements.type("efficiency").variant(variant).position('atEquipment').value(specificFlow);
|
||||||
this.measurements.type("specificEnergyConsumption").variant(variant).position('atEquipment').value(specificEnergyConsumption);
|
this.measurements.type("specificEnergyConsumption").variant(variant).position('atEquipment').value(specificEnergyConsumption);
|
||||||
|
|
||||||
if(pressureDiff?.value != null && flowM3s != null && powerWatt != null){
|
if (pressureDiff?.value != null && Number.isFinite(flowM3s) && Number.isFinite(powerWatt) && powerWatt > 0) {
|
||||||
const meterPerBar = pressureDiff.value / rho * g;
|
// Engineering references: P_h = Q * Δp = ρ g Q H, η_h = P_h / P_in
|
||||||
const nHydraulicEfficiency = rho * g * flowM3s * (pressureDiff.value * meterPerBar ) / powerWatt;
|
const pressureDiffPa = Number(pressureDiff.value);
|
||||||
|
const headMeters = (Number.isFinite(rho) && rho > 0) ? pressureDiffPa / (rho * g) : null;
|
||||||
|
const hydraulicPowerW = pressureDiffPa * flowM3s;
|
||||||
|
const nHydraulicEfficiency = hydraulicPowerW / powerWatt;
|
||||||
|
|
||||||
|
if (Number.isFinite(headMeters)) {
|
||||||
|
this.measurements.type("pumpHead").variant(variant).position('atEquipment').value(headMeters, Date.now(), 'm');
|
||||||
|
}
|
||||||
|
this.measurements.type("hydraulicPower").variant(variant).position('atEquipment').value(hydraulicPowerW, Date.now(), 'W');
|
||||||
this.measurements.type("nHydraulicEfficiency").variant(variant).position('atEquipment').value(nHydraulicEfficiency);
|
this.measurements.type("nHydraulicEfficiency").variant(variant).position('atEquipment').value(nHydraulicEfficiency);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,7 +1458,13 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
updateCurve(newCurve) {
|
updateCurve(newCurve) {
|
||||||
this.logger.info(`Updating machine curve`);
|
this.logger.info(`Updating machine curve`);
|
||||||
const newConfig = { asset: { machineCurve: newCurve } };
|
const normalizedCurve = this._normalizeMachineCurve(newCurve);
|
||||||
|
const newConfig = {
|
||||||
|
asset: {
|
||||||
|
machineCurve: normalizedCurve,
|
||||||
|
curveUnits: this.unitPolicy.curve,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
//validate input of new curve fed to the machine
|
//validate input of new curve fed to the machine
|
||||||
this.config = this.configUtils.updateConfig(this.config, newConfig);
|
this.config = this.configUtils.updateConfig(this.config, newConfig);
|
||||||
@@ -945,7 +1505,9 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
// Improved output object generation
|
// Improved output object generation
|
||||||
|
|
||||||
const output = this.measurements.getFlattenedOutput();
|
const output = this.measurements.getFlattenedOutput({
|
||||||
|
requestedUnits: this.unitPolicy.output,
|
||||||
|
});
|
||||||
|
|
||||||
//fill in the rest of the output object
|
//fill in the rest of the output object
|
||||||
output["state"] = this.state.getCurrentState();
|
output["state"] = this.state.getCurrentState();
|
||||||
@@ -962,10 +1524,30 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
const flowDrift = this.flowDrift;
|
const flowDrift = this.flowDrift;
|
||||||
output["flowNrmse"] = flowDrift.nrmse;
|
output["flowNrmse"] = flowDrift.nrmse;
|
||||||
output["flowLongterNRMSD"] = flowDrift.longTermNRMSD;
|
output["flowLongterNRMSD"] = flowDrift.longTermNRMSD;
|
||||||
|
output["flowLongTermNRMSD"] = flowDrift.longTermNRMSD;
|
||||||
output["flowImmediateLevel"] = flowDrift.immediateLevel;
|
output["flowImmediateLevel"] = flowDrift.immediateLevel;
|
||||||
output["flowLongTermLevel"] = flowDrift.longTermLevel;
|
output["flowLongTermLevel"] = flowDrift.longTermLevel;
|
||||||
|
output["flowDriftValid"] = flowDrift.valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(this.powerDrift != null){
|
||||||
|
const powerDrift = this.powerDrift;
|
||||||
|
output["powerNrmse"] = powerDrift.nrmse;
|
||||||
|
output["powerLongTermNRMSD"] = powerDrift.longTermNRMSD;
|
||||||
|
output["powerImmediateLevel"] = powerDrift.immediateLevel;
|
||||||
|
output["powerLongTermLevel"] = powerDrift.longTermLevel;
|
||||||
|
output["powerDriftValid"] = powerDrift.valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
output["pressureDriftLevel"] = this.pressureDrift.level;
|
||||||
|
output["pressureDriftSource"] = this.pressureDrift.source;
|
||||||
|
output["pressureDriftFlags"] = this.pressureDrift.flags;
|
||||||
|
|
||||||
|
output["predictionQuality"] = this.predictionHealth.quality;
|
||||||
|
output["predictionConfidence"] = Math.round(this.predictionHealth.confidence * 1000) / 1000;
|
||||||
|
output["predictionPressureSource"] = this.predictionHealth.pressureSource;
|
||||||
|
output["predictionFlags"] = this.predictionHealth.flags;
|
||||||
|
|
||||||
//should this all go in the container of measurements?
|
//should this all go in the container of measurements?
|
||||||
output["effDistFromPeak"] = this.absDistFromPeak;
|
output["effDistFromPeak"] = this.absDistFromPeak;
|
||||||
output["effRelDistFromPeak"] = this.relDistFromPeak;
|
output["effRelDistFromPeak"] = this.relDistFromPeak;
|
||||||
@@ -978,4 +1560,3 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
} // end of class
|
} // end of class
|
||||||
|
|
||||||
module.exports = Machine;
|
module.exports = Machine;
|
||||||
|
|
||||||
|
|||||||
109
test/basic/nodeClass-config.basic.test.js
Normal file
109
test/basic/nodeClass-config.basic.test.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const NodeClass = require('../../src/nodeClass');
|
||||||
|
const { makeNodeStub } = require('../helpers/factories');
|
||||||
|
|
||||||
|
function makeUiConfig(overrides = {}) {
|
||||||
|
return {
|
||||||
|
unit: 'm3/h',
|
||||||
|
enableLog: true,
|
||||||
|
logLevel: 'debug',
|
||||||
|
supplier: 'hidrostal',
|
||||||
|
category: 'machine',
|
||||||
|
assetType: 'pump',
|
||||||
|
model: 'hidrostal-H05K-S03R',
|
||||||
|
curvePressureUnit: 'mbar',
|
||||||
|
curveFlowUnit: 'm3/h',
|
||||||
|
curvePowerUnit: 'kW',
|
||||||
|
curveControlUnit: '%',
|
||||||
|
positionVsParent: 'atEquipment',
|
||||||
|
speed: 1,
|
||||||
|
movementMode: 'staticspeed',
|
||||||
|
startup: 0,
|
||||||
|
warmup: 0,
|
||||||
|
shutdown: 0,
|
||||||
|
cooldown: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('_loadConfig maps legacy editor fields for asset identity', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
inst.node = makeNodeStub();
|
||||||
|
inst.name = 'rotatingMachine';
|
||||||
|
|
||||||
|
inst._loadConfig(
|
||||||
|
makeUiConfig({
|
||||||
|
uuid: 'uuid-from-editor',
|
||||||
|
assetTagNumber: 'TAG-123',
|
||||||
|
}),
|
||||||
|
inst.node
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(inst.config.asset.uuid, 'uuid-from-editor');
|
||||||
|
assert.equal(inst.config.asset.tagCode, 'TAG-123');
|
||||||
|
assert.equal(inst.config.asset.tagNumber, 'TAG-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_loadConfig prefers explicit assetUuid/assetTagCode when present', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
inst.node = makeNodeStub();
|
||||||
|
inst.name = 'rotatingMachine';
|
||||||
|
|
||||||
|
inst._loadConfig(
|
||||||
|
makeUiConfig({
|
||||||
|
uuid: 'legacy-uuid',
|
||||||
|
assetUuid: 'explicit-uuid',
|
||||||
|
assetTagNumber: 'legacy-tag',
|
||||||
|
assetTagCode: 'explicit-tag',
|
||||||
|
}),
|
||||||
|
inst.node
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(inst.config.asset.uuid, 'explicit-uuid');
|
||||||
|
assert.equal(inst.config.asset.tagCode, 'explicit-tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_loadConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
inst.node = makeNodeStub();
|
||||||
|
inst.name = 'rotatingMachine';
|
||||||
|
|
||||||
|
inst._loadConfig(
|
||||||
|
makeUiConfig({
|
||||||
|
unit: 'not-a-unit',
|
||||||
|
curvePressureUnit: 'mbar',
|
||||||
|
curveFlowUnit: 'm3/h',
|
||||||
|
curvePowerUnit: 'kW',
|
||||||
|
curveControlUnit: '%',
|
||||||
|
}),
|
||||||
|
inst.node
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(inst.config.general.unit, 'm3/h');
|
||||||
|
assert.equal(inst.config.asset.unit, 'm3/h');
|
||||||
|
assert.equal(inst.config.asset.curveUnits.pressure, 'mbar');
|
||||||
|
assert.equal(inst.config.asset.curveUnits.flow, 'm3/h');
|
||||||
|
assert.equal(inst.config.asset.curveUnits.power, 'kW');
|
||||||
|
assert.equal(inst.config.asset.curveUnits.control, '%');
|
||||||
|
assert.ok(inst.node._warns.length >= 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_setupSpecificClass propagates logging settings into state config', () => {
|
||||||
|
const inst = Object.create(NodeClass.prototype);
|
||||||
|
inst.node = makeNodeStub();
|
||||||
|
inst.name = 'rotatingMachine';
|
||||||
|
const uiConfig = makeUiConfig({
|
||||||
|
enableLog: true,
|
||||||
|
logLevel: 'warn',
|
||||||
|
uuid: 'uuid-test',
|
||||||
|
assetTagNumber: 'TAG-9',
|
||||||
|
});
|
||||||
|
|
||||||
|
inst._loadConfig(uiConfig, inst.node);
|
||||||
|
inst._setupSpecificClass(uiConfig);
|
||||||
|
|
||||||
|
assert.equal(inst.source.state.config.general.logging.enabled, true);
|
||||||
|
assert.equal(inst.source.state.config.general.logging.logLevel, 'warn');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,9 +43,15 @@ test('input handler routes topics to source methods', () => {
|
|||||||
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();
|
||||||
@@ -53,13 +59,53 @@ 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], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
|
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', () => {
|
test('status shows warning when pressure inputs are not initialized', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ 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', () => {
|
test('predictions use initialized medium pressure and not the minimum-pressure fallback', () => {
|
||||||
@@ -33,7 +48,7 @@ test('predictions use initialized medium pressure and not the minimum-pressure f
|
|||||||
assert.equal(pressureStatus.initialized, true);
|
assert.equal(pressureStatus.initialized, true);
|
||||||
assert.equal(pressureStatus.hasDifferential, true);
|
assert.equal(pressureStatus.hasDifferential, true);
|
||||||
|
|
||||||
const expectedDiff = mediumDownstreamMbar - mediumUpstreamMbar;
|
const expectedDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa canonical
|
||||||
assert.equal(Math.round(machine.predictFlow.fDimension), expectedDiff);
|
assert.equal(Math.round(machine.predictFlow.fDimension), expectedDiff);
|
||||||
assert.ok(machine.predictFlow.fDimension > 0);
|
assert.ok(machine.predictFlow.fDimension > 0);
|
||||||
});
|
});
|
||||||
|
|||||||
75
test/integration/prediction-health.integration.test.js
Normal file
75
test/integration/prediction-health.integration.test.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||||
|
|
||||||
|
test('flow drift is assessed with NRMSE and exposed in output', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
machine.updateMeasuredPressure(700, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
|
||||||
|
machine.updateMeasuredPressure(1100, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||||
|
machine.updatePosition();
|
||||||
|
|
||||||
|
const predictedFlow = machine.measurements
|
||||||
|
.type('flow')
|
||||||
|
.variant('predicted')
|
||||||
|
.position('downstream')
|
||||||
|
.getCurrentValue('m3/h');
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
machine.updateMeasuredFlow(predictedFlow * 0.92, 'downstream', {
|
||||||
|
timestamp: Date.now() + i,
|
||||||
|
unit: 'm3/h',
|
||||||
|
childName: 'ft-down',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
assert.ok(Number.isFinite(output.flowNrmse));
|
||||||
|
assert.equal(typeof output.flowImmediateLevel, 'number');
|
||||||
|
assert.equal(typeof output.flowLongTermLevel, 'number');
|
||||||
|
assert.ok(['high', 'medium', 'low', 'invalid'].includes(output.predictionQuality));
|
||||||
|
assert.ok(Number.isFinite(output.predictionConfidence));
|
||||||
|
assert.equal(output.predictionPressureSource, 'differential');
|
||||||
|
assert.ok(Array.isArray(output.predictionFlags));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('power drift is assessed when measured power is provided', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
|
||||||
|
machine.updateMeasuredPressure(700, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
|
||||||
|
machine.updateMeasuredPressure(1100, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||||
|
machine.updatePosition();
|
||||||
|
|
||||||
|
const predictedPower = machine.measurements
|
||||||
|
.type('power')
|
||||||
|
.variant('predicted')
|
||||||
|
.position('atEquipment')
|
||||||
|
.getCurrentValue('kW');
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
machine.updateMeasuredPower(predictedPower * 1.08, 'atEquipment', {
|
||||||
|
timestamp: Date.now() + i,
|
||||||
|
unit: 'kW',
|
||||||
|
childName: 'power-meter',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
assert.ok(Number.isFinite(output.powerNrmse));
|
||||||
|
assert.equal(typeof output.powerImmediateLevel, 'number');
|
||||||
|
assert.equal(typeof output.powerLongTermLevel, 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single-side pressure lowers prediction confidence category', () => {
|
||||||
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
|
machine.updateMeasuredPressure(950, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||||
|
|
||||||
|
const output = machine.getOutput();
|
||||||
|
assert.equal(output.predictionPressureSource, 'downstream');
|
||||||
|
assert.ok(output.predictionConfidence < 0.9);
|
||||||
|
assert.equal(output.pressureDriftLevel, 1);
|
||||||
|
assert.ok(Array.isArray(output.predictionFlags));
|
||||||
|
assert.ok(output.predictionFlags.includes('single_side_pressure'));
|
||||||
|
});
|
||||||
@@ -27,8 +27,8 @@ test('pressure initialization combinations are handled explicitly', () => {
|
|||||||
assert.equal(status.hasDifferential, false);
|
assert.equal(status.hasDifferential, false);
|
||||||
assert.equal(status.source, 'upstream');
|
assert.equal(status.source, 'upstream');
|
||||||
const upstreamValue = machine.getMeasuredPressure();
|
const upstreamValue = machine.getMeasuredPressure();
|
||||||
assert.equal(Math.round(upstreamValue), upstreamOnly);
|
assert.equal(Math.round(upstreamValue), upstreamOnly * 100);
|
||||||
assert.equal(Math.round(machine.predictFlow.fDimension), upstreamOnly);
|
assert.equal(Math.round(machine.predictFlow.fDimension), upstreamOnly * 100);
|
||||||
|
|
||||||
// downstream only
|
// downstream only
|
||||||
machine = createMachine();
|
machine = createMachine();
|
||||||
@@ -41,8 +41,8 @@ test('pressure initialization combinations are handled explicitly', () => {
|
|||||||
assert.equal(status.hasDifferential, false);
|
assert.equal(status.hasDifferential, false);
|
||||||
assert.equal(status.source, 'downstream');
|
assert.equal(status.source, 'downstream');
|
||||||
const downstreamValue = machine.getMeasuredPressure();
|
const downstreamValue = machine.getMeasuredPressure();
|
||||||
assert.equal(Math.round(downstreamValue), downstreamOnly);
|
assert.equal(Math.round(downstreamValue), downstreamOnly * 100);
|
||||||
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly);
|
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100);
|
||||||
|
|
||||||
// downstream and upstream
|
// downstream and upstream
|
||||||
machine = createMachine();
|
machine = createMachine();
|
||||||
@@ -57,8 +57,8 @@ test('pressure initialization combinations are handled explicitly', () => {
|
|||||||
assert.equal(status.hasDifferential, true);
|
assert.equal(status.hasDifferential, true);
|
||||||
assert.equal(status.source, 'differential');
|
assert.equal(status.source, 'differential');
|
||||||
const differentialValue = machine.getMeasuredPressure();
|
const differentialValue = machine.getMeasuredPressure();
|
||||||
assert.equal(Math.round(differentialValue), downstream - upstream);
|
assert.equal(Math.round(differentialValue), (downstream - upstream) * 100);
|
||||||
assert.equal(Math.round(machine.predictFlow.fDimension), downstream - upstream);
|
assert.equal(Math.round(machine.predictFlow.fDimension), (downstream - upstream) * 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('real pressure child data has priority over simulated dashboard pressure', async () => {
|
test('real pressure child data has priority over simulated dashboard pressure', async () => {
|
||||||
@@ -66,7 +66,7 @@ test('real pressure child data has priority over simulated dashboard pressure',
|
|||||||
|
|
||||||
machine.updateSimulatedMeasurement('pressure', 'upstream', 900, { unit: 'mbar', timestamp: Date.now() });
|
machine.updateSimulatedMeasurement('pressure', 'upstream', 900, { unit: 'mbar', timestamp: Date.now() });
|
||||||
machine.updateSimulatedMeasurement('pressure', 'downstream', 1200, { unit: 'mbar', timestamp: Date.now() });
|
machine.updateSimulatedMeasurement('pressure', 'downstream', 1200, { unit: 'mbar', timestamp: Date.now() });
|
||||||
assert.equal(Math.round(machine.getMeasuredPressure()), 300);
|
assert.equal(Math.round(machine.getMeasuredPressure()), 30000);
|
||||||
|
|
||||||
const upstreamChild = makeChildMeasurement({ id: 'pt-up-real', name: 'PT Up', positionVsParent: 'upstream', type: 'pressure', unit: 'mbar' });
|
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' });
|
const downstreamChild = makeChildMeasurement({ id: 'pt-down-real', name: 'PT Down', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
|
||||||
@@ -77,7 +77,7 @@ test('real pressure child data has priority over simulated dashboard pressure',
|
|||||||
upstreamChild.measurements.type('pressure').variant('measured').position('upstream').value(700, Date.now(), 'mbar');
|
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');
|
downstreamChild.measurements.type('pressure').variant('measured').position('downstream').value(1300, Date.now(), 'mbar');
|
||||||
|
|
||||||
assert.equal(Math.round(machine.getMeasuredPressure()), 600);
|
assert.equal(Math.round(machine.getMeasuredPressure()), 60000);
|
||||||
const status = machine.getPressureInitializationStatus();
|
const status = machine.getPressureInitializationStatus();
|
||||||
assert.equal(status.source, 'differential');
|
assert.equal(status.source, 'differential');
|
||||||
assert.equal(status.initialized, true);
|
assert.equal(status.initialized, true);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user