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 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) + '/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) + '' : '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