feat(dashboard): add basin fill gauge, countdown, and basin trend charts
Some checks failed
CI / lint-and-test (push) Has been cancelled

PS control page now shows 7 fields instead of 5:
  - Direction (filling/draining/steady)
  - Basin level (m)
  - Basin volume (m³)
  - Fill level (%)
  - Net flow (m³/h, signed)
  - Time to full/empty (countdown in min or s)
  - Inflow (m³/h)

Two new trend pages per time window (short 10 min / long 1 hour):
  - Basin chart: 3 series (Basin fill %, Basin level m, Net flow m³/h)
    on both Trends 10 min and Trends 1 hour pages.

PS formatter now extracts direction, netFlow, seconds from the delta-
compressed port 0 cache and computes fillPct from vol/maxVol. Dispatcher
sends 10 outputs (7 text + 3 trend numerics to both short+long basin
charts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-14 10:35:44 +02:00
parent 60c8d0ff66
commit b18c47c07e
2 changed files with 327 additions and 57 deletions

View File

@@ -611,14 +611,26 @@ def build_process_tab():
"const vol = find('volume.predicted.');\n" "const vol = find('volume.predicted.');\n"
"const qIn = find('flow.measured.upstream.') || find('flow.measured.in.');\n" "const qIn = find('flow.measured.upstream.') || find('flow.measured.in.');\n"
"const qOut = find('flow.measured.downstream.') || find('flow.measured.out.');\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" "msg.payload = {\n"
" direction: c.direction || 'steady',\n"
" level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n" " level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n"
" volume: vol != null ? Number(vol).toFixed(1) + '' : 'n/a',\n" " volume: vol != null ? Number(vol).toFixed(1) + '' : '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" " 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" " qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n"
" state: c.state || c.direction || 'idle',\n" " // Numerics for trends\n"
" levelNum: lvl != null ? Number(lvl) : null,\n" " levelNum: lvl != null ? Number(lvl) : null,\n"
" volumeNum: vol != null ? Number(vol) : null,\n" " volumeNum: vol != null ? Number(vol) : null,\n"
" fillPctNum: fillPct,\n"
" netFlowNum: netM3h,\n"
"};\n" "};\n"
"return msg;", "return msg;",
outputs=1, wires=[["lout_evt_ps"]], outputs=1, wires=[["lout_evt_ps"]],
@@ -721,8 +733,10 @@ def build_ui_tab():
# Trends on separate pages # Trends on separate pages
ui_group(g_trend_short_flow, "Flow (10 min)", PG_SHORT, width=12, order=1), 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_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_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_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, 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"], CH_PS_EVT, source_out_ids=["lout_evt_ps"],
downstream=["dispatch_ps"] downstream=["dispatch_ps"]
)) ))
# PS dispatcher: 10 outputs — 7 text fields + 3 trend numerics
nodes.append(function_node( nodes.append(function_node(
"dispatch_ps", TAB_UI, LANE_X[1], y + 160, "dispatch_ps", TAB_UI, LANE_X[1], y + 160,
"dispatch PS", "dispatch PS",
"const p = msg.payload || {};\n" "const p = msg.payload || {};\n"
"const ts = Date.now();\n"
"return [\n" "return [\n"
" {payload: String(p.state || 'idle')},\n" " {payload: String(p.direction || 'steady')},\n"
" {payload: String(p.level || 'n/a')},\n" " {payload: String(p.level || 'n/a')},\n"
" {payload: String(p.volume || '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" " {payload: String(p.qIn || 'n/a')},\n"
" {payload: String(p.qOut || '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, outputs=10,
wires=[["ui_ps_state"], ["ui_ps_level"], ["ui_ps_volume"], ["ui_ps_qin"], ["ui_ps_qout"]], 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}}")) # 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, nodes.append(ui_text("ui_ps_level", TAB_UI, LANE_X[2], y + 200, g_ps,
"PS level", "Basin level", "{{msg.payload}}")) "PS level", "Basin level", "{{msg.payload}}"))
nodes.append(ui_text("ui_ps_volume", TAB_UI, LANE_X[2], y + 240, g_ps, nodes.append(ui_text("ui_ps_volume", TAB_UI, LANE_X[2], y + 240, g_ps,
"PS volume", "Basin volume", "{{msg.payload}}")) "PS volume", "Basin volume", "{{msg.payload}}"))
nodes.append(ui_text("ui_ps_qin", TAB_UI, LANE_X[2], y + 280, g_ps, 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}}")) "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}}"))
# ===== SECTION: Per-pump panels ===== # ===== SECTION: Per-pump panels =====
y_pumps_start = 1000 y_pumps_start = 1000
@@ -1046,6 +1086,26 @@ def build_ui_tab():
order=1, 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 return nodes

View File

@@ -881,7 +881,7 @@
"type": "function", "type": "function",
"z": "tab_process", "z": "tab_process",
"name": "format PS port 0", "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, "outputs": 1,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -1343,6 +1343,20 @@
"disabled": false, "disabled": false,
"visible": true "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", "id": "ui_grp_trend_long_flow",
"type": "ui-group", "type": "ui-group",
@@ -1371,6 +1385,20 @@
"disabled": false, "disabled": false,
"visible": true "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", "id": "c_ui_title",
"type": "comment", "type": "comment",
@@ -1829,8 +1857,8 @@
"type": "function", "type": "function",
"z": "tab_ui", "z": "tab_ui",
"name": "dispatch PS", "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];", "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": 5, "outputs": 10,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
"finalize": "", "finalize": "",
@@ -1839,7 +1867,7 @@
"y": 760, "y": 760,
"wires": [ "wires": [
[ [
"ui_ps_state" "ui_ps_direction"
], ],
[ [
"ui_ps_level" "ui_ps_level"
@@ -1847,24 +1875,42 @@
[ [
"ui_ps_volume" "ui_ps_volume"
], ],
[
"ui_ps_fill"
],
[
"ui_ps_netflow"
],
[
"ui_ps_timeleft"
],
[ [
"ui_ps_qin" "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", "type": "ui-text",
"z": "tab_ui", "z": "tab_ui",
"group": "ui_grp_ps", "group": "ui_grp_ps",
"order": 1, "order": 1,
"width": "0", "width": "0",
"height": "0", "height": "0",
"name": "PS state", "name": "PS direction",
"label": "Basin state", "label": "Direction",
"format": "{{msg.payload}}", "format": "{{msg.payload}}",
"layout": "row-left", "layout": "row-left",
"style": false, "style": false,
@@ -1915,6 +1961,66 @@
"y": 840, "y": 840,
"wires": [] "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", "id": "ui_ps_qin",
"type": "ui-text", "type": "ui-text",
@@ -1932,27 +2038,7 @@
"fontSize": 14, "fontSize": 14,
"color": "#000000", "color": "#000000",
"x": 640, "x": 640,
"y": 880, "y": 1000,
"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,
"wires": [] "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", "id": "tab_drivers",
"type": "tab", "type": "tab",