diff --git a/.claude/rules/node-red-flow-layout.md b/.claude/rules/node-red-flow-layout.md new file mode 100644 index 0000000..69e1e61 --- /dev/null +++ b/.claude/rules/node-red-flow-layout.md @@ -0,0 +1,206 @@ +# Node-RED Flow Layout Rules + +How to lay out a multi-tab Node-RED demo or production flow so it is readable, debuggable, and trivially extendable. These rules apply to anything you build with `examples/` flows, dashboards, or production deployments. + +## 1. Tab boundaries โ€” by CONCERN, not by data + +Every node lives on the tab matching its **concern**, never where it happens to be wired: + +| Tab | Lives here | Never here | +|---|---|---| +| **๐Ÿญ Process Plant** | EVOLV nodes (rotatingMachine, MGC, pumpingStation, measurement, reactor, settler, โ€ฆ) + small per-node output formatters | UI widgets, demo drivers, one-shot setup injects | +| **๐Ÿ“Š Dashboard UI** | All `ui-*` widgets, the wrapper functions that turn a button click into a typed `msg`, the trend-feeder split functions | Anything that produces data autonomously, anything that talks to EVOLV nodes directly | +| **๐ŸŽ›๏ธ Demo Drivers** | Random generators, scripted scenarios, schedule injectors, anything that exists only to drive the demo | Real production data sources (those go on Process Plant or are wired in externally) | +| **โš™๏ธ Setup & Init** | One-shot `once: true` injects (setMode, setScaling, auto-startup) | Anything that fires more than once | + +**Why these four:** each tab can be disabled or deleted independently. Disable Demo Drivers โ†’ demo becomes inert until a real data source is wired. Disable Setup โ†’ fresh deploys don't auto-configure (good for debugging). Disable Dashboard UI โ†’ headless mode for tests. Process Plant always stays. + +If you find yourself wanting a node "between" two tabs, you've named your concerns wrong โ€” re-split. + +## 2. Cross-tab wiring โ€” link nodes only, named channels + +Never wire a node on tab A directly to a node on tab B. Use **named link-out / link-in pairs**: + +```text +[ui-slider] โ”€โ”€โ–บ [link out cmd:demand] โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ” + โ”‚ + โ–ผ +[random gen] โ”€โ–บ [link out cmd:demand] โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€โ–บ [link in cmd:demand] โ”€โ”€โ–บ [router] โ”€โ”€โ–บ [MGC] + โ–ฒ + โ”‚ + many link-outs may target one link-in +``` + +### Naming convention + +Channels follow `:` lowercase, kebab-case after the colon: + +- `cmd:` โ€” UI / drivers โ†’ process. Carries commands. +- `evt:` โ€” process โ†’ UI / external. Carries state events. +- `setup:` โ€” setup tab โ†’ wherever. Carries one-shot init. + +Examples used in the pumping-station demo: +- `cmd:demand`, `cmd:randomToggle`, `cmd:mode` +- `cmd:station-startup`, `cmd:station-shutdown`, `cmd:station-estop` +- `cmd:setpoint-A`, `cmd:setpoint-B`, `cmd:setpoint-C` +- `cmd:pump-A-seq` (start/stop for pump A specifically) +- `evt:pump-A`, `evt:pump-B`, `evt:pump-C`, `evt:mgc`, `evt:ps` +- `setup:to-mgc` + +### Channels are the contract + +The list of channel names IS the inter-tab API. Document it in the demo's README. Renaming a channel is a breaking change. + +### When to use one channel vs many + +- One channel, many emitters: same kind of message from multiple sources (e.g. `cmd:demand` is fired by both the slider and the random generator). +- Different channels: messages with different *meaning* even if they go to the same node (e.g. don't fold `cmd:setpoint-A` into a generic `cmd:pump-A` โ€” keep setpoint and start/stop separate). +- Avoid one mega-channel: a "process commands" channel that the receiver routes-by-topic is harder to read than separate channels per concern. + +### Don't use link-call for fan-out + +`link call` is for synchronous request/response (waits for a paired `link out` in `return` mode). For fan-out, use plain `link out` (mode=`link`) with multiple targets, or a single link out โ†’ single link in โ†’ function-node fan-out (whichever is clearer for your case). + +## 3. Spacing and visual layout + +Nodes need air to be readable. Apply these constants in any flow generator: + +```python +LANE_X = [120, 380, 640, 900, 1160, 1420] # 6 vertical lanes per tab +ROW = 80 # standard row pitch +SECTION_GAP = 200 # extra y-shift between sections +``` + +### Lane assignment (process plant tab as example) + +| Lane | Contents | +|---|---| +| 0 (x=120) | Inputs from outside the tab โ€” link-in nodes, injects | +| 1 (x=380) | First-level transformers โ€” wrappers, fan-outs, routers | +| 2 (x=640) | Mid-level โ€” section comments live here too | +| 3 (x=900) | Target nodes โ€” the EVOLV node itself (pump, MGC, PS) | +| 4 (x=1160) | Output formatters โ€” function nodes that build dashboard-friendly payloads | +| 5 (x=1420) | Outputs to outside the tab โ€” link-out nodes, debug taps | + +Inputs flow left โ†’ right. Don't loop wires backwards across the tab. + +### Section comments + +Every logical group within a tab gets a comment header at lane 2 with a `โ”€โ”€ Section name โ”€โ”€` style label. Use them liberally โ€” every 3-5 nodes deserves a header. The `info` field on the comment carries the multi-line description. + +### Section spacing + +`SECTION_GAP = 200` between sections, on top of the standard row pitch. Don't pack sections together โ€” when you have 6 measurements on a tab, give each pump 4 rows + a 200 px gap to the next pump. Yes, it makes tabs scroll. Scroll is cheap; visual confusion is expensive. + +## 4. Charts โ€” the trend-split rule + +ui-chart with `category: "topic"` + `categoryType: "msg"` plots one series per unique `msg.topic`. So: + +- One chart per **metric type** (one chart for flow, one for power). +- Each chart receives msgs whose `topic` is the **series label** (e.g. `Pump A`, `Pump B`, `Pump C`). + +### The trend-split function pattern + +A common bug: feeding both flow and power msgs to a single function output that wires to both charts. Both charts then plot all metrics, garbling the legend. + +**Fix:** the trend-feeder function MUST have one output per chart, and split: + +```js +// outputs: 2 +// wires: [["chart_flow"], ["chart_power"]] +const flowMsg = p.flowNum != null ? { topic: 'Pump A', payload: p.flowNum } : null; +const powerMsg = p.powerNum != null ? { topic: 'Pump A', payload: p.powerNum } : null; +return [flowMsg, powerMsg]; +``` + +A null msg on a given output sends nothing on that output โ€” exactly what we want. + +### Chart axis settings to actually configure + +- `removeOlder` + `removeOlderUnit`: how much history to keep (e.g. 10 minutes). +- `removeOlderPoints`: cap on points per series (200 is sensible for a demo). +- `ymin` / `ymax`: leave blank for autoscale, or set numeric strings if you want a fixed range. + +## 5. Inject node โ€” payload typing + +Multi-prop inject must populate `v` and `vt` **per prop**, not just the legacy top-level `payload` + `payloadType`: + +```json +{ + "props": [ + {"p": "topic", "vt": "str"}, + {"p": "payload", "v": "{\"action\":\"startup\"}", "vt": "json"} + ], + "topic": "execSequence", + "payload": "{\"action\":\"startup\"}", + "payloadType": "json" +} +``` + +If you only fill the top-level fields, `payload_type=json` is silently treated as `str`. + +## 6. Dashboard widget rules + +- **Widget = display only.** No business logic in `ui-text` formats or `ui-template` HTML. +- **Buttons emit a typed string payload** (`"fired"` or similar). Convert to the real msg shape with a tiny wrapper function on the same tab, before the link-out. +- **Sliders use `passthru: true`** so they re-emit on input messages (useful for syncing initial state from the process side later). +- **One ui-page per demo.** Multiple groups under one page is the natural split. +- **Group widths should sum to a multiple of 12.** The page grid is 12 columns. A row of `4 + 4 + 4` or `6 + 6` works; mixing arbitrary widths leaves gaps. +- **EVERY ui-* node needs `x` and `y` keys.** Without them Node-RED dumps the node at (0,0) โ€” every text widget and chart piles up in the top-left of the editor canvas. The dashboard itself still renders correctly (it lays out by group/order, not editor x/y), but the editor view is unreadable. If you write a flow generator helper, set `x` and `y` on the dict EVERY time. Test with `jq '[.[] | select(.x==0 and .y==0 and (.type|tostring|startswith("ui-")))]'` after generating. + +## 7. Do / don't checklist + +โœ… Do: + +- Generate flows from a Python builder (`build_flow.py`) โ€” it's the source of truth. +- Use deterministic IDs (`pump_a`, `meas_pump_a_u`, `lin_demand_to_mgc`) โ€” reproducible diffs across regenerations. +- Tag every channel name with `cmd:` / `evt:` / `setup:`. +- Comment every section, even short ones. +- Verify trends with a `ui-chart` of synthetic data first, before plumbing real data through. + +โŒ Don't: + +- Don't use `replace_all` on a Python identifier that appears in a node's own wires definition โ€” you'll create self-loops (>250k msg/s discovered the hard way). +- Don't wire across tabs directly. The wire IS allowed but it makes the editor unreadable. +- Don't put dashboard widgets next to EVOLV nodes โ€” different concerns. +- Don't pack nodes within 40 px of each other โ€” labels overlap, wires snap to wrong handles. +- Don't ship `enableLog: "debug"` in a demo โ€” fills the container log within seconds and obscures real errors. + +## 8. The link-out / link-in JSON shape (cheat sheet) + +```json +{ + "id": "lout_demand_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:demand", + "mode": "link", + "links": ["lin_demand_to_mgc"], + "x": 380, "y": 140, + "wires": [] +} +``` + +```json +{ + "id": "lin_demand_to_mgc", + "type": "link in", + "z": "tab_process", + "name": "cmd:demand", + "links": ["lout_demand_dash", "lout_demand_drivers"], + "x": 120, "y": 1500, + "wires": [["demand_fanout_mgc_ps"]] +} +``` + +Both ends store the paired ids in `links`. The `name` is cosmetic (label only) โ€” Node-RED routes by id. Multiple emitters can target one receiver; one emitter can target multiple receivers. + +## 9. Verifying the layout + +Before declaring a flow done: + +1. **Open the tab in the editor โ€” every wire should run left โ†’ right.** No backward loops. +2. **Open each section by section comment โ€” visible in 1 screen height.** If not, raise `SECTION_GAP`. +3. **Hit the dashboard URL โ€” every widget has data.** `n/a` everywhere is a contract failure. +4. **For charts, watch a series populate over 30 s.** A blank chart after 30 s = bug. +5. **Disable each tab one at a time and re-deploy.** Process Plant alone should still load (just inert). Dashboard UI alone should serve a page (just empty). If disabling a tab errors out, the tab boundaries are wrong. diff --git a/CLAUDE.md b/CLAUDE.md index 65375b2..608167e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ Each node follows a three-layer pattern: - Config JSON files in `generalFunctions/src/configs/` define defaults, types, enums per node - Tick loop runs at 1000ms intervals for time-based updates - Three outputs per node: [process, dbase, parent] +- **Multi-tab demo flows**: see `.claude/rules/node-red-flow-layout.md` for the tab/link-channel/spacing rule set used by `examples/` ## Development Notes - No build step required - pure Node.js diff --git a/examples/pumpingstation-3pumps-dashboard/README.md b/examples/pumpingstation-3pumps-dashboard/README.md index 1538868..b12bd93 100644 --- a/examples/pumpingstation-3pumps-dashboard/README.md +++ b/examples/pumpingstation-3pumps-dashboard/README.md @@ -22,6 +22,34 @@ Then open the dashboard: - +## Tabs + +The flow is split across four tabs by **concern**: + +| Tab | Lives here | Why | +|---|---|---| +| ๐Ÿญ **Process Plant** | EVOLV nodes (3 pumps + MGC + PS + 6 measurements) and per-node output formatters | The "real plant" layer. Lift this tab into production unchanged. | +| ๐Ÿ“Š **Dashboard UI** | All `ui-*` widgets, button/setpoint wrappers, trend-split functions | Display + operator inputs only. No business logic. | +| ๐ŸŽ›๏ธ **Demo Drivers** | Random demand generator, random-toggle state | Demo-only stimulus. In production, delete this tab and feed `cmd:demand` from your real demand source. | +| โš™๏ธ **Setup & Init** | One-shot `once: true` injects (MGC scaling/mode, pumps mode, auto-startup, random-on) | Runs at deploy time only. Disable for production runtimes. | + +Cross-tab wiring uses **named link-out / link-in pairs**, never direct cross-tab wires. The channel names form the contract: + +| Channel | Direction | What it carries | +|---|---|---| +| `cmd:demand` | UI / drivers โ†’ process | numeric demand in mยณ/h | +| `cmd:randomToggle` | UI โ†’ drivers | `'on'` / `'off'` | +| `cmd:mode` | UI / setup โ†’ process | `'auto'` / `'virtualControl'` setMode broadcast | +| `cmd:station-startup` / `cmd:station-shutdown` / `cmd:station-estop` | UI / setup โ†’ process | station-wide command, fanned to all 3 pumps | +| `cmd:setpoint-A` / `-B` / `-C` | UI โ†’ process | per-pump setpoint slider value | +| `cmd:pump-A-seq` / `-B-seq` / `-C-seq` | UI โ†’ process | per-pump start/stop | +| `evt:pump-A` / `-B` / `-C` | process โ†’ UI | formatted per-pump status | +| `evt:mgc` | process โ†’ UI | MGC totals (flow / power / efficiency) | +| `evt:ps` | process โ†’ UI | basin state + level + volume + flows | +| `setup:to-mgc` | setup โ†’ process | MGC scaling/mode init | + +See `.claude/rules/node-red-flow-layout.md` for the full layout rule set this demo follows. + ## What the flow contains | Layer | Node(s) | Role | diff --git a/examples/pumpingstation-3pumps-dashboard/build_flow.py b/examples/pumpingstation-3pumps-dashboard/build_flow.py index 99f9479..3f532f7 100644 --- a/examples/pumpingstation-3pumps-dashboard/build_flow.py +++ b/examples/pumpingstation-3pumps-dashboard/build_flow.py @@ -1,106 +1,149 @@ #!/usr/bin/env python3 """ -Generate the full Node-RED flow JSON for the -'pumpingstation-3pumps-dashboard' end-to-end example. +Generate the multi-tab Node-RED flow for the +'pumpingstation-3pumps-dashboard' end-to-end demo. -The flow encodes: +Layout philosophy +----------------- +Every node gets a home tab based on its CONCERN, not the data it touches: - - 1 pumpingStation (basin model) - - 1 machineGroupControl (orchestrator, optimal control mode) - - 3 rotatingMachine (pumps, hidrostal-H05K-S03R curve) - - 6 measurement nodes (per pump: upstream + downstream pressure, mbar, - simulator mode so they tick continuously) - - Process demand input (dashboard slider + random generator) routed to - both pumpingStation (q_in) and MGC (Qd) - - Mode toggle between AUTO (MGC drives pumps) and MANUAL (dashboard - drives each pump individually) - - Per-pump and station-wide dashboard groups with status, setpoint, - flow / power gauges, and trend charts - - Inject-driven setup at deploy time so the stack auto-wires children - and starts in a known state + 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. -This file is the SOURCE OF TRUTH for the demo. To regenerate flow.json: + 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 - -To deploy directly to a running Dockerized Node-RED: - - python3 build_flow.py | curl -s -X POST http://localhost:1880/flows \ - -H "Content-Type: application/json" \ - -H "Node-RED-Deployment-Type: full" \ - --data-binary @- """ import json import sys -TAB_ID = "ps_demo_tab" # --------------------------------------------------------------------------- -# Helpers +# Tab IDs # --------------------------------------------------------------------------- -def mk_id(name): - return name.replace(" ", "_").replace("-", "_") +TAB_PROCESS = "tab_process" +TAB_UI = "tab_ui" +TAB_DRIVERS = "tab_drivers" +TAB_SETUP = "tab_setup" -def comment(node_id, x, y, name, info=""): - return { - "id": node_id, "type": "comment", "z": TAB_ID, "name": name, - "info": info, "x": x, "y": y, "wires": [] - } +# --------------------------------------------------------------------------- +# Spacing constants +# --------------------------------------------------------------------------- +LANE_X = [120, 380, 640, 900, 1160, 1420] +ROW = 80 # standard inter-row pitch +SECTION_GAP = 200 # additional shift between major sections -def inject(node_id, x, y, name, topic, payload, payload_type="str", once=False, repeat="", wires=None): - # Use the per-prop v/vt form so the runtime correctly types the - # payload (especially for payload_type='json' which otherwise gets - # passed through as a plain string when only the legacy top-level - # payload/payloadType fields are populated). + +# --------------------------------------------------------------------------- +# 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_ID, "name": name, + "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": "0.5", + "once": once, "onceDelay": once_delay, "x": x, "y": y, "wires": [wires or []], } -def function_node(node_id, x, y, name, code, outputs=1, wires=None): + +def function_node(node_id, tab, x, y, name, code, outputs=1, wires=None): return { - "id": node_id, "type": "function", "z": TAB_ID, "name": name, + "id": node_id, "type": "function", "z": tab, "name": name, "func": code, "outputs": outputs, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": x, "y": y, "wires": wires or [[] for _ in range(outputs)], + "x": x, "y": y, "wires": wires if wires is not None else [[] for _ in range(outputs)], } -def change_node(node_id, x, y, name, rules, wires=None): + +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": "change", "z": TAB_ID, "name": name, - "rules": rules, "action": "", "property": "", "from": "", "to": "", - "reg": False, "x": x, "y": y, "wires": [wires or []], + "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, x, y, name, links): + +def link_in(node_id, tab, x, y, channel_name, source_out_ids, downstream): return { - "id": node_id, "type": "link in", "z": TAB_ID, "name": name, - "links": links, "x": x, "y": y, "wires": [[]] + "id": node_id, "type": "link in", "z": tab, "name": channel_name, + "links": list(source_out_ids), + "x": x, "y": y, "wires": [downstream or []], } -def link_out(node_id, x, y, name, links): - return { - "id": node_id, "type": "link out", "z": TAB_ID, "name": name, - "mode": "link", "links": links, "x": x, "y": y, "wires": [] - } -def debug_node(node_id, x, y, name, target="payload", target_type="msg", active=False): +def debug_node(node_id, tab, x, y, name, target="payload", + target_type="msg", active=False): return { - "id": node_id, "type": "debug", "z": TAB_ID, "name": name, + "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": [] + "x": x, "y": y, "wires": [], } + # --------------------------------------------------------------------------- -# Dashboard scaffolding +# Dashboard scaffolding (ui-base / ui-theme / ui-page / ui-group) # --------------------------------------------------------------------------- def dashboard_scaffold(): base = { @@ -109,18 +152,18 @@ def dashboard_scaffold(): "includeClientData": True, "acceptsClientConfig": ["ui-notification", "ui-control"], "showPathInSidebar": True, "headerContent": "page", - "navigationStyle": "default", "titleBarStyle": "default" + "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" + "bgPage": "#f4f6fa", "groupBg": "#ffffff", "groupOutline": "#cccccc", }, "sizes": { "density": "default", "pagePadding": "12px", - "groupGap": "12px", "groupBorderRadius": "6px", "widgetGap": "8px" - } + "groupGap": "12px", "groupBorderRadius": "6px", "widgetGap": "8px", + }, } page = { "id": "ui_page_ps_demo", "type": "ui-page", @@ -128,52 +171,64 @@ def dashboard_scaffold(): "path": "/pumping-station-demo", "icon": "water_pump", "layout": "grid", "theme": "ui_theme_ps_demo", "breakpoints": [{"name": "Default", "px": "0", "cols": "12"}], - "order": 1, "className": "" + "order": 1, "className": "", } return [base, theme, page] + 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 + "disabled": False, "visible": True, } -def ui_text(node_id, x, y, group, name, label, fmt, layout="row-left"): + +def ui_text(node_id, tab, x, y, group, name, label, fmt, layout="row-left"): return { - "id": node_id, "type": "ui-text", "z": TAB_ID, "group": group, + "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", "wires": [] + "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, x, y, group, name, label, payload, payload_type, topic, color="#0f52a5", icon="play_arrow", wires=None): + +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_ID, "group": group, + "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 []] + "x": x, "y": y, "wires": [wires or []], } -def ui_slider(node_id, x, y, group, name, label, mn, mx, step=1.0, topic="", wires=None): + +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_ID, "group": group, + "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 []] + "iconEnd": "", "x": x, "y": y, "wires": [wires or []], } -def ui_switch(node_id, x, y, group, name, label, on_value="auto", off_value="manual", topic="modeToggle", wires=None): + +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_ID, "group": group, + "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", @@ -182,12 +237,13 @@ def ui_switch(node_id, x, y, group, name, label, on_value="auto", off_value="man "onicon": "auto_mode", "oncolor": "#0f52a5", "offvalue": off_value, "offvalueType": "str", "officon": "back_hand", "offcolor": "#888888", - "x": x, "y": y, "wires": [wires or []] + "x": x, "y": y, "wires": [wires or []], } -def ui_chart(node_id, x, y, group, name, label, series_topics, ymin=None, ymax=None): + +def ui_chart(node_id, tab, x, y, group, name, label, ymin=None, ymax=None): return { - "id": node_id, "type": "ui-chart", "z": TAB_ID, "group": group, + "id": node_id, "type": "ui-chart", "z": tab, "group": group, "name": name, "label": label, "order": 1, "chartType": "line", "category": "topic", "categoryType": "msg", "xAxisLabel": "", "xAxisType": "time", "xAxisTimeFormat": "auto", @@ -195,81 +251,54 @@ def ui_chart(node_id, x, y, group, name, label, series_topics, ymin=None, ymax=N "ymax": "" if ymax is None else str(ymax), "action": "append", "pointShape": "circle", "pointRadius": 2, "showLegend": True, "removeOlder": "10", "removeOlderUnit": "60", - "removeOlderPoints": "200", "colors": [], "textColor": [], "textColorDefault": True, - "width": "0", "height": "0", "className": "", "wires": [[]] + "removeOlderPoints": "200", "colors": [], "textColor": [], + "textColorDefault": True, + "width": "0", "height": "0", "className": "", + "x": x, "y": y, + "wires": [[]], } -def ui_gauge(node_id, x, y, group, name, label, mn, mx, units, segments=None): - return { - "id": node_id, "type": "ui-gauge", "z": TAB_ID, "group": group, - "name": name, "label": label, "order": 1, "width": "0", "height": "0", - "min": str(mn), "max": str(mx), "type": "gauge", - "units": units, "valueColor": "#0f52a5", "valueRound": True, - "valueFontSize": 16, "labelFontSize": 12, "iconColor": "", - "valueFormat": "value | number:1", "wires": [] - } # --------------------------------------------------------------------------- -# Build the flow +# Tab 1 โ€” PROCESS PLANT # --------------------------------------------------------------------------- -def build(): +def build_process_tab(): nodes = [] - nodes += dashboard_scaffold() - # ----- Tab ----- nodes.append({ - "id": TAB_ID, "type": "tab", - "label": "Pumping Station โ€” 3 Pumps Demo", + "id": TAB_PROCESS, "type": "tab", + "label": "๐Ÿญ Process Plant", "disabled": False, - "info": "End-to-end demo: pumpingStation + machineGroupControl + 3 rotatingMachines, each with upstream/downstream pressure sensors. Process demand input via dashboard slider OR auto random generator. Dashboard at /dashboard/pumping-station-demo." + "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.", }) - # ----- Dashboard groups ----- - 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" - g_trend = "ui_grp_trend" - PG = "ui_page_ps_demo" - nodes += [ - ui_group(g_demand, "1. Process Demand", PG, width=12, order=1), - ui_group(g_station, "2. Pumping Station", PG, width=12, order=2), - ui_group(g_pump_a, "3a. Pump A", PG, width=4, order=3), - ui_group(g_pump_b, "3b. Pump B", PG, width=4, order=4), - ui_group(g_pump_c, "3c. Pump C", PG, width=4, order=5), - ui_group(g_trend, "4. Trends", PG, width=12, order=6), - ] - - # ----- Comments / sections ----- - nodes.append(comment("c_title", 200, 40, - "Pumping Station โ€” 3 Pumps Demo", - "Process demand โ†’ pumpingStation (basin model) + machineGroupControl (orchestrator) โ†’ 3 rotatingMachines. Each pump has upstream + downstream pressure measurements. Auto/Manual mode toggle on the dashboard." + 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." )) - # =========================================================== - # Backend node IDs - # =========================================================== - PS_ID = "ps_basin" - MGC_ID = "mgc_pumps" - PUMPS = ["pump_a", "pump_b", "pump_c"] - PUMP_LABELS = {"pump_a": "Pump A", "pump_b": "Pump B", "pump_c": "Pump C"} - PUMP_GROUPS = {"pump_a": g_pump_a, "pump_b": g_pump_b, "pump_c": g_pump_c} - - # =========================================================== - # MEASUREMENT NODES (6 total โ€” 2 per pump) - # =========================================================== - meas_nodes = [] + # ---------------- 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]}" - x = 200 + j * 240 - y = 1000 + i * 220 - # Different ranges/setpoints so each pump has plausible pressure absmin, absmax = (50, 400) if pos == "upstream" else (800, 2200) - mid_label = f"PT-{PUMP_LABELS[pump].split()[1]}-{'Up' if pos=='upstream' else 'Dn'}" + mid_label = f"PT-{label.split()[1]}-{'Up' if pos == 'upstream' else 'Dn'}" nodes.append({ - "id": mid, "type": "measurement", "z": TAB_ID, + "id": mid, "type": "measurement", "z": TAB_PROCESS, "name": mid_label, "mode": "analog", "channels": "[]", "scaling": False, @@ -282,24 +311,45 @@ def build(): "supplier": "vega", "category": "sensor", "assetType": "pressure", "model": "vega-pressure-10", "unit": "mbar", "assetTagNumber": f"PT-{i+1}-{pos[0].upper()}", - "enableLog": True, "logLevel": "warn", + "enableLog": False, "logLevel": "warn", "positionVsParent": pos, "positionIcon": "", - "hasDistance": False, "distance": 0, "distanceUnit": "m", "distanceDescription": "", - "x": x, "y": y, "wires": [[], [], [pump]] # Port 2 -> pump + "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]], }) - meas_nodes.append(mid) - # =========================================================== - # ROTATING MACHINES (3 pumps) - # =========================================================== - for i, pump in enumerate(PUMPS): - x = 700 - y = 700 + i * 120 - # Wires: port 0 -> pump-port-0 router; port 1 -> debug; port 2 -> MGC + # 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_ID, - "name": PUMP_LABELS[pump], - "speed": "10", # 10 %/s ramp โ€” fast enough for demo + "id": pump, "type": "rotatingMachine", "z": TAB_PROCESS, + "name": label, + "speed": "10", "startup": "2", "warmup": "1", "shutdown": "2", "cooldown": "1", "movementMode": "staticspeed", "machineCurve": "", @@ -310,297 +360,23 @@ def build(): "unit": "m3/h", "curvePressureUnit": "mbar", "curveFlowUnit": "m3/h", "curvePowerUnit": "kW", "curveControlUnit": "%", - "enableLog": True, "logLevel": "warn", + "enableLog": False, "logLevel": "warn", "positionVsParent": "atEquipment", "positionIcon": "", - "hasDistance": False, "distance": 0, "distanceUnit": "m", "distanceDescription": "", - "x": x, "y": y, + "hasDistance": False, "distance": 0, "distanceUnit": "m", + "distanceDescription": "", + "x": LANE_X[3], "y": y_section + 80, "wires": [ - [f"router_p0_{pump}"], # port 0 process - [], # port 1 dbase - [MGC_ID], # port 2 -> MGC for registration - ] + [f"format_{pump}"], # port 0 process -> formatter + [], # port 1 dbase + [MGC_ID], # port 2 -> MGC for registerChild + ], }) - # =========================================================== - # MACHINE GROUP CONTROL - # =========================================================== - nodes.append({ - "id": MGC_ID, "type": "machineGroupControl", "z": TAB_ID, - "name": "MGC โ€” Pump Group", - "uuid": "mgc-pump-group", - "category": "controller", - "assetType": "machinegroupcontrol", - "model": "default", - "unit": "m3/h", - "supplier": "evolv", - "enableLog": True, "logLevel": "warn", - "positionVsParent": "atEquipment", "positionIcon": "", - "hasDistance": False, "distance": 0, "distanceUnit": "m", "distanceDescription": "", - "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", - "x": 1100, "y": 760, - "wires": [ - [], # port 0 process - [], # port 1 dbase - [PS_ID], # port 2 -> pumpingStation registration - ] - }) - - # =========================================================== - # PUMPING STATION - # =========================================================== - nodes.append({ - "id": PS_ID, "type": "pumpingStation", "z": TAB_ID, - "name": "Pumping Station", - "uuid": "ps-basin-1", - "category": "station", - "assetType": "pumpingstation", - "model": "default", - "unit": "m3/s", - "supplier": "evolv", - "enableLog": True, "logLevel": "info", - "positionVsParent": "atEquipment", "positionIcon": "", - "hasDistance": False, "distance": 0, "distanceUnit": "m", "distanceDescription": "", - "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", - # Default PS control mode 'levelbased' will auto-shut all pumps as - # soon as basin level dips below stopLevel (1m default). For an - # operator-driven demo we want PS to OBSERVE only โ€” set it to - # 'manual' from boot via the controlMode UI field. - "controlMode": "manual", - # Bigger basin so level metrics are interesting on the dashboard. - "basinVolume": 50, "basinHeight": 4, - # Disable safety guards. The defaults turn ON dry-run + overfill - # protection which shut every pump as soon as basin vol drops below - # 2% of minVol (or rises above 98% of maxVolOverflow). For an - # operator-driven demo where there is no real inflow source, the - # basin will frequently look "empty" โ€” that's expected, not a fault. - "enableDryRunProtection": False, - "enableOverfillProtection": False, - "dryRunThresholdPercent": 0, - "overfillThresholdPercent": 100, - "timeleftToFullOrEmptyThresholdSeconds": 0, - "x": 1450, "y": 760, - "wires": [ - ["ps_to_dashboard"], # port 0 process -> dashboard formatter - [], # port 1 dbase - ] - }) - - # =========================================================== - # PROCESS DEMAND โ€” slider + random generator + router - # =========================================================== - # Demand value enters a router that fans out to: - # - pumpingStation (q_in) - # - MGC (Qd) - # - dashboard text + chart - nodes.append(ui_slider( - "ui_demand_slider", 100, 200, g_demand, - "Process demand slider", "Process Demand (mยณ/h)", - 0, 300, 5.0, "manualDemand", - wires=["demand_router"] - )) - nodes.append(ui_switch( - "ui_random_toggle", 100, 260, g_demand, - "Random demand", "Random demand generator (auto)", - on_value="on", off_value="off", topic="randomToggle", - wires=["random_state"] - )) - nodes.append(ui_text( - "ui_demand_text", 100, 320, g_demand, - "Current demand text", "Current demand", "{{msg.payload}} mยณ/h", - )) - - # Random generator: every 3s pick a value in [40, 240] mยณ/h. - nodes.append(inject( - "demand_rand_tick", 100, 380, - "tick (random demand)", - topic="randomTick", payload="", payload_type="date", - repeat="3", wires=["demand_rand_fn"] - )) - nodes.append(function_node( - "random_state", 280, 260, "store random state", - "context.set('on', msg.payload === 'on'); return null;", - outputs=0 - )) - nodes.append(function_node( - "demand_rand_fn", 280, 380, "random demand", - "if (!context.get('on')) { return null; }\n" - "const v = Math.round(40 + Math.random() * 200);\n" - "msg.payload = v;\n" - "msg.topic = 'manualDemand';\n" - "return msg;", - outputs=1, wires=[["demand_router", "ui_demand_text"]] - )) - - # Router: fan out current demand to pumpingStation (q_in) and MGC (Qd). - # NOTE: We only forward when demand > 0. A demand of 0 would - # - shut every running pump via MGC.turnOffAllMachines (Qd=0 path), and - # - drive the basin level toward 'draining', which makes - # pumpingStation._applyLevelBasedControl call group.turnOffAllMachines - # ANYWAY, even without the MGC Qd wire. - # The single guard below short-circuits the whole chain so the demo - # doesn't auto-shutdown pumps when no operator demand has been entered yet. - # Use the Stop All button to actually take pumps down. - nodes.append(function_node( - "demand_router", 480, 260, "fan-out demand", - "const v = Number(msg.payload);\n" - "if (!Number.isFinite(v) || v <= 0) return null;\n" - "// Feed pumpingStation q_in (m3/s canonical) and MGC Qd (m3/h).\n" - "const qin = { topic: 'q_in', payload: v / 3600, unit: 'm3/s' };\n" - "const qd = { topic: 'Qd', payload: v };\n" - "const text = { topic: 'demand', payload: v };\n" - "return [qin, qd, text];", - outputs=3, - wires=[[PS_ID], [MGC_ID, "dbg_demand_to_mgc"], ["ui_demand_text"]] - )) - nodes.append(debug_node("dbg_demand_to_mgc", 700, 320, "โ†’ MGC Qd", target="payload", active=True)) - - # =========================================================== - # MODE toggle (auto / manual) - # =========================================================== - nodes.append(ui_switch( - "ui_mode_toggle", 100, 460, g_station, - "Auto/Manual mode", "Mode (Auto = MGC orchestrates ยท Manual = dashboard per-pump)", - on_value="auto", off_value="virtualControl", topic="setMode", - wires=["mode_fanout"] - )) - nodes.append(function_node( - "mode_fanout", 320, 460, "broadcast setMode to all pumps", - "msg.topic = 'setMode';\n" - "// Send same setMode payload to all 3 pumps.\n" - "return [msg, msg, msg];", - outputs=3, - wires=[["pump_a"], ["pump_b"], ["pump_c"]] - )) - - # =========================================================== - # STATION-WIDE BUTTONS - # =========================================================== - nodes.append(ui_button( - "btn_station_startup", 100, 520, g_station, - "Start all pumps", "Startup all", "startup", "str", - topic="stationStartup", color="#16a34a", icon="play_arrow", - wires=["station_startup_fan"] - )) - nodes.append(function_node( - "station_startup_fan", 320, 520, "fan startup to pumps", - "const cmd = { topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } };\n" - "return [cmd, cmd, cmd];", - outputs=3, - wires=[["pump_a"], ["pump_b"], ["pump_c"]] - )) - nodes.append(ui_button( - "btn_station_shutdown", 100, 580, g_station, - "Stop all pumps", "Shutdown all", "shutdown", "str", - topic="stationShutdown", color="#ea580c", icon="stop", - wires=["station_shutdown_fan"] - )) - nodes.append(function_node( - "station_shutdown_fan", 320, 580, "fan shutdown to pumps", - "const cmd = { topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'shutdown' } };\n" - "return [cmd, cmd, cmd];", - outputs=3, - wires=[["pump_a"], ["pump_b"], ["pump_c"]] - )) - nodes.append(ui_button( - "btn_station_estop", 100, 640, g_station, - "EMERGENCY STOP", "EMERGENCY STOP", "estop", "str", - topic="stationEstop", color="#dc2626", icon="stop_circle", - wires=["station_estop_fan"] - )) - nodes.append(function_node( - "station_estop_fan", 320, 640, "fan estop to pumps", - "const cmd = { topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } };\n" - "return [cmd, cmd, cmd];", - outputs=3, - wires=[["pump_a"], ["pump_b"], ["pump_c"]] - )) - - # =========================================================== - # PER-PUMP DASHBOARD (status text + setpoint slider + buttons) - # =========================================================== - for i, pump in enumerate(PUMPS): - g = PUMP_GROUPS[pump] - label = PUMP_LABELS[pump] - y_base = 100 + i * 320 - - # Status text โ€” fed by router_p0_ formatter - nodes.append(ui_text( - f"ui_{pump}_state", 1500, y_base + 0, g, - f"{label} state", "State", "{{msg.payload.state}}" - )) - nodes.append(ui_text( - f"ui_{pump}_mode", 1500, y_base + 30, g, - f"{label} mode", "Mode", "{{msg.payload.mode}}" - )) - nodes.append(ui_text( - f"ui_{pump}_ctrl", 1500, y_base + 60, g, - f"{label} ctrl", "Controller %", "{{msg.payload.ctrl}}" - )) - nodes.append(ui_text( - f"ui_{pump}_flow", 1500, y_base + 90, g, - f"{label} flow", "Flow (mยณ/h)", "{{msg.payload.flow}}" - )) - nodes.append(ui_text( - f"ui_{pump}_power", 1500, y_base + 120, g, - f"{label} power", "Power (kW)", "{{msg.payload.power}}" - )) - nodes.append(ui_text( - f"ui_{pump}_pUp", 1500, y_base + 150, g, - f"{label} pUp", "p Upstream (mbar)", "{{msg.payload.pUp}}" - )) - nodes.append(ui_text( - f"ui_{pump}_pDn", 1500, y_base + 180, g, - f"{label} pDn", "p Downstream (mbar)", "{{msg.payload.pDn}}" - )) - - # Per-pump manual setpoint slider (only effective in virtualControl mode) - nodes.append(ui_slider( - f"ui_{pump}_setpoint", 100, y_base, g, - f"{label} setpoint", "Setpoint % (manual mode)", - 0, 100, 5.0, f"setpoint_{pump}", - wires=[f"setpoint_to_pump_{pump}"] - )) + # Per-pump output formatter: builds the structured event used by the + # dashboard widgets and trend feeders. nodes.append(function_node( - f"setpoint_to_pump_{pump}", 320, y_base, f"build setpoint cmd for {label}", - "msg.topic = 'execMovement';\n" - "msg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\n" - "return msg;", - outputs=1, wires=[[pump]] - )) - - # Per-pump start/stop buttons - nodes.append(ui_button( - f"btn_{pump}_startup", 100, y_base + 60, g, - f"{label} startup", "Startup", "startup", "str", - topic=f"start_{pump}", color="#16a34a", icon="play_arrow", - wires=[f"start_to_pump_{pump}"] - )) - nodes.append(function_node( - f"start_to_pump_{pump}", 320, y_base + 60, f"build startup for {label}", - "msg.topic = 'execSequence';\n" - "msg.payload = { source: 'GUI', action: 'execSequence', parameter: 'startup' };\n" - "return msg;", - outputs=1, wires=[[pump]] - )) - nodes.append(ui_button( - f"btn_{pump}_shutdown", 100, y_base + 120, g, - f"{label} shutdown", "Shutdown", "shutdown", "str", - topic=f"stop_{pump}", color="#ea580c", icon="stop", - wires=[f"stop_to_pump_{pump}"] - )) - nodes.append(function_node( - f"stop_to_pump_{pump}", 320, y_base + 120, f"build shutdown for {label}", - "msg.topic = 'execSequence';\n" - "msg.payload = { source: 'GUI', action: 'execSequence', parameter: 'shutdown' };\n" - "return msg;", - outputs=1, wires=[[pump]] - )) - - # Port 0 router โ€” merge delta updates into a per-pump cache and - # produce a tidy {state, mode, ctrl, flow, power, pUp, pDn} object - # for the dashboard widgets. - nodes.append(function_node( - f"router_p0_{pump}", 1100, y_base + 90, f"format {label} port 0", + 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" @@ -621,49 +397,128 @@ def build(): " power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n" " pUp: pU != null ? Number(pU).toFixed(0) : 'n/a',\n" " pDn: pD != null ? Number(pD).toFixed(0) : 'n/a',\n" - " flowNum: flow != null ? Number(flow) : null,\n" - " powerNum: power != null ? Number(power) : null\n" + " flowNum: flow != null ? Number(flow) : null,\n" + " powerNum: power != null ? Number(power) : null,\n" "};\n" "return msg;", - outputs=1, - wires=[[ - f"ui_{pump}_state", f"ui_{pump}_mode", f"ui_{pump}_ctrl", - f"ui_{pump}_flow", f"ui_{pump}_power", - f"ui_{pump}_pUp", f"ui_{pump}_pDn", - f"trend_split_{pump}" - ]] + outputs=1, wires=[[f"lout_evt_{pump}"]], )) - # Trend feeders โ€” one stream per metric for the chart - nodes.append(function_node( - f"trend_split_{pump}", 1300, y_base + 150, f"emit trend points for {label}", - "const p = msg.payload || {};\n" - "const out = [];\n" - "if (p.flowNum != null) out.push({ topic: '" + label + " flow', payload: p.flowNum });\n" - "if (p.powerNum != null) out.push({ topic: '" + label + " power', payload: p.powerNum });\n" - "return [out];", - outputs=1, wires=[["trend_chart_flow", "trend_chart_power"]] + # link-out: per-pump event stream โ†’ dashboard + 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"], )) - # =========================================================== - # TREND CHARTS - # =========================================================== - nodes.append(ui_chart( - "trend_chart_flow", 1500, 1450, g_trend, - "Flow per pump (mยณ/h)", "Flow per pump", - ["Pump A flow", "Pump B flow", "Pump C flow"] + # ---------------- 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." )) - nodes.append(ui_chart( - "trend_chart_power", 1500, 1500, g_trend, - "Power per pump (kW)", "Power per pump", - ["Pump A power", "Pump B power", "Pump C power"] + nodes.append(link_in( + "lin_demand_to_mgc", TAB_PROCESS, LANE_X[0], y_mgc + 60, + CH_DEMAND, + source_out_ids=[f"lout_demand_drivers", f"lout_demand_dash"], + downstream=["demand_fanout_mgc_ps"], )) - - # =========================================================== - # PUMPING STATION DASHBOARD (basin level + total flow) - # =========================================================== + # Single fanout: one demand value โ†’ MGC (Qd) + PS (q_in). + # Skips when v <= 0 to avoid auto-shutdown. nodes.append(function_node( - "ps_to_dashboard", 1700, 700, "format PS port 0 for dashboard", + "demand_fanout_mgc_ps", TAB_PROCESS, LANE_X[1] + 220, y_mgc + 60, + "demand โ†’ MGC + PS", + "const v = Number(msg.payload);\n" + "if (!Number.isFinite(v) || v <= 0) return null;\n" + "// MGC accepts Qd in m3/h directly when scaling=absolute.\n" + "const qd = { topic: 'Qd', payload: v };\n" + "// PS accepts q_in in m3/s (canonical) via the 'unit' field.\n" + "const qin = { topic: 'q_in', payload: v / 3600, unit: 'm3/s' };\n" + "return [qd, qin];", + outputs=2, wires=[[MGC_ID], [PS_ID]], + )) + 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, manual control mode)", + "Receives q_in from demand fanout. Emits formatted basin state." + )) + nodes.append({ + "id": PS_ID, "type": "pumpingStation", "z": TAB_PROCESS, + "name": "Pumping Station", + "uuid": "ps-basin-1", + "category": "station", "assetType": "pumpingstation", + "model": "default", "unit": "m3/s", "supplier": "evolv", + "enableLog": False, "logLevel": "warn", + "positionVsParent": "atEquipment", "positionIcon": "", + "hasDistance": False, "distance": 0, "distanceUnit": "m", + "distanceDescription": "", + "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", + # PS in manual mode + safeties off โ€” see top-level README for why. + "controlMode": "manual", + "basinVolume": 50, "basinHeight": 4, + "enableDryRunProtection": False, + "enableOverfillProtection": False, + "dryRunThresholdPercent": 0, + "overfillThresholdPercent": 100, + "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" @@ -682,82 +537,546 @@ def build(): " 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" " state: c.state || c.direction || 'idle',\n" - " netNum: (qIn != null && qOut != null) ? (Number(qIn) - Number(qOut)) * 3600 : null\n" + " levelNum: lvl != null ? Number(lvl) : null,\n" + " volumeNum: vol != null ? Number(vol) : null,\n" "};\n" "return msg;", - outputs=1, wires=[[ - "ui_ps_level", "ui_ps_volume", "ui_ps_qin", "ui_ps_qout", "ui_ps_state" - ]] + 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"], )) - nodes.append(ui_text("ui_ps_state", 1900, 700, g_station, "PS state", "Basin state", "{{msg.payload.state}}")) - nodes.append(ui_text("ui_ps_level", 1900, 730, g_station, "PS level", "Basin level", "{{msg.payload.level}}")) - nodes.append(ui_text("ui_ps_volume", 1900, 760, g_station, "PS volume","Basin volume", "{{msg.payload.volume}}")) - nodes.append(ui_text("ui_ps_qin", 1900, 790, g_station, "PS Qin", "Inflow", "{{msg.payload.qIn}}")) - nodes.append(ui_text("ui_ps_qout", 1900, 820, g_station, "PS Qout", "Pumped out", "{{msg.payload.qOut}}")) - # =========================================================== - # SETUP INJECTS (fire once on deploy) - # =========================================================== - # MGC needs scaling=absolute + mode=optimalcontrol so Qd is treated - # in mยณ/h directly and pumps are dispatched by BEP optimization. - nodes.append(inject( - "setup_mgc_scaling", 100, 800, - "setup: MGC scaling=absolute", - topic="setScaling", payload="absolute", payload_type="str", - once=True, wires=[MGC_ID] + # ---------------- 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(inject( - "setup_mgc_mode", 100, 840, - "setup: MGC mode=optimalcontrol", - topic="setMode", payload="optimalcontrol", payload_type="str", - once=True, wires=[MGC_ID] - )) - # Default the pumps + MGC to auto so the slider drives them out of the box. - nodes.append(inject( - "setup_pumps_mode", 100, 880, - "setup: pumps mode=auto", - topic="setMode", payload="auto", payload_type="str", - once=True, wires=[ - "mode_setup_fan" - ] + 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( - "mode_setup_fan", 320, 880, "fan setup mode to pumps", + "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"]] - )) - # Auto-startup all pumps after registration settles (~3s) - nodes.append(inject( - "setup_pumps_startup", 100, 920, - "setup: pumps startup", - topic="execSequence", - payload='{"source":"GUI","action":"execSequence","parameter":"startup"}', - payload_type="json", once=True, wires=["startup_setup_fan"] - )) - nodes[-1]["onceDelay"] = "4" - nodes.append(function_node( - "startup_setup_fan", 320, 920, "fan startup to pumps", - "return [msg, msg, msg];", - outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]] + outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]], )) - # Auto-enable the random demand generator + seed an initial demand - # value so the dashboard shows activity immediately on first deploy. - # Operator can flip the random toggle off + drag the slider any time. - nodes.append(inject( - "setup_random_on", 100, 960, - "setup: random demand ON", - topic="randomToggle", payload="on", payload_type="str", - once=True, wires=["random_state"] + # ---------------- 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." )) - nodes[-1]["onceDelay"] = "5" + 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_ps_demo" + 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" + g_trend = "ui_grp_trend" + 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), + ui_group(g_trend, "5. Trends", PG, width=12, order=8), + ] + + 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, + "Process demand slider", "Process Demand (mยณ/h)", + 0, 300, 5.0, "manualDemand", + wires=["lout_demand_dash"] + )) + nodes.append(link_out( + "lout_demand_dash", TAB_UI, LANE_X[1], y + 40, + CH_DEMAND, target_in_ids=["lin_demand_to_mgc", "lin_demand_to_text"] + )) + nodes.append(ui_switch( + "ui_random_toggle", TAB_UI, LANE_X[0], y + 100, g_demand, + "Random demand", "Random demand generator (auto)", + on_value="on", off_value="off", topic="randomToggle", + wires=["lout_random_dash"] + )) + nodes.append(link_out( + "lout_random_dash", TAB_UI, LANE_X[1], y + 100, + CH_RANDOM_TOGGLE, target_in_ids=["lin_random_to_drivers"] + )) + nodes.append(ui_text( + "ui_demand_text", TAB_UI, LANE_X[3], y + 40, g_demand, + "Current demand", "Current demand", "{{msg.payload}} mยณ/h" + )) + # Echo the demand back for the text widget + nodes.append(link_in( + "lin_demand_to_text", TAB_UI, LANE_X[2], y + 40, + CH_DEMAND, source_out_ids=["lout_demand_dash", "lout_demand_drivers"], + downstream=["ui_demand_text"] + )) + + # ===== SECTION: Mode + Station Buttons ===== + y = 320 + nodes.append(comment("c_ui_station", TAB_UI, LANE_X[2], y, + "โ”€โ”€ Mode + Station-wide buttons โ”€โ”€", "")) + nodes.append(ui_switch( + "ui_mode_toggle", TAB_UI, LANE_X[0], y + 40, g_station, + "Auto/Manual mode", + "Mode (Auto = MGC orchestrates ยท Manual = dashboard per-pump)", + on_value="auto", off_value="virtualControl", topic="setMode", + wires=["lout_mode_dash"] + )) + nodes.append(link_out( + "lout_mode_dash", TAB_UI, LANE_X[1], y + 40, + CH_MODE, target_in_ids=["lin_mode"] + )) + + 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=["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.totalFlow}}")) + nodes.append(ui_text("ui_mgc_total_power", TAB_UI, LANE_X[2], y + 70, g_mgc, + "MGC total power", "Total power", "{{msg.payload.totalPower}}")) + nodes.append(ui_text("ui_mgc_eff", TAB_UI, LANE_X[2], y + 100, g_mgc, + "MGC efficiency", "Group efficiency", "{{msg.payload.efficiency}}")) + + 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=["ui_ps_state", "ui_ps_level", "ui_ps_volume", "ui_ps_qin", "ui_ps_qout"] + )) + nodes.append(ui_text("ui_ps_state", TAB_UI, LANE_X[2], y + 160, g_ps, + "PS state", "Basin state", "{{msg.payload.state}}")) + nodes.append(ui_text("ui_ps_level", TAB_UI, LANE_X[2], y + 190, g_ps, + "PS level", "Basin level", "{{msg.payload.level}}")) + nodes.append(ui_text("ui_ps_volume", TAB_UI, LANE_X[2], y + 220, g_ps, + "PS volume","Basin volume", "{{msg.payload.volume}}")) + nodes.append(ui_text("ui_ps_qin", TAB_UI, LANE_X[2], y + 250, g_ps, + "PS Qin", "Inflow", "{{msg.payload.qIn}}")) + nodes.append(ui_text("ui_ps_qout", TAB_UI, LANE_X[2], y + 280, g_ps, + "PS Qout", "Pumped out", "{{msg.payload.qOut}}")) + + # ===== 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 for this pump's events + 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"ui_{pump}_state", + f"ui_{pump}_mode", + f"ui_{pump}_ctrl", + f"ui_{pump}_flow", + f"ui_{pump}_power", + f"ui_{pump}_pUp", + f"ui_{pump}_pDn", + f"trend_split_{pump}", + ], + )) + + # Status text widgets โ€” text-only, fed by the link-in + for k, (label_txt, fmt_field) in enumerate([ + ("State", "state"), + ("Mode", "mode"), + ("Controller %", "ctrl"), + ("Flow", "flow"), + ("Power", "power"), + ("p Upstream", "pUp"), + ("p Downstream", "pDn"), + ]): + nodes.append(ui_text( + f"ui_{pump}_{fmt_field}", TAB_UI, LANE_X[2], y_p + 40 + k * 30, g, + f"{label} {label_txt}", label_txt, + "{{msg.payload." + fmt_field + "}}" + )) + + # 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 feeder โ€” TWO outputs so flow goes to flow chart, power to + # power chart. Previous bug: single output โ†’ both charts saw both + # series and trends were unreadable. + nodes.append(function_node( + f"trend_split_{pump}", TAB_UI, LANE_X[3], y_p + 80, + f"trend split ({label})", + "const p = msg.payload || {};\n" + "const flowMsg = p.flowNum != null ? " + "{ topic: '" + label + "', payload: Number(p.flowNum) } : null;\n" + "const powerMsg = p.powerNum != null ? " + "{ topic: '" + label + "', payload: Number(p.powerNum) } : null;\n" + "return [flowMsg, powerMsg];", + outputs=2, + wires=[["trend_chart_flow"], ["trend_chart_power"]] + )) + + # Trend charts (shared across all 3 pumps) + y_charts = y_pumps_start + len(PUMPS) * SECTION_GAP * 2 + 80 + nodes.append(comment("c_ui_trends", TAB_UI, LANE_X[2], y_charts, + "โ”€โ”€ Trends (shared by all pumps) โ”€โ”€", + "Each chart accepts msg.topic as the series name (categoryType=msg)." + )) + nodes.append(ui_chart( + "trend_chart_flow", TAB_UI, LANE_X[3], y_charts + 40, g_trend, + "Flow per pump (mยณ/h)", "Flow per pump" + )) + nodes.append(ui_chart( + "trend_chart_power", TAB_UI, LANE_X[3], y_charts + 100, g_trend, + "Power per pump (kW)", "Power per pump" + )) + + return nodes + + +# --------------------------------------------------------------------------- +# Tab 3 โ€” DEMO DRIVERS +# --------------------------------------------------------------------------- +def build_drivers_tab(): + nodes = [] + nodes.append({ + "id": TAB_DRIVERS, "type": "tab", + "label": "๐ŸŽ›๏ธ Demo Drivers", + "disabled": False, + "info": "Auto stimulus for the demo. Random demand generator + state holder " + "for the dashboard's randomToggle switch. In production, delete this " + "tab and feed cmd:demand from your real demand source.", + }) + nodes.append(comment("c_drv_title", TAB_DRIVERS, LANE_X[2], 20, + "๐ŸŽ›๏ธ DEMO DRIVERS โ€” auto stimulus only", + "Removable: in production, replace this tab with the real demand source." + )) + + # Random toggle state holder (set by the dashboard switch) + y = 100 + nodes.append(comment("c_drv_state", TAB_DRIVERS, LANE_X[2], y, + "โ”€โ”€ Random toggle state โ”€โ”€", "")) + nodes.append(link_in( + "lin_random_to_drivers", TAB_DRIVERS, LANE_X[0], y + 40, + CH_RANDOM_TOGGLE, source_out_ids=["lout_random_dash"], + downstream=["random_state"] + )) + nodes.append(function_node( + "random_state", TAB_DRIVERS, LANE_X[1], y + 40, + "store random on/off", + "flow.set('randomOn', msg.payload === 'on');\n" + "return null;", + outputs=1, wires=[[]] + )) + + # Random demand generator: every 3s pick a value in [40, 240] mยณ/h + y = 250 + nodes.append(comment("c_drv_random", TAB_DRIVERS, LANE_X[2], y, + "โ”€โ”€ Random demand generator โ”€โ”€ (every 3 s)", "")) + nodes.append(inject( + "rand_tick", TAB_DRIVERS, LANE_X[0], y + 40, + "tick (random demand)", + topic="randomTick", payload="", payload_type="date", + repeat="3", wires=["random_demand_fn"] + )) + nodes.append(function_node( + "random_demand_fn", TAB_DRIVERS, LANE_X[1] + 220, y + 40, + "random demand", + "if (!flow.get('randomOn')) return null;\n" + "const v = Math.round(40 + Math.random() * 200);\n" + "return { topic: 'manualDemand', payload: v };", + outputs=1, wires=[["lout_demand_drivers"]] + )) + nodes.append(link_out( + "lout_demand_drivers", TAB_DRIVERS, LANE_X[3], y + 40, + CH_DEMAND, target_in_ids=["lin_demand_to_mgc", "lin_demand_to_text"] + )) + + 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 = absolute", + topic="setScaling", payload="absolute", 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"] + )) + + y = 450 + nodes.append(inject( + "setup_random_on", TAB_SETUP, LANE_X[0], y, + "auto-enable random demand", + topic="randomToggle", payload="on", payload_type="str", + once=True, once_delay="5", + wires=["lout_setup_random"] + )) + nodes.append(link_out( + "lout_setup_random", TAB_SETUP, LANE_X[1], y, + CH_RANDOM_TOGGLE, target_in_ids=["lin_random_to_drivers"] + )) + + 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(): - nodes = build() + 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") diff --git a/examples/pumpingstation-3pumps-dashboard/flow.json b/examples/pumpingstation-3pumps-dashboard/flow.json index de4a4a3..4156ab7 100644 --- a/examples/pumpingstation-3pumps-dashboard/flow.json +++ b/examples/pumpingstation-3pumps-dashboard/flow.json @@ -1,4 +1,1095 @@ [ + { + "id": "tab_process", + "type": "tab", + "label": "\ud83c\udfed 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 \u2014 those live on their own tabs so this layer can be lifted into production unchanged." + }, + { + "id": "c_process_title", + "type": "comment", + "z": "tab_process", + "name": "\ud83c\udfed PROCESS PLANT \u2014 EVOLV nodes only", + "info": "Per pump: 2 measurement sensors \u2192 rotatingMachine \u2192 output formatter \u2192 link-out to dashboard.\nMGC orchestrates 3 pumps. PS observes basin (manual mode for the demo).\nAll cross-tab wires are link-in / link-out by named channel.", + "x": 640, + "y": 20, + "wires": [] + }, + { + "id": "c_pump_a", + "type": "comment", + "z": "tab_process", + "name": "\u2500\u2500 Pump A \u2500\u2500", + "info": "Up + Dn pressure sensors register as children. rotatingMachine emits state on port 0 (formatted then link-out to UI). Port 2 emits registerChild \u2192 MGC.", + "x": 640, + "y": 100, + "wires": [] + }, + { + "id": "meas_pump_a_u", + "type": "measurement", + "z": "tab_process", + "name": "PT-A-Up", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 50, + "o_max": 400, + "simulator": true, + "smooth_method": "mean", + "count": "5", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_a-upstream", + "supplier": "vega", + "category": "sensor", + "assetType": "pressure", + "model": "vega-pressure-10", + "unit": "mbar", + "assetTagNumber": "PT-1-U", + "enableLog": false, + "logLevel": "warn", + "positionVsParent": "upstream", + "positionIcon": "", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 140, + "wires": [ + [], + [], + [ + "pump_a" + ] + ] + }, + { + "id": "meas_pump_a_d", + "type": "measurement", + "z": "tab_process", + "name": "PT-A-Dn", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 800, + "o_max": 2200, + "simulator": true, + "smooth_method": "mean", + "count": "5", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_a-downstream", + "supplier": "vega", + "category": "sensor", + "assetType": "pressure", + "model": "vega-pressure-10", + "unit": "mbar", + "assetTagNumber": "PT-1-D", + "enableLog": false, + "logLevel": "warn", + "positionVsParent": "downstream", + "positionIcon": "", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 190, + "wires": [ + [], + [], + [ + "pump_a" + ] + ] + }, + { + "id": "lin_setpoint_pump_a", + "type": "link in", + "z": "tab_process", + "name": "cmd:setpoint-A", + "links": [ + "lout_setpoint_pump_a_dash" + ], + "x": 120, + "y": 160, + "wires": [ + [ + "build_setpoint_pump_a" + ] + ] + }, + { + "id": "build_setpoint_pump_a", + "type": "function", + "z": "tab_process", + "name": "build setpoint cmd (Pump A)", + "func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 600, + "y": 160, + "wires": [ + [ + "pump_a" + ] + ] + }, + { + "id": "lin_seq_pump_a", + "type": "link in", + "z": "tab_process", + "name": "cmd:pump-A-seq", + "links": [ + "lout_seq_pump_a_dash" + ], + "x": 120, + "y": 210, + "wires": [ + [ + "pump_a" + ] + ] + }, + { + "id": "pump_a", + "type": "rotatingMachine", + "z": "tab_process", + "name": "Pump A", + "speed": "10", + "startup": "2", + "warmup": "1", + "shutdown": "2", + "cooldown": "1", + "movementMode": "staticspeed", + "machineCurve": "", + "uuid": "pump-pump_a", + "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": 900, + "y": 180, + "wires": [ + [ + "format_pump_a" + ], + [], + [ + "mgc_pumps" + ] + ] + }, + { + "id": "format_pump_a", + "type": "function", + "z": "tab_process", + "name": "format Pump A port 0", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst pU = find('pressure.measured.upstream.');\nconst pD = find('pressure.measured.downstream.');\nmsg.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\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pU != null ? Number(pU).toFixed(0) : 'n/a',\n pDn: pD != null ? Number(pD).toFixed(0) : 'n/a',\n flowNum: flow != null ? Number(flow) : null,\n powerNum: power != null ? Number(power) : null,\n};\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1160, + "y": 180, + "wires": [ + [ + "lout_evt_pump_a" + ] + ] + }, + { + "id": "lout_evt_pump_a", + "type": "link out", + "z": "tab_process", + "name": "evt:pump-A", + "mode": "link", + "links": [ + "lin_evt_pump_a_dash" + ], + "x": 1420, + "y": 180, + "wires": [] + }, + { + "id": "c_pump_b", + "type": "comment", + "z": "tab_process", + "name": "\u2500\u2500 Pump B \u2500\u2500", + "info": "Up + Dn pressure sensors register as children. rotatingMachine emits state on port 0 (formatted then link-out to UI). Port 2 emits registerChild \u2192 MGC.", + "x": 640, + "y": 300, + "wires": [] + }, + { + "id": "meas_pump_b_u", + "type": "measurement", + "z": "tab_process", + "name": "PT-B-Up", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 50, + "o_max": 400, + "simulator": true, + "smooth_method": "mean", + "count": "5", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_b-upstream", + "supplier": "vega", + "category": "sensor", + "assetType": "pressure", + "model": "vega-pressure-10", + "unit": "mbar", + "assetTagNumber": "PT-2-U", + "enableLog": false, + "logLevel": "warn", + "positionVsParent": "upstream", + "positionIcon": "", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 340, + "wires": [ + [], + [], + [ + "pump_b" + ] + ] + }, + { + "id": "meas_pump_b_d", + "type": "measurement", + "z": "tab_process", + "name": "PT-B-Dn", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 800, + "o_max": 2200, + "simulator": true, + "smooth_method": "mean", + "count": "5", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_b-downstream", + "supplier": "vega", + "category": "sensor", + "assetType": "pressure", + "model": "vega-pressure-10", + "unit": "mbar", + "assetTagNumber": "PT-2-D", + "enableLog": false, + "logLevel": "warn", + "positionVsParent": "downstream", + "positionIcon": "", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 390, + "wires": [ + [], + [], + [ + "pump_b" + ] + ] + }, + { + "id": "lin_setpoint_pump_b", + "type": "link in", + "z": "tab_process", + "name": "cmd:setpoint-B", + "links": [ + "lout_setpoint_pump_b_dash" + ], + "x": 120, + "y": 360, + "wires": [ + [ + "build_setpoint_pump_b" + ] + ] + }, + { + "id": "build_setpoint_pump_b", + "type": "function", + "z": "tab_process", + "name": "build setpoint cmd (Pump B)", + "func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 600, + "y": 360, + "wires": [ + [ + "pump_b" + ] + ] + }, + { + "id": "lin_seq_pump_b", + "type": "link in", + "z": "tab_process", + "name": "cmd:pump-B-seq", + "links": [ + "lout_seq_pump_b_dash" + ], + "x": 120, + "y": 410, + "wires": [ + [ + "pump_b" + ] + ] + }, + { + "id": "pump_b", + "type": "rotatingMachine", + "z": "tab_process", + "name": "Pump B", + "speed": "10", + "startup": "2", + "warmup": "1", + "shutdown": "2", + "cooldown": "1", + "movementMode": "staticspeed", + "machineCurve": "", + "uuid": "pump-pump_b", + "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": 900, + "y": 380, + "wires": [ + [ + "format_pump_b" + ], + [], + [ + "mgc_pumps" + ] + ] + }, + { + "id": "format_pump_b", + "type": "function", + "z": "tab_process", + "name": "format Pump B port 0", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst pU = find('pressure.measured.upstream.');\nconst pD = find('pressure.measured.downstream.');\nmsg.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\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pU != null ? Number(pU).toFixed(0) : 'n/a',\n pDn: pD != null ? Number(pD).toFixed(0) : 'n/a',\n flowNum: flow != null ? Number(flow) : null,\n powerNum: power != null ? Number(power) : null,\n};\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1160, + "y": 380, + "wires": [ + [ + "lout_evt_pump_b" + ] + ] + }, + { + "id": "lout_evt_pump_b", + "type": "link out", + "z": "tab_process", + "name": "evt:pump-B", + "mode": "link", + "links": [ + "lin_evt_pump_b_dash" + ], + "x": 1420, + "y": 380, + "wires": [] + }, + { + "id": "c_pump_c", + "type": "comment", + "z": "tab_process", + "name": "\u2500\u2500 Pump C \u2500\u2500", + "info": "Up + Dn pressure sensors register as children. rotatingMachine emits state on port 0 (formatted then link-out to UI). Port 2 emits registerChild \u2192 MGC.", + "x": 640, + "y": 500, + "wires": [] + }, + { + "id": "meas_pump_c_u", + "type": "measurement", + "z": "tab_process", + "name": "PT-C-Up", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 50, + "o_max": 400, + "simulator": true, + "smooth_method": "mean", + "count": "5", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_c-upstream", + "supplier": "vega", + "category": "sensor", + "assetType": "pressure", + "model": "vega-pressure-10", + "unit": "mbar", + "assetTagNumber": "PT-3-U", + "enableLog": false, + "logLevel": "warn", + "positionVsParent": "upstream", + "positionIcon": "", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 540, + "wires": [ + [], + [], + [ + "pump_c" + ] + ] + }, + { + "id": "meas_pump_c_d", + "type": "measurement", + "z": "tab_process", + "name": "PT-C-Dn", + "mode": "analog", + "channels": "[]", + "scaling": false, + "i_min": 0, + "i_max": 1, + "i_offset": 0, + "o_min": 800, + "o_max": 2200, + "simulator": true, + "smooth_method": "mean", + "count": "5", + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "uuid": "sensor-pump_c-downstream", + "supplier": "vega", + "category": "sensor", + "assetType": "pressure", + "model": "vega-pressure-10", + "unit": "mbar", + "assetTagNumber": "PT-3-D", + "enableLog": false, + "logLevel": "warn", + "positionVsParent": "downstream", + "positionIcon": "", + "hasDistance": false, + "distance": 0, + "distanceUnit": "m", + "distanceDescription": "", + "x": 380, + "y": 590, + "wires": [ + [], + [], + [ + "pump_c" + ] + ] + }, + { + "id": "lin_setpoint_pump_c", + "type": "link in", + "z": "tab_process", + "name": "cmd:setpoint-C", + "links": [ + "lout_setpoint_pump_c_dash" + ], + "x": 120, + "y": 560, + "wires": [ + [ + "build_setpoint_pump_c" + ] + ] + }, + { + "id": "build_setpoint_pump_c", + "type": "function", + "z": "tab_process", + "name": "build setpoint cmd (Pump C)", + "func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 600, + "y": 560, + "wires": [ + [ + "pump_c" + ] + ] + }, + { + "id": "lin_seq_pump_c", + "type": "link in", + "z": "tab_process", + "name": "cmd:pump-C-seq", + "links": [ + "lout_seq_pump_c_dash" + ], + "x": 120, + "y": 610, + "wires": [ + [ + "pump_c" + ] + ] + }, + { + "id": "pump_c", + "type": "rotatingMachine", + "z": "tab_process", + "name": "Pump C", + "speed": "10", + "startup": "2", + "warmup": "1", + "shutdown": "2", + "cooldown": "1", + "movementMode": "staticspeed", + "machineCurve": "", + "uuid": "pump-pump_c", + "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": 900, + "y": 580, + "wires": [ + [ + "format_pump_c" + ], + [], + [ + "mgc_pumps" + ] + ] + }, + { + "id": "format_pump_c", + "type": "function", + "z": "tab_process", + "name": "format Pump C port 0", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst pU = find('pressure.measured.upstream.');\nconst pD = find('pressure.measured.downstream.');\nmsg.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\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pU != null ? Number(pU).toFixed(0) : 'n/a',\n pDn: pD != null ? Number(pD).toFixed(0) : 'n/a',\n flowNum: flow != null ? Number(flow) : null,\n powerNum: power != null ? Number(power) : null,\n};\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1160, + "y": 580, + "wires": [ + [ + "lout_evt_pump_c" + ] + ] + }, + { + "id": "lout_evt_pump_c", + "type": "link out", + "z": "tab_process", + "name": "evt:pump-C", + "mode": "link", + "links": [ + "lin_evt_pump_c_dash" + ], + "x": 1420, + "y": 580, + "wires": [] + }, + { + "id": "c_mgc", + "type": "comment", + "z": "tab_process", + "name": "\u2500\u2500 MGC \u2500\u2500 (orchestrates the 3 pumps via optimalcontrol)", + "info": "Receives Qd from cmd:demand link-in. Distributes flow across pumps.", + "x": 640, + "y": 700, + "wires": [] + }, + { + "id": "lin_demand_to_mgc", + "type": "link in", + "z": "tab_process", + "name": "cmd:demand", + "links": [ + "lout_demand_drivers", + "lout_demand_dash" + ], + "x": 120, + "y": 760, + "wires": [ + [ + "demand_fanout_mgc_ps" + ] + ] + }, + { + "id": "demand_fanout_mgc_ps", + "type": "function", + "z": "tab_process", + "name": "demand \u2192 MGC + PS", + "func": "const v = Number(msg.payload);\nif (!Number.isFinite(v) || v <= 0) return null;\n// MGC accepts Qd in m3/h directly when scaling=absolute.\nconst qd = { topic: 'Qd', payload: v };\n// PS accepts q_in in m3/s (canonical) via the 'unit' field.\nconst qin = { topic: 'q_in', payload: v / 3600, unit: 'm3/s' };\nreturn [qd, qin];", + "outputs": 2, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 600, + "y": 760, + "wires": [ + [ + "mgc_pumps" + ], + [ + "ps_basin" + ] + ] + }, + { + "id": "mgc_pumps", + "type": "machineGroupControl", + "z": "tab_process", + "name": "MGC \u2014 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": 900, + "y": 780, + "wires": [ + [ + "format_mgc" + ], + [], + [ + "ps_basin" + ] + ] + }, + { + "id": "format_mgc", + "type": "function", + "z": "tab_process", + "name": "format MGC port 0", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst totalFlow = find('flow.predicted.atequipment.') ?? find('downstream_predicted_flow');\nconst totalPower = find('power.predicted.atequipment.') ?? find('atEquipment_predicted_power');\nconst eff = find('efficiency.predicted.atequipment.');\nmsg.payload = {\n totalFlow: totalFlow != null ? Number(totalFlow).toFixed(1) + ' m\u00b3/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};\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1160, + "y": 780, + "wires": [ + [ + "lout_evt_mgc" + ] + ] + }, + { + "id": "lout_evt_mgc", + "type": "link out", + "z": "tab_process", + "name": "evt:mgc", + "mode": "link", + "links": [ + "lin_evt_mgc_dash" + ], + "x": 1420, + "y": 780, + "wires": [] + }, + { + "id": "c_ps", + "type": "comment", + "z": "tab_process", + "name": "\u2500\u2500 Pumping Station \u2500\u2500 (basin model, manual control mode)", + "info": "Receives q_in from demand fanout. Emits formatted basin state.", + "x": 640, + "y": 900, + "wires": [] + }, + { + "id": "ps_basin", + "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", + "controlMode": "manual", + "basinVolume": 50, + "basinHeight": 4, + "enableDryRunProtection": false, + "enableOverfillProtection": false, + "dryRunThresholdPercent": 0, + "overfillThresholdPercent": 100, + "timeleftToFullOrEmptyThresholdSeconds": 0, + "x": 900, + "y": 980, + "wires": [ + [ + "format_ps" + ], + [] + ] + }, + { + "id": "format_ps", + "type": "function", + "z": "tab_process", + "name": "format PS port 0", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst lvl = find('level.predicted.');\nconst vol = find('volume.predicted.');\nconst qIn = find('flow.measured.upstream.') || find('flow.measured.in.');\nconst qOut = find('flow.measured.downstream.') || find('flow.measured.out.');\nmsg.payload = {\n level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n volume: vol != null ? Number(vol).toFixed(1) + ' m\u00b3' : 'n/a',\n qIn: qIn != null ? (Number(qIn) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n state: c.state || c.direction || 'idle',\n levelNum: lvl != null ? Number(lvl) : null,\n volumeNum: vol != null ? Number(vol) : null,\n};\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1160, + "y": 980, + "wires": [ + [ + "lout_evt_ps" + ] + ] + }, + { + "id": "lout_evt_ps", + "type": "link out", + "z": "tab_process", + "name": "evt:ps", + "mode": "link", + "links": [ + "lin_evt_ps_dash" + ], + "x": 1420, + "y": 980, + "wires": [] + }, + { + "id": "c_mode_bcast", + "type": "comment", + "z": "tab_process", + "name": "\u2500\u2500 Mode broadcast \u2500\u2500", + "info": "Single 'auto' / 'virtualControl' value fans out as setMode to all 3 pumps.", + "x": 640, + "y": 1100, + "wires": [] + }, + { + "id": "lin_mode", + "type": "link in", + "z": "tab_process", + "name": "cmd:mode", + "links": [ + "lout_mode_dash" + ], + "x": 120, + "y": 1160, + "wires": [ + [ + "fanout_mode" + ] + ] + }, + { + "id": "fanout_mode", + "type": "function", + "z": "tab_process", + "name": "fan setMode \u2192 3 pumps", + "func": "msg.topic = 'setMode';\nreturn [msg, msg, msg];", + "outputs": 3, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 600, + "y": 1160, + "wires": [ + [ + "pump_a" + ], + [ + "pump_b" + ], + [ + "pump_c" + ] + ] + }, + { + "id": "c_station_cmds", + "type": "comment", + "z": "tab_process", + "name": "\u2500\u2500 Station-wide commands \u2500\u2500 (Start All / Stop All / Emergency)", + "info": "Each link-in carries a fully-built msg ready for handleInput; we just fan out 3-way.", + "x": 640, + "y": 1300, + "wires": [] + }, + { + "id": "lin_station_start", + "type": "link in", + "z": "tab_process", + "name": "cmd:station-startup", + "links": [ + "lout_cmd_station_startup_dash" + ], + "x": 120, + "y": 1360, + "wires": [ + [ + "fan_station_start" + ] + ] + }, + { + "id": "fan_station_start", + "type": "function", + "z": "tab_process", + "name": "fan startup \u2192 3 pumps", + "func": "return [msg, msg, msg];", + "outputs": 3, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 600, + "y": 1360, + "wires": [ + [ + "pump_a" + ], + [ + "pump_b" + ], + [ + "pump_c" + ] + ] + }, + { + "id": "lin_station_stop", + "type": "link in", + "z": "tab_process", + "name": "cmd:station-shutdown", + "links": [ + "lout_cmd_station_shutdown_dash" + ], + "x": 120, + "y": 1420, + "wires": [ + [ + "fan_station_stop" + ] + ] + }, + { + "id": "fan_station_stop", + "type": "function", + "z": "tab_process", + "name": "fan shutdown \u2192 3 pumps", + "func": "return [msg, msg, msg];", + "outputs": 3, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 600, + "y": 1420, + "wires": [ + [ + "pump_a" + ], + [ + "pump_b" + ], + [ + "pump_c" + ] + ] + }, + { + "id": "lin_station_estop", + "type": "link in", + "z": "tab_process", + "name": "cmd:station-estop", + "links": [ + "lout_cmd_station_estop_dash" + ], + "x": 120, + "y": 1480, + "wires": [ + [ + "fan_station_estop" + ] + ] + }, + { + "id": "fan_station_estop", + "type": "function", + "z": "tab_process", + "name": "fan emergency stop \u2192 3 pumps", + "func": "return [msg, msg, msg];", + "outputs": 3, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 600, + "y": 1480, + "wires": [ + [ + "pump_a" + ], + [ + "pump_b" + ], + [ + "pump_c" + ] + ] + }, + { + "id": "c_setup_at_mgc", + "type": "comment", + "z": "tab_process", + "name": "\u2500\u2500 Setup feeders \u2500\u2500", + "info": "Cross-tab link from Setup tab \u2192 MGC scaling/mode init.", + "x": 640, + "y": 1500, + "wires": [] + }, + { + "id": "lin_setup_at_mgc", + "type": "link in", + "z": "tab_process", + "name": "setup:to-mgc", + "links": [ + "lout_setup_to_mgc" + ], + "x": 120, + "y": 1560, + "wires": [ + [ + "mgc_pumps" + ] + ] + }, + { + "id": "tab_ui", + "type": "tab", + "label": "\ud83d\udcca 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." + }, { "id": "ui_base_ps_demo", "type": "ui-base", @@ -53,13 +1144,6 @@ "order": 1, "className": "" }, - { - "id": "ps_demo_tab", - "type": "tab", - "label": "Pumping Station \u2014 3 Pumps Demo", - "disabled": false, - "info": "End-to-end demo: pumpingStation + machineGroupControl + 3 rotatingMachines, each with upstream/downstream pressure sensors. Process demand input via dashboard slider OR auto random generator. Dashboard at /dashboard/pumping-station-demo." - }, { "id": "ui_grp_demand", "type": "ui-group", @@ -77,7 +1161,7 @@ { "id": "ui_grp_station", "type": "ui-group", - "name": "2. Pumping Station", + "name": "2. Station Controls", "page": "ui_page_ps_demo", "width": "12", "height": "1", @@ -89,11 +1173,11 @@ "visible": true }, { - "id": "ui_grp_pump_a", + "id": "ui_grp_mgc", "type": "ui-group", - "name": "3a. Pump A", + "name": "3a. MGC Status", "page": "ui_page_ps_demo", - "width": "4", + "width": "6", "height": "1", "order": 3, "showTitle": true, @@ -103,11 +1187,11 @@ "visible": true }, { - "id": "ui_grp_pump_b", + "id": "ui_grp_ps", "type": "ui-group", - "name": "3b. Pump B", + "name": "3b. Basin Status", "page": "ui_page_ps_demo", - "width": "4", + "width": "6", "height": "1", "order": 4, "showTitle": true, @@ -117,9 +1201,9 @@ "visible": true }, { - "id": "ui_grp_pump_c", + "id": "ui_grp_pump_a", "type": "ui-group", - "name": "3c. Pump C", + "name": "4a. Pump A", "page": "ui_page_ps_demo", "width": "4", "height": "1", @@ -131,11 +1215,11 @@ "visible": true }, { - "id": "ui_grp_trend", + "id": "ui_grp_pump_b", "type": "ui-group", - "name": "4. Trends", + "name": "4b. Pump B", "page": "ui_page_ps_demo", - "width": "12", + "width": "4", "height": "1", "order": 6, "showTitle": true, @@ -145,472 +1229,57 @@ "visible": true }, { - "id": "c_title", + "id": "ui_grp_pump_c", + "type": "ui-group", + "name": "4c. Pump C", + "page": "ui_page_ps_demo", + "width": "4", + "height": "1", + "order": 7, + "showTitle": true, + "className": "", + "groupType": "default", + "disabled": false, + "visible": true + }, + { + "id": "ui_grp_trend", + "type": "ui-group", + "name": "5. Trends", + "page": "ui_page_ps_demo", + "width": "12", + "height": "1", + "order": 8, + "showTitle": true, + "className": "", + "groupType": "default", + "disabled": false, + "visible": true + }, + { + "id": "c_ui_title", "type": "comment", - "z": "ps_demo_tab", - "name": "Pumping Station \u2014 3 Pumps Demo", - "info": "Process demand \u2192 pumpingStation (basin model) + machineGroupControl (orchestrator) \u2192 3 rotatingMachines. Each pump has upstream + downstream pressure measurements. Auto/Manual mode toggle on the dashboard.", - "x": 200, - "y": 40, + "z": "tab_ui", + "name": "\ud83d\udcca DASHBOARD UI \u2014 only ui-* widgets here", + "info": "Layout: column 1 = inputs (sliders/switches/buttons) \u2192 link-outs.\nColumn 2 = link-ins from process \u2192 routed to text/gauge/chart widgets.", + "x": 640, + "y": 20, "wires": [] }, { - "id": "meas_pump_a_u", - "type": "measurement", - "z": "ps_demo_tab", - "name": "PT-A-Up", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 50, - "o_max": 400, - "simulator": true, - "smooth_method": "mean", - "count": "5", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_a-upstream", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "PT-1-U", - "enableLog": true, - "logLevel": "warn", - "positionVsParent": "upstream", - "positionIcon": "", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 200, - "y": 1000, - "wires": [ - [], - [], - [ - "pump_a" - ] - ] - }, - { - "id": "meas_pump_a_d", - "type": "measurement", - "z": "ps_demo_tab", - "name": "PT-A-Dn", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 800, - "o_max": 2200, - "simulator": true, - "smooth_method": "mean", - "count": "5", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_a-downstream", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "PT-1-D", - "enableLog": true, - "logLevel": "warn", - "positionVsParent": "downstream", - "positionIcon": "", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 440, - "y": 1000, - "wires": [ - [], - [], - [ - "pump_a" - ] - ] - }, - { - "id": "meas_pump_b_u", - "type": "measurement", - "z": "ps_demo_tab", - "name": "PT-B-Up", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 50, - "o_max": 400, - "simulator": true, - "smooth_method": "mean", - "count": "5", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_b-upstream", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "PT-2-U", - "enableLog": true, - "logLevel": "warn", - "positionVsParent": "upstream", - "positionIcon": "", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 200, - "y": 1220, - "wires": [ - [], - [], - [ - "pump_b" - ] - ] - }, - { - "id": "meas_pump_b_d", - "type": "measurement", - "z": "ps_demo_tab", - "name": "PT-B-Dn", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 800, - "o_max": 2200, - "simulator": true, - "smooth_method": "mean", - "count": "5", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_b-downstream", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "PT-2-D", - "enableLog": true, - "logLevel": "warn", - "positionVsParent": "downstream", - "positionIcon": "", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 440, - "y": 1220, - "wires": [ - [], - [], - [ - "pump_b" - ] - ] - }, - { - "id": "meas_pump_c_u", - "type": "measurement", - "z": "ps_demo_tab", - "name": "PT-C-Up", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 50, - "o_max": 400, - "simulator": true, - "smooth_method": "mean", - "count": "5", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_c-upstream", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "PT-3-U", - "enableLog": true, - "logLevel": "warn", - "positionVsParent": "upstream", - "positionIcon": "", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 200, - "y": 1440, - "wires": [ - [], - [], - [ - "pump_c" - ] - ] - }, - { - "id": "meas_pump_c_d", - "type": "measurement", - "z": "ps_demo_tab", - "name": "PT-C-Dn", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 800, - "o_max": 2200, - "simulator": true, - "smooth_method": "mean", - "count": "5", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_c-downstream", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "PT-3-D", - "enableLog": true, - "logLevel": "warn", - "positionVsParent": "downstream", - "positionIcon": "", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 440, - "y": 1440, - "wires": [ - [], - [], - [ - "pump_c" - ] - ] - }, - { - "id": "pump_a", - "type": "rotatingMachine", - "z": "ps_demo_tab", - "name": "Pump A", - "speed": "10", - "startup": "2", - "warmup": "1", - "shutdown": "2", - "cooldown": "1", - "movementMode": "staticspeed", - "machineCurve": "", - "uuid": "pump-pump_a", - "supplier": "hidrostal", - "category": "pump", - "assetType": "pump-centrifugal", - "model": "hidrostal-H05K-S03R", - "unit": "m3/h", - "curvePressureUnit": "mbar", - "curveFlowUnit": "m3/h", - "curvePowerUnit": "kW", - "curveControlUnit": "%", - "enableLog": true, - "logLevel": "warn", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 700, - "y": 700, - "wires": [ - [ - "router_p0_pump_a" - ], - [], - [ - "mgc_pumps" - ] - ] - }, - { - "id": "pump_b", - "type": "rotatingMachine", - "z": "ps_demo_tab", - "name": "Pump B", - "speed": "10", - "startup": "2", - "warmup": "1", - "shutdown": "2", - "cooldown": "1", - "movementMode": "staticspeed", - "machineCurve": "", - "uuid": "pump-pump_b", - "supplier": "hidrostal", - "category": "pump", - "assetType": "pump-centrifugal", - "model": "hidrostal-H05K-S03R", - "unit": "m3/h", - "curvePressureUnit": "mbar", - "curveFlowUnit": "m3/h", - "curvePowerUnit": "kW", - "curveControlUnit": "%", - "enableLog": true, - "logLevel": "warn", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 700, - "y": 820, - "wires": [ - [ - "router_p0_pump_b" - ], - [], - [ - "mgc_pumps" - ] - ] - }, - { - "id": "pump_c", - "type": "rotatingMachine", - "z": "ps_demo_tab", - "name": "Pump C", - "speed": "10", - "startup": "2", - "warmup": "1", - "shutdown": "2", - "cooldown": "1", - "movementMode": "staticspeed", - "machineCurve": "", - "uuid": "pump-pump_c", - "supplier": "hidrostal", - "category": "pump", - "assetType": "pump-centrifugal", - "model": "hidrostal-H05K-S03R", - "unit": "m3/h", - "curvePressureUnit": "mbar", - "curveFlowUnit": "m3/h", - "curvePowerUnit": "kW", - "curveControlUnit": "%", - "enableLog": true, - "logLevel": "warn", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 700, - "y": 940, - "wires": [ - [ - "router_p0_pump_c" - ], - [], - [ - "mgc_pumps" - ] - ] - }, - { - "id": "mgc_pumps", - "type": "machineGroupControl", - "z": "ps_demo_tab", - "name": "MGC \u2014 Pump Group", - "uuid": "mgc-pump-group", - "category": "controller", - "assetType": "machinegroupcontrol", - "model": "default", - "unit": "m3/h", - "supplier": "evolv", - "enableLog": true, - "logLevel": "warn", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "x": 1100, - "y": 760, - "wires": [ - [], - [], - [ - "ps_basin" - ] - ] - }, - { - "id": "ps_basin", - "type": "pumpingStation", - "z": "ps_demo_tab", - "name": "Pumping Station", - "uuid": "ps-basin-1", - "category": "station", - "assetType": "pumpingstation", - "model": "default", - "unit": "m3/s", - "supplier": "evolv", - "enableLog": true, - "logLevel": "info", - "positionVsParent": "atEquipment", - "positionIcon": "", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "controlMode": "manual", - "basinVolume": 50, - "basinHeight": 4, - "enableDryRunProtection": false, - "enableOverfillProtection": false, - "dryRunThresholdPercent": 0, - "overfillThresholdPercent": 100, - "timeleftToFullOrEmptyThresholdSeconds": 0, - "x": 1450, - "y": 760, - "wires": [ - [ - "ps_to_dashboard" - ], - [] - ] + "id": "c_ui_demand", + "type": "comment", + "z": "tab_ui", + "name": "\u2500\u2500 Process Demand \u2500\u2500", + "info": "", + "x": 640, + "y": 100, + "wires": [] }, { "id": "ui_demand_slider", "type": "ui-slider", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_demand", "name": "Process demand slider", "label": "Process Demand (m\u00b3/h)", @@ -632,18 +1301,32 @@ "thumbLabel": false, "iconStart": "", "iconEnd": "", - "x": 100, - "y": 200, + "x": 120, + "y": 140, "wires": [ [ - "demand_router" + "lout_demand_dash" ] ] }, + { + "id": "lout_demand_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:demand", + "mode": "link", + "links": [ + "lin_demand_to_mgc", + "lin_demand_to_text" + ], + "x": 380, + "y": 140, + "wires": [] + }, { "id": "ui_random_toggle", "type": "ui-switch", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_demand", "name": "Random demand", "label": "Random demand generator (auto)", @@ -666,23 +1349,36 @@ "offvalueType": "str", "officon": "back_hand", "offcolor": "#888888", - "x": 100, - "y": 260, + "x": 120, + "y": 200, "wires": [ [ - "random_state" + "lout_random_dash" ] ] }, + { + "id": "lout_random_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:randomToggle", + "mode": "link", + "links": [ + "lin_random_to_drivers" + ], + "x": 380, + "y": 200, + "wires": [] + }, { "id": "ui_demand_text", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_demand", "order": 1, "width": "0", "height": "0", - "name": "Current demand text", + "name": "Current demand", "label": "Current demand", "format": "{{msg.payload}} m\u00b3/h", "layout": "row-left", @@ -690,119 +1386,41 @@ "font": "", "fontSize": 14, "color": "#000000", + "x": 900, + "y": 140, "wires": [] }, { - "id": "demand_rand_tick", - "type": "inject", - "z": "ps_demo_tab", - "name": "tick (random demand)", - "props": [ - { - "p": "topic", - "vt": "str" - }, - { - "p": "payload", - "v": "", - "vt": "date" - } + "id": "lin_demand_to_text", + "type": "link in", + "z": "tab_ui", + "name": "cmd:demand", + "links": [ + "lout_demand_dash", + "lout_demand_drivers" ], - "topic": "randomTick", - "payload": "", - "payloadType": "date", - "repeat": "3", - "crontab": "", - "once": false, - "onceDelay": "0.5", - "x": 100, - "y": 380, + "x": 640, + "y": 140, "wires": [ - [ - "demand_rand_fn" - ] - ] - }, - { - "id": "random_state", - "type": "function", - "z": "ps_demo_tab", - "name": "store random state", - "func": "context.set('on', msg.payload === 'on'); return null;", - "outputs": 0, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 280, - "y": 260, - "wires": [] - }, - { - "id": "demand_rand_fn", - "type": "function", - "z": "ps_demo_tab", - "name": "random demand", - "func": "if (!context.get('on')) { return null; }\nconst v = Math.round(40 + Math.random() * 200);\nmsg.payload = v;\nmsg.topic = 'manualDemand';\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 280, - "y": 380, - "wires": [ - [ - "demand_router", - "ui_demand_text" - ] - ] - }, - { - "id": "demand_router", - "type": "function", - "z": "ps_demo_tab", - "name": "fan-out demand", - "func": "const v = Number(msg.payload);\nif (!Number.isFinite(v) || v <= 0) return null;\n// Feed pumpingStation q_in (m3/s canonical) and MGC Qd (m3/h).\nconst qin = { topic: 'q_in', payload: v / 3600, unit: 'm3/s' };\nconst qd = { topic: 'Qd', payload: v };\nconst text = { topic: 'demand', payload: v };\nreturn [qin, qd, text];", - "outputs": 3, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 480, - "y": 260, - "wires": [ - [ - "ps_basin" - ], - [ - "mgc_pumps", - "dbg_demand_to_mgc" - ], [ "ui_demand_text" ] ] }, { - "id": "dbg_demand_to_mgc", - "type": "debug", - "z": "ps_demo_tab", - "name": "\u2192 MGC Qd", - "active": true, - "tosidebar": true, - "console": false, - "tostatus": false, - "complete": "payload", - "targetType": "msg", - "x": 700, + "id": "c_ui_station", + "type": "comment", + "z": "tab_ui", + "name": "\u2500\u2500 Mode + Station-wide buttons \u2500\u2500", + "info": "", + "x": 640, "y": 320, "wires": [] }, { "id": "ui_mode_toggle", "type": "ui-switch", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_station", "name": "Auto/Manual mode", "label": "Mode (Auto = MGC orchestrates \u00b7 Manual = dashboard per-pump)", @@ -825,46 +1443,34 @@ "offvalueType": "str", "officon": "back_hand", "offcolor": "#888888", - "x": 100, - "y": 460, + "x": 120, + "y": 360, "wires": [ [ - "mode_fanout" + "lout_mode_dash" ] ] }, { - "id": "mode_fanout", - "type": "function", - "z": "ps_demo_tab", - "name": "broadcast setMode to all pumps", - "func": "msg.topic = 'setMode';\n// Send same setMode payload to all 3 pumps.\nreturn [msg, msg, msg];", - "outputs": 3, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 320, - "y": 460, - "wires": [ - [ - "pump_a" - ], - [ - "pump_b" - ], - [ - "pump_c" - ] - ] + "id": "lout_mode_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:mode", + "mode": "link", + "links": [ + "lin_mode" + ], + "x": 380, + "y": 360, + "wires": [] }, { - "id": "btn_station_startup", + "id": "btn_station_0", "type": "ui-button", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_station", "name": "Start all pumps", - "label": "Startup all", + "label": "Start all pumps", "order": 1, "width": "0", "height": "0", @@ -874,51 +1480,58 @@ "className": "", "icon": "play_arrow", "iconPosition": "left", - "payload": "startup", + "payload": "fired", "payloadType": "str", - "topic": "stationStartup", + "topic": "station_0", "topicType": "str", "buttonType": "default", - "x": 100, - "y": 520, + "x": 120, + "y": 420, "wires": [ [ - "station_startup_fan" + "wrap_station_0" ] ] }, { - "id": "station_startup_fan", + "id": "wrap_station_0", "type": "function", - "z": "ps_demo_tab", - "name": "fan startup to pumps", - "func": "const cmd = { topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } };\nreturn [cmd, cmd, cmd];", - "outputs": 3, + "z": "tab_ui", + "name": "build cmd (Start all pumps)", + "func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\nreturn msg;", + "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 320, - "y": 520, + "x": 480, + "y": 420, "wires": [ [ - "pump_a" - ], - [ - "pump_b" - ], - [ - "pump_c" + "lout_cmd_station_startup_dash" ] ] }, { - "id": "btn_station_shutdown", + "id": "lout_cmd_station_startup_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:station-startup", + "mode": "link", + "links": [ + "lin_station_start" + ], + "x": 640, + "y": 420, + "wires": [] + }, + { + "id": "btn_station_1", "type": "ui-button", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_station", "name": "Stop all pumps", - "label": "Shutdown all", + "label": "Stop all pumps", "order": 1, "width": "0", "height": "0", @@ -928,48 +1541,55 @@ "className": "", "icon": "stop", "iconPosition": "left", - "payload": "shutdown", + "payload": "fired", "payloadType": "str", - "topic": "stationShutdown", + "topic": "station_1", "topicType": "str", "buttonType": "default", - "x": 100, - "y": 580, + "x": 120, + "y": 480, "wires": [ [ - "station_shutdown_fan" + "wrap_station_1" ] ] }, { - "id": "station_shutdown_fan", + "id": "wrap_station_1", "type": "function", - "z": "ps_demo_tab", - "name": "fan shutdown to pumps", - "func": "const cmd = { topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'shutdown' } };\nreturn [cmd, cmd, cmd];", - "outputs": 3, + "z": "tab_ui", + "name": "build cmd (Stop all pumps)", + "func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\nreturn msg;", + "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 320, - "y": 580, + "x": 480, + "y": 480, "wires": [ [ - "pump_a" - ], - [ - "pump_b" - ], - [ - "pump_c" + "lout_cmd_station_shutdown_dash" ] ] }, { - "id": "btn_station_estop", + "id": "lout_cmd_station_shutdown_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:station-shutdown", + "mode": "link", + "links": [ + "lin_station_stop" + ], + "x": 640, + "y": 480, + "wires": [] + }, + { + "id": "btn_station_2", "type": "ui-button", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_station", "name": "EMERGENCY STOP", "label": "EMERGENCY STOP", @@ -982,53 +1602,301 @@ "className": "", "icon": "stop_circle", "iconPosition": "left", - "payload": "estop", + "payload": "fired", "payloadType": "str", - "topic": "stationEstop", + "topic": "station_2", "topicType": "str", "buttonType": "default", - "x": 100, - "y": 640, + "x": 120, + "y": 540, "wires": [ [ - "station_estop_fan" + "wrap_station_2" ] ] }, { - "id": "station_estop_fan", + "id": "wrap_station_2", "type": "function", - "z": "ps_demo_tab", - "name": "fan estop to pumps", - "func": "const cmd = { topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } };\nreturn [cmd, cmd, cmd];", - "outputs": 3, + "z": "tab_ui", + "name": "build cmd (EMERGENCY STOP)", + "func": "msg.topic = 'emergencystop';\nmsg.payload = { source:'GUI', action:'emergencystop' };\nreturn msg;", + "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 320, + "x": 480, + "y": 540, + "wires": [ + [ + "lout_cmd_station_estop_dash" + ] + ] + }, + { + "id": "lout_cmd_station_estop_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:station-estop", + "mode": "link", + "links": [ + "lin_station_estop" + ], + "x": 640, + "y": 540, + "wires": [] + }, + { + "id": "c_ui_mgc_ps", + "type": "comment", + "z": "tab_ui", + "name": "\u2500\u2500 MGC + Basin overview \u2500\u2500", + "info": "", + "x": 640, + "y": 600, + "wires": [] + }, + { + "id": "lin_evt_mgc_dash", + "type": "link in", + "z": "tab_ui", + "name": "evt:mgc", + "links": [ + "lout_evt_mgc" + ], + "x": 120, "y": 640, "wires": [ [ - "pump_a" - ], + "ui_mgc_total_flow", + "ui_mgc_total_power", + "ui_mgc_eff" + ] + ] + }, + { + "id": "ui_mgc_total_flow", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_mgc", + "order": 1, + "width": "0", + "height": "0", + "name": "MGC total flow", + "label": "Total flow", + "format": "{{msg.payload.totalFlow}}", + "layout": "row-left", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 640, + "wires": [] + }, + { + "id": "ui_mgc_total_power", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_mgc", + "order": 1, + "width": "0", + "height": "0", + "name": "MGC total power", + "label": "Total power", + "format": "{{msg.payload.totalPower}}", + "layout": "row-left", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 670, + "wires": [] + }, + { + "id": "ui_mgc_eff", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_mgc", + "order": 1, + "width": "0", + "height": "0", + "name": "MGC efficiency", + "label": "Group efficiency", + "format": "{{msg.payload.efficiency}}", + "layout": "row-left", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 700, + "wires": [] + }, + { + "id": "lin_evt_ps_dash", + "type": "link in", + "z": "tab_ui", + "name": "evt:ps", + "links": [ + "lout_evt_ps" + ], + "x": 120, + "y": 760, + "wires": [ [ - "pump_b" - ], + "ui_ps_state", + "ui_ps_level", + "ui_ps_volume", + "ui_ps_qin", + "ui_ps_qout" + ] + ] + }, + { + "id": "ui_ps_state", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_ps", + "order": 1, + "width": "0", + "height": "0", + "name": "PS state", + "label": "Basin state", + "format": "{{msg.payload.state}}", + "layout": "row-left", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 760, + "wires": [] + }, + { + "id": "ui_ps_level", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_ps", + "order": 1, + "width": "0", + "height": "0", + "name": "PS level", + "label": "Basin level", + "format": "{{msg.payload.level}}", + "layout": "row-left", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 790, + "wires": [] + }, + { + "id": "ui_ps_volume", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_ps", + "order": 1, + "width": "0", + "height": "0", + "name": "PS volume", + "label": "Basin volume", + "format": "{{msg.payload.volume}}", + "layout": "row-left", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 820, + "wires": [] + }, + { + "id": "ui_ps_qin", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_ps", + "order": 1, + "width": "0", + "height": "0", + "name": "PS Qin", + "label": "Inflow", + "format": "{{msg.payload.qIn}}", + "layout": "row-left", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 850, + "wires": [] + }, + { + "id": "ui_ps_qout", + "type": "ui-text", + "z": "tab_ui", + "group": "ui_grp_ps", + "order": 1, + "width": "0", + "height": "0", + "name": "PS Qout", + "label": "Pumped out", + "format": "{{msg.payload.qOut}}", + "layout": "row-left", + "style": false, + "font": "", + "fontSize": 14, + "color": "#000000", + "x": 640, + "y": 880, + "wires": [] + }, + { + "id": "c_ui_pump_a", + "type": "comment", + "z": "tab_ui", + "name": "\u2500\u2500 Pump A \u2500\u2500", + "info": "", + "x": 640, + "y": 1000, + "wires": [] + }, + { + "id": "lin_evt_pump_a_dash", + "type": "link in", + "z": "tab_ui", + "name": "evt:pump-A", + "links": [ + "lout_evt_pump_a" + ], + "x": 120, + "y": 1040, + "wires": [ [ - "pump_c" + "ui_pump_a_state", + "ui_pump_a_mode", + "ui_pump_a_ctrl", + "ui_pump_a_flow", + "ui_pump_a_power", + "ui_pump_a_pUp", + "ui_pump_a_pDn", + "trend_split_pump_a" ] ] }, { "id": "ui_pump_a_state", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_a", "order": 1, "width": "0", "height": "0", - "name": "Pump A state", + "name": "Pump A State", "label": "State", "format": "{{msg.payload.state}}", "layout": "row-left", @@ -1036,17 +1904,19 @@ "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1040, "wires": [] }, { "id": "ui_pump_a_mode", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_a", "order": 1, "width": "0", "height": "0", - "name": "Pump A mode", + "name": "Pump A Mode", "label": "Mode", "format": "{{msg.payload.mode}}", "layout": "row-left", @@ -1054,17 +1924,19 @@ "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1070, "wires": [] }, { "id": "ui_pump_a_ctrl", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_a", "order": 1, "width": "0", "height": "0", - "name": "Pump A ctrl", + "name": "Pump A Controller %", "label": "Controller %", "format": "{{msg.payload.ctrl}}", "layout": "row-left", @@ -1072,84 +1944,94 @@ "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1100, "wires": [] }, { "id": "ui_pump_a_flow", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_a", "order": 1, "width": "0", "height": "0", - "name": "Pump A flow", - "label": "Flow (m\u00b3/h)", + "name": "Pump A Flow", + "label": "Flow", "format": "{{msg.payload.flow}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1130, "wires": [] }, { "id": "ui_pump_a_power", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_a", "order": 1, "width": "0", "height": "0", - "name": "Pump A power", - "label": "Power (kW)", + "name": "Pump A Power", + "label": "Power", "format": "{{msg.payload.power}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1160, "wires": [] }, { "id": "ui_pump_a_pUp", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_a", "order": 1, "width": "0", "height": "0", - "name": "Pump A pUp", - "label": "p Upstream (mbar)", + "name": "Pump A p Upstream", + "label": "p Upstream", "format": "{{msg.payload.pUp}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1190, "wires": [] }, { "id": "ui_pump_a_pDn", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_a", "order": 1, "width": "0", "height": "0", - "name": "Pump A pDn", - "label": "p Downstream (mbar)", + "name": "Pump A p Downstream", + "label": "p Downstream", "format": "{{msg.payload.pDn}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1220, "wires": [] }, { "id": "ui_pump_a_setpoint", "type": "ui-slider", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_a", "name": "Pump A setpoint", "label": "Setpoint % (manual mode)", @@ -1171,37 +2053,31 @@ "thumbLabel": false, "iconStart": "", "iconEnd": "", - "x": 100, - "y": 100, + "x": 120, + "y": 1280, "wires": [ [ - "setpoint_to_pump_pump_a" + "lout_setpoint_pump_a_dash" ] ] }, { - "id": "setpoint_to_pump_pump_a", - "type": "function", - "z": "ps_demo_tab", - "name": "build setpoint cmd for Pump A", - "func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 320, - "y": 100, - "wires": [ - [ - "pump_a" - ] - ] + "id": "lout_setpoint_pump_a_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:setpoint-A", + "mode": "link", + "links": [ + "lin_setpoint_pump_a" + ], + "x": 380, + "y": 1280, + "wires": [] }, { - "id": "btn_pump_a_startup", + "id": "btn_pump_a_start", "type": "ui-button", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_a", "name": "Pump A startup", "label": "Startup", @@ -1214,42 +2090,42 @@ "className": "", "icon": "play_arrow", "iconPosition": "left", - "payload": "startup", + "payload": "fired", "payloadType": "str", "topic": "start_pump_a", "topicType": "str", "buttonType": "default", - "x": 100, - "y": 160, + "x": 120, + "y": 1330, "wires": [ [ - "start_to_pump_pump_a" + "wrap_pump_a_start" ] ] }, { - "id": "start_to_pump_pump_a", + "id": "wrap_pump_a_start", "type": "function", - "z": "ps_demo_tab", - "name": "build startup for Pump A", - "func": "msg.topic = 'execSequence';\nmsg.payload = { source: 'GUI', action: 'execSequence', parameter: 'startup' };\nreturn msg;", + "z": "tab_ui", + "name": "build start (Pump A)", + "func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 320, - "y": 160, + "x": 480, + "y": 1330, "wires": [ [ - "pump_a" + "lout_seq_pump_a_dash" ] ] }, { - "id": "btn_pump_a_shutdown", + "id": "btn_pump_a_stop", "type": "ui-button", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_a", "name": "Pump A shutdown", "label": "Shutdown", @@ -1262,93 +2138,115 @@ "className": "", "icon": "stop", "iconPosition": "left", - "payload": "shutdown", + "payload": "fired", "payloadType": "str", "topic": "stop_pump_a", "topicType": "str", "buttonType": "default", - "x": 100, - "y": 220, + "x": 120, + "y": 1380, "wires": [ [ - "stop_to_pump_pump_a" + "wrap_pump_a_stop" ] ] }, { - "id": "stop_to_pump_pump_a", + "id": "wrap_pump_a_stop", "type": "function", - "z": "ps_demo_tab", - "name": "build shutdown for Pump A", - "func": "msg.topic = 'execSequence';\nmsg.payload = { source: 'GUI', action: 'execSequence', parameter: 'shutdown' };\nreturn msg;", + "z": "tab_ui", + "name": "build stop (Pump A)", + "func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 320, - "y": 220, + "x": 480, + "y": 1380, "wires": [ [ - "pump_a" + "lout_seq_pump_a_dash" ] ] }, { - "id": "router_p0_pump_a", - "type": "function", - "z": "ps_demo_tab", - "name": "format Pump A port 0", - "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst pU = find('pressure.measured.upstream.');\nconst pD = find('pressure.measured.downstream.');\nmsg.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\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pU != null ? Number(pU).toFixed(0) : 'n/a',\n pDn: pD != null ? Number(pD).toFixed(0) : 'n/a',\n flowNum: flow != null ? Number(flow) : null,\n powerNum: power != null ? Number(power) : null\n};\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1100, - "y": 190, - "wires": [ - [ - "ui_pump_a_state", - "ui_pump_a_mode", - "ui_pump_a_ctrl", - "ui_pump_a_flow", - "ui_pump_a_power", - "ui_pump_a_pUp", - "ui_pump_a_pDn", - "trend_split_pump_a" - ] - ] + "id": "lout_seq_pump_a_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:pump-A-seq", + "mode": "link", + "links": [ + "lin_seq_pump_a" + ], + "x": 640, + "y": 1355, + "wires": [] }, { "id": "trend_split_pump_a", "type": "function", - "z": "ps_demo_tab", - "name": "emit trend points for Pump A", - "func": "const p = msg.payload || {};\nconst out = [];\nif (p.flowNum != null) out.push({ topic: 'Pump A flow', payload: p.flowNum });\nif (p.powerNum != null) out.push({ topic: 'Pump A power', payload: p.powerNum });\nreturn [out];", - "outputs": 1, + "z": "tab_ui", + "name": "trend split (Pump A)", + "func": "const p = msg.payload || {};\nconst flowMsg = p.flowNum != null ? { topic: 'Pump A', payload: Number(p.flowNum) } : null;\nconst powerMsg = p.powerNum != null ? { topic: 'Pump A', payload: Number(p.powerNum) } : null;\nreturn [flowMsg, powerMsg];", + "outputs": 2, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 1300, - "y": 250, + "x": 900, + "y": 1080, "wires": [ [ - "trend_chart_flow", + "trend_chart_flow" + ], + [ "trend_chart_power" ] ] }, + { + "id": "c_ui_pump_b", + "type": "comment", + "z": "tab_ui", + "name": "\u2500\u2500 Pump B \u2500\u2500", + "info": "", + "x": 640, + "y": 1400, + "wires": [] + }, + { + "id": "lin_evt_pump_b_dash", + "type": "link in", + "z": "tab_ui", + "name": "evt:pump-B", + "links": [ + "lout_evt_pump_b" + ], + "x": 120, + "y": 1440, + "wires": [ + [ + "ui_pump_b_state", + "ui_pump_b_mode", + "ui_pump_b_ctrl", + "ui_pump_b_flow", + "ui_pump_b_power", + "ui_pump_b_pUp", + "ui_pump_b_pDn", + "trend_split_pump_b" + ] + ] + }, { "id": "ui_pump_b_state", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_b", "order": 1, "width": "0", "height": "0", - "name": "Pump B state", + "name": "Pump B State", "label": "State", "format": "{{msg.payload.state}}", "layout": "row-left", @@ -1356,17 +2254,19 @@ "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1440, "wires": [] }, { "id": "ui_pump_b_mode", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_b", "order": 1, "width": "0", "height": "0", - "name": "Pump B mode", + "name": "Pump B Mode", "label": "Mode", "format": "{{msg.payload.mode}}", "layout": "row-left", @@ -1374,17 +2274,19 @@ "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1470, "wires": [] }, { "id": "ui_pump_b_ctrl", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_b", "order": 1, "width": "0", "height": "0", - "name": "Pump B ctrl", + "name": "Pump B Controller %", "label": "Controller %", "format": "{{msg.payload.ctrl}}", "layout": "row-left", @@ -1392,84 +2294,94 @@ "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1500, "wires": [] }, { "id": "ui_pump_b_flow", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_b", "order": 1, "width": "0", "height": "0", - "name": "Pump B flow", - "label": "Flow (m\u00b3/h)", + "name": "Pump B Flow", + "label": "Flow", "format": "{{msg.payload.flow}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1530, "wires": [] }, { "id": "ui_pump_b_power", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_b", "order": 1, "width": "0", "height": "0", - "name": "Pump B power", - "label": "Power (kW)", + "name": "Pump B Power", + "label": "Power", "format": "{{msg.payload.power}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1560, "wires": [] }, { "id": "ui_pump_b_pUp", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_b", "order": 1, "width": "0", "height": "0", - "name": "Pump B pUp", - "label": "p Upstream (mbar)", + "name": "Pump B p Upstream", + "label": "p Upstream", "format": "{{msg.payload.pUp}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1590, "wires": [] }, { "id": "ui_pump_b_pDn", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_b", "order": 1, "width": "0", "height": "0", - "name": "Pump B pDn", - "label": "p Downstream (mbar)", + "name": "Pump B p Downstream", + "label": "p Downstream", "format": "{{msg.payload.pDn}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1620, "wires": [] }, { "id": "ui_pump_b_setpoint", "type": "ui-slider", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_b", "name": "Pump B setpoint", "label": "Setpoint % (manual mode)", @@ -1491,37 +2403,31 @@ "thumbLabel": false, "iconStart": "", "iconEnd": "", - "x": 100, - "y": 420, + "x": 120, + "y": 1680, "wires": [ [ - "setpoint_to_pump_pump_b" + "lout_setpoint_pump_b_dash" ] ] }, { - "id": "setpoint_to_pump_pump_b", - "type": "function", - "z": "ps_demo_tab", - "name": "build setpoint cmd for Pump B", - "func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 320, - "y": 420, - "wires": [ - [ - "pump_b" - ] - ] + "id": "lout_setpoint_pump_b_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:setpoint-B", + "mode": "link", + "links": [ + "lin_setpoint_pump_b" + ], + "x": 380, + "y": 1680, + "wires": [] }, { - "id": "btn_pump_b_startup", + "id": "btn_pump_b_start", "type": "ui-button", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_b", "name": "Pump B startup", "label": "Startup", @@ -1534,42 +2440,42 @@ "className": "", "icon": "play_arrow", "iconPosition": "left", - "payload": "startup", + "payload": "fired", "payloadType": "str", "topic": "start_pump_b", "topicType": "str", "buttonType": "default", - "x": 100, - "y": 480, + "x": 120, + "y": 1730, "wires": [ [ - "start_to_pump_pump_b" + "wrap_pump_b_start" ] ] }, { - "id": "start_to_pump_pump_b", + "id": "wrap_pump_b_start", "type": "function", - "z": "ps_demo_tab", - "name": "build startup for Pump B", - "func": "msg.topic = 'execSequence';\nmsg.payload = { source: 'GUI', action: 'execSequence', parameter: 'startup' };\nreturn msg;", + "z": "tab_ui", + "name": "build start (Pump B)", + "func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 320, - "y": 480, + "x": 480, + "y": 1730, "wires": [ [ - "pump_b" + "lout_seq_pump_b_dash" ] ] }, { - "id": "btn_pump_b_shutdown", + "id": "btn_pump_b_stop", "type": "ui-button", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_b", "name": "Pump B shutdown", "label": "Shutdown", @@ -1582,93 +2488,115 @@ "className": "", "icon": "stop", "iconPosition": "left", - "payload": "shutdown", + "payload": "fired", "payloadType": "str", "topic": "stop_pump_b", "topicType": "str", "buttonType": "default", - "x": 100, - "y": 540, + "x": 120, + "y": 1780, "wires": [ [ - "stop_to_pump_pump_b" + "wrap_pump_b_stop" ] ] }, { - "id": "stop_to_pump_pump_b", + "id": "wrap_pump_b_stop", "type": "function", - "z": "ps_demo_tab", - "name": "build shutdown for Pump B", - "func": "msg.topic = 'execSequence';\nmsg.payload = { source: 'GUI', action: 'execSequence', parameter: 'shutdown' };\nreturn msg;", + "z": "tab_ui", + "name": "build stop (Pump B)", + "func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 320, - "y": 540, + "x": 480, + "y": 1780, "wires": [ [ - "pump_b" + "lout_seq_pump_b_dash" ] ] }, { - "id": "router_p0_pump_b", - "type": "function", - "z": "ps_demo_tab", - "name": "format Pump B port 0", - "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst pU = find('pressure.measured.upstream.');\nconst pD = find('pressure.measured.downstream.');\nmsg.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\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pU != null ? Number(pU).toFixed(0) : 'n/a',\n pDn: pD != null ? Number(pD).toFixed(0) : 'n/a',\n flowNum: flow != null ? Number(flow) : null,\n powerNum: power != null ? Number(power) : null\n};\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1100, - "y": 510, - "wires": [ - [ - "ui_pump_b_state", - "ui_pump_b_mode", - "ui_pump_b_ctrl", - "ui_pump_b_flow", - "ui_pump_b_power", - "ui_pump_b_pUp", - "ui_pump_b_pDn", - "trend_split_pump_b" - ] - ] + "id": "lout_seq_pump_b_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:pump-B-seq", + "mode": "link", + "links": [ + "lin_seq_pump_b" + ], + "x": 640, + "y": 1755, + "wires": [] }, { "id": "trend_split_pump_b", "type": "function", - "z": "ps_demo_tab", - "name": "emit trend points for Pump B", - "func": "const p = msg.payload || {};\nconst out = [];\nif (p.flowNum != null) out.push({ topic: 'Pump B flow', payload: p.flowNum });\nif (p.powerNum != null) out.push({ topic: 'Pump B power', payload: p.powerNum });\nreturn [out];", - "outputs": 1, + "z": "tab_ui", + "name": "trend split (Pump B)", + "func": "const p = msg.payload || {};\nconst flowMsg = p.flowNum != null ? { topic: 'Pump B', payload: Number(p.flowNum) } : null;\nconst powerMsg = p.powerNum != null ? { topic: 'Pump B', payload: Number(p.powerNum) } : null;\nreturn [flowMsg, powerMsg];", + "outputs": 2, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 1300, - "y": 570, + "x": 900, + "y": 1480, "wires": [ [ - "trend_chart_flow", + "trend_chart_flow" + ], + [ "trend_chart_power" ] ] }, + { + "id": "c_ui_pump_c", + "type": "comment", + "z": "tab_ui", + "name": "\u2500\u2500 Pump C \u2500\u2500", + "info": "", + "x": 640, + "y": 1800, + "wires": [] + }, + { + "id": "lin_evt_pump_c_dash", + "type": "link in", + "z": "tab_ui", + "name": "evt:pump-C", + "links": [ + "lout_evt_pump_c" + ], + "x": 120, + "y": 1840, + "wires": [ + [ + "ui_pump_c_state", + "ui_pump_c_mode", + "ui_pump_c_ctrl", + "ui_pump_c_flow", + "ui_pump_c_power", + "ui_pump_c_pUp", + "ui_pump_c_pDn", + "trend_split_pump_c" + ] + ] + }, { "id": "ui_pump_c_state", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_c", "order": 1, "width": "0", "height": "0", - "name": "Pump C state", + "name": "Pump C State", "label": "State", "format": "{{msg.payload.state}}", "layout": "row-left", @@ -1676,17 +2604,19 @@ "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1840, "wires": [] }, { "id": "ui_pump_c_mode", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_c", "order": 1, "width": "0", "height": "0", - "name": "Pump C mode", + "name": "Pump C Mode", "label": "Mode", "format": "{{msg.payload.mode}}", "layout": "row-left", @@ -1694,17 +2624,19 @@ "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1870, "wires": [] }, { "id": "ui_pump_c_ctrl", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_c", "order": 1, "width": "0", "height": "0", - "name": "Pump C ctrl", + "name": "Pump C Controller %", "label": "Controller %", "format": "{{msg.payload.ctrl}}", "layout": "row-left", @@ -1712,84 +2644,94 @@ "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1900, "wires": [] }, { "id": "ui_pump_c_flow", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_c", "order": 1, "width": "0", "height": "0", - "name": "Pump C flow", - "label": "Flow (m\u00b3/h)", + "name": "Pump C Flow", + "label": "Flow", "format": "{{msg.payload.flow}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1930, "wires": [] }, { "id": "ui_pump_c_power", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_c", "order": 1, "width": "0", "height": "0", - "name": "Pump C power", - "label": "Power (kW)", + "name": "Pump C Power", + "label": "Power", "format": "{{msg.payload.power}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1960, "wires": [] }, { "id": "ui_pump_c_pUp", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_c", "order": 1, "width": "0", "height": "0", - "name": "Pump C pUp", - "label": "p Upstream (mbar)", + "name": "Pump C p Upstream", + "label": "p Upstream", "format": "{{msg.payload.pUp}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 1990, "wires": [] }, { "id": "ui_pump_c_pDn", "type": "ui-text", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_c", "order": 1, "width": "0", "height": "0", - "name": "Pump C pDn", - "label": "p Downstream (mbar)", + "name": "Pump C p Downstream", + "label": "p Downstream", "format": "{{msg.payload.pDn}}", "layout": "row-left", "style": false, "font": "", "fontSize": 14, "color": "#000000", + "x": 640, + "y": 2020, "wires": [] }, { "id": "ui_pump_c_setpoint", "type": "ui-slider", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_c", "name": "Pump C setpoint", "label": "Setpoint % (manual mode)", @@ -1811,37 +2753,31 @@ "thumbLabel": false, "iconStart": "", "iconEnd": "", - "x": 100, - "y": 740, + "x": 120, + "y": 2080, "wires": [ [ - "setpoint_to_pump_pump_c" + "lout_setpoint_pump_c_dash" ] ] }, { - "id": "setpoint_to_pump_pump_c", - "type": "function", - "z": "ps_demo_tab", - "name": "build setpoint cmd for Pump C", - "func": "msg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint: Number(msg.payload) };\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 320, - "y": 740, - "wires": [ - [ - "pump_c" - ] - ] + "id": "lout_setpoint_pump_c_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:setpoint-C", + "mode": "link", + "links": [ + "lin_setpoint_pump_c" + ], + "x": 380, + "y": 2080, + "wires": [] }, { - "id": "btn_pump_c_startup", + "id": "btn_pump_c_start", "type": "ui-button", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_c", "name": "Pump C startup", "label": "Startup", @@ -1854,42 +2790,42 @@ "className": "", "icon": "play_arrow", "iconPosition": "left", - "payload": "startup", + "payload": "fired", "payloadType": "str", "topic": "start_pump_c", "topicType": "str", "buttonType": "default", - "x": 100, - "y": 800, + "x": 120, + "y": 2130, "wires": [ [ - "start_to_pump_pump_c" + "wrap_pump_c_start" ] ] }, { - "id": "start_to_pump_pump_c", + "id": "wrap_pump_c_start", "type": "function", - "z": "ps_demo_tab", - "name": "build startup for Pump C", - "func": "msg.topic = 'execSequence';\nmsg.payload = { source: 'GUI', action: 'execSequence', parameter: 'startup' };\nreturn msg;", + "z": "tab_ui", + "name": "build start (Pump C)", + "func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'startup' };\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 320, - "y": 800, + "x": 480, + "y": 2130, "wires": [ [ - "pump_c" + "lout_seq_pump_c_dash" ] ] }, { - "id": "btn_pump_c_shutdown", + "id": "btn_pump_c_stop", "type": "ui-button", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_pump_c", "name": "Pump C shutdown", "label": "Shutdown", @@ -1902,88 +2838,87 @@ "className": "", "icon": "stop", "iconPosition": "left", - "payload": "shutdown", + "payload": "fired", "payloadType": "str", "topic": "stop_pump_c", "topicType": "str", "buttonType": "default", - "x": 100, - "y": 860, + "x": 120, + "y": 2180, "wires": [ [ - "stop_to_pump_pump_c" + "wrap_pump_c_stop" ] ] }, { - "id": "stop_to_pump_pump_c", + "id": "wrap_pump_c_stop", "type": "function", - "z": "ps_demo_tab", - "name": "build shutdown for Pump C", - "func": "msg.topic = 'execSequence';\nmsg.payload = { source: 'GUI', action: 'execSequence', parameter: 'shutdown' };\nreturn msg;", + "z": "tab_ui", + "name": "build stop (Pump C)", + "func": "msg.topic = 'execSequence';\nmsg.payload = { source:'GUI', action:'execSequence', parameter:'shutdown' };\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 320, - "y": 860, + "x": 480, + "y": 2180, "wires": [ [ - "pump_c" + "lout_seq_pump_c_dash" ] ] }, { - "id": "router_p0_pump_c", - "type": "function", - "z": "ps_demo_tab", - "name": "format Pump C port 0", - "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst flow = find('flow.predicted.downstream.');\nconst power = find('power.predicted.atequipment.');\nconst pU = find('pressure.measured.upstream.');\nconst pD = find('pressure.measured.downstream.');\nmsg.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\u00b3/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pU != null ? Number(pU).toFixed(0) : 'n/a',\n pDn: pD != null ? Number(pD).toFixed(0) : 'n/a',\n flowNum: flow != null ? Number(flow) : null,\n powerNum: power != null ? Number(power) : null\n};\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1100, - "y": 830, - "wires": [ - [ - "ui_pump_c_state", - "ui_pump_c_mode", - "ui_pump_c_ctrl", - "ui_pump_c_flow", - "ui_pump_c_power", - "ui_pump_c_pUp", - "ui_pump_c_pDn", - "trend_split_pump_c" - ] - ] + "id": "lout_seq_pump_c_dash", + "type": "link out", + "z": "tab_ui", + "name": "cmd:pump-C-seq", + "mode": "link", + "links": [ + "lin_seq_pump_c" + ], + "x": 640, + "y": 2155, + "wires": [] }, { "id": "trend_split_pump_c", "type": "function", - "z": "ps_demo_tab", - "name": "emit trend points for Pump C", - "func": "const p = msg.payload || {};\nconst out = [];\nif (p.flowNum != null) out.push({ topic: 'Pump C flow', payload: p.flowNum });\nif (p.powerNum != null) out.push({ topic: 'Pump C power', payload: p.powerNum });\nreturn [out];", - "outputs": 1, + "z": "tab_ui", + "name": "trend split (Pump C)", + "func": "const p = msg.payload || {};\nconst flowMsg = p.flowNum != null ? { topic: 'Pump C', payload: Number(p.flowNum) } : null;\nconst powerMsg = p.powerNum != null ? { topic: 'Pump C', payload: Number(p.powerNum) } : null;\nreturn [flowMsg, powerMsg];", + "outputs": 2, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 1300, - "y": 890, + "x": 900, + "y": 1880, "wires": [ [ - "trend_chart_flow", + "trend_chart_flow" + ], + [ "trend_chart_power" ] ] }, + { + "id": "c_ui_trends", + "type": "comment", + "z": "tab_ui", + "name": "\u2500\u2500 Trends (shared by all pumps) \u2500\u2500", + "info": "Each chart accepts msg.topic as the series name (categoryType=msg).", + "x": 640, + "y": 2280, + "wires": [] + }, { "id": "trend_chart_flow", "type": "ui-chart", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_trend", "name": "Flow per pump (m\u00b3/h)", "label": "Flow per pump", @@ -2010,6 +2945,8 @@ "width": "0", "height": "0", "className": "", + "x": 900, + "y": 2320, "wires": [ [] ] @@ -2017,7 +2954,7 @@ { "id": "trend_chart_power", "type": "ui-chart", - "z": "ps_demo_tab", + "z": "tab_ui", "group": "ui_grp_trend", "name": "Power per pump (kW)", "label": "Power per pump", @@ -2044,128 +2981,168 @@ "width": "0", "height": "0", "className": "", + "x": 900, + "y": 2380, "wires": [ [] ] }, { - "id": "ps_to_dashboard", + "id": "tab_drivers", + "type": "tab", + "label": "\ud83c\udf9b\ufe0f Demo Drivers", + "disabled": false, + "info": "Auto stimulus for the demo. Random demand generator + state holder for the dashboard's randomToggle switch. In production, delete this tab and feed cmd:demand from your real demand source." + }, + { + "id": "c_drv_title", + "type": "comment", + "z": "tab_drivers", + "name": "\ud83c\udf9b\ufe0f DEMO DRIVERS \u2014 auto stimulus only", + "info": "Removable: in production, replace this tab with the real demand source.", + "x": 640, + "y": 20, + "wires": [] + }, + { + "id": "c_drv_state", + "type": "comment", + "z": "tab_drivers", + "name": "\u2500\u2500 Random toggle state \u2500\u2500", + "info": "", + "x": 640, + "y": 100, + "wires": [] + }, + { + "id": "lin_random_to_drivers", + "type": "link in", + "z": "tab_drivers", + "name": "cmd:randomToggle", + "links": [ + "lout_random_dash" + ], + "x": 120, + "y": 140, + "wires": [ + [ + "random_state" + ] + ] + }, + { + "id": "random_state", "type": "function", - "z": "ps_demo_tab", - "name": "format PS port 0 for dashboard", - "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst lvl = find('level.predicted.');\nconst vol = find('volume.predicted.');\nconst qIn = find('flow.measured.upstream.') || find('flow.measured.in.');\nconst qOut = find('flow.measured.downstream.') || find('flow.measured.out.');\nmsg.payload = {\n level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n volume: vol != null ? Number(vol).toFixed(1) + ' m\u00b3' : 'n/a',\n qIn: qIn != null ? (Number(qIn) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n state: c.state || c.direction || 'idle',\n netNum: (qIn != null && qOut != null) ? (Number(qIn) - Number(qOut)) * 3600 : null\n};\nreturn msg;", + "z": "tab_drivers", + "name": "store random on/off", + "func": "flow.set('randomOn', msg.payload === 'on');\nreturn null;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 1700, - "y": 700, + "x": 380, + "y": 140, + "wires": [ + [] + ] + }, + { + "id": "c_drv_random", + "type": "comment", + "z": "tab_drivers", + "name": "\u2500\u2500 Random demand generator \u2500\u2500 (every 3 s)", + "info": "", + "x": 640, + "y": 250, + "wires": [] + }, + { + "id": "rand_tick", + "type": "inject", + "z": "tab_drivers", + "name": "tick (random demand)", + "props": [ + { + "p": "topic", + "vt": "str" + }, + { + "p": "payload", + "v": "", + "vt": "date" + } + ], + "topic": "randomTick", + "payload": "", + "payloadType": "date", + "repeat": "3", + "crontab": "", + "once": false, + "onceDelay": "0.5", + "x": 120, + "y": 290, "wires": [ [ - "ui_ps_level", - "ui_ps_volume", - "ui_ps_qin", - "ui_ps_qout", - "ui_ps_state" + "random_demand_fn" ] ] }, { - "id": "ui_ps_state", - "type": "ui-text", - "z": "ps_demo_tab", - "group": "ui_grp_station", - "order": 1, - "width": "0", - "height": "0", - "name": "PS state", - "label": "Basin state", - "format": "{{msg.payload.state}}", - "layout": "row-left", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", + "id": "random_demand_fn", + "type": "function", + "z": "tab_drivers", + "name": "random demand", + "func": "if (!flow.get('randomOn')) return null;\nconst v = Math.round(40 + Math.random() * 200);\nreturn { topic: 'manualDemand', payload: v };", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 600, + "y": 290, + "wires": [ + [ + "lout_demand_drivers" + ] + ] + }, + { + "id": "lout_demand_drivers", + "type": "link out", + "z": "tab_drivers", + "name": "cmd:demand", + "mode": "link", + "links": [ + "lin_demand_to_mgc", + "lin_demand_to_text" + ], + "x": 900, + "y": 290, "wires": [] }, { - "id": "ui_ps_level", - "type": "ui-text", - "z": "ps_demo_tab", - "group": "ui_grp_station", - "order": 1, - "width": "0", - "height": "0", - "name": "PS level", - "label": "Basin level", - "format": "{{msg.payload.level}}", - "layout": "row-left", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "wires": [] + "id": "tab_setup", + "type": "tab", + "label": "\u2699\ufe0f 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." }, { - "id": "ui_ps_volume", - "type": "ui-text", - "z": "ps_demo_tab", - "group": "ui_grp_station", - "order": 1, - "width": "0", - "height": "0", - "name": "PS volume", - "label": "Basin volume", - "format": "{{msg.payload.volume}}", - "layout": "row-left", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "wires": [] - }, - { - "id": "ui_ps_qin", - "type": "ui-text", - "z": "ps_demo_tab", - "group": "ui_grp_station", - "order": 1, - "width": "0", - "height": "0", - "name": "PS Qin", - "label": "Inflow", - "format": "{{msg.payload.qIn}}", - "layout": "row-left", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "wires": [] - }, - { - "id": "ui_ps_qout", - "type": "ui-text", - "z": "ps_demo_tab", - "group": "ui_grp_station", - "order": 1, - "width": "0", - "height": "0", - "name": "PS Qout", - "label": "Pumped out", - "format": "{{msg.payload.qOut}}", - "layout": "row-left", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", + "id": "c_setup_title", + "type": "comment", + "z": "tab_setup", + "name": "\u2699\ufe0f SETUP & INIT \u2014 one-shot deploy-time injects", + "info": "Disable this tab in production \u2014 the runtime should be persistent.", + "x": 640, + "y": 20, "wires": [] }, { "id": "setup_mgc_scaling", "type": "inject", - "z": "ps_demo_tab", - "name": "setup: MGC scaling=absolute", + "z": "tab_setup", + "name": "MGC scaling = absolute", "props": [ { "p": "topic", @@ -2183,20 +3160,20 @@ "repeat": "", "crontab": "", "once": true, - "onceDelay": "0.5", - "x": 100, - "y": 800, + "onceDelay": "1.5", + "x": 120, + "y": 100, "wires": [ [ - "mgc_pumps" + "lout_setup_to_mgc" ] ] }, { "id": "setup_mgc_mode", "type": "inject", - "z": "ps_demo_tab", - "name": "setup: MGC mode=optimalcontrol", + "z": "tab_setup", + "name": "MGC mode = optimalcontrol", "props": [ { "p": "topic", @@ -2214,20 +3191,33 @@ "repeat": "", "crontab": "", "once": true, - "onceDelay": "0.5", - "x": 100, - "y": 840, + "onceDelay": "1.7", + "x": 120, + "y": 160, "wires": [ [ - "mgc_pumps" + "lout_setup_to_mgc" ] ] }, + { + "id": "lout_setup_to_mgc", + "type": "link out", + "z": "tab_setup", + "name": "setup:to-mgc", + "mode": "link", + "links": [ + "lin_setup_at_mgc" + ], + "x": 380, + "y": 130, + "wires": [] + }, { "id": "setup_pumps_mode", "type": "inject", - "z": "ps_demo_tab", - "name": "setup: pumps mode=auto", + "z": "tab_setup", + "name": "pumps mode = auto", "props": [ { "p": "topic", @@ -2245,45 +3235,33 @@ "repeat": "", "crontab": "", "once": true, - "onceDelay": "0.5", - "x": 100, - "y": 880, + "onceDelay": "2.0", + "x": 120, + "y": 250, "wires": [ [ - "mode_setup_fan" + "lout_mode_setup" ] ] }, { - "id": "mode_setup_fan", - "type": "function", - "z": "ps_demo_tab", - "name": "fan setup mode to pumps", - "func": "msg.topic = 'setMode';\nreturn [msg, msg, msg];", - "outputs": 3, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 320, - "y": 880, - "wires": [ - [ - "pump_a" - ], - [ - "pump_b" - ], - [ - "pump_c" - ] - ] + "id": "lout_mode_setup", + "type": "link out", + "z": "tab_setup", + "name": "cmd:mode", + "mode": "link", + "links": [ + "lin_mode" + ], + "x": 380, + "y": 250, + "wires": [] }, { "id": "setup_pumps_startup", "type": "inject", - "z": "ps_demo_tab", - "name": "setup: pumps startup", + "z": "tab_setup", + "name": "auto-startup all pumps", "props": [ { "p": "topic", @@ -2302,44 +3280,32 @@ "crontab": "", "once": true, "onceDelay": "4", - "x": 100, - "y": 920, + "x": 120, + "y": 350, "wires": [ [ - "startup_setup_fan" + "lout_setup_station_start" ] ] }, { - "id": "startup_setup_fan", - "type": "function", - "z": "ps_demo_tab", - "name": "fan startup to pumps", - "func": "return [msg, msg, msg];", - "outputs": 3, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 320, - "y": 920, - "wires": [ - [ - "pump_a" - ], - [ - "pump_b" - ], - [ - "pump_c" - ] - ] + "id": "lout_setup_station_start", + "type": "link out", + "z": "tab_setup", + "name": "cmd:station-startup", + "mode": "link", + "links": [ + "lin_station_start" + ], + "x": 380, + "y": 350, + "wires": [] }, { "id": "setup_random_on", "type": "inject", - "z": "ps_demo_tab", - "name": "setup: random demand ON", + "z": "tab_setup", + "name": "auto-enable random demand", "props": [ { "p": "topic", @@ -2358,12 +3324,25 @@ "crontab": "", "once": true, "onceDelay": "5", - "x": 100, - "y": 960, + "x": 120, + "y": 450, "wires": [ [ - "random_state" + "lout_setup_random" ] ] + }, + { + "id": "lout_setup_random", + "type": "link out", + "z": "tab_setup", + "name": "cmd:randomToggle", + "mode": "link", + "links": [ + "lin_random_to_drivers" + ], + "x": 380, + "y": 450, + "wires": [] } ]