diff --git a/examples/pumpingstation-3pumps-dashboard/build_flow.py b/examples/pumpingstation-3pumps-dashboard/build_flow.py index 1c510dc..2dc79dc 100644 --- a/examples/pumpingstation-3pumps-dashboard/build_flow.py +++ b/examples/pumpingstation-3pumps-dashboard/build_flow.py @@ -611,14 +611,26 @@ def build_process_tab(): "const vol = find('volume.predicted.');\n" "const qIn = find('flow.measured.upstream.') || find('flow.measured.in.');\n" "const qOut = find('flow.measured.downstream.') || find('flow.measured.out.');\n" + "// Compute derived metrics\n" + "const maxVol = 9.33; // must match basinVolume * basinHeight / basinHeight = basinVolume / surfaceArea * height\n" + "const fillPct = vol != null ? Math.round(Number(vol) / maxVol * 100) : null;\n" + "const netM3h = (c.netFlow != null) ? Number(c.netFlow) * 3600 : null;\n" + "const seconds = (c.seconds != null && Number.isFinite(Number(c.seconds))) ? Number(c.seconds) : null;\n" + "const timeStr = seconds != null ? (seconds > 60 ? Math.round(seconds/60) + ' min' : Math.round(seconds) + ' s') : 'n/a';\n" "msg.payload = {\n" - " level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n" - " volume: vol != null ? Number(vol).toFixed(1) + ' m³' : 'n/a',\n" - " qIn: qIn != null ? (Number(qIn) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n" - " qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n" - " state: c.state || c.direction || 'idle',\n" - " levelNum: lvl != null ? Number(lvl) : null,\n" - " volumeNum: vol != null ? Number(vol) : null,\n" + " direction: c.direction || 'steady',\n" + " level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n" + " volume: vol != null ? Number(vol).toFixed(1) + ' m³' : 'n/a',\n" + " fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n" + " netFlow: netM3h != null ? netM3h.toFixed(0) + ' m³/h' : 'n/a',\n" + " timeLeft: timeStr,\n" + " qIn: qIn != null ? (Number(qIn) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n" + " qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n" + " // Numerics for trends\n" + " levelNum: lvl != null ? Number(lvl) : null,\n" + " volumeNum: vol != null ? Number(vol) : null,\n" + " fillPctNum: fillPct,\n" + " netFlowNum: netM3h,\n" "};\n" "return msg;", outputs=1, wires=[["lout_evt_ps"]], @@ -719,10 +731,12 @@ def build_ui_tab(): ui_group(g_pump_b, "4b. Pump B", PG, width=4, order=6), ui_group(g_pump_c, "4c. Pump C", PG, width=4, order=7), # Trends on separate pages - ui_group(g_trend_short_flow, "Flow (10 min)", PG_SHORT, width=12, order=1), - ui_group(g_trend_short_power, "Power (10 min)", PG_SHORT, width=12, order=2), - ui_group(g_trend_long_flow, "Flow (1 hour)", PG_LONG, width=12, order=1), - ui_group(g_trend_long_power, "Power (1 hour)", PG_LONG, width=12, order=2), + ui_group(g_trend_short_flow, "Flow (10 min)", PG_SHORT, width=12, order=1), + ui_group(g_trend_short_power, "Power (10 min)", PG_SHORT, width=12, order=2), + ui_group("ui_grp_trend_short_basin", "Basin (10 min)", PG_SHORT, width=12, order=3), + ui_group(g_trend_long_flow, "Flow (1 hour)", PG_LONG, width=12, order=1), + ui_group(g_trend_long_power, "Power (1 hour)", PG_LONG, width=12, order=2), + ui_group("ui_grp_trend_long_basin", "Basin (1 hour)", PG_LONG, width=12, order=3), ] nodes.append(comment("c_ui_title", TAB_UI, LANE_X[2], 20, @@ -863,30 +877,56 @@ def build_ui_tab(): CH_PS_EVT, source_out_ids=["lout_evt_ps"], downstream=["dispatch_ps"] )) + # PS dispatcher: 10 outputs — 7 text fields + 3 trend numerics nodes.append(function_node( "dispatch_ps", TAB_UI, LANE_X[1], y + 160, "dispatch PS", "const p = msg.payload || {};\n" + "const ts = Date.now();\n" "return [\n" - " {payload: String(p.state || 'idle')},\n" - " {payload: String(p.level || 'n/a')},\n" - " {payload: String(p.volume || 'n/a')},\n" - " {payload: String(p.qIn || 'n/a')},\n" - " {payload: String(p.qOut || 'n/a')},\n" + " {payload: String(p.direction || 'steady')},\n" + " {payload: String(p.level || 'n/a')},\n" + " {payload: String(p.volume || 'n/a')},\n" + " {payload: String(p.fillPct || 'n/a')},\n" + " {payload: String(p.netFlow || 'n/a')},\n" + " {payload: String(p.timeLeft || 'n/a')},\n" + " {payload: String(p.qIn || 'n/a')},\n" + " // Trend numerics\n" + " p.fillPctNum != null ? {topic: 'Basin fill', payload: p.fillPctNum, timestamp: ts} : null,\n" + " p.levelNum != null ? {topic: 'Basin level', payload: p.levelNum, timestamp: ts} : null,\n" + " p.netFlowNum != null ? {topic: 'Net flow', payload: p.netFlowNum,timestamp: ts} : null,\n" "];", - outputs=5, - wires=[["ui_ps_state"], ["ui_ps_level"], ["ui_ps_volume"], ["ui_ps_qin"], ["ui_ps_qout"]], + outputs=10, + wires=[ + ["ui_ps_direction"], + ["ui_ps_level"], + ["ui_ps_volume"], + ["ui_ps_fill"], + ["ui_ps_netflow"], + ["ui_ps_timeleft"], + ["ui_ps_qin"], + # Trend outputs → both short + long charts + ["trend_short_basin", "trend_long_basin"], # fill % + ["trend_short_basin", "trend_long_basin"], # level + ["trend_short_basin", "trend_long_basin"], # net flow + ], )) - nodes.append(ui_text("ui_ps_state", TAB_UI, LANE_X[2], y + 160, g_ps, - "PS state", "Basin state", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_level", TAB_UI, LANE_X[2], y + 200, g_ps, - "PS level", "Basin level", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_volume", TAB_UI, LANE_X[2], y + 240, g_ps, - "PS volume","Basin volume", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_qin", TAB_UI, LANE_X[2], y + 280, g_ps, - "PS Qin", "Inflow", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_qout", TAB_UI, LANE_X[2], y + 320, g_ps, - "PS Qout", "Pumped out", "{{msg.payload}}")) + + # PS text widgets + nodes.append(ui_text("ui_ps_direction", TAB_UI, LANE_X[2], y + 160, g_ps, + "PS direction", "Direction", "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_level", TAB_UI, LANE_X[2], y + 200, g_ps, + "PS level", "Basin level", "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_volume", TAB_UI, LANE_X[2], y + 240, g_ps, + "PS volume", "Basin volume", "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_fill", TAB_UI, LANE_X[2], y + 280, g_ps, + "PS fill %", "Fill level", "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_netflow", TAB_UI, LANE_X[2], y + 320, g_ps, + "PS net flow", "Net flow", "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_timeleft", TAB_UI, LANE_X[2], y + 360, g_ps, + "PS time left", "Time to full/empty", "{{msg.payload}}")) + nodes.append(ui_text("ui_ps_qin", TAB_UI, LANE_X[2], y + 400, g_ps, + "PS Qin", "Inflow", "{{msg.payload}}")) # ===== SECTION: Per-pump panels ===== y_pumps_start = 1000 @@ -1046,6 +1086,26 @@ def build_ui_tab(): order=1, )) + # ===== Basin charts (fill %, level, net flow) ===== + # Short-term + nodes.append(ui_chart( + "trend_short_basin", TAB_UI, LANE_X[3], y_charts + 360, + "ui_grp_trend_short_basin", + "Basin — 10 min", "Basin metrics", + width=12, height=8, + remove_older="10", remove_older_unit="60", remove_older_points="300", + y_axis_label="", order=1, + )) + # Long-term + nodes.append(ui_chart( + "trend_long_basin", TAB_UI, LANE_X[3], y_charts + 440, + "ui_grp_trend_long_basin", + "Basin — 1 hour", "Basin metrics", + width=12, height=8, + remove_older="60", remove_older_unit="60", remove_older_points="1800", + y_axis_label="", order=1, + )) + return nodes diff --git a/examples/pumpingstation-3pumps-dashboard/flow.json b/examples/pumpingstation-3pumps-dashboard/flow.json index 7ba6064..fdc70ee 100644 --- a/examples/pumpingstation-3pumps-dashboard/flow.json +++ b/examples/pumpingstation-3pumps-dashboard/flow.json @@ -881,7 +881,7 @@ "type": "function", "z": "tab_process", "name": "format PS port 0", - "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst lvl = find('level.predicted.');\nconst vol = find('volume.predicted.');\nconst qIn = find('flow.measured.upstream.') || find('flow.measured.in.');\nconst qOut = find('flow.measured.downstream.') || find('flow.measured.out.');\nmsg.payload = {\n level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n volume: vol != null ? Number(vol).toFixed(1) + ' m\u00b3' : 'n/a',\n qIn: qIn != null ? (Number(qIn) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n state: c.state || c.direction || 'idle',\n levelNum: lvl != null ? Number(lvl) : null,\n volumeNum: vol != null ? Number(vol) : null,\n};\nreturn msg;", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst lvl = find('level.predicted.');\nconst vol = find('volume.predicted.');\nconst qIn = find('flow.measured.upstream.') || find('flow.measured.in.');\nconst qOut = find('flow.measured.downstream.') || find('flow.measured.out.');\n// Compute derived metrics\nconst maxVol = 9.33; // must match basinVolume * basinHeight / basinHeight = basinVolume / surfaceArea * height\nconst fillPct = vol != null ? Math.round(Number(vol) / maxVol * 100) : null;\nconst netM3h = (c.netFlow != null) ? Number(c.netFlow) * 3600 : null;\nconst seconds = (c.seconds != null && Number.isFinite(Number(c.seconds))) ? Number(c.seconds) : null;\nconst timeStr = seconds != null ? (seconds > 60 ? Math.round(seconds/60) + ' min' : Math.round(seconds) + ' s') : 'n/a';\nmsg.payload = {\n direction: c.direction || 'steady',\n level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n volume: vol != null ? Number(vol).toFixed(1) + ' m\u00b3' : 'n/a',\n fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n netFlow: netM3h != null ? netM3h.toFixed(0) + ' m\u00b3/h' : 'n/a',\n timeLeft: timeStr,\n qIn: qIn != null ? (Number(qIn) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n // Numerics for trends\n levelNum: lvl != null ? Number(lvl) : null,\n volumeNum: vol != null ? Number(vol) : null,\n fillPctNum: fillPct,\n netFlowNum: netM3h,\n};\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", @@ -1343,6 +1343,20 @@ "disabled": false, "visible": true }, + { + "id": "ui_grp_trend_short_basin", + "type": "ui-group", + "name": "Basin (10 min)", + "page": "ui_page_short_trends", + "width": "12", + "height": "1", + "order": 3, + "showTitle": true, + "className": "", + "groupType": "default", + "disabled": false, + "visible": true + }, { "id": "ui_grp_trend_long_flow", "type": "ui-group", @@ -1371,6 +1385,20 @@ "disabled": false, "visible": true }, + { + "id": "ui_grp_trend_long_basin", + "type": "ui-group", + "name": "Basin (1 hour)", + "page": "ui_page_long_trends", + "width": "12", + "height": "1", + "order": 3, + "showTitle": true, + "className": "", + "groupType": "default", + "disabled": false, + "visible": true + }, { "id": "c_ui_title", "type": "comment", @@ -1829,8 +1857,8 @@ "type": "function", "z": "tab_ui", "name": "dispatch PS", - "func": "const p = msg.payload || {};\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.level || 'n/a')},\n {payload: String(p.volume || 'n/a')},\n {payload: String(p.qIn || 'n/a')},\n {payload: String(p.qOut || 'n/a')},\n];", - "outputs": 5, + "func": "const p = msg.payload || {};\nconst ts = Date.now();\nreturn [\n {payload: String(p.direction || 'steady')},\n {payload: String(p.level || 'n/a')},\n {payload: String(p.volume || 'n/a')},\n {payload: String(p.fillPct || 'n/a')},\n {payload: String(p.netFlow || 'n/a')},\n {payload: String(p.timeLeft || 'n/a')},\n {payload: String(p.qIn || 'n/a')},\n // Trend numerics\n p.fillPctNum != null ? {topic: 'Basin fill', payload: p.fillPctNum, timestamp: ts} : null,\n p.levelNum != null ? {topic: 'Basin level', payload: p.levelNum, timestamp: ts} : null,\n p.netFlowNum != null ? {topic: 'Net flow', payload: p.netFlowNum,timestamp: ts} : null,\n];", + "outputs": 10, "noerr": 0, "initialize": "", "finalize": "", @@ -1839,7 +1867,7 @@ "y": 760, "wires": [ [ - "ui_ps_state" + "ui_ps_direction" ], [ "ui_ps_level" @@ -1847,24 +1875,42 @@ [ "ui_ps_volume" ], + [ + "ui_ps_fill" + ], + [ + "ui_ps_netflow" + ], + [ + "ui_ps_timeleft" + ], [ "ui_ps_qin" ], [ - "ui_ps_qout" + "trend_short_basin", + "trend_long_basin" + ], + [ + "trend_short_basin", + "trend_long_basin" + ], + [ + "trend_short_basin", + "trend_long_basin" ] ] }, { - "id": "ui_ps_state", + "id": "ui_ps_direction", "type": "ui-text", "z": "tab_ui", "group": "ui_grp_ps", "order": 1, "width": "0", "height": "0", - "name": "PS state", - "label": "Basin state", + "name": "PS direction", + "label": "Direction", "format": "{{msg.payload}}", "layout": "row-left", "style": false, @@ -1915,6 +1961,66 @@ "y": 840, "wires": [] }, + { + "id": "ui_ps_fill", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_ps", + "order": 1, + "width": "0", + "height": "0", + "name": "PS fill %", + "label": "Fill level", + "format": "{{msg.payload}}", + "layout": "row-left", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 880, + "wires": [] + }, + { + "id": "ui_ps_netflow", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_ps", + "order": 1, + "width": "0", + "height": "0", + "name": "PS net flow", + "label": "Net flow", + "format": "{{msg.payload}}", + "layout": "row-left", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 920, + "wires": [] + }, + { + "id": "ui_ps_timeleft", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_ps", + "order": 1, + "width": "0", + "height": "0", + "name": "PS time left", + "label": "Time to full/empty", + "format": "{{msg.payload}}", + "layout": "row-left", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 960, + "wires": [] + }, { "id": "ui_ps_qin", "type": "ui-text", @@ -1932,27 +2038,7 @@ "fontSize": 14, "color": "#000000", "x": 640, - "y": 880, - "wires": [] - }, - { - "id": "ui_ps_qout", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_ps", - "order": 1, - "width": "0", - "height": "0", - "name": "PS Qout", - "label": "Pumped out", - "format": "{{msg.payload}}", - "layout": "row-left", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 920, + "y": 1000, "wires": [] }, { @@ -3311,6 +3397,130 @@ [] ] }, + { + "id": "trend_short_basin", + "type": "ui-chart", + "z": "tab_ui", + "group": "ui_grp_trend_short_basin", + "name": "Basin \u2014 10 min", + "label": "Basin metrics", + "order": 1, + "chartType": "line", + "interpolation": "linear", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "", + "xAxisType": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "xmin": "", + "xmax": "", + "yAxisLabel": "", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "ymin": "", + "ymax": "", + "removeOlder": "10", + "removeOlderUnit": "60", + "removeOlderPoints": "300", + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "showLegend": true, + "bins": 10, + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "width": 12, + "height": 8, + "className": "", + "x": 900, + "y": 2640, + "wires": [ + [] + ] + }, + { + "id": "trend_long_basin", + "type": "ui-chart", + "z": "tab_ui", + "group": "ui_grp_trend_long_basin", + "name": "Basin \u2014 1 hour", + "label": "Basin metrics", + "order": 1, + "chartType": "line", + "interpolation": "linear", + "category": "topic", + "categoryType": "msg", + "xAxisLabel": "", + "xAxisType": "time", + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "xAxisFormat": "", + "xAxisFormatType": "auto", + "xmin": "", + "xmax": "", + "yAxisLabel": "", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "ymin": "", + "ymax": "", + "removeOlder": "60", + "removeOlderUnit": "60", + "removeOlderPoints": "1800", + "action": "append", + "stackSeries": false, + "pointShape": "circle", + "pointRadius": 4, + "showLegend": true, + "bins": 10, + "colors": [ + "#0095FF", + "#FF0000", + "#FF7F0E", + "#2CA02C", + "#A347E1", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5" + ], + "textColor": [ + "#666666" + ], + "textColorDefault": true, + "gridColor": [ + "#e5e5e5" + ], + "gridColorDefault": true, + "width": 12, + "height": 8, + "className": "", + "x": 900, + "y": 2720, + "wires": [ + [] + ] + }, { "id": "tab_drivers", "type": "tab",