[
{
"id": "tab_mgc_dash",
"type": "tab",
"label": "MGC - Dashboard",
"disabled": false,
"info": "Tier 2: dashboard-driven MGC with three pumps. Demand is unit-aware on each set.demand message (bare number = %; {value, unit} for absolute flow; negative for stop)."
},
{
"id": "grp_mgc_unit",
"type": "group",
"z": "tab_mgc_dash",
"name": "Machine Group (Unit)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#50a8d9",
"fill-opacity": "0.10"
},
"nodes": [
"mgc_dash_node"
],
"x": 994,
"y": 351.5,
"w": 212,
"h": 97
},
{
"id": "grp_pump_a",
"type": "group",
"z": "tab_mgc_dash",
"name": "Pump A (Equipment)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#86bbdd",
"fill-opacity": "0.10"
},
"nodes": [
"rm_dash_pump_a"
],
"x": 714,
"y": 451.5,
"w": 272,
"h": 97
},
{
"id": "grp_pump_b",
"type": "group",
"z": "tab_mgc_dash",
"name": "Pump B (Equipment)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#86bbdd",
"fill-opacity": "0.10"
},
"nodes": [
"rm_dash_pump_b"
],
"x": 714,
"y": 571.5,
"w": 272,
"h": 97
},
{
"id": "grp_pump_c",
"type": "group",
"z": "tab_mgc_dash",
"name": "Pump C (Equipment)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#86bbdd",
"fill-opacity": "0.10"
},
"nodes": [
"rm_dash_pump_c"
],
"x": 714,
"y": 691.5,
"w": 272,
"h": 97
},
{
"id": "grp_drv_mode",
"type": "group",
"z": "tab_mgc_dash",
"name": "1. Control mode",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"ui_btn_mode_optimal",
"ui_btn_mode_priority"
],
"x": 754,
"y": 19,
"w": 252,
"h": 122
},
{
"id": "grp_drv_demand",
"type": "group",
"z": "tab_mgc_dash",
"name": "3. Operator demand",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"ui_slider_demand",
"ui_btn_demand_min",
"ui_btn_demand_stop",
"ui_btn_demand_abs_200",
"ui_btn_demand_abs_400",
"ui_btn_demand_abs_lps"
],
"x": 354,
"y": 59,
"w": 312,
"h": 282
},
{
"id": "grp_setup",
"type": "group",
"z": "tab_mgc_dash",
"name": "Setup \u2014 once on deploy + manual re-init",
"style": {
"stroke": "#666666",
"fill": "#dddddd",
"fill-opacity": "0.20",
"label": true,
"color": "#333333"
},
"nodes": [
"inj_setup_once",
"ui_btn_setup_init",
"fn_setup_fanout"
],
"x": 114,
"y": 951.5,
"w": 752,
"h": 97
},
{
"id": "grp_status_panel",
"type": "group",
"z": "tab_mgc_dash",
"name": "Live status, trends, raw output",
"style": {
"stroke": "#666666",
"fill": "#bde0fe",
"fill-opacity": "0.20",
"label": true,
"color": "#333333"
},
"nodes": [
"fn_status_split",
"ui_txt_mode",
"ui_txt_flow",
"ui_txt_power",
"ui_txt_capacity",
"ui_txt_machines",
"ui_txt_bep",
"ui_chart_flow",
"ui_chart_power",
"ui_chart_bep",
"ui_tpl_raw",
"ui_chart_per_pump_flow",
"fn_chart_pump_a",
"fn_chart_pump_b",
"fn_chart_pump_c",
"fn_chart_total"
],
"x": 1234,
"y": 59,
"w": 682,
"h": 602
},
{
"id": "grp_drv_pressure",
"type": "group",
"z": "tab_mgc_dash",
"name": "4. Pressure (manual sweep)",
"style": {
"stroke": "#666666",
"fill": "#dddddd",
"fill-opacity": "0.20",
"label": true,
"color": "#333333"
},
"nodes": [
"ui_slider_pressure",
"fn_pressure_wrap",
"fn_pressure_fanout"
],
"x": 34,
"y": 831.5,
"w": 842,
"h": 97
},
{
"id": "cmt_title",
"type": "comment",
"z": "tab_mgc_dash",
"name": "MGC \u2014 Dashboard (Tier 2)",
"info": "Same command surface as the Basic flow, driven by a FlowFuse dashboard.\n\nOpen /dashboard/mgc-basic after deploy.\n\nDEMAND SEMANTICS (set.demand)\n- bare number \u2192 % of group capacity (0\u2013100)\n- { value, unit:'%' } \u2192 %, explicit\n- { value, unit:'m3/h' | 'l/s' | 'm3/s' | ... } \u2192 absolute flow\n- negative value \u2192 stop all pumps\nThe handler resolves the unit and converts to canonical m\u00b3/s before dispatch.\n\nSETUP \u2014 fires once on deploy and on \"Initialize pumps\"\n- Switches all 3 pumps to auto mode (so MGC's parent-sourced commands route through).\n- Simulates a nominal pressure operating point per pump: downstream = 1100 mbar,\n upstream = 0 mbar \u2192 1100 mbar differential.\n- Sends cmd.startup to all 3 pumps.\n\nCONTROLS panel\n- Mode: optimalControl / priorityControl \u2192 set.mode\n- Demand slider 0\u2013100 \u2192 set.demand (interpreted as % via bare-number default).\n- Min flow button (set.demand = 0) \u2014 sends the minimum-control sentinel.\n- Stop all button (set.demand = -1) \u2014 turns every pump off.\n- Absolute demand quick-buttons: 200 m\u00b3/h, 400 m\u00b3/h, 100 l/s \u2014 each sends\n { value, unit } and exercises the unit-aware path (incl. l/s \u2192 m\u00b3/s conversion).\n- Pressure slider 600\u20131500 mbar \u2014 live downstream head sweep.\n- Initialize pumps button \u2014 re-runs the once-on-deploy setup.\n\nSTATUS panel\n- Mode / Total flow / Total power / Capacity (Qmin\u2013Qmax) / Active machines / BEP distance (rel %)\n\nTRENDS panel\n- Flow (m\u00b3/h) \u2014 predicted aggregate vs capacity\n- Power (kW)\n- BEP distance (rel %)\n\nRAW OUTPUT panel\n- Full key/value dump of the latest MGC Port 0 cache (sorted).\n\nPORTS (preserved for inspection)\n- Port 0: process output (changed fields only)\n- Port 1: InfluxDB-shaped {measurement, fields, tags, timestamp}\n- Port 2: parent registration (when wired into a pumpingStation)",
"x": 1060,
"y": 240,
"wires": []
},
{
"id": "ui_btn_mode_optimal",
"type": "ui-button",
"z": "tab_mgc_dash",
"g": "grp_drv_mode",
"group": "ui_group_ctrl",
"name": "Mode: optimalControl",
"label": "Mode: optimalControl",
"order": 1,
"width": "3",
"height": "1",
"emulateClick": false,
"tooltip": "Best-combination optimiser (BEP-Gravitation / NCog)",
"color": "",
"bgcolor": "",
"icon": "auto_fix_high",
"payload": "optimalControl",
"payloadType": "str",
"topic": "set.mode",
"topicType": "str",
"x": 880,
"y": 60,
"wires": [
[
"mgc_dash_node"
]
]
},
{
"id": "ui_btn_mode_priority",
"type": "ui-button",
"z": "tab_mgc_dash",
"g": "grp_drv_mode",
"group": "ui_group_ctrl",
"name": "Mode: priorityControl",
"label": "Mode: priorityControl",
"order": 2,
"width": "3",
"height": "1",
"emulateClick": false,
"tooltip": "Sequential equal-flow control by priority list",
"color": "",
"bgcolor": "",
"icon": "format_list_numbered",
"payload": "priorityControl",
"payloadType": "str",
"topic": "set.mode",
"topicType": "str",
"x": 880,
"y": 100,
"wires": [
[
"mgc_dash_node"
]
]
},
{
"id": "ui_slider_demand",
"type": "ui-slider",
"z": "tab_mgc_dash",
"g": "grp_drv_demand",
"group": "ui_group_ctrl",
"name": "Demand",
"label": "Demand",
"order": 3,
"width": "6",
"height": "1",
"passthru": true,
"outs": "all",
"topic": "set.demand",
"topicType": "str",
"thumbLabel": "always",
"showTicks": false,
"min": 0,
"max": 100,
"step": 1,
"className": "",
"x": 580,
"y": 100,
"wires": [
[
"mgc_dash_node"
]
],
"icon": "speed"
},
{
"id": "ui_btn_demand_min",
"type": "ui-button",
"z": "tab_mgc_dash",
"g": "grp_drv_demand",
"group": "ui_group_ctrl",
"name": "Demand = 0 (min, normalized)",
"label": "Min flow (demand = 0)",
"order": 4,
"width": "6",
"height": "1",
"emulateClick": false,
"tooltip": "Send set.demand = 0 \u2014 normalized mode interpolates to the group's flow floor (lightest valid combination, usually one pump at its min ctrl%).",
"color": "#ffffff",
"bgcolor": "#666666",
"icon": "speed",
"payload": "0",
"payloadType": "num",
"topic": "set.demand",
"topicType": "str",
"x": 510,
"y": 140,
"wires": [
[
"mgc_dash_node"
]
]
},
{
"id": "ui_btn_demand_stop",
"type": "ui-button",
"z": "tab_mgc_dash",
"g": "grp_drv_demand",
"group": "ui_group_ctrl",
"name": "Demand = -1 (stop all)",
"label": "Stop all (demand = -1)",
"order": 5,
"width": "6",
"height": "1",
"emulateClick": false,
"tooltip": "Send set.demand = -1 \u2014 MGC calls turnOffAllMachines and parks any pending demand.",
"color": "#ffffff",
"bgcolor": "#cc3333",
"icon": "stop",
"payload": "-1",
"payloadType": "num",
"topic": "set.demand",
"topicType": "str",
"x": 540,
"y": 180,
"wires": [
[
"mgc_dash_node"
]
]
},
{
"id": "inj_setup_once",
"type": "inject",
"z": "tab_mgc_dash",
"g": "grp_setup",
"name": "auto-init on deploy",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": 1,
"topic": "",
"payload": "go",
"payloadType": "str",
"x": 140,
"y": 1000,
"wires": [
[
"fn_setup_fanout"
]
]
},
{
"id": "ui_btn_setup_init",
"type": "ui-button",
"z": "tab_mgc_dash",
"g": "grp_setup",
"group": "ui_group_ctrl",
"name": "Initialize pumps",
"label": "Initialize pumps (auto + pressure + startup)",
"order": 10,
"width": "12",
"height": "1",
"emulateClick": false,
"tooltip": "Re-runs the once-on-deploy setup: auto mode + nominal pressure (1100 mbar diff) + cmd.startup on all three pumps",
"color": "",
"bgcolor": "",
"icon": "play_arrow",
"payload": "go",
"payloadType": "str",
"topic": "",
"topicType": "str",
"x": 400,
"y": 1000,
"wires": [
[
"fn_setup_fanout"
]
]
},
{
"id": "fn_setup_fanout",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_setup",
"name": "fan-out: auto + pressure + startup \u2192 A/B/C",
"func": "// Setup messages per pump: set.mode = auto, simulate nominal pressure\n// operating point, then cmd.startup.\n//\n// Pumps must be in 'auto' so the MGC's parent-sourced flow setpoints are\n// accepted. 'auto' allowedSources = [parent, GUI, fysical] so GUI buttons\n// continue to work.\n//\n// Nominal pressure: downstream = 1100 mbar, upstream = 0 mbar \u2192 1100 mbar\n// differential (hidrostal-H05K-S03R curve nominal). Without this, MGC's\n// equalize() short-circuits, status badge sticks at 'pressure not\n// initialized', and the dashboard reports zero flow/power. Use the\n// Pressure slider in the Controls panel to sweep head live.\nconst setMode = { topic: 'set.mode', payload: 'auto' };\nconst pDown = { topic: 'data.simulate-measurement', payload: { type: 'pressure', position: 'downstream', value: 1100, unit: 'mbar' } };\nconst pUp = { topic: 'data.simulate-measurement', payload: { type: 'pressure', position: 'upstream', value: 0, unit: 'mbar' } };\nconst startup = { topic: 'cmd.startup', payload: {} };\nreturn [\n [setMode, pUp, pDown, startup], // \u2192 Pump A\n [setMode, pUp, pDown, startup], // \u2192 Pump B\n [setMode, pUp, pDown, startup], // \u2192 Pump C\n];\n",
"outputs": 3,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 670,
"y": 1000,
"wires": [
[
"rm_dash_pump_a"
],
[
"rm_dash_pump_b"
],
[
"rm_dash_pump_c"
]
]
},
{
"id": "fn_status_split",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"name": "fan-out Port 0 (status + charts + raw)",
"func": "// MGC Port 0 emits delta-only. Cache last-known so deltas never blank a\n// row, then fan out one msg per ui-text / ui-chart / ui-template slot.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\n// num/pct treat null AND undefined as \"no data\" (display em-dash). Without\n// the explicit null check, `+null === 0` would silently render as \"0.0 %\" \u2014\n// the bug class we hit twice today (\u03b7-null and Ncog/bepRel degenerate).\nconst num = (v, dp, unit) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\nconst pct = (v, dp) => {\n if (v == null) return '\u2014';\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return (n * 100).toFixed(dp) + ' %';\n};\n\nconst mode = cache.mode || '\u2014';\nconst flow = cache['atEquipment_predicted_flow'];\nconst power = cache['atEquipment_predicted_power'];\nconst qMin = cache.flowCapacityMin;\nconst qMax = cache.flowCapacityMax;\nconst nAct = cache.machineCountActive;\nconst nTot = cache.machineCount;\nconst bepRel = cache.relDistFromPeak; // 0..1\nconst bepAbs = cache.absDistFromPeak; // \u03b7 points (dimensionless)\nconst eta = cache['atEquipment_predicted_efficiency']; // 0..1\n// MGC emits atEquipment_predicted_Ncog as the SUM of per-pump NCog values from\n// the BEP-Gravitation optimizer (bepGravitation.js:162 totalCog). Range is\n// 0..N where N=active pumps, NOT 0..1. Normalize here so the dashboard shows\n// a per-pump average position on the BEP envelope.\nconst ncogSum = +cache['atEquipment_predicted_Ncog'];\n// undefined (not null) for the degraded case \u2014 pct() does `+v` and `+null === 0`,\n// which would silently display \"0.0 %\" instead of the em-dash that means \"no data\".\nconst ncog = (Number.isFinite(ncogSum) && Number.isFinite(+nAct) && +nAct > 0)\n ? ncogSum / +nAct\n : undefined;\n// Peak \u03b7 isn't emitted directly; derive: peak = eta + absDistFromPeak.\nconst etaPeak = (Number.isFinite(+eta) && Number.isFinite(+bepAbs)) ? (+eta + +bepAbs) : null;\n\n// % of capacity \u2014 realized predicted flow / max capacity. Both already coerced safely above.\nconst pctCap = (Number.isFinite(+flow) && Number.isFinite(+qMax) && +qMax > 0)\n ? (+flow / +qMax) * 100\n : undefined;\n\nconst chart = (topic, v, scale = 1) =>\n Number.isFinite(+v) ? { topic, payload: +v * scale } : null;\n\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '\u2014';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 0-5: original status texts (mode, flow, power, capacity, machines, BEP rel%)\n { payload: mode },\n { payload: num(flow, 1, 'm\u00b3/h') },\n { payload: num(power, 2, 'kW') },\n { payload: Number.isFinite(+qMax) ? (num(qMin, 1) + ' \u2013 ' + num(qMax, 1, 'm\u00b3/h')) : '\u2014' },\n { payload: (Number.isFinite(+nAct) && Number.isFinite(+nTot)) ? (nAct + ' / ' + nTot) : '\u2014' },\n { payload: pct(bepRel, 1) }, // BEP rel% \u2014 was buggy: now \u00d7100 then format\n\n // 6-9: new status texts (\u03b7, \u03b7 peak, BEP abs gap, NCog)\n { payload: pct(eta, 1) }, // \u03b7 (hydraulic)\n { payload: pct(etaPeak, 1) }, // \u03b7 peak\n { payload: num(bepAbs, 3) }, // BEP abs gap (\u03b7 points)\n { payload: pct(ncog, 1) }, // NCog (BEP flow position)\n\n // 10-13: charts (flow predicted, capacity max, power, BEP rel% scaled to 0..100)\n chart('Flow', flow),\n chart('Capacity', qMax),\n chart('Power', power),\n chart('BEP rel %', bepRel, 100), // chart also fixed: scale 0..1 \u2192 0..100\n\n // 14: efficiency chart \u2014 emit only when eta is finite (null msg = no output,\n // which avoids ui-chart crashing on { payload: null })\n chart('\u03b7 (%)', eta, 100),\n\n // 15: raw rows for the ui-template\n { payload: rawRows },\n { payload: msg.payload }, // 16: raw passthrough for Q-H chart\n // 17: % of capacity chart\n chart('% of capacity', pctCap),\n];\n",
"outputs": 18,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1410,
"y": 180,
"wires": [
[
"ui_txt_mode"
],
[
"ui_txt_flow"
],
[
"ui_txt_power"
],
[
"ui_txt_capacity"
],
[
"ui_txt_machines"
],
[
"ui_txt_bep"
],
[
"ui_txt_eta"
],
[
"ui_txt_eta_peak"
],
[
"ui_txt_bep_abs"
],
[
"ui_txt_ncog"
],
[
"ui_chart_flow"
],
[
"ui_chart_flow"
],
[
"ui_chart_power"
],
[
"ui_chart_bep"
],
[
"ui_chart_eta"
],
[
"ui_tpl_raw"
],
[
"fn_qh_point"
],
[
"ui_chart_mgc_pctcap"
]
]
},
{
"id": "ui_txt_mode",
"type": "ui-text",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_status",
"order": 1,
"width": "6",
"height": "1",
"name": "Mode",
"label": "Mode",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#1F4E79",
"x": 1700,
"y": 100,
"wires": []
},
{
"id": "ui_txt_flow",
"type": "ui-text",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_status",
"order": 3,
"width": "6",
"height": "1",
"name": "Total flow",
"label": "Total flow",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#1F4E79",
"x": 1720,
"y": 140,
"wires": []
},
{
"id": "ui_txt_power",
"type": "ui-text",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_status",
"order": 4,
"width": "6",
"height": "1",
"name": "Total power",
"label": "Total power",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#1F4E79",
"x": 1730,
"y": 180,
"wires": []
},
{
"id": "ui_txt_capacity",
"type": "ui-text",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_status",
"order": 5,
"width": "6",
"height": "1",
"name": "Capacity (Qmin\u2013Qmax)",
"label": "Capacity",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#1F4E79",
"x": 1770,
"y": 220,
"wires": []
},
{
"id": "ui_txt_machines",
"type": "ui-text",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_status",
"order": 6,
"width": "6",
"height": "1",
"name": "Machines (active / total)",
"label": "Machines",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#1F4E79",
"x": 1780,
"y": 260,
"wires": []
},
{
"id": "ui_txt_bep",
"type": "ui-text",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_status",
"order": 7,
"width": "6",
"height": "1",
"name": "BEP distance (rel)",
"label": "BEP distance",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#7D3C98",
"x": 1750,
"y": 300,
"wires": []
},
{
"id": "ui_chart_flow",
"type": "ui-chart",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "Flow vs capacity",
"label": "Flow (m\u00b3/h) \u2014 predicted vs capacity",
"order": 1,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisType": "time",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "m\u00b3/h",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"removeOlder": "15",
"removeOlderUnit": "60",
"removeOlderPoints": "",
"colors": [
"#0095FF",
"#cccccc",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 6,
"height": 4,
"className": "",
"interpolation": "linear",
"x": 1750,
"y": 380,
"wires": [
[]
]
},
{
"id": "ui_chart_power",
"type": "ui-chart",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "Power",
"label": "Power (kW)",
"order": 3,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisType": "time",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "kW",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": false,
"removeOlder": "15",
"removeOlderUnit": "60",
"removeOlderPoints": "",
"colors": [
"#2CA02C",
"#FF0000",
"#FF7F0E",
"#0095FF",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 6,
"height": 4,
"className": "",
"interpolation": "linear",
"x": 1720,
"y": 420,
"wires": [
[]
]
},
{
"id": "ui_chart_bep",
"type": "ui-chart",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "BEP distance (rel %)",
"label": "BEP distance (rel %)",
"order": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisType": "time",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "%",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": false,
"removeOlder": "15",
"removeOlderUnit": "60",
"removeOlderPoints": "",
"colors": [
"#A347E1",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#0095FF",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 6,
"height": 4,
"className": "",
"interpolation": "linear",
"x": 1770,
"y": 460,
"wires": [
[]
]
},
{
"id": "ui_tpl_raw",
"type": "ui-template",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_raw",
"name": "Raw output table",
"order": 1,
"width": "12",
"height": "8",
"head": "",
"format": "\n \n
\n \n | {{ row.key }} | \n {{ row.value }} | \n
\n
\n
\n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 1760,
"y": 540,
"wires": [
[]
]
},
{
"id": "rm_dash_pump_a",
"type": "rotatingMachine",
"z": "tab_mgc_dash",
"g": "grp_pump_a",
"name": "Pump A",
"speed": 1,
"startup": 0,
"warmup": 0,
"shutdown": 0,
"cooldown": 0,
"movementMode": "staticspeed",
"machineCurve": "",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "mgc-dash-pump-a",
"assetTagNumber": "",
"model": "hidrostal-H05K-S03R",
"unit": "m3/h",
"curvePressureUnit": "mbar",
"curveFlowUnit": "m3/h",
"curvePowerUnit": "kW",
"curveControlUnit": "%",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": "",
"x": 850,
"y": 500,
"wires": [
[
"fn_chart_pump_a",
"fn_qh_inject_id"
],
[],
[
"mgc_dash_node"
]
]
},
{
"id": "rm_dash_pump_b",
"type": "rotatingMachine",
"z": "tab_mgc_dash",
"g": "grp_pump_b",
"name": "Pump B",
"speed": 1,
"startup": 0,
"warmup": 0,
"shutdown": 0,
"cooldown": 0,
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": "mgc-dash-pump-b",
"assetTagNumber": "",
"model": "hidrostal-H05K-S03R",
"unit": "m3/h",
"curvePressureUnit": "mbar",
"curveFlowUnit": "m3/h",
"curvePowerUnit": "kW",
"curveControlUnit": "%",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": "",
"x": 850,
"y": 620,
"wires": [
[
"fn_chart_pump_b"
],
[],
[
"mgc_dash_node"
]
]
},
{
"id": "rm_dash_pump_c",
"type": "rotatingMachine",
"z": "tab_mgc_dash",
"g": "grp_pump_c",
"name": "Pump C",
"speed": 1,
"startup": 0,
"warmup": 0,
"shutdown": 0,
"cooldown": 0,
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": "mgc-dash-pump-c",
"assetTagNumber": "",
"model": "hidrostal-H05K-S03R",
"unit": "m3/h",
"curvePressureUnit": "mbar",
"curveFlowUnit": "m3/h",
"curvePowerUnit": "kW",
"curveControlUnit": "%",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": "",
"x": 850,
"y": 740,
"wires": [
[
"fn_chart_pump_c"
],
[],
[
"mgc_dash_node"
]
]
},
{
"id": "mgc_dash_node",
"type": "machineGroupControl",
"z": "tab_mgc_dash",
"g": "grp_mgc_unit",
"name": "Machine Group",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"mode": "optimalControl",
"uuid": "",
"supplier": "",
"category": "",
"assetType": "",
"model": "",
"unit": "",
"enableLog": false,
"logLevel": "info",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"distanceDescription": "",
"x": 1100,
"y": 400,
"wires": [
[
"fn_status_split",
"fn_chart_total"
],
[],
[]
]
},
{
"id": "ui_slider_pressure",
"type": "ui-slider",
"z": "tab_mgc_dash",
"g": "grp_drv_pressure",
"group": "ui_group_ctrl",
"name": "Pressure downstream (mbar)",
"label": "Pressure \u2193 (mbar)",
"order": 9,
"width": "12",
"height": "1",
"passthru": true,
"outs": "end",
"topic": "",
"topicType": "str",
"thumbLabel": "always",
"showTicks": false,
"min": 600,
"max": 1500,
"step": 50,
"className": "",
"x": 180,
"y": 880,
"wires": [
[
"fn_pressure_wrap"
]
],
"icon": "compress"
},
{
"id": "fn_pressure_wrap",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_drv_pressure",
"name": "wrap \u2192 data.simulate-measurement",
"func": "// Slider emits msg.payload = Number. Convert into the canonical\n// data.simulate-measurement shape so each pump's command registry can\n// route it through the same path as inject-based pressure tests.\nconst v = Number(msg.payload);\nif (!Number.isFinite(v)) return null;\nmsg.topic = 'data.simulate-measurement';\nmsg.payload = { type: 'pressure', position: 'downstream', value: v, unit: 'mbar' };\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 470,
"y": 880,
"wires": [
[
"fn_pressure_fanout"
]
]
},
{
"id": "fn_pressure_fanout",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_drv_pressure",
"name": "fan-out: pressure \u2192 A/B/C",
"func": "// Forward the wrapped data.simulate-measurement msg to all 3 pumps.\nreturn [msg, msg, msg];\n",
"outputs": 3,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 740,
"y": 880,
"wires": [
[
"rm_dash_pump_a"
],
[
"rm_dash_pump_b"
],
[
"rm_dash_pump_c"
]
]
},
{
"id": "ui_btn_demand_abs_200",
"type": "ui-button",
"z": "tab_mgc_dash",
"g": "grp_drv_demand",
"group": "ui_group_ctrl",
"name": "200 m\u00b3/h",
"label": "200 m\u00b3/h",
"order": 6,
"width": "4",
"height": "1",
"emulateClick": false,
"tooltip": "Sends set.demand = {\"value\":200,\"unit\":\"m3/h\"}. Unit conversion to canonical m\u00b3/s happens in the MGC setDemand handler.",
"color": "",
"bgcolor": "",
"icon": "water_drop",
"payload": "{\"value\":200,\"unit\":\"m3/h\"}",
"payloadType": "json",
"topic": "set.demand",
"topicType": "str",
"x": 580,
"y": 220,
"wires": [
[
"mgc_dash_node"
]
]
},
{
"id": "ui_btn_demand_abs_400",
"type": "ui-button",
"z": "tab_mgc_dash",
"g": "grp_drv_demand",
"group": "ui_group_ctrl",
"name": "400 m\u00b3/h",
"label": "400 m\u00b3/h",
"order": 7,
"width": "4",
"height": "1",
"emulateClick": false,
"tooltip": "Sends set.demand = {\"value\":400,\"unit\":\"m3/h\"}. Unit conversion to canonical m\u00b3/s happens in the MGC setDemand handler.",
"color": "",
"bgcolor": "",
"icon": "water_drop",
"payload": "{\"value\":400,\"unit\":\"m3/h\"}",
"payloadType": "json",
"topic": "set.demand",
"topicType": "str",
"x": 580,
"y": 260,
"wires": [
[
"mgc_dash_node"
]
]
},
{
"id": "ui_btn_demand_abs_lps",
"type": "ui-button",
"z": "tab_mgc_dash",
"g": "grp_drv_demand",
"group": "ui_group_ctrl",
"name": "100 l/s",
"label": "100 l/s",
"order": 8,
"width": "4",
"height": "1",
"emulateClick": false,
"tooltip": "Sends set.demand = {\"value\":100,\"unit\":\"l/s\"}. Unit conversion to canonical m\u00b3/s happens in the MGC setDemand handler.",
"color": "",
"bgcolor": "",
"icon": "water_drop",
"payload": "{\"value\":100,\"unit\":\"l/s\"}",
"payloadType": "json",
"topic": "set.demand",
"topicType": "str",
"x": 590,
"y": 300,
"wires": [
[
"mgc_dash_node"
]
]
},
{
"id": "ui_chart_per_pump_flow",
"type": "ui-chart",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "Per-pump flow",
"label": "Per-pump flow (m\u00b3/h) \u2014 A / B / C vs Total",
"order": 2,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisType": "time",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "m\u00b3/h",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"removeOlder": "15",
"removeOlderUnit": "60",
"removeOlderPoints": "",
"colors": [
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#0095FF",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5",
"#cccccc"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 6,
"height": 4,
"className": "",
"interpolation": "linear",
"x": 1750,
"y": 620,
"wires": [
[]
]
},
{
"id": "fn_chart_pump_a",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"name": "chart: Pump A",
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump A', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump A', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump A', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1450,
"y": 500,
"wires": [
[
"ui_chart_per_pump_flow"
],
[
"ui_chart_pumps_ctrl"
]
]
},
{
"id": "fn_chart_pump_b",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"name": "chart: Pump B",
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump B', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump B', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump B', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1450,
"y": 540,
"wires": [
[
"ui_chart_per_pump_flow"
],
[
"ui_chart_pumps_ctrl"
]
]
},
{
"id": "fn_chart_pump_c",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"name": "chart: Pump C",
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump C', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump C', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump C', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1450,
"y": 580,
"wires": [
[
"ui_chart_per_pump_flow"
],
[
"ui_chart_pumps_ctrl"
]
]
},
{
"id": "fn_chart_total",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"name": "chart: Total",
"func": "const cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nconst total = cache.downstream_predicted_flow ?? cache.atEquipment_predicted_flow;\nif (total == null) return null;\nreturn { topic: 'Total', payload: Number(total) };\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1450,
"y": 620,
"wires": [
[
"ui_chart_per_pump_flow"
]
]
},
{
"id": "ba175534fa51a1a9",
"type": "inject",
"z": "tab_mgc_dash",
"name": "",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": 0.1,
"topic": "empty_graphs",
"payload": "[]",
"payloadType": "json",
"x": 1520,
"y": 740,
"wires": [
[
"ui_chart_per_pump_flow",
"ui_chart_bep",
"ui_chart_power",
"ui_chart_flow"
]
]
},
{
"id": "ui_group_ctrl",
"type": "ui-group",
"name": "Controls",
"page": "ui_page_mgc",
"width": "6",
"height": "1",
"order": 1,
"showTitle": true,
"className": ""
},
{
"id": "ui_group_status",
"type": "ui-group",
"name": "Status",
"page": "ui_page_mgc",
"width": "6",
"height": "1",
"order": 2,
"showTitle": true,
"className": ""
},
{
"id": "ui_group_trends",
"type": "ui-group",
"name": "Trends",
"page": "ui_page_mgc",
"width": "12",
"height": "1",
"order": 3,
"showTitle": true,
"className": ""
},
{
"id": "ui_group_raw",
"type": "ui-group",
"name": "Raw output (Port 0 cache)",
"page": "ui_page_mgc",
"width": "12",
"height": "1",
"order": 4,
"showTitle": true,
"className": ""
},
{
"id": "ui_page_mgc",
"type": "ui-page",
"name": "MGC Basic",
"ui": "ui_base_mgc",
"path": "/mgc-basic",
"icon": "settings-input-component",
"layout": "grid",
"theme": "ui_theme_mgc",
"breakpoints": [
{
"name": "Default",
"px": "0",
"cols": "12"
}
],
"order": 1,
"className": ""
},
{
"id": "ui_base_mgc",
"type": "ui-base",
"name": "EVOLV Demo",
"path": "/dashboard",
"appIcon": "",
"includeClientData": true,
"acceptsClientConfig": [
"ui-notification",
"ui-control"
],
"showPathInSidebar": false,
"headerContent": "page",
"navigationStyle": "default",
"titleBarStyle": "default"
},
{
"id": "ui_theme_mgc",
"type": "ui-theme",
"name": "EVOLV Basic Theme",
"colors": {
"surface": "#ffffff",
"primary": "#50a8d9",
"bgPage": "#eeeeee",
"groupBg": "#ffffff",
"groupOutline": "#cccccc"
},
"sizes": {
"density": "default",
"pagePadding": "14px",
"groupGap": "14px",
"groupBorderRadius": "6px",
"widgetGap": "12px"
}
},
{
"id": "c6acdcdb49901fe9",
"type": "global-config",
"env": [],
"modules": {
"@flowfuse/node-red-dashboard": "1.30.2",
"EVOLV": "1.0.29"
}
},
{
"id": "ui_txt_eta",
"type": "ui-text",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_status",
"order": 8,
"width": "6",
"height": "1",
"name": "\u03b7 (hydraulic)",
"label": "\u03b7 (hydraulic)",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#1A5276",
"x": 1750,
"y": 360,
"wires": []
},
{
"id": "ui_txt_eta_peak",
"type": "ui-text",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_status",
"order": 9,
"width": "6",
"height": "1",
"name": "\u03b7 peak (BEP)",
"label": "\u03b7 peak (BEP)",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#1A5276",
"x": 1750,
"y": 420,
"wires": []
},
{
"id": "ui_txt_bep_abs",
"type": "ui-text",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_status",
"order": 10,
"width": "6",
"height": "1",
"name": "BEP gap (\u03b7 pts)",
"label": "BEP gap (\u03b7 pts)",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#7D3C98",
"x": 1750,
"y": 480,
"wires": []
},
{
"id": "ui_txt_ncog",
"type": "ui-text",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_status",
"order": 11,
"width": "6",
"height": "1",
"name": "BEP flow pos (NCog)",
"label": "BEP flow pos (NCog)",
"format": "{{msg.payload}}",
"layout": "row-spread",
"style": false,
"font": "",
"fontSize": 14,
"color": "#7D3C98",
"x": 1750,
"y": 540,
"wires": []
},
{
"id": "ui_chart_eta",
"type": "ui-chart",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "Efficiency (hydraulic, %)",
"label": "Hydraulic efficiency \u03b7 (%) \u2014 predicted vs peak",
"order": 104,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisType": "time",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "%",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": false,
"removeOlder": "15",
"removeOlderUnit": "60",
"removeOlderPoints": "",
"colors": [
"#A347E1",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#0095FF",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 6,
"height": 4,
"className": "",
"interpolation": "linear",
"x": 1770,
"y": 540,
"wires": []
},
{
"id": "fn_qh_point",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"name": "Q-H operating point",
"func": "// Build a single (Q, H) point for the operating-point series. The chart\n// is configured action='append', so we precede each new point with a\n// clear msg targeting this topic only \u2014 the dot moves without leaving a\n// trail. The Q-H curve overlay (topic='Curve') uses the same pattern.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\n\nconst Q = cache['atEquipment_predicted_flow']; // m\u00b3/h\nconst dpPa = (() => {\n if (Number.isFinite(+cache.headerDiffPa) && +cache.headerDiffPa > 0) return +cache.headerDiffPa;\n if (Number.isFinite(+cache.headerDiffMbar) && +cache.headerDiffMbar > 0) return +cache.headerDiffMbar * 100;\n const d = cache['differential_measured_pressure'];\n if (Number.isFinite(+d) && +d > 0) return +d * 100;\n const up = cache['upstream_measured_pressure'];\n const dn = cache['downstream_measured_pressure'];\n if (Number.isFinite(+up) && Number.isFinite(+dn) && +dn > +up) return (+dn - +up) * 100;\n return null;\n})();\nif (!Number.isFinite(+Q) || !Number.isFinite(+dpPa)) return null;\nconst H = dpPa / (999.1 * 9.80665);\nreturn [[\n { topic: 'Operating point', action: 'clear' },\n { topic: 'Operating point', payload: { x: +Q, y: +H } },\n]];\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1850,
"y": 760,
"wires": [
[
"ui_chart_qh"
]
]
},
{
"id": "ui_chart_qh",
"type": "ui-chart",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "Q-H operating point",
"label": "Q-H curve + operating point",
"order": 201,
"chartType": "line",
"interpolation": "linear",
"category": "topic",
"categoryType": "msg",
"xAxisType": "linear",
"xAxisProperty": "payload.x",
"xAxisPropertyType": "msg",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload.y",
"yAxisPropertyType": "msg",
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 5,
"showLegend": true,
"bins": 10,
"width": "12",
"height": "6",
"removeOlder": "0",
"removeOlderUnit": "1",
"removeOlderPoints": "",
"colors": [
"#0095FF",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"x": 2050,
"y": 760,
"wires": []
},
{
"id": "fn_qh_curve_fetcher",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_pump_a",
"name": "Q-H curve fetch (throttled)",
"func": "// Throttle: refetch the Q-H curve only when ctrl moves >2 percentage\n// points or \u0394p moves >50 mbar. Curve shape is shared across all pumps\n// once MGC equalizes the header, so any pump's port-0 stream works.\nconst cache = context.get('c') || { ctrl: null, dpMbar: null, id: null };\nconst p = msg.payload || {};\n// Tap the pump's node id from the runtime context (provided by Node-RED\n// when the upstream node injects it). Fallback to env var if needed.\nconst nodeId = msg._nodeId || cache.id || env.get('QH_PUMP_ID') || null;\nconst ctrl = (typeof p.ctrl === 'number') ? p.ctrl : cache.ctrl;\n// \u0394p keys vary across pumps; try the canonical set produced by the\n// rotatingMachine port-0 flattener.\nconst dpMbar = (() => {\n if (typeof p.differential_measured_pressure === 'number') return p.differential_measured_pressure;\n const dn = p['pressure.measured.downstream'] ?? p.downstream_measured_pressure;\n const up = p['pressure.measured.upstream'] ?? p.upstream_measured_pressure;\n if (typeof dn === 'number' && typeof up === 'number') return dn - up;\n return cache.dpMbar;\n})();\nif (typeof ctrl !== 'number' || typeof dpMbar !== 'number') return null;\nconst ctrlDelta = (cache.ctrl == null) ? Infinity : Math.abs(ctrl - cache.ctrl);\nconst dpDelta = (cache.dpMbar == null) ? Infinity : Math.abs(dpMbar - cache.dpMbar);\nif (ctrlDelta < 2 && dpDelta < 50 && nodeId === cache.id) return null;\ncontext.set('c', { ctrl, dpMbar, id: nodeId });\nif (!nodeId) {\n node.warn('No pump node id known yet \u2014 set msg._nodeId or env QH_PUMP_ID');\n return null;\n}\n// Emit a single msg the http-request node will consume.\nreturn { method: 'GET', url: `/rotatingMachine/${nodeId}/qh-curve?ctrl=${ctrl.toFixed(2)}`, _nodeId: nodeId, _ctrl: ctrl, _dpMbar: dpMbar };\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1850,
"y": 840,
"wires": [
[
"fn_qh_http"
]
]
},
{
"id": "fn_qh_http",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_pump_a",
"name": "Q-H curve HTTP GET",
"func": "// Run the HTTP fetch using Node 20's global fetch. The function-node\n// scope is sandboxed, so we resolve the absolute URL using the same host\n// the dashboard runs on. Result body flows to the next function.\nconst baseUrl = global.get('NODE_RED_BASE_URL') || 'http://localhost:1880';\ntry {\n const r = await fetch(baseUrl + msg.url);\n if (!r.ok) { node.warn(`qh-curve HTTP ${r.status}`); return null; }\n msg.payload = await r.json();\n return msg;\n} catch (err) {\n node.warn(`qh-curve fetch failed: ${err.message}`);\n return null;\n}\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 2050,
"y": 840,
"wires": [
[
"fn_qh_fanout"
]
]
},
{
"id": "fn_qh_fanout",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_pump_a",
"name": "Q-H curve points \u2192 chart",
"func": "// Emit one chart msg per point. Topic='Curve' makes the chart treat\n// it as a second series next to the 'Operating point' scatter.\n// Action 'replace' so each new sample sweeps the curve fresh (no\n// trail buildup).\nconst r = msg.payload || {};\nif (r.error || !Array.isArray(r.points) || r.points.length === 0) return null;\n\n// Trim the trailing flat-Q tail. buildQHCurve returns ~33 points across the\n// full pressure envelope, but at low ctrl% the last ~10 points clamp to the\n// pump's minimum-flow envelope (constant Q across rising H). Plotting those\n// stretches the chart's H axis to ~40 m even though the operating point sits\n// near H=11 m \u2014 making the curve look like a vertical line with the\n// operating point lost at the bottom. Keep one entry-point of the tail so\n// the curve still terminates visually, drop the rest.\nconst FLAT_Q_EPS = 0.5; // m\u00b3/h \u2014 pump-curve resolution; below this is noise.\nlet trimTo = r.points.length;\nfor (let i = r.points.length - 1; i > 0; i--) {\n if (Math.abs(r.points[i].Q - r.points[i-1].Q) >= FLAT_Q_EPS) { trimTo = i + 1; break; }\n}\nconst trimmed = r.points.slice(0, trimTo);\n\nconst out = trimmed.map((pt) => ({ topic: 'Curve', payload: { x: pt.Q, y: pt.H } }));\n// Send a reset to clear the previous curve before appending the new one.\nout.unshift({ topic: 'Curve', action: 'clear' });\nreturn [out];\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 2250,
"y": 840,
"wires": [
[
"ui_chart_qh"
]
]
},
{
"id": "fn_qh_inject_id",
"type": "function",
"z": "tab_mgc_dash",
"g": "grp_pump_a",
"name": "tag with pump id",
"func": "msg._nodeId = 'rm_dash_pump_a'; return msg;\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1700,
"y": 840,
"wires": [
[
"fn_qh_curve_fetcher"
]
]
},
{
"id": "ui_chart_mgc_pctcap",
"type": "ui-chart",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "% of capacity",
"label": "Group % of capacity \u2014 flow \u00f7 Qmax",
"order": 5,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisType": "time",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "%",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "0",
"ymax": "120",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": false,
"removeOlder": "15",
"removeOlderUnit": "60",
"removeOlderPoints": "",
"colors": [
"#A347E1",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#0095FF",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 6,
"height": 4,
"className": "",
"interpolation": "linear",
"x": 1770,
"y": 500,
"wires": [
[]
]
},
{
"id": "ui_chart_pumps_ctrl",
"type": "ui-chart",
"z": "tab_mgc_dash",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "Per-pump % control",
"label": "Per-pump % control \u2014 A / B / C",
"order": 6,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisType": "time",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "%",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"ymin": "-5",
"ymax": "100",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"removeOlder": "15",
"removeOlderUnit": "60",
"removeOlderPoints": "",
"colors": [
"#FF7F0E",
"#2CA02C",
"#A347E1",
"#0095FF",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5",
"#cccccc"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 6,
"height": 4,
"className": "",
"interpolation": "linear",
"x": 1750,
"y": 700,
"wires": [
[]
]
}
]