refactor(examples): split pumpingstation demo across 4 concern-based tabs + add layout rule set
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
The demo was a single 96-node tab with everything wired directly. Now 4 tabs wired only through named link-out / link-in pairs, and a permanent rule set for future Claude sessions to follow. Tabs (by concern, not by data flow): 🏭 Process Plant only EVOLV nodes (3 pumps + MGC + PS + 6 measurements) + per-node output formatters 📊 Dashboard UI only ui-* widgets, button/setpoint wrappers, trend splitters 🎛️ Demo Drivers random demand generator + state holder. Removable in production ⚙️ Setup & Init one-shot deploy-time injects (mode, scaling, auto-startup, random-on) Cross-tab wiring uses a fixed named-channel contract (cmd:demand, cmd:mode, cmd:setpoint-A, evt:pump-A, etc.) — multiple emitters can target a single link-in for fan-in, e.g. both the slider and the random generator feed cmd:demand. Bug fixes folded in: 1. Trend chart was empty / scrambled. Root cause: the trend-feeder function had ONE output that wired to BOTH flow and power charts, so each chart received both flow and power msgs and the legend garbled. Now: 2 outputs (flow → flow chart, power → power chart), one msg per output. 2. Every ui-text and ui-chart fell on the (0, 0) corner of the editor canvas. Root cause: the helper functions accepted x/y parameters but never assigned them on the returned node dict — Node-RED defaulted every widget to (0, 0) and they piled on top of each other. The dashboard render was unaffected (it lays out by group/ order), but the editor was unreadable. Fixed both helpers and added a verification step ("no node should be at (0, 0)") to the rule set. Spacing convention (now codified): - 6 lanes per tab at x = [120, 380, 640, 900, 1160, 1420] - 80 px standard row pitch, 30-40 px for tight ui-text stacks - 200 px gap between sections, with a comment header per section New rule set: .claude/rules/node-red-flow-layout.md - Tab boundaries by concern - Link-channel naming convention (cmd:/evt:/setup: prefixes) - Spacing constants - Trend-split chart pattern - Inject node payload typing pitfall (per-prop v/vt) - Dashboard widget rules (every ui-* needs x/y!) - Do/don't checklist - Link-out/link-in JSON cheat sheet - 5-step layout verification before declaring a flow done CLAUDE.md updated to point at the new rule set. Verified end-to-end on Dockerized Node-RED 2026-04-13: 168 nodes across 4 tabs, all wired via 22 link-out / 19 link-in pairs, no nodes at (0, 0), pumps reach operational ~5 s after deploy, MGC distributes random demand, trends populate per pump. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
206
.claude/rules/node-red-flow-layout.md
Normal file
206
.claude/rules/node-red-flow-layout.md
Normal file
@@ -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 `<direction>:<topic>` 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.
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,34 @@ Then open the dashboard:
|
||||
|
||||
- <http://localhost:1880/dashboard/pumping-station-demo>
|
||||
|
||||
## 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 |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user