Some checks failed
CI / lint-and-test (push) Has been cancelled
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>
1344 lines
58 KiB
Python
1344 lines
58 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",
|
||
# === 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) + ' m³' : 'n/a',\n"
|
||
" fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n"
|
||
" netFlow: netM3h != null ? netM3h.toFixed(0) + ' m³/h' : 'n/a',\n"
|
||
" timeLeft: timeStr,\n"
|
||
" qIn: qIn != null ? (Number(qIn) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n"
|
||
" qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n"
|
||
" // Numerics for trends\n"
|
||
" levelNum: lvl != null ? Number(lvl) : null,\n"
|
||
" volumeNum: vol != null ? Number(vol) : null,\n"
|
||
" fillPctNum: fillPct,\n"
|
||
" netFlowNum: netM3h,\n"
|
||
"};\n"
|
||
"return msg;",
|
||
outputs=1, wires=[["lout_evt_ps"]],
|
||
))
|
||
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 0–3 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()
|