From b5137ba9c2b1526405380bac9a1117b6d937d643 Mon Sep 17 00:00:00 2001 From: znetsixe <73483679+znetsixe@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:36:44 +0100 Subject: [PATCH] before functional changes by Codex --- examples/basic.flow.json | 434 ++++++++++++++++-- examples/edge.flow.json | 2 + examples/integration.flow.json | 220 ++++++++- src/nodeClass.js | 52 ++- src/specificClass.js | 330 ++++++------- test/basic/mode-and-input.basic.test.js | 9 + test/edge/nodeClass-routing.edge.test.js | 83 +++- .../basic-flow-dashboard.integration.test.js | 107 +++++ test/integration/coolprop.integration.test.js | 17 + ...ressure-initialization.integration.test.js | 84 ++++ 10 files changed, 1118 insertions(+), 220 deletions(-) create mode 100644 test/integration/basic-flow-dashboard.integration.test.js create mode 100644 test/integration/pressure-initialization.integration.test.js diff --git a/examples/basic.flow.json b/examples/basic.flow.json index b632d6c..5c94ed9 100644 --- a/examples/basic.flow.json +++ b/examples/basic.flow.json @@ -35,9 +35,9 @@ }, "sizes": { "density": "default", - "pagePadding": "12px", - "groupGap": "12px", - "groupBorderRadius": "4px", + "pagePadding": "14px", + "groupGap": "14px", + "groupBorderRadius": "6px", "widgetGap": "12px" } }, @@ -409,8 +409,8 @@ "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, + "func": "const incoming = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst lastPayload = context.get('lastPayload') || {};\nconst merged = { ...lastPayload, ...incoming };\ncontext.set('lastPayload', merged);\n\nconst cache = context.get('metricCache') || {\n flow: 0,\n power: 0,\n ctrl: 0,\n nCog: 0,\n stateCode: 0,\n state: 'idle',\n mode: 'auto',\n runtime: 0,\n moveTimeleft: 0,\n maintenanceTime: 0,\n pressureUp: null,\n pressureDown: null,\n};\n\nconst pickNumber = (...keys) => {\n for (const key of keys) {\n const value = Number(merged[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\n\nconst pickByPrefix = (...prefixes) => {\n const keys = Object.keys(merged);\n for (const prefix of prefixes) {\n const direct = Number(merged[prefix]);\n if (Number.isFinite(direct)) return direct;\n\n const dynamicKey = keys.find((k) => k === prefix || k.startsWith(prefix + '.'));\n if (!dynamicKey) continue;\n\n const value = Number(merged[dynamicKey]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\n\nconst pickString = (key, fallback = null) => {\n const value = merged[key];\n if (value === undefined || value === null || value === '') return fallback;\n return String(value);\n};\n\nconst flowValue = pickByPrefix('flow.predicted.downstream');\nconst power = pickByPrefix('power.predicted.atequipment', 'power.predicted.atEquipment');\nconst ctrl = pickNumber('ctrl') ?? pickByPrefix('ctrl.predicted.atequipment', 'ctrl.predicted.atEquipment');\nconst nCog = pickNumber('NCogPercent', 'NCog');\nconst runtime = pickNumber('runtime');\nconst moveTimeleft = pickNumber('moveTimeleft');\nconst maintenanceTime = pickNumber('maintenanceTime');\nconst pressureDownIncoming = pickByPrefix('pressure.measured.downstream');\nconst pressureUpIncoming = pickByPrefix('pressure.measured.upstream');\nconst state = pickString('state', cache.state);\nconst mode = pickString('mode', cache.mode);\n\nconst stateCodeMap = { off: 0, idle: 1, starting: 2, warmingup: 3, operational: 4, accelerating: 5, decelerating: 6, stopping: 7, coolingdown: 8, maintenance: 9 };\nconst stateCode = stateCodeMap[state] ?? cache.stateCode;\n\nif (flowValue !== null) cache.flow = flowValue;\nif (power !== null) cache.power = power;\nif (ctrl !== null) cache.ctrl = ctrl;\nif (nCog !== null) cache.nCog = nCog;\nif (runtime !== null) cache.runtime = runtime;\nif (moveTimeleft !== null) cache.moveTimeleft = moveTimeleft;\nif (maintenanceTime !== null) cache.maintenanceTime = maintenanceTime;\nif (pressureUpIncoming !== null) cache.pressureUp = pressureUpIncoming;\nif (pressureDownIncoming !== null) cache.pressureDown = pressureDownIncoming;\ncache.state = state;\ncache.mode = mode;\ncache.stateCode = stateCode;\ncontext.set('metricCache', cache);\n\nconst pressureUp = Number.isFinite(cache.pressureUp) ? cache.pressureUp : null;\nconst pressureDown = Number.isFinite(cache.pressureDown) ? cache.pressureDown : null;\nconst pressureDelta = (pressureDown !== null && pressureUp !== null) ? (pressureDown - pressureUp) : null;\n\nconst now = Date.now();\nconst compactSnapshot = [\n `Q=${cache.flow.toFixed(1)} m3/h`,\n `P=${cache.power.toFixed(2)} kW`,\n `Ctrl=${cache.ctrl.toFixed(1)}%`,\n `NCog=${cache.nCog.toFixed(1)}%`,\n `Pup=${pressureUp == null ? 'n/a' : pressureUp.toFixed(0)} mbar`,\n `Pdown=${pressureDown == null ? 'n/a' : pressureDown.toFixed(0)} mbar`\n].join(' | ');\n\nreturn [\n { topic: 'actual_flow', payload: cache.flow, timestamp: now },\n { topic: 'predicted_power', payload: cache.power, timestamp: now },\n { topic: 'actual_ctrl', payload: cache.ctrl, timestamp: now },\n { topic: 'nCog', payload: cache.nCog, timestamp: now },\n { topic: 'stateCode', payload: cache.stateCode, timestamp: now },\n pressureUp === null ? null : { topic: 'pressure_upstream', payload: pressureUp, timestamp: now },\n pressureDown === null ? null : { topic: 'pressure_downstream', payload: pressureDown, timestamp: now },\n pressureDelta === null ? null : { topic: 'pressure_delta', payload: pressureDelta, timestamp: now },\n { topic: 'stateMode', payload: `state=${cache.state} | mode=${cache.mode} | ctrl=${cache.ctrl.toFixed(1)}%` },\n { topic: 'timing', payload: `runtime=${cache.runtime.toFixed(2)} h | moveLeft=${cache.moveTimeleft.toFixed(0)} s | maint=${cache.maintenanceTime.toFixed(2)} h` },\n { topic: 'snapshot', payload: compactSnapshot }\n];", + "outputs": 11, "noerr": 0, "x": 1310, "y": 420, @@ -430,6 +430,15 @@ [ "rm_chart_statecode" ], + [ + "rm_chart_pressure_up" + ], + [ + "rm_chart_pressure_down" + ], + [ + "rm_chart_pressure_delta" + ], [ "rm_text_state" ], @@ -449,20 +458,57 @@ "name": "Predicted Flow", "label": "Flow (m3/h)", "order": 1, - "width": 12, + "width": 6, "height": 4, "chartType": "line", - "category": "", + "category": "topic", "xAxisLabel": "time", "xAxisType": "time", "yAxisLabel": "m3/h", - "removeOlder": "1", - "removeOlderUnit": "3600", + "removeOlder": "15", + "removeOlderUnit": "60", "removeOlderPoints": "", "x": 1560, "y": 340, "wires": [], - "showLegend": true + "showLegend": false, + "categoryType": "msg", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "xmin": "", + "xmax": "", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "interpolation": "linear", + "className": "", + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true }, { "id": "rm_chart_power", @@ -472,17 +518,55 @@ "name": "Predicted Power", "label": "Power (kW)", "order": 2, - "width": 12, + "width": 6, "height": 4, "chartType": "line", "xAxisType": "time", "yAxisLabel": "kW", - "removeOlder": "1", - "removeOlderUnit": "3600", + "removeOlder": "15", + "removeOlderUnit": "60", "x": 1560, "y": 400, "wires": [], - "showLegend": true + "showLegend": false, + "category": "topic", + "categoryType": "msg", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "xmin": "", + "xmax": "", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "interpolation": "linear", + "className": "", + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true }, { "id": "rm_chart_ctrl", @@ -492,17 +576,55 @@ "name": "Control Position", "label": "Ctrl (%)", "order": 3, - "width": 12, + "width": 6, "height": 4, "chartType": "line", "xAxisType": "time", "yAxisLabel": "%", - "removeOlder": "1", - "removeOlderUnit": "3600", + "removeOlder": "15", + "removeOlderUnit": "60", "x": 1560, "y": 460, "wires": [], - "showLegend": true + "showLegend": false, + "category": "topic", + "categoryType": "msg", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "xmin": "", + "xmax": "", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "interpolation": "linear", + "className": "", + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true }, { "id": "rm_text_state", @@ -511,7 +633,7 @@ "group": "ui_group_rm_obs", "name": "State/Mode", "label": "Current State", - "order": 6, + "order": 9, "width": 12, "height": 1, "format": "{{msg.payload}}", @@ -558,17 +680,55 @@ "name": "NCog", "label": "NCog (%)", "order": 4, - "width": 12, + "width": 6, "height": 4, "chartType": "line", "xAxisType": "time", "yAxisLabel": "%", - "removeOlder": "1", - "removeOlderUnit": "3600", + "removeOlder": "15", + "removeOlderUnit": "60", "x": 1560, "y": 520, "wires": [], - "showLegend": true + "showLegend": false, + "category": "topic", + "categoryType": "msg", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "xmin": "", + "xmax": "", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "interpolation": "linear", + "className": "", + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true }, { "id": "rm_chart_statecode", @@ -578,17 +738,55 @@ "name": "Machine State Code", "label": "State Code (off=0 .. maint=9)", "order": 5, - "width": 12, + "width": 6, "height": 4, "chartType": "line", "xAxisType": "time", "yAxisLabel": "state", - "removeOlder": "1", - "removeOlderUnit": "3600", + "removeOlder": "15", + "removeOlderUnit": "60", "x": 1560, "y": 580, "wires": [], - "showLegend": true + "showLegend": false, + "category": "topic", + "categoryType": "msg", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "xmin": "", + "xmax": "", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "interpolation": "linear", + "className": "", + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true }, { "id": "rm_text_timing", @@ -597,7 +795,7 @@ "group": "ui_group_rm_obs", "name": "Timing", "label": "Timing", - "order": 7, + "order": 10, "width": 12, "height": 1, "format": "{{msg.payload}}", @@ -611,9 +809,9 @@ "type": "ui-text", "z": "f1e8a6c8b2a4477f", "group": "ui_group_rm_obs", - "name": "Latest Payload", - "label": "Latest Payload", - "order": 8, + "name": "Snapshot", + "label": "Snapshot", + "order": 11, "width": 12, "height": 1, "format": "{{msg.payload}}", @@ -640,5 +838,179 @@ "rm_chart_ctrl" ] ] + }, + { + "id": "rm_chart_pressure_up", + "type": "ui-chart", + "z": "f1e8a6c8b2a4477f", + "group": "ui_group_rm_obs", + "name": "Pressure Upstream", + "label": "Upstream Pressure (mbar)", + "order": 6, + "width": 6, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "mbar", + "removeOlder": "15", + "removeOlderUnit": "60", + "x": 1560, + "y": 640, + "wires": [], + "showLegend": false, + "category": "topic", + "categoryType": "msg", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "xmin": "", + "xmax": "", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "interpolation": "linear", + "className": "", + "colors": [ + "#2CA02C", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true + }, + { + "id": "rm_chart_pressure_down", + "type": "ui-chart", + "z": "f1e8a6c8b2a4477f", + "group": "ui_group_rm_obs", + "name": "Pressure Downstream", + "label": "Downstream Pressure (mbar)", + "order": 7, + "width": 6, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "mbar", + "removeOlder": "15", + "removeOlderUnit": "60", + "x": 1560, + "y": 700, + "wires": [], + "showLegend": false, + "category": "topic", + "categoryType": "msg", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "xmin": "", + "xmax": "", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "interpolation": "linear", + "className": "", + "colors": [ + "#FF7F0E", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true + }, + { + "id": "rm_chart_pressure_delta", + "type": "ui-chart", + "z": "f1e8a6c8b2a4477f", + "group": "ui_group_rm_obs", + "name": "Pressure Differential", + "label": "Pressure Delta (mbar)", + "order": 8, + "width": 6, + "height": 4, + "chartType": "line", + "xAxisType": "time", + "yAxisLabel": "mbar", + "removeOlder": "15", + "removeOlderUnit": "60", + "x": 1560, + "y": 760, + "wires": [], + "showLegend": false, + "category": "topic", + "categoryType": "msg", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "xmin": "", + "xmax": "", + "ymin": "", + "ymax": "", + "bins": 10, + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "interpolation": "linear", + "className": "", + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true } ] diff --git a/examples/edge.flow.json b/examples/edge.flow.json index dc976ce..6ebdfec 100644 --- a/examples/edge.flow.json +++ b/examples/edge.flow.json @@ -314,6 +314,8 @@ "width": 6, "height": 4, "chartType": "line", + "category": "topic", + "categoryType": "msg", "xAxisType": "time", "yAxisLabel": "state", "removeOlder": "1", diff --git a/examples/integration.flow.json b/examples/integration.flow.json index d032e8c..0088987 100644 --- a/examples/integration.flow.json +++ b/examples/integration.flow.json @@ -34,9 +34,9 @@ }, "sizes": { "density": "default", - "pagePadding": "12px", - "groupGap": "12px", - "groupBorderRadius": "4px", + "pagePadding": "14px", + "groupGap": "14px", + "groupBorderRadius": "6px", "widgetGap": "12px" } }, @@ -87,7 +87,7 @@ "name": "Observed Behaviour", "page": "ui_page_rm_int", "width": "12", - "height": "20", + "height": "24", "order": 3, "showTitle": true, "className": "" @@ -361,7 +361,7 @@ "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];", + "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, @@ -394,7 +394,7 @@ "name": "Flow", "label": "Flow (m3/h)", "order": 1, - "width": 12, + "width": 6, "height": 4, "chartType": "line", "xAxisType": "time", @@ -404,7 +404,45 @@ "x": 1510, "y": 280, "wires": [], - "showLegend": true + "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", @@ -414,7 +452,7 @@ "name": "Power", "label": "Power (kW)", "order": 2, - "width": 12, + "width": 6, "height": 4, "chartType": "line", "xAxisType": "time", @@ -424,7 +462,45 @@ "x": 1510, "y": 340, "wires": [], - "showLegend": true + "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", @@ -434,7 +510,7 @@ "name": "NCog", "label": "NCog (%)", "order": 3, - "width": 12, + "width": 6, "height": 4, "chartType": "line", "xAxisType": "time", @@ -444,7 +520,45 @@ "x": 1510, "y": 400, "wires": [], - "showLegend": true + "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", @@ -496,7 +610,7 @@ "name": "Ctrl", "label": "Ctrl (%)", "order": 4, - "width": 12, + "width": 6, "height": 4, "chartType": "line", "xAxisType": "time", @@ -506,7 +620,45 @@ "x": 1510, "y": 460, "wires": [], - "showLegend": true + "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", @@ -516,7 +668,7 @@ "name": "State Code", "label": "State Code (off=0 .. maint=9)", "order": 5, - "width": 12, + "width": 6, "height": 4, "chartType": "line", "xAxisType": "time", @@ -526,7 +678,45 @@ "x": 1510, "y": 520, "wires": [], - "showLegend": true + "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", diff --git a/src/nodeClass.js b/src/nodeClass.js index 54afc0c..9fe55dd 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -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.source = null; // Will hold the specific class instance this.config = null; // Will hold the merged configuration + this._pressureInitWarned = false; // Load default & UI config this._loadConfig(uiConfig,this.node); @@ -117,6 +118,22 @@ class nodeClass { try { const mode = m.currentMode; 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 power = Math.round(m.measurements.type("power").variant("predicted").position('atEquipment').getCurrentValue('kW')); let symbolState; @@ -234,7 +251,7 @@ class nodeClass { const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb'); // Send only updated outputs on ports 0 & 1 - this.node.send([processMsg, influxMsg]); + this.node.send([processMsg, influxMsg, null]); } /** @@ -242,13 +259,20 @@ class nodeClass { */ _attachInputHandler() { 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; - switch(msg.topic) { + const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg); + + try { + switch(msg.topic) { case 'registerChild': // Register this node as a child of the parent node 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); break; case 'setMode': @@ -292,7 +316,11 @@ class nodeClass { switch (type) { case 'pressure': - m.updateMeasuredPressure(value, position, context); + 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); @@ -306,14 +334,20 @@ class nodeClass { } break; case 'showWorkingCurves': - m.showWorkingCurves(); - send({ topic : "Showing curve" , payload: m.showWorkingCurves() }); + nodeSend([{ ...msg, topic : "showWorkingCurves" , payload: m.showWorkingCurves() }, null, null]); break; case 'CoG': - m.showCoG(); - send({ topic : "Showing CoG" , payload: m.showCoG() }); + nodeSend([{ ...msg, topic : "showCoG" , payload: m.showCoG() }, null, null]); break; } + if (typeof done === 'function') done(); + } catch (error) { + if (typeof done === 'function') { + done(error); + } else { + this.node.error(error, msg); + } + } }); } diff --git a/src/specificClass.js b/src/specificClass.js index 0b60561..eea9415 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -87,10 +87,63 @@ class Machine { this.child = {}; // object to hold child information so we know on what to subscribe 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(){ //assume standard temperature is 20degrees this.measurements.type('temperature').variant('measured').position('atEquipment').value(15).unit('C'); @@ -118,13 +171,19 @@ class Machine { /*------------------- Register child events -------------------*/ 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"){ - const position = child.config.functionality.positionVsParent; - const distance = child.config.functionality.distanceVsParent || 0; + if(resolvedSoftwareType === "measurement"){ + const position = String(child.config.functionality.positionVsParent || "atEquipment").toLowerCase(); 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. const eventName = `${measurementType}.measured.${position}`; @@ -140,6 +199,7 @@ class Machine { .type(measurementType) .variant("measured") .position(position) + .child(childId) .value(eventData.value, eventData.timestamp, eventData.unit); // Call the appropriate handler @@ -439,14 +499,16 @@ _callMeasurementHandler(measurementType, value, position, context) { 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 - if (pressureDiff) { - this.logger.debug(`Pressure differential: ${pressureDiff.value}`); - this.predictFlow.fDimension = pressureDiff.value; - this.predictPower.fDimension = pressureDiff.value; - this.predictCtrl.fDimension = pressureDiff.value; + if (upstreamPressure != null && downstreamPressure != null) { + const pressureDiffValue = downstreamPressure - upstreamPressure; + this.logger.debug(`Pressure differential: ${pressureDiffValue}`); + this.predictFlow.fDimension = pressureDiffValue; + this.predictPower.fDimension = pressureDiffValue; + this.predictCtrl.fDimension = pressureDiffValue; //update the cog const { cog, minEfficiency } = this.calcCog(); // calc efficiency @@ -454,12 +516,9 @@ _callMeasurementHandler(measurementType, value, position, context) { //update the distance from peak 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 if (downstreamPressure != null) { this.logger.warn(`Using downstream pressure only for prediction: ${downstreamPressure} This is less acurate!!`); @@ -475,6 +534,21 @@ _callMeasurementHandler(measurementType, value, position, context) { 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`); //set default at 0 => lowest pressure possible @@ -493,6 +567,80 @@ _callMeasurementHandler(measurementType, value, position, context) { 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() { const flowDiff = this.measurements.type('flow').variant('measured').difference(); @@ -588,8 +736,9 @@ _callMeasurementHandler(measurementType, value, position, context) { // Helper method for operational state check _isOperationalState() { const state = this.state.getCurrentState(); - this.logger.debug(`Checking operational state ${this.state.getCurrentState()} ? ${["operational", "accelerating", "decelerating"].includes(state)}`); - return ["operational", "accelerating", "decelerating"].includes(state); + const activeStates = ["operational", "warmingup", "accelerating", "decelerating"]; + 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. @@ -822,150 +971,3 @@ _callMeasurementHandler(measurementType, value, position, context) { 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}`); - } -} - - -//*/ - - - diff --git a/test/basic/mode-and-input.basic.test.js b/test/basic/mode-and-input.basic.test.js index 5ceddc3..d367897 100644 --- a/test/basic/mode-and-input.basic.test.js +++ b/test/basic/mode-and-input.basic.test.js @@ -33,3 +33,12 @@ test('handleInput ignores disallowed source/action combination', async () => { 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); +}); diff --git a/test/edge/nodeClass-routing.edge.test.js b/test/edge/nodeClass-routing.edge.test.js index 9ec3274..f6ab5a0 100644 --- a/test/edge/nodeClass-routing.edge.test.js +++ b/test/edge/nodeClass-routing.edge.test.js @@ -34,6 +34,9 @@ test('input handler routes topics to source methods', () => { showCoG() { return { cog: 1 }; }, + updateSimulatedMeasurement(type, position, value) { + calls.push(['updateSimulatedMeasurement', type, position, value]); + }, updateMeasuredPressure(value, position) { calls.push(['updateMeasuredPressure', value, position]); }, @@ -56,5 +59,83 @@ test('input handler routes topics to source methods', () => { 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']); + 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'); }); diff --git a/test/integration/basic-flow-dashboard.integration.test.js b/test/integration/basic-flow-dashboard.integration.test.js new file mode 100644 index 0000000..b751f0f --- /dev/null +++ b/test/integration/basic-flow-dashboard.integration.test.js @@ -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)); +}); diff --git a/test/integration/coolprop.integration.test.js b/test/integration/coolprop.integration.test.js index c1b24cb..cdf30d6 100644 --- a/test/integration/coolprop.integration.test.js +++ b/test/integration/coolprop.integration.test.js @@ -20,3 +20,20 @@ test('calcEfficiency runs through coolprop path without mocks', () => { 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); +}); diff --git a/test/integration/pressure-initialization.integration.test.js b/test/integration/pressure-initialization.integration.test.js new file mode 100644 index 0000000..2d2c2de --- /dev/null +++ b/test/integration/pressure-initialization.integration.test.js @@ -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); +});