#!/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": 1.0, # 0% pump demand at this level "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()