[ { "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 — 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\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];\n", "outputs": 17, "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" ] ] }, { "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", "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.');\nif (flow == null) return null;\nreturn { topic: 'Pump A', payload: Number(flow) };\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1450, "y": 500, "wires": [ [ "ui_chart_per_pump_flow" ] ] }, { "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.');\nif (flow == null) return null;\nreturn { topic: 'Pump B', payload: Number(flow) };\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1450, "y": 540, "wires": [ [ "ui_chart_per_pump_flow" ] ] }, { "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.');\nif (flow == null) return null;\nreturn { topic: 'Pump C', payload: Number(flow) };\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1450, "y": 580, "wires": [ [ "ui_chart_per_pump_flow" ] ] }, { "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 — 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³/h — 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" ] ] } ]