Files
EVOLV/examples/pumpingstation-3pumps-dashboard/build_flow.py
znetsixe b693e0b90c
Some checks failed
CI / lint-and-test (push) Has been cancelled
fix: graduated pump control + mass balance corrections
Three fixes:

1. PS outflow triple-counted (pumpingStation c62d8bc): MGC registered
   twice + individual pumps registered alongside MGC + dual event
   subscription per child. Now: one registration per aggregation level,
   one event per child. Volume integration tracks correctly.

2. All 3 pumps always on: minFlowLevel was 1.0 m but startLevel was
   2.0 m, so at the moment pumps started the percControl was already
   40% → MGC mapped to 356 m³/h → all 3 pumps. Fixed: minFlowLevel
   = startLevel (2.0 m) so percControl starts at 0% and ramps
   linearly. Now pumps graduate: 1-2 pumps at low level, 3 at high.

3. Generalizable registration rule added as code comments: when a group
   aggregator exists (MGC), subscribe to it, not its children. Pick
   one event name per measurement type per child.

E2E verified: 2/3 pumps active at 56% fill, volume draining correctly,
pump C at 5.2% ctrl delivering 99 m³/h while pump A stays off.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:10:32 +02:00

1344 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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",
# === FULLY CONFIGURED PS — every field explicitly set ===
# Rule: ALWAYS configure ALL node fields. Defaults are for
# schema validation, not for realistic operation.
#
# Basin geometry: 30 m³, 4 m tall → surfaceArea = 7.5 m²
# Sized so peak sinus inflow (0.035 m³/s = 126 m³/h) takes
# ~6 min to fill from startLevel to overflow → pumps have time.
"controlMode": "levelbased",
"basinVolume": 30,
"basinHeight": 4,
"heightInlet": 3.5,
"heightOutlet": 0.3,
"heightOverflow": 3.8,
"inletPipeDiameter": 0.3,
"outletPipeDiameter": 0.3,
# Level-based control thresholds
"startLevel": 2.0, # pumps ON above 2.0 m (50% of height)
"stopLevel": 1.0, # pumps OFF below 1.0 m (25% of height)
"minFlowLevel": 2.0, # 0% pump demand at startLevel (must match startLevel!)
"maxFlowLevel": 3.5, # 100% pump demand at this level
# Hydraulics
"refHeight": "NAP",
"minHeightBasedOn": "outlet",
"basinBottomRef": 0,
"staticHead": 12,
"maxDischargeHead": 24,
"pipelineLength": 80,
"defaultFluid": "wastewater",
"temperatureReferenceDegC": 15,
"maxInflowRate": 200,
# Safety guards
"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"
"// Basin capacity = basinVolume (config). Don't hardcode — read it once.\n"
"if (!context.get('maxVol')) context.set('maxVol', 30.0); // basinVolume from PS config\n"
"const maxVol = context.get('maxVol');\n"
"const fillPct = vol != null ? Math.min(100, Math.max(0, 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 + gauge outputs (short + long page gauges)
["trend_short_basin", "trend_long_basin", "gauge_ps_fill", "gauge_ps_fill_long"], # fill %
["trend_short_basin", "trend_long_basin", "gauge_ps_level", "gauge_ps_level_long"], # level
["trend_short_basin", "trend_long_basin"], # net flow
],
))
# (Basin gauges live on the trend pages, not the control page —
# see the trend section below for gauge_ps_level / gauge_ps_fill.)
# 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 + gauges (fill %, level, net flow) =====
# Gauge segment definitions (reused for both pages)
TANK_SEGMENTS = [
{"color": "#f44336", "from": 0}, # red: below stopLevel (1.0 m)
{"color": "#ff9800", "from": 1.0}, # orange: between stop and start
{"color": "#2196f3", "from": 2.0}, # blue: normal operating (startLevel)
{"color": "#ff9800", "from": 3.5}, # orange: approaching overflow
{"color": "#f44336", "from": 3.8}, # red: overflow zone (heightOverflow)
]
FILL_SEGMENTS = [
{"color": "#f44336", "from": 0},
{"color": "#ff9800", "from": 10},
{"color": "#4caf50", "from": 30},
{"color": "#ff9800", "from": 80},
{"color": "#f44336", "from": 95},
]
for suffix, grp, remove_older, remove_points, y_off in [
("short", "ui_grp_trend_short_basin", "10", "300", 360),
("long", "ui_grp_trend_long_basin", "60", "1800", 540),
]:
# Basin trend chart (width 8 to leave room for gauges)
nodes.append(ui_chart(
f"trend_{suffix}_basin", TAB_UI, LANE_X[3], y_charts + y_off,
grp,
f"Basin — {'10 min' if suffix == 'short' else '1 hour'}", "Basin metrics",
width=8, height=8,
remove_older=remove_older, remove_older_unit="60",
remove_older_points=remove_points,
y_axis_label="", order=1,
))
# Tank gauge: basin level 03 m
gauge_id_suffix = "" if suffix == "short" else "_long"
nodes.append({
"id": f"gauge_ps_level{gauge_id_suffix}", "type": "ui-gauge",
"z": TAB_UI, "group": grp,
"name": f"Basin level gauge ({suffix})",
"gtype": "gauge-tank", "gstyle": "Rounded",
"title": "Level", "units": "m",
"prefix": "", "suffix": " m",
"min": 0, "max": 4,
"segments": TANK_SEGMENTS,
"width": 2, "height": 5, "order": 2,
"icon": "", "sizeGauge": 20, "sizeGap": 2, "sizeSegments": 10,
"x": LANE_X[4], "y": y_charts + y_off, "wires": [],
})
# 270° arc: fill %
nodes.append({
"id": f"gauge_ps_fill{gauge_id_suffix}", "type": "ui-gauge",
"z": TAB_UI, "group": grp,
"name": f"Basin fill gauge ({suffix})",
"gtype": "gauge-34", "gstyle": "Rounded",
"title": "Fill", "units": "%",
"prefix": "", "suffix": "%",
"min": 0, "max": 100,
"segments": FILL_SEGMENTS,
"width": 2, "height": 4, "order": 3,
"icon": "water_drop", "sizeGauge": 20, "sizeGap": 2, "sizeSegments": 10,
"x": LANE_X[5], "y": y_charts + y_off, "wires": [],
})
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.02; // m³/s (~72 m³/h always)\n"
"const amplitude = 0.10; // m³/s (~360 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()