diff --git a/examples/01 - Basic Manual Control.json b/examples/01 - Basic Manual Control.json
new file mode 100644
index 0000000..40f7ecc
--- /dev/null
+++ b/examples/01 - Basic Manual Control.json
@@ -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": []
+ }
+]
diff --git a/examples/02 - Integration with Machine Group.json b/examples/02 - Integration with Machine Group.json
new file mode 100644
index 0000000..9b0f3ca
--- /dev/null
+++ b/examples/02 - Integration with Machine Group.json
@@ -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": []
+ }
+]
diff --git a/examples/03 - Dashboard Visualization.json b/examples/03 - Dashboard Visualization.json
new file mode 100644
index 0000000..19f1958
--- /dev/null
+++ b/examples/03 - Dashboard Visualization.json
@@ -0,0 +1,1026 @@
+[
+ {
+ "id": "f1e8a6c8b2a4477f",
+ "type": "tab",
+ "label": "RotatingMachine - Dashboard Visualization",
+ "disabled": false,
+ "info": "Interactive dashboard with real-time charts for rotatingMachine. Requires @flowfuse/node-red-dashboard."
+ },
+ {
+ "id": "rm_dash_comment_title",
+ "type": "comment",
+ "z": "f1e8a6c8b2a4477f",
+ "name": "RotatingMachine - Dashboard Visualization\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nInteractive dashboard with mode selector, startup/shutdown\nbuttons, speed setpoint, pressure simulation, and real-time\ncharts for flow, power, control, efficiency, and pressure.\n\nPrerequisites: EVOLV + @flowfuse/node-red-dashboard",
+ "info": "",
+ "x": 400,
+ "y": 40,
+ "wires": []
+ },
+ {
+ "id": "ui_base_rm_basic",
+ "type": "ui-base",
+ "name": "EVOLV Demo",
+ "path": "/dashboard",
+ "appIcon": "",
+ "includeClientData": true,
+ "acceptsClientConfig": [
+ "ui-notification",
+ "ui-control"
+ ],
+ "showPathInSidebar": false,
+ "headerContent": "page",
+ "navigationStyle": "default",
+ "titleBarStyle": "default"
+ },
+ {
+ "id": "ui_theme_rm_basic",
+ "type": "ui-theme",
+ "name": "EVOLV Basic Theme",
+ "colors": {
+ "surface": "#ffffff",
+ "primary": "#0094ce",
+ "bgPage": "#eeeeee",
+ "groupBg": "#ffffff",
+ "groupOutline": "#cccccc"
+ },
+ "sizes": {
+ "density": "default",
+ "pagePadding": "14px",
+ "groupGap": "14px",
+ "groupBorderRadius": "6px",
+ "widgetGap": "12px"
+ }
+ },
+ {
+ "id": "ui_page_rm_basic",
+ "type": "ui-page",
+ "name": "RotatingMachine Basic",
+ "ui": "ui_base_rm_basic",
+ "path": "/rotating-machine-basic",
+ "icon": "settings_input_component",
+ "layout": "grid",
+ "theme": "ui_theme_rm_basic",
+ "breakpoints": [
+ {
+ "name": "Default",
+ "px": "0",
+ "cols": "12"
+ }
+ ],
+ "order": 1,
+ "className": ""
+ },
+ {
+ "id": "ui_group_rm_ctrl",
+ "type": "ui-group",
+ "name": "Machine Controls",
+ "page": "ui_page_rm_basic",
+ "width": "6",
+ "height": "1",
+ "order": 1,
+ "showTitle": true,
+ "className": ""
+ },
+ {
+ "id": "ui_group_rm_sim",
+ "type": "ui-group",
+ "name": "Simulation Inputs",
+ "page": "ui_page_rm_basic",
+ "width": "6",
+ "height": "1",
+ "order": 2,
+ "showTitle": true,
+ "className": ""
+ },
+ {
+ "id": "ui_group_rm_obs",
+ "type": "ui-group",
+ "name": "Observed Output",
+ "page": "ui_page_rm_basic",
+ "width": "12",
+ "height": "24",
+ "order": 3,
+ "showTitle": true,
+ "className": ""
+ },
+ {
+ "id": "rm_node_basic",
+ "type": "rotatingMachine",
+ "z": "f1e8a6c8b2a4477f",
+ "name": "RM Basic",
+ "speed": "1",
+ "startup": "3",
+ "warmup": "3",
+ "shutdown": "3",
+ "cooldown": "3",
+ "movementMode": "staticspeed",
+ "machineCurve": "",
+ "uuid": "",
+ "supplier": "hidrostal",
+ "category": "machine",
+ "assetType": "pump-centrifugal",
+ "model": "hidrostal-H05K-S03R",
+ "unit": "m3/h",
+ "enableLog": false,
+ "logLevel": "error",
+ "positionVsParent": "atEquipment",
+ "positionIcon": "",
+ "hasDistance": false,
+ "distance": "",
+ "distanceUnit": "m",
+ "distanceDescription": "",
+ "x": 1060,
+ "y": 420,
+ "wires": [
+ [
+ "rm_parse_output"
+ ],
+ [
+ "rm_debug_influx"
+ ],
+ [
+ "rm_debug_parent"
+ ]
+ ]
+ },
+ {
+ "id": "rm_mode_dropdown",
+ "type": "ui-dropdown",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_ctrl",
+ "name": "Mode",
+ "label": "Mode",
+ "order": 1,
+ "width": "6",
+ "height": "1",
+ "passthru": true,
+ "multiple": false,
+ "options": [
+ {
+ "label": "auto",
+ "value": "auto",
+ "type": "str"
+ },
+ {
+ "label": "virtualControl",
+ "value": "virtualControl",
+ "type": "str"
+ },
+ {
+ "label": "fysicalControl",
+ "value": "fysicalControl",
+ "type": "str"
+ }
+ ],
+ "payload": "",
+ "topic": "setMode",
+ "x": 210,
+ "y": 160,
+ "wires": [
+ [
+ "rm_set_mode"
+ ]
+ ]
+ },
+ {
+ "id": "rm_set_mode",
+ "type": "change",
+ "z": "f1e8a6c8b2a4477f",
+ "name": "topic=setMode",
+ "rules": [
+ {
+ "t": "set",
+ "p": "topic",
+ "pt": "msg",
+ "to": "setMode",
+ "tot": "str"
+ }
+ ],
+ "x": 440,
+ "y": 160,
+ "wires": [
+ [
+ "rm_node_basic"
+ ]
+ ]
+ },
+ {
+ "id": "rm_startup_btn",
+ "type": "ui-button",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_ctrl",
+ "name": "Startup",
+ "label": "Startup",
+ "order": 2,
+ "width": "3",
+ "height": "1",
+ "emulateClick": false,
+ "tooltip": "Run startup sequence",
+ "color": "",
+ "bgcolor": "",
+ "icon": "play_arrow",
+ "payload": "startup",
+ "payloadType": "str",
+ "topic": "",
+ "x": 200,
+ "y": 220,
+ "wires": [
+ [
+ "rm_exec_sequence_builder"
+ ]
+ ]
+ },
+ {
+ "id": "rm_shutdown_btn",
+ "type": "ui-button",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_ctrl",
+ "name": "Shutdown",
+ "label": "Shutdown",
+ "order": 3,
+ "width": "3",
+ "height": "1",
+ "emulateClick": false,
+ "tooltip": "Run shutdown sequence",
+ "icon": "stop",
+ "payload": "shutdown",
+ "payloadType": "str",
+ "topic": "",
+ "x": 200,
+ "y": 260,
+ "wires": [
+ [
+ "rm_exec_sequence_builder"
+ ]
+ ]
+ },
+ {
+ "id": "rm_emergency_btn",
+ "type": "ui-button",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_ctrl",
+ "name": "Emergency Stop",
+ "label": "Emergency Stop",
+ "order": 4,
+ "width": "6",
+ "height": "1",
+ "emulateClick": false,
+ "tooltip": "Emergency stop",
+ "color": "#ffffff",
+ "bgcolor": "#cc0000",
+ "icon": "warning",
+ "payload": "{\"source\":\"GUI\",\"action\":\"emergencystop\"}",
+ "payloadType": "json",
+ "topic": "emergencystop",
+ "x": 240,
+ "y": 300,
+ "wires": [
+ [
+ "rm_node_basic"
+ ]
+ ]
+ },
+ {
+ "id": "rm_exec_sequence_builder",
+ "type": "function",
+ "z": "f1e8a6c8b2a4477f",
+ "name": "Build execSequence",
+ "func": "msg.topic = 'execSequence';\nmsg.payload = {\n source: 'GUI',\n action: 'execSequence',\n parameter: msg.payload\n};\nreturn msg;",
+ "outputs": 1,
+ "noerr": 0,
+ "x": 470,
+ "y": 240,
+ "wires": [
+ [
+ "rm_node_basic"
+ ]
+ ]
+ },
+ {
+ "id": "rm_setpoint_input",
+ "type": "ui-number-input",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_ctrl",
+ "name": "Setpoint",
+ "label": "Setpoint %",
+ "order": 5,
+ "width": "6",
+ "height": "1",
+ "passthru": true,
+ "topic": "",
+ "min": 0,
+ "max": 100,
+ "step": 1,
+ "x": 200,
+ "y": 360,
+ "wires": [
+ [
+ "rm_exec_movement_builder",
+ "rm_ctrl_setpoint_for_chart"
+ ]
+ ]
+ },
+ {
+ "id": "rm_exec_movement_builder",
+ "type": "function",
+ "z": "f1e8a6c8b2a4477f",
+ "name": "Build execMovement",
+ "func": "msg.topic = 'execMovement';\nmsg.payload = {\n source: 'GUI',\n action: 'execMovement',\n setpoint: Number(msg.payload)\n};\nreturn msg;",
+ "outputs": 1,
+ "noerr": 0,
+ "x": 480,
+ "y": 360,
+ "wires": [
+ [
+ "rm_node_basic"
+ ]
+ ]
+ },
+ {
+ "id": "rm_pressure_down_input",
+ "type": "ui-number-input",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_sim",
+ "name": "Downstream Pressure",
+ "label": "Downstream pressure (mbar)",
+ "order": 1,
+ "width": "6",
+ "height": "1",
+ "passthru": true,
+ "topic": "",
+ "min": 0,
+ "max": 3000,
+ "step": 10,
+ "x": 230,
+ "y": 460,
+ "wires": [
+ [
+ "rm_sim_pressure_down_builder"
+ ]
+ ]
+ },
+ {
+ "id": "rm_pressure_up_input",
+ "type": "ui-number-input",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_sim",
+ "name": "Upstream Pressure",
+ "label": "Upstream pressure (mbar)",
+ "order": 2,
+ "width": "6",
+ "height": "1",
+ "passthru": true,
+ "topic": "",
+ "min": 0,
+ "max": 3000,
+ "step": 10,
+ "x": 220,
+ "y": 500,
+ "wires": [
+ [
+ "rm_sim_pressure_up_builder"
+ ]
+ ]
+ },
+ {
+ "id": "rm_sim_pressure_down_builder",
+ "type": "function",
+ "z": "f1e8a6c8b2a4477f",
+ "name": "simulate downstream pressure",
+ "func": "msg.topic = 'simulateMeasurement';\nmsg.payload = {\n type: 'pressure',\n position: 'downstream',\n value: Number(msg.payload),\n unit: 'mbar'\n};\nreturn msg;",
+ "outputs": 1,
+ "noerr": 0,
+ "x": 510,
+ "y": 460,
+ "wires": [
+ [
+ "rm_node_basic"
+ ]
+ ]
+ },
+ {
+ "id": "rm_sim_pressure_up_builder",
+ "type": "function",
+ "z": "f1e8a6c8b2a4477f",
+ "name": "simulate upstream pressure",
+ "func": "msg.topic = 'simulateMeasurement';\nmsg.payload = {\n type: 'pressure',\n position: 'upstream',\n value: Number(msg.payload),\n unit: 'mbar'\n};\nreturn msg;",
+ "outputs": 1,
+ "noerr": 0,
+ "x": 500,
+ "y": 500,
+ "wires": [
+ [
+ "rm_node_basic"
+ ]
+ ]
+ },
+ {
+ "id": "rm_parse_output",
+ "type": "function",
+ "z": "f1e8a6c8b2a4477f",
+ "name": "Parse RM process output",
+ "func": "const incoming = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst lastPayload = context.get('lastPayload') || {};\nconst merged = { ...lastPayload, ...incoming };\ncontext.set('lastPayload', merged);\n\nconst cache = context.get('metricCache') || {\n flow: 0,\n power: 0,\n ctrl: 0,\n nCog: 0,\n stateCode: 0,\n state: 'idle',\n mode: 'auto',\n runtime: 0,\n moveTimeleft: 0,\n maintenanceTime: 0,\n pressureUp: null,\n pressureDown: null,\n};\n\nconst pickNumber = (...keys) => {\n for (const key of keys) {\n const value = Number(merged[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\n\nconst pickByPrefix = (...prefixes) => {\n const keys = Object.keys(merged);\n for (const prefix of prefixes) {\n const direct = Number(merged[prefix]);\n if (Number.isFinite(direct)) return direct;\n\n const dynamicKey = keys.find((k) => k === prefix || k.startsWith(prefix + '.'));\n if (!dynamicKey) continue;\n\n const value = Number(merged[dynamicKey]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\n\nconst pickString = (key, fallback = null) => {\n const value = merged[key];\n if (value === undefined || value === null || value === '') return fallback;\n return String(value);\n};\n\nconst flowValue = pickByPrefix('flow.predicted.downstream');\nconst power = pickByPrefix('power.predicted.atequipment', 'power.predicted.atEquipment');\nconst ctrl = pickNumber('ctrl') ?? pickByPrefix('ctrl.predicted.atequipment', 'ctrl.predicted.atEquipment');\nconst nCog = pickNumber('NCogPercent', 'NCog');\nconst runtime = pickNumber('runtime');\nconst moveTimeleft = pickNumber('moveTimeleft');\nconst maintenanceTime = pickNumber('maintenanceTime');\nconst pressureDownIncoming = pickByPrefix('pressure.measured.downstream');\nconst pressureUpIncoming = pickByPrefix('pressure.measured.upstream');\nconst state = pickString('state', cache.state);\nconst mode = pickString('mode', cache.mode);\n\nconst stateCodeMap = { off: 0, idle: 1, starting: 2, warmingup: 3, operational: 4, accelerating: 5, decelerating: 6, stopping: 7, coolingdown: 8, maintenance: 9 };\nconst stateCode = stateCodeMap[state] ?? cache.stateCode;\n\nif (flowValue !== null) cache.flow = flowValue;\nif (power !== null) cache.power = power;\nif (ctrl !== null) cache.ctrl = ctrl;\nif (nCog !== null) cache.nCog = nCog;\nif (runtime !== null) cache.runtime = runtime;\nif (moveTimeleft !== null) cache.moveTimeleft = moveTimeleft;\nif (maintenanceTime !== null) cache.maintenanceTime = maintenanceTime;\nif (pressureUpIncoming !== null) cache.pressureUp = pressureUpIncoming;\nif (pressureDownIncoming !== null) cache.pressureDown = pressureDownIncoming;\ncache.state = state;\ncache.mode = mode;\ncache.stateCode = stateCode;\ncontext.set('metricCache', cache);\n\nconst pressureUp = Number.isFinite(cache.pressureUp) ? cache.pressureUp : null;\nconst pressureDown = Number.isFinite(cache.pressureDown) ? cache.pressureDown : null;\nconst pressureDelta = (pressureDown !== null && pressureUp !== null) ? (pressureDown - pressureUp) : null;\n\nconst now = Date.now();\nconst compactSnapshot = [\n `Q=${cache.flow.toFixed(1)} m3/h`,\n `P=${cache.power.toFixed(2)} kW`,\n `Ctrl=${cache.ctrl.toFixed(1)}%`,\n `NCog=${cache.nCog.toFixed(1)}%`,\n `Pup=${pressureUp == null ? 'n/a' : pressureUp.toFixed(0)} mbar`,\n `Pdown=${pressureDown == null ? 'n/a' : pressureDown.toFixed(0)} mbar`\n].join(' | ');\n\nreturn [\n { topic: 'actual_flow', payload: cache.flow, timestamp: now },\n { topic: 'predicted_power', payload: cache.power, timestamp: now },\n { topic: 'actual_ctrl', payload: cache.ctrl, timestamp: now },\n { topic: 'nCog', payload: cache.nCog, timestamp: now },\n { topic: 'stateCode', payload: cache.stateCode, timestamp: now },\n pressureUp === null ? null : { topic: 'pressure_upstream', payload: pressureUp, timestamp: now },\n pressureDown === null ? null : { topic: 'pressure_downstream', payload: pressureDown, timestamp: now },\n pressureDelta === null ? null : { topic: 'pressure_delta', payload: pressureDelta, timestamp: now },\n { topic: 'stateMode', payload: `state=${cache.state} | mode=${cache.mode} | ctrl=${cache.ctrl.toFixed(1)}%` },\n { topic: 'timing', payload: `runtime=${cache.runtime.toFixed(2)} h | moveLeft=${cache.moveTimeleft.toFixed(0)} s | maint=${cache.maintenanceTime.toFixed(2)} h` },\n { topic: 'snapshot', payload: compactSnapshot }\n];",
+ "outputs": 11,
+ "noerr": 0,
+ "x": 1310,
+ "y": 420,
+ "wires": [
+ [
+ "rm_chart_flow"
+ ],
+ [
+ "rm_chart_power"
+ ],
+ [
+ "rm_chart_ctrl"
+ ],
+ [
+ "rm_chart_ncog"
+ ],
+ [
+ "rm_chart_statecode"
+ ],
+ [
+ "rm_chart_pressure_up"
+ ],
+ [
+ "rm_chart_pressure_down"
+ ],
+ [
+ "rm_chart_pressure_delta"
+ ],
+ [
+ "rm_text_state"
+ ],
+ [
+ "rm_text_timing"
+ ],
+ [
+ "rm_text_snapshot"
+ ]
+ ]
+ },
+ {
+ "id": "rm_chart_flow",
+ "type": "ui-chart",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_obs",
+ "name": "Predicted Flow",
+ "label": "Flow (m3/h)",
+ "order": 1,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "category": "topic",
+ "xAxisLabel": "time",
+ "xAxisType": "time",
+ "yAxisLabel": "m3/h",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "removeOlderPoints": "",
+ "x": 1560,
+ "y": 340,
+ "wires": [],
+ "showLegend": false,
+ "categoryType": "msg",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "",
+ "ymax": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "className": "",
+ "colors": [
+ "#0095FF",
+ "#FF0000",
+ "#FF7F0E",
+ "#2CA02C",
+ "#A347E1",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true
+ },
+ {
+ "id": "rm_chart_power",
+ "type": "ui-chart",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_obs",
+ "name": "Predicted Power",
+ "label": "Power (kW)",
+ "order": 2,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "xAxisType": "time",
+ "yAxisLabel": "kW",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "x": 1560,
+ "y": 400,
+ "wires": [],
+ "showLegend": false,
+ "category": "topic",
+ "categoryType": "msg",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "",
+ "ymax": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "className": "",
+ "colors": [
+ "#0095FF",
+ "#FF0000",
+ "#FF7F0E",
+ "#2CA02C",
+ "#A347E1",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true
+ },
+ {
+ "id": "rm_chart_ctrl",
+ "type": "ui-chart",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_obs",
+ "name": "Control Position",
+ "label": "Ctrl (%)",
+ "order": 3,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "xAxisType": "time",
+ "yAxisLabel": "%",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "x": 1560,
+ "y": 460,
+ "wires": [],
+ "showLegend": false,
+ "category": "topic",
+ "categoryType": "msg",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "",
+ "ymax": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "className": "",
+ "colors": [
+ "#0095FF",
+ "#FF0000",
+ "#FF7F0E",
+ "#2CA02C",
+ "#A347E1",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true
+ },
+ {
+ "id": "rm_text_state",
+ "type": "ui-text",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_obs",
+ "name": "State/Mode",
+ "label": "Current State",
+ "order": 9,
+ "width": 12,
+ "height": 1,
+ "format": "{{msg.payload}}",
+ "layout": "row-spread",
+ "x": 1560,
+ "y": 520,
+ "wires": []
+ },
+ {
+ "id": "rm_debug_influx",
+ "type": "debug",
+ "z": "f1e8a6c8b2a4477f",
+ "name": "Influx output",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "x": 1290,
+ "y": 560,
+ "wires": []
+ },
+ {
+ "id": "rm_debug_parent",
+ "type": "debug",
+ "z": "f1e8a6c8b2a4477f",
+ "name": "Parent output",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "x": 1280,
+ "y": 600,
+ "wires": []
+ },
+ {
+ "id": "rm_chart_ncog",
+ "type": "ui-chart",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_obs",
+ "name": "NCog",
+ "label": "NCog (%)",
+ "order": 4,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "xAxisType": "time",
+ "yAxisLabel": "%",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "x": 1560,
+ "y": 520,
+ "wires": [],
+ "showLegend": false,
+ "category": "topic",
+ "categoryType": "msg",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "",
+ "ymax": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "className": "",
+ "colors": [
+ "#0095FF",
+ "#FF0000",
+ "#FF7F0E",
+ "#2CA02C",
+ "#A347E1",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true
+ },
+ {
+ "id": "rm_chart_statecode",
+ "type": "ui-chart",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_obs",
+ "name": "Machine State Code",
+ "label": "State Code (off=0 .. maint=9)",
+ "order": 5,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "xAxisType": "time",
+ "yAxisLabel": "state",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "x": 1560,
+ "y": 580,
+ "wires": [],
+ "showLegend": false,
+ "category": "topic",
+ "categoryType": "msg",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "",
+ "ymax": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "className": "",
+ "colors": [
+ "#0095FF",
+ "#FF0000",
+ "#FF7F0E",
+ "#2CA02C",
+ "#A347E1",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true
+ },
+ {
+ "id": "rm_text_timing",
+ "type": "ui-text",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_obs",
+ "name": "Timing",
+ "label": "Timing",
+ "order": 10,
+ "width": 12,
+ "height": 1,
+ "format": "{{msg.payload}}",
+ "layout": "row-spread",
+ "x": 1560,
+ "y": 700,
+ "wires": []
+ },
+ {
+ "id": "rm_text_snapshot",
+ "type": "ui-text",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_obs",
+ "name": "Snapshot",
+ "label": "Snapshot",
+ "order": 11,
+ "width": 12,
+ "height": 1,
+ "format": "{{msg.payload}}",
+ "layout": "row-spread",
+ "x": 1560,
+ "y": 740,
+ "wires": []
+ },
+ {
+ "id": "rm_ctrl_setpoint_for_chart",
+ "type": "function",
+ "z": "f1e8a6c8b2a4477f",
+ "name": "ctrl setpoint series",
+ "func": "msg.topic = 'setpoint_ctrl';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
+ "outputs": 1,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 610,
+ "y": 280,
+ "wires": [
+ [
+ "rm_chart_ctrl"
+ ]
+ ]
+ },
+ {
+ "id": "rm_chart_pressure_up",
+ "type": "ui-chart",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_obs",
+ "name": "Pressure Upstream",
+ "label": "Upstream Pressure (mbar)",
+ "order": 6,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "xAxisType": "time",
+ "yAxisLabel": "mbar",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "x": 1560,
+ "y": 640,
+ "wires": [],
+ "showLegend": false,
+ "category": "topic",
+ "categoryType": "msg",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "",
+ "ymax": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "className": "",
+ "colors": [
+ "#2CA02C",
+ "#FF0000",
+ "#FF7F0E",
+ "#2CA02C",
+ "#A347E1",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true
+ },
+ {
+ "id": "rm_chart_pressure_down",
+ "type": "ui-chart",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_obs",
+ "name": "Pressure Downstream",
+ "label": "Downstream Pressure (mbar)",
+ "order": 7,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "xAxisType": "time",
+ "yAxisLabel": "mbar",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "x": 1560,
+ "y": 700,
+ "wires": [],
+ "showLegend": false,
+ "category": "topic",
+ "categoryType": "msg",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "",
+ "ymax": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "className": "",
+ "colors": [
+ "#FF7F0E",
+ "#FF0000",
+ "#FF7F0E",
+ "#2CA02C",
+ "#A347E1",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true
+ },
+ {
+ "id": "rm_chart_pressure_delta",
+ "type": "ui-chart",
+ "z": "f1e8a6c8b2a4477f",
+ "group": "ui_group_rm_obs",
+ "name": "Pressure Differential",
+ "label": "Pressure Delta (mbar)",
+ "order": 8,
+ "width": 6,
+ "height": 4,
+ "chartType": "line",
+ "xAxisType": "time",
+ "yAxisLabel": "mbar",
+ "removeOlder": "15",
+ "removeOlderUnit": "60",
+ "x": 1560,
+ "y": 760,
+ "wires": [],
+ "showLegend": false,
+ "category": "topic",
+ "categoryType": "msg",
+ "xAxisProperty": "",
+ "xAxisPropertyType": "timestamp",
+ "xAxisFormat": "",
+ "xAxisFormatType": "auto",
+ "yAxisProperty": "payload",
+ "yAxisPropertyType": "msg",
+ "xmin": "",
+ "xmax": "",
+ "ymin": "",
+ "ymax": "",
+ "bins": 10,
+ "action": "append",
+ "stackSeries": false,
+ "pointShape": "circle",
+ "pointRadius": 4,
+ "interpolation": "linear",
+ "className": "",
+ "colors": [
+ "#0095FF",
+ "#FF0000",
+ "#FF7F0E",
+ "#2CA02C",
+ "#A347E1",
+ "#D62728",
+ "#FF9896",
+ "#9467BD",
+ "#C5B0D5"
+ ],
+ "textColor": [
+ "#666666"
+ ],
+ "textColorDefault": true,
+ "gridColor": [
+ "#e5e5e5"
+ ],
+ "gridColorDefault": true
+ }
+]
diff --git a/examples/README.md b/examples/README.md
index 3e96f81..1bdd896 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,49 +1,53 @@
# 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`
-Purpose: quick manual control + local visualization.
-Includes:
-- mode selection
-- startup/shutdown/emergency buttons
-- setpoint control
-- simulated upstream/downstream pressure input
-- local charts for predicted flow/power/ctrl
+### 01 - Basic Manual Control
+**Dependencies:** EVOLV only (no dashboard)
-- `integration.flow.json`
-Purpose: richer scenario testing from dashboard controls.
-Includes:
-- sequence controls (startup/shutdown/maintenance)
-- direct setpoint + flowMovement commands
-- simulated pressure inputs
-- charts for flow, power, NCog%
-- state snapshot text
+Inject-based flow demonstrating all core functionality:
+- Mode switching (auto / virtualControl / fysicalControl)
+- Startup/shutdown/emergency sequences
+- Speed setpoint control (30%, 60%, 100%)
+- Pressure simulation (upstream + downstream)
+- Maintenance mode enter/leave
+- Debug outputs on all 3 ports
-- `edge.flow.json`
-Purpose: intentionally send invalid/boundary inputs and observe behavior.
-Includes:
-- invalid mode command
-- negative setpoint command
-- disallowed source sequence command
-- unsupported simulated measurement type
-- debug outputs for process/influx/parent channels
+### 02 - Integration with Machine Group
+**Dependencies:** EVOLV only (no dashboard)
-## 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.
-- FlowFuse Dashboard 2 nodes installed (`ui-base`, `ui-page`, `ui-group`, etc.).
+### 03 - Dashboard Visualization
+**Dependencies:** EVOLV + @flowfuse/node-red-dashboard
+
+Interactive FlowFuse dashboard with:
+- Mode dropdown, startup/shutdown/emergency buttons
+- Speed setpoint input, pressure simulation inputs
+- Real-time charts: flow, power, ctrl%, NCog, state code, pressure
+
+## Legacy Files
+
+The following files are from the original flow set and will be removed in a future release:
+- `basic.flow.json` → replaced by `01 - Basic Manual Control.json`
+- `integration.flow.json` → replaced by `02 - Integration with Machine Group.json`
+- `edge.flow.json` → edge-case testing (inject-based)
## Import
-1. In Node-RED, use `Import` and select one of the `*.flow.json` files.
-2. Deploy.
-3. Open the dashboard page path configured in the flow.
+1. In Node-RED, use **Import > Examples > EVOLV** (auto-discovered)
+2. Or manually: **Import > Clipboard** and paste the `.json` file contents
+3. Deploy
## Notes
-- These examples are manual by design: no auto-start on deploy.
-- Pressure simulation uses `msg.topic = "simulateMeasurement"` handled by the rotatingMachine wrapper.
-- Output graphs are based on the rotatingMachine process output payload fields.
+- Tier 1 and 2 examples have zero dashboard dependencies — they work on any Node-RED install with EVOLV
+- Tier 3 requires `@flowfuse/node-red-dashboard` (included in EVOLV's package.json dependencies)
+- All examples use `enableLog: true` so you can observe behavior in the Node-RED debug panel
diff --git a/rotatingMachine.html b/rotatingMachine.html
index 28e210c..97620fa 100644
--- a/rotatingMachine.html
+++ b/rotatingMachine.html
@@ -29,11 +29,16 @@
//define asset properties
uuid: { value: "" },
+ assetTagNumber: { value: "" },
supplier: { value: "" },
category: { value: "" },
assetType: { value: "" },
model: { value: "" },
unit: { value: "" },
+ curvePressureUnit: { value: "mbar" },
+ curveFlowUnit: { value: "" },
+ curvePowerUnit: { value: "kW" },
+ curveControlUnit: { value: "%" },
//logger properties
enableLog: { value: false },
@@ -55,7 +60,7 @@
icon: "font-awesome/fa-cog",
label: function () {
- return this.positionIcon + " " + this.category || "Machine";
+ return (this.positionIcon || "") + " " + (this.category || "Machine");
},
oneditprepare: function() {
@@ -162,4 +167,4 @@
Enable Log / Log Level: toggle via Logger menu.
Position: set Upstream / At Equipment / Downstream via Position menu.
-
\ No newline at end of file
+
diff --git a/src/nodeClass.js b/src/nodeClass.js
index 998dddb..324d51a 100644
--- a/src/nodeClass.js
+++ b/src/nodeClass.js
@@ -4,7 +4,7 @@
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
* 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");
class nodeClass {
@@ -42,25 +42,37 @@ class nodeClass {
* @param {object} uiConfig - Raw config from Node-RED UI.
*/
_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
this.config = {
general: {
+ name: this.name,
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: {
enabled: uiConfig.enableLog,
logLevel: uiConfig.logLevel
}
},
asset: {
- uuid: uiConfig.assetUuid, //need to add this later to the asset model
- tagCode: uiConfig.assetTagCode, //need to add this later to the asset model
+ uuid: resolvedAssetUuid, // support both legacy and current editor field names
+ tagCode: resolvedAssetTagCode, // support both legacy and current editor field names
+ tagNumber: uiConfig.assetTagNumber || null,
supplier: uiConfig.supplier,
category: uiConfig.category, //add later to define as the software type
type: uiConfig.assetType,
model: uiConfig.model,
- unit: uiConfig.unit
+ unit: flowUnit,
+ curveUnits
},
functionality: {
positionVsParent: uiConfig.positionVsParent
@@ -71,6 +83,29 @@ class nodeClass {
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.
*/
@@ -81,8 +116,8 @@ class nodeClass {
const stateConfig = {
general: {
logging: {
- enabled: machineConfig.eneableLog,
- logLevel: machineConfig.logLevel
+ enabled: machineConfig.general.logging.enabled,
+ logLevel: machineConfig.general.logging.logLevel
}
},
movement: {
@@ -132,7 +167,8 @@ class nodeClass {
if (pressureStatus.initialized) {
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'));
let symbolState;
switch(state){
@@ -179,16 +215,16 @@ class nodeClass {
status = { fill: "blue", shape: "dot", text: `${mode}: ${symbolState}` };
break;
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;
case "starting":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
break;
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;
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;
case "stopping":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
@@ -197,7 +233,7 @@ class nodeClass {
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
break;
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;
default:
status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` };
@@ -299,7 +335,8 @@ class nodeClass {
const type = String(payload.type || '').toLowerCase();
const position = payload.position || 'atEquipment';
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 = {
timestamp: payload.timestamp || Date.now(),
unit,
@@ -312,6 +349,21 @@ class nodeClass {
break;
}
+ if (!supportedTypes.has(type)) {
+ this.node.warn(`Unsupported simulateMeasurement type: ${type}`);
+ break;
+ }
+
+ if (!unit) {
+ this.node.warn('simulateMeasurement payload.unit is required');
+ break;
+ }
+
+ if (typeof m.isUnitValidForType === 'function' && !m.isUnitValidForType(type, unit)) {
+ this.node.warn(`simulateMeasurement payload.unit '${unit}' is invalid for type '${type}'`);
+ break;
+ }
+
switch (type) {
case 'pressure':
if (typeof m.updateSimulatedMeasurement === "function") {
@@ -326,8 +378,9 @@ class nodeClass {
case 'temperature':
m.updateMeasuredTemperature(value, position, context);
break;
- default:
- this.node.warn(`Unsupported simulateMeasurement type: ${type}`);
+ case 'power':
+ m.updateMeasuredPower(value, position, context);
+ break;
}
}
break;
diff --git a/src/specificClass.js b/src/specificClass.js
index 711d8e8..6356756 100644
--- a/src/specificClass.js
+++ b/src/specificClass.js
@@ -1,5 +1,27 @@
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.
@@ -21,15 +43,25 @@ class Machine {
// Load a specific curve
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
this.config = this.configUtils.initConfig(machineConfig);
//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.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.`);
// Set prediction objects to null to prevent method calls
this.predictFlow = null;
@@ -38,12 +70,21 @@ class Machine {
this.hasCurve = false;
}
else{
- this.hasCurve = true;
- this.config = this.configUtils.updateConfig(this.config, { asset: { ...this.config.asset, machineCurve: this.curve } });
- //machineConfig = { ...machineConfig, asset: { ...machineConfig.asset, machineCurve: this.curve } }; // Merge curve into machineConfig
- 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)
+ try {
+ this.hasCurve = true;
+ this.curve = this._normalizeMachineCurve(this.rawCurve);
+ this.config = this.configUtils.updateConfig(this.config, { asset: { ...this.config.asset, machineCurve: this.curve } });
+ //machineConfig = { ...machineConfig, asset: { ...machineConfig.asset, machineCurve: this.curve } }; // Merge curve into machineConfig
+ 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
@@ -54,16 +95,55 @@ class Machine {
autoConvert: true,
windowSize: 50,
defaultUnits: {
- pressure: 'mbar',
- flow: this.config.general.unit,
- power: 'kW',
- temperature: 'C'
- }
- });
+ pressure: this.unitPolicy.output.pressure,
+ flow: this.unitPolicy.output.flow,
+ power: this.unitPolicy.output.power,
+ 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.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.currentEfficiencyCurve = {};
@@ -101,6 +181,7 @@ class Machine {
upstream: new Set(),
downstream: new Set(),
};
+ this.childMeasurementListeners = new Map();
this._initVirtualPressureChildren();
@@ -113,12 +194,23 @@ class Machine {
const measurements = new MeasurementContainer({
autoConvert: true,
defaultUnits: {
- pressure: "mbar",
- flow: this.config.general.unit,
- power: "kW",
- temperature: "C",
+ pressure: this.unitPolicy.output.pressure,
+ flow: this.unitPolicy.output.flow,
+ power: this.unitPolicy.output.power,
+ 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.setChildName(name);
@@ -133,7 +225,7 @@ class Machine {
},
asset: {
type: "pressure",
- unit: "mbar",
+ unit: this.unitPolicy.output.pressure,
},
},
measurements,
@@ -151,14 +243,14 @@ class Machine {
_init(){
//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
- 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
- const flowunit = this.config.general.unit;
+ const flowunit = this.unitPolicy.canonical.flow;
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('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 {
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);
@@ -169,9 +261,11 @@ class Machine {
const isOperational = this._isOperationalState();
if(!isOperational){
//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("atEquipment").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.unitPolicy.canonical.flow);
+ this.measurements.type("power").variant("predicted").position("atEquipment").value(0,Date.now(),this.unitPolicy.canonical.power);
}
+ this._updatePredictionHealth();
}
/*------------------- 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.
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}`);
// 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(` Emitting... ${eventName} with data:`);
- // Store directly in parent's measurement container
- this.measurements
- .type(measurementType)
- .variant("measured")
- .position(position)
- .child(childId)
- .value(eventData.value, eventData.timestamp, eventData.unit);
-
- // Call the appropriate handler
+ // Route through centralized handlers so unit validation/conversion is applied once.
this._callMeasurementHandler(measurementType, eventData.value, position, eventData);
+ };
+ child.measurements.emitter.on(eventName, listener);
+ this.childMeasurementListeners.set(listenerKey, {
+ emitter: child.measurements.emitter,
+ eventName,
+ handler: listener,
});
}
}
@@ -223,6 +324,10 @@ _callMeasurementHandler(measurementType, value, position, context) {
case 'flow':
this.updateMeasuredFlow(value, position, context);
break;
+
+ case 'power':
+ this.updateMeasuredPower(value, position, context);
+ break;
case 'temperature':
this.updateMeasuredTemperature(value, position, context);
@@ -238,21 +343,352 @@ _callMeasurementHandler(measurementType, value, position, context) {
//---------------- END child stuff -------------//
- // Method to assess drift using errorMetrics
- assessDrift(measurement, processMin, processMax) {
- this.logger.debug(`Assessing drift for measurement: ${measurement} processMin: ${processMin} processMax: ${processMax}`);
- const predictedMeasurement = this.measurements.type(measurement).variant("predicted").position("downstream").getAllValues().values;
- const measuredMeasurement = this.measurements.type(measurement).variant("measured").position("downstream").getAllValues().values;
+ _buildUnitPolicy(config) {
+ const flowOutputUnit = this._resolveUnitOrFallback(
+ config?.general?.unit,
+ 'volumeFlowRate',
+ 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 this.errorMetrics.assessDrift(
- predictedMeasurement,
- measuredMeasurement,
- processMin,
- processMax
- );
+ return {
+ canonical: { ...CANONICAL_UNITS },
+ output: {
+ pressure: pressureOutputUnit,
+ flow: flowOutputUnit,
+ power: powerOutputUnit,
+ temperature: temperatureOutputUnit,
+ atmPressure: 'Pa',
+ },
+ curve: curveUnits,
+ };
+ }
+
+ _resolveCurveUnits(curveUnits = {}, fallbackFlowUnit = DEFAULT_CURVE_UNITS.flow) {
+ const pressure = this._resolveUnitOrFallback(
+ curveUnits.pressure,
+ 'pressure',
+ 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) {
const reversedCurve = {};
@@ -322,8 +758,15 @@ _callMeasurementHandler(measurementType, value, position, context) {
return await this.executeSequence(parameter);
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
- const pos = this.calcCtrl(parameter);
+ const pos = this.calcCtrl(canonicalFlowSetpoint);
// Move to the desired setpoint
return await this.setpoint(pos);
@@ -399,42 +842,72 @@ _callMeasurementHandler(measurementType, value, position, context) {
async setpoint(setpoint) {
try {
- // Validate setpoint
- if (typeof setpoint !== 'number' || setpoint < 0) {
- throw new Error("Invalid setpoint: Setpoint must be a non-negative number.");
+ // Validate and normalize setpoint
+ if (!Number.isFinite(setpoint)) {
+ 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
- await this.state.moveTo(setpoint);
+ await this.state.moveTo(constrainedSetpoint);
} 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
calcFlow(x) {
if(this.hasCurve) {
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("atEquipment").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.unitPolicy.canonical.flow);
this.logger.debug(`Machine is not operational. Setting predicted flow to 0.`);
return 0;
}
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("atEquipment").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.unitPolicy.canonical.flow);
//this.logger.debug(`Calculated flow: ${cFlow} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
return cFlow;
}
// 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.measurements.type("flow").variant("predicted").position("downstream").value(0, Date.now(),this.config.general.unit);
- this.measurements.type("flow").variant("predicted").position("atEquipment").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.unitPolicy.canonical.flow);
return 0;
}
@@ -443,20 +916,20 @@ _callMeasurementHandler(measurementType, value, position, context) {
calcPower(x) {
if(this.hasCurve) {
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.`);
return 0;
}
//this.predictPower.currentX = x; Decrepated
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}`);
return cPower;
}
// 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.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;
}
@@ -474,7 +947,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
// 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.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;
}
@@ -491,7 +964,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
// 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.measurements.type("ctrl").variant("predicted").position('atEquipment').value(0);
+ this.measurements.type("ctrl").variant("predicted").position('atEquipment').value(0, Date.now());
return 0;
}
@@ -567,8 +1040,8 @@ _callMeasurementHandler(measurementType, value, position, context) {
//update the distance from peak
this.calcDistanceBEP(efficiency,cog,minEfficiency);
//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('min').value(this.predictFlow.currentFxyYMin).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, Date.now(), this.unitPolicy.canonical.flow);
return 0;
}
@@ -639,11 +1112,19 @@ _callMeasurementHandler(measurementType, value, position, context) {
return;
}
+ let measurementUnit;
+ try {
+ measurementUnit = this._resolveMeasurementUnit('pressure', context.unit);
+ } catch (error) {
+ this.logger.warn(`Rejected simulated pressure measurement: ${error.message}`);
+ return;
+ }
+
child.measurements
.type("pressure")
.variant("measured")
.position(normalizedPosition)
- .value(value, context.timestamp || Date.now(), context.unit || "mbar");
+ .value(value, context.timestamp || Date.now(), measurementUnit);
}
handleMeasuredFlow() {
@@ -702,19 +1183,36 @@ _callMeasurementHandler(measurementType, value, position, context) {
updateMeasuredTemperature(value, position, context = {}) {
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
updateMeasuredPressure(value, position, context = {}) {
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
- 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)
const pressure = this.getMeasuredPressure();
this.updatePosition();
+ this._updatePressureDriftStatus();
+ this._updatePredictionHealth();
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'}`);
+ 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
- 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
if (this.predictFlow) {
- this.measurements.type("flow").variant("predicted").position("downstream").value(this.predictFlow.outputY || 0);
- this.measurements.type("flow").variant("predicted").position("atEquipment").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, 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
@@ -767,6 +1311,8 @@ _callMeasurementHandler(measurementType, value, position, context) {
}
+ this._updatePredictionHealth();
+
}
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("specificEnergyConsumption").variant(variant).position('atEquipment').value(specificEnergyConsumption);
- if(pressureDiff?.value != null && flowM3s != null && powerWatt != null){
- const meterPerBar = pressureDiff.value / rho * g;
- const nHydraulicEfficiency = rho * g * flowM3s * (pressureDiff.value * meterPerBar ) / powerWatt;
+ if (pressureDiff?.value != null && Number.isFinite(flowM3s) && Number.isFinite(powerWatt) && powerWatt > 0) {
+ // Engineering references: P_h = Q * Δp = ρ g Q H, η_h = P_h / P_in
+ 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);
}
@@ -904,7 +1458,13 @@ _callMeasurementHandler(measurementType, value, position, context) {
updateCurve(newCurve) {
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
this.config = this.configUtils.updateConfig(this.config, newConfig);
@@ -945,7 +1505,9 @@ _callMeasurementHandler(measurementType, value, position, context) {
// 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
output["state"] = this.state.getCurrentState();
@@ -962,10 +1524,30 @@ _callMeasurementHandler(measurementType, value, position, context) {
const flowDrift = this.flowDrift;
output["flowNrmse"] = flowDrift.nrmse;
output["flowLongterNRMSD"] = flowDrift.longTermNRMSD;
+ output["flowLongTermNRMSD"] = flowDrift.longTermNRMSD;
output["flowImmediateLevel"] = flowDrift.immediateLevel;
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?
output["effDistFromPeak"] = this.absDistFromPeak;
output["effRelDistFromPeak"] = this.relDistFromPeak;
@@ -978,4 +1560,3 @@ _callMeasurementHandler(measurementType, value, position, context) {
} // end of class
module.exports = Machine;
-
diff --git a/test/basic/nodeClass-config.basic.test.js b/test/basic/nodeClass-config.basic.test.js
new file mode 100644
index 0000000..7ae5f6d
--- /dev/null
+++ b/test/basic/nodeClass-config.basic.test.js
@@ -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');
+});
diff --git a/test/edge/error-paths.edge.test.js b/test/edge/error-paths.edge.test.js
index e21d0eb..e1b04c3 100644
--- a/test/edge/error-paths.edge.test.js
+++ b/test/edge/error-paths.edge.test.js
@@ -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', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
@@ -29,3 +51,24 @@ test('nodeClass _updateNodeStatus returns error status on internal failure', ()
assert.equal(status.text, 'Status Error');
assert.equal(node._errors.length, 1);
});
+
+test('measurement handlers reject incompatible units', () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
+
+ assert.equal(machine.isUnitValidForType('flow', 'm3/h'), true);
+ assert.equal(machine.isUnitValidForType('flow', 'mbar'), false);
+
+ machine.updateMeasuredFlow(100, 'downstream', {
+ timestamp: Date.now(),
+ unit: 'mbar',
+ childName: 'bad-ft',
+ });
+
+ const measuredFlow = machine.measurements
+ .type('flow')
+ .variant('measured')
+ .position('downstream')
+ .getCurrentValue();
+
+ assert.equal(measuredFlow, null);
+});
diff --git a/test/edge/nodeClass-routing.edge.test.js b/test/edge/nodeClass-routing.edge.test.js
index f6ab5a0..f517b9e 100644
--- a/test/edge/nodeClass-routing.edge.test.js
+++ b/test/edge/nodeClass-routing.edge.test.js
@@ -43,9 +43,15 @@ test('input handler routes topics to source methods', () => {
updateMeasuredFlow(value, position) {
calls.push(['updateMeasuredFlow', value, position]);
},
+ updateMeasuredPower(value, position) {
+ calls.push(['updateMeasuredPower', value, position]);
+ },
updateMeasuredTemperature(value, position) {
calls.push(['updateMeasuredTemperature', value, position]);
},
+ isUnitValidForType() {
+ return true;
+ },
};
inst._attachInputHandler();
@@ -53,13 +59,53 @@ test('input handler routes topics to source methods', () => {
onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } }, () => {}, () => {});
+ onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
+ onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
+ onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
assert.deepEqual(calls[0], ['setMode', 'auto']);
assert.deepEqual(calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
- assert.deepEqual(calls[2], ['registerChild', { id: 'child-source' }, 'downstream']);
- assert.deepEqual(calls[3], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
+ assert.deepEqual(calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
+ assert.deepEqual(calls[3], ['handleInput', 'GUI', 'emergencystop', undefined]);
+ assert.deepEqual(calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
+ assert.deepEqual(calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
+ assert.deepEqual(calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
+});
+
+test('simulateMeasurement warns and ignores invalid payloads', () => {
+ const inst = Object.create(NodeClass.prototype);
+ const node = makeNodeStub();
+
+ const calls = [];
+ inst.node = node;
+ inst.RED = makeREDStub();
+ inst.source = {
+ childRegistrationUtils: { registerChild() {} },
+ setMode() {},
+ handleInput() {},
+ showWorkingCurves() { return {}; },
+ showCoG() { return {}; },
+ updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
+ updateMeasuredPressure() { calls.push('updateMeasuredPressure'); },
+ updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
+ updateMeasuredPower() { calls.push('updateMeasuredPower'); },
+ updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
+ };
+
+ inst._attachInputHandler();
+ const onInput = node._handlers.input;
+
+ onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
+ onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
+ onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
+
+ assert.equal(calls.length, 0);
+ assert.equal(node._warns.length, 3);
+ assert.match(String(node._warns[0]), /finite number/i);
+ assert.match(String(node._warns[1]), /payload\.unit is required/i);
+ assert.match(String(node._warns[2]), /unsupported simulatemeasurement type/i);
});
test('status shows warning when pressure inputs are not initialized', () => {
diff --git a/test/helpers/factories.js b/test/helpers/factories.js
index c78aa53..6b19a44 100644
--- a/test/helpers/factories.js
+++ b/test/helpers/factories.js
@@ -17,6 +17,12 @@ function makeMachineConfig(overrides = {}) {
type: 'pump',
model: 'hidrostal-H05K-S03R',
unit: 'm3/h',
+ curveUnits: {
+ pressure: 'mbar',
+ flow: 'm3/h',
+ power: 'kW',
+ control: '%',
+ },
},
...overrides,
};
diff --git a/test/integration/coolprop.integration.test.js b/test/integration/coolprop.integration.test.js
index cdf30d6..e529449 100644
--- a/test/integration/coolprop.integration.test.js
+++ b/test/integration/coolprop.integration.test.js
@@ -19,6 +19,21 @@ test('calcEfficiency runs through coolprop path without mocks', () => {
const eff = machine.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue();
assert.equal(typeof eff, 'number');
assert.ok(eff > 0);
+
+ const pressureDiffPa = (1200 - 800) * 100; // mbar -> Pa
+ const flowM3s = 120 / 3600; // m3/h -> m3/s
+ const expectedHydraulicPower = pressureDiffPa * flowM3s;
+ const expectedHydraulicEfficiency = expectedHydraulicPower / 12000; // 12kW -> W
+
+ const hydraulicPower = machine.measurements.type('hydraulicPower').variant('predicted').position('atEquipment').getCurrentValue('W');
+ const hydraulicEfficiency = machine.measurements.type('nHydraulicEfficiency').variant('predicted').position('atEquipment').getCurrentValue();
+ const head = machine.measurements.type('pumpHead').variant('predicted').position('atEquipment').getCurrentValue('m');
+
+ assert.ok(Number.isFinite(hydraulicPower));
+ assert.ok(Number.isFinite(hydraulicEfficiency));
+ assert.ok(Number.isFinite(head));
+ assert.ok(Math.abs(hydraulicPower - expectedHydraulicPower) < 1);
+ assert.ok(Math.abs(hydraulicEfficiency - expectedHydraulicEfficiency) < 0.01);
});
test('predictions use initialized medium pressure and not the minimum-pressure fallback', () => {
@@ -33,7 +48,7 @@ test('predictions use initialized medium pressure and not the minimum-pressure f
assert.equal(pressureStatus.initialized, 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.ok(machine.predictFlow.fDimension > 0);
});
diff --git a/test/integration/prediction-health.integration.test.js b/test/integration/prediction-health.integration.test.js
new file mode 100644
index 0000000..e65c0ba
--- /dev/null
+++ b/test/integration/prediction-health.integration.test.js
@@ -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'));
+});
diff --git a/test/integration/pressure-initialization.integration.test.js b/test/integration/pressure-initialization.integration.test.js
index 2d2c2de..5a2d655 100644
--- a/test/integration/pressure-initialization.integration.test.js
+++ b/test/integration/pressure-initialization.integration.test.js
@@ -27,8 +27,8 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(status.hasDifferential, false);
assert.equal(status.source, 'upstream');
const upstreamValue = machine.getMeasuredPressure();
- assert.equal(Math.round(upstreamValue), upstreamOnly);
- assert.equal(Math.round(machine.predictFlow.fDimension), upstreamOnly);
+ assert.equal(Math.round(upstreamValue), upstreamOnly * 100);
+ assert.equal(Math.round(machine.predictFlow.fDimension), upstreamOnly * 100);
// downstream only
machine = createMachine();
@@ -41,8 +41,8 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(status.hasDifferential, false);
assert.equal(status.source, 'downstream');
const downstreamValue = machine.getMeasuredPressure();
- assert.equal(Math.round(downstreamValue), downstreamOnly);
- assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly);
+ assert.equal(Math.round(downstreamValue), downstreamOnly * 100);
+ assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100);
// downstream and upstream
machine = createMachine();
@@ -57,8 +57,8 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(status.hasDifferential, true);
assert.equal(status.source, 'differential');
const differentialValue = machine.getMeasuredPressure();
- assert.equal(Math.round(differentialValue), downstream - upstream);
- assert.equal(Math.round(machine.predictFlow.fDimension), downstream - upstream);
+ assert.equal(Math.round(differentialValue), (downstream - upstream) * 100);
+ assert.equal(Math.round(machine.predictFlow.fDimension), (downstream - upstream) * 100);
});
test('real pressure child data has priority over simulated dashboard pressure', async () => {
@@ -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', '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 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');
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();
assert.equal(status.source, 'differential');
assert.equal(status.initialized, true);
diff --git a/test/integration/registration.integration.test.js b/test/integration/registration.integration.test.js
index db46b11..694599f 100644
--- a/test/integration/registration.integration.test.js
+++ b/test/integration/registration.integration.test.js
@@ -25,3 +25,29 @@ test('registerChild listens to measurement events and stores measured pressure',
assert.equal(typeof stored, 'number');
assert.equal(Math.round(stored), 123);
});
+
+test('registerChild deduplicates listeners on re-registration', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+ const child = makeChildMeasurement({ id: 'pt-dup', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
+ const eventName = 'pressure.measured.downstream';
+
+ let handlerCalls = 0;
+ const originalUpdatePressure = machine.updateMeasuredPressure.bind(machine);
+ machine.updateMeasuredPressure = (...args) => {
+ handlerCalls += 1;
+ return originalUpdatePressure(...args);
+ };
+
+ machine.registerChild(child, 'measurement');
+ machine.registerChild(child, 'measurement');
+
+ assert.equal(child.measurements.emitter.listenerCount(eventName), 1);
+
+ child.measurements
+ .type('pressure')
+ .variant('measured')
+ .position('downstream')
+ .value(321, Date.now(), 'mbar');
+
+ assert.equal(handlerCalls, 1);
+});
diff --git a/test/integration/sequences.integration.test.js b/test/integration/sequences.integration.test.js
index ca52936..175658b 100644
--- a/test/integration/sequences.integration.test.js
+++ b/test/integration/sequences.integration.test.js
@@ -12,11 +12,13 @@ test('execSequence startup reaches operational with zero transition times', asyn
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 { max } = machine._resolveSetpointBounds();
await machine.handleInput('parent', 'execMovement', 10);
const pos = machine.state.getCurrentPosition();
- assert.ok(pos >= 9.9 && pos <= 10);
+ assert.ok(pos <= max);
+ assert.equal(pos, max);
});