docs(rules): add S88-hierarchical placement rules for Node-RED flows
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
Sections 10-16 extend the existing flow-layout rule with a deterministic lane-and-group convention anchored in the S88 hierarchy: - 8 logical lanes: L0 inputs -> L1 adapters -> L2 CM -> L3 EM -> L4 UN -> L5 PC -> L6 formatters -> L7 outputs. 240 px between lanes. - Lane assignment is by S88 level, not by node name. New nodes inherit a lane via a NODE_LEVEL registry, no rule change needed. - Every parent + its direct children is wrapped in a Node-RED group box coloured by the parent's S88 level (Pump A = EM blue, MGC = Unit blue, PS = Process Cell blue, ...). Search the parent's name -> group highlights. - Utility clusters (mode broadcast, station-wide commands, demand fan-out) use neutral-grey group boxes. - Dashboard / setup / demo-driver tabs each get a variant of the rule. - Spacing constants, place() and wrap_in_group() helpers, an 8-step verification checklist. Off-spec colours (settler orange, monster teal, diffuser and dashboardAPI missing) are flagged in Section 16 as a follow-up cleanup. The NODE_LEVEL registry already maps those nodes to their semantic S88 level regardless of what the node's own colour currently says. Rule lives in the superproject only; per-node repos will reference it from their own CLAUDE.md files (separate commits per submodule). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -204,3 +204,240 @@ Before declaring a flow done:
|
|||||||
3. **Hit the dashboard URL — every widget has data.** `n/a` everywhere is a contract failure.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
## 10. Hierarchical placement — by S88 level, not by node name
|
||||||
|
|
||||||
|
The lane assignment maps to the **S88 hierarchy**, not to specific node names. Any node that lives at a given S88 level goes in the same lane regardless of what kind of equipment it is. New node types added to the platform inherit a lane by their S88 category — no rule change needed.
|
||||||
|
|
||||||
|
### 10.1 Lane convention (x-axis = S88 level)
|
||||||
|
|
||||||
|
| Lane | x | Purpose | S88 level | Colour | Current EVOLV nodes |
|
||||||
|
|---:|---:|---|---|---|---|
|
||||||
|
| **L0** | 120 | Tab inputs | — | (none) | `link in`, `inject` |
|
||||||
|
| **L1** | 360 | Adapters | — | (none) | `function` (msg-shape wrappers) |
|
||||||
|
| **L2** | 600 | Control Module | CM | `#a9daee` | `measurement` |
|
||||||
|
| **L3** | 840 | Equipment Module | EM | `#86bbdd` | `rotatingMachine`, `valve`, `diffuser` |
|
||||||
|
| **L4** | 1080 | Unit | UN | `#50a8d9` | `machineGroupControl`, `valveGroupControl`, `reactor`, `settler`, `monster` |
|
||||||
|
| **L5** | 1320 | Process Cell | PC | `#0c99d9` | `pumpingStation` |
|
||||||
|
| **L6** | 1560 | Output formatters | — | (none) | `function` (build dashboard payload from port 0) |
|
||||||
|
| **L7** | 1800 | Tab outputs | — | (none) | `link out`, `debug` |
|
||||||
|
|
||||||
|
Spacing: **240 px** between lanes. Tab width ≤ 1920 px (fits standard monitors without horizontal scroll in the editor).
|
||||||
|
|
||||||
|
**Area level** (`#0f52a5`) is reserved for plant-wide coordination and currently unused — when added, allocate a new lane and shift formatter/output one lane right (i.e. expand to 9 lanes if and when needed).
|
||||||
|
|
||||||
|
### 10.2 The group rule (Node-RED `group` boxes anchor each parent + its children)
|
||||||
|
|
||||||
|
Use Node-RED's native `group` node (the visual box around a set of nodes — not to be confused with `ui-group`) to anchor every "parent + direct children" cluster. The box makes ownership unambiguous and lets you collapse the cluster in the editor.
|
||||||
|
|
||||||
|
**Group rules:**
|
||||||
|
|
||||||
|
- **One Node-RED group per parent + its direct children.**
|
||||||
|
Example: `Pump A + meas-A-up + meas-A-dn` is one group, named `Pump A`.
|
||||||
|
- **Group colour = parent's S88 colour.**
|
||||||
|
So a Pump-A group is `#86bbdd` (Equipment Module). A reactor group is `#50a8d9` (Unit).
|
||||||
|
- **Group `style.label = true`** so the box shows the parent's name.
|
||||||
|
- **Group must contain all the children's adapters / wrappers / formatters** too if those exclusively belong to the parent. The box is the visual anchor for "this is everything that owns / serves Pump A".
|
||||||
|
- **Utility groups for cross-cutting logic** (mode broadcast, station-wide commands, demand fan-out) use a neutral colour (`#dddddd`).
|
||||||
|
|
||||||
|
JSON shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "grp_pump_a",
|
||||||
|
"type": "group",
|
||||||
|
"z": "tab_process",
|
||||||
|
"name": "Pump A",
|
||||||
|
"style": { "label": true, "stroke": "#000000", "fill": "#86bbdd", "fill-opacity": "0.10" },
|
||||||
|
"nodes": ["meas_pump_a_u", "meas_pump_a_d", "pump_a", "format_pump_a", "lin_setpoint_pump_a", "build_setpoint_pump_a", "lin_seq_pump_a", "lout_evt_pump_a"],
|
||||||
|
"x": 80, "y": 100, "w": 1800, "h": 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`x/y/w/h` is the bounding box of contained nodes + padding — compute it from the children's positions.
|
||||||
|
|
||||||
|
### 10.3 The hierarchy rule, restated
|
||||||
|
|
||||||
|
> Nodes at the **same S88 level** (siblings sharing one parent) **stack vertically in the same lane**.
|
||||||
|
>
|
||||||
|
> Nodes at **different S88 levels** (parent ↔ child) sit **next to each other on different lanes**.
|
||||||
|
|
||||||
|
### 10.4 Worked example — pumping station demo
|
||||||
|
|
||||||
|
```
|
||||||
|
L0 L1 L2 L3 L4 L5 L6 L7
|
||||||
|
(input) (adapter) (CM) (EM) (Unit) (PC) (formatter) (output)
|
||||||
|
|
||||||
|
┌── group: Pump A (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [lin-set-A] [build-A] │
|
||||||
|
│ [lin-seq-A] │
|
||||||
|
│ [meas-A-up] │
|
||||||
|
│ [meas-A-dn] → [Pump A] → │
|
||||||
|
│ [format-A] →[lout-evt-A]
|
||||||
|
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌── group: Pump B (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ... same shape ... │
|
||||||
|
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌── group: Pump C (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ... same shape ... │
|
||||||
|
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌── group: MGC — Pump Group (#50a8d9) ──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [lin-demand] [demand→MGC+PS] [MGC] [format-MGC]→[lout-evt-MGC]
|
||||||
|
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌── group: Pumping Station (#0c99d9) ───────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [PS] [format-PS]→[lout-evt-PS]
|
||||||
|
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌── group: Mode broadcast (#dddddd, neutral) ───────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [lin-mode] [fan-mode] ─────────────► to all 3 pumps in the Pump A/B/C groups │
|
||||||
|
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌── group: Station-wide commands (#dddddd) ─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [lin-start] [fan-start] ─► to pumps │
|
||||||
|
│ [lin-stop] [fan-stop] │
|
||||||
|
│ [lin-estop] [fan-estop] │
|
||||||
|
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
What that buys:
|
||||||
|
|
||||||
|
- Search "Pump A" highlights the whole group box (parent + sensors + adapters + formatter).
|
||||||
|
- S88 colour of the group box tells you the level at a glance.
|
||||||
|
- Wires are horizontal within a group; cross-group wires (Pump A port 2 → MGC) cross only one band.
|
||||||
|
- Collapse a group in the editor and it becomes a single tile — clutter disappears during reviews.
|
||||||
|
|
||||||
|
### 10.5 Multi-input fan-in rule
|
||||||
|
|
||||||
|
Stack link-ins tightly at L0, centred on the destination's y. Merge node one lane right at the same y.
|
||||||
|
|
||||||
|
### 10.6 Multi-output fan-out rule
|
||||||
|
|
||||||
|
Source at the y-centre of its destinations; destinations stack vertically in the next lane. Wires fork cleanly without jogging.
|
||||||
|
|
||||||
|
### 10.7 Link-in placement (within a tab)
|
||||||
|
|
||||||
|
- All link-ins on **L0**.
|
||||||
|
- Order them top-to-bottom by the y of their **first downstream target**.
|
||||||
|
- Link-ins that feed the same destination share the same y-band as that destination.
|
||||||
|
|
||||||
|
### 10.8 Link-out placement (within a tab)
|
||||||
|
|
||||||
|
- All link-outs on **L7** (the rightmost lane).
|
||||||
|
- Each link-out's y matches its **upstream source's** y, so the wire is horizontal.
|
||||||
|
|
||||||
|
### 10.9 Cross-tab wire rule
|
||||||
|
|
||||||
|
Cross-tab wires use `link out` / `link in` pairs (see Section 2). Direct cross-tab wires are forbidden.
|
||||||
|
|
||||||
|
### 10.10 The "no jog" verification
|
||||||
|
|
||||||
|
- A wire whose source y == destination y is fine (perfectly horizontal).
|
||||||
|
- A wire that jogs vertically by ≤ 80 px is fine (one row of slop).
|
||||||
|
- A wire that jogs by > 80 px means **the destination is in the wrong group y-band**. Move the destination, not the source — the source's position was determined by its own group.
|
||||||
|
|
||||||
|
## 11. Dashboard tab variant
|
||||||
|
|
||||||
|
Dashboard widgets are stamped to the real grid by the FlowFuse renderer; editor x/y is for the editor's readability.
|
||||||
|
|
||||||
|
- Use only **L0, L2, L4, L7**:
|
||||||
|
- L0 = `link in` (events from process)
|
||||||
|
- L2 = `ui-*` inputs (sliders, switches, buttons)
|
||||||
|
- L4 = wrapper / format / trend-split functions
|
||||||
|
- L7 = `link out` (commands going back)
|
||||||
|
- **One Node-RED group per `ui-group`.** Editor group's name matches the `ui-group` name. Colour follows the S88 level of the represented equipment (MGC group = `#50a8d9`, Pump A group = `#86bbdd`, …) so the editor view mirrors the dashboard structure.
|
||||||
|
- Within the group, widgets stack vertically by their visual order in the dashboard.
|
||||||
|
|
||||||
|
## 12. Setup tab variant
|
||||||
|
|
||||||
|
Single-column ladder L0 → L7, ordered top-to-bottom by `onceDelay`. Wrap in a single neutral-grey Node-RED group named `Deploy-time setup`.
|
||||||
|
|
||||||
|
## 13. Demo Drivers tab variant
|
||||||
|
|
||||||
|
Same as Process Plant but typically only L0, L2, L4, L7 are used. Wrap each driver (random gen, scripted scenario, …) in its own neutral Node-RED group.
|
||||||
|
|
||||||
|
## 14. Spacing constants (final)
|
||||||
|
|
||||||
|
```python
|
||||||
|
LANE_X = [120, 360, 600, 840, 1080, 1320, 1560, 1800]
|
||||||
|
SIBLING_PITCH = 40
|
||||||
|
GROUP_GAP = 200
|
||||||
|
TAB_TOP_MARGIN = 80
|
||||||
|
GROUP_PADDING = 20 # extra px around child bounding box for the Node-RED group box
|
||||||
|
|
||||||
|
S88_COLORS = {
|
||||||
|
"AR": "#0f52a5", # Area (currently unused)
|
||||||
|
"PC": "#0c99d9", # Process Cell
|
||||||
|
"UN": "#50a8d9", # Unit
|
||||||
|
"EM": "#86bbdd", # Equipment Module
|
||||||
|
"CM": "#a9daee", # Control Module
|
||||||
|
"neutral": "#dddddd",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Registry: drop a new node type here to place it automatically.
|
||||||
|
NODE_LEVEL = {
|
||||||
|
"measurement": "CM",
|
||||||
|
"rotatingMachine": "EM",
|
||||||
|
"valve": "EM",
|
||||||
|
"diffuser": "EM",
|
||||||
|
"machineGroupControl": "UN",
|
||||||
|
"valveGroupControl": "UN",
|
||||||
|
"reactor": "UN",
|
||||||
|
"settler": "UN",
|
||||||
|
"monster": "UN",
|
||||||
|
"pumpingStation": "PC",
|
||||||
|
"dashboardAPI": "neutral",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Helpers for the build script:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def place(lane, group_index, position_in_group, group_size):
|
||||||
|
"""Compute (x, y) for a node in a process group."""
|
||||||
|
x = LANE_X[lane]
|
||||||
|
band_centre = TAB_TOP_MARGIN + group_index * (group_size * SIBLING_PITCH + GROUP_GAP) \
|
||||||
|
+ (group_size - 1) * SIBLING_PITCH / 2
|
||||||
|
y = band_centre + (position_in_group - (group_size - 1) / 2) * SIBLING_PITCH
|
||||||
|
return int(x), int(y)
|
||||||
|
|
||||||
|
def wrap_in_group(child_ids, name, s88_color, nodes_by_id, padding=GROUP_PADDING):
|
||||||
|
"""Compute the Node-RED group box around a set of children."""
|
||||||
|
xs = [nodes_by_id[c]["x"] for c in child_ids]
|
||||||
|
ys = [nodes_by_id[c]["y"] for c in child_ids]
|
||||||
|
return {
|
||||||
|
"type": "group", "name": name,
|
||||||
|
"style": {"label": True, "stroke": "#000000", "fill": s88_color, "fill-opacity": "0.10"},
|
||||||
|
"nodes": list(child_ids),
|
||||||
|
"x": min(xs) - padding, "y": min(ys) - padding,
|
||||||
|
"w": max(xs) - min(xs) + 160 + 2 * padding,
|
||||||
|
"h": max(ys) - min(ys) + 40 + 2 * padding,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15. Verification checklist (extends Section 9)
|
||||||
|
|
||||||
|
After building a tab:
|
||||||
|
|
||||||
|
1. **No wire jogs > 80 px vertically within a group.**
|
||||||
|
2. **Each lane contains nodes of one purpose only** (never an `ui-text` on L3; never a `rotatingMachine` on L2).
|
||||||
|
3. **Peers share a lane; parents and children sit on adjacent lanes.**
|
||||||
|
4. **Every parent + direct children sit inside one Node-RED group box, coloured by the parent's S88 level.**
|
||||||
|
5. **Utility groups** (mode broadcast, station commands, demand fan-out) wrapped in neutral-grey Node-RED groups.
|
||||||
|
6. **Section comments at the top of each group band.**
|
||||||
|
7. **Editor scrollable in y but NOT in x** on a normal monitor.
|
||||||
|
8. **Search test:** typing the parent's name in the editor highlights the whole group box.
|
||||||
|
|
||||||
|
## 16. S88 colour cleanup (separate follow-up task)
|
||||||
|
|
||||||
|
These nodes don't currently follow the S88 palette. They should be brought in line in a separate session before the placement rule is fully consistent across the editor:
|
||||||
|
|
||||||
|
- `settler` (`#e4a363` orange) → should be `#50a8d9` (Unit)
|
||||||
|
- `monster` (`#4f8582` teal) → should be `#50a8d9` (Unit)
|
||||||
|
- `diffuser` (no colour set) → should be `#86bbdd` (Equipment Module)
|
||||||
|
- `dashboardAPI` (no colour set) → utility, no S88 colour needed
|
||||||
|
|
||||||
|
Until cleaned up, the placement rule still works — `NODE_LEVEL` (Section 14) already maps these to their semantic S88 level regardless of the node's own colour.
|
||||||
|
|||||||
Reference in New Issue
Block a user