Files
EVOLV/examples/pumpingstation-3pumps-dashboard/build_flow.py
znetsixe b18c47c07e
Some checks failed
CI / lint-and-test (push) Has been cancelled
feat(dashboard): add basin fill gauge, countdown, and basin trend charts
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>
2026-04-14 10:35:44 +02:00

1276 lines
55 KiB
Python

#!/usr/bin/env python3
"""
Generate the multi-tab Node-RED flow for the
'pumpingstation-3pumps-dashboard' end-to-end demo.
Layout philosophy
-----------------
Every node gets a home tab based on its CONCERN, not the data it touches:
Tab 1 Process Plant only EVOLV nodes (pumps, MGC, PS, measurements)
+ small per-node output formatters. NO UI, NO
demo drivers, NO setup logic. This is the
deployable plant model in isolation.
Tab 2 Dashboard UI only ui-* widgets. NO routing logic beyond
topic-tagging for the chart legends.
Tab 3 Demo Drivers auto random demand generator + station-wide
command fan-outs. Only here so the demo "lives"
without an operator. Removable in production.
Tab 4 Setup & Init one-shot deploy-time injects (mode, scaling,
auto-startup). Easy to disable for production.
Cross-tab wiring is via NAMED link-out / link-in pairs, not direct
wires. The channel names are the contract — see CHANNELS below.
Spacing
-------
Five lanes per tab, x in [120, 380, 640, 900, 1160]. Row pitch 80 px.
Major sections separated by 200 px y-shift + a comment header.
To regenerate:
python3 build_flow.py > flow.json
"""
import json
import sys
# ---------------------------------------------------------------------------
# Tab IDs
# ---------------------------------------------------------------------------
TAB_PROCESS = "tab_process"
TAB_UI = "tab_ui"
TAB_DRIVERS = "tab_drivers"
TAB_SETUP = "tab_setup"
# ---------------------------------------------------------------------------
# Spacing constants
# ---------------------------------------------------------------------------
LANE_X = [120, 380, 640, 900, 1160, 1420]
ROW = 80 # standard inter-row pitch
SECTION_GAP = 200 # additional shift between major sections
# ---------------------------------------------------------------------------
# Cross-tab link channel names (the wiring contract)
# ---------------------------------------------------------------------------
# command channels: dashboard or drivers -> process
CH_DEMAND = "cmd:demand" # numeric demand (m3/h)
CH_RANDOM_TOGGLE = "cmd:randomToggle" # 'on' / 'off'
CH_MODE = "cmd:mode" # 'auto' / 'virtualControl' setMode broadcast
CH_STATION_START = "cmd:station-startup"
CH_STATION_STOP = "cmd:station-shutdown"
CH_STATION_ESTOP = "cmd:station-estop"
CH_PUMP_SETPOINT = {"pump_a": "cmd:setpoint-A",
"pump_b": "cmd:setpoint-B",
"pump_c": "cmd:setpoint-C"}
CH_PUMP_SEQUENCE = {"pump_a": "cmd:pump-A-seq", # carries startup/shutdown
"pump_b": "cmd:pump-B-seq",
"pump_c": "cmd:pump-C-seq"}
# event channels: process -> dashboard
CH_PUMP_EVT = {"pump_a": "evt:pump-A",
"pump_b": "evt:pump-B",
"pump_c": "evt:pump-C"}
CH_MGC_EVT = "evt:mgc"
CH_PS_EVT = "evt:ps"
PUMPS = ["pump_a", "pump_b", "pump_c"]
PUMP_LABELS = {"pump_a": "Pump A", "pump_b": "Pump B", "pump_c": "Pump C"}
# ---------------------------------------------------------------------------
# Generic node-builder helpers
# ---------------------------------------------------------------------------
def comment(node_id, tab, x, y, name, info=""):
return {"id": node_id, "type": "comment", "z": tab, "name": name,
"info": info, "x": x, "y": y, "wires": []}
def inject(node_id, tab, x, y, name, topic, payload, payload_type="str",
once=False, repeat="", once_delay="0.5", wires=None):
"""Inject node using the per-prop v/vt form so payload_type=json works."""
return {
"id": node_id, "type": "inject", "z": tab, "name": name,
"props": [
{"p": "topic", "vt": "str"},
{"p": "payload", "v": str(payload), "vt": payload_type},
],
"topic": topic, "payload": str(payload), "payloadType": payload_type,
"repeat": repeat, "crontab": "",
"once": once, "onceDelay": once_delay,
"x": x, "y": y, "wires": [wires or []],
}
def function_node(node_id, tab, x, y, name, code, outputs=1, wires=None):
return {
"id": node_id, "type": "function", "z": tab, "name": name,
"func": code, "outputs": outputs,
"noerr": 0, "initialize": "", "finalize": "", "libs": [],
"x": x, "y": y, "wires": wires if wires is not None else [[] for _ in range(outputs)],
}
def link_out(node_id, tab, x, y, channel_name, target_in_ids):
"""Mode 'link' — fires the named link-in nodes (by id)."""
return {
"id": node_id, "type": "link out", "z": tab, "name": channel_name,
"mode": "link", "links": list(target_in_ids),
"x": x, "y": y, "wires": [],
}
def link_in(node_id, tab, x, y, channel_name, source_out_ids, downstream):
return {
"id": node_id, "type": "link in", "z": tab, "name": channel_name,
"links": list(source_out_ids),
"x": x, "y": y, "wires": [downstream or []],
}
def debug_node(node_id, tab, x, y, name, target="payload",
target_type="msg", active=False):
return {
"id": node_id, "type": "debug", "z": tab, "name": name,
"active": active, "tosidebar": True, "console": False, "tostatus": False,
"complete": target, "targetType": target_type,
"x": x, "y": y, "wires": [],
}
# ---------------------------------------------------------------------------
# Dashboard scaffolding (ui-base / ui-theme / ui-page / ui-group)
# ---------------------------------------------------------------------------
def dashboard_scaffold():
base = {
"id": "ui_base_ps_demo", "type": "ui-base", "name": "EVOLV Demo",
"path": "/dashboard", "appIcon": "",
"includeClientData": True,
"acceptsClientConfig": ["ui-notification", "ui-control"],
"showPathInSidebar": True, "headerContent": "page",
"navigationStyle": "default", "titleBarStyle": "default",
}
theme = {
"id": "ui_theme_ps_demo", "type": "ui-theme", "name": "EVOLV Theme",
"colors": {
"surface": "#ffffff", "primary": "#0f52a5",
"bgPage": "#f4f6fa", "groupBg": "#ffffff", "groupOutline": "#cccccc",
},
"sizes": {
"density": "default", "pagePadding": "12px",
"groupGap": "12px", "groupBorderRadius": "6px", "widgetGap": "8px",
},
}
page_control = {
"id": "ui_page_control", "type": "ui-page",
"name": "Control", "ui": "ui_base_ps_demo",
"path": "/pumping-station-demo", "icon": "water_pump",
"layout": "grid", "theme": "ui_theme_ps_demo",
"breakpoints": [{"name": "Default", "px": "0", "cols": "12"}],
"order": 1, "className": "",
}
page_short = {
"id": "ui_page_short_trends", "type": "ui-page",
"name": "Trends — 10 min", "ui": "ui_base_ps_demo",
"path": "/pumping-station-demo/trends-short", "icon": "show_chart",
"layout": "grid", "theme": "ui_theme_ps_demo",
"breakpoints": [{"name": "Default", "px": "0", "cols": "12"}],
"order": 2, "className": "",
}
page_long = {
"id": "ui_page_long_trends", "type": "ui-page",
"name": "Trends — 1 hour", "ui": "ui_base_ps_demo",
"path": "/pumping-station-demo/trends-long", "icon": "timeline",
"layout": "grid", "theme": "ui_theme_ps_demo",
"breakpoints": [{"name": "Default", "px": "0", "cols": "12"}],
"order": 3, "className": "",
}
return [base, theme, page_control, page_short, page_long]
def ui_group(group_id, name, page_id, width=6, order=1):
return {
"id": group_id, "type": "ui-group", "name": name, "page": page_id,
"width": str(width), "height": "1", "order": order,
"showTitle": True, "className": "", "groupType": "default",
"disabled": False, "visible": True,
}
def ui_text(node_id, tab, x, y, group, name, label, fmt, layout="row-left"):
return {
"id": node_id, "type": "ui-text", "z": tab, "group": group,
"order": 1, "width": "0", "height": "0", "name": name, "label": label,
"format": fmt, "layout": layout, "style": False, "font": "",
"fontSize": 14, "color": "#000000",
"x": x, "y": y, # editor canvas position — without these
# Node-RED dumps every ui-text at (0,0)
# and you get a pile in the top-left corner
"wires": [],
}
def ui_button(node_id, tab, x, y, group, name, label, payload, payload_type,
topic, color="#0f52a5", icon="play_arrow", wires=None):
return {
"id": node_id, "type": "ui-button", "z": tab, "group": group,
"name": name, "label": label, "order": 1, "width": "0", "height": "0",
"tooltip": "", "color": "#ffffff", "bgcolor": color,
"className": "", "icon": icon, "iconPosition": "left",
"payload": payload, "payloadType": payload_type,
"topic": topic, "topicType": "str", "buttonType": "default",
"x": x, "y": y, "wires": [wires or []],
}
def ui_slider(node_id, tab, x, y, group, name, label, mn, mx, step=1.0,
topic="", wires=None):
return {
"id": node_id, "type": "ui-slider", "z": tab, "group": group,
"name": name, "label": label, "tooltip": "", "order": 1,
"width": "0", "height": "0", "passthru": True, "outs": "end",
"topic": topic, "topicType": "str",
"min": str(mn), "max": str(mx), "step": str(step),
"showLabel": True, "showValue": True, "labelPosition": "top",
"valuePosition": "left", "thumbLabel": False, "iconStart": "",
"iconEnd": "", "x": x, "y": y, "wires": [wires or []],
}
def ui_switch(node_id, tab, x, y, group, name, label, on_value, off_value,
topic, wires=None):
return {
"id": node_id, "type": "ui-switch", "z": tab, "group": group,
"name": name, "label": label, "tooltip": "", "order": 1,
"width": "0", "height": "0", "passthru": True, "decouple": "false",
"topic": topic, "topicType": "str",
"style": "", "className": "", "evaluate": "true",
"onvalue": on_value, "onvalueType": "str",
"onicon": "auto_mode", "oncolor": "#0f52a5",
"offvalue": off_value, "offvalueType": "str",
"officon": "back_hand", "offcolor": "#888888",
"x": x, "y": y, "wires": [wires or []],
}
def ui_chart(node_id, tab, x, y, group, name, label,
width=12, height=6,
remove_older="15", remove_older_unit="60",
remove_older_points="",
y_axis_label="", ymin=None, ymax=None, order=1):
"""
FlowFuse ui-chart (line type, time x-axis).
IMPORTANT: This template is derived from the working charts in
rotatingMachine/examples/03-Dashboard.json. Every field listed below
is required or the chart renders blank. Key gotchas:
- `width` / `height` must be NUMBERS not strings.
- `interpolation` must be set ("linear", "step", "bezier",
"cubic", "cubic-mono") or no line is drawn.
- `yAxisProperty: "payload"` + `yAxisPropertyType: "msg"` tells
the chart WHERE in the msg to find the y-value. Without these
the chart has no data to plot.
- `xAxisPropertyType: "timestamp"` tells the chart to use
msg.timestamp (or auto-generated timestamp) for the x-axis.
- `removeOlderPoints` should be "" (empty string) to let
removeOlder + removeOlderUnit control retention, OR a number
string to cap points per series.
"""
return {
"id": node_id, "type": "ui-chart", "z": tab, "group": group,
"name": name, "label": label, "order": order,
"chartType": "line",
"interpolation": "linear",
# Series identification
"category": "topic", "categoryType": "msg",
# X-axis (time)
"xAxisLabel": "", "xAxisType": "time",
"xAxisProperty": "", "xAxisPropertyType": "timestamp",
"xAxisFormat": "", "xAxisFormatType": "auto",
"xmin": "", "xmax": "",
# Y-axis (msg.payload)
"yAxisLabel": y_axis_label,
"yAxisProperty": "payload", "yAxisPropertyType": "msg",
"ymin": "" if ymin is None else str(ymin),
"ymax": "" if ymax is None else str(ymax),
# Data retention
"removeOlder": str(remove_older),
"removeOlderUnit": str(remove_older_unit),
"removeOlderPoints": str(remove_older_points),
# Rendering
"action": "append",
"stackSeries": False,
"pointShape": "circle", "pointRadius": 4,
"showLegend": True,
"bins": 10,
# Colours (defaults — chart auto-cycles through these per series)
"colors": [
"#0095FF", "#FF0000", "#FF7F0E", "#2CA02C",
"#A347E1", "#D62728", "#FF9896", "#9467BD", "#C5B0D5",
],
"textColor": ["#666666"], "textColorDefault": True,
"gridColor": ["#e5e5e5"], "gridColorDefault": True,
# Editor layout + dimensions (NUMBERS, not strings)
"width": int(width), "height": int(height), "className": "",
"x": x, "y": y,
"wires": [[]],
}
# ---------------------------------------------------------------------------
# Tab 1 — PROCESS PLANT
# ---------------------------------------------------------------------------
def build_process_tab():
nodes = []
nodes.append({
"id": TAB_PROCESS, "type": "tab",
"label": "🏭 Process Plant",
"disabled": False,
"info": "EVOLV plant model: pumpingStation + machineGroupControl + 3 rotatingMachines, each with upstream and downstream pressure measurements.\n\nReceives commands via link-in nodes from the Dashboard / Demo Drivers tabs. Emits per-pump status via link-out per pump.\n\nNo UI, no demo drivers, no one-shot setup logic on this tab — those live on their own tabs so this layer can be lifted into production unchanged.",
})
nodes.append(comment("c_process_title", TAB_PROCESS, LANE_X[2], 20,
"🏭 PROCESS PLANT — EVOLV nodes only",
"Per pump: 2 measurement sensors → rotatingMachine → output formatter → link-out to dashboard.\n"
"MGC orchestrates 3 pumps. PS observes basin (manual mode for the demo).\n"
"All cross-tab wires are link-in / link-out by named channel."
))
# ---------------- Per-pump rows ----------------
for i, pump in enumerate(PUMPS):
label = PUMP_LABELS[pump]
# Each pump occupies a 4-row block, separated by SECTION_GAP from the next.
y_section = 100 + i * SECTION_GAP
nodes.append(comment(f"c_{pump}", TAB_PROCESS, LANE_X[2], y_section,
f"── {label} ──",
"Up + Dn pressure sensors register as children. "
"rotatingMachine emits state on port 0 (formatted then link-out to UI). "
"Port 2 emits registerChild → MGC."
))
# Two measurement sensors (upstream + downstream)
for j, pos in enumerate(("upstream", "downstream")):
mid = f"meas_{pump}_{pos[0]}"
absmin, absmax = (50, 400) if pos == "upstream" else (800, 2200)
mid_label = f"PT-{label.split()[1]}-{'Up' if pos == 'upstream' else 'Dn'}"
nodes.append({
"id": mid, "type": "measurement", "z": TAB_PROCESS,
"name": mid_label,
"mode": "analog", "channels": "[]",
"scaling": False,
"i_min": 0, "i_max": 1, "i_offset": 0,
"o_min": absmin, "o_max": absmax,
"simulator": True,
"smooth_method": "mean", "count": "5",
"processOutputFormat": "process", "dbaseOutputFormat": "influxdb",
"uuid": f"sensor-{pump}-{pos}",
"supplier": "vega", "category": "sensor",
"assetType": "pressure", "model": "vega-pressure-10",
"unit": "mbar", "assetTagNumber": f"PT-{i+1}-{pos[0].upper()}",
"enableLog": False, "logLevel": "warn",
"positionVsParent": pos, "positionIcon": "",
"hasDistance": False, "distance": 0, "distanceUnit": "m",
"distanceDescription": "",
"x": LANE_X[1], "y": y_section + 40 + j * 50,
# Port 2 -> pump (registerChild). Ports 0/1 unused for now.
"wires": [[], [], [pump]],
})
# link-in for setpoint slider (from dashboard)
nodes.append(link_in(
f"lin_setpoint_{pump}", TAB_PROCESS, LANE_X[0], y_section + 60,
CH_PUMP_SETPOINT[pump],
source_out_ids=[f"lout_setpoint_{pump}_dash"],
downstream=[f"build_setpoint_{pump}"],
))
nodes.append(function_node(
f"build_setpoint_{pump}", TAB_PROCESS, LANE_X[1] + 220, y_section + 60,
f"build setpoint cmd ({label})",
"msg.topic = 'execMovement';\n"
"msg.payload = { source: 'GUI', action: 'execMovement', "
"setpoint: Number(msg.payload) };\n"
"return msg;",
outputs=1, wires=[[pump]],
))
# link-in for per-pump sequence (start/stop) commands
nodes.append(link_in(
f"lin_seq_{pump}", TAB_PROCESS, LANE_X[0], y_section + 110,
CH_PUMP_SEQUENCE[pump],
source_out_ids=[f"lout_seq_{pump}_dash"],
downstream=[pump],
))
# The pump itself
nodes.append({
"id": pump, "type": "rotatingMachine", "z": TAB_PROCESS,
"name": label,
"speed": "10",
"startup": "2", "warmup": "1", "shutdown": "2", "cooldown": "1",
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": f"pump-{pump}",
"supplier": "hidrostal", "category": "pump",
"assetType": "pump-centrifugal",
"model": "hidrostal-H05K-S03R",
"unit": "m3/h",
"curvePressureUnit": "mbar", "curveFlowUnit": "m3/h",
"curvePowerUnit": "kW", "curveControlUnit": "%",
"enableLog": False, "logLevel": "warn",
"positionVsParent": "atEquipment", "positionIcon": "",
"hasDistance": False, "distance": 0, "distanceUnit": "m",
"distanceDescription": "",
"x": LANE_X[3], "y": y_section + 80,
"wires": [
[f"format_{pump}"], # port 0 process -> formatter
[], # port 1 dbase
[MGC_ID], # port 2 -> MGC for registerChild
],
})
# Per-pump output formatter: builds a fat object with all fields.
# The dashboard dispatcher (on the UI tab) then splits it into
# plain-string payloads per ui-text widget. One link-out per pump.
nodes.append(function_node(
f"format_{pump}", TAB_PROCESS, LANE_X[4], y_section + 80,
f"format {label} port 0",
"const p = msg.payload || {};\n"
"const c = context.get('c') || {};\n"
"Object.assign(c, p);\n"
"context.set('c', c);\n"
"function find(prefix) {\n"
" for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n"
" return null;\n"
"}\n"
"const flow = find('flow.predicted.downstream.');\n"
"const power = find('power.predicted.atequipment.');\n"
"const pU = find('pressure.measured.upstream.');\n"
"const pD = find('pressure.measured.downstream.');\n"
"msg.payload = {\n"
" state: c.state || 'idle',\n"
" mode: c.mode || 'auto',\n"
" ctrl: c.ctrl != null ? Number(c.ctrl).toFixed(1) + '%' : 'n/a',\n"
" flow: flow != null ? Number(flow).toFixed(1) + ' m³/h' : 'n/a',\n"
" power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n"
" pUp: pU != null ? Number(pU).toFixed(0) + ' mbar' : 'n/a',\n"
" pDn: pD != null ? Number(pD).toFixed(0) + ' mbar' : 'n/a',\n"
" flowNum: flow != null ? Number(flow) : null,\n"
" powerNum: power != null ? Number(power) : null,\n"
"};\n"
"return msg;",
outputs=1, wires=[[f"lout_evt_{pump}"]],
))
# link-out: one per pump → dashboard dispatcher
nodes.append(link_out(
f"lout_evt_{pump}", TAB_PROCESS, LANE_X[5], y_section + 80,
CH_PUMP_EVT[pump],
target_in_ids=[f"lin_evt_{pump}_dash"],
))
# ---------------- MGC ----------------
y_mgc = 100 + 3 * SECTION_GAP
nodes.append(comment("c_mgc", TAB_PROCESS, LANE_X[2], y_mgc,
"── MGC ── (orchestrates the 3 pumps via optimalcontrol)",
"Receives Qd from cmd:demand link-in. Distributes flow across pumps."
))
# MGC no longer receives direct Qd from the dashboard — PS drives it
# via level-based control or manual Qd forwarding. The demand_fanout
# has been replaced by: sinus → q_in → PS (levelbased), and
# slider → Qd → PS (manual mode only).
nodes.append({
"id": MGC_ID, "type": "machineGroupControl", "z": TAB_PROCESS,
"name": "MGC — Pump Group",
"uuid": "mgc-pump-group",
"category": "controller",
"assetType": "machinegroupcontrol",
"model": "default", "unit": "m3/h", "supplier": "evolv",
"enableLog": False, "logLevel": "warn",
"positionVsParent": "atEquipment", "positionIcon": "",
"hasDistance": False, "distance": 0, "distanceUnit": "m",
"distanceDescription": "",
"processOutputFormat": "process", "dbaseOutputFormat": "influxdb",
"x": LANE_X[3], "y": y_mgc + 80,
"wires": [
["format_mgc"], # port 0 → formatter
[], # port 1 dbase
[PS_ID], # port 2 → PS for registerChild
],
})
nodes.append(function_node(
"format_mgc", TAB_PROCESS, LANE_X[4], y_mgc + 80,
"format MGC port 0",
"const p = msg.payload || {};\n"
"const c = context.get('c') || {};\n"
"Object.assign(c, p);\n"
"context.set('c', c);\n"
"function find(prefix) {\n"
" for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n"
" return null;\n"
"}\n"
"const totalFlow = find('flow.predicted.atequipment.') ?? find('downstream_predicted_flow');\n"
"const totalPower = find('power.predicted.atequipment.') ?? find('atEquipment_predicted_power');\n"
"const eff = find('efficiency.predicted.atequipment.');\n"
"msg.payload = {\n"
" totalFlow: totalFlow != null ? Number(totalFlow).toFixed(1) + ' m³/h' : 'n/a',\n"
" totalPower: totalPower != null ? Number(totalPower).toFixed(2) + ' kW' : 'n/a',\n"
" efficiency: eff != null ? Number(eff).toFixed(3) : 'n/a',\n"
" totalFlowNum: totalFlow != null ? Number(totalFlow) : null,\n"
" totalPowerNum: totalPower != null ? Number(totalPower) : null,\n"
"};\n"
"return msg;",
outputs=1, wires=[["lout_evt_mgc"]],
))
nodes.append(link_out(
"lout_evt_mgc", TAB_PROCESS, LANE_X[5], y_mgc + 80,
CH_MGC_EVT, target_in_ids=["lin_evt_mgc_dash"],
))
# ---------------- PS ----------------
y_ps = 100 + 4 * SECTION_GAP
nodes.append(comment("c_ps", TAB_PROCESS, LANE_X[2], y_ps,
"── Pumping Station ── (basin model, levelbased control)",
"Receives q_in (simulated inflow) from Demo Drivers tab.\n"
"Level-based control starts/stops pumps via MGC when level crosses start/stop thresholds."
))
# link-in for simulated inflow from Drivers tab
nodes.append(link_in(
"lin_qin_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 40,
"cmd:q_in", source_out_ids=["lout_qin_drivers"],
downstream=[PS_ID],
))
# link-in for manual Qd demand from Dashboard slider (only effective in manual mode)
nodes.append(link_in(
"lin_qd_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 80,
"cmd:Qd", source_out_ids=["lout_demand_dash"],
downstream=["qd_to_ps_wrap"],
))
nodes.append(function_node(
"qd_to_ps_wrap", TAB_PROCESS, LANE_X[1], y_ps + 80,
"wrap slider → PS Qd",
"msg.topic = 'Qd';\n"
"return msg;",
outputs=1, wires=[[PS_ID]],
))
# link-in for PS mode toggle from Dashboard
nodes.append(link_in(
"lin_ps_mode_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 120,
"cmd:ps-mode", source_out_ids=["lout_ps_mode_dash"],
downstream=[PS_ID],
))
nodes.append({
"id": PS_ID, "type": "pumpingStation", "z": TAB_PROCESS,
"name": "Pumping Station",
"uuid": "ps-basin-1",
"category": "station", "assetType": "pumpingstation",
"model": "default", "unit": "m3/s", "supplier": "evolv",
"enableLog": False, "logLevel": "warn",
"positionVsParent": "atEquipment", "positionIcon": "",
"hasDistance": False, "distance": 0, "distanceUnit": "m",
"distanceDescription": "",
"processOutputFormat": "process", "dbaseOutputFormat": "influxdb",
# PS in levelbased mode — sinus inflow fills the basin, pumps start
# when level > startLevel, stop when level < stopLevel.
"controlMode": "levelbased",
"basinVolume": 10, "basinHeight": 3,
"startLevel": 1.2, "stopLevel": 0.6,
"minFlowLevel": 0.6, "maxFlowLevel": 2.8,
"heightInlet": 2.5, "heightOutlet": 0.2, "heightOverflow": 2.8,
# Volume-based safeties ON, time-based OFF (time guard fires too
# aggressively with the sinus demo's small basin + high peak inflow).
"enableDryRunProtection": True,
"enableOverfillProtection": True,
"dryRunThresholdPercent": 5,
"overfillThresholdPercent": 95,
"timeleftToFullOrEmptyThresholdSeconds": 0,
"x": LANE_X[3], "y": y_ps + 80,
"wires": [
["format_ps"],
[],
],
})
nodes.append(function_node(
"format_ps", TAB_PROCESS, LANE_X[4], y_ps + 80,
"format PS port 0",
"const p = msg.payload || {};\n"
"const c = context.get('c') || {};\n"
"Object.assign(c, p);\n"
"context.set('c', c);\n"
"function find(prefix) {\n"
" for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n"
" return null;\n"
"}\n"
"const lvl = find('level.predicted.');\n"
"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"
" 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"]],
))
nodes.append(link_out(
"lout_evt_ps", TAB_PROCESS, LANE_X[5], y_ps + 80,
CH_PS_EVT, target_in_ids=["lin_evt_ps_dash"],
))
# ---------------- Mode broadcast (Auto/Manual to all pumps) ----------------
y_mode = 100 + 5 * SECTION_GAP
nodes.append(comment("c_mode_bcast", TAB_PROCESS, LANE_X[2], y_mode,
"── Mode broadcast ──",
"Single 'auto' / 'virtualControl' value fans out as setMode to all 3 pumps."
))
nodes.append(link_in(
"lin_mode", TAB_PROCESS, LANE_X[0], y_mode + 60,
CH_MODE,
source_out_ids=["lout_mode_dash"],
downstream=["fanout_mode"],
))
nodes.append(function_node(
"fanout_mode", TAB_PROCESS, LANE_X[1] + 220, y_mode + 60,
"fan setMode → 3 pumps",
"msg.topic = 'setMode';\n"
"return [msg, msg, msg];",
outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]],
))
# ---------------- Station-wide commands (start/stop/estop) ----------------
y_station = 100 + 6 * SECTION_GAP
nodes.append(comment("c_station_cmds", TAB_PROCESS, LANE_X[2], y_station,
"── Station-wide commands ── (Start All / Stop All / Emergency)",
"Each link-in carries a fully-built msg ready for handleInput; we just fan out 3-way."
))
for k, (chan, link_id, fn_name, label_suffix) in enumerate([
(CH_STATION_START, "lin_station_start", "fan_station_start", "startup"),
(CH_STATION_STOP, "lin_station_stop", "fan_station_stop", "shutdown"),
(CH_STATION_ESTOP, "lin_station_estop", "fan_station_estop", "emergency stop"),
]):
y = y_station + 60 + k * 60
nodes.append(link_in(
link_id, TAB_PROCESS, LANE_X[0], y, chan,
source_out_ids=[f"lout_{chan.replace(':', '_').replace('-', '_')}_dash"],
downstream=[fn_name],
))
nodes.append(function_node(
fn_name, TAB_PROCESS, LANE_X[1] + 220, y,
f"fan {label_suffix} → 3 pumps",
"return [msg, msg, msg];",
outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]],
))
return nodes
MGC_ID = "mgc_pumps"
PS_ID = "ps_basin"
# ---------------------------------------------------------------------------
# Tab 2 — DASHBOARD UI
# ---------------------------------------------------------------------------
def build_ui_tab():
nodes = []
nodes.append({
"id": TAB_UI, "type": "tab",
"label": "📊 Dashboard UI",
"disabled": False,
"info": "Every ui-* widget lives here. Inputs (sliders/switches/buttons) emit "
"via link-out; status text + charts receive via link-in. No business "
"logic on this tab.",
})
# Dashboard scaffold (page + theme + base) + groups
nodes += dashboard_scaffold()
PG = "ui_page_control" # control page is the main page
g_demand = "ui_grp_demand"
g_station = "ui_grp_station"
g_pump_a = "ui_grp_pump_a"
g_pump_b = "ui_grp_pump_b"
g_pump_c = "ui_grp_pump_c"
# Trend groups live on separate pages, not the control page.
PG_SHORT = "ui_page_short_trends"
PG_LONG = "ui_page_long_trends"
g_trend_short_flow = "ui_grp_trend_short_flow"
g_trend_short_power = "ui_grp_trend_short_power"
g_trend_long_flow = "ui_grp_trend_long_flow"
g_trend_long_power = "ui_grp_trend_long_power"
g_mgc = "ui_grp_mgc"
g_ps = "ui_grp_ps"
nodes += [
ui_group(g_demand, "1. Process Demand", PG, width=12, order=1),
ui_group(g_station, "2. Station Controls", PG, width=12, order=2),
ui_group(g_mgc, "3a. MGC Status", PG, width=6, order=3),
ui_group(g_ps, "3b. Basin Status", PG, width=6, order=4),
ui_group(g_pump_a, "4a. Pump A", PG, width=4, order=5),
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("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,
"📊 DASHBOARD UI — only ui-* widgets here",
"Layout: column 1 = inputs (sliders/switches/buttons) → link-outs.\n"
"Column 2 = link-ins from process → routed to text/gauge/chart widgets."
))
# ===== SECTION: Process Demand =====
y = 100
nodes.append(comment("c_ui_demand", TAB_UI, LANE_X[2], y,
"── Process Demand ──", ""))
nodes.append(ui_slider(
"ui_demand_slider", TAB_UI, LANE_X[0], y + 40, g_demand,
"Manual demand (manual mode only)", "Manual demand (m³/h) — active in manual mode only",
0, 100, 5.0, "manualDemand",
wires=["lout_demand_dash"]
))
nodes.append(link_out(
"lout_demand_dash", TAB_UI, LANE_X[1], y + 40,
"cmd:Qd", target_in_ids=["lin_qd_at_ps"]
))
nodes.append(ui_text(
"ui_demand_text", TAB_UI, LANE_X[3], y + 40, g_demand,
"Manual demand (active in manual mode)", "Manual demand",
"{{msg.payload}} m³/h"
))
# The slider value routes to PS as Qd — only effective in manual mode.
# Route is: slider → link-out cmd:Qd → process tab link-in → PS.
# ===== SECTION: Mode + Station Buttons =====
y = 320
nodes.append(comment("c_ui_station", TAB_UI, LANE_X[2], y,
"── Mode + Station-wide buttons ──", ""))
# Mode toggle now drives the PUMPING STATION mode (levelbased ↔ manual)
# instead of per-pump setMode. In levelbased mode, PS drives pumps
# automatically. In manual mode, the demand slider takes over.
nodes.append(ui_switch(
"ui_mode_toggle", TAB_UI, LANE_X[0], y + 40, g_station,
"Station mode",
"Station mode (Auto = level-based control · Manual = slider demand)",
on_value="levelbased", off_value="manual", topic="changemode",
wires=["lout_ps_mode_dash"]
))
nodes.append(link_out(
"lout_ps_mode_dash", TAB_UI, LANE_X[1], y + 40,
"cmd:ps-mode", target_in_ids=["lin_ps_mode_at_ps"]
))
for k, (text, payload, color, icon, lout_id, channel) in enumerate([
("Start all pumps", '{"topic":"execSequence","payload":{"source":"GUI","action":"execSequence","parameter":"startup"}}',
"#16a34a", "play_arrow", "lout_cmd_station_startup_dash", CH_STATION_START),
("Stop all pumps", '{"topic":"execSequence","payload":{"source":"GUI","action":"execSequence","parameter":"shutdown"}}',
"#ea580c", "stop", "lout_cmd_station_shutdown_dash", CH_STATION_STOP),
("EMERGENCY STOP", '{"topic":"emergencystop","payload":{"source":"GUI","action":"emergencystop"}}',
"#dc2626", "stop_circle", "lout_cmd_station_estop_dash", CH_STATION_ESTOP),
]):
yk = y + 100 + k * 60
# The ui-button payload becomes msg.payload; we want the button to send
# a fully-formed {topic, payload} for the per-pump nodeClass to dispatch.
# ui-button can't set msg.topic from a constant payload that's an
# object directly — easier path: a small function in front of the
# link-out that wraps the button's plain payload string into the
# right shape per channel.
btn_id = f"btn_station_{k}"
wrap_id = f"wrap_station_{k}"
# Use simple payload (just the button text) and let the wrapper build
# the real msg shape.
if k == 0: # startup
wrap_code = (
"msg.topic = 'execSequence';\n"
"msg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\n"
"return msg;"
)
elif k == 1: # shutdown
wrap_code = (
"msg.topic = 'execSequence';\n"
"msg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\n"
"return msg;"
)
else: # estop
wrap_code = (
"msg.topic = 'emergencystop';\n"
"msg.payload = { source:'GUI', action:'emergencystop' };\n"
"return msg;"
)
nodes.append(ui_button(
btn_id, TAB_UI, LANE_X[0], yk, g_station,
text, text, "fired", "str",
topic=f"station_{k}", color=color, icon=icon,
wires=[wrap_id]
))
nodes.append(function_node(
wrap_id, TAB_UI, LANE_X[1] + 100, yk, f"build cmd ({text})",
wrap_code, outputs=1, wires=[[lout_id]]
))
nodes.append(link_out(
lout_id, TAB_UI, LANE_X[2], yk,
channel,
target_in_ids=[{
CH_STATION_START: "lin_station_start",
CH_STATION_STOP: "lin_station_stop",
CH_STATION_ESTOP: "lin_station_estop",
}[channel]]
))
# ===== SECTION: MGC + PS overview =====
y = 600
nodes.append(comment("c_ui_mgc_ps", TAB_UI, LANE_X[2], y,
"── MGC + Basin overview ──", ""))
nodes.append(link_in(
"lin_evt_mgc_dash", TAB_UI, LANE_X[0], y + 40,
CH_MGC_EVT, source_out_ids=["lout_evt_mgc"],
downstream=["dispatch_mgc"]
))
nodes.append(function_node(
"dispatch_mgc", TAB_UI, LANE_X[1], y + 40,
"dispatch MGC",
"const p = msg.payload || {};\n"
"return [\n"
" {payload: String(p.totalFlow || 'n/a')},\n"
" {payload: String(p.totalPower || 'n/a')},\n"
" {payload: String(p.efficiency || 'n/a')},\n"
"];",
outputs=3,
wires=[["ui_mgc_total_flow"], ["ui_mgc_total_power"], ["ui_mgc_eff"]],
))
nodes.append(ui_text("ui_mgc_total_flow", TAB_UI, LANE_X[2], y + 40, g_mgc,
"MGC total flow", "Total flow", "{{msg.payload}}"))
nodes.append(ui_text("ui_mgc_total_power", TAB_UI, LANE_X[2], y + 70, g_mgc,
"MGC total power", "Total power", "{{msg.payload}}"))
nodes.append(ui_text("ui_mgc_eff", TAB_UI, LANE_X[2], y + 100, g_mgc,
"MGC efficiency", "Group efficiency", "{{msg.payload}}"))
nodes.append(link_in(
"lin_evt_ps_dash", TAB_UI, LANE_X[0], y + 160,
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.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,
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
],
))
# 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
for i, pump in enumerate(PUMPS):
label = PUMP_LABELS[pump]
g = {"pump_a": g_pump_a, "pump_b": g_pump_b, "pump_c": g_pump_c}[pump]
y_p = y_pumps_start + i * SECTION_GAP * 2
nodes.append(comment(f"c_ui_{pump}", TAB_UI, LANE_X[2], y_p,
f"── {label} ──", ""))
# link-in: one fat object per pump → dispatcher splits into
# plain-string payloads per ui-text widget + numeric payloads
# for trend charts. 9 outputs total.
DISPLAY_FIELDS = [
("State", "state"),
("Mode", "mode"),
("Controller %", "ctrl"),
("Flow", "flow"),
("Power", "power"),
("p Upstream", "pUp"),
("p Downstream", "pDn"),
]
nodes.append(link_in(
f"lin_evt_{pump}_dash", TAB_UI, LANE_X[0], y_p + 40,
CH_PUMP_EVT[pump], source_out_ids=[f"lout_evt_{pump}"],
downstream=[f"dispatch_{pump}"],
))
# Dispatcher: takes the fat object and returns 9 outputs, each
# with a plain payload ready for a ui-text or trend chart.
nodes.append(function_node(
f"dispatch_{pump}", TAB_UI, LANE_X[1], y_p + 40,
f"dispatch {label}",
"const p = msg.payload || {};\n"
"const ts = Date.now();\n"
"return [\n"
" {payload: String(p.state || 'idle')},\n"
" {payload: String(p.mode || 'auto')},\n"
" {payload: String(p.ctrl || 'n/a')},\n"
" {payload: String(p.flow || 'n/a')},\n"
" {payload: String(p.power || 'n/a')},\n"
" {payload: String(p.pUp || 'n/a')},\n"
" {payload: String(p.pDn || 'n/a')},\n"
" p.flowNum != null ? {topic: '" + label + "', payload: p.flowNum, timestamp: ts} : null,\n"
" p.powerNum != null ? {topic: '" + label + "', payload: p.powerNum, timestamp: ts} : null,\n"
"];",
outputs=9,
wires=[
[f"ui_{pump}_{f}"] for _, f in DISPLAY_FIELDS
] + [
["trend_short_flow", "trend_long_flow"], # output 7: flowNum → both flow charts
["trend_short_power", "trend_long_power"], # output 8: powerNum → both power charts
],
))
# ui-text widgets
for k, (label_txt, field) in enumerate(DISPLAY_FIELDS):
nodes.append(ui_text(
f"ui_{pump}_{field}", TAB_UI, LANE_X[2], y_p + 40 + k * 40, g,
f"{label} {label_txt}", label_txt,
"{{msg.payload}}" # plain string — FlowFuse-safe
))
# Setpoint slider → wrapper → link-out → process pump (cmd:setpoint-X)
nodes.append(ui_slider(
f"ui_{pump}_setpoint", TAB_UI, LANE_X[0], y_p + 280, g,
f"{label} setpoint", "Setpoint % (manual mode)",
0, 100, 5.0, f"setpoint_{pump}",
wires=[f"lout_setpoint_{pump}_dash"]
))
nodes.append(link_out(
f"lout_setpoint_{pump}_dash", TAB_UI, LANE_X[1], y_p + 280,
CH_PUMP_SETPOINT[pump],
target_in_ids=[f"lin_setpoint_{pump}"]
))
# Per-pump start/stop buttons → link-out
# We need wrappers because ui-button payload must be string-typed.
nodes.append(ui_button(
f"btn_{pump}_start", TAB_UI, LANE_X[0], y_p + 330, g,
f"{label} startup", "Startup", "fired", "str",
topic=f"start_{pump}", color="#16a34a", icon="play_arrow",
wires=[f"wrap_{pump}_start"]
))
nodes.append(function_node(
f"wrap_{pump}_start", TAB_UI, LANE_X[1] + 100, y_p + 330,
f"build start ({label})",
"msg.topic = 'execSequence';\n"
"msg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\n"
"return msg;",
outputs=1, wires=[[f"lout_seq_{pump}_dash"]]
))
nodes.append(ui_button(
f"btn_{pump}_stop", TAB_UI, LANE_X[0], y_p + 380, g,
f"{label} shutdown", "Shutdown", "fired", "str",
topic=f"stop_{pump}", color="#ea580c", icon="stop",
wires=[f"wrap_{pump}_stop"]
))
nodes.append(function_node(
f"wrap_{pump}_stop", TAB_UI, LANE_X[1] + 100, y_p + 380,
f"build stop ({label})",
"msg.topic = 'execSequence';\n"
"msg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\n"
"return msg;",
outputs=1, wires=[[f"lout_seq_{pump}_dash"]]
))
# Both start and stop wrappers feed one shared link-out
nodes.append(link_out(
f"lout_seq_{pump}_dash", TAB_UI, LANE_X[2], y_p + 355,
CH_PUMP_SEQUENCE[pump],
target_in_ids=[f"lin_seq_{pump}"]
))
# (Trend feed is handled by dispatcher outputs 7+8 above — no separate
# trend_split function needed.)
# ===== Trend charts — two pages, two charts per page =====
# Short-term (10 min rolling window) and long-term (1 hour).
# Same data feed; different removeOlder settings.
y_charts = y_pumps_start + len(PUMPS) * SECTION_GAP * 2 + 80
nodes.append(comment("c_ui_trends", TAB_UI, LANE_X[2], y_charts,
"── Trend charts ── (feed to 4 charts on 2 pages)",
"Short-term (10 min) and long-term (1 h) trends share the same feed.\n"
"Each chart on its own page."
))
# Short-term (10 min)
nodes.append(ui_chart(
"trend_short_flow", TAB_UI, LANE_X[3], y_charts + 40,
g_trend_short_flow,
"Flow per pump — 10 min", "Flow per pump (m³/h)",
width="12", height="8",
remove_older="10", remove_older_unit="60", remove_older_points="300",
order=1,
))
nodes.append(ui_chart(
"trend_short_power", TAB_UI, LANE_X[3], y_charts + 120,
g_trend_short_power,
"Power per pump — 10 min", "Power per pump (kW)",
width="12", height="8",
remove_older="10", remove_older_unit="60", remove_older_points="300",
order=1,
))
# Long-term (1 hour)
nodes.append(ui_chart(
"trend_long_flow", TAB_UI, LANE_X[3], y_charts + 200,
g_trend_long_flow,
"Flow per pump — 1 hour", "Flow per pump (m³/h)",
width="12", height="8",
remove_older="60", remove_older_unit="60", remove_older_points="1800",
order=1,
))
nodes.append(ui_chart(
"trend_long_power", TAB_UI, LANE_X[3], y_charts + 280,
g_trend_long_power,
"Power per pump — 1 hour", "Power per pump (kW)",
width="12", height="8",
remove_older="60", remove_older_unit="60", remove_older_points="1800",
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
# ---------------------------------------------------------------------------
# Tab 3 — DEMO DRIVERS
# ---------------------------------------------------------------------------
def build_drivers_tab():
nodes = []
nodes.append({
"id": TAB_DRIVERS, "type": "tab",
"label": "🎛️ Demo Drivers",
"disabled": False,
"info": "Simulated inflow for the demo. A slow sinusoid generates "
"inflow into the pumping station basin, which then drives "
"the level-based pump control automatically.\n\n"
"In production, delete this tab — real inflow comes from "
"upstream measurement sensors.",
})
nodes.append(comment("c_drv_title", TAB_DRIVERS, LANE_X[2], 20,
"🎛️ DEMO DRIVERS — simulated basin inflow",
"Sinus generator → q_in to pumpingStation. Basin fills → level-based\n"
"control starts pumps → basin drains → pumps stop → cycle repeats."
))
# Sinus inflow generator: produces a flow value (m³/s) that
# simulates incoming wastewater. Period ~120s so the fill/drain
# cycle is visible on the dashboard. Amplitude scaled so 3 pumps
# can handle the peak.
# Q_in = base + amplitude * (1 + sin(2π t / period)) / 2
# base = 0.005 m³/s (~18 m³/h) — always some inflow
# amplitude = 0.03 m³/s (~108 m³/h peak)
# period = 120 s
y = 100
nodes.append(comment("c_drv_sinus", TAB_DRIVERS, LANE_X[2], y,
"── Sinusoidal inflow generator ──",
"Produces a smooth inflow curve (m³/s) and sends to pumpingStation\n"
"via the cmd:q_in link channel. Period = 120s."
))
nodes.append(inject(
"sinus_tick", TAB_DRIVERS, LANE_X[0], y + 40,
"tick (1s inflow)",
topic="sinusTick", payload="", payload_type="date",
repeat="1", wires=["sinus_fn"]
))
nodes.append(function_node(
"sinus_fn", TAB_DRIVERS, LANE_X[1] + 220, y + 40,
"sinus inflow (m³/s)",
"const base = 0.005; // m³/s (~18 m³/h always)\n"
"const amplitude = 0.03; // m³/s (~108 m³/h peak)\n"
"const period = 120; // seconds per full cycle\n"
"const t = Date.now() / 1000; // seconds since epoch\n"
"const q = base + amplitude * (1 + Math.sin(2 * Math.PI * t / period)) / 2;\n"
"return { topic: 'q_in', payload: q, unit: 'm3/s', timestamp: Date.now() };",
outputs=1, wires=[["lout_qin_drivers"]]
))
nodes.append(link_out(
"lout_qin_drivers", TAB_DRIVERS, LANE_X[3], y + 40,
"cmd:q_in", target_in_ids=["lin_qin_at_ps"]
))
return nodes
# ---------------------------------------------------------------------------
# Tab 4 — SETUP & INIT
# ---------------------------------------------------------------------------
def build_setup_tab():
nodes = []
nodes.append({
"id": TAB_SETUP, "type": "tab",
"label": "⚙️ Setup & Init",
"disabled": False,
"info": "One-shot deploy-time injects. Sets MGC scaling/mode, broadcasts "
"pumps mode = auto, and auto-starts the pumps + random demand.",
})
nodes.append(comment("c_setup_title", TAB_SETUP, LANE_X[2], 20,
"⚙️ SETUP & INIT — one-shot deploy-time injects",
"Disable this tab in production — the runtime should be persistent."
))
# Setup wires DIRECTLY to the process nodes (cross-tab via link is cleaner
# but for one-shot setups direct wiring keeps the intent obvious).
y = 100
nodes.append(inject(
"setup_mgc_scaling", TAB_SETUP, LANE_X[0], y,
"MGC scaling = normalized",
topic="setScaling", payload="normalized", payload_type="str",
once=True, once_delay="1.5",
wires=["lout_setup_to_mgc"]
))
nodes.append(inject(
"setup_mgc_mode", TAB_SETUP, LANE_X[0], y + 60,
"MGC mode = optimalcontrol",
topic="setMode", payload="optimalcontrol", payload_type="str",
once=True, once_delay="1.7",
wires=["lout_setup_to_mgc"]
))
nodes.append(link_out(
"lout_setup_to_mgc", TAB_SETUP, LANE_X[1], y + 30,
"setup:to-mgc", target_in_ids=["lin_setup_at_mgc"]
))
y = 250
nodes.append(inject(
"setup_pumps_mode", TAB_SETUP, LANE_X[0], y,
"pumps mode = auto",
topic="setMode", payload="auto", payload_type="str",
once=True, once_delay="2.0",
wires=["lout_mode_setup"]
))
nodes.append(link_out(
"lout_mode_setup", TAB_SETUP, LANE_X[1], y,
CH_MODE, target_in_ids=["lin_mode"]
))
y = 350
nodes.append(inject(
"setup_pumps_startup", TAB_SETUP, LANE_X[0], y,
"auto-startup all pumps",
topic="execSequence",
payload='{"source":"GUI","action":"execSequence","parameter":"startup"}',
payload_type="json", once=True, once_delay="4",
wires=["lout_setup_station_start"]
))
nodes.append(link_out(
"lout_setup_station_start", TAB_SETUP, LANE_X[1], y,
CH_STATION_START, target_in_ids=["lin_station_start"]
))
# (Random demand removed — sinus inflow drives the demo automatically.
# No explicit "random on" inject needed.)
return nodes
# ---------------------------------------------------------------------------
# Process tab additions: setup link-in feeding MGC
# ---------------------------------------------------------------------------
def add_setup_link_to_process(process_nodes):
"""Inject a link-in on the process tab that funnels setup msgs to MGC."""
y = 100 + 7 * SECTION_GAP
process_nodes.append(comment(
"c_setup_at_mgc", TAB_PROCESS, LANE_X[2], y,
"── Setup feeders ──",
"Cross-tab link from Setup tab → MGC scaling/mode init."
))
process_nodes.append(link_in(
"lin_setup_at_mgc", TAB_PROCESS, LANE_X[0], y + 60,
"setup:to-mgc",
source_out_ids=["lout_setup_to_mgc"],
downstream=[MGC_ID]
))
# ---------------------------------------------------------------------------
# Assemble + emit
# ---------------------------------------------------------------------------
def main():
process_nodes = build_process_tab()
add_setup_link_to_process(process_nodes)
nodes = process_nodes + build_ui_tab() + build_drivers_tab() + build_setup_tab()
json.dump(nodes, sys.stdout, indent=2)
sys.stdout.write("\n")
if __name__ == "__main__":
main()