diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..3e96f81 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,49 @@ +# RotatingMachine Example Flows + +These flows are import-ready Node-RED examples focused on the `rotatingMachine` node. + +## Files + +- `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 + +- `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 + +- `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 + +## Requirements + +- EVOLV rotatingMachine node installed/available in Node-RED. +- FlowFuse Dashboard 2 nodes installed (`ui-base`, `ui-page`, `ui-group`, etc.). + +## 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. + +## 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. diff --git a/examples/basic.flow.json b/examples/basic.flow.json new file mode 100644 index 0000000..b632d6c --- /dev/null +++ b/examples/basic.flow.json @@ -0,0 +1,644 @@ +[ + { + "id": "f1e8a6c8b2a4477f", + "type": "tab", + "label": "RotatingMachine Basic", + "disabled": false, + "info": "Basic manual control and visualization for rotatingMachine" + }, + { + "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": "12px", + "groupGap": "12px", + "groupBorderRadius": "4px", + "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};\n\nconst pickNumber = (...keys) => {\n for (const key of keys) {\n const value = Number(merged[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\n\nconst pickString = (key, fallback = null) => {\n const value = merged[key];\n if (value === undefined || value === null || value === '') return fallback;\n return String(value);\n};\n\nconst flow = pickNumber('flow.predicted.downstream.default', 'flow.predicted.downstream');\nconst power = pickNumber('power.predicted.atequipment.default', 'power.predicted.atequipment', 'power.predicted.atEquipment.default', 'power.predicted.atEquipment');\nconst ctrl = pickNumber('ctrl', 'ctrl.predicted.atequipment.default', 'ctrl.predicted.atequipment', 'ctrl.predicted.atEquipment.default', 'ctrl.predicted.atEquipment');\nconst nCog = pickNumber('NCogPercent', 'NCog');\nconst runtime = pickNumber('runtime');\nconst moveTimeleft = pickNumber('moveTimeleft');\nconst maintenanceTime = pickNumber('maintenanceTime');\nconst state = pickString('state', cache.state);\nconst mode = pickString('mode', cache.mode);\n\nconst stateCodeMap = { off: 0, idle: 1, starting: 2, warmingup: 3, operational: 4, accelerating: 5, decelerating: 6, stopping: 7, coolingdown: 8, maintenance: 9 };\nconst stateCode = stateCodeMap[state] ?? cache.stateCode;\n\nif (flow !== null) cache.flow = flow;\nif (power !== null) cache.power = power;\nif (ctrl !== null) cache.ctrl = ctrl;\nif (nCog !== null) cache.nCog = nCog;\nif (runtime !== null) cache.runtime = runtime;\nif (moveTimeleft !== null) cache.moveTimeleft = moveTimeleft;\nif (maintenanceTime !== null) cache.maintenanceTime = maintenanceTime;\ncache.state = state;\ncache.mode = mode;\ncache.stateCode = stateCode;\ncontext.set('metricCache', cache);\n\nreturn [\n { topic: 'actual_flow', payload: cache.flow },\n { topic: 'predicted_power', payload: cache.power },\n { topic: 'actual_ctrl', payload: cache.ctrl },\n { topic: 'nCog', payload: cache.nCog },\n { topic: 'stateCode', payload: cache.stateCode },\n { payload: 'state=' + cache.state + ', mode=' + cache.mode + ', ctrl=' + cache.ctrl.toFixed(2) + '%' },\n { payload: 'runtime=' + cache.runtime.toFixed(3) + ' h | moveTimeLeft=' + cache.moveTimeleft.toFixed(0) + ' s | maintenance=' + cache.maintenanceTime.toFixed(3) + ' h' },\n { payload: JSON.stringify(merged) }\n];", + "outputs": 8, + "noerr": 0, + "x": 1310, + "y": 420, + "wires": [ + [ + "rm_chart_flow" + ], + [ + "rm_chart_power" + ], + [ + "rm_chart_ctrl" + ], + [ + "rm_chart_ncog" + ], + [ + "rm_chart_statecode" + ], + [ + "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": 12, + "height": 4, + "chartType": "line", + "category": "", + "xAxisLabel": "time", + "xAxisType": "time", + "yAxisLabel": "m3/h", + "removeOlder": "1", + "removeOlderUnit": "3600", + "removeOlderPoints": "", + "x": 1560, + "y": 340, + "wires": [], + "showLegend": true + }, + { + "id": "rm_chart_power", + "type": "ui-chart", + "z": "f1e8a6c8b2a4477f", + "group": "ui_group_rm_obs", + "name": "Predicted Power", + "label": "Power (kW)", + "order": 2, + "width": 12, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "kW", + "removeOlder": "1", + "removeOlderUnit": "3600", + "x": 1560, + "y": 400, + "wires": [], + "showLegend": true + }, + { + "id": "rm_chart_ctrl", + "type": "ui-chart", + "z": "f1e8a6c8b2a4477f", + "group": "ui_group_rm_obs", + "name": "Control Position", + "label": "Ctrl (%)", + "order": 3, + "width": 12, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "%", + "removeOlder": "1", + "removeOlderUnit": "3600", + "x": 1560, + "y": 460, + "wires": [], + "showLegend": true + }, + { + "id": "rm_text_state", + "type": "ui-text", + "z": "f1e8a6c8b2a4477f", + "group": "ui_group_rm_obs", + "name": "State/Mode", + "label": "Current State", + "order": 6, + "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": 12, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "%", + "removeOlder": "1", + "removeOlderUnit": "3600", + "x": 1560, + "y": 520, + "wires": [], + "showLegend": 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": 12, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "state", + "removeOlder": "1", + "removeOlderUnit": "3600", + "x": 1560, + "y": 580, + "wires": [], + "showLegend": true + }, + { + "id": "rm_text_timing", + "type": "ui-text", + "z": "f1e8a6c8b2a4477f", + "group": "ui_group_rm_obs", + "name": "Timing", + "label": "Timing", + "order": 7, + "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": "Latest Payload", + "label": "Latest Payload", + "order": 8, + "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" + ] + ] + } +] diff --git a/examples/edge.flow.json b/examples/edge.flow.json new file mode 100644 index 0000000..dc976ce --- /dev/null +++ b/examples/edge.flow.json @@ -0,0 +1,325 @@ +[ + { + "id": "91a88f212fb34de8", + "type": "tab", + "label": "RotatingMachine Edge Cases", + "disabled": false, + "info": "Manual edge-case driving for rotatingMachine" + }, + { + "id": "ui_base_rm_edge", + "type": "ui-base", + "name": "EVOLV Demo", + "path": "/dashboard", + "includeClientData": true, + "acceptsClientConfig": [ + "ui-notification", + "ui-control" + ], + "showPathInSidebar": false, + "headerContent": "page", + "navigationStyle": "default", + "titleBarStyle": "default" + }, + { + "id": "ui_theme_rm_edge", + "type": "ui-theme", + "name": "EVOLV Edge Theme", + "colors": { + "surface": "#ffffff", + "primary": "#0094ce", + "bgPage": "#eeeeee", + "groupBg": "#ffffff", + "groupOutline": "#cccccc" + }, + "sizes": { + "density": "default", + "pagePadding": "12px", + "groupGap": "12px", + "groupBorderRadius": "4px", + "widgetGap": "12px" + } + }, + { + "id": "ui_page_rm_edge", + "type": "ui-page", + "name": "RotatingMachine Edge", + "ui": "ui_base_rm_edge", + "path": "/rotating-machine-edge", + "icon": "report_problem", + "layout": "grid", + "theme": "ui_theme_rm_edge", + "breakpoints": [ + { + "name": "Default", + "px": "0", + "cols": "12" + } + ], + "order": 3, + "className": "" + }, + { + "id": "ui_group_rm_edge_inputs", + "type": "ui-group", + "name": "Edge Input Generators", + "page": "ui_page_rm_edge", + "width": "6", + "height": "1", + "order": 1, + "showTitle": true + }, + { + "id": "ui_group_rm_edge_obs", + "type": "ui-group", + "name": "Observed Responses", + "page": "ui_page_rm_edge", + "width": "6", + "height": "8", + "order": 2, + "showTitle": true + }, + { + "id": "rm_node_edge", + "type": "rotatingMachine", + "z": "91a88f212fb34de8", + "name": "RM Edge", + "speed": "1", + "startup": "3", + "warmup": "3", + "shutdown": "3", + "cooldown": "3", + "movementMode": "staticspeed", + "machineCurve": "", + "uuid": "", + "supplier": "hidrostal", + "category": "machine", + "assetType": "pump-centrifugal", + "model": "hidrostal-H05K-S03R", + "unit": "m3/h", + "enableLog": false, + "logLevel": "error", + "positionVsParent": "atEquipment", + "x": 930, + "y": 300, + "wires": [ + [ + "rm_edge_parse", + "rm_edge_process_debug" + ], + [ + "rm_edge_debug_influx" + ], + [ + "rm_edge_debug_parent" + ] + ], + "hasDistance": false + }, + { + "id": "rm_edge_invalid_mode_btn", + "type": "ui-button", + "z": "91a88f212fb34de8", + "group": "ui_group_rm_edge_inputs", + "name": "Invalid Mode", + "label": "Send invalid mode", + "order": 1, + "width": "6", + "height": "1", + "payload": "invalidMode", + "payloadType": "str", + "topic": "setMode", + "x": 190, + "y": 120, + "wires": [ + [ + "rm_node_edge" + ] + ] + }, + { + "id": "rm_edge_neg_setpoint", + "type": "ui-number-input", + "z": "91a88f212fb34de8", + "group": "ui_group_rm_edge_inputs", + "name": "Negative Setpoint", + "label": "Negative setpoint", + "order": 2, + "width": "6", + "height": "1", + "passthru": true, + "min": -100, + "max": 0, + "step": 1, + "x": 190, + "y": 170, + "wires": [ + [ + "rm_edge_exec_movement" + ] + ] + }, + { + "id": "rm_edge_exec_movement", + "type": "function", + "z": "91a88f212fb34de8", + "name": "Build execMovement", + "func": "msg.topic = 'execMovement';\nmsg.payload = {source:'GUI', action:'execMovement', setpoint:Number(msg.payload)};\nreturn msg;", + "outputs": 1, + "x": 450, + "y": 170, + "wires": [ + [ + "rm_node_edge" + ] + ] + }, + { + "id": "rm_edge_bad_source_btn", + "type": "ui-button", + "z": "91a88f212fb34de8", + "group": "ui_group_rm_edge_inputs", + "name": "Bad Source", + "label": "Disallowed source action", + "order": 3, + "width": "6", + "height": "1", + "payload": "{}", + "payloadType": "str", + "x": 210, + "y": 220, + "wires": [ + [ + "rm_edge_bad_source_builder" + ] + ] + }, + { + "id": "rm_edge_bad_source_builder", + "type": "function", + "z": "91a88f212fb34de8", + "name": "Build blocked execSequence", + "func": "msg.topic='execSequence';\nmsg.payload={source:'bad-source', action:'execSequence', parameter:'startup'};\nreturn msg;", + "outputs": 1, + "x": 500, + "y": 220, + "wires": [ + [ + "rm_node_edge" + ] + ] + }, + { + "id": "rm_edge_bad_sim_type", + "type": "ui-button", + "z": "91a88f212fb34de8", + "group": "ui_group_rm_edge_inputs", + "name": "Unsupported Sim Type", + "label": "simulateMeasurement (bad type)", + "order": 4, + "width": "6", + "height": "1", + "payload": "{\"type\":\"unknown\",\"position\":\"downstream\",\"value\":123,\"unit\":\"mbar\"}", + "payloadType": "json", + "topic": "simulateMeasurement", + "x": 220, + "y": 270, + "wires": [ + [ + "rm_node_edge" + ] + ] + }, + { + "id": "rm_edge_parse", + "type": "function", + "z": "91a88f212fb34de8", + "name": "Summarize process output", + "func": "const incoming = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst merged = { ...(context.get('lastPayload') || {}), ...incoming };\ncontext.set('lastPayload', merged);\n\nconst state = merged.state == null ? 'n/a' : String(merged.state);\nconst mode = merged.mode == null ? 'n/a' : String(merged.mode);\nconst ctrl = Number(merged.ctrl);\nconst stateCodeMap = { off: 0, idle: 1, starting: 2, warmingup: 3, operational: 4, accelerating: 5, decelerating: 6, stopping: 7, coolingdown: 8, maintenance: 9 };\nconst stateCode = stateCodeMap[state] ?? -1;\n\nreturn [\n { payload: `state=${state}, mode=${mode}, ctrl=${Number.isFinite(ctrl) ? ctrl.toFixed(2) : 'n/a'}%` },\n { topic: 'stateCode', payload: stateCode }\n];", + "outputs": 2, + "x": 1230, + "y": 300, + "wires": [ + [ + "rm_edge_state_text" + ], + [ + "rm_edge_state_chart" + ] + ] + }, + { + "id": "rm_edge_state_text", + "type": "ui-text", + "z": "91a88f212fb34de8", + "group": "ui_group_rm_edge_obs", + "name": "State Text", + "label": "Machine summary", + "order": 2, + "format": "{{msg.payload}}", + "layout": "row-spread", + "x": 1450, + "y": 300, + "wires": [], + "width": 6, + "height": 1 + }, + { + "id": "rm_edge_process_debug", + "type": "debug", + "z": "91a88f212fb34de8", + "name": "Process Output", + "active": true, + "tosidebar": true, + "complete": "true", + "targetType": "full", + "x": 1220, + "y": 340, + "wires": [] + }, + { + "id": "rm_edge_debug_influx", + "type": "debug", + "z": "91a88f212fb34de8", + "name": "Influx Output", + "active": true, + "tosidebar": true, + "complete": "true", + "targetType": "full", + "x": 1220, + "y": 380, + "wires": [] + }, + { + "id": "rm_edge_debug_parent", + "type": "debug", + "z": "91a88f212fb34de8", + "name": "Parent Output", + "active": true, + "tosidebar": true, + "complete": "true", + "targetType": "full", + "x": 1220, + "y": 420, + "wires": [] + }, + { + "id": "rm_edge_state_chart", + "type": "ui-chart", + "z": "91a88f212fb34de8", + "group": "ui_group_rm_edge_obs", + "name": "State Code", + "label": "State Code", + "order": 1, + "width": 6, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "state", + "removeOlder": "1", + "removeOlderUnit": "3600", + "x": 1230, + "y": 300, + "wires": [] + } +] diff --git a/examples/integration.flow.json b/examples/integration.flow.json new file mode 100644 index 0000000..d032e8c --- /dev/null +++ b/examples/integration.flow.json @@ -0,0 +1,569 @@ +[ + { + "id": "12f41a7b538c40db", + "type": "tab", + "label": "RotatingMachine Integration", + "disabled": false, + "info": "Manual integration-style scenario builder for rotatingMachine" + }, + { + "id": "ui_base_rm_int", + "type": "ui-base", + "name": "EVOLV Demo", + "path": "/dashboard", + "includeClientData": true, + "acceptsClientConfig": [ + "ui-notification", + "ui-control" + ], + "showPathInSidebar": false, + "headerContent": "page", + "navigationStyle": "default", + "titleBarStyle": "default" + }, + { + "id": "ui_theme_rm_int", + "type": "ui-theme", + "name": "EVOLV Integration Theme", + "colors": { + "surface": "#ffffff", + "primary": "#0094ce", + "bgPage": "#eeeeee", + "groupBg": "#ffffff", + "groupOutline": "#cccccc" + }, + "sizes": { + "density": "default", + "pagePadding": "12px", + "groupGap": "12px", + "groupBorderRadius": "4px", + "widgetGap": "12px" + } + }, + { + "id": "ui_page_rm_int", + "type": "ui-page", + "name": "RotatingMachine Integration", + "ui": "ui_base_rm_int", + "path": "/rotating-machine-integration", + "icon": "lan", + "layout": "grid", + "theme": "ui_theme_rm_int", + "breakpoints": [ + { + "name": "Default", + "px": "0", + "cols": "12" + } + ], + "order": 2, + "className": "" + }, + { + "id": "ui_group_rm_int_ctrl", + "type": "ui-group", + "name": "Control Sequences", + "page": "ui_page_rm_int", + "width": "6", + "height": "1", + "order": 1, + "showTitle": true, + "className": "" + }, + { + "id": "ui_group_rm_int_sim", + "type": "ui-group", + "name": "Process Simulation", + "page": "ui_page_rm_int", + "width": "6", + "height": "1", + "order": 2, + "showTitle": true, + "className": "" + }, + { + "id": "ui_group_rm_int_vis", + "type": "ui-group", + "name": "Observed Behaviour", + "page": "ui_page_rm_int", + "width": "12", + "height": "20", + "order": 3, + "showTitle": true, + "className": "" + }, + { + "id": "rm_node_int", + "type": "rotatingMachine", + "z": "12f41a7b538c40db", + "name": "RM Integration", + "speed": "1", + "startup": "3", + "warmup": "3", + "shutdown": "3", + "cooldown": "3", + "movementMode": "staticspeed", + "machineCurve": "", + "uuid": "", + "supplier": "hidrostal", + "category": "machine", + "assetType": "pump-centrifugal", + "model": "hidrostal-H05K-S03R", + "unit": "m3/h", + "enableLog": false, + "logLevel": "error", + "positionVsParent": "atEquipment", + "x": 1040, + "y": 360, + "wires": [ + [ + "rm_int_parse" + ], + [ + "rm_int_debug_influx" + ], + [ + "rm_int_debug_parent" + ] + ], + "hasDistance": false + }, + { + "id": "rm_int_startup", + "type": "ui-button", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_ctrl", + "name": "Startup", + "label": "Startup", + "order": 1, + "width": "3", + "height": "1", + "icon": "play_arrow", + "payload": "startup", + "payloadType": "str", + "x": 190, + "y": 120, + "wires": [ + [ + "rm_int_exec_seq" + ] + ] + }, + { + "id": "rm_int_shutdown", + "type": "ui-button", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_ctrl", + "name": "Shutdown", + "label": "Shutdown", + "order": 2, + "width": "3", + "height": "1", + "icon": "stop", + "payload": "shutdown", + "payloadType": "str", + "x": 190, + "y": 160, + "wires": [ + [ + "rm_int_exec_seq" + ] + ] + }, + { + "id": "rm_int_maint", + "type": "ui-button", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_ctrl", + "name": "Maintenance", + "label": "Enter Maintenance", + "order": 3, + "width": "6", + "height": "1", + "icon": "build", + "payload": "entermaintenance", + "payloadType": "str", + "x": 220, + "y": 200, + "wires": [ + [ + "rm_int_exec_seq" + ] + ] + }, + { + "id": "rm_int_exec_seq", + "type": "function", + "z": "12f41a7b538c40db", + "name": "Build execSequence", + "func": "msg.topic = 'execSequence';\nmsg.payload = {source:'GUI', action:'execSequence', parameter: msg.payload};\nreturn msg;", + "outputs": 1, + "x": 450, + "y": 160, + "wires": [ + [ + "rm_node_int" + ] + ] + }, + { + "id": "rm_int_setpoint", + "type": "ui-number-input", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_ctrl", + "name": "Setpoint", + "label": "Setpoint %", + "order": 4, + "width": "6", + "height": "1", + "passthru": true, + "min": 0, + "max": 100, + "step": 1, + "x": 190, + "y": 260, + "wires": [ + [ + "rm_int_exec_movement", + "rm_int_ctrl_setpoint_for_chart" + ] + ] + }, + { + "id": "rm_int_exec_movement", + "type": "function", + "z": "12f41a7b538c40db", + "name": "Build execMovement", + "func": "msg.topic='execMovement';\nmsg.payload={source:'GUI', action:'execMovement', setpoint:Number(msg.payload)};\nreturn msg;", + "outputs": 1, + "x": 460, + "y": 260, + "wires": [ + [ + "rm_node_int" + ] + ] + }, + { + "id": "rm_int_flow_target", + "type": "ui-number-input", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_sim", + "name": "Flow target", + "label": "Flow target (m3/h)", + "order": 1, + "width": "6", + "height": "1", + "passthru": true, + "min": 0, + "max": 1000, + "step": 5, + "x": 200, + "y": 340, + "wires": [ + [ + "rm_int_flow_move", + "rm_int_flow_setpoint_for_chart" + ] + ] + }, + { + "id": "rm_int_flow_move", + "type": "function", + "z": "12f41a7b538c40db", + "name": "Build flowMovement", + "func": "msg.topic='flowMovement';\nmsg.payload={source:'GUI', action:'flowMovement', setpoint:Number(msg.payload)};\nreturn msg;", + "outputs": 1, + "x": 450, + "y": 340, + "wires": [ + [ + "rm_node_int" + ] + ] + }, + { + "id": "rm_int_pressure_up", + "type": "ui-number-input", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_sim", + "name": "Upstream Pressure", + "label": "Upstream pressure (mbar)", + "order": 2, + "width": "6", + "height": "1", + "passthru": true, + "min": 0, + "max": 3000, + "step": 10, + "x": 210, + "y": 390, + "wires": [ + [ + "rm_int_sim_up" + ] + ] + }, + { + "id": "rm_int_pressure_down", + "type": "ui-number-input", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_sim", + "name": "Downstream Pressure", + "label": "Downstream pressure (mbar)", + "order": 3, + "width": "6", + "height": "1", + "passthru": true, + "min": 0, + "max": 3000, + "step": 10, + "x": 220, + "y": 430, + "wires": [ + [ + "rm_int_sim_down" + ] + ] + }, + { + "id": "rm_int_sim_up", + "type": "function", + "z": "12f41a7b538c40db", + "name": "simulate upstream pressure", + "func": "msg.topic='simulateMeasurement';\nmsg.payload={type:'pressure', position:'upstream', value:Number(msg.payload), unit:'mbar'};\nreturn msg;", + "outputs": 1, + "x": 500, + "y": 390, + "wires": [ + [ + "rm_node_int" + ] + ] + }, + { + "id": "rm_int_sim_down", + "type": "function", + "z": "12f41a7b538c40db", + "name": "simulate downstream pressure", + "func": "msg.topic='simulateMeasurement';\nmsg.payload={type:'pressure', position:'downstream', value:Number(msg.payload), unit:'mbar'};\nreturn msg;", + "outputs": 1, + "x": 510, + "y": 430, + "wires": [ + [ + "rm_node_int" + ] + ] + }, + { + "id": "rm_int_parse", + "type": "function", + "z": "12f41a7b538c40db", + "name": "Parse RM output", + "func": "const incoming = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst lastPayload = context.get('lastPayload') || {};\nconst merged = { ...lastPayload, ...incoming };\ncontext.set('lastPayload', merged);\n\nconst cache = context.get('metricCache') || {\n flow: 0,\n power: 0,\n nCog: 0,\n ctrl: 0,\n stateCode: 0,\n state: 'idle',\n mode: 'auto',\n runtime: 0,\n moveTimeleft: 0,\n maintenanceTime: 0\n};\n\nconst pickNumber = (...keys) => {\n for (const key of keys) {\n const value = Number(merged[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\n\nconst pickString = (key, fallback = null) => {\n const value = merged[key];\n if (value === undefined || value === null || value === '') return fallback;\n return String(value);\n};\n\nconst flow = pickNumber('flow.predicted.downstream.default', 'flow.predicted.downstream');\nconst power = pickNumber('power.predicted.atequipment.default', 'power.predicted.atequipment', 'power.predicted.atEquipment.default', 'power.predicted.atEquipment');\nconst nCog = pickNumber('NCogPercent', 'NCog');\nconst ctrl = pickNumber('ctrl', 'ctrl.predicted.atequipment.default', 'ctrl.predicted.atequipment', 'ctrl.predicted.atEquipment.default', 'ctrl.predicted.atEquipment');\nconst runtime = pickNumber('runtime');\nconst moveTimeleft = pickNumber('moveTimeleft');\nconst maintenanceTime = pickNumber('maintenanceTime');\nconst state = pickString('state', cache.state);\nconst mode = pickString('mode', cache.mode);\n\nconst stateCodeMap = { off: 0, idle: 1, starting: 2, warmingup: 3, operational: 4, accelerating: 5, decelerating: 6, stopping: 7, coolingdown: 8, maintenance: 9 };\nconst stateCode = stateCodeMap[state] ?? cache.stateCode;\n\nif (flow !== null) cache.flow = flow;\nif (power !== null) cache.power = power;\nif (nCog !== null) cache.nCog = nCog;\nif (ctrl !== null) cache.ctrl = ctrl;\nif (runtime !== null) cache.runtime = runtime;\nif (moveTimeleft !== null) cache.moveTimeleft = moveTimeleft;\nif (maintenanceTime !== null) cache.maintenanceTime = maintenanceTime;\ncache.state = state;\ncache.mode = mode;\ncache.stateCode = stateCode;\ncontext.set('metricCache', cache);\n\nreturn [\n { topic: 'actual_flow', payload: cache.flow },\n { topic: 'predicted_power', payload: cache.power },\n { topic: 'nCog', payload: cache.nCog },\n { topic: 'actual_ctrl', payload: cache.ctrl },\n { topic: 'stateCode', payload: cache.stateCode },\n { payload: JSON.stringify({ state: cache.state, mode: cache.mode, ctrl: cache.ctrl, runtime: cache.runtime, moveTimeleft: cache.moveTimeleft, maintenanceTime: cache.maintenanceTime }) }\n];", + "outputs": 6, + "x": 1260, + "y": 360, + "wires": [ + [ + "rm_int_chart_flow" + ], + [ + "rm_int_chart_power" + ], + [ + "rm_int_chart_nCog" + ], + [ + "rm_int_chart_ctrl" + ], + [ + "rm_int_chart_statecode" + ], + [ + "rm_int_state_text" + ] + ] + }, + { + "id": "rm_int_chart_flow", + "type": "ui-chart", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_vis", + "name": "Flow", + "label": "Flow (m3/h)", + "order": 1, + "width": 12, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "m3/h", + "removeOlder": "1", + "removeOlderUnit": "3600", + "x": 1510, + "y": 280, + "wires": [], + "showLegend": true + }, + { + "id": "rm_int_chart_power", + "type": "ui-chart", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_vis", + "name": "Power", + "label": "Power (kW)", + "order": 2, + "width": 12, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "kW", + "removeOlder": "1", + "removeOlderUnit": "3600", + "x": 1510, + "y": 340, + "wires": [], + "showLegend": true + }, + { + "id": "rm_int_chart_nCog", + "type": "ui-chart", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_vis", + "name": "NCog", + "label": "NCog (%)", + "order": 3, + "width": 12, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "%", + "removeOlder": "1", + "removeOlderUnit": "3600", + "x": 1510, + "y": 400, + "wires": [], + "showLegend": true + }, + { + "id": "rm_int_state_text", + "type": "ui-text", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_vis", + "name": "State", + "label": "State Snapshot", + "order": 6, + "width": 12, + "height": 1, + "format": "{{msg.payload}}", + "layout": "row-spread", + "x": 1510, + "y": 460, + "wires": [] + }, + { + "id": "rm_int_debug_influx", + "type": "debug", + "z": "12f41a7b538c40db", + "name": "Influx", + "active": true, + "tosidebar": true, + "complete": "true", + "targetType": "full", + "x": 1250, + "y": 520, + "wires": [] + }, + { + "id": "rm_int_debug_parent", + "type": "debug", + "z": "12f41a7b538c40db", + "name": "Parent", + "active": true, + "tosidebar": true, + "complete": "true", + "targetType": "full", + "x": 1240, + "y": 560, + "wires": [] + }, + { + "id": "rm_int_chart_ctrl", + "type": "ui-chart", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_vis", + "name": "Ctrl", + "label": "Ctrl (%)", + "order": 4, + "width": 12, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "%", + "removeOlder": "1", + "removeOlderUnit": "3600", + "x": 1510, + "y": 460, + "wires": [], + "showLegend": true + }, + { + "id": "rm_int_chart_statecode", + "type": "ui-chart", + "z": "12f41a7b538c40db", + "group": "ui_group_rm_int_vis", + "name": "State Code", + "label": "State Code (off=0 .. maint=9)", + "order": 5, + "width": 12, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "state", + "removeOlder": "1", + "removeOlderUnit": "3600", + "x": 1510, + "y": 520, + "wires": [], + "showLegend": true + }, + { + "id": "rm_int_ctrl_setpoint_for_chart", + "type": "function", + "z": "12f41a7b538c40db", + "name": "ctrl setpoint series", + "func": "msg.topic = 'setpoint_ctrl';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 560, + "y": 220, + "wires": [ + [ + "rm_int_chart_ctrl" + ] + ] + }, + { + "id": "rm_int_flow_setpoint_for_chart", + "type": "function", + "z": "12f41a7b538c40db", + "name": "flow setpoint series", + "func": "msg.topic = 'setpoint_flow';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 570, + "y": 330, + "wires": [ + [ + "rm_int_chart_flow" + ] + ] + } +] diff --git a/package.json b/package.json index 2d11dfc..6a3c694 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Control module rotatingMachine", "main": "rotatingMachine.js", "scripts": { - "test": "node rotatingMachine.js" + "test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js" }, "repository": { "type": "git", diff --git a/src/nodeClass.js b/src/nodeClass.js index a000f7e..54afc0c 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -118,7 +118,7 @@ class nodeClass { const mode = m.currentMode; const state = m.state.getCurrentState(); const flow = Math.round(m.measurements.type("flow").variant("predicted").position('downstream').getCurrentValue('m3/h')); - const power = Math.round(m.measurements.type("power").variant("predicted").position('atequipment').getCurrentValue('kW')); + const power = Math.round(m.measurements.type("power").variant("predicted").position('atEquipment').getCurrentValue('kW')); let symbolState; switch(state){ case "off": @@ -189,7 +189,7 @@ class nodeClass { } return status; } catch (error) { - node.error("Error in updateNodeStatus: " + error.message); + this.node.error("Error in updateNodeStatus: " + error.message); return { fill: "red", shape: "ring", text: "Status Error" }; } } @@ -271,6 +271,40 @@ class nodeClass { const { source: esSource, action: esAction } = msg.payload; m.handleInput(esSource, esAction); break; + case 'simulateMeasurement': + { + const payload = msg.payload || {}; + const type = String(payload.type || '').toLowerCase(); + const position = payload.position || 'atEquipment'; + const value = Number(payload.value); + const unit = payload.unit; + const context = { + timestamp: payload.timestamp || Date.now(), + unit, + childName: 'dashboard-sim', + childId: 'dashboard-sim', + }; + + if (!Number.isFinite(value)) { + this.node.warn('simulateMeasurement payload.value must be a finite number'); + break; + } + + switch (type) { + case 'pressure': + m.updateMeasuredPressure(value, position, context); + break; + case 'flow': + m.updateMeasuredFlow(value, position, context); + break; + case 'temperature': + m.updateMeasuredTemperature(value, position, context); + break; + default: + this.node.warn(`Unsupported simulateMeasurement type: ${type}`); + } + } + break; case 'showWorkingCurves': m.showWorkingCurves(); send({ topic : "Showing curve" , payload: m.showWorkingCurves() }); diff --git a/src/specificClass.js b/src/specificClass.js index 146ffb8..0b60561 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -96,10 +96,15 @@ class Machine { this.measurements.type('temperature').variant('measured').position('atEquipment').value(15).unit('C'); //assume standard atm pressure is at sea level this.measurements.type('atmPressure').variant('measured').position('atEquipment').value(101325).unit('Pa'); - //populate min and max + //populate min and max when curve data is available const flowunit = this.config.general.unit; - 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); + 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); + } 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); + } } _updateState(){ @@ -705,7 +710,14 @@ _callMeasurementHandler(measurementType, value, position, context) { const atmPressure = this.measurements.type('atmPressure').variant('measured').position('atEquipment').getCurrentValue('Pa'); console.log(`--------------------calc efficiency : Pressure diff:${pressureDiff},${temp}, ${g} `); - const rho = coolprop.PropsSI('D', 'T', temp, 'P', atmPressure, 'WasteWater'); + let rho = null; + try { + rho = coolprop.PropsSI('D', 'T', temp, 'P', atmPressure, 'WasteWater'); + } catch (error) { + // coolprop can throw transient initialization errors; keep machine calculations running. + this.logger.warn(`CoolProp density lookup failed: ${error.message}. Using fallback density.`); + rho = 1000; // kg/m3 fallback for water-like fluids + } this.logger.debug(`temp: ${temp} atmPressure : ${atmPressure} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`); diff --git a/test/basic/constructor.basic.test.js b/test/basic/constructor.basic.test.js new file mode 100644 index 0000000..04aa63a --- /dev/null +++ b/test/basic/constructor.basic.test.js @@ -0,0 +1,31 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Machine = require('../../src/specificClass'); +const { makeMachineConfig, makeStateConfig } = require('../helpers/factories'); + +test('constructor initializes with valid curve model', () => { + const machine = new Machine(makeMachineConfig(), makeStateConfig()); + assert.equal(machine.hasCurve, true); + assert.ok(machine.predictFlow); + assert.ok(machine.predictPower); + assert.ok(machine.predictCtrl); + + const out = machine.getOutput(); + assert.ok('state' in out); + assert.ok('mode' in out); + assert.ok('ctrl' in out); +}); + +test('constructor handles missing curve model without throwing', () => { + const cfg = makeMachineConfig({ asset: { supplier: 'x', category: 'machine', type: 'pump', model: 'not-existing-model', unit: 'm3/h' } }); + const machine = new Machine(cfg, makeStateConfig()); + + assert.equal(machine.hasCurve, false); + assert.equal(machine.predictFlow, null); + assert.equal(machine.predictPower, null); + assert.equal(machine.predictCtrl, null); + + const out = machine.getOutput(); + assert.ok('state' in out); +}); diff --git a/test/basic/mode-and-input.basic.test.js b/test/basic/mode-and-input.basic.test.js new file mode 100644 index 0000000..5ceddc3 --- /dev/null +++ b/test/basic/mode-and-input.basic.test.js @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Machine = require('../../src/specificClass'); +const { makeMachineConfig, makeStateConfig } = require('../helpers/factories'); + +test('setMode changes mode only for allowed values', () => { + const machine = new Machine(makeMachineConfig(), makeStateConfig()); + const original = machine.currentMode; + + machine.setMode('virtualControl'); + assert.equal(machine.currentMode, 'virtualControl'); + + machine.setMode('invalid-mode'); + assert.equal(machine.currentMode, 'virtualControl'); + assert.notEqual(machine.currentMode, original); +}); + +test('handleInput rejects non-string action safely', async () => { + const machine = new Machine(makeMachineConfig(), makeStateConfig()); + await assert.doesNotReject(async () => { + await machine.handleInput('GUI', 123, null); + }); +}); + +test('handleInput ignores disallowed source/action combination', async () => { + const machine = new Machine(makeMachineConfig(), makeStateConfig()); + machine.setMode('fysicalControl'); + + const before = machine.state.getCurrentState(); + await machine.handleInput('GUI', 'execSequence', 'startup'); + const after = machine.state.getCurrentState(); + + assert.equal(before, after); +}); diff --git a/test/edge/error-paths.edge.test.js b/test/edge/error-paths.edge.test.js new file mode 100644 index 0000000..e21d0eb --- /dev/null +++ b/test/edge/error-paths.edge.test.js @@ -0,0 +1,31 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Machine = require('../../src/specificClass'); +const NodeClass = require('../../src/nodeClass'); +const { makeMachineConfig, makeStateConfig, makeNodeStub } = require('../helpers/factories'); + +test('setpoint rejects negative inputs without throwing', async () => { + const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } })); + await assert.doesNotReject(async () => { + await machine.setpoint(-1); + }); +}); + +test('nodeClass _updateNodeStatus returns error status on internal failure', () => { + const inst = Object.create(NodeClass.prototype); + const node = makeNodeStub(); + inst.node = node; + inst.source = { + currentMode: 'auto', + state: { + getCurrentState() { + throw new Error('boom'); + }, + }, + }; + + const status = inst._updateNodeStatus(); + assert.equal(status.text, 'Status Error'); + assert.equal(node._errors.length, 1); +}); diff --git a/test/edge/nodeClass-routing.edge.test.js b/test/edge/nodeClass-routing.edge.test.js new file mode 100644 index 0000000..9ec3274 --- /dev/null +++ b/test/edge/nodeClass-routing.edge.test.js @@ -0,0 +1,60 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const NodeClass = require('../../src/nodeClass'); +const { makeNodeStub, makeREDStub } = require('../helpers/factories'); + +test('input handler routes topics to source methods', () => { + const inst = Object.create(NodeClass.prototype); + const node = makeNodeStub(); + + const calls = []; + inst.node = node; + inst.RED = makeREDStub({ + child1: { + source: { id: 'child-source' }, + }, + }); + + inst.source = { + childRegistrationUtils: { + registerChild(childSource, pos) { + calls.push(['registerChild', childSource, pos]); + }, + }, + setMode(mode) { + calls.push(['setMode', mode]); + }, + handleInput(source, action, parameter) { + calls.push(['handleInput', source, action, parameter]); + }, + showWorkingCurves() { + return { ok: true }; + }, + showCoG() { + return { cog: 1 }; + }, + updateMeasuredPressure(value, position) { + calls.push(['updateMeasuredPressure', value, position]); + }, + updateMeasuredFlow(value, position) { + calls.push(['updateMeasuredFlow', value, position]); + }, + updateMeasuredTemperature(value, position) { + calls.push(['updateMeasuredTemperature', value, position]); + }, + }; + + inst._attachInputHandler(); + const onInput = node._handlers.input; + + onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {}); + onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } }, () => {}, () => {}); + onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {}); + onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {}); + + 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], ['updateMeasuredPressure', 250, 'upstream']); +}); diff --git a/test/helpers/factories.js b/test/helpers/factories.js new file mode 100644 index 0000000..c78aa53 --- /dev/null +++ b/test/helpers/factories.js @@ -0,0 +1,110 @@ +const { MeasurementContainer } = require('generalFunctions'); + +function makeMachineConfig(overrides = {}) { + return { + general: { + id: 'rm-test-1', + name: 'rotating-machine-test', + unit: 'm3/h', + logging: { enabled: false, logLevel: 'error' }, + }, + functionality: { + positionVsParent: 'atEquipment', + }, + asset: { + supplier: 'hidrostal', + category: 'machine', + type: 'pump', + model: 'hidrostal-H05K-S03R', + unit: 'm3/h', + }, + ...overrides, + }; +} + +function makeStateConfig(overrides = {}) { + return { + general: { + logging: { enabled: false, logLevel: 'error' }, + }, + state: { + current: 'idle', + }, + movement: { + mode: 'staticspeed', + speed: 1000, + maxSpeed: 1000, + interval: 10, + }, + time: { + starting: 0, + warmingup: 0, + stopping: 0, + coolingdown: 0, + }, + ...overrides, + }; +} + +function makeChildMeasurement({ id = 'child-1', name = 'PT-1', positionVsParent = 'downstream', type = 'pressure', unit = 'mbar' } = {}) { + const measurements = new MeasurementContainer({ + autoConvert: true, + defaultUnits: { + pressure: 'mbar', + flow: 'm3/h', + temperature: 'C', + power: 'kW', + }, + }); + + return { + config: { + general: { id, name }, + functionality: { positionVsParent }, + asset: { type, unit }, + }, + measurements, + }; +} + +function makeNodeStub() { + const handlers = {}; + const sent = []; + const statuses = []; + const errors = []; + const warns = []; + return { + id: 'node-1', + source: null, + send(msg) { sent.push(msg); }, + status(s) { statuses.push(s); }, + error(e) { errors.push(e); }, + warn(w) { warns.push(w); }, + on(event, cb) { handlers[event] = cb; }, + _handlers: handlers, + _sent: sent, + _statuses: statuses, + _errors: errors, + _warns: warns, + }; +} + +function makeREDStub(nodeMap = {}) { + return { + nodes: { + getNode(id) { + return nodeMap[id] || null; + }, + createNode() {}, + registerType() {}, + }, + }; +} + +module.exports = { + makeMachineConfig, + makeStateConfig, + makeChildMeasurement, + makeNodeStub, + makeREDStub, +}; diff --git a/test/integration/coolprop.integration.test.js b/test/integration/coolprop.integration.test.js new file mode 100644 index 0000000..c1b24cb --- /dev/null +++ b/test/integration/coolprop.integration.test.js @@ -0,0 +1,22 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Machine = require('../../src/specificClass'); +const { makeMachineConfig, makeStateConfig } = require('../helpers/factories'); + +test('calcEfficiency runs through coolprop path without mocks', () => { + const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } })); + + machine.measurements.type('pressure').variant('measured').position('downstream').value(1200, Date.now(), 'mbar'); + machine.measurements.type('pressure').variant('measured').position('upstream').value(800, Date.now(), 'mbar'); + machine.measurements.type('flow').variant('predicted').position('atEquipment').value(120, Date.now(), 'm3/h'); + machine.measurements.type('power').variant('predicted').position('atEquipment').value(12, Date.now(), 'kW'); + + assert.doesNotThrow(() => { + machine.calcEfficiency(12, 120, 'predicted'); + }); + + const eff = machine.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue(); + assert.equal(typeof eff, 'number'); + assert.ok(eff > 0); +}); diff --git a/test/integration/registration.integration.test.js b/test/integration/registration.integration.test.js new file mode 100644 index 0000000..db46b11 --- /dev/null +++ b/test/integration/registration.integration.test.js @@ -0,0 +1,27 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Machine = require('../../src/specificClass'); +const { makeMachineConfig, makeStateConfig, makeChildMeasurement } = require('../helpers/factories'); + +test('registerChild listens to measurement events and stores measured pressure', async () => { + const machine = new Machine(makeMachineConfig(), makeStateConfig()); + const child = makeChildMeasurement({ positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' }); + + machine.registerChild(child, 'measurement'); + + child.measurements + .type('pressure') + .variant('measured') + .position('downstream') + .value(123, Date.now(), 'mbar'); + + const stored = machine.measurements + .type('pressure') + .variant('measured') + .position('downstream') + .getCurrentValue('mbar'); + + assert.equal(typeof stored, 'number'); + assert.equal(Math.round(stored), 123); +}); diff --git a/test/integration/sequences.integration.test.js b/test/integration/sequences.integration.test.js new file mode 100644 index 0000000..ca52936 --- /dev/null +++ b/test/integration/sequences.integration.test.js @@ -0,0 +1,22 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Machine = require('../../src/specificClass'); +const { makeMachineConfig, makeStateConfig } = require('../helpers/factories'); + +test('execSequence startup reaches operational with zero transition times', async () => { + const machine = new Machine(makeMachineConfig(), makeStateConfig()); + + await machine.handleInput('parent', 'execSequence', 'startup'); + + assert.equal(machine.state.getCurrentState(), 'operational'); +}); + +test('execMovement updates controller position in operational state', async () => { + const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } })); + + await machine.handleInput('parent', 'execMovement', 10); + + const pos = machine.state.getCurrentPosition(); + assert.ok(pos >= 9.9 && pos <= 10); +});