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