Compare commits

...

4 Commits

Author SHA1 Message Date
znetsixe
33f3c2ef61 update 2026-02-23 13:17:18 +01:00
znetsixe
b5137ba9c2 before functional changes by Codex 2026-02-19 17:36:44 +01:00
znetsixe
405be33626 updates to rotating machine struct 2026-02-12 10:48:44 +01:00
znetsixe
c63701db38 updates 2026-01-29 13:32:39 +01:00
17 changed files with 3063 additions and 184 deletions

49
examples/README.md Normal file
View File

@@ -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.

1016
examples/basic.flow.json Normal file

File diff suppressed because it is too large Load Diff

327
examples/edge.flow.json Normal file
View File

@@ -0,0 +1,327 @@
[
{
"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",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time",
"yAxisLabel": "state",
"removeOlder": "1",
"removeOlderUnit": "3600",
"x": 1230,
"y": 300,
"wires": []
}
]

View File

@@ -0,0 +1,759 @@
[
{
"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": "14px",
"groupGap": "14px",
"groupBorderRadius": "6px",
"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": "24",
"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 ctrl: 0,\n nCog: 0,\n stateCode: 0,\n state: 'idle',\n mode: 'auto',\n runtime: 0,\n moveTimeleft: 0,\n maintenanceTime: 0,\n pressureUp: null,\n pressureDown: null,\n};\n\nconst pickNumber = (...keys) => {\n for (const key of keys) {\n const value = Number(merged[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\n\nconst pickByPrefix = (...prefixes) => {\n const keys = Object.keys(merged);\n for (const prefix of prefixes) {\n const direct = Number(merged[prefix]);\n if (Number.isFinite(direct)) return direct;\n\n const dynamicKey = keys.find((k) => k === prefix || k.startsWith(prefix + '.'));\n if (!dynamicKey) continue;\n\n const value = Number(merged[dynamicKey]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\n\nconst pickString = (key, fallback = null) => {\n const value = merged[key];\n if (value === undefined || value === null || value === '') return fallback;\n return String(value);\n};\n\nconst flowValue = pickByPrefix('flow.predicted.downstream');\nconst power = pickByPrefix('power.predicted.atequipment', 'power.predicted.atEquipment');\nconst ctrl = pickNumber('ctrl') ?? pickByPrefix('ctrl.predicted.atequipment', 'ctrl.predicted.atEquipment');\nconst nCog = pickNumber('NCogPercent', 'NCog');\nconst runtime = pickNumber('runtime');\nconst moveTimeleft = pickNumber('moveTimeleft');\nconst maintenanceTime = pickNumber('maintenanceTime');\nconst pressureDownIncoming = pickByPrefix('pressure.measured.downstream');\nconst pressureUpIncoming = pickByPrefix('pressure.measured.upstream');\nconst state = pickString('state', cache.state);\nconst mode = pickString('mode', cache.mode);\n\nconst stateCodeMap = { off: 0, idle: 1, starting: 2, warmingup: 3, operational: 4, accelerating: 5, decelerating: 6, stopping: 7, coolingdown: 8, maintenance: 9 };\nconst stateCode = stateCodeMap[state] ?? cache.stateCode;\n\nif (flowValue !== null) cache.flow = flowValue;\nif (power !== null) cache.power = power;\nif (ctrl !== null) cache.ctrl = ctrl;\nif (nCog !== null) cache.nCog = nCog;\nif (runtime !== null) cache.runtime = runtime;\nif (moveTimeleft !== null) cache.moveTimeleft = moveTimeleft;\nif (maintenanceTime !== null) cache.maintenanceTime = maintenanceTime;\nif (pressureUpIncoming !== null) cache.pressureUp = pressureUpIncoming;\nif (pressureDownIncoming !== null) cache.pressureDown = pressureDownIncoming;\ncache.state = state;\ncache.mode = mode;\ncache.stateCode = stateCode;\ncontext.set('metricCache', cache);\n\nconst pressureUp = Number.isFinite(cache.pressureUp) ? cache.pressureUp : null;\nconst pressureDown = Number.isFinite(cache.pressureDown) ? cache.pressureDown : null;\nconst pressureDelta = (pressureDown !== null && pressureUp !== null) ? (pressureDown - pressureUp) : null;\n\nconst now = Date.now();\nreturn [\n { topic: 'actual_flow', payload: cache.flow, timestamp: now },\n { topic: 'predicted_power', payload: cache.power, timestamp: now },\n { topic: 'nCog', payload: cache.nCog, timestamp: now },\n { topic: 'actual_ctrl', payload: cache.ctrl, timestamp: now },\n { topic: 'stateCode', payload: cache.stateCode, timestamp: now },\n { payload: JSON.stringify({ state: cache.state, mode: cache.mode, ctrl: cache.ctrl, runtime: cache.runtime, moveTimeleft: cache.moveTimeleft, maintenanceTime: cache.maintenanceTime, pressureUp, pressureDown, pressureDelta }) }\n];",
"outputs": 6,
"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": 6,
"height": 4,
"chartType": "line",
"xAxisType": "time",
"yAxisLabel": "m3/h",
"removeOlder": "1",
"removeOlderUnit": "3600",
"x": 1510,
"y": 280,
"wires": [],
"showLegend": true,
"category": "topic",
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "rm_int_chart_power",
"type": "ui-chart",
"z": "12f41a7b538c40db",
"group": "ui_group_rm_int_vis",
"name": "Power",
"label": "Power (kW)",
"order": 2,
"width": 6,
"height": 4,
"chartType": "line",
"xAxisType": "time",
"yAxisLabel": "kW",
"removeOlder": "1",
"removeOlderUnit": "3600",
"x": 1510,
"y": 340,
"wires": [],
"showLegend": true,
"category": "topic",
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "rm_int_chart_nCog",
"type": "ui-chart",
"z": "12f41a7b538c40db",
"group": "ui_group_rm_int_vis",
"name": "NCog",
"label": "NCog (%)",
"order": 3,
"width": 6,
"height": 4,
"chartType": "line",
"xAxisType": "time",
"yAxisLabel": "%",
"removeOlder": "1",
"removeOlderUnit": "3600",
"x": 1510,
"y": 400,
"wires": [],
"showLegend": true,
"category": "topic",
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "rm_int_state_text",
"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": 6,
"height": 4,
"chartType": "line",
"xAxisType": "time",
"yAxisLabel": "%",
"removeOlder": "1",
"removeOlderUnit": "3600",
"x": 1510,
"y": 460,
"wires": [],
"showLegend": true,
"category": "topic",
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "rm_int_chart_statecode",
"type": "ui-chart",
"z": "12f41a7b538c40db",
"group": "ui_group_rm_int_vis",
"name": "State Code",
"label": "State Code (off=0 .. maint=9)",
"order": 5,
"width": 6,
"height": 4,
"chartType": "line",
"xAxisType": "time",
"yAxisLabel": "state",
"removeOlder": "1",
"removeOlderUnit": "3600",
"x": 1510,
"y": 520,
"wires": [],
"showLegend": true,
"category": "topic",
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "rm_int_ctrl_setpoint_for_chart",
"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"
]
]
}
]

View File

@@ -4,7 +4,7 @@
"description": "Control module rotatingMachine", "description": "Control module rotatingMachine",
"main": "rotatingMachine.js", "main": "rotatingMachine.js",
"scripts": { "scripts": {
"test": "node rotatingMachine.js" "test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -21,6 +21,7 @@ class nodeClass {
this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED
this.source = null; // Will hold the specific class instance this.source = null; // Will hold the specific class instance
this.config = null; // Will hold the merged configuration this.config = null; // Will hold the merged configuration
this._pressureInitWarned = false;
// Load default & UI config // Load default & UI config
this._loadConfig(uiConfig,this.node); this._loadConfig(uiConfig,this.node);
@@ -76,8 +77,6 @@ class nodeClass {
_setupSpecificClass(uiConfig) { _setupSpecificClass(uiConfig) {
const machineConfig = this.config; const machineConfig = this.config;
console.log(`----------------> Loaded movementMode in nodeClass: ${uiConfig.movementMode}`);
// need extra state for this // need extra state for this
const stateConfig = { const stateConfig = {
general: { general: {
@@ -117,8 +116,24 @@ class nodeClass {
try { try {
const mode = m.currentMode; const mode = m.currentMode;
const state = m.state.getCurrentState(); const state = m.state.getCurrentState();
const requiresPressurePrediction = ["operational", "warmingup", "accelerating", "decelerating"].includes(state);
const pressureStatus = typeof m.getPressureInitializationStatus === "function"
? m.getPressureInitializationStatus()
: { initialized: true };
if (requiresPressurePrediction && !pressureStatus.initialized) {
if (!this._pressureInitWarned) {
this.node.warn("Pressure input is not initialized (upstream/downstream missing). Predictions are using minimum pressure.");
this._pressureInitWarned = true;
}
return { fill: "yellow", shape: "ring", text: `${mode}: pressure not initialized` };
}
if (pressureStatus.initialized) {
this._pressureInitWarned = false;
}
const flow = Math.round(m.measurements.type("flow").variant("predicted").position('downstream').getCurrentValue('m3/h')); 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; let symbolState;
switch(state){ switch(state){
case "off": case "off":
@@ -189,7 +204,7 @@ class nodeClass {
} }
return status; return status;
} catch (error) { } 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" }; return { fill: "red", shape: "ring", text: "Status Error" };
} }
} }
@@ -234,7 +249,7 @@ class nodeClass {
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb'); const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
// Send only updated outputs on ports 0 & 1 // Send only updated outputs on ports 0 & 1
this.node.send([processMsg, influxMsg]); this.node.send([processMsg, influxMsg, null]);
} }
/** /**
@@ -244,11 +259,18 @@ class nodeClass {
this.node.on('input', (msg, send, done) => { this.node.on('input', (msg, send, done) => {
/* Update to complete event based node by putting the tick function after an input event */ /* Update to complete event based node by putting the tick function after an input event */
const m = this.source; const m = this.source;
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
try {
switch(msg.topic) { switch(msg.topic) {
case 'registerChild': case 'registerChild':
// Register this node as a child of the parent node // Register this node as a child of the parent node
const childId = msg.payload; const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId); const childObj = this.RED.nodes.getNode(childId);
if (!childObj || !childObj.source) {
this.node.warn(`registerChild failed: child '${childId}' not found or has no source`);
break;
}
m.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent); m.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
break; break;
case 'setMode': case 'setMode':
@@ -271,15 +293,59 @@ class nodeClass {
const { source: esSource, action: esAction } = msg.payload; const { source: esSource, action: esAction } = msg.payload;
m.handleInput(esSource, esAction); m.handleInput(esSource, esAction);
break; break;
case 'simulateMeasurement':
{
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':
if (typeof m.updateSimulatedMeasurement === "function") {
m.updateSimulatedMeasurement(type, position, value, context);
} else {
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': case 'showWorkingCurves':
m.showWorkingCurves(); nodeSend([{ ...msg, topic : "showWorkingCurves" , payload: m.showWorkingCurves() }, null, null]);
send({ topic : "Showing curve" , payload: m.showWorkingCurves() });
break; break;
case 'CoG': case 'CoG':
m.showCoG(); nodeSend([{ ...msg, topic : "showCoG" , payload: m.showCoG() }, null, null]);
send({ topic : "Showing CoG" , payload: m.showCoG() });
break; break;
} }
if (typeof done === 'function') done();
} catch (error) {
if (typeof done === 'function') {
done(error);
} else {
this.node.error(error, msg);
}
}
}); });
} }
@@ -290,7 +356,7 @@ class nodeClass {
this.node.on('close', (done) => { this.node.on('close', (done) => {
clearInterval(this._tickInterval); clearInterval(this._tickInterval);
clearInterval(this._statusInterval); clearInterval(this._statusInterval);
done(); if (typeof done === 'function') done();
}); });
} }
} }

View File

@@ -1,6 +1,11 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const {loadCurve,gravity,logger,configUtils,configManager,state, nrmse, MeasurementContainer, predict, interpolation , childRegistrationUtils,coolprop} = require('generalFunctions'); const {loadCurve,gravity,logger,configUtils,configManager,state, nrmse, MeasurementContainer, predict, interpolation , childRegistrationUtils,coolprop} = require('generalFunctions');
/**
* Rotating machine domain model.
* Combines machine curves, state transitions and measurement reconciliation
* to produce flow/power/efficiency behavior for pumps and similar assets.
*/
class Machine { class Machine {
/*------------------- Construct and set vars -------------------*/ /*------------------- Construct and set vars -------------------*/
@@ -87,19 +92,77 @@ class Machine {
this.child = {}; // object to hold child information so we know on what to subscribe this.child = {}; // object to hold child information so we know on what to subscribe
this.childRegistrationUtils = new childRegistrationUtils(this); // Child registration utility this.childRegistrationUtils = new childRegistrationUtils(this); // Child registration utility
this.virtualPressureChildIds = {
upstream: "dashboard-sim-upstream",
downstream: "dashboard-sim-downstream",
};
this.virtualPressureChildren = {};
this.realPressureChildIds = {
upstream: new Set(),
downstream: new Set(),
};
this._initVirtualPressureChildren();
} }
_initVirtualPressureChildren() {
const createVirtualChild = (position) => {
const id = this.virtualPressureChildIds[position];
const name = `dashboard-sim-${position}`;
const measurements = new MeasurementContainer({
autoConvert: true,
defaultUnits: {
pressure: "mbar",
flow: this.config.general.unit,
power: "kW",
temperature: "C",
},
});
measurements.setChildId(id);
measurements.setChildName(name);
measurements.setParentRef(this);
return {
config: {
general: { id, name },
functionality: {
softwareType: "measurement",
positionVsParent: position,
},
asset: {
type: "pressure",
unit: "mbar",
},
},
measurements,
};
};
const upstreamChild = createVirtualChild("upstream");
const downstreamChild = createVirtualChild("downstream");
this.virtualPressureChildren.upstream = upstreamChild;
this.virtualPressureChildren.downstream = downstreamChild;
this.registerChild(upstreamChild, "measurement");
this.registerChild(downstreamChild, "measurement");
}
_init(){ _init(){
//assume standard temperature is 20degrees //assume standard temperature is 20degrees
this.measurements.type('temperature').variant('measured').position('atEquipment').value(15).unit('C'); this.measurements.type('temperature').variant('measured').position('atEquipment').value(15).unit('C');
//assume standard atm pressure is at sea level //assume standard atm pressure is at sea level
this.measurements.type('atmPressure').variant('measured').position('atEquipment').value(101325).unit('Pa'); this.measurements.type('atmPressure').variant('measured').position('atEquipment').value(101325).unit('Pa');
//populate min and max //populate min and max when curve data is available
const flowunit = this.config.general.unit; const flowunit = this.config.general.unit;
this.measurements.type('flow').variant('predicted').position('max').value(this.predictFlow.currentFxyYMax, Date.now() , flowunit) 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).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(){ _updateState(){
@@ -113,13 +176,19 @@ class Machine {
/*------------------- Register child events -------------------*/ /*------------------- Register child events -------------------*/
registerChild(child, softwareType) { registerChild(child, softwareType) {
this.logger.debug('Setting up child event for softwaretype ' + softwareType); const resolvedSoftwareType = softwareType || child?.config?.functionality?.softwareType || "measurement";
this.logger.debug('Setting up child event for softwaretype ' + resolvedSoftwareType);
if(softwareType === "measurement"){ if(resolvedSoftwareType === "measurement"){
const position = child.config.functionality.positionVsParent; const position = String(child.config.functionality.positionVsParent || "atEquipment").toLowerCase();
const distance = child.config.functionality.distanceVsParent || 0;
const measurementType = child.config.asset.type; const measurementType = child.config.asset.type;
const key = `${measurementType}_${position}`; const childId = child.config?.general?.id || `${measurementType}-${position}-unknown`;
const isVirtualPressureChild = Object.values(this.virtualPressureChildIds).includes(childId);
if (measurementType === "pressure" && !isVirtualPressureChild) {
this.realPressureChildIds[position]?.add(childId);
}
//rebuild to measurementype.variant no position and then switch based on values not strings or names. //rebuild to measurementype.variant no position and then switch based on values not strings or names.
const eventName = `${measurementType}.measured.${position}`; const eventName = `${measurementType}.measured.${position}`;
@@ -135,6 +204,7 @@ class Machine {
.type(measurementType) .type(measurementType)
.variant("measured") .variant("measured")
.position(position) .position(position)
.child(childId)
.value(eventData.value, eventData.timestamp, eventData.unit); .value(eventData.value, eventData.timestamp, eventData.unit);
// Call the appropriate handler // Call the appropriate handler
@@ -434,14 +504,16 @@ _callMeasurementHandler(measurementType, value, position, context) {
return 0; return 0;
} }
const pressureDiff = this.measurements.type('pressure').variant('measured').difference(); const upstreamPressure = this._getPreferredPressureValue("upstream");
const downstreamPressure = this._getPreferredPressureValue("downstream");
// Both upstream & downstream => differential // Both upstream & downstream => differential
if (pressureDiff) { if (upstreamPressure != null && downstreamPressure != null) {
this.logger.debug(`Pressure differential: ${pressureDiff.value}`); const pressureDiffValue = downstreamPressure - upstreamPressure;
this.predictFlow.fDimension = pressureDiff.value; this.logger.debug(`Pressure differential: ${pressureDiffValue}`);
this.predictPower.fDimension = pressureDiff.value; this.predictFlow.fDimension = pressureDiffValue;
this.predictCtrl.fDimension = pressureDiff.value; this.predictPower.fDimension = pressureDiffValue;
this.predictCtrl.fDimension = pressureDiffValue;
//update the cog //update the cog
const { cog, minEfficiency } = this.calcCog(); const { cog, minEfficiency } = this.calcCog();
// calc efficiency // calc efficiency
@@ -449,12 +521,9 @@ _callMeasurementHandler(measurementType, value, position, context) {
//update the distance from peak //update the distance from peak
this.calcDistanceBEP(efficiency,cog,minEfficiency); this.calcDistanceBEP(efficiency,cog,minEfficiency);
return pressureDiff.value; return pressureDiffValue;
} }
// get downstream
const downstreamPressure = this.measurements.type('pressure').variant('measured').position('downstream').getCurrentValue();
// Only downstream => use it, warn that it's partial // Only downstream => use it, warn that it's partial
if (downstreamPressure != null) { if (downstreamPressure != null) {
this.logger.warn(`Using downstream pressure only for prediction: ${downstreamPressure} This is less acurate!!`); this.logger.warn(`Using downstream pressure only for prediction: ${downstreamPressure} This is less acurate!!`);
@@ -470,6 +539,21 @@ _callMeasurementHandler(measurementType, value, position, context) {
return downstreamPressure; return downstreamPressure;
} }
// Only upstream => use it, warn that it's partial
if (upstreamPressure != null) {
this.logger.warn(`Using upstream pressure only for prediction: ${upstreamPressure} This is less acurate!!`);
this.predictFlow.fDimension = upstreamPressure;
this.predictPower.fDimension = upstreamPressure;
this.predictCtrl.fDimension = upstreamPressure;
//update the cog
const { cog, minEfficiency } = this.calcCog();
// calc efficiency
const efficiency = this.calcEfficiency(this.predictPower.outputY, this.predictFlow.outputY, "predicted");
//update the distance from peak
this.calcDistanceBEP(efficiency,cog,minEfficiency);
return upstreamPressure;
}
this.logger.error(`No valid pressure measurements available to calculate prediction using last known pressure`); this.logger.error(`No valid pressure measurements available to calculate prediction using last known pressure`);
//set default at 0 => lowest pressure possible //set default at 0 => lowest pressure possible
@@ -488,6 +572,80 @@ _callMeasurementHandler(measurementType, value, position, context) {
return 0; return 0;
} }
_getPreferredPressureValue(position) {
const realIds = Array.from(this.realPressureChildIds[position] || []);
for (const childId of realIds) {
const value = this.measurements
.type("pressure")
.variant("measured")
.position(position)
.child(childId)
.getCurrentValue();
if (value != null) return value;
}
const virtualId = this.virtualPressureChildIds[position];
if (virtualId) {
const simulatedValue = this.measurements
.type("pressure")
.variant("measured")
.position(position)
.child(virtualId)
.getCurrentValue();
if (simulatedValue != null) return simulatedValue;
}
return this.measurements
.type("pressure")
.variant("measured")
.position(position)
.getCurrentValue();
}
getPressureInitializationStatus() {
const upstreamPressure = this._getPreferredPressureValue("upstream");
const downstreamPressure = this._getPreferredPressureValue("downstream");
const hasUpstream = upstreamPressure != null;
const hasDownstream = downstreamPressure != null;
const hasDifferential = hasUpstream && hasDownstream;
return {
hasUpstream,
hasDownstream,
hasDifferential,
initialized: hasUpstream || hasDownstream || hasDifferential,
source: hasDifferential ? 'differential' : hasDownstream ? 'downstream' : hasUpstream ? 'upstream' : null,
};
}
updateSimulatedMeasurement(type, position, value, context = {}) {
const normalizedType = String(type || "").toLowerCase();
const normalizedPosition = String(position || "atEquipment").toLowerCase();
if (normalizedType !== "pressure") {
this._callMeasurementHandler(normalizedType, value, normalizedPosition, context);
return;
}
if (!this.virtualPressureChildIds[normalizedPosition]) {
this.logger.warn(`Unsupported simulated pressure position '${normalizedPosition}'`);
return;
}
const child = this.virtualPressureChildren[normalizedPosition];
if (!child?.measurements) {
this.logger.error(`Virtual pressure child '${normalizedPosition}' is missing`);
return;
}
child.measurements
.type("pressure")
.variant("measured")
.position(normalizedPosition)
.value(value, context.timestamp || Date.now(), context.unit || "mbar");
}
handleMeasuredFlow() { handleMeasuredFlow() {
const flowDiff = this.measurements.type('flow').variant('measured').difference(); const flowDiff = this.measurements.type('flow').variant('measured').difference();
@@ -542,6 +700,10 @@ _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'})`);
}
// context handler for pressure updates // context handler for pressure updates
updateMeasuredPressure(value, position, context = {}) { updateMeasuredPressure(value, position, context = {}) {
@@ -579,8 +741,9 @@ _callMeasurementHandler(measurementType, value, position, context) {
// Helper method for operational state check // Helper method for operational state check
_isOperationalState() { _isOperationalState() {
const state = this.state.getCurrentState(); const state = this.state.getCurrentState();
this.logger.debug(`Checking operational state ${this.state.getCurrentState()} ? ${["operational", "accelerating", "decelerating"].includes(state)}`); const activeStates = ["operational", "warmingup", "accelerating", "decelerating"];
return ["operational", "accelerating", "decelerating"].includes(state); this.logger.debug(`Checking operational state ${this.state.getCurrentState()} ? ${activeStates.includes(state)}`);
return activeStates.includes(state);
} }
//what is the internal functions that need updating when something changes that has influence on this. //what is the internal functions that need updating when something changes that has influence on this.
@@ -695,13 +858,23 @@ _callMeasurementHandler(measurementType, value, position, context) {
calcEfficiency(power,flow,variant) { calcEfficiency(power,flow,variant) {
const pressureDiff = this.measurements.type('pressure').variant('measured').difference('Pa'); // Request a pressure differential explicitly in Pascal for hydraulic efficiency.
const pressureDiff = this.measurements
.type('pressure')
.variant('measured')
.difference({ unit: 'Pa' });
const g = gravity.getStandardGravity(); const g = gravity.getStandardGravity();
const temp = this.measurements.type('temperature').variant('measured').position('atEquipment').getCurrentValue('K'); const temp = this.measurements.type('temperature').variant('measured').position('atEquipment').getCurrentValue('K');
const atmPressure = this.measurements.type('atmPressure').variant('measured').position('atEquipment').getCurrentValue('Pa'); const atmPressure = this.measurements.type('atmPressure').variant('measured').position('atEquipment').getCurrentValue('Pa');
console.log(`--------------------calc efficiency : Pressure diff:${pressureDiff},${temp}, ${g} `); let rho = null;
const rho = coolprop.PropsSI('D', 'T', temp, 'P', atmPressure, 'WasteWater'); 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}`); this.logger.debug(`temp: ${temp} atmPressure : ${atmPressure} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`);
@@ -806,150 +979,3 @@ _callMeasurementHandler(measurementType, value, position, context) {
module.exports = Machine; module.exports = Machine;
/*------------------- Testing -------------------*/
/*
curve = require('C:/Users/zn375/.node-red/public/fallbackData.json');
//import a child
const Child = require('../../measurement/src/specificClass');
console.log(`Creating child...`);
const PT1 = new Child(config={
general:{
name:"PT1",
logging:{
enabled:true,
logLevel:"debug",
},
},
functionality:{
softwareType:"measurement",
positionVsParent:"upstream",
},
asset:{
supplier:"Vega",
category:"sensor",
type:"pressure",
model:"Vegabar 82",
unit: "mbar"
},
});
const PT2 = new Child(config={
general:{
name:"PT2",
logging:{
enabled:true,
logLevel:"debug",
},
},
functionality:{
softwareType:"measurement",
positionVsParent:"upstream",
},
asset:{
supplier:"Vega",
category:"sensor",
type:"pressure",
model:"Vegabar 82",
unit: "mbar"
},
});
//create a machine
console.log(`Creating machine...`);
const machineConfig = {
general: {
name: "Hydrostal",
logging: {
enabled: true,
logLevel: "debug",
}
},
asset: {
supplier: "Hydrostal",
type: "pump",
category: "centrifugal",
model: "H05K-S03R+HGM1X-X280KO", // Ensure this field is present.
machineCurve: curve["machineCurves"]["Hydrostal"]["H05K-S03R+HGM1X-X280KO"],
}
}
const stateConfig = {
general: {
logging: {
enabled: true,
logLevel: "debug",
},
},
// Your custom config here (or leave empty for defaults)
movement: {
speed: 1,
},
time: {
starting: 2,
warmingup: 3,
stopping: 2,
coolingdown: 3,
},
};
const machine = new Machine(machineConfig, stateConfig);
//machine.logger.info(JSON.stringify(curve["machineCurves"]["Hydrostal"]["H05K-S03R+HGM1X-X280KO"]));
machine.logger.info(`Registering child...`);
machine.childRegistrationUtils.registerChild(PT1, "upstream");
machine.childRegistrationUtils.registerChild(PT2, "downstream");
//feed curve to the machine class
//machine.updateCurve(curve["machineCurves"]["Hydrostal"]["H05K-S03R+HGM1X-X280KO"]);
PT1.logger.info(`Enable sim...`);
PT1.toggleSimulation();
PT2.logger.info(`Enable sim...`);
PT2.toggleSimulation();
machine.getOutput();
//manual test
//machine.handleInput("parent", "execSequence", "startup");
machine.measurements.type("pressure").variant("measured").position('upstream').value(-200);
machine.measurements.type("pressure").variant("measured").position('downstream').value(1000);
testingSequences();
const tickLoop = setInterval(changeInput,1000);
function changeInput(){
PT1.logger.info(`tick...`);
PT1.tick();
PT2.tick();
}
async function testingSequences(){
try{
console.log(` ********** Testing sequence startup... **********`);
await machine.handleInput("parent", "execSequence", "startup");
console.log(` ********** Testing movement to 15... **********`);
await machine.handleInput("parent", "execMovement", 15);
machine.getOutput();
console.log(` ********** Testing sequence shutdown... **********`);
await machine.handleInput("parent", "execSequence", "shutdown");
console.log(`********** Testing moving to setpoint 10... while in idle **********`);
await machine.handleInput("parent", "execMovement", 10);
console.log(` ********** Testing sequence emergencyStop... **********`);
await machine.handleInput("parent", "execSequence", "emergencystop");
console.log(`********** Testing sequence boot... **********`);
await machine.handleInput("parent", "execSequence", "boot");
}catch(error){
console.error(`Error: ${error}`);
}
}
//*/

View File

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

View File

@@ -0,0 +1,44 @@
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);
});
test('warmingup is treated as active for prediction updates', () => {
const machine = new Machine(
makeMachineConfig(),
makeStateConfig({ state: { current: 'warmingup' } })
);
assert.equal(machine._isOperationalState(), true);
});

View File

@@ -0,0 +1,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);
});

View File

@@ -0,0 +1,141 @@
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 };
},
updateSimulatedMeasurement(type, position, value) {
calls.push(['updateSimulatedMeasurement', type, position, value]);
},
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], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
});
test('status shows warning when pressure inputs are not initialized', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
inst.node = node;
inst.source = {
currentMode: 'virtualControl',
state: {
getCurrentState() {
return 'operational';
},
getCurrentPosition() {
return 50;
},
},
getPressureInitializationStatus() {
return { initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false };
},
measurements: {
type() {
return {
variant() {
return {
position() {
return { getCurrentValue() { return 0; } };
},
};
},
};
},
},
};
const status = inst._updateNodeStatus();
const statusAgain = inst._updateNodeStatus();
assert.equal(status.fill, 'yellow');
assert.equal(status.shape, 'ring');
assert.match(status.text, /pressure not initialized/i);
assert.equal(statusAgain.fill, 'yellow');
assert.equal(node._warns.length, 1);
assert.match(String(node._warns[0]), /Pressure input is not initialized/i);
});
test('showWorkingCurves and CoG route reply messages to process output index', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
inst.node = node;
inst.RED = makeREDStub();
inst.source = {
childRegistrationUtils: { registerChild() {} },
setMode() {},
handleInput() {},
showWorkingCurves() {
return { curve: [1, 2, 3] };
},
showCoG() {
return { cog: 0.77 };
},
};
inst._attachInputHandler();
const onInput = node._handlers.input;
const sent = [];
const send = (out) => sent.push(out);
onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
assert.equal(sent.length, 2);
assert.equal(Array.isArray(sent[0]), true);
assert.equal(sent[0].length, 3);
assert.equal(sent[0][0].topic, 'showWorkingCurves');
assert.equal(sent[0][1], null);
assert.equal(sent[0][2], null);
assert.equal(sent[1][0].topic, 'showCoG');
});

110
test/helpers/factories.js Normal file
View File

@@ -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,
};

View File

@@ -0,0 +1,107 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function loadBasicFlow() {
const flowPath = path.join(__dirname, '../../examples/basic.flow.json');
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
}
function makeContextStub() {
const store = {};
return {
get(key) {
return store[key];
},
set(key, value) {
store[key] = value;
},
};
}
test('basic flow parser routes predicted_power to output index 2 with numeric payload', () => {
const flow = loadBasicFlow();
const parser = flow.find((n) => n.id === 'rm_parse_output');
assert.ok(parser, 'rm_parse_output node should exist');
assert.equal(parser.outputs, 11);
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
const msg = {
payload: {
'flow.predicted.downstream.default': 220,
'power.predicted.atequipment.default': 50,
ctrl: 40,
NCogPercent: 72,
state: 'operational',
mode: 'virtualControl',
runtime: 10.2,
moveTimeleft: 0,
maintenanceTime: 150.5,
},
};
const out = func(msg, context, node);
assert.ok(Array.isArray(out));
assert.equal(out.length, 11);
assert.equal(out[1].topic, 'predicted_power');
assert.equal(typeof out[1].payload, 'number');
assert.ok(Number.isFinite(out[1].payload));
assert.equal(out[1].payload, 50);
});
test('basic flow parser output index wiring matches chart nodes', () => {
const flow = loadBasicFlow();
const parser = flow.find((n) => n.id === 'rm_parse_output');
const powerChart = flow.find((n) => n.id === 'rm_chart_power');
assert.ok(parser, 'rm_parse_output node should exist');
assert.ok(powerChart, 'rm_chart_power node should exist');
assert.equal(parser.wires[1][0], 'rm_chart_power');
assert.equal(powerChart.type, 'ui-chart');
assert.equal(powerChart.chartType, 'line');
assert.equal(powerChart.xAxisType, 'time');
});
test('basic flow parser routes pressure series to explicit pressure charts', () => {
const flow = loadBasicFlow();
const parser = flow.find((n) => n.id === 'rm_parse_output');
const upChart = flow.find((n) => n.id === 'rm_chart_pressure_up');
const downChart = flow.find((n) => n.id === 'rm_chart_pressure_down');
const deltaChart = flow.find((n) => n.id === 'rm_chart_pressure_delta');
assert.ok(parser, 'rm_parse_output node should exist');
assert.ok(upChart, 'rm_chart_pressure_up node should exist');
assert.ok(downChart, 'rm_chart_pressure_down node should exist');
assert.ok(deltaChart, 'rm_chart_pressure_delta node should exist');
assert.equal(parser.wires[5][0], 'rm_chart_pressure_up');
assert.equal(parser.wires[6][0], 'rm_chart_pressure_down');
assert.equal(parser.wires[7][0], 'rm_chart_pressure_delta');
});
test('basic flow parser suppresses pressure chart messages when pressure inputs are incomplete', () => {
const flow = loadBasicFlow();
const parser = flow.find((n) => n.id === 'rm_parse_output');
assert.ok(parser, 'rm_parse_output node should exist');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
// Only upstream present: downstream/delta chart outputs should be null
let out = func({ payload: { 'pressure.measured.upstream.default': 950 } }, context, node);
assert.equal(out[5]?.topic, 'pressure_upstream');
assert.equal(out[6], null);
assert.equal(out[7], null);
// Once downstream arrives, delta should be emitted as finite numeric payload
out = func({ payload: { 'pressure.measured.downstream.default': 1200 } }, context, node);
assert.equal(out[6]?.topic, 'pressure_downstream');
assert.equal(out[7]?.topic, 'pressure_delta');
assert.equal(typeof out[7].payload, 'number');
assert.ok(Number.isFinite(out[7].payload));
});

View File

@@ -0,0 +1,39 @@
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);
});
test('predictions use initialized medium pressure and not the minimum-pressure fallback', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
const mediumUpstreamMbar = 700;
const mediumDownstreamMbar = 1100;
machine.updateMeasuredPressure(mediumUpstreamMbar, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'test-pt-up' });
machine.updateMeasuredPressure(mediumDownstreamMbar, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'test-pt-down' });
const pressureStatus = machine.getPressureInitializationStatus();
assert.equal(pressureStatus.initialized, true);
assert.equal(pressureStatus.hasDifferential, true);
const expectedDiff = mediumDownstreamMbar - mediumUpstreamMbar;
assert.equal(Math.round(machine.predictFlow.fDimension), expectedDiff);
assert.ok(machine.predictFlow.fDimension > 0);
});

View File

@@ -0,0 +1,84 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig, makeChildMeasurement } = require('../helpers/factories');
test('pressure initialization combinations are handled explicitly', () => {
const createMachine = () => new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
// nothing
let machine = createMachine();
let status = machine.getPressureInitializationStatus();
assert.equal(status.initialized, false);
assert.equal(status.source, null);
const noPressureValue = machine.getMeasuredPressure();
assert.equal(noPressureValue, 0);
assert.ok(machine.predictFlow.fDimension <= 1);
// upstream only
machine = createMachine();
const upstreamOnly = 850;
machine.measurements.type('pressure').variant('measured').position('upstream').value(upstreamOnly, Date.now(), 'mbar');
status = machine.getPressureInitializationStatus();
assert.equal(status.initialized, true);
assert.equal(status.hasUpstream, true);
assert.equal(status.hasDownstream, false);
assert.equal(status.hasDifferential, false);
assert.equal(status.source, 'upstream');
const upstreamValue = machine.getMeasuredPressure();
assert.equal(Math.round(upstreamValue), upstreamOnly);
assert.equal(Math.round(machine.predictFlow.fDimension), upstreamOnly);
// downstream only
machine = createMachine();
const downstreamOnly = 1150;
machine.measurements.type('pressure').variant('measured').position('downstream').value(downstreamOnly, Date.now(), 'mbar');
status = machine.getPressureInitializationStatus();
assert.equal(status.initialized, true);
assert.equal(status.hasUpstream, false);
assert.equal(status.hasDownstream, true);
assert.equal(status.hasDifferential, false);
assert.equal(status.source, 'downstream');
const downstreamValue = machine.getMeasuredPressure();
assert.equal(Math.round(downstreamValue), downstreamOnly);
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly);
// downstream and upstream
machine = createMachine();
const upstream = 700;
const downstream = 1100;
machine.measurements.type('pressure').variant('measured').position('upstream').value(upstream, Date.now(), 'mbar');
machine.measurements.type('pressure').variant('measured').position('downstream').value(downstream, Date.now(), 'mbar');
status = machine.getPressureInitializationStatus();
assert.equal(status.initialized, true);
assert.equal(status.hasUpstream, true);
assert.equal(status.hasDownstream, true);
assert.equal(status.hasDifferential, true);
assert.equal(status.source, 'differential');
const differentialValue = machine.getMeasuredPressure();
assert.equal(Math.round(differentialValue), downstream - upstream);
assert.equal(Math.round(machine.predictFlow.fDimension), downstream - upstream);
});
test('real pressure child data has priority over simulated dashboard pressure', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.updateSimulatedMeasurement('pressure', 'upstream', 900, { unit: 'mbar', timestamp: Date.now() });
machine.updateSimulatedMeasurement('pressure', 'downstream', 1200, { unit: 'mbar', timestamp: Date.now() });
assert.equal(Math.round(machine.getMeasuredPressure()), 300);
const upstreamChild = makeChildMeasurement({ id: 'pt-up-real', name: 'PT Up', positionVsParent: 'upstream', type: 'pressure', unit: 'mbar' });
const downstreamChild = makeChildMeasurement({ id: 'pt-down-real', name: 'PT Down', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
await machine.childRegistrationUtils.registerChild(upstreamChild, 'upstream');
await machine.childRegistrationUtils.registerChild(downstreamChild, 'downstream');
upstreamChild.measurements.type('pressure').variant('measured').position('upstream').value(700, Date.now(), 'mbar');
downstreamChild.measurements.type('pressure').variant('measured').position('downstream').value(1300, Date.now(), 'mbar');
assert.equal(Math.round(machine.getMeasuredPressure()), 600);
const status = machine.getPressureInitializationStatus();
assert.equal(status.source, 'differential');
assert.equal(status.initialized, true);
});

View File

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

View File

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