diff --git a/.claude/refactor/MODULE_SPLIT.md b/.claude/refactor/MODULE_SPLIT.md index 53e33e5..751ea1d 100644 --- a/.claude/refactor/MODULE_SPLIT.md +++ b/.claude/refactor/MODULE_SPLIT.md @@ -29,7 +29,7 @@ nodes// edge/ ``` -## pumpingStation (Process Cell — L5, `#0c99d9`) +## pumpingStation (Process Cell — L5, group `#0c99d9` · palette `#8B4513`) ``` src/ @@ -61,7 +61,7 @@ examples/ standalone-demo.js # extracted from the bottom of specificClass.js ``` -## measurement (Control Module — L2, `#a9daee`) +## measurement (Control Module — L2, group `#a9daee` · palette `#D4A02E`) The good news: `Channel.js` already exists and is pure. Most of the analog mode in `specificClass.js` is duplication that vanishes when the @@ -90,7 +90,7 @@ src/ `generalFunctions/src/stats/`. Both `Channel.static helpers` and the calibrator use them. -## machineGroupControl (Unit — L4, `#50a8d9`) +## machineGroupControl (Unit — L4, group `#50a8d9` · palette `#B5651D`) ``` src/ @@ -117,7 +117,7 @@ src/ handlers.js ``` -## rotatingMachine (Equipment Module — L3, `#86bbdd`) +## rotatingMachine (Equipment Module — L3, group `#86bbdd` · palette `#E89B3A`) The biggest specificClass (1760 lines). The split mirrors the natural boundaries the existing comments suggest. @@ -166,6 +166,8 @@ src/ | `diffuser` | Equipment Module. Aeration controller. Likely small. | | `dashboardAPI` | Utility. InfluxDB endpoints. Likely no `BaseDomain` — it's a passive HTTP server. | +Palette swatches for these (sidebar): `valve` `#3CAEA3`, `valveGroupControl` `#2A8A82`, `reactor` `#6FAE5F`, `settler` `#8FAD3F`, `monster` `#9C5BB0`, `diffuser` `#6EB5E5`, `dashboardAPI` `#7A8BA3`. Group-box hex still follows S88 level (see `.claude/rules/node-red-flow-layout.md` §10.0). + The "skeleton" refactor for these is just: - Convert `nodeClass.js` to extend `BaseNodeAdapter`. - Convert `specificClass.js` to extend `BaseDomain`. diff --git a/.claude/refactor/OPEN_QUESTIONS.md b/.claude/refactor/OPEN_QUESTIONS.md index 988a71f..f110acb 100644 --- a/.claude/refactor/OPEN_QUESTIONS.md +++ b/.claude/refactor/OPEN_QUESTIONS.md @@ -741,3 +741,25 @@ work can decide whether to preserve original casing globally. **Decision needed by:** Phase 7 (topic-name + schema standardisation) — once enums standardise on a canonical casing, drop the `.toUpperCase()` guard here. + +--- + +## 2026-05-21 — Palette swatches switched to domain-hue (resolved) + +**Context:** Node-RED sidebar showed every EVOLV node in a shade of blue because palette colours were set from the S88 level (Area / ProcessCell / Unit / Equipment / ControlModule). Operators reported difficulty picking the right node by eye. + +**Decision:** Split the colour systems. The **palette swatch** in each `.html` (`RED.nodes.registerType({ color })`) becomes domain-hue per node; family hue = function (rotating = orange, valves = teal, biology = green/olive, sampling = violet, sensor = amber, infrastructure = slate, aeration = sky blue). Within a family, darker = higher S88 (e.g. RM → MGC → pumpingStation darkens the orange). **Editor-group rectangles** in `flow.json` (`style.fill`) continue to follow S88 level — the hierarchy story stays visible in flow diagrams. Two systems, two purposes. + +**Final palette table:** see `.claude/rules/node-red-flow-layout.md` §10.0. + +**Why split rather than rework S88:** S88 hierarchy is genuinely useful for flow-diagram readability (it's the whole point of group boxes). Throwing it out to fix palette identifiability would have cost the hierarchy signal. Two systems = both problems solved. + +**Files touched (palette):** the 12 `nodes//.html` files, one line each. + +**Files touched (docs):** `CLAUDE.md` (L52 split into palette + group lines); `.claude/rules/node-red-flow-layout.md` (new §10.0); `.claude/refactor/MODULE_SPLIT.md` (per-node headers annotated with both hexes); `.claude/refactor/WIKI_HOME_TEMPLATE.md` + `WIKI_TEMPLATE.md` (clarifying sentence — Mermaid classDefs are hierarchy, not palette); this entry. + +**Unchanged on purpose:** 32 submodule wiki/CLAUDE.md files that name S88 hexes — they describe hierarchy diagrams or editor-group boxes, both of which still use S88. Spot-checked `rotatingMachine` + `reactor` wikis to confirm. + +**Open follow-ups:** +- If `coresync` ends up classified as a process-data node rather than infrastructure, repick a non-slate hue. +- Consider a `tools/palette-lint/` check that diffs declared palette hexes vs. this table to catch future drift (low priority). diff --git a/.claude/refactor/WIKI_HOME_TEMPLATE.md b/.claude/refactor/WIKI_HOME_TEMPLATE.md index 3b74379..f603b7c 100644 --- a/.claude/refactor/WIKI_HOME_TEMPLATE.md +++ b/.claude/refactor/WIKI_HOME_TEMPLATE.md @@ -62,7 +62,7 @@ flowchart TB classDef neutral fill:#dddddd,color:#000 ~~~ -S88 colours: Process Cell `#0c99d9`, Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Solid arrow = parent/child relationship. Dashed arrow = data flow (`measurement` feeds many node types). +S88 colours (used here for **hierarchy visualization only** — distinct from the node-palette swatches in the Node-RED sidebar, which are domain-hue; see `.claude/rules/node-red-flow-layout.md` §10.0): Process Cell `#0c99d9`, Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Solid arrow = parent/child relationship. Dashed arrow = data flow (`measurement` feeds many node types). ## Live nodes diff --git a/.claude/refactor/WIKI_TEMPLATE.md b/.claude/refactor/WIKI_TEMPLATE.md index 93919e7..45ac14c 100644 --- a/.claude/refactor/WIKI_TEMPLATE.md +++ b/.claude/refactor/WIKI_TEMPLATE.md @@ -75,7 +75,7 @@ flowchart LR classDef ctrl fill:#a9daee,color:#000 ~~~ -S88 colours are mandatory. Map: Process Cell `#0c99d9`, Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`. +S88 colours are mandatory **inside hierarchy diagrams** (Mermaid `classDef`, flow.json group `style.fill`). They are NOT the node-palette swatch hexes shown in the Node-RED sidebar — those are domain-hue per node. Map (hierarchy use): Process Cell `#0c99d9`, Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md` (§10.0 for palette, §10.1 for groups/lanes). ## 3. Capability matrix diff --git a/.claude/rules/node-red-flow-layout.md b/.claude/rules/node-red-flow-layout.md index 8a15e6c..b80ba3d 100644 --- a/.claude/rules/node-red-flow-layout.md +++ b/.claude/rules/node-red-flow-layout.md @@ -273,6 +273,34 @@ Before declaring a flow done: 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.0 Two color systems — palette swatch vs. editor group + +EVOLV uses two distinct color schemes for two distinct purposes. Mixing them up is the most common visual-design bug we see in flows. + +| System | Where it's set | What it signals | Scheme | +|---|---|---|---| +| **Palette swatch** | `RED.nodes.registerType(..., { color })` in `.html` | "Which node am I picking from the sidebar?" | **Domain-hue per node** (table below) | +| **Editor group rectangle** | `style.fill` on a `group` node in `flow.json` | "Which S88 cluster does this box represent?" | **S88 level** (§10.1 table) | + +**Palette swatches (set 2026-05-21).** Family hue = function. Within a family, darker = higher S88 / "more controller-ish." + +| Node | Hex | Family | +|---|---|---| +| `rotatingMachine` | `#E89B3A` | 🟧 orange — leaf (individual machine) | +| `machineGroupControl` | `#B5651D` | 🟫 orange — mid (parent of RM) | +| `pumpingStation` | `#8B4513` | 🟤 orange — dark (top of pump hierarchy) | +| `valve` | `#3CAEA3` | 🟦 teal — leaf | +| `valveGroupControl` | `#2A8A82` | 🟦 teal — dark (parent of valve) | +| `reactor` | `#6FAE5F` | 🟩 green — biology | +| `settler` | `#8FAD3F` | 🟢 olive — biology | +| `diffuser` | `#6EB5E5` | 🟦 sky blue — aeration | +| `monster` | `#9C5BB0` | 🟪 violet — sampling | +| `measurement` | `#D4A02E` | 🟨 amber — sensor | +| `dashboardAPI` | `#7A8BA3` | ⬜ slate — infrastructure | +| `coresync` | `#54647B` | ⬛ dark slate — infrastructure | + +**Important:** the §10.1 "Colour" column below refers to **editor groups + lane backgrounds** (S88), not to the palette swatch. Don't use the S88 hex inside `registerType`; don't use the palette hex inside a `flow.json` group `style.fill`. + ### 10.1 Lane convention (x-axis = S88 level) | Lane | x | Purpose | S88 level | Colour | Current EVOLV nodes | diff --git a/.gitmodules b/.gitmodules index 5ab80ea..9093f65 100644 --- a/.gitmodules +++ b/.gitmodules @@ -35,3 +35,6 @@ [submodule "nodes/settler"] path = nodes/settler url = https://gitea.wbd-rd.nl/RnD/settler.git +[submodule "nodes/coresync"] + path = nodes/coresync + url = https://gitea.wbd-rd.nl/RnD/coresync.git diff --git a/CLAUDE.md b/CLAUDE.md index e6f196d..a576107 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,9 @@ only the file paths change. `dashboardAPI` was migrated this way on 2026-05-19. ## Conventions - Nodes register under category `'EVOLV'` in Node-RED -- S88 color scheme: Area=#0f52a5, ProcessCell=#0c99d9, Unit=#50a8d9, Equipment=#86bbdd, ControlModule=#a9daee +- Two color systems (don't confuse): + - **Palette swatch** (Node-RED sidebar, set in `.html`) = domain-hue per node — full table in `.claude/rules/node-red-flow-layout.md` §10.0. Changed 2026-05-21; see `.claude/refactor/OPEN_QUESTIONS.md`. + - **Editor-group rectangle** (flow.json `style.fill`) = S88 level (unchanged): Area=#0f52a5, ProcessCell=#0c99d9, Unit=#50a8d9, Equipment=#86bbdd, ControlModule=#a9daee - Config JSON files in `generalFunctions/src/configs/` define defaults, types, enums per node - Tick loop is **opt-in per node** — default cadence 1000 ms, but each node sets `static tickInterval` (or skips it). See `.claude/refactor/OPEN_QUESTIONS.md` (2026-05-10 entry) for the design decision - Output ports + 3-tier architecture + file-naming + `src/editor/` layout: see `.claude/rules/node-architecture.md` diff --git a/CORESYNC_FROST_INTERVIEW_HANDOFF.md b/CORESYNC_FROST_INTERVIEW_HANDOFF.md new file mode 100644 index 0000000..59dc3d3 --- /dev/null +++ b/CORESYNC_FROST_INTERVIEW_HANDOFF.md @@ -0,0 +1,414 @@ +# CoreSync FROST Interview Handoff + +Date: 2026-05-19 + +## Continue Here First + +Resume the interview at **Question 20**. The last open design topic was the reducer comparison method: + +**Q20. Should slope change be compared by angle in degrees or by relative slope delta?** + +Recommended direction before pausing: + +- Support both eventually. +- Default to angle comparison with normalized time/value axes. +- Compute `dx = deltaTimeMs / timeScaleMs`. +- Compute `dy = deltaValue / valueScale`. +- Compare `atan2(dy, dx)` direction changes against `angleToleranceDeg`. + +## Agreed Decisions + +- Use FROST/SensorThings instead of direct InfluxDB for the new CoreSync path. +- Keep EVOLV standard outputs: + - `process` + - `dbase` + - `parent` +- Add a `dbase` output format option for `frost`. +- `dbase = frost` emits FROST-ready HTTP request messages. +- The CoreSync node does not post directly to FROST in the first version. +- A normal Node-RED HTTP request node sends the FROST messages. +- HTTP responses feed back into the same CoreSync input with `msg.topic = "frost.response"`. +- All FROST metadata lookup/create/patch requests leave on `dbase`, not `process`. +- `process` is reserved for functional process data and optional functional state. +- The resolver is lazy: streams are resolved only when telemetry arrives. +- Pending queue policy for unresolved/FROST-down streams is keep first + latest, drop middle. +- Observation writes use nested Datastream endpoints: + - `POST /v1.1/Datastreams({datastreamId})/Observations` +- Preserve provenance in Observation `parameters`. +- On angle/slope change, emit the previous point as the knot. +- Do not forward-fill delta-compressed fields. +- Latest values are queried per Datastream: + - `/Datastreams(id)/Observations?$orderby=phenomenonTime desc&$top=1` + +## SensorThings Mapping + +- EVOLV asset/apparatus/node -> FROST `Thing` +- EVOLV field `type` -> FROST `ObservedProperty` +- EVOLV `variant` (`measured`, `predicted`, `setpoint`) -> FROST `Sensor` +- EVOLV `position` -> stable FROST `FeatureOfInterest` +- EVOLV numeric field -> one FROST `Datastream` +- One reducer-kept knot -> one FROST `Observation` + +Stable FOI convention: + +```text +{thingId}:upstream +{thingId}:atEquipment +{thingId}:downstream +``` + +Also copy position into `Datastream.properties.position` for filtering. + +## Units + +Use EVOLV canonical ingest units. UI conversion happens client-side. + +- pressure: `Pa` +- flow: `m3/s` +- power: `W` +- temperature: `K` +- density: `kg/m3` +- level: `m` +- volume: `m3` +- control / percentage / efficiency: normalized ratio `1` + +No leading zeros in engineering tags: + +```text +P-1 +PT-1 +FT-9999999 +``` + +Never: + +```text +P-001 +PT-0001 +``` + +## Identity And Registry + +- Node-RED is not the source of truth for asset identity. +- Future central asset registry owns tag allocation and duplicate detection. +- Use one central counter per tag prefix: + - `P` + - `PT` + - `FT` + - `TT` + - etc. +- The central registry, not local Node-RED, performs atomic `+1`. +- For now, assume the central registry is future work. +- First implementation derives identity when possible and allows overrides. +- Keep a boundary like `resolveIdentity(input)` so future registry integration is straightforward. + +First-version identity behavior: + +```text +Thing tag: configured/derived, e.g. P-1 +Sensor tag: configured/derived, e.g. PT-1, MODEL-P-1, CTRL-P-1 +Stream key: thingTag:type:variant:position:sensorTag +``` + +## Shared Collector Model + +Use one shared CoreSync per FROST target/stack level. + +Many EVOLV nodes can connect their `dbase` output to the CoreSync input, assuming payloads are structured as: + +```js +{ + measurement: "P-1", + fields: { + "pressure.measured.upstream.PT-1": 12345 + }, + tags: { + tagcode: "P-1" + }, + timestamp: Date +} +``` + +Also accept arrays of such payloads. + +Internal stream key: + +```text +thingTag:type:variant:position:sensorTag +``` + +Per-stream state: + +- FROST id cache +- latest FROST `phenomenonTime` +- reducer anchor point +- reducer previous point +- pending latest point +- bounded pending queue + +## FROST Request Message Shape + +Outgoing request messages should preserve correlation metadata: + +```js +{ + topic: "frost.metadata.lookup", + requestId: "thing:P-1:lookup", + _coreSync: { + kind: "thing", + action: "lookup", + externalKey: "thing:P-1", + streamKey: "P-1:pressure:measured:upstream:PT-1" + }, + method: "GET", + url: "...", + payload: null +} +``` + +FROST response feedback: + +```js +{ + topic: "frost.response", + requestId: "...", + statusCode: 200, + payload: {}, + _coreSync: {} +} +``` + +Observation write target: + +```http +POST /v1.1/Datastreams({datastreamId})/Observations +``` + +Observation payload: + +```json +{ + "phenomenonTime": "2026-05-19T10:15:30.000Z", + "result": 123.4, + "FeatureOfInterest": { + "@iot.id": 7 + }, + "parameters": { + "reduction": "knot", + "reductionReason": "first|angle-change|max-gap|flush", + "evolvFieldKey": "pressure.measured.upstream.PT-1", + "evolvStreamKey": "P-1:pressure:measured:upstream:PT-1", + "sourceMeasurement": "Pump A" + } +} +``` + +## Reducer Decisions So Far + +- Reducer runs independently per Datastream. +- 2D vector means time on X and numeric field value on Y. +- On direction change, emit the previous point. +- First point of a stream is kept. +- Previous point is kept on angle change. +- Pending latest point is emitted on explicit flush, max gap, or close. +- No forward-fill. + +Pending queue during unresolved metadata/FROST downtime: + +```text +queue empty -> store observation +queue has 1 -> keep first, append latest +queue has 2 -> keep first, replace second with latest +``` + +## Open Interview Questions + +## Implementation Progress 2026-05-21 + +First coding pass added: + +- New Node-RED node: `nodes/coresync/coresync.js` / `coresync.html`. +- New `frost` dbase formatter in `generalFunctions`. +- Root Node-RED registration: `package.json` -> `coresync`. +- Focused tests: `nodes/coresync/test/basic/coresync.basic.test.js`. +- FROST request builder for lazy lookup/create and nested Observation writes. +- Per-stream normalized-angle reducer, defaulting to: + - `angleToleranceDeg = 5` + - `timeScaleMs = 60000` + - `maxGapMs = 300000` + - keep first + latest pending queue +- Minimal response state machine: + - `GET lookup` + - `POST create if missing` + - cache returned `@iot.id` + - drain pending Observations once Datastream and FOI ids are known + +Validation run: + +```text +npx jest nodes/coresync/test/basic/coresync.basic.test.js --runInBand +PASS, 4 tests + +npx eslint nodes/coresync/**/*.js nodes/generalFunctions/src/helper/formatters/frostFormatter.js nodes/generalFunctions/src/helper/formatters/index.js +PASS +``` + +Full `npm run lint` still fails on pre-existing unrelated repo issues, mostly browser globals in editor scripts and older lint findings. + +Q20 decision implemented: default to normalized angle comparison. Relative slope mode is present as an advanced option in the reducer and editor config. + +Q21 decision implemented: first defaults are the candidate defaults from this document, with per-type value-scale defaults in the CoreSync domain and fallback scale `1`. + +Q22 decision implemented: explicit `msg.topic = "coresync.flush"`, `maxGapMs`, and close flush are supported. No periodic flush timer was added. + +Q23 decision implemented: lazy resolver order is: + +```text +Thing +ObservedProperty +Sensor +FeatureOfInterest +Datastream +Observation +``` + +Each metadata entity uses lookup/create only. PATCH drift correction is not in this pass. + +Q24 decision implemented in editor defaults: + +```text +frostBaseUrl +serviceVersion +assetTagOverride +sensorTagOverride +comparisonMode +angleToleranceDeg +timeScaleMs +maxGapMs +minDeltaTimeMs +minDeltaValue +maxQueuedObservationsPerStream +diagnosticsEnabled +``` + +Q25 decision implemented: emitted request messages are plain Node-RED HTTP-compatible messages and preserve `requestId` / `_coreSync` correlation fields. + +Q26 decision implemented: id cache is runtime-only. + +Q27 partial: failed metadata responses emit a process diagnostic and clear in-flight metadata. Backoff timing is not implemented yet. + +Q28 implemented scope: skeleton, normalizer, reducer, FROST request builder, and minimal response state machine. + +### Q20. Reducer comparison method + +Should slope change be compared by: + +- angle in degrees, using normalized axes, or +- relative slope delta? + +Recommended: default to normalized angle comparison and keep relative slope as an optional advanced mode. + +### Q21. Reducer defaults + +What should the first defaults be? + +Candidate defaults: + +```text +angleToleranceDeg = 5 +timeScaleMs = 60000 +valueScaleMode = auto +minDeltaTimeMs = 0 +minDeltaValue = 0 +maxGapMs = 300000 +``` + +Need decide whether `valueScale` is: + +- configured per observed property/unit, +- auto-learned per stream, +- fixed to `1`. + +Recommended: configured defaults by type, with auto fallback. + +### Q22. Flush behavior + +When should pending latest points flush? + +Options: + +- on node close only, +- on explicit `msg.topic = "coresync.flush"`, +- on `maxGapMs`, +- on periodic flush timer. + +Recommended: support explicit flush and `maxGapMs`; avoid periodic flush unless needed. + +### Q23. Metadata bootstrap order + +What exact lazy resolver chain should the first implementation use? + +Candidate: + +```text +Thing +ObservedProperty +Sensor +FeatureOfInterest +Datastream +Observation +``` + +Need decide whether each entity is `GET lookup -> POST create if missing`, and whether PATCH metadata drift is included in v1. + +Recommended v1: lookup/create only; no PATCH drift correction yet. + +### Q24. FROST base URL config + +What config fields belong on the CoreSync node? + +Candidate: + +```text +frostBaseUrl +serviceVersion = v1.1 +dbaseFormat = frost +assetTagOverride +sensorTagOverride +angleToleranceDeg +timeScaleMs +maxGapMs +maxQueuedObservationsPerStream +diagnosticsEnabled +``` + +Need decide which are required for v1 editor UI. + +### Q25. HTTP node compatibility + +Do we require a wrapper function around Node-RED HTTP request to preserve `_coreSync` and `requestId`, or should CoreSync emit messages exactly in the shape the HTTP node preserves by default? + +Recommended: design emitted messages to survive the standard HTTP node, then add a helper/example flow if needed. + +### Q26. Local id cache persistence + +Should resolved FROST ids be runtime-only, or persisted in Node-RED context? + +Recommended v1: runtime-only cache, because metadata lookup is lazy and deterministic. Add persistent context later if lookups become expensive. + +### Q27. Error handling policy + +For failed FROST responses, should the stream: + +- retry immediately, +- back off, +- mark unresolved and keep first/latest pending, +- drop until manual reset? + +Recommended: exponential-ish backoff per stream plus keep first/latest pending. + +### Q28. First implementation scope + +Should the first coding pass create only the node skeleton plus reducer tests, or include the lazy FROST resolver end-to-end? + +Recommended: implement skeleton, normalizer, reducer, and FROST request builder together; keep HTTP response state machine minimal but functional. diff --git a/examples/README.md b/examples/README.md index adbbb6d..e69de29 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,53 +0,0 @@ -# EVOLV — End-to-End Example Flows - -> **Working with these examples?** See [`WORKFLOW.md`](WORKFLOW.md) — the canonical guide for editing, switching projects, persistence, and debugging. - -Demo flows that show how multiple EVOLV nodes work together in a realistic wastewater-automation scenario. Each example is self-contained: its folder has a `flow.json` you can import directly into Node-RED plus a `README.md` that walks through the topology, control modes, and dashboard layout. - -These flows complement the per-node example flows under `nodes//examples/` (which exercise a single node in isolation). Use the per-node flows for smoke tests during development; use the flows here when you want to see how a real plant section behaves end-to-end. - -## Catalogue - -| Folder | What it shows | -|---|---| -| [`pumpingstation-complete-example/`](pumpingstation-complete-example/) | End-to-end stack: pumpingStation + MGC + 3 pumps + 12 measurement nodes (4 per pump, physics-coupled), operator-driven inflow with scenario buttons (Constant / Sine / Diurnal / Storm), FlowFuse dashboard (realtime + 1h trends), and provisioned Grafana dashboard backed by InfluxDB. | - -## How it loads - -Each subfolder here is a **Node-RED project**. The Docker stack has Node-RED's Projects feature enabled and bootstraps each `examples//` into `/data/projects//` on first container start. - -To run: - -1. `docker compose up -d` from the EVOLV root. -2. Open Node-RED at `http://localhost:1880`. -3. Menu → **Projects** → **Open Project** → pick one. -4. Open the FlowFuse dashboard at `http://localhost:1880/dashboard`. - -The default active project is `pumpingstation-complete-example` (override via `DEFAULT_PROJECT` env var on the nodered service). Switching is two clicks; persistence is handled by the `evolv_nodered_data` named volume — `docker compose down && up` doesn't lose the active flow. - -Each example uses a unique dashboard `path` so they can coexist if you load multiple in the same runtime. - -## Adding new examples - -When you create a new end-to-end example: - -1. Make a subfolder under `examples/` named `-`. -2. Include at least `flow.json` and `README.md`. A `build_flow.py` (or equivalent generator) is recommended so the JSON stays diff-friendly. -3. `docker compose restart nodered` — the entrypoint will bootstrap your new folder as a Node-RED project (synthesizes `package.json`, `git init`, initial commit) under `/data/projects//`. -4. Editor → Projects → Open Project → pick your new one. -5. Add a row to the catalogue table above. - -The bootstrap skips folders that already exist in the volume. To force a refresh of an existing project from the repo source (e.g. after editing `build_flow.py`), use `./scripts/sync-example.sh `. - -## Wishlist for future examples - -These are scenarios worth building when there's a session for it: - -- **Pump failure + MGC re-routing** — kill pump 2 mid-run, watch MGC redistribute to pumps 1 and 3. -- **Energy-optimal vs equal-flow control** — same demand profile run through `optimalcontrol` and `prioritycontrol` modes side-by-side, energy comparison chart. -- **Schedule-driven demand** — diurnal flow pattern (low at night, peak at 7 am), MGC auto-tuning over 24 simulated hours. -- **Reactor + clarifier loop** — `reactor` upstream feeding `settler`, return sludge controlled by a small `pumpingStation`. -- **Diffuser + DO control** — aeration grid driven by a PID controller from a dissolved-oxygen sensor. -- **Digital sensor bundle** — MQTT-style sensor (BME280, ATAS, etc.) feeding a `measurement` node in digital mode + parent equipment node. -- **Maintenance window** — entermaintenance / exitmaintenance cycle with operator handover dashboard. -- **Calibration walk-through** — measurement node calibrate cycle with stable / unstable input demonstrations. diff --git a/examples/pumpingstation-complete-example/README.md b/examples/pumpingstation-complete-example/README.md deleted file mode 100644 index 0591498..0000000 --- a/examples/pumpingstation-complete-example/README.md +++ /dev/null @@ -1,195 +0,0 @@ -# Pumping Station — Complete Example - -End-to-end EVOLV stack: 1 pumpingStation + 1 machineGroupControl + 3 rotatingMachine pumps + 12 measurement nodes (4 per pump), wired through Node-RED to InfluxDB and Grafana. - -This is the canonical "everything works together" demo. After any cross-node refactor, run this and verify the Node-RED dashboard, the InfluxDB writes, and the Grafana dashboard all populate. - -## Quick start - -```bash -cd /home/znetsixe/EVOLV -docker compose up -d -# Wait for http://localhost:1880/nodes to return 200, then: -curl -s -X POST http://localhost:1880/flows \ - -H "Content-Type: application/json" \ - -H "Node-RED-Deployment-Type: full" \ - --data-binary @examples/pumpingstation-complete-example/flow.json -``` - -Then open: - -- Node-RED dashboard (realtime + 1h trends): -- Grafana dashboard (realtime gauges + historic graphs): (anonymous viewer is on; the dashboard is `EVOLV / Pumping Station (complete)`) -- InfluxDB UI: (user `evolv` / password `evolv-dev-pw`) - -## What the flow contains - -| Layer | Node(s) | Role | -|---|---|---| -| Process Cell | `pumpingStation` "Pumping Station" | Wet-well basin model. Levelbased control: drives MGC by basin level. Inflow comes from the Drivers tab; outflow is computed from the pumps. | -| Unit | `machineGroupControl` "MGC — Pump Group" | Distributes flow across the 3 pumps via `optimalcontrol`. | -| Equipment | `rotatingMachine` × 3 — Pump A / B / C | Hidrostal H05K-S03R curve. Auto by default; manual setpoint slider per pump when in `virtualControl`. | -| Control Modules | `measurement` × 12 (4 per pump) | Upstream pressure, downstream pressure, flow, power. Each pump's 4 sensors are driven by a per-pump physics function — values are physically coupled to plant state, not random. | -| Telemetry | shared `evt:tlm` link channel → http POST → InfluxDB | Every EVOLV node's port-1 payload is converted to v2 line protocol and POSTed to `telemetry` bucket. | - -## Tabs - -The flow is split across 5 tabs, by **concern**: - -| Tab | Lives here | Why | -|---|---|---| -| 🏭 **Process Plant** | EVOLV nodes (PS, MGC, 3 pumps, 12 sensors) + per-node output formatters + per-pump physics feeders | The deployable plant model. | -| 📊 **Dashboard UI** | All `ui-*` widgets, button/setpoint wrappers, dispatch functions | Display + operator inputs. No business logic. | -| 🎛️ **Demo Drivers** | Inflow generator (Constant / Sine / Diurnal / Storm) + 1Hz tick | Inflow is operator-driven via slider + scenario buttons. Outflow is implicit (the pumps drain the basin). | -| ⚙️ **Setup & Init** | One-shot `once: true` injects (MGC scaling/mode, pumps mode, initial inflow scenario) | Runs at deploy time only. | -| 📈 **Telemetry** | link-in `evt:tlm` → line-protocol function → http POST | InfluxDB writer. | - -Cross-tab wiring uses **named link-out / link-in pairs**, never direct cross-tab wires. - -### Channel contract - -| Channel | Direction | What it carries | -|---|---|---| -| `cmd:inflow-baseline` | UI → Drivers | numeric m³/h baseline | -| `cmd:inflow-scenario` | UI → Drivers | `'constant' \| 'sine' \| 'diurnal' \| 'storm'` | -| `cmd:q_in` | Drivers → process | computed inflow in m³/s | -| `cmd:Qd` | UI → process | manual demand m³/h (manual mode only) | -| `cmd:ps-mode` | UI → process | `'levelbased' \| 'manual'` | -| `cmd:mode` | Setup → process | per-pump `setMode` broadcast | -| `cmd:station-startup / -shutdown / -estop` | UI → 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 | -| `evt:ps` | process → UI | basin state, level, fill | -| `evt:inflow` | Drivers → UI | live inflow value + active scenario | -| `evt:tlm` | every EVOLV node → Telemetry | port-1 payload in `{measurement, fields, tags}` shape | -| `setup:to-mgc` | Setup → process | one-shot MGC scaling/mode init | - -## Per-pump physics feeder - -Each pump has a `physics_` function node on the Process Plant tab. It receives: - -1. The pump's own port-0 stream (state, predicted flow, predicted power). -2. PS port-0 stream (basin level), fanned out by `ps_to_physics`. - -It computes physically-coupled values for each sensor and emits them to the 4 measurement nodes: - -| Sensor | Computation | -|---|---| -| Upstream pressure | `ρ g h` where `h = max(0, basinLevel − outflowLevel)`; pump suction sees the basin's hydrostatic head. | -| Downstream pressure | Idle → static head only (12 m → 1177 mbar). Running → static + flow²-scaled dynamic head (up to ~2354 mbar at q=200 m³/h). | -| Flow | Mirrors rotatingMachine's predicted flow with 1% Gaussian noise. Zero when the pump is idle. | -| Power | Mirrors rotatingMachine's predicted power with 0.5% Gaussian noise. Zero when the pump is idle. | - -Gaussian noise uses a 12-uniform-sum approximation (no external libs). - -## Inflow scenarios - -Pick a scenario on the **Realtime** dashboard page (group "Inflow"): - -| Scenario | Behaviour | -|---|---| -| Constant | `q_h = baseline` (no modulation) | -| Sine | `baseline · (1 + 0.5 · sin(2πt/240))` — period 4 min | -| Diurnal | `baseline · (1 + 0.6 · sin(2πt/480 − π/2))` — period 8 min, peak offset | -| Storm | 4-min cycle: rapid 5× ramp, then linear decay back to baseline | - -Slider sets `baseline` in m³/h (0–250). The generator emits `q_in` to PS every second. - -## Dashboard map - -### Node-RED — `/dashboard` - -Realtime page (`/dashboard/realtime`): - -1. Inflow — slider, 4 scenario buttons, live value + active scenario label -2. Station mode + commands — Auto/Manual switch, manual Qd slider, Start All / Stop All / Emergency Stop -3. Basin realtime — direction, level, volume, fill %, net flow, time-to-full/empty, inflow, outflow, safety state, gauges (level + fill) -4. MGC — total flow + power (text + gauges), efficiency -5. Pump A / B / C — state, mode, controller %, flow, power, up/dn pressure (text), setpoint slider, Startup / Shutdown buttons - -Trends page (`/dashboard/trends`) — 1-hour rolling windows: - -- Basin level + fill % -- Inflow / Outflow / Per-pump flow (one chart, multi-series) -- Per-pump power -- Per-pump up/dn pressure - -### Grafana — `EVOLV / Pumping Station (complete)` - -Two rows: - -- **Realtime** — gauges for basin level + fill, stat panels for total flow / total power / per-pump state. -- **Historic** — line charts for level + fill, inflow/outflow/net, per-pump flow + power (predicted), per-pump pressure, per-pump sensor flow + power (measured). - -Default time range: last 15 minutes. Adjust with the Grafana picker for longer history. - -## Verification - -```bash -# 1. Bring up the stack -docker compose up -d -sleep 10 # wait for Node-RED ready - -# 2. Deploy the flow -curl -s -X POST http://localhost:1880/flows \ - -H 'Content-Type: application/json' \ - -H 'Node-RED-Deployment-Type: full' \ - --data-binary @examples/pumpingstation-complete-example/flow.json | jq . - -# 3. Quick sanity check on Influx writes -curl -s -X POST 'http://localhost:8086/api/v2/query?org=evolv' \ - -H 'Authorization: Token evolv-dev-token' \ - -H 'Accept: application/csv' \ - -H 'Content-type: application/vnd.flux' \ - --data 'from(bucket:"telemetry") |> range(start: -1m) |> count() |> group(columns: ["_measurement"])' -``` - -You should see counts per measurement (`Pumping Station`, `Pump A`, `MGC — Pump Group`, the per-pump sensors, …) growing in real time. - -## Regenerating `flow.json` - -`flow.json` is generated from `build_flow.py`. Edit the Python (cleaner diff) and regenerate: - -```bash -cd examples/pumpingstation-complete-example -python3 build_flow.py > flow.json -``` - -The Python is the source of truth. - -After regenerating, push the new flow into the running runtime: - -```bash -./scripts/sync-example.sh pumpingstation-complete-example -``` - -## Projects + persistence (Node-RED) - -The Docker stack uses a named volume (`evolv_nodered_data`) for `/data`, and Node-RED's **Projects** feature is enabled. Each folder under `examples/` is bootstrapped into `/data/projects//` on first container start with its own `git init` and a synthesized `package.json`. Switching between projects is two clicks in the editor: **menu → Projects → Open Project**. - -| What you do | Where it lives | What persists | -|---|---|---| -| `docker compose down && up` | Container is recreated; named volume survives | Active flow + project list survive | -| Edit a flow in the Node-RED editor | `/data/projects//flow.json` (in volume) | Until `docker compose down -v` | -| Edit `examples//build_flow.py` then regenerate | `examples//flow.json` (in repo) | Always — it's in Git | -| Run `scripts/sync-example.sh ` | Copies repo's `flow.json` → volume's project + reloads | Volume copy now matches repo | - -### Adding a new example as a project - -1. Create `examples//flow.json` (build it however you like — `build_flow.py` is one way). -2. Restart the Node-RED container: `docker compose restart nodered`. -3. Editor → Projects → Open Project → pick ``. - -The bootstrap is idempotent: existing projects in the volume aren't overwritten. To force a refresh from the repo: delete the project in the volume (`docker exec evolv-nodered rm -rf /data/projects/`) and restart, or use `scripts/sync-example.sh` for a flow-only refresh. - -To start fresh (wipe all volume state including flows, sessions, project history): `docker compose down -v`. - -## Notable design choices - -- **PS in `levelbased` mode** with `manual` mode toggleable from the UI. Levelbased = PS commands MGC by basin level; manual = operator drives MGC via the Qd slider. -- **Inflow is operator-driven**, outflow is implicit (computed from pump activity). Single steerable knob (the Inflow group) keeps the demo focused. -- **Sensors driven externally**, not by the measurement node's built-in simulator. The physics feeder is a function node on the Process Plant tab — disable it and sensors freeze, which is a useful failure mode to demonstrate. -- **All EVOLV port 1 → one shared telemetry channel** (`evt:tlm`) → one writer. Adding a new EVOLV node anywhere in the flow only needs a new `lout_tlm_` link-out + appending the id to `_all_tlm_lout_ids()` in `build_flow.py`. -- **Dashboard pages split by concern, not data**: realtime widgets never share a page with historical charts. diff --git a/examples/pumpingstation-complete-example/build_flow.py b/examples/pumpingstation-complete-example/build_flow.py deleted file mode 100644 index ebd03a5..0000000 --- a/examples/pumpingstation-complete-example/build_flow.py +++ /dev/null @@ -1,1910 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate the multi-tab Node-RED flow for the -'pumpingstation-complete-example' end-to-end demo. - -Stack ------ -- 1 pumpingStation (basin model, levelbased control) -- 1 machineGroupControl (orchestrates the 3 pumps) -- 3 rotatingMachine pumps -- 12 measurement nodes (4 per pump: upstream P, downstream P, flow, power) -- All EVOLV node port-1 telemetry routed to InfluxDB via http request -- FlowFuse dashboard (realtime + 1h trends) -- Grafana dashboard (realtime gauges + historic graphs) - -Tabs ----- - Tab 1 Process Plant EVOLV nodes only — pumps, MGC, PS, measurements, - per-node output formatters and per-pump physics - feeders that drive the measurement nodes from live - plant state. - - Tab 2 Dashboard UI only ui-* widgets. No business logic. - - Tab 3 Demo Drivers inflow generator (Constant / Sine / Diurnal / Storm - scenarios chosen by buttons; baseline set by slider). - - Tab 4 Setup & Init one-shot deploy-time injects (MGC scaling/mode, - pumps mode = auto). - - Tab 5 Telemetry collects port-1 InfluxDB payloads from every EVOLV - node, converts to line protocol, POSTs to InfluxDB. - -Cross-tab wiring is via NAMED link-out / link-in pairs only. - -To regenerate: - python3 build_flow.py > flow.json -""" -import json -import sys - -# --------------------------------------------------------------------------- -# Tab IDs -# --------------------------------------------------------------------------- -TAB_PROCESS = "tab_process" -TAB_UI = "tab_ui" -TAB_DRIVERS = "tab_drivers" -TAB_SETUP = "tab_setup" -TAB_TLM = "tab_telemetry" - -# --------------------------------------------------------------------------- -# Spacing constants -# --------------------------------------------------------------------------- -LANE_X = [120, 380, 640, 900, 1160, 1420] -ROW = 80 -SECTION_GAP = 220 - -POSITION_ICON = { - "upstream": "→", - "downstream": "←", - "atEquipment": "⊥", -} - -# --------------------------------------------------------------------------- -# Cross-tab link channels — the wiring contract -# --------------------------------------------------------------------------- -CH_INFLOW_BASELINE = "cmd:inflow-baseline" # m³/h baseline (slider) -CH_INFLOW_SCENARIO = "cmd:inflow-scenario" # 'constant' | 'sine' | 'diurnal' | 'storm' -CH_QIN = "cmd:q_in" # m³/s, generator → PS -CH_QD = "cmd:Qd" # m³/h, slider → PS (manual mode only) -CH_PS_MODE = "cmd:ps-mode" # 'levelbased' | 'manual' - -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", - "pump_b": "cmd:pump-B-seq", - "pump_c": "cmd:pump-C-seq"} - -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" -CH_INFLOW_EVT = "evt:inflow" - -CH_TLM = "evt:tlm" - -PUMPS = ["pump_a", "pump_b", "pump_c"] -PUMP_LABELS = {"pump_a": "Pump A", "pump_b": "Pump B", "pump_c": "Pump C"} - -MGC_ID = "mgc_pumps" -PS_ID = "ps_basin" - -# Basin geometry — single source of truth. -# Realistic wet-well wastewater pumping station — pumps are oversized -# ~5× nominal inflow for storm tolerance. Sized so: -# - nominal inflow ~25 m³/h refills the dead-band [stopLvl, startLvl] -# (~6.25 m³) in ~15 min while pumps are off -# - one pump at minimum stable flow (~99 m³/h) drains the same band in -# ~5 min once engaged via the stopLevel Schmitt trigger -# - storm inflow ~250 m³/h pushes percControl up the ramp until all 3 -# pumps are engaged at high flow (combined max ≈ 681 m³/h) -# surfaceArea = 50 / 4 = 12.5 m²; band volume = 12.5 × 0.5 = 6.25 m³ -BASIN_VOLUME = 50.0 -BASIN_HEIGHT = 4.0 -OUTFLOW_LEVEL = 0.3 -OVERFLOW_LEVEL = 3.8 - - -# --------------------------------------------------------------------------- -# 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): - return { - "id": node_id, "type": "inject", "z": tab, "name": name, - "props": [ - {"p": "topic", "vt": "str"}, - {"p": "payload", "v": str(payload), "vt": payload_type}, - ], - "topic": topic, "payload": str(payload), "payloadType": payload_type, - "repeat": repeat, "crontab": "", - "once": once, "onceDelay": once_delay, - "x": x, "y": y, "wires": [wires or []], - } - - -def function_node(node_id, tab, x, y, name, code, outputs=1, wires=None): - return { - "id": node_id, "type": "function", "z": tab, "name": name, - "func": code, "outputs": outputs, - "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": x, "y": y, - "wires": wires if wires is not None else [[] for _ in range(outputs)], - } - - -def link_out(node_id, tab, x, y, channel_name, target_in_ids): - return { - "id": node_id, "type": "link out", "z": tab, "name": channel_name, - "mode": "link", "links": list(target_in_ids), - "x": x, "y": y, "wires": [], - } - - -def link_in(node_id, tab, x, y, channel_name, source_out_ids, downstream): - return { - "id": node_id, "type": "link in", "z": tab, "name": channel_name, - "links": list(source_out_ids), - "x": x, "y": y, "wires": [downstream or []], - } - - -def debug_node(node_id, tab, x, y, name, target="payload", - target_type="msg", active=False): - return { - "id": node_id, "type": "debug", "z": tab, "name": name, - "active": active, "tosidebar": True, "console": False, "tostatus": False, - "complete": target, "targetType": target_type, - "x": x, "y": y, "wires": [], - } - - -# --------------------------------------------------------------------------- -# Dashboard scaffolding -# --------------------------------------------------------------------------- -def dashboard_scaffold(): - base = { - "id": "ui_base", "type": "ui-base", "name": "EVOLV Pumping", - "path": "/dashboard", "appIcon": "", - "includeClientData": True, - "acceptsClientConfig": ["ui-notification", "ui-control"], - "showPathInSidebar": True, "headerContent": "page", - "navigationStyle": "default", "titleBarStyle": "default", - } - theme = { - "id": "ui_theme", "type": "ui-theme", "name": "EVOLV Theme", - "colors": { - "surface": "#ffffff", "primary": "#0c99d9", - "bgPage": "#f4f6fa", "groupBg": "#ffffff", - "groupOutline": "#cccccc", - }, - "sizes": { - "density": "default", "pagePadding": "12px", - "groupGap": "12px", "groupBorderRadius": "6px", - "widgetGap": "8px", - }, - } - page_realtime = { - "id": "ui_page_realtime", "type": "ui-page", - "name": "Realtime", "ui": "ui_base", - "path": "/realtime", "icon": "speed", - "layout": "grid", "theme": "ui_theme", - "breakpoints": [{"name": "Default", "px": "0", "cols": "12"}], - "order": 1, "className": "", - } - page_trends = { - "id": "ui_page_trends", "type": "ui-page", - "name": "Trends — 1 hour", "ui": "ui_base", - "path": "/trends", "icon": "show_chart", - "layout": "grid", "theme": "ui_theme", - "breakpoints": [{"name": "Default", "px": "0", "cols": "12"}], - "order": 2, "className": "", - "d": True, - } - return [base, theme, page_realtime, page_trends] - - -def ui_group(group_id, name, page_id, width=6, order=1): - return { - "id": group_id, "type": "ui-group", "name": name, "page": page_id, - "width": str(width), "height": "1", "order": order, - "showTitle": True, "className": "", "groupType": "default", - "disabled": False, "visible": True, - } - - -def ui_text(node_id, tab, x, y, group, name, label, fmt, layout="row-spread"): - return { - "id": node_id, "type": "ui-text", "z": tab, "group": group, - "order": 1, "width": "0", "height": "0", "name": name, "label": label, - "format": fmt, "layout": layout, "style": False, "font": "", - "fontSize": 14, "color": "#000000", - "x": x, "y": y, "wires": [], - } - - -def ui_button(node_id, tab, x, y, group, name, label, payload, payload_type, - topic, color="#0c99d9", icon="play_arrow", wires=None): - return { - "id": node_id, "type": "ui-button", "z": tab, "group": group, - "name": name, "label": label, "order": 1, "width": "0", "height": "0", - "tooltip": "", "color": "#ffffff", "bgcolor": color, - "className": "", "icon": icon, "iconPosition": "left", - "payload": payload, "payloadType": payload_type, - "topic": topic, "topicType": "str", "buttonType": "default", - "x": x, "y": y, "wires": [wires or []], - } - - -def ui_slider(node_id, tab, x, y, group, name, label, mn, mx, step=1.0, - topic="", wires=None): - return { - "id": node_id, "type": "ui-slider", "z": tab, "group": group, - "name": name, "label": label, "tooltip": "", "order": 1, - "width": "0", "height": "0", "passthru": True, "outs": "end", - "topic": topic, "topicType": "str", - "min": str(mn), "max": str(mx), "step": str(step), - "showLabel": True, "showValue": True, "labelPosition": "top", - "valuePosition": "left", "thumbLabel": False, - "iconStart": "", "iconEnd": "", - "x": x, "y": y, "wires": [wires or []], - } - - -def ui_switch(node_id, tab, x, y, group, name, label, on_value, off_value, - topic, wires=None): - return { - "id": node_id, "type": "ui-switch", "z": tab, "group": group, - "name": name, "label": label, "tooltip": "", "order": 1, - "width": "0", "height": "0", "passthru": True, "decouple": "false", - "topic": topic, "topicType": "str", - "style": "", "className": "", "evaluate": "true", - "onvalue": on_value, "onvalueType": "str", - "onicon": "auto_mode", "oncolor": "#0c99d9", - "offvalue": off_value, "offvalueType": "str", - "officon": "back_hand", "offcolor": "#888888", - "x": x, "y": y, "wires": [wires or []], - } - - -def ui_chart(node_id, tab, x, y, group, name, label, - width=12, height=6, - remove_older="60", remove_older_unit="60", - remove_older_points="1800", - y_axis_label="", ymin=None, ymax=None, order=1, - interpolation="linear"): - """FlowFuse ui-chart — full required field set per node-red-flow-layout.md.""" - return { - "id": node_id, "type": "ui-chart", "z": tab, "group": group, - "name": name, "label": label, "order": order, - "chartType": "line", - "interpolation": interpolation, - "category": "topic", "categoryType": "msg", - "xAxisLabel": "", "xAxisType": "time", - "xAxisProperty": "", "xAxisPropertyType": "timestamp", - "xAxisFormat": "", "xAxisFormatType": "auto", - "xmin": "", "xmax": "", - "yAxisLabel": y_axis_label, - "yAxisProperty": "payload", "yAxisPropertyType": "msg", - "ymin": "" if ymin is None else str(ymin), - "ymax": "" if ymax is None else str(ymax), - "removeOlder": str(remove_older), - "removeOlderUnit": str(remove_older_unit), - "removeOlderPoints": str(remove_older_points), - "action": "append", - "stackSeries": False, - "pointShape": "circle", "pointRadius": 4, - "showLegend": True, - "bins": 10, - "colors": [ - "#0095FF", "#FF0000", "#FF7F0E", "#2CA02C", - "#A347E1", "#D62728", "#FF9896", "#9467BD", "#C5B0D5", - ], - "textColor": ["#666666"], "textColorDefault": True, - "gridColor": ["#e5e5e5"], "gridColorDefault": True, - "width": int(width), "height": int(height), "className": "", - "x": x, "y": y, "wires": [[]], - } - - -def ui_gauge(node_id, tab, x, y, group, name, title, units, mn, mx, - segments, gtype="gauge-34", suffix="", icon="", - width=3, height=3, order=1): - return { - "id": node_id, "type": "ui-gauge", "z": tab, "group": group, - "name": name, "gtype": gtype, "gstyle": "Rounded", - "title": title, "units": units, "prefix": "", "suffix": suffix, - "min": mn, "max": mx, "segments": segments, - "width": width, "height": height, "order": order, - "icon": icon, "sizeGauge": 20, "sizeGap": 2, "sizeSegments": 10, - "x": x, "y": y, "wires": [], - } - - -# --------------------------------------------------------------------------- -# Tab 1 — PROCESS PLANT -# --------------------------------------------------------------------------- -def build_process_tab(): - nodes = [] - - nodes.append({ - "id": TAB_PROCESS, "type": "tab", - "label": "🏭 Process Plant", - "disabled": False, - "info": ( - "EVOLV plant model: 3 rotatingMachines (each with 4 measurement " - "nodes — upstream P, downstream P, flow, power), MGC, PS.\n\n" - "Per pump there is a 'physics' function node that consumes the " - "pump's own port-0 stream PLUS PS port-0 (basin level) and " - "drives all 4 measurement nodes with physically-coupled values " - "(upstream P from basin head; downstream P from pump state + " - "flow; flow/power mirror predicted with Gaussian noise). This " - "lives on this tab so the plant model is self-contained.\n\n" - "All cross-tab wires use named link-in / link-out channels." - ), - }) - - nodes.append(comment("c_process_title", TAB_PROCESS, LANE_X[2], 20, - "🏭 PROCESS PLANT — EVOLV nodes + per-pump physics feeders", - "")) - - # ---------------- Per-pump rows ---------------- - for i, pump in enumerate(PUMPS): - label = PUMP_LABELS[pump] - y_section = 80 + i * (SECTION_GAP + 60) - - nodes.append(comment(f"c_{pump}", TAB_PROCESS, LANE_X[2], y_section, - f"── {label} ── (pump + 4 sensors + physics feeder)", - "Up/Dn pressure + flow + power sensors register as children of " - "the pump. The physics_ function takes the pump's own " - "port-0 stream and PS port-0 (basin level) and drives all 4 " - "sensors with physically-coupled values." - )) - - # ---- 4 measurement nodes (driven via msg.topic='measurement') ---- - SENSORS = [ - ("u", "Up", "upstream", "mbar", - "pressure", "vega", "vega-pressure-10"), - ("d", "Dn", "downstream", "mbar", - "pressure", "vega", "vega-pressure-10"), - ("f", "Flow", "downstream", "m3/h", - "flow", "endress", "endress-promag-50"), - ("p", "Pwr", "atEquipment","kW", - "power", "siemens", "siemens-sentron-pac4200"), - ] - for j, (suffix, lbl, pos, unit, asset_type, supplier, model) in enumerate(SENSORS): - mid = f"meas_{pump}_{suffix}" - mid_label = f"{label.split()[1]}-{lbl}" - if asset_type == "pressure": - o_min, o_max = 0, 4000 - elif asset_type == "flow": - o_min, o_max = 0, 250 - else: # power - o_min, o_max = 0, 30 - nodes.append({ - "id": mid, "type": "measurement", "z": TAB_PROCESS, - "name": mid_label, - "mode": "analog", "channels": "[]", - "scaling": False, - "i_min": 0, "i_max": 1, "i_offset": 0, - "o_min": o_min, "o_max": o_max, - "simulator": False, - "smooth_method": "mean", "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": f"sensor-{pump}-{suffix}", - "supplier": supplier, "category": "sensor", - "assetType": asset_type, "model": model, - "unit": unit, - "assetTagNumber": f"{label.split()[1]}-{suffix.upper()}", - "enableLog": False, "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": pos, - "positionIcon": POSITION_ICON.get(pos, ""), - "hasDistance": False, "distance": 0, "distanceUnit": "m", - "distanceDescription": "", - "x": LANE_X[1], "y": y_section + 40 + j * 35, - # Port 0 unused, port 1 → telemetry, port 2 → pump (registerChild) - "wires": [[], [f"lout_tlm_{mid}"], [pump]], - }) - nodes.append(link_out( - f"lout_tlm_{mid}", TAB_PROCESS, LANE_X[1] + 200, - y_section + 40 + j * 35, - CH_TLM, target_in_ids=["lin_tlm"], - )) - - # ---- The pump itself ---- - nodes.append({ - "id": pump, "type": "rotatingMachine", "z": TAB_PROCESS, - "name": label, - # speed (movement units/s). The state machine doesn't auto- - # return to 'operational' after a routine abort (avoids a - # bounce loop), so any setpoint that arrives while still - # accelerating gets deferred via delayedMove. With MGC - # retargeting every PS tick (2 s) and a 0..100 position - # range, speed must be high enough that the movement - # finishes inside one tick — otherwise the FSM gets parked - # in 'accelerating' and the badge stops advancing. 200 u/s - # gives a worst-case 0..100 traversal of 0.5 s, well inside - # the 2 s window. - "speed": "200", - "startup": "2", "warmup": "1", "shutdown": "2", "cooldown": "1", - "movementMode": "staticspeed", - "machineCurve": "", - "uuid": f"pump-{pump}", - "supplier": "hidrostal", "category": "pump", - "assetType": "pump-centrifugal", - "model": "hidrostal-H05K-S03R", - "unit": "m3/h", - "curvePressureUnit": "mbar", "curveFlowUnit": "m3/h", - "curvePowerUnit": "kW", "curveControlUnit": "%", - "enableLog": False, "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "atEquipment", - "positionIcon": POSITION_ICON["atEquipment"], - "hasDistance": False, "distance": 0, "distanceUnit": "m", - "distanceDescription": "", - "x": LANE_X[3], "y": y_section + 90, - "wires": [ - [f"format_{pump}", f"physics_{pump}"], - [f"lout_tlm_{pump}"], - [MGC_ID], - ], - }) - nodes.append(link_out( - f"lout_tlm_{pump}", TAB_PROCESS, LANE_X[3], y_section + 130, - CH_TLM, target_in_ids=["lin_tlm"], - )) - - # ---- Per-pump output formatter (for dashboard) ---- - nodes.append(function_node( - f"format_{pump}", TAB_PROCESS, LANE_X[4], y_section + 90, - f"format {label} port 0", - "const p = msg.payload || {};\n" - "const c = context.get('c') || {};\n" - "Object.assign(c, p);\n" - "context.set('c', c);\n" - "// Throttle dashboard fan-out to ≤ 2 Hz. The pump emits on\n" - "// every state change (multiple per sec while cycling); the\n" - "// dashboard doesn't need that resolution and the websocket\n" - "// fan-out chokes the browser.\n" - "const now = Date.now();\n" - "const last = context.get('_lastEmit') || 0;\n" - "if (now - last < 1000) return null;\n" - "context.set('_lastEmit', now);\n" - "function find(prefix) {\n" - " for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n" - " return null;\n" - "}\n" - "const flow = find('flow.predicted.downstream.');\n" - "const power = find('power.predicted.atequipment.');\n" - "const ctrl = find('ctrl.predicted.atequipment.');\n" - "const pUp = find('pressure.measured.upstream.');\n" - "const pDn = find('pressure.measured.downstream.');\n" - "msg.payload = {\n" - " state: c.state || 'idle',\n" - " mode: c.mode || 'auto',\n" - " ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n" - " flow: flow != null ? Number(flow ).toFixed(1) + ' m³/h' : 'n/a',\n" - " power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n" - " pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n" - " pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n" - " ctrlNum: ctrl != null ? Number(ctrl ) : null,\n" - " flowNum: flow != null ? Number(flow ) : null,\n" - " powerNum: power != null ? Number(power) : null,\n" - " pUpNum: pUp != null ? Number(pUp ) : null,\n" - " pDnNum: pDn != null ? Number(pDn ) : null,\n" - " // Pump is moving water any time it's between startup and shutdown, not\n" - " // just during steady operational. accelerate/decelerate/warmup count.\n" - " isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n" - "};\n" - "return msg;", - outputs=1, wires=[[f"lout_evt_{pump}"]], - )) - nodes.append(link_out( - f"lout_evt_{pump}", TAB_PROCESS, LANE_X[5], y_section + 90, - CH_PUMP_EVT[pump], - target_in_ids=[f"lin_evt_{pump}_dash"], - )) - - # ---- Physics feeder ---- - nodes.append(function_node( - f"physics_{pump}", TAB_PROCESS, LANE_X[4], y_section + 160, - f"physics {label} → 4 sensors", - _physics_code(pump.split("_", 1)[1]), - outputs=4, - wires=[ - [f"meas_{pump}_u"], - [f"meas_{pump}_d"], - [f"meas_{pump}_f"], - [f"meas_{pump}_p"], - ], - )) - - # ---- Setpoint slider link-in ---- - 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]], - )) - - # ---- Per-pump start/stop link-in ---- - 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], - )) - - # ---------------- MGC ---------------- - y_mgc = 80 + len(PUMPS) * (SECTION_GAP + 60) - nodes.append(comment("c_mgc", TAB_PROCESS, LANE_X[2], y_mgc, - "── MGC ── (orchestrates the 3 pumps via optimalcontrol)", - "")) - 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": True, "logLevel": "debug", - "tickIntervalMs": 2000, - "positionVsParent": "atEquipment", - "positionIcon": POSITION_ICON["atEquipment"], - "hasDistance": False, "distance": 0, "distanceUnit": "m", - "distanceDescription": "", - "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", - "x": LANE_X[3], "y": y_mgc + 80, - "wires": [ - ["format_mgc"], - ["lout_tlm_mgc"], - [PS_ID], - ], - }) - nodes.append(link_out( - "lout_tlm_mgc", TAB_PROCESS, LANE_X[3], y_mgc + 120, - CH_TLM, target_in_ids=["lin_tlm"], - )) - 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" - "// Throttle: MGC fires on every distribution change.\n" - "const now = Date.now();\n" - "const last = context.get('_lastEmit') || 0;\n" - "if (now - last < 1000) return null;\n" - "context.set('_lastEmit', now);\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" - " efficiencyNum: eff != null ? Number(eff) : 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 = y_mgc + SECTION_GAP + 60 - nodes.append(comment("c_ps", TAB_PROCESS, LANE_X[2], y_ps, - "── Pumping Station ── (basin model, levelbased control)", "")) - - nodes.append(link_in( - "lin_qin_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 40, - CH_QIN, source_out_ids=["lout_qin_drivers"], - downstream=[PS_ID], - )) - nodes.append(link_in( - "lin_qd_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 80, - CH_QD, source_out_ids=["lout_qd_dash"], - downstream=["qd_to_ps_wrap"], - )) - nodes.append(function_node( - "qd_to_ps_wrap", TAB_PROCESS, LANE_X[1], y_ps + 80, - "wrap slider → PS Qd", - "msg.topic = 'Qd';\n" - "return msg;", - outputs=1, wires=[[PS_ID]], - )) - nodes.append(link_in( - "lin_ps_mode_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 120, - CH_PS_MODE, source_out_ids=["lout_ps_mode_dash"], - downstream=[PS_ID], - )) - - nodes.append({ - "id": PS_ID, "type": "pumpingStation", "z": TAB_PROCESS, - "name": "Pumping Station", - "uuid": "ps-basin-1", - "category": "station", "assetType": "pumpingstation", - "model": "default", "unit": "m3/s", "supplier": "evolv", - "enableLog": False, "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "atEquipment", - "positionIcon": POSITION_ICON["atEquipment"], - "hasDistance": False, "distance": 0, "distanceUnit": "m", - "distanceDescription": "", - "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", - "controlMode": "levelbased", - "basinVolume": BASIN_VOLUME, - "basinHeight": BASIN_HEIGHT, - # inflowLevel = top of inlet pipe (geometry) AND foot of the - # demand ramp (control). Setting it equal to maxLevel collapses - # the ramp to a step function — the runtime cycles 0/100 % every - # tick AND the editor's level-mode preview hides the diagonal - # line (mode-preview.js refuses to draw a degenerate ramp). - "inflowLevel": 2.5, - "outflowLevel": OUTFLOW_LEVEL, - "overflowLevel": OVERFLOW_LEVEL, - "inletPipeDiameter": 0.3, - "outletPipeDiameter": 0.3, - "minLevel": 0.5, - # startLevel — ramp foot AND rising-edge engage point. Demand - # scales 0..100 % over [startLevel, maxLevel]. - "startLevel": 2.5, - # stopLevel — falling-edge disengage point. While engaged AND - # level < startLevel (basin draining through the dead band), PS - # emits the keep-alive percControl below so MGC keeps a single - # pump running until level reaches stopLevel. - "stopLevel": 2.0, - # deadZoneKeepAlivePercent — % sent to MGC while engaged in the - # dead band [stopLevel, startLevel). Mapped by MGC's normalized - # scaling to flow.min — i.e., a single pump at minimum stable - # speed. 1 % is small enough to round to flow.min. - "deadZoneKeepAlivePercent": 1, - "maxLevel": 3.5, - "refHeight": "NAP", - "minHeightBasedOn": "outlet", - "basinBottomRef": 0, - "staticHead": 12, - "maxDischargeHead": 24, - "pipelineLength": 80, - "defaultFluid": "wastewater", - "temperatureReferenceDegC": 15, - "maxInflowRate": 200, - "enableDryRunProtection": True, - "enableOverfillProtection": True, - "dryRunThresholdPercent": 5, - "overfillThresholdPercent": 95, - "timeleftToFullOrEmptyThresholdSeconds": 0, - "x": LANE_X[3], "y": y_ps + 80, - "wires": [ - ["format_ps", "ps_to_physics"], - ["lout_tlm_ps"], - ], - }) - nodes.append(link_out( - "lout_tlm_ps", TAB_PROCESS, LANE_X[3], y_ps + 120, - CH_TLM, target_in_ids=["lin_tlm"], - )) - nodes.append(function_node( - "ps_to_physics", TAB_PROCESS, LANE_X[4], y_ps + 130, - "ps → fan basin level to 3 physics feeders", - "const out = { from: 'ps', payload: msg.payload };\n" - "return [out, out, out];", - outputs=3, - wires=[["physics_pump_a"], ["physics_pump_b"], ["physics_pump_c"]], - )) - nodes.append(function_node( - "format_ps", TAB_PROCESS, LANE_X[4], y_ps + 80, - "format PS port 0", - "const p = msg.payload || {};\n" - "const c = context.get('c') || {};\n" - "Object.assign(c, p);\n" - "context.set('c', c);\n" - "// Throttle: PS emits frequently in levelbased mode.\n" - "const now = Date.now();\n" - "const last = context.get('_lastEmit') || 0;\n" - "if (now - last < 1000) return null;\n" - "context.set('_lastEmit', now);\n" - "function find(prefix) {\n" - " for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n" - " return null;\n" - "}\n" - f"const MAX_VOL = {BASIN_VOLUME};\n" - "const lvl = find('level.predicted.');\n" - "const vol = find('volume.predicted.');\n" - "const qIn = find('flow.predicted.in.');\n" - "const qOut = find('flow.predicted.out.');\n" - "const netFlowRate = find('netFlowRate.predicted.');\n" - "const fillPct = vol != null\n" - " ? Math.min(100, Math.max(0, Math.round(Number(vol) / MAX_VOL * 100)))\n" - " : null;\n" - "const netM3h = netFlowRate != null ? Number(netFlowRate) * 3600 : null;\n" - "const seconds = (c.timeleft != null && Number.isFinite(Number(c.timeleft)))\n" - " ? Number(c.timeleft) : null;\n" - "const timeStr = seconds != null\n" - " ? (seconds > 60 ? Math.round(seconds/60) + ' min'\n" - " : Math.round(seconds) + ' s')\n" - " : 'n/a';\n" - "msg.payload = {\n" - " direction: c.direction || 'steady',\n" - " level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n" - " volume: vol != null ? Number(vol).toFixed(1) + ' m³' : 'n/a',\n" - " fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n" - " netFlow: netM3h != null ? netM3h.toFixed(0) + ' m³/h' : 'n/a',\n" - " timeLeft: timeStr,\n" - " qIn: qIn != null ? (Number(qIn ) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n" - " qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n" - " levelNum: lvl != null ? Number(lvl) : null,\n" - " volumeNum: vol != null ? Number(vol) : null,\n" - " fillPctNum: fillPct,\n" - " netFlowNum: netM3h,\n" - " percControl: c.percControl != null ? Number(c.percControl) : null,\n" - " qInNum: qIn != null ? Number(qIn ) * 3600 : null,\n" - " qOutNum: qOut != null ? Number(qOut) * 3600 : null,\n" - " safetyState: c.safetyState || 'normal',\n" - "};\n" - "return msg;", - outputs=1, wires=[["lout_evt_ps"]], - )) - nodes.append(link_out( - "lout_evt_ps", TAB_PROCESS, LANE_X[5], y_ps + 80, - CH_PS_EVT, target_in_ids=["lin_evt_ps_dash"], - )) - - # ---------------- Mode broadcast ---------------- - y_mode = y_ps + SECTION_GAP - nodes.append(comment("c_mode_bcast", TAB_PROCESS, LANE_X[2], y_mode, - "── Mode broadcast ──", "")) - nodes.append(link_in( - "lin_mode", TAB_PROCESS, LANE_X[0], y_mode + 60, - "cmd:mode", - source_out_ids=["lout_mode_setup"], - downstream=["fanout_mode"], - )) - nodes.append(function_node( - "fanout_mode", TAB_PROCESS, LANE_X[1] + 220, y_mode + 60, - "fan setMode → 3 pumps", - "msg.topic = 'setMode';\n" - "return [msg, msg, msg];", - outputs=3, wires=[["pump_a"], ["pump_b"], ["pump_c"]], - )) - - # ---------------- Station-wide commands ---------------- - y_station = y_mode + 200 - nodes.append(comment("c_station_cmds", TAB_PROCESS, LANE_X[2], y_station, - "── Station-wide commands ──", "")) - 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 - slug = chan.replace(":", "_").replace("-", "_") - nodes.append(link_in( - link_id, TAB_PROCESS, LANE_X[0], y, chan, - source_out_ids=[f"lout_{slug}_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"]], - )) - - # ---------------- Setup feeder link-in ---------------- - y_setup_in = y_station + 280 - nodes.append(comment("c_setup_at_mgc", TAB_PROCESS, LANE_X[2], y_setup_in, - "── Setup feeders ──", "")) - nodes.append(link_in( - "lin_setup_at_mgc", TAB_PROCESS, LANE_X[0], y_setup_in + 60, - "setup:to-mgc", - source_out_ids=["lout_setup_to_mgc"], - downstream=[MGC_ID], - )) - nodes.append(link_in( - "lin_setup_calibrate_ps", TAB_PROCESS, LANE_X[0], y_setup_in + 120, - "setup:calibrate-ps", - source_out_ids=["lout_setup_calibrate"], - downstream=[PS_ID], - )) - - return nodes - - -def _physics_code(pump_letter): - """JS source for the per-pump physics feeder. - - Real parallel-pump installations share suction and discharge headers, - so every pump sees the SAME differential pressure. We therefore - publish each pump's predicted flow into Node-RED `flow` context, sum - across all pumps to get the manifold flow, and derive ONE header - pressure used as p_downstream for ALL pumps. Per-pump diagnostics - still get individually-noisy upstream values (suction header) since - sensor noise is local even on a shared header. - """ - return ( - "const c = context.get('c') || {};\n" - "function find(o, prefix) {\n" - " for (const k in o) { if (k.indexOf(prefix) === 0) return o[k]; }\n" - " return null;\n" - "}\n" - "function gauss(sigma) {\n" - " let s = 0;\n" - " for (let i = 0; i < 12; i++) s += Math.random();\n" - " return (s - 6) * sigma;\n" - "}\n" - "\n" - "if (msg.from === 'ps') {\n" - " const psSnap = c.ps || {};\n" - " Object.assign(psSnap, msg.payload || {});\n" - " c.ps = psSnap;\n" - " const lvl = find(psSnap, 'level.predicted.atequipment.')\n" - " ?? find(psSnap, 'level.measured.atequipment.');\n" - " if (lvl != null) c.basinLevel = Number(lvl);\n" - " context.set('c', c);\n" - " return null;\n" - "}\n" - "\n" - "const pumpSnap = c.pump || {};\n" - "Object.assign(pumpSnap, msg.payload || {});\n" - "c.pump = pumpSnap;\n" - "context.set('c', c);\n" - "// Throttle: 1 Hz sensor updates are plenty for the demo; the\n" - "// pump emits on every state change (5+/sec while cycling).\n" - "const _now = Date.now();\n" - "const _last = context.get('_lastEmit') || 0;\n" - "if (_now - _last < 1000) return null;\n" - "context.set('_lastEmit', _now);\n" - "\n" - "const state = pumpSnap.state || 'idle';\n" - "// 'isRunning' = the rotor is spinning (any non-idle, non-cooled state).\n" - "// MGC retargets flow on every tick, so the pump spends most of its\n" - "// time in 'accelerating' or 'decelerating', not 'operational'. Those\n" - "// transient states are still moving water — flow/power sensors must\n" - "// publish non-zero values during them or the measurement nodes go\n" - "// quiet (formatMsg skips emits on no-diff).\n" - "const isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n" - "// 'pumpFlow' (not 'flow') — `flow` is the Node-RED flow-context object.\n" - "const pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\n" - "const pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\n" - "const basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n" - "\n" - "// Publish this pump's contribution to the flow-context shared\n" - "// header so the other physics feeders can compute total flow.\n" - f"flow.set('pump_flow_{pump_letter}', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\n" - f"flow.set('pump_flow_{pump_letter}_state', state);\n" - "const flowA = Number(flow.get('pump_flow_a') || 0);\n" - "const flowB = Number(flow.get('pump_flow_b') || 0);\n" - "const flowC = Number(flow.get('pump_flow_c') || 0);\n" - "const totalFlow = flowA + flowB + flowC;\n" - "\n" - # Hydrostatic head → mbar. - # Pa = rho * g * h = 9810 * h (rho=1000, g=9.81) - # mbar = Pa / 100 = 98.1 * h - f"const HEAD_M = Math.max(0, basinLevel - {OUTFLOW_LEVEL});\n" - "// Suction (basin) header pressure — same physical value for all\n" - "// pumps; per-pump sensor noise added independently.\n" - "const p_upstream_clean = 98.1 * HEAD_M;\n" - "let p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n" - "\n" - "// Discharge (header) pressure — driven by TOTAL flow leaving the\n" - "// manifold, NOT this pump's individual flow. Static head 12 m\n" - "// + quadratic system curve scaled so totalFlow=300 m³/h gives\n" - "// ~full dynamic head.\n" - "const STATIC_MBAR = 12 * 98.1;\n" - "const DYN_MBAR_MAX = 12 * 98.1;\n" - "const TOTAL_FLOW_MAX = 300;\n" - "const ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\n" - "const p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n" - "// Publish the clean header value to flow context so the MGC's\n" - "// header-pressure measurement child can read it.\n" - "flow.set('header_p_downstream', p_downstream_header);\n" - "flow.set('header_p_upstream', p_upstream_clean);\n" - "// Per-pump downstream sensor: header value with local sensor noise.\n" - "let p_downstream = Math.max(0, p_downstream_header + gauss(8));\n" - "\n" - "const flowMeas = (isRunning && Number.isFinite(pumpFlow))\n" - " ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n" - " : 0;\n" - "\n" - "const powerMeas = (isRunning && Number.isFinite(pumpPower))\n" - " ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n" - " : 0;\n" - "\n" - "return [\n" - " { topic: 'measurement', payload: p_upstream },\n" - " { topic: 'measurement', payload: p_downstream },\n" - " { topic: 'measurement', payload: flowMeas },\n" - " { topic: 'measurement', payload: powerMeas },\n" - "];\n" - ) - - -# --------------------------------------------------------------------------- -# Tab 2 — DASHBOARD UI -# --------------------------------------------------------------------------- -def build_ui_tab(): - nodes = [] - nodes.append({ - "id": TAB_UI, "type": "tab", - "label": "📊 Dashboard UI", - "disabled": False, - "info": ( - "All FlowFuse ui-* widgets. Two pages:\n" - " /dashboard/realtime — gauges + per-pump status (no time history)\n" - " /dashboard/trends — line charts, 1 hour rolling window\n\n" - "All inputs leave via link-out; all process state arrives via link-in." - ), - }) - - nodes += dashboard_scaffold() - - PG_RT = "ui_page_realtime" - PG_TRENDS = "ui_page_trends" - - g_inflow = "ui_grp_inflow" - g_station = "ui_grp_station" - g_basin = "ui_grp_basin" - g_mgc = "ui_grp_mgc" - g_pump_a = "ui_grp_pump_a" - g_pump_b = "ui_grp_pump_b" - g_pump_c = "ui_grp_pump_c" - g_tr_basin = "ui_grp_tr_basin" - g_tr_demand = "ui_grp_tr_demand" - g_tr_dq = "ui_grp_tr_dq" - g_tr_states = "ui_grp_tr_states" - g_tr_flow = "ui_grp_tr_flow" - g_tr_power = "ui_grp_tr_power" - g_tr_press = "ui_grp_tr_press" - - nodes += [ - ui_group(g_inflow, "1. Inflow (operator input)", PG_RT, width=12, order=1), - ui_group(g_station, "2. Station Mode + Commands", PG_RT, width=12, order=2), - ui_group(g_basin, "3. Basin Realtime", PG_RT, width=6, order=3), - ui_group(g_mgc, "4. Pump Group (MGC)", PG_RT, width=6, order=4), - ui_group(g_pump_a, "5a. Pump A", PG_RT, width=4, order=5), - ui_group(g_pump_b, "5b. Pump B", PG_RT, width=4, order=6), - ui_group(g_pump_c, "5c. Pump C", PG_RT, width=4, order=7), - ui_group(g_tr_basin, "Basin level + fill (1h)", PG_TRENDS, width=12, order=1), - ui_group(g_tr_demand, "Process demand — PS percControl (1h)", - PG_TRENDS, width=12, order=2), - ui_group(g_tr_dq, "ΔQ = inflow − outflow (m³/h, +fill / −drain)", - PG_TRENDS, width=12, order=3), - ui_group(g_tr_states, "Pump state timeline (gantt)", - PG_TRENDS, width=12, order=4), - ui_group(g_tr_flow, "Inflow / Outflow / Per-pump flow (1h)", - PG_TRENDS, width=12, order=5), - ui_group(g_tr_power, "Per-pump power (1h)", PG_TRENDS, width=12, order=6), - ui_group(g_tr_press, "Per-pump pressures (1h)", PG_TRENDS, width=12, order=7), - ] - - nodes.append(comment("c_ui_title", TAB_UI, LANE_X[2], 20, - "📊 DASHBOARD UI — only ui-* widgets here", "")) - - # ---------- INFLOW SECTION ---------- - y = 80 - nodes.append(comment("c_ui_inflow", TAB_UI, LANE_X[2], y, - "── Operator inflow input ──", "")) - nodes.append(ui_slider( - "ui_inflow_slider", TAB_UI, LANE_X[0], y + 40, g_inflow, - "Inflow baseline", - "Inflow baseline (m³/h) — scenarios modulate around this value", - 0, 250, 5.0, "inflowBaseline", - wires=["lout_inflow_baseline"], - )) - nodes.append(link_out( - "lout_inflow_baseline", TAB_UI, LANE_X[1], y + 40, - CH_INFLOW_BASELINE, target_in_ids=["lin_inflow_baseline"], - )) - - SCENARIOS = [ - ("constant", "Constant", "#0c99d9", "horizontal_rule"), - ("sine", "Sine wave","#16a34a", "show_chart"), - ("diurnal", "Diurnal", "#f59e0b", "schedule"), - ("storm", "Storm", "#dc2626", "thunderstorm"), - ] - for k, (key, label, color, icon) in enumerate(SCENARIOS): - ybtn = y + 100 + k * 50 - btn_id = f"btn_scn_{key}" - wrap_id = f"wrap_scn_{key}" - nodes.append(ui_button( - btn_id, TAB_UI, LANE_X[0], ybtn, g_inflow, - f"Scenario {label}", label, key, "str", - topic="scenario", color=color, icon=icon, - wires=[wrap_id], - )) - nodes.append(function_node( - wrap_id, TAB_UI, LANE_X[1] + 100, ybtn, - f"build scenario {key}", - f"msg.payload = '{key}';\n" - "return msg;", - outputs=1, wires=[["lout_inflow_scenario"]], - )) - nodes.append(link_out( - "lout_inflow_scenario", TAB_UI, LANE_X[2], y + 100, - CH_INFLOW_SCENARIO, target_in_ids=["lin_inflow_scenario"], - )) - - nodes.append(link_in( - "lin_evt_inflow", TAB_UI, LANE_X[3], y + 40, - CH_INFLOW_EVT, source_out_ids=["lout_evt_inflow"], - downstream=["dispatch_inflow"], - )) - nodes.append(function_node( - "dispatch_inflow", TAB_UI, LANE_X[4], y + 40, - "dispatch inflow", - "const p = msg.payload || {};\n" - "const ts = Date.now();\n" - "return [\n" - " { payload: (p.scenario || 'constant').toUpperCase() },\n" - " { payload: p.q_h != null ? Number(p.q_h).toFixed(1) + ' m³/h' : 'n/a' },\n" - " p.q_h != null ? { topic: 'Inflow', payload: Number(p.q_h), timestamp: ts } : null,\n" - "];", - outputs=3, - wires=[["ui_inflow_scn_text"], ["ui_inflow_value_text"], ["chart_trend_flow"]], - )) - nodes.append(ui_text( - "ui_inflow_scn_text", TAB_UI, LANE_X[5], y + 40, g_inflow, - "Active scenario", "Active scenario", "{{msg.payload}}", - )) - nodes.append(ui_text( - "ui_inflow_value_text", TAB_UI, LANE_X[5], y + 80, g_inflow, - "Live inflow", "Live inflow", "{{msg.payload}}", - )) - - # ---------- MODE + STATION COMMANDS ---------- - y = 380 - 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, - "Station mode", - "Station mode (Auto = level-based · Manual = slider Qd)", - on_value="levelbased", off_value="manual", topic="changemode", - wires=["lout_ps_mode_dash"], - )) - nodes.append(link_out( - "lout_ps_mode_dash", TAB_UI, LANE_X[1], y + 40, - CH_PS_MODE, target_in_ids=["lin_ps_mode_at_ps"], - )) - - nodes.append(ui_slider( - "ui_qd_slider", TAB_UI, LANE_X[0], y + 90, g_station, - "Manual Qd", - "Manual Qd (m³/h, manual mode only)", 0, 600, 5.0, - "manualDemand", wires=["lout_qd_dash"], - )) - nodes.append(link_out( - "lout_qd_dash", TAB_UI, LANE_X[1], y + 90, - CH_QD, target_in_ids=["lin_qd_at_ps"], - )) - - for k, (text, color, icon, lout_id, channel, - wrap_code) in enumerate([ - ("Start all pumps", "#16a34a", "play_arrow", - "lout_cmd_station_startup_dash", CH_STATION_START, - "msg.topic = 'execSequence';\n" - "msg.payload = { source:'GUI', action:'execSequence', " - "parameter:'startup' };\n" - "return msg;"), - ("Stop all pumps", "#ea580c", "stop", - "lout_cmd_station_shutdown_dash", CH_STATION_STOP, - "msg.topic = 'execSequence';\n" - "msg.payload = { source:'GUI', action:'execSequence', " - "parameter:'shutdown' };\n" - "return msg;"), - ("EMERGENCY STOP", "#dc2626", "stop_circle", - "lout_cmd_station_estop_dash", CH_STATION_ESTOP, - "msg.topic = 'emergencystop';\n" - "msg.payload = { source:'GUI', action:'emergencystop' };\n" - "return msg;"), - ]): - yk = y + 150 + k * 50 - btn_id = f"btn_station_{k}" - wrap_id = f"wrap_station_{k}" - 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]], - )) - - # ---------- BASIN REALTIME ---------- - y = 700 - nodes.append(comment("c_ui_basin", TAB_UI, LANE_X[2], y, - "── Basin realtime (gauges + text) ──", "")) - nodes.append(link_in( - "lin_evt_ps_dash", TAB_UI, LANE_X[0], y + 40, - CH_PS_EVT, source_out_ids=["lout_evt_ps"], - downstream=["dispatch_ps"], - )) - nodes.append(function_node( - "dispatch_ps", TAB_UI, LANE_X[1], y + 40, - "dispatch PS", - "const p = msg.payload || {};\n" - "const ts = Date.now();\n" - "// ΔQ = inflow − outflow in m³/h (positive = filling).\n" - "const dQ = (p.qInNum != null && p.qOutNum != null)\n" - " ? p.qInNum - p.qOutNum : null;\n" - "// Demand text formatting.\n" - "const demandStr = p.percControl != null\n" - " ? Number(p.percControl).toFixed(0) + '%' : 'n/a';\n" - "return [\n" - " { payload: String(p.direction || 'steady') },\n" - " { payload: String(p.level || 'n/a') },\n" - " { payload: String(p.volume || 'n/a') },\n" - " { payload: String(p.fillPct || 'n/a') },\n" - " { payload: String(p.netFlow || 'n/a') },\n" - " { payload: String(p.timeLeft || 'n/a') },\n" - " { payload: String(p.qIn || 'n/a') },\n" - " { payload: String(p.qOut || 'n/a') },\n" - " { payload: String(p.safetyState || 'normal') },\n" - " { payload: demandStr },\n" - " p.levelNum != null ? { payload: p.levelNum } : null,\n" - " p.fillPctNum != null ? { payload: p.fillPctNum } : null,\n" - " p.percControl != null ? { payload: p.percControl } : null,\n" - " p.levelNum != null ? { topic: 'Basin level', payload: p.levelNum, timestamp: ts } : null,\n" - " p.fillPctNum != null ? { topic: 'Fill %', payload: p.fillPctNum, timestamp: ts } : null,\n" - " p.qOutNum != null ? { topic: 'Outflow', payload: p.qOutNum, timestamp: ts } : null,\n" - " p.percControl != null ? { topic: 'PS demand', payload: p.percControl, timestamp: ts } : null,\n" - " dQ != null ? { topic: 'ΔQ', payload: dQ, timestamp: ts } : null,\n" - "];", - outputs=18, - wires=[ - ["ui_ps_direction"], - ["ui_ps_level"], - ["ui_ps_volume"], - ["ui_ps_fill"], - ["ui_ps_netflow"], - ["ui_ps_timeleft"], - ["ui_ps_qin"], - ["ui_ps_qout"], - ["ui_ps_safety"], - ["ui_ps_demand"], - ["gauge_basin_level"], - ["gauge_basin_fill"], - ["gauge_ps_demand"], - ["chart_trend_basin"], - ["chart_trend_basin"], - ["chart_trend_flow"], - ["chart_trend_demand"], - ["chart_trend_dq"], - ], - )) - nodes.append(ui_text("ui_ps_direction", TAB_UI, LANE_X[2], y + 40, g_basin, - "Direction", "Direction", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_level", TAB_UI, LANE_X[2], y + 70, g_basin, - "Basin level", "Basin level","{{msg.payload}}")) - nodes.append(ui_text("ui_ps_volume", TAB_UI, LANE_X[2], y + 100, g_basin, - "Basin volume","Basin volume","{{msg.payload}}")) - nodes.append(ui_text("ui_ps_fill", TAB_UI, LANE_X[2], y + 130, g_basin, - "Fill %", "Fill %", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_netflow", TAB_UI, LANE_X[2], y + 160, g_basin, - "Net flow", "Net flow", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_timeleft", TAB_UI, LANE_X[2], y + 190, g_basin, - "Time left", "Time to full/empty", - "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_qin", TAB_UI, LANE_X[2], y + 220, g_basin, - "Inflow", "Inflow", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_qout", TAB_UI, LANE_X[2], y + 250, g_basin, - "Outflow", "Outflow", "{{msg.payload}}")) - nodes.append(ui_text("ui_ps_safety", TAB_UI, LANE_X[2], y + 280, g_basin, - "Safety", "Safety state","{{msg.payload}}")) - nodes.append(ui_text("ui_ps_demand", TAB_UI, LANE_X[2], y + 310, g_basin, - "PS demand", "Process demand","{{msg.payload}}")) - - LEVEL_SEGMENTS = [ - {"color": "#f44336", "from": 0}, - {"color": "#ff9800", "from": 1.0}, - {"color": "#2196f3", "from": 2.0}, - {"color": "#ff9800", "from": 3.5}, - {"color": "#f44336", "from": 3.8}, - ] - FILL_SEGMENTS = [ - {"color": "#f44336", "from": 0}, - {"color": "#ff9800", "from": 10}, - {"color": "#4caf50", "from": 30}, - {"color": "#ff9800", "from": 80}, - {"color": "#f44336", "from": 95}, - ] - nodes.append(ui_gauge( - "gauge_basin_level", TAB_UI, LANE_X[3], y + 40, g_basin, - "Basin level gauge", "Level", "m", 0, BASIN_HEIGHT, - LEVEL_SEGMENTS, gtype="gauge-tank", suffix=" m", - width=3, height=4, order=10, - )) - nodes.append(ui_gauge( - "gauge_basin_fill", TAB_UI, LANE_X[3], y + 100, g_basin, - "Basin fill gauge", "Fill", "%", 0, 100, - FILL_SEGMENTS, gtype="gauge-34", suffix="%", - icon="water_drop", width=3, height=4, order=11, - )) - # PS process demand gauge — shows the % command PS sends to MGC. - DEMAND_SEGMENTS = [ - {"color": "#cccccc", "from": 0}, - {"color": "#0c99d9", "from": 5}, - {"color": "#16a34a", "from": 30}, - {"color": "#f59e0b", "from": 70}, - {"color": "#dc2626", "from": 95}, - ] - nodes.append(ui_gauge( - "gauge_ps_demand", TAB_UI, LANE_X[3], y + 160, g_basin, - "PS demand gauge", "PS demand", "%", 0, 100, - DEMAND_SEGMENTS, gtype="gauge-34", suffix="%", - icon="speed", width=3, height=4, order=12, - )) - - # ---------- MGC REALTIME ---------- - y = 1080 - nodes.append(comment("c_ui_mgc", TAB_UI, LANE_X[2], y, - "── MGC realtime ──", "")) - nodes.append(link_in( - "lin_evt_mgc_dash", TAB_UI, LANE_X[0], y + 40, - CH_MGC_EVT, source_out_ids=["lout_evt_mgc"], - downstream=["dispatch_mgc"], - )) - nodes.append(function_node( - "dispatch_mgc", TAB_UI, LANE_X[1], y + 40, - "dispatch MGC", - "const p = msg.payload || {};\n" - "return [\n" - " { payload: String(p.totalFlow || 'n/a') },\n" - " { payload: String(p.totalPower || 'n/a') },\n" - " { payload: String(p.efficiency || 'n/a') },\n" - " p.totalFlowNum != null ? { payload: p.totalFlowNum } : null,\n" - " p.totalPowerNum != null ? { payload: p.totalPowerNum } : null,\n" - "];", - outputs=5, - wires=[ - ["ui_mgc_total_flow"], - ["ui_mgc_total_power"], - ["ui_mgc_eff"], - ["gauge_mgc_flow"], - ["gauge_mgc_power"], - ], - )) - nodes.append(ui_text("ui_mgc_total_flow", TAB_UI, LANE_X[2], y + 40, g_mgc, - "MGC total flow", "Total flow", "{{msg.payload}}")) - nodes.append(ui_text("ui_mgc_total_power", TAB_UI, LANE_X[2], y + 70, g_mgc, - "MGC total power", "Total power", "{{msg.payload}}")) - nodes.append(ui_text("ui_mgc_eff", TAB_UI, LANE_X[2], y + 100, g_mgc, - "MGC efficiency", "Group efficiency", "{{msg.payload}}")) - nodes.append(ui_gauge( - "gauge_mgc_flow", TAB_UI, LANE_X[3], y + 40, g_mgc, - "MGC total flow gauge", "Total flow", "m³/h", 0, 600, - [ - {"color": "#cccccc", "from": 0}, - {"color": "#0c99d9", "from": 50}, - {"color": "#16a34a", "from": 200}, - {"color": "#f59e0b", "from": 500}, - ], - gtype="gauge-34", suffix=" m³/h", - width=3, height=4, order=10, - )) - nodes.append(ui_gauge( - "gauge_mgc_power", TAB_UI, LANE_X[3], y + 100, g_mgc, - "MGC total power gauge", "Total power", "kW", 0, 30, - [ - {"color": "#cccccc", "from": 0}, - {"color": "#0c99d9", "from": 1}, - {"color": "#16a34a", "from": 5}, - {"color": "#f59e0b", "from": 20}, - ], - gtype="gauge-34", suffix=" kW", - width=3, height=4, order=11, - )) - - # ---------- PER-PUMP REALTIME PANELS ---------- - y_pumps_start = 1340 - PUMP_FIELDS = [ - ("State", "state", "{{msg.payload}}"), - ("Mode", "mode", "{{msg.payload}}"), - ("Controller %", "ctrl", "{{msg.payload}}"), - ("Flow", "flow", "{{msg.payload}}"), - ("Power", "power", "{{msg.payload}}"), - ("p Upstream", "pUp", "{{msg.payload}}"), - ("p Downstream", "pDn", "{{msg.payload}}"), - ] - 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 * 480 - state_offset = i * 3 # A=0, B=3, C=6 - - nodes.append(comment(f"c_ui_{pump}", TAB_UI, LANE_X[2], y_p, - f"── {label} ──", "")) - nodes.append(link_in( - f"lin_evt_{pump}_dash", TAB_UI, LANE_X[0], y_p + 40, - CH_PUMP_EVT[pump], - source_out_ids=[f"lout_evt_{pump}"], - downstream=[f"dispatch_{pump}"], - )) - dispatch_code = ( - "const p = msg.payload || {};\n" - "const ts = Date.now();\n" - f"const OFF = {state_offset};\n" - "function stateNum(s) {\n" - " switch (s) {\n" - " case 'operational': return OFF + 2;\n" - " case 'starting':\n" - " case 'warmingup': return OFF + 1;\n" - " case 'stopping': return OFF + 1.5;\n" - " case 'coolingdown': return OFF + 0.5;\n" - " default: return OFF;\n" - " }\n" - "}\n" - "const sNum = p.state ? stateNum(p.state) : null;\n" - "return [\n" - " {payload: String(p.state || 'idle')},\n" - " {payload: String(p.mode || 'auto')},\n" - " {payload: String(p.ctrl || 'n/a')},\n" - " {payload: String(p.flow || 'n/a')},\n" - " {payload: String(p.power || 'n/a')},\n" - " {payload: String(p.pUp || 'n/a')},\n" - " {payload: String(p.pDn || 'n/a')},\n" - " p.flowNum != null ? {topic: '" + label + "', payload: p.flowNum, timestamp: ts} : null,\n" - " p.powerNum != null ? {topic: '" + label + "', payload: p.powerNum, timestamp: ts} : null,\n" - " p.pUpNum != null ? {topic: '" + label + " up', payload: p.pUpNum, timestamp: ts} : null,\n" - " p.pDnNum != null ? {topic: '" + label + " dn', payload: p.pDnNum, timestamp: ts} : null,\n" - " sNum != null ? {topic: '" + label + " state', payload: sNum, timestamp: ts} : null,\n" - "];" - ) - nodes.append(function_node( - f"dispatch_{pump}", TAB_UI, LANE_X[1], y_p + 40, - f"dispatch {label}", dispatch_code, - outputs=12, - wires=[ - [f"ui_{pump}_{f}"] for _, f, _ in PUMP_FIELDS - ] + [ - ["chart_trend_flow"], - ["chart_trend_power"], - ["chart_trend_pressure"], - ["chart_trend_pressure"], - ["chart_trend_states"], - ], - )) - for k, (label_txt, field, fmt) in enumerate(PUMP_FIELDS): - nodes.append(ui_text( - f"ui_{pump}_{field}", TAB_UI, LANE_X[2], y_p + 40 + k * 30, g, - f"{label} {label_txt}", label_txt, fmt, - )) - - 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}"], - )) - - 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"]], - )) - 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 CHARTS ---------- - y_trends = y_pumps_start + len(PUMPS) * 480 + 60 - nodes.append(comment("c_ui_trends", TAB_UI, LANE_X[2], y_trends, - "── Trend charts (1h rolling) ──", "")) - - nodes.append(ui_chart( - "chart_trend_basin", TAB_UI, LANE_X[3], y_trends + 40, - g_tr_basin, - "Basin level + fill %", "Basin level + fill", - width=12, height=8, y_axis_label="m / %", - remove_older="60", remove_older_unit="60", - remove_older_points="3600", - order=1, - )) - nodes.append(ui_chart( - "chart_trend_demand", TAB_UI, LANE_X[3], y_trends + 80, - g_tr_demand, - "PS process demand %", "PS demand", - width=12, height=6, y_axis_label="%", - remove_older="60", remove_older_unit="60", - remove_older_points="3600", - ymin=0, ymax=110, order=1, - )) - nodes.append(ui_chart( - "chart_trend_dq", TAB_UI, LANE_X[3], y_trends + 100, - g_tr_dq, - "ΔQ — inflow − outflow", "ΔQ", - width=12, height=6, y_axis_label="m³/h", - remove_older="60", remove_older_unit="60", - remove_older_points="3600", - order=1, - )) - # State timeline: each pump has a Y-axis "track" (A=0..2, B=3..5, C=6..8) - # with discrete values: 0/3/6 idle, 0.5/3.5/6.5 coolingdown, - # 1/4/7 starting/warmingup, 1.5/4.5/7.5 stopping, 2/5/8 operational. - # Step interpolation so transitions are sharp. - nodes.append(ui_chart( - "chart_trend_states", TAB_UI, LANE_X[3], y_trends + 120, - g_tr_states, - "Pump state timeline", "Pump states (A=0-2, B=3-5, C=6-8)", - width=12, height=6, y_axis_label="A B C tracks", - remove_older="60", remove_older_unit="60", - remove_older_points="3600", - ymin=-0.5, ymax=8.5, order=1, - interpolation="step", - )) - nodes.append(ui_chart( - "chart_trend_flow", TAB_UI, LANE_X[3], y_trends + 120, - g_tr_flow, - "Inflow / Outflow / Per-pump flow", "Flows", - width=12, height=8, y_axis_label="m³/h", - remove_older="60", remove_older_unit="60", - remove_older_points="3600", - order=1, - )) - nodes.append(ui_chart( - "chart_trend_power", TAB_UI, LANE_X[3], y_trends + 200, - g_tr_power, - "Per-pump power", "Power", - width=12, height=8, y_axis_label="kW", - remove_older="60", remove_older_unit="60", - remove_older_points="3600", - order=1, - )) - nodes.append(ui_chart( - "chart_trend_pressure", TAB_UI, LANE_X[3], y_trends + 280, - g_tr_press, - "Per-pump up/dn pressure", "Pressure", - width=12, height=8, y_axis_label="mbar", - remove_older="60", remove_older_unit="60", - remove_older_points="3600", - order=1, - )) - - return nodes - - -# --------------------------------------------------------------------------- -# Tab 3 — DEMO DRIVERS (inflow generator) -# --------------------------------------------------------------------------- -def build_drivers_tab(): - nodes = [] - nodes.append({ - "id": TAB_DRIVERS, "type": "tab", - "label": "🎛️ Demo Drivers", - "disabled": False, - "info": ( - "Inflow generator. The operator picks a SCENARIO (Constant / Sine /" - " Diurnal / Storm) on the dashboard and sets a BASELINE m³/h value." - " Every second this generator emits q_in to the PS based on the " - "active scenario + baseline.\n\n" - "Outflow is implicit: the pumps drain the basin via MGC." - ), - }) - - nodes.append(comment("c_drv_title", TAB_DRIVERS, LANE_X[2], 20, - "🎛️ DEMO DRIVERS — operator-driven inflow generator", "")) - - nodes.append(link_in( - "lin_inflow_scenario", TAB_DRIVERS, LANE_X[0], 100, - CH_INFLOW_SCENARIO, - source_out_ids=["lout_inflow_scenario", "lout_setup_inflow_scn"], - downstream=["inflow_state"], - )) - nodes.append(link_in( - "lin_inflow_baseline", TAB_DRIVERS, LANE_X[0], 140, - CH_INFLOW_BASELINE, - source_out_ids=["lout_inflow_baseline", "lout_setup_inflow_baseline"], - downstream=["inflow_state"], - )) - nodes.append(inject( - "inflow_tick", TAB_DRIVERS, LANE_X[0], 200, - "tick (1 Hz)", topic="tick", payload="", payload_type="date", - repeat="1", wires=["inflow_state"], - )) - - nodes.append(function_node( - "inflow_state", TAB_DRIVERS, LANE_X[2], 160, - "inflow scenario engine", - "let scenario = context.get('scenario') || 'constant';\n" - "let baseline = context.get('baseline');\n" - "if (baseline == null) baseline = 60;\n" - "\n" - "if (msg.topic === 'inflowBaseline') {\n" - " const v = Number(msg.payload);\n" - " if (Number.isFinite(v) && v >= 0) {\n" - " baseline = v;\n" - " context.set('baseline', baseline);\n" - " }\n" - " return null;\n" - "}\n" - "if (msg.topic === 'scenario') {\n" - " const s = String(msg.payload || '').toLowerCase();\n" - " if (['constant','sine','diurnal','storm'].includes(s)) {\n" - " scenario = s;\n" - " context.set('scenario', scenario);\n" - " }\n" - " return null;\n" - "}\n" - "const t = Date.now() / 1000;\n" - "let q_h;\n" - "switch (scenario) {\n" - " case 'sine': {\n" - " q_h = baseline * (1 + 0.5 * Math.sin(2 * Math.PI * t / 240));\n" - " break;\n" - " }\n" - " case 'diurnal': {\n" - " q_h = baseline * (1 + 0.6 * Math.sin(2 * Math.PI * t / 480 - Math.PI/2));\n" - " break;\n" - " }\n" - " case 'storm': {\n" - " const phase = (t % 240) / 240;\n" - " let factor;\n" - " if (phase < 0.15) factor = 1 + (4 / 0.15) * phase;\n" - " else factor = Math.max(1, 5 - (4 / 0.85) * (phase - 0.15));\n" - " q_h = baseline * factor;\n" - " break;\n" - " }\n" - " case 'constant':\n" - " default:\n" - " q_h = baseline;\n" - "}\n" - "q_h = Math.max(0, q_h);\n" - "const q_s = q_h / 3600;\n" - "return [\n" - " { topic: 'q_in', payload: q_s, unit: 'm3/s', timestamp: Date.now() },\n" - " { payload: { scenario, baseline, q_h, q_s, ts: Date.now() } },\n" - "];", - outputs=2, - wires=[["lout_qin_drivers"], ["lout_evt_inflow"]], - )) - nodes.append(link_out( - "lout_qin_drivers", TAB_DRIVERS, LANE_X[3], 140, - CH_QIN, target_in_ids=["lin_qin_at_ps"], - )) - nodes.append(link_out( - "lout_evt_inflow", TAB_DRIVERS, LANE_X[3], 180, - CH_INFLOW_EVT, target_in_ids=["lin_evt_inflow"], - )) - - 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:\n" - " • MGC scaling = normalized + mode = optimalcontrol\n" - " • all pumps mode = auto\n" - " • initial inflow baseline + scenario\n\n" - "Disable this tab in production." - ), - }) - - nodes.append(comment("c_setup_title", TAB_SETUP, LANE_X[2], 20, - "⚙️ SETUP & INIT — one-shot deploy-time injects", "")) - - nodes.append(inject( - "setup_mgc_scaling", TAB_SETUP, LANE_X[0], 100, - "MGC scaling = normalized", - topic="setScaling", payload="normalized", payload_type="str", - once=True, once_delay="1.5", - wires=["lout_setup_to_mgc"], - )) - nodes.append(inject( - "setup_mgc_mode", TAB_SETUP, LANE_X[0], 160, - "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], 130, - "setup:to-mgc", target_in_ids=["lin_setup_at_mgc"], - )) - - nodes.append(inject( - "setup_pumps_mode", TAB_SETUP, LANE_X[0], 240, - "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], 240, - "cmd:mode", target_in_ids=["lin_mode"], - )) - - nodes.append(inject( - "setup_inflow_baseline", TAB_SETUP, LANE_X[0], 320, - "inflow baseline = 25 m³/h (nominal)", - topic="inflowBaseline", payload="25", payload_type="num", - once=True, once_delay="2.5", - wires=["lout_setup_inflow_baseline"], - )) - nodes.append(link_out( - "lout_setup_inflow_baseline", TAB_SETUP, LANE_X[1], 320, - CH_INFLOW_BASELINE, target_in_ids=["lin_inflow_baseline"], - )) - nodes.append(inject( - "setup_inflow_scenario", TAB_SETUP, LANE_X[0], 380, - "inflow scenario = sine", - topic="scenario", payload="sine", payload_type="str", - once=True, once_delay="2.7", - wires=["lout_setup_inflow_scn"], - )) - nodes.append(link_out( - "lout_setup_inflow_scn", TAB_SETUP, LANE_X[1], 380, - CH_INFLOW_SCENARIO, target_in_ids=["lin_inflow_scenario"], - )) - - # Manual calibrate basin button — does NOT auto-fire on deploy. - # Auto-firing on every flow reload would clobber the basin level - # mid-cycle and reset the simulation, so we expose this as an inject - # the user clicks when they actually want to reset (e.g. starting a - # fresh demo run). To use: open the editor's Setup tab and click the - # button on this inject node. - nodes.append(inject( - "setup_calibrate_level", TAB_SETUP, LANE_X[0], 460, - "[manual] calibrate basin = 1.0 m (click to reset)", - topic="calibratePredictedLevel", payload="1.0", payload_type="num", - once=False, # <- never fire on deploy - wires=["lout_setup_calibrate"], - )) - nodes.append(link_out( - "lout_setup_calibrate", TAB_SETUP, LANE_X[1], 460, - "setup:calibrate-ps", target_in_ids=["lin_setup_calibrate_ps"], - )) - - return nodes - - -# --------------------------------------------------------------------------- -# Tab 5 — TELEMETRY (port 1 → InfluxDB line protocol → http POST) -# --------------------------------------------------------------------------- -def build_telemetry_tab(): - nodes = [] - nodes.append({ - "id": TAB_TLM, "type": "tab", - "label": "📈 Telemetry", - "disabled": False, - "info": ( - "InfluxDB writer: every EVOLV node's port-1 telemetry is fanned in " - "via the evt:tlm link channel, converted to line protocol, and " - "POSTed to InfluxDB v2 (org=evolv, bucket=telemetry).\n\n" - "Pattern adapted from docker/demo-flow.json." - ), - }) - - nodes.append(comment("c_tlm_title", TAB_TLM, LANE_X[2], 20, - "📈 TELEMETRY — InfluxDB writer", "")) - - nodes.append(link_in( - "lin_tlm", TAB_TLM, LANE_X[0], 100, - CH_TLM, - source_out_ids=_all_tlm_lout_ids(), - downstream=["fn_tlm_to_lp"], - )) - - # ── Pipeline ── - # link in → fn_tlm_to_lp (one line / msg) - # → join (string mode, joiner=\n, count=100 OR timeout 1s) - # → fn_tlm_post (set headers/url/method) - # → http request → fn_count - nodes.append(function_node( - "fn_tlm_to_lp", TAB_TLM, LANE_X[2], 100, - "→ InfluxDB line protocol", - "const p = msg.payload;\n" - "if (!p || !p.measurement || !p.fields) return null;\n" - "const esc = (s) => String(s)\n" - " .replace(/,/g, '\\\\,').replace(/ /g, '\\\\ ').replace(/=/g, '\\\\=');\n" - "const tags = Object.entries(p.tags || {})\n" - " .filter(([k, v]) => v !== undefined && v !== null && v !== '')\n" - " .map(([k, v]) => `${esc(k)}=${esc(v)}`).join(',');\n" - "const fieldPairs = Object.entries(p.fields)\n" - " .filter(([k, v]) => v !== undefined && v !== null)\n" - " .map(([k, v]) => {\n" - " if (typeof v === 'number' && Number.isFinite(v)) return `${esc(k)}=${v}`;\n" - " if (typeof v === 'boolean') return `${esc(k)}=${v}`;\n" - " return `${esc(k)}=\"${String(v).replace(/\"/g, '\\\\\"')}\"`;\n" - " });\n" - "if (fieldPairs.length === 0) return null;\n" - "const ts = Date.now() * 1000000;\n" - "msg.payload = `${esc(p.measurement)}${tags ? ',' + tags : ''} `\n" - " + `${fieldPairs.join(',')} ${ts}`;\n" - "// Hint the join node to fire on size or timeout.\n" - "msg.topic = 'tlm';\n" - "return msg;", - outputs=1, wires=[["join_tlm"]], - )) - - # Idiomatic Node-RED batching: join collects messages into a single - # newline-joined string, flushed every `count` messages OR `timeout` - # seconds, whichever fires first. - nodes.append({ - "id": "join_tlm", "type": "join", "z": TAB_TLM, - "name": "batch (200 lines / 2 s)", - "mode": "custom", - "build": "string", - "property": "payload", "propertyType": "msg", - "key": "topic", - "joiner": "\\n", "joinerType": "str", - "accumulate": False, - "timeout": "2", - "count": "200", - "reduceRight": False, - "reduceExp": "", "reduceInit": "", - "reduceInitType": "", "reduceFixup": "", - "x": LANE_X[3], "y": 100, - "wires": [["fn_tlm_post"]], - }) - - nodes.append(function_node( - "fn_tlm_post", TAB_TLM, LANE_X[3] + 200, 100, - "wrap as InfluxDB POST", - "// Count lines for status reporting.\n" - "const body = String(msg.payload || '');\n" - "const lineCount = body ? body.split('\\n').length : 0;\n" - "if (lineCount === 0) return null;\n" - "msg.lineCount = lineCount;\n" - "msg.headers = {\n" - " 'Authorization': 'Token evolv-dev-token',\n" - " 'Content-Type': 'text/plain'\n" - "};\n" - "msg.url = 'http://influxdb:8086/api/v2/write?org=evolv&bucket=telemetry&precision=ns';\n" - "msg.method = 'POST';\n" - "return msg;", - outputs=1, wires=[["http_tlm"]], - )) - - nodes.append({ - "id": "http_tlm", "type": "http request", "z": TAB_TLM, - "name": "Write InfluxDB", - "method": "use", "ret": "txt", "paytoqs": "ignore", - "url": "", "tls": "", "persist": False, "proxy": "", - "authType": "", "senderr": False, - "x": LANE_X[4] + 80, "y": 100, - "wires": [["fn_tlm_count"]], - }) - - nodes.append(function_node( - "fn_tlm_count", TAB_TLM, LANE_X[5], 100, - "Count writes", - "const lines = Number(msg.lineCount) || 0;\n" - "const writes = (global.get('influx_writes') || 0) + 1;\n" - "const totalLines = (global.get('influx_lines') || 0) + lines;\n" - "global.set('influx_writes', writes);\n" - "global.set('influx_lines', totalLines);\n" - "const errors = global.get('influx_errors') || 0;\n" - "if (msg.statusCode && msg.statusCode >= 400) {\n" - " global.set('influx_errors', errors + 1);\n" - " node.status({fill:'red', shape:'ring',\n" - " text:`ERR ${errors+1}: ${msg.statusCode}`});\n" - "} else {\n" - " node.status({fill:'green', shape:'dot',\n" - " text:`${writes} POSTs · ${totalLines} lines (${errors} err)`});\n" - "}\n" - "return null;", - outputs=1, wires=[[]], - )) - - return nodes - - -def _all_tlm_lout_ids(): - """Every link-out id that emits to evt:tlm. Listed explicitly for stable - cross-tab wiring.""" - ids = [] - for pump in PUMPS: - ids.append(f"lout_tlm_{pump}") - for suffix in ("u", "d", "f", "p"): - ids.append(f"lout_tlm_meas_{pump}_{suffix}") - ids.append("lout_tlm_mgc") - ids.append("lout_tlm_ps") - return ids - - -# --------------------------------------------------------------------------- -# Assemble + emit -# --------------------------------------------------------------------------- -def main(): - nodes = ( - build_process_tab() - + build_ui_tab() - + build_drivers_tab() - + build_setup_tab() - + build_telemetry_tab() - ) - json.dump(nodes, sys.stdout, indent=2) - sys.stdout.write("\n") - - -if __name__ == "__main__": - main() diff --git a/examples/pumpingstation-complete-example/flow.json b/examples/pumpingstation-complete-example/flow.json deleted file mode 100644 index 0526bd5..0000000 --- a/examples/pumpingstation-complete-example/flow.json +++ /dev/null @@ -1,5462 +0,0 @@ -[ - { - "id": "tab_process", - "type": "tab", - "label": "🏭 Process Plant", - "disabled": false, - "info": "EVOLV plant model: 3 rotatingMachines (each with 4 measurement nodes — upstream P, downstream P, flow, power), MGC, PS.\n\nPer pump there is a 'physics' function node that consumes the pump's own port-0 stream PLUS PS port-0 (basin level) and drives all 4 measurement nodes with physically-coupled values (upstream P from basin head; downstream P from pump state + flow; flow/power mirror predicted with Gaussian noise). This lives on this tab so the plant model is self-contained.\n\nAll cross-tab wires use named link-in / link-out channels." - }, - { - "id": "c_process_title", - "type": "comment", - "z": "tab_process", - "name": "🏭 PROCESS PLANT — EVOLV nodes + per-pump physics feeders", - "info": "", - "x": 640, - "y": 20, - "wires": [] - }, - { - "id": "c_pump_a", - "type": "comment", - "z": "tab_process", - "name": "── Pump A ── (pump + 4 sensors + physics feeder)", - "info": "Up/Dn pressure + flow + power sensors register as children of the pump. The physics_ function takes the pump's own port-0 stream and PS port-0 (basin level) and drives all 4 sensors with physically-coupled values.", - "x": 640, - "y": 80, - "wires": [] - }, - { - "id": "meas_pump_a_u", - "type": "measurement", - "z": "tab_process", - "name": "A-Up", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 4000, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_a-u", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "A-U", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "upstream", - "positionIcon": "→", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 120, - "wires": [ - [], - [ - "lout_tlm_meas_pump_a_u" - ], - [ - "pump_a" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_a_u", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 120, - "wires": [] - }, - { - "id": "meas_pump_a_d", - "type": "measurement", - "z": "tab_process", - "name": "A-Dn", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 4000, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_a-d", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "A-D", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "downstream", - "positionIcon": "←", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 155, - "wires": [ - [], - [ - "lout_tlm_meas_pump_a_d" - ], - [ - "pump_a" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_a_d", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 155, - "wires": [] - }, - { - "id": "meas_pump_a_f", - "type": "measurement", - "z": "tab_process", - "name": "A-Flow", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 250, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_a-f", - "supplier": "endress", - "category": "sensor", - "assetType": "flow", - "model": "endress-promag-50", - "unit": "m3/h", - "assetTagNumber": "A-F", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "downstream", - "positionIcon": "←", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 190, - "wires": [ - [], - [ - "lout_tlm_meas_pump_a_f" - ], - [ - "pump_a" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_a_f", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 190, - "wires": [] - }, - { - "id": "meas_pump_a_p", - "type": "measurement", - "z": "tab_process", - "name": "A-Pwr", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 30, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_a-p", - "supplier": "siemens", - "category": "sensor", - "assetType": "power", - "model": "siemens-sentron-pac4200", - "unit": "kW", - "assetTagNumber": "A-P", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "atEquipment", - "positionIcon": "⊥", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 225, - "wires": [ - [], - [ - "lout_tlm_meas_pump_a_p" - ], - [ - "pump_a" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_a_p", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 225, - "wires": [] - }, - { - "id": "pump_a", - "type": "rotatingMachine", - "z": "tab_process", - "name": "Pump A", - "speed": "200", - "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", - "tickIntervalMs": 2000, - "positionVsParent": "atEquipment", - "positionIcon": "⊥", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 900, - "y": 170, - "wires": [ - [ - "format_pump_a", - "physics_pump_a" - ], - [ - "lout_tlm_pump_a" - ], - [ - "mgc_pumps" - ] - ] - }, - { - "id": "lout_tlm_pump_a", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 900, - "y": 210, - "wires": [] - }, - { - "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);\n// Throttle dashboard fan-out to ≤ 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\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 ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m³/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1160, - "y": 170, - "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": 170, - "wires": [] - }, - { - "id": "physics_pump_a", - "type": "function", - "z": "tab_process", - "name": "physics Pump A → 4 sensors", - "func": "const c = context.get('c') || {};\nfunction find(o, prefix) {\n for (const k in o) { if (k.indexOf(prefix) === 0) return o[k]; }\n return null;\n}\nfunction gauss(sigma) {\n let s = 0;\n for (let i = 0; i < 12; i++) s += Math.random();\n return (s - 6) * sigma;\n}\n\nif (msg.from === 'ps') {\n const psSnap = c.ps || {};\n Object.assign(psSnap, msg.payload || {});\n c.ps = psSnap;\n const lvl = find(psSnap, 'level.predicted.atequipment.')\n ?? find(psSnap, 'level.measured.atequipment.');\n if (lvl != null) c.basinLevel = Number(lvl);\n context.set('c', c);\n return null;\n}\n\nconst pumpSnap = c.pump || {};\nObject.assign(pumpSnap, msg.payload || {});\nc.pump = pumpSnap;\ncontext.set('c', c);\n// Throttle: 1 Hz sensor updates are plenty for the demo; the\n// pump emits on every state change (5+/sec while cycling).\nconst _now = Date.now();\nconst _last = context.get('_lastEmit') || 0;\nif (_now - _last < 1000) return null;\ncontext.set('_lastEmit', _now);\n\nconst state = pumpSnap.state || 'idle';\n// 'isRunning' = the rotor is spinning (any non-idle, non-cooled state).\n// MGC retargets flow on every tick, so the pump spends most of its\n// time in 'accelerating' or 'decelerating', not 'operational'. Those\n// transient states are still moving water — flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') — `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_a', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_a_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure — same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure — driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m³/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n", - "outputs": 4, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1160, - "y": 240, - "wires": [ - [ - "meas_pump_a_u" - ], - [ - "meas_pump_a_d" - ], - [ - "meas_pump_a_f" - ], - [ - "meas_pump_a_p" - ] - ] - }, - { - "id": "lin_setpoint_pump_a", - "type": "link in", - "z": "tab_process", - "name": "cmd:setpoint-A", - "links": [ - "lout_setpoint_pump_a_dash" - ], - "x": 120, - "y": 140, - "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": 140, - "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": 190, - "wires": [ - [ - "pump_a" - ] - ] - }, - { - "id": "c_pump_b", - "type": "comment", - "z": "tab_process", - "name": "── Pump B ── (pump + 4 sensors + physics feeder)", - "info": "Up/Dn pressure + flow + power sensors register as children of the pump. The physics_ function takes the pump's own port-0 stream and PS port-0 (basin level) and drives all 4 sensors with physically-coupled values.", - "x": 640, - "y": 360, - "wires": [] - }, - { - "id": "meas_pump_b_u", - "type": "measurement", - "z": "tab_process", - "name": "B-Up", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 4000, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_b-u", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "B-U", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "upstream", - "positionIcon": "→", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 400, - "wires": [ - [], - [ - "lout_tlm_meas_pump_b_u" - ], - [ - "pump_b" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_b_u", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 400, - "wires": [] - }, - { - "id": "meas_pump_b_d", - "type": "measurement", - "z": "tab_process", - "name": "B-Dn", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 4000, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_b-d", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "B-D", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "downstream", - "positionIcon": "←", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 435, - "wires": [ - [], - [ - "lout_tlm_meas_pump_b_d" - ], - [ - "pump_b" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_b_d", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 435, - "wires": [] - }, - { - "id": "meas_pump_b_f", - "type": "measurement", - "z": "tab_process", - "name": "B-Flow", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 250, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_b-f", - "supplier": "endress", - "category": "sensor", - "assetType": "flow", - "model": "endress-promag-50", - "unit": "m3/h", - "assetTagNumber": "B-F", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "downstream", - "positionIcon": "←", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 470, - "wires": [ - [], - [ - "lout_tlm_meas_pump_b_f" - ], - [ - "pump_b" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_b_f", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 470, - "wires": [] - }, - { - "id": "meas_pump_b_p", - "type": "measurement", - "z": "tab_process", - "name": "B-Pwr", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 30, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_b-p", - "supplier": "siemens", - "category": "sensor", - "assetType": "power", - "model": "siemens-sentron-pac4200", - "unit": "kW", - "assetTagNumber": "B-P", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "atEquipment", - "positionIcon": "⊥", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 505, - "wires": [ - [], - [ - "lout_tlm_meas_pump_b_p" - ], - [ - "pump_b" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_b_p", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 505, - "wires": [] - }, - { - "id": "pump_b", - "type": "rotatingMachine", - "z": "tab_process", - "name": "Pump B", - "speed": "200", - "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", - "tickIntervalMs": 2000, - "positionVsParent": "atEquipment", - "positionIcon": "⊥", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 900, - "y": 450, - "wires": [ - [ - "format_pump_b", - "physics_pump_b" - ], - [ - "lout_tlm_pump_b" - ], - [ - "mgc_pumps" - ] - ] - }, - { - "id": "lout_tlm_pump_b", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 900, - "y": 490, - "wires": [] - }, - { - "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);\n// Throttle dashboard fan-out to ≤ 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\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 ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m³/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1160, - "y": 450, - "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": 450, - "wires": [] - }, - { - "id": "physics_pump_b", - "type": "function", - "z": "tab_process", - "name": "physics Pump B → 4 sensors", - "func": "const c = context.get('c') || {};\nfunction find(o, prefix) {\n for (const k in o) { if (k.indexOf(prefix) === 0) return o[k]; }\n return null;\n}\nfunction gauss(sigma) {\n let s = 0;\n for (let i = 0; i < 12; i++) s += Math.random();\n return (s - 6) * sigma;\n}\n\nif (msg.from === 'ps') {\n const psSnap = c.ps || {};\n Object.assign(psSnap, msg.payload || {});\n c.ps = psSnap;\n const lvl = find(psSnap, 'level.predicted.atequipment.')\n ?? find(psSnap, 'level.measured.atequipment.');\n if (lvl != null) c.basinLevel = Number(lvl);\n context.set('c', c);\n return null;\n}\n\nconst pumpSnap = c.pump || {};\nObject.assign(pumpSnap, msg.payload || {});\nc.pump = pumpSnap;\ncontext.set('c', c);\n// Throttle: 1 Hz sensor updates are plenty for the demo; the\n// pump emits on every state change (5+/sec while cycling).\nconst _now = Date.now();\nconst _last = context.get('_lastEmit') || 0;\nif (_now - _last < 1000) return null;\ncontext.set('_lastEmit', _now);\n\nconst state = pumpSnap.state || 'idle';\n// 'isRunning' = the rotor is spinning (any non-idle, non-cooled state).\n// MGC retargets flow on every tick, so the pump spends most of its\n// time in 'accelerating' or 'decelerating', not 'operational'. Those\n// transient states are still moving water — flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') — `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_b', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_b_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure — same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure — driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m³/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n", - "outputs": 4, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1160, - "y": 520, - "wires": [ - [ - "meas_pump_b_u" - ], - [ - "meas_pump_b_d" - ], - [ - "meas_pump_b_f" - ], - [ - "meas_pump_b_p" - ] - ] - }, - { - "id": "lin_setpoint_pump_b", - "type": "link in", - "z": "tab_process", - "name": "cmd:setpoint-B", - "links": [ - "lout_setpoint_pump_b_dash" - ], - "x": 120, - "y": 420, - "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": 420, - "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": 470, - "wires": [ - [ - "pump_b" - ] - ] - }, - { - "id": "c_pump_c", - "type": "comment", - "z": "tab_process", - "name": "── Pump C ── (pump + 4 sensors + physics feeder)", - "info": "Up/Dn pressure + flow + power sensors register as children of the pump. The physics_ function takes the pump's own port-0 stream and PS port-0 (basin level) and drives all 4 sensors with physically-coupled values.", - "x": 640, - "y": 640, - "wires": [] - }, - { - "id": "meas_pump_c_u", - "type": "measurement", - "z": "tab_process", - "name": "C-Up", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 4000, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_c-u", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "C-U", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "upstream", - "positionIcon": "→", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 680, - "wires": [ - [], - [ - "lout_tlm_meas_pump_c_u" - ], - [ - "pump_c" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_c_u", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 680, - "wires": [] - }, - { - "id": "meas_pump_c_d", - "type": "measurement", - "z": "tab_process", - "name": "C-Dn", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 4000, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_c-d", - "supplier": "vega", - "category": "sensor", - "assetType": "pressure", - "model": "vega-pressure-10", - "unit": "mbar", - "assetTagNumber": "C-D", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "downstream", - "positionIcon": "←", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 715, - "wires": [ - [], - [ - "lout_tlm_meas_pump_c_d" - ], - [ - "pump_c" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_c_d", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 715, - "wires": [] - }, - { - "id": "meas_pump_c_f", - "type": "measurement", - "z": "tab_process", - "name": "C-Flow", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 250, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_c-f", - "supplier": "endress", - "category": "sensor", - "assetType": "flow", - "model": "endress-promag-50", - "unit": "m3/h", - "assetTagNumber": "C-F", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "downstream", - "positionIcon": "←", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 750, - "wires": [ - [], - [ - "lout_tlm_meas_pump_c_f" - ], - [ - "pump_c" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_c_f", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 750, - "wires": [] - }, - { - "id": "meas_pump_c_p", - "type": "measurement", - "z": "tab_process", - "name": "C-Pwr", - "mode": "analog", - "channels": "[]", - "scaling": false, - "i_min": 0, - "i_max": 1, - "i_offset": 0, - "o_min": 0, - "o_max": 30, - "simulator": false, - "smooth_method": "mean", - "count": "3", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "uuid": "sensor-pump_c-p", - "supplier": "siemens", - "category": "sensor", - "assetType": "power", - "model": "siemens-sentron-pac4200", - "unit": "kW", - "assetTagNumber": "C-P", - "enableLog": false, - "logLevel": "warn", - "tickIntervalMs": 2000, - "positionVsParent": "atEquipment", - "positionIcon": "⊥", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 380, - "y": 785, - "wires": [ - [], - [ - "lout_tlm_meas_pump_c_p" - ], - [ - "pump_c" - ] - ] - }, - { - "id": "lout_tlm_meas_pump_c_p", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 580, - "y": 785, - "wires": [] - }, - { - "id": "pump_c", - "type": "rotatingMachine", - "z": "tab_process", - "name": "Pump C", - "speed": "200", - "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", - "tickIntervalMs": 2000, - "positionVsParent": "atEquipment", - "positionIcon": "⊥", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "x": 900, - "y": 730, - "wires": [ - [ - "format_pump_c", - "physics_pump_c" - ], - [ - "lout_tlm_pump_c" - ], - [ - "mgc_pumps" - ] - ] - }, - { - "id": "lout_tlm_pump_c", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 900, - "y": 770, - "wires": [] - }, - { - "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);\n// Throttle dashboard fan-out to ≤ 2 Hz. The pump emits on\n// every state change (multiple per sec while cycling); the\n// dashboard doesn't need that resolution and the websocket\n// fan-out chokes the browser.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\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 ctrl = find('ctrl.predicted.atequipment.');\nconst pUp = find('pressure.measured.upstream.');\nconst pDn = find('pressure.measured.downstream.');\nmsg.payload = {\n state: c.state || 'idle',\n mode: c.mode || 'auto',\n ctrl: ctrl != null ? Number(ctrl ).toFixed(1) + '%' : 'n/a',\n flow: flow != null ? Number(flow ).toFixed(1) + ' m³/h' : 'n/a',\n power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n pUp: pUp != null ? Number(pUp ).toFixed(0) + ' mbar' : 'n/a',\n pDn: pDn != null ? Number(pDn ).toFixed(0) + ' mbar' : 'n/a',\n ctrlNum: ctrl != null ? Number(ctrl ) : null,\n flowNum: flow != null ? Number(flow ) : null,\n powerNum: power != null ? Number(power) : null,\n pUpNum: pUp != null ? Number(pUp ) : null,\n pDnNum: pDn != null ? Number(pDn ) : null,\n // Pump is moving water any time it's between startup and shutdown, not\n // just during steady operational. accelerate/decelerate/warmup count.\n isRunning: ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(c.state),\n};\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1160, - "y": 730, - "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": 730, - "wires": [] - }, - { - "id": "physics_pump_c", - "type": "function", - "z": "tab_process", - "name": "physics Pump C → 4 sensors", - "func": "const c = context.get('c') || {};\nfunction find(o, prefix) {\n for (const k in o) { if (k.indexOf(prefix) === 0) return o[k]; }\n return null;\n}\nfunction gauss(sigma) {\n let s = 0;\n for (let i = 0; i < 12; i++) s += Math.random();\n return (s - 6) * sigma;\n}\n\nif (msg.from === 'ps') {\n const psSnap = c.ps || {};\n Object.assign(psSnap, msg.payload || {});\n c.ps = psSnap;\n const lvl = find(psSnap, 'level.predicted.atequipment.')\n ?? find(psSnap, 'level.measured.atequipment.');\n if (lvl != null) c.basinLevel = Number(lvl);\n context.set('c', c);\n return null;\n}\n\nconst pumpSnap = c.pump || {};\nObject.assign(pumpSnap, msg.payload || {});\nc.pump = pumpSnap;\ncontext.set('c', c);\n// Throttle: 1 Hz sensor updates are plenty for the demo; the\n// pump emits on every state change (5+/sec while cycling).\nconst _now = Date.now();\nconst _last = context.get('_lastEmit') || 0;\nif (_now - _last < 1000) return null;\ncontext.set('_lastEmit', _now);\n\nconst state = pumpSnap.state || 'idle';\n// 'isRunning' = the rotor is spinning (any non-idle, non-cooled state).\n// MGC retargets flow on every tick, so the pump spends most of its\n// time in 'accelerating' or 'decelerating', not 'operational'. Those\n// transient states are still moving water — flow/power sensors must\n// publish non-zero values during them or the measurement nodes go\n// quiet (formatMsg skips emits on no-diff).\nconst isRunning = ['operational','starting','warmingup','accelerating','decelerating','stopping'].includes(state);\n// 'pumpFlow' (not 'flow') — `flow` is the Node-RED flow-context object.\nconst pumpFlow = Number(find(pumpSnap, 'flow.predicted.downstream.'));\nconst pumpPower = Number(find(pumpSnap, 'power.predicted.atequipment.'));\nconst basinLevel = c.basinLevel != null ? Number(c.basinLevel) : 0;\n\n// Publish this pump's contribution to the flow-context shared\n// header so the other physics feeders can compute total flow.\nflow.set('pump_flow_c', isRunning && Number.isFinite(pumpFlow) ? pumpFlow : 0);\nflow.set('pump_flow_c_state', state);\nconst flowA = Number(flow.get('pump_flow_a') || 0);\nconst flowB = Number(flow.get('pump_flow_b') || 0);\nconst flowC = Number(flow.get('pump_flow_c') || 0);\nconst totalFlow = flowA + flowB + flowC;\n\nconst HEAD_M = Math.max(0, basinLevel - 0.3);\n// Suction (basin) header pressure — same physical value for all\n// pumps; per-pump sensor noise added independently.\nconst p_upstream_clean = 98.1 * HEAD_M;\nlet p_upstream = Math.max(0, p_upstream_clean + gauss(2.5));\n\n// Discharge (header) pressure — driven by TOTAL flow leaving the\n// manifold, NOT this pump's individual flow. Static head 12 m\n// + quadratic system curve scaled so totalFlow=300 m³/h gives\n// ~full dynamic head.\nconst STATIC_MBAR = 12 * 98.1;\nconst DYN_MBAR_MAX = 12 * 98.1;\nconst TOTAL_FLOW_MAX = 300;\nconst ratio = Math.min(1, totalFlow / TOTAL_FLOW_MAX);\nconst p_downstream_header = STATIC_MBAR + ratio * ratio * DYN_MBAR_MAX;\n// Publish the clean header value to flow context so the MGC's\n// header-pressure measurement child can read it.\nflow.set('header_p_downstream', p_downstream_header);\nflow.set('header_p_upstream', p_upstream_clean);\n// Per-pump downstream sensor: header value with local sensor noise.\nlet p_downstream = Math.max(0, p_downstream_header + gauss(8));\n\nconst flowMeas = (isRunning && Number.isFinite(pumpFlow))\n ? Math.max(0, pumpFlow + gauss(Math.max(0.5, pumpFlow * 0.01)))\n : 0;\n\nconst powerMeas = (isRunning && Number.isFinite(pumpPower))\n ? Math.max(0, pumpPower + gauss(Math.max(0.05, pumpPower * 0.005)))\n : 0;\n\nreturn [\n { topic: 'measurement', payload: p_upstream },\n { topic: 'measurement', payload: p_downstream },\n { topic: 'measurement', payload: flowMeas },\n { topic: 'measurement', payload: powerMeas },\n];\n", - "outputs": 4, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1160, - "y": 800, - "wires": [ - [ - "meas_pump_c_u" - ], - [ - "meas_pump_c_d" - ], - [ - "meas_pump_c_f" - ], - [ - "meas_pump_c_p" - ] - ] - }, - { - "id": "lin_setpoint_pump_c", - "type": "link in", - "z": "tab_process", - "name": "cmd:setpoint-C", - "links": [ - "lout_setpoint_pump_c_dash" - ], - "x": 120, - "y": 700, - "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": 700, - "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": 750, - "wires": [ - [ - "pump_c" - ] - ] - }, - { - "id": "c_mgc", - "type": "comment", - "z": "tab_process", - "name": "── MGC ── (orchestrates the 3 pumps via optimalcontrol)", - "info": "", - "x": 640, - "y": 920, - "wires": [] - }, - { - "id": "mgc_pumps", - "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": true, - "logLevel": "debug", - "tickIntervalMs": 2000, - "positionVsParent": "atEquipment", - "positionIcon": "⊥", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "x": 900, - "y": 1000, - "wires": [ - [ - "format_mgc" - ], - [ - "lout_tlm_mgc" - ], - [ - "ps_basin" - ] - ] - }, - { - "id": "lout_tlm_mgc", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 900, - "y": 1040, - "wires": [] - }, - { - "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);\n// Throttle: MGC fires on every distribution change.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\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³/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 efficiencyNum: eff != null ? Number(eff) : null,\n};\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1160, - "y": 1000, - "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": 1000, - "wires": [] - }, - { - "id": "c_ps", - "type": "comment", - "z": "tab_process", - "name": "── Pumping Station ── (basin model, levelbased control)", - "info": "", - "x": 640, - "y": 1200, - "wires": [] - }, - { - "id": "lin_qin_at_ps", - "type": "link in", - "z": "tab_process", - "name": "cmd:q_in", - "links": [ - "lout_qin_drivers" - ], - "x": 120, - "y": 1240, - "wires": [ - [ - "ps_basin" - ] - ] - }, - { - "id": "lin_qd_at_ps", - "type": "link in", - "z": "tab_process", - "name": "cmd:Qd", - "links": [ - "lout_qd_dash" - ], - "x": 120, - "y": 1280, - "wires": [ - [ - "qd_to_ps_wrap" - ] - ] - }, - { - "id": "qd_to_ps_wrap", - "type": "function", - "z": "tab_process", - "name": "wrap slider → PS Qd", - "func": "msg.topic = 'Qd';\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 380, - "y": 1280, - "wires": [ - [ - "ps_basin" - ] - ] - }, - { - "id": "lin_ps_mode_at_ps", - "type": "link in", - "z": "tab_process", - "name": "cmd:ps-mode", - "links": [ - "lout_ps_mode_dash" - ], - "x": 120, - "y": 1320, - "wires": [ - [ - "ps_basin" - ] - ] - }, - { - "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", - "tickIntervalMs": 2000, - "positionVsParent": "atEquipment", - "positionIcon": "⊥", - "hasDistance": false, - "distance": 0, - "distanceUnit": "m", - "distanceDescription": "", - "processOutputFormat": "process", - "dbaseOutputFormat": "influxdb", - "controlMode": "levelbased", - "basinVolume": 50.0, - "basinHeight": 4.0, - "inflowLevel": 2.5, - "outflowLevel": 0.3, - "overflowLevel": 3.8, - "inletPipeDiameter": 0.3, - "outletPipeDiameter": 0.3, - "minLevel": 0.5, - "startLevel": 2.5, - "stopLevel": 2.0, - "deadZoneKeepAlivePercent": 1, - "maxLevel": 3.5, - "refHeight": "NAP", - "minHeightBasedOn": "outlet", - "basinBottomRef": 0, - "staticHead": 12, - "maxDischargeHead": 24, - "pipelineLength": 80, - "defaultFluid": "wastewater", - "temperatureReferenceDegC": 15, - "maxInflowRate": 200, - "enableDryRunProtection": true, - "enableOverfillProtection": true, - "dryRunThresholdPercent": 5, - "overfillThresholdPercent": 95, - "timeleftToFullOrEmptyThresholdSeconds": 0, - "x": 900, - "y": 1280, - "wires": [ - [ - "format_ps", - "ps_to_physics" - ], - [ - "lout_tlm_ps" - ] - ] - }, - { - "id": "lout_tlm_ps", - "type": "link out", - "z": "tab_process", - "name": "evt:tlm", - "mode": "link", - "links": [ - "lin_tlm" - ], - "x": 900, - "y": 1320, - "wires": [] - }, - { - "id": "ps_to_physics", - "type": "function", - "z": "tab_process", - "name": "ps → fan basin level to 3 physics feeders", - "func": "const out = { from: 'ps', payload: msg.payload };\nreturn [out, out, out];", - "outputs": 3, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1160, - "y": 1330, - "wires": [ - [ - "physics_pump_a" - ], - [ - "physics_pump_b" - ], - [ - "physics_pump_c" - ] - ] - }, - { - "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);\n// Throttle: PS emits frequently in levelbased mode.\nconst now = Date.now();\nconst last = context.get('_lastEmit') || 0;\nif (now - last < 1000) return null;\ncontext.set('_lastEmit', now);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst MAX_VOL = 50.0;\nconst lvl = find('level.predicted.');\nconst vol = find('volume.predicted.');\nconst qIn = find('flow.predicted.in.');\nconst qOut = find('flow.predicted.out.');\nconst netFlowRate = find('netFlowRate.predicted.');\nconst fillPct = vol != null\n ? Math.min(100, Math.max(0, Math.round(Number(vol) / MAX_VOL * 100)))\n : null;\nconst netM3h = netFlowRate != null ? Number(netFlowRate) * 3600 : null;\nconst seconds = (c.timeleft != null && Number.isFinite(Number(c.timeleft)))\n ? Number(c.timeleft) : null;\nconst timeStr = seconds != null\n ? (seconds > 60 ? Math.round(seconds/60) + ' min'\n : Math.round(seconds) + ' s')\n : 'n/a';\nmsg.payload = {\n direction: c.direction || 'steady',\n level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n volume: vol != null ? Number(vol).toFixed(1) + ' m³' : 'n/a',\n fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n netFlow: netM3h != null ? netM3h.toFixed(0) + ' m³/h' : 'n/a',\n timeLeft: timeStr,\n qIn: qIn != null ? (Number(qIn ) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m³/h' : 'n/a',\n levelNum: lvl != null ? Number(lvl) : null,\n volumeNum: vol != null ? Number(vol) : null,\n fillPctNum: fillPct,\n netFlowNum: netM3h,\n percControl: c.percControl != null ? Number(c.percControl) : null,\n qInNum: qIn != null ? Number(qIn ) * 3600 : null,\n qOutNum: qOut != null ? Number(qOut) * 3600 : null,\n safetyState: c.safetyState || 'normal',\n};\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1160, - "y": 1280, - "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": 1280, - "wires": [] - }, - { - "id": "c_mode_bcast", - "type": "comment", - "z": "tab_process", - "name": "── Mode broadcast ──", - "info": "", - "x": 640, - "y": 1420, - "wires": [] - }, - { - "id": "lin_mode", - "type": "link in", - "z": "tab_process", - "name": "cmd:mode", - "links": [ - "lout_mode_setup" - ], - "x": 120, - "y": 1480, - "wires": [ - [ - "fanout_mode" - ] - ] - }, - { - "id": "fanout_mode", - "type": "function", - "z": "tab_process", - "name": "fan setMode → 3 pumps", - "func": "msg.topic = 'setMode';\nreturn [msg, msg, msg];", - "outputs": 3, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 600, - "y": 1480, - "wires": [ - [ - "pump_a" - ], - [ - "pump_b" - ], - [ - "pump_c" - ] - ] - }, - { - "id": "c_station_cmds", - "type": "comment", - "z": "tab_process", - "name": "── Station-wide commands ──", - "info": "", - "x": 640, - "y": 1620, - "wires": [] - }, - { - "id": "lin_station_start", - "type": "link in", - "z": "tab_process", - "name": "cmd:station-startup", - "links": [ - "lout_cmd_station_startup_dash" - ], - "x": 120, - "y": 1680, - "wires": [ - [ - "fan_station_start" - ] - ] - }, - { - "id": "fan_station_start", - "type": "function", - "z": "tab_process", - "name": "fan startup → 3 pumps", - "func": "return [msg, msg, msg];", - "outputs": 3, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 600, - "y": 1680, - "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": 1740, - "wires": [ - [ - "fan_station_stop" - ] - ] - }, - { - "id": "fan_station_stop", - "type": "function", - "z": "tab_process", - "name": "fan shutdown → 3 pumps", - "func": "return [msg, msg, msg];", - "outputs": 3, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 600, - "y": 1740, - "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": 1800, - "wires": [ - [ - "fan_station_estop" - ] - ] - }, - { - "id": "fan_station_estop", - "type": "function", - "z": "tab_process", - "name": "fan emergency stop → 3 pumps", - "func": "return [msg, msg, msg];", - "outputs": 3, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 600, - "y": 1800, - "wires": [ - [ - "pump_a" - ], - [ - "pump_b" - ], - [ - "pump_c" - ] - ] - }, - { - "id": "c_setup_at_mgc", - "type": "comment", - "z": "tab_process", - "name": "── Setup feeders ──", - "info": "", - "x": 640, - "y": 1900, - "wires": [] - }, - { - "id": "lin_setup_at_mgc", - "type": "link in", - "z": "tab_process", - "name": "setup:to-mgc", - "links": [ - "lout_setup_to_mgc" - ], - "x": 120, - "y": 1960, - "wires": [ - [ - "mgc_pumps" - ] - ] - }, - { - "id": "lin_setup_calibrate_ps", - "type": "link in", - "z": "tab_process", - "name": "setup:calibrate-ps", - "links": [ - "lout_setup_calibrate" - ], - "x": 120, - "y": 2020, - "wires": [ - [ - "ps_basin" - ] - ] - }, - { - "id": "tab_ui", - "type": "tab", - "label": "📊 Dashboard UI", - "disabled": false, - "info": "All FlowFuse ui-* widgets. Two pages:\n /dashboard/realtime — gauges + per-pump status (no time history)\n /dashboard/trends — line charts, 1 hour rolling window\n\nAll inputs leave via link-out; all process state arrives via link-in." - }, - { - "id": "ui_base", - "type": "ui-base", - "name": "EVOLV Pumping", - "path": "/dashboard", - "appIcon": "", - "includeClientData": true, - "acceptsClientConfig": [ - "ui-notification", - "ui-control" - ], - "showPathInSidebar": true, - "headerContent": "page", - "navigationStyle": "default", - "titleBarStyle": "default" - }, - { - "id": "ui_theme", - "type": "ui-theme", - "name": "EVOLV Theme", - "colors": { - "surface": "#ffffff", - "primary": "#0c99d9", - "bgPage": "#f4f6fa", - "groupBg": "#ffffff", - "groupOutline": "#cccccc" - }, - "sizes": { - "density": "default", - "pagePadding": "12px", - "groupGap": "12px", - "groupBorderRadius": "6px", - "widgetGap": "8px" - } - }, - { - "id": "ui_page_realtime", - "type": "ui-page", - "name": "Realtime", - "ui": "ui_base", - "path": "/realtime", - "icon": "speed", - "layout": "grid", - "theme": "ui_theme", - "breakpoints": [ - { - "name": "Default", - "px": "0", - "cols": "12" - } - ], - "order": 1, - "className": "" - }, - { - "id": "ui_page_trends", - "type": "ui-page", - "name": "Trends — 1 hour", - "ui": "ui_base", - "path": "/trends", - "icon": "show_chart", - "layout": "grid", - "theme": "ui_theme", - "breakpoints": [ - { - "name": "Default", - "px": "0", - "cols": "12" - } - ], - "order": 2, - "className": "", - "d": true - }, - { - "id": "ui_grp_inflow", - "type": "ui-group", - "name": "1. Inflow (operator input)", - "page": "ui_page_realtime", - "width": "12", - "height": "1", - "order": 1, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_station", - "type": "ui-group", - "name": "2. Station Mode + Commands", - "page": "ui_page_realtime", - "width": "12", - "height": "1", - "order": 2, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_basin", - "type": "ui-group", - "name": "3. Basin Realtime", - "page": "ui_page_realtime", - "width": "6", - "height": "1", - "order": 3, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_mgc", - "type": "ui-group", - "name": "4. Pump Group (MGC)", - "page": "ui_page_realtime", - "width": "6", - "height": "1", - "order": 4, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_pump_a", - "type": "ui-group", - "name": "5a. Pump A", - "page": "ui_page_realtime", - "width": "4", - "height": "1", - "order": 5, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_pump_b", - "type": "ui-group", - "name": "5b. Pump B", - "page": "ui_page_realtime", - "width": "4", - "height": "1", - "order": 6, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_pump_c", - "type": "ui-group", - "name": "5c. Pump C", - "page": "ui_page_realtime", - "width": "4", - "height": "1", - "order": 7, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_tr_basin", - "type": "ui-group", - "name": "Basin level + fill (1h)", - "page": "ui_page_trends", - "width": "12", - "height": "1", - "order": 1, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_tr_demand", - "type": "ui-group", - "name": "Process demand — PS percControl (1h)", - "page": "ui_page_trends", - "width": "12", - "height": "1", - "order": 2, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_tr_dq", - "type": "ui-group", - "name": "ΔQ = inflow − outflow (m³/h, +fill / −drain)", - "page": "ui_page_trends", - "width": "12", - "height": "1", - "order": 3, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_tr_states", - "type": "ui-group", - "name": "Pump state timeline (gantt)", - "page": "ui_page_trends", - "width": "12", - "height": "1", - "order": 4, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_tr_flow", - "type": "ui-group", - "name": "Inflow / Outflow / Per-pump flow (1h)", - "page": "ui_page_trends", - "width": "12", - "height": "1", - "order": 5, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_tr_power", - "type": "ui-group", - "name": "Per-pump power (1h)", - "page": "ui_page_trends", - "width": "12", - "height": "1", - "order": 6, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "ui_grp_tr_press", - "type": "ui-group", - "name": "Per-pump pressures (1h)", - "page": "ui_page_trends", - "width": "12", - "height": "1", - "order": 7, - "showTitle": true, - "className": "", - "groupType": "default", - "disabled": false, - "visible": true - }, - { - "id": "c_ui_title", - "type": "comment", - "z": "tab_ui", - "name": "📊 DASHBOARD UI — only ui-* widgets here", - "info": "", - "x": 640, - "y": 20, - "wires": [] - }, - { - "id": "c_ui_inflow", - "type": "comment", - "z": "tab_ui", - "name": "── Operator inflow input ──", - "info": "", - "x": 640, - "y": 80, - "wires": [] - }, - { - "id": "ui_inflow_slider", - "type": "ui-slider", - "z": "tab_ui", - "group": "ui_grp_inflow", - "name": "Inflow baseline", - "label": "Inflow baseline (m³/h) — scenarios modulate around this value", - "tooltip": "", - "order": 1, - "width": "0", - "height": "0", - "passthru": true, - "outs": "end", - "topic": "inflowBaseline", - "topicType": "str", - "min": "0", - "max": "250", - "step": "5.0", - "showLabel": true, - "showValue": true, - "labelPosition": "top", - "valuePosition": "left", - "thumbLabel": false, - "iconStart": "", - "iconEnd": "", - "x": 120, - "y": 120, - "wires": [ - [ - "lout_inflow_baseline" - ] - ] - }, - { - "id": "lout_inflow_baseline", - "type": "link out", - "z": "tab_ui", - "name": "cmd:inflow-baseline", - "mode": "link", - "links": [ - "lin_inflow_baseline" - ], - "x": 380, - "y": 120, - "wires": [] - }, - { - "id": "btn_scn_constant", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_inflow", - "name": "Scenario Constant", - "label": "Constant", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#0c99d9", - "className": "", - "icon": "horizontal_rule", - "iconPosition": "left", - "payload": "constant", - "payloadType": "str", - "topic": "scenario", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 180, - "wires": [ - [ - "wrap_scn_constant" - ] - ] - }, - { - "id": "wrap_scn_constant", - "type": "function", - "z": "tab_ui", - "name": "build scenario constant", - "func": "msg.payload = 'constant';\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 480, - "y": 180, - "wires": [ - [ - "lout_inflow_scenario" - ] - ] - }, - { - "id": "btn_scn_sine", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_inflow", - "name": "Scenario Sine wave", - "label": "Sine wave", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#16a34a", - "className": "", - "icon": "show_chart", - "iconPosition": "left", - "payload": "sine", - "payloadType": "str", - "topic": "scenario", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 230, - "wires": [ - [ - "wrap_scn_sine" - ] - ] - }, - { - "id": "wrap_scn_sine", - "type": "function", - "z": "tab_ui", - "name": "build scenario sine", - "func": "msg.payload = 'sine';\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 480, - "y": 230, - "wires": [ - [ - "lout_inflow_scenario" - ] - ] - }, - { - "id": "btn_scn_diurnal", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_inflow", - "name": "Scenario Diurnal", - "label": "Diurnal", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#f59e0b", - "className": "", - "icon": "schedule", - "iconPosition": "left", - "payload": "diurnal", - "payloadType": "str", - "topic": "scenario", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 280, - "wires": [ - [ - "wrap_scn_diurnal" - ] - ] - }, - { - "id": "wrap_scn_diurnal", - "type": "function", - "z": "tab_ui", - "name": "build scenario diurnal", - "func": "msg.payload = 'diurnal';\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 480, - "y": 280, - "wires": [ - [ - "lout_inflow_scenario" - ] - ] - }, - { - "id": "btn_scn_storm", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_inflow", - "name": "Scenario Storm", - "label": "Storm", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#dc2626", - "className": "", - "icon": "thunderstorm", - "iconPosition": "left", - "payload": "storm", - "payloadType": "str", - "topic": "scenario", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 330, - "wires": [ - [ - "wrap_scn_storm" - ] - ] - }, - { - "id": "wrap_scn_storm", - "type": "function", - "z": "tab_ui", - "name": "build scenario storm", - "func": "msg.payload = 'storm';\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 480, - "y": 330, - "wires": [ - [ - "lout_inflow_scenario" - ] - ] - }, - { - "id": "lout_inflow_scenario", - "type": "link out", - "z": "tab_ui", - "name": "cmd:inflow-scenario", - "mode": "link", - "links": [ - "lin_inflow_scenario" - ], - "x": 640, - "y": 180, - "wires": [] - }, - { - "id": "lin_evt_inflow", - "type": "link in", - "z": "tab_ui", - "name": "evt:inflow", - "links": [ - "lout_evt_inflow" - ], - "x": 900, - "y": 120, - "wires": [ - [ - "dispatch_inflow" - ] - ] - }, - { - "id": "dispatch_inflow", - "type": "function", - "z": "tab_ui", - "name": "dispatch inflow", - "func": "const p = msg.payload || {};\nconst ts = Date.now();\nreturn [\n { payload: (p.scenario || 'constant').toUpperCase() },\n { payload: p.q_h != null ? Number(p.q_h).toFixed(1) + ' m³/h' : 'n/a' },\n p.q_h != null ? { topic: 'Inflow', payload: Number(p.q_h), timestamp: ts } : null,\n];", - "outputs": 3, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1160, - "y": 120, - "wires": [ - [ - "ui_inflow_scn_text" - ], - [ - "ui_inflow_value_text" - ], - [ - "chart_trend_flow" - ] - ] - }, - { - "id": "ui_inflow_scn_text", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_inflow", - "order": 1, - "width": "0", - "height": "0", - "name": "Active scenario", - "label": "Active scenario", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 1420, - "y": 120, - "wires": [] - }, - { - "id": "ui_inflow_value_text", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_inflow", - "order": 1, - "width": "0", - "height": "0", - "name": "Live inflow", - "label": "Live inflow", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 1420, - "y": 160, - "wires": [] - }, - { - "id": "c_ui_station", - "type": "comment", - "z": "tab_ui", - "name": "── Mode + Station-wide buttons ──", - "info": "", - "x": 640, - "y": 380, - "wires": [] - }, - { - "id": "ui_mode_toggle", - "type": "ui-switch", - "z": "tab_ui", - "group": "ui_grp_station", - "name": "Station mode", - "label": "Station mode (Auto = level-based · Manual = slider Qd)", - "tooltip": "", - "order": 1, - "width": "0", - "height": "0", - "passthru": true, - "decouple": "false", - "topic": "changemode", - "topicType": "str", - "style": "", - "className": "", - "evaluate": "true", - "onvalue": "levelbased", - "onvalueType": "str", - "onicon": "auto_mode", - "oncolor": "#0c99d9", - "offvalue": "manual", - "offvalueType": "str", - "officon": "back_hand", - "offcolor": "#888888", - "x": 120, - "y": 420, - "wires": [ - [ - "lout_ps_mode_dash" - ] - ] - }, - { - "id": "lout_ps_mode_dash", - "type": "link out", - "z": "tab_ui", - "name": "cmd:ps-mode", - "mode": "link", - "links": [ - "lin_ps_mode_at_ps" - ], - "x": 380, - "y": 420, - "wires": [] - }, - { - "id": "ui_qd_slider", - "type": "ui-slider", - "z": "tab_ui", - "group": "ui_grp_station", - "name": "Manual Qd", - "label": "Manual Qd (m³/h, manual mode only)", - "tooltip": "", - "order": 1, - "width": "0", - "height": "0", - "passthru": true, - "outs": "end", - "topic": "manualDemand", - "topicType": "str", - "min": "0", - "max": "600", - "step": "5.0", - "showLabel": true, - "showValue": true, - "labelPosition": "top", - "valuePosition": "left", - "thumbLabel": false, - "iconStart": "", - "iconEnd": "", - "x": 120, - "y": 470, - "wires": [ - [ - "lout_qd_dash" - ] - ] - }, - { - "id": "lout_qd_dash", - "type": "link out", - "z": "tab_ui", - "name": "cmd:Qd", - "mode": "link", - "links": [ - "lin_qd_at_ps" - ], - "x": 380, - "y": 470, - "wires": [] - }, - { - "id": "btn_station_0", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_station", - "name": "Start all pumps", - "label": "Start all pumps", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#16a34a", - "className": "", - "icon": "play_arrow", - "iconPosition": "left", - "payload": "fired", - "payloadType": "str", - "topic": "station_0", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 530, - "wires": [ - [ - "wrap_station_0" - ] - ] - }, - { - "id": "wrap_station_0", - "type": "function", - "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": 480, - "y": 530, - "wires": [ - [ - "lout_cmd_station_startup_dash" - ] - ] - }, - { - "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": 530, - "wires": [] - }, - { - "id": "btn_station_1", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_station", - "name": "Stop all pumps", - "label": "Stop all pumps", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#ea580c", - "className": "", - "icon": "stop", - "iconPosition": "left", - "payload": "fired", - "payloadType": "str", - "topic": "station_1", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 580, - "wires": [ - [ - "wrap_station_1" - ] - ] - }, - { - "id": "wrap_station_1", - "type": "function", - "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": 480, - "y": 580, - "wires": [ - [ - "lout_cmd_station_shutdown_dash" - ] - ] - }, - { - "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": 580, - "wires": [] - }, - { - "id": "btn_station_2", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_station", - "name": "EMERGENCY STOP", - "label": "EMERGENCY STOP", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#dc2626", - "className": "", - "icon": "stop_circle", - "iconPosition": "left", - "payload": "fired", - "payloadType": "str", - "topic": "station_2", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 630, - "wires": [ - [ - "wrap_station_2" - ] - ] - }, - { - "id": "wrap_station_2", - "type": "function", - "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": 480, - "y": 630, - "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": 630, - "wires": [] - }, - { - "id": "c_ui_basin", - "type": "comment", - "z": "tab_ui", - "name": "── Basin realtime (gauges + text) ──", - "info": "", - "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": 740, - "wires": [ - [ - "dispatch_ps" - ] - ] - }, - { - "id": "dispatch_ps", - "type": "function", - "z": "tab_ui", - "name": "dispatch PS", - "func": "const p = msg.payload || {};\nconst ts = Date.now();\n// ΔQ = inflow − outflow in m³/h (positive = filling).\nconst dQ = (p.qInNum != null && p.qOutNum != null)\n ? p.qInNum - p.qOutNum : null;\n// Demand text formatting.\nconst demandStr = p.percControl != null\n ? Number(p.percControl).toFixed(0) + '%' : 'n/a';\nreturn [\n { payload: String(p.direction || 'steady') },\n { payload: String(p.level || 'n/a') },\n { payload: String(p.volume || 'n/a') },\n { payload: String(p.fillPct || 'n/a') },\n { payload: String(p.netFlow || 'n/a') },\n { payload: String(p.timeLeft || 'n/a') },\n { payload: String(p.qIn || 'n/a') },\n { payload: String(p.qOut || 'n/a') },\n { payload: String(p.safetyState || 'normal') },\n { payload: demandStr },\n p.levelNum != null ? { payload: p.levelNum } : null,\n p.fillPctNum != null ? { payload: p.fillPctNum } : null,\n p.percControl != null ? { payload: p.percControl } : null,\n p.levelNum != null ? { topic: 'Basin level', payload: p.levelNum, timestamp: ts } : null,\n p.fillPctNum != null ? { topic: 'Fill %', payload: p.fillPctNum, timestamp: ts } : null,\n p.qOutNum != null ? { topic: 'Outflow', payload: p.qOutNum, timestamp: ts } : null,\n p.percControl != null ? { topic: 'PS demand', payload: p.percControl, timestamp: ts } : null,\n dQ != null ? { topic: 'ΔQ', payload: dQ, timestamp: ts } : null,\n];", - "outputs": 18, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 380, - "y": 740, - "wires": [ - [ - "ui_ps_direction" - ], - [ - "ui_ps_level" - ], - [ - "ui_ps_volume" - ], - [ - "ui_ps_fill" - ], - [ - "ui_ps_netflow" - ], - [ - "ui_ps_timeleft" - ], - [ - "ui_ps_qin" - ], - [ - "ui_ps_qout" - ], - [ - "ui_ps_safety" - ], - [ - "ui_ps_demand" - ], - [ - "gauge_basin_level" - ], - [ - "gauge_basin_fill" - ], - [ - "gauge_ps_demand" - ], - [ - "chart_trend_basin" - ], - [ - "chart_trend_basin" - ], - [ - "chart_trend_flow" - ], - [ - "chart_trend_demand" - ], - [ - "chart_trend_dq" - ] - ] - }, - { - "id": "ui_ps_direction", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_basin", - "order": 1, - "width": "0", - "height": "0", - "name": "Direction", - "label": "Direction", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 740, - "wires": [] - }, - { - "id": "ui_ps_level", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_basin", - "order": 1, - "width": "0", - "height": "0", - "name": "Basin level", - "label": "Basin level", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 770, - "wires": [] - }, - { - "id": "ui_ps_volume", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_basin", - "order": 1, - "width": "0", - "height": "0", - "name": "Basin volume", - "label": "Basin volume", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 800, - "wires": [] - }, - { - "id": "ui_ps_fill", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_basin", - "order": 1, - "width": "0", - "height": "0", - "name": "Fill %", - "label": "Fill %", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 830, - "wires": [] - }, - { - "id": "ui_ps_netflow", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_basin", - "order": 1, - "width": "0", - "height": "0", - "name": "Net flow", - "label": "Net flow", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 860, - "wires": [] - }, - { - "id": "ui_ps_timeleft", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_basin", - "order": 1, - "width": "0", - "height": "0", - "name": "Time left", - "label": "Time to full/empty", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 890, - "wires": [] - }, - { - "id": "ui_ps_qin", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_basin", - "order": 1, - "width": "0", - "height": "0", - "name": "Inflow", - "label": "Inflow", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 920, - "wires": [] - }, - { - "id": "ui_ps_qout", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_basin", - "order": 1, - "width": "0", - "height": "0", - "name": "Outflow", - "label": "Outflow", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 950, - "wires": [] - }, - { - "id": "ui_ps_safety", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_basin", - "order": 1, - "width": "0", - "height": "0", - "name": "Safety", - "label": "Safety state", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 980, - "wires": [] - }, - { - "id": "ui_ps_demand", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_basin", - "order": 1, - "width": "0", - "height": "0", - "name": "PS demand", - "label": "Process demand", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1010, - "wires": [] - }, - { - "id": "gauge_basin_level", - "type": "ui-gauge", - "z": "tab_ui", - "group": "ui_grp_basin", - "name": "Basin level gauge", - "gtype": "gauge-tank", - "gstyle": "Rounded", - "title": "Level", - "units": "m", - "prefix": "", - "suffix": " m", - "min": 0, - "max": 4.0, - "segments": [ - { - "color": "#f44336", - "from": 0 - }, - { - "color": "#ff9800", - "from": 1.0 - }, - { - "color": "#2196f3", - "from": 2.0 - }, - { - "color": "#ff9800", - "from": 3.5 - }, - { - "color": "#f44336", - "from": 3.8 - } - ], - "width": 3, - "height": 4, - "order": 10, - "icon": "", - "sizeGauge": 20, - "sizeGap": 2, - "sizeSegments": 10, - "x": 900, - "y": 740, - "wires": [] - }, - { - "id": "gauge_basin_fill", - "type": "ui-gauge", - "z": "tab_ui", - "group": "ui_grp_basin", - "name": "Basin fill gauge", - "gtype": "gauge-34", - "gstyle": "Rounded", - "title": "Fill", - "units": "%", - "prefix": "", - "suffix": "%", - "min": 0, - "max": 100, - "segments": [ - { - "color": "#f44336", - "from": 0 - }, - { - "color": "#ff9800", - "from": 10 - }, - { - "color": "#4caf50", - "from": 30 - }, - { - "color": "#ff9800", - "from": 80 - }, - { - "color": "#f44336", - "from": 95 - } - ], - "width": 3, - "height": 4, - "order": 11, - "icon": "water_drop", - "sizeGauge": 20, - "sizeGap": 2, - "sizeSegments": 10, - "x": 900, - "y": 800, - "wires": [] - }, - { - "id": "gauge_ps_demand", - "type": "ui-gauge", - "z": "tab_ui", - "group": "ui_grp_basin", - "name": "PS demand gauge", - "gtype": "gauge-34", - "gstyle": "Rounded", - "title": "PS demand", - "units": "%", - "prefix": "", - "suffix": "%", - "min": 0, - "max": 100, - "segments": [ - { - "color": "#cccccc", - "from": 0 - }, - { - "color": "#0c99d9", - "from": 5 - }, - { - "color": "#16a34a", - "from": 30 - }, - { - "color": "#f59e0b", - "from": 70 - }, - { - "color": "#dc2626", - "from": 95 - } - ], - "width": 3, - "height": 4, - "order": 12, - "icon": "speed", - "sizeGauge": 20, - "sizeGap": 2, - "sizeSegments": 10, - "x": 900, - "y": 860, - "wires": [] - }, - { - "id": "c_ui_mgc", - "type": "comment", - "z": "tab_ui", - "name": "── MGC realtime ──", - "info": "", - "x": 640, - "y": 1080, - "wires": [] - }, - { - "id": "lin_evt_mgc_dash", - "type": "link in", - "z": "tab_ui", - "name": "evt:mgc", - "links": [ - "lout_evt_mgc" - ], - "x": 120, - "y": 1120, - "wires": [ - [ - "dispatch_mgc" - ] - ] - }, - { - "id": "dispatch_mgc", - "type": "function", - "z": "tab_ui", - "name": "dispatch MGC", - "func": "const p = msg.payload || {};\nreturn [\n { payload: String(p.totalFlow || 'n/a') },\n { payload: String(p.totalPower || 'n/a') },\n { payload: String(p.efficiency || 'n/a') },\n p.totalFlowNum != null ? { payload: p.totalFlowNum } : null,\n p.totalPowerNum != null ? { payload: p.totalPowerNum } : null,\n];", - "outputs": 5, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 380, - "y": 1120, - "wires": [ - [ - "ui_mgc_total_flow" - ], - [ - "ui_mgc_total_power" - ], - [ - "ui_mgc_eff" - ], - [ - "gauge_mgc_flow" - ], - [ - "gauge_mgc_power" - ] - ] - }, - { - "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}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1120, - "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}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1150, - "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}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1180, - "wires": [] - }, - { - "id": "gauge_mgc_flow", - "type": "ui-gauge", - "z": "tab_ui", - "group": "ui_grp_mgc", - "name": "MGC total flow gauge", - "gtype": "gauge-34", - "gstyle": "Rounded", - "title": "Total flow", - "units": "m³/h", - "prefix": "", - "suffix": " m³/h", - "min": 0, - "max": 600, - "segments": [ - { - "color": "#cccccc", - "from": 0 - }, - { - "color": "#0c99d9", - "from": 50 - }, - { - "color": "#16a34a", - "from": 200 - }, - { - "color": "#f59e0b", - "from": 500 - } - ], - "width": 3, - "height": 4, - "order": 10, - "icon": "", - "sizeGauge": 20, - "sizeGap": 2, - "sizeSegments": 10, - "x": 900, - "y": 1120, - "wires": [] - }, - { - "id": "gauge_mgc_power", - "type": "ui-gauge", - "z": "tab_ui", - "group": "ui_grp_mgc", - "name": "MGC total power gauge", - "gtype": "gauge-34", - "gstyle": "Rounded", - "title": "Total power", - "units": "kW", - "prefix": "", - "suffix": " kW", - "min": 0, - "max": 30, - "segments": [ - { - "color": "#cccccc", - "from": 0 - }, - { - "color": "#0c99d9", - "from": 1 - }, - { - "color": "#16a34a", - "from": 5 - }, - { - "color": "#f59e0b", - "from": 20 - } - ], - "width": 3, - "height": 4, - "order": 11, - "icon": "", - "sizeGauge": 20, - "sizeGap": 2, - "sizeSegments": 10, - "x": 900, - "y": 1180, - "wires": [] - }, - { - "id": "c_ui_pump_a", - "type": "comment", - "z": "tab_ui", - "name": "── Pump A ──", - "info": "", - "x": 640, - "y": 1340, - "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": 1380, - "wires": [ - [ - "dispatch_pump_a" - ] - ] - }, - { - "id": "dispatch_pump_a", - "type": "function", - "z": "tab_ui", - "name": "dispatch Pump A", - "func": "const p = msg.payload || {};\nconst ts = Date.now();\nconst OFF = 0;\nfunction stateNum(s) {\n switch (s) {\n case 'operational': return OFF + 2;\n case 'starting':\n case 'warmingup': return OFF + 1;\n case 'stopping': return OFF + 1.5;\n case 'coolingdown': return OFF + 0.5;\n default: return OFF;\n }\n}\nconst sNum = p.state ? stateNum(p.state) : null;\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump A', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump A', payload: p.powerNum, timestamp: ts} : null,\n p.pUpNum != null ? {topic: 'Pump A up', payload: p.pUpNum, timestamp: ts} : null,\n p.pDnNum != null ? {topic: 'Pump A dn', payload: p.pDnNum, timestamp: ts} : null,\n sNum != null ? {topic: 'Pump A state', payload: sNum, timestamp: ts} : null,\n];", - "outputs": 12, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 380, - "y": 1380, - "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" - ], - [ - "chart_trend_flow" - ], - [ - "chart_trend_power" - ], - [ - "chart_trend_pressure" - ], - [ - "chart_trend_pressure" - ], - [ - "chart_trend_states" - ] - ] - }, - { - "id": "ui_pump_a_state", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_a", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump A State", - "label": "State", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1380, - "wires": [] - }, - { - "id": "ui_pump_a_mode", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_a", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump A Mode", - "label": "Mode", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1410, - "wires": [] - }, - { - "id": "ui_pump_a_ctrl", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_a", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump A Controller %", - "label": "Controller %", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1440, - "wires": [] - }, - { - "id": "ui_pump_a_flow", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_a", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump A Flow", - "label": "Flow", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1470, - "wires": [] - }, - { - "id": "ui_pump_a_power", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_a", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump A Power", - "label": "Power", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1500, - "wires": [] - }, - { - "id": "ui_pump_a_pUp", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_a", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump A p Upstream", - "label": "p Upstream", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1530, - "wires": [] - }, - { - "id": "ui_pump_a_pDn", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_a", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump A p Downstream", - "label": "p Downstream", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1560, - "wires": [] - }, - { - "id": "ui_pump_a_setpoint", - "type": "ui-slider", - "z": "tab_ui", - "group": "ui_grp_pump_a", - "name": "Pump A setpoint", - "label": "Setpoint % (manual mode)", - "tooltip": "", - "order": 1, - "width": "0", - "height": "0", - "passthru": true, - "outs": "end", - "topic": "setpoint_pump_a", - "topicType": "str", - "min": "0", - "max": "100", - "step": "5.0", - "showLabel": true, - "showValue": true, - "labelPosition": "top", - "valuePosition": "left", - "thumbLabel": false, - "iconStart": "", - "iconEnd": "", - "x": 120, - "y": 1620, - "wires": [ - [ - "lout_setpoint_pump_a_dash" - ] - ] - }, - { - "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": 1620, - "wires": [] - }, - { - "id": "btn_pump_a_start", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_pump_a", - "name": "Pump A startup", - "label": "Startup", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#16a34a", - "className": "", - "icon": "play_arrow", - "iconPosition": "left", - "payload": "fired", - "payloadType": "str", - "topic": "start_pump_a", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 1670, - "wires": [ - [ - "wrap_pump_a_start" - ] - ] - }, - { - "id": "wrap_pump_a_start", - "type": "function", - "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": 480, - "y": 1670, - "wires": [ - [ - "lout_seq_pump_a_dash" - ] - ] - }, - { - "id": "btn_pump_a_stop", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_pump_a", - "name": "Pump A shutdown", - "label": "Shutdown", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#ea580c", - "className": "", - "icon": "stop", - "iconPosition": "left", - "payload": "fired", - "payloadType": "str", - "topic": "stop_pump_a", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 1720, - "wires": [ - [ - "wrap_pump_a_stop" - ] - ] - }, - { - "id": "wrap_pump_a_stop", - "type": "function", - "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": 480, - "y": 1720, - "wires": [ - [ - "lout_seq_pump_a_dash" - ] - ] - }, - { - "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": 1695, - "wires": [] - }, - { - "id": "c_ui_pump_b", - "type": "comment", - "z": "tab_ui", - "name": "── Pump B ──", - "info": "", - "x": 640, - "y": 1820, - "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": 1860, - "wires": [ - [ - "dispatch_pump_b" - ] - ] - }, - { - "id": "dispatch_pump_b", - "type": "function", - "z": "tab_ui", - "name": "dispatch Pump B", - "func": "const p = msg.payload || {};\nconst ts = Date.now();\nconst OFF = 3;\nfunction stateNum(s) {\n switch (s) {\n case 'operational': return OFF + 2;\n case 'starting':\n case 'warmingup': return OFF + 1;\n case 'stopping': return OFF + 1.5;\n case 'coolingdown': return OFF + 0.5;\n default: return OFF;\n }\n}\nconst sNum = p.state ? stateNum(p.state) : null;\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump B', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump B', payload: p.powerNum, timestamp: ts} : null,\n p.pUpNum != null ? {topic: 'Pump B up', payload: p.pUpNum, timestamp: ts} : null,\n p.pDnNum != null ? {topic: 'Pump B dn', payload: p.pDnNum, timestamp: ts} : null,\n sNum != null ? {topic: 'Pump B state', payload: sNum, timestamp: ts} : null,\n];", - "outputs": 12, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 380, - "y": 1860, - "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" - ], - [ - "chart_trend_flow" - ], - [ - "chart_trend_power" - ], - [ - "chart_trend_pressure" - ], - [ - "chart_trend_pressure" - ], - [ - "chart_trend_states" - ] - ] - }, - { - "id": "ui_pump_b_state", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_b", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump B State", - "label": "State", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1860, - "wires": [] - }, - { - "id": "ui_pump_b_mode", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_b", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump B Mode", - "label": "Mode", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1890, - "wires": [] - }, - { - "id": "ui_pump_b_ctrl", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_b", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump B Controller %", - "label": "Controller %", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1920, - "wires": [] - }, - { - "id": "ui_pump_b_flow", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_b", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump B Flow", - "label": "Flow", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1950, - "wires": [] - }, - { - "id": "ui_pump_b_power", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_b", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump B Power", - "label": "Power", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 1980, - "wires": [] - }, - { - "id": "ui_pump_b_pUp", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_b", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump B p Upstream", - "label": "p Upstream", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 2010, - "wires": [] - }, - { - "id": "ui_pump_b_pDn", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_b", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump B p Downstream", - "label": "p Downstream", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 2040, - "wires": [] - }, - { - "id": "ui_pump_b_setpoint", - "type": "ui-slider", - "z": "tab_ui", - "group": "ui_grp_pump_b", - "name": "Pump B setpoint", - "label": "Setpoint % (manual mode)", - "tooltip": "", - "order": 1, - "width": "0", - "height": "0", - "passthru": true, - "outs": "end", - "topic": "setpoint_pump_b", - "topicType": "str", - "min": "0", - "max": "100", - "step": "5.0", - "showLabel": true, - "showValue": true, - "labelPosition": "top", - "valuePosition": "left", - "thumbLabel": false, - "iconStart": "", - "iconEnd": "", - "x": 120, - "y": 2100, - "wires": [ - [ - "lout_setpoint_pump_b_dash" - ] - ] - }, - { - "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": 2100, - "wires": [] - }, - { - "id": "btn_pump_b_start", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_pump_b", - "name": "Pump B startup", - "label": "Startup", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#16a34a", - "className": "", - "icon": "play_arrow", - "iconPosition": "left", - "payload": "fired", - "payloadType": "str", - "topic": "start_pump_b", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 2150, - "wires": [ - [ - "wrap_pump_b_start" - ] - ] - }, - { - "id": "wrap_pump_b_start", - "type": "function", - "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": 480, - "y": 2150, - "wires": [ - [ - "lout_seq_pump_b_dash" - ] - ] - }, - { - "id": "btn_pump_b_stop", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_pump_b", - "name": "Pump B shutdown", - "label": "Shutdown", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#ea580c", - "className": "", - "icon": "stop", - "iconPosition": "left", - "payload": "fired", - "payloadType": "str", - "topic": "stop_pump_b", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 2200, - "wires": [ - [ - "wrap_pump_b_stop" - ] - ] - }, - { - "id": "wrap_pump_b_stop", - "type": "function", - "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": 480, - "y": 2200, - "wires": [ - [ - "lout_seq_pump_b_dash" - ] - ] - }, - { - "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": 2175, - "wires": [] - }, - { - "id": "c_ui_pump_c", - "type": "comment", - "z": "tab_ui", - "name": "── Pump C ──", - "info": "", - "x": 640, - "y": 2300, - "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": 2340, - "wires": [ - [ - "dispatch_pump_c" - ] - ] - }, - { - "id": "dispatch_pump_c", - "type": "function", - "z": "tab_ui", - "name": "dispatch Pump C", - "func": "const p = msg.payload || {};\nconst ts = Date.now();\nconst OFF = 6;\nfunction stateNum(s) {\n switch (s) {\n case 'operational': return OFF + 2;\n case 'starting':\n case 'warmingup': return OFF + 1;\n case 'stopping': return OFF + 1.5;\n case 'coolingdown': return OFF + 0.5;\n default: return OFF;\n }\n}\nconst sNum = p.state ? stateNum(p.state) : null;\nreturn [\n {payload: String(p.state || 'idle')},\n {payload: String(p.mode || 'auto')},\n {payload: String(p.ctrl || 'n/a')},\n {payload: String(p.flow || 'n/a')},\n {payload: String(p.power || 'n/a')},\n {payload: String(p.pUp || 'n/a')},\n {payload: String(p.pDn || 'n/a')},\n p.flowNum != null ? {topic: 'Pump C', payload: p.flowNum, timestamp: ts} : null,\n p.powerNum != null ? {topic: 'Pump C', payload: p.powerNum, timestamp: ts} : null,\n p.pUpNum != null ? {topic: 'Pump C up', payload: p.pUpNum, timestamp: ts} : null,\n p.pDnNum != null ? {topic: 'Pump C dn', payload: p.pDnNum, timestamp: ts} : null,\n sNum != null ? {topic: 'Pump C state', payload: sNum, timestamp: ts} : null,\n];", - "outputs": 12, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 380, - "y": 2340, - "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" - ], - [ - "chart_trend_flow" - ], - [ - "chart_trend_power" - ], - [ - "chart_trend_pressure" - ], - [ - "chart_trend_pressure" - ], - [ - "chart_trend_states" - ] - ] - }, - { - "id": "ui_pump_c_state", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_c", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump C State", - "label": "State", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 2340, - "wires": [] - }, - { - "id": "ui_pump_c_mode", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_c", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump C Mode", - "label": "Mode", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 2370, - "wires": [] - }, - { - "id": "ui_pump_c_ctrl", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_c", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump C Controller %", - "label": "Controller %", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 2400, - "wires": [] - }, - { - "id": "ui_pump_c_flow", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_c", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump C Flow", - "label": "Flow", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 2430, - "wires": [] - }, - { - "id": "ui_pump_c_power", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_c", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump C Power", - "label": "Power", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 2460, - "wires": [] - }, - { - "id": "ui_pump_c_pUp", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_c", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump C p Upstream", - "label": "p Upstream", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 2490, - "wires": [] - }, - { - "id": "ui_pump_c_pDn", - "type": "ui-text", - "z": "tab_ui", - "group": "ui_grp_pump_c", - "order": 1, - "width": "0", - "height": "0", - "name": "Pump C p Downstream", - "label": "p Downstream", - "format": "{{msg.payload}}", - "layout": "row-spread", - "style": false, - "font": "", - "fontSize": 14, - "color": "#000000", - "x": 640, - "y": 2520, - "wires": [] - }, - { - "id": "ui_pump_c_setpoint", - "type": "ui-slider", - "z": "tab_ui", - "group": "ui_grp_pump_c", - "name": "Pump C setpoint", - "label": "Setpoint % (manual mode)", - "tooltip": "", - "order": 1, - "width": "0", - "height": "0", - "passthru": true, - "outs": "end", - "topic": "setpoint_pump_c", - "topicType": "str", - "min": "0", - "max": "100", - "step": "5.0", - "showLabel": true, - "showValue": true, - "labelPosition": "top", - "valuePosition": "left", - "thumbLabel": false, - "iconStart": "", - "iconEnd": "", - "x": 120, - "y": 2580, - "wires": [ - [ - "lout_setpoint_pump_c_dash" - ] - ] - }, - { - "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": 2580, - "wires": [] - }, - { - "id": "btn_pump_c_start", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_pump_c", - "name": "Pump C startup", - "label": "Startup", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#16a34a", - "className": "", - "icon": "play_arrow", - "iconPosition": "left", - "payload": "fired", - "payloadType": "str", - "topic": "start_pump_c", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 2630, - "wires": [ - [ - "wrap_pump_c_start" - ] - ] - }, - { - "id": "wrap_pump_c_start", - "type": "function", - "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": 480, - "y": 2630, - "wires": [ - [ - "lout_seq_pump_c_dash" - ] - ] - }, - { - "id": "btn_pump_c_stop", - "type": "ui-button", - "z": "tab_ui", - "group": "ui_grp_pump_c", - "name": "Pump C shutdown", - "label": "Shutdown", - "order": 1, - "width": "0", - "height": "0", - "tooltip": "", - "color": "#ffffff", - "bgcolor": "#ea580c", - "className": "", - "icon": "stop", - "iconPosition": "left", - "payload": "fired", - "payloadType": "str", - "topic": "stop_pump_c", - "topicType": "str", - "buttonType": "default", - "x": 120, - "y": 2680, - "wires": [ - [ - "wrap_pump_c_stop" - ] - ] - }, - { - "id": "wrap_pump_c_stop", - "type": "function", - "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": 480, - "y": 2680, - "wires": [ - [ - "lout_seq_pump_c_dash" - ] - ] - }, - { - "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": 2655, - "wires": [] - }, - { - "id": "c_ui_trends", - "type": "comment", - "z": "tab_ui", - "name": "── Trend charts (1h rolling) ──", - "info": "", - "x": 640, - "y": 2840, - "wires": [] - }, - { - "id": "chart_trend_basin", - "type": "ui-chart", - "z": "tab_ui", - "group": "ui_grp_tr_basin", - "name": "Basin level + fill %", - "label": "Basin level + fill", - "order": 1, - "chartType": "line", - "interpolation": "linear", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "xmin": "", - "xmax": "", - "yAxisLabel": "m / %", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "ymin": "", - "ymax": "", - "removeOlder": "60", - "removeOlderUnit": "60", - "removeOlderPoints": "3600", - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "showLegend": true, - "bins": 10, - "colors": [ - "#0095FF", - "#FF0000", - "#FF7F0E", - "#2CA02C", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "width": 12, - "height": 8, - "className": "", - "x": 900, - "y": 2880, - "wires": [ - [] - ] - }, - { - "id": "chart_trend_demand", - "type": "ui-chart", - "z": "tab_ui", - "group": "ui_grp_tr_demand", - "name": "PS process demand %", - "label": "PS demand", - "order": 1, - "chartType": "line", - "interpolation": "linear", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "xmin": "", - "xmax": "", - "yAxisLabel": "%", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "ymin": "0", - "ymax": "110", - "removeOlder": "60", - "removeOlderUnit": "60", - "removeOlderPoints": "3600", - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "showLegend": true, - "bins": 10, - "colors": [ - "#0095FF", - "#FF0000", - "#FF7F0E", - "#2CA02C", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "width": 12, - "height": 6, - "className": "", - "x": 900, - "y": 2920, - "wires": [ - [] - ] - }, - { - "id": "chart_trend_dq", - "type": "ui-chart", - "z": "tab_ui", - "group": "ui_grp_tr_dq", - "name": "ΔQ — inflow − outflow", - "label": "ΔQ", - "order": 1, - "chartType": "line", - "interpolation": "linear", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "xmin": "", - "xmax": "", - "yAxisLabel": "m³/h", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "ymin": "", - "ymax": "", - "removeOlder": "60", - "removeOlderUnit": "60", - "removeOlderPoints": "3600", - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "showLegend": true, - "bins": 10, - "colors": [ - "#0095FF", - "#FF0000", - "#FF7F0E", - "#2CA02C", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "width": 12, - "height": 6, - "className": "", - "x": 900, - "y": 2940, - "wires": [ - [] - ] - }, - { - "id": "chart_trend_states", - "type": "ui-chart", - "z": "tab_ui", - "group": "ui_grp_tr_states", - "name": "Pump state timeline", - "label": "Pump states (A=0-2, B=3-5, C=6-8)", - "order": 1, - "chartType": "line", - "interpolation": "step", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "xmin": "", - "xmax": "", - "yAxisLabel": "A B C tracks", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "ymin": "-0.5", - "ymax": "8.5", - "removeOlder": "60", - "removeOlderUnit": "60", - "removeOlderPoints": "3600", - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "showLegend": true, - "bins": 10, - "colors": [ - "#0095FF", - "#FF0000", - "#FF7F0E", - "#2CA02C", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "width": 12, - "height": 6, - "className": "", - "x": 900, - "y": 2960, - "wires": [ - [] - ] - }, - { - "id": "chart_trend_flow", - "type": "ui-chart", - "z": "tab_ui", - "group": "ui_grp_tr_flow", - "name": "Inflow / Outflow / Per-pump flow", - "label": "Flows", - "order": 1, - "chartType": "line", - "interpolation": "linear", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "xmin": "", - "xmax": "", - "yAxisLabel": "m³/h", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "ymin": "", - "ymax": "", - "removeOlder": "60", - "removeOlderUnit": "60", - "removeOlderPoints": "3600", - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "showLegend": true, - "bins": 10, - "colors": [ - "#0095FF", - "#FF0000", - "#FF7F0E", - "#2CA02C", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "width": 12, - "height": 8, - "className": "", - "x": 900, - "y": 2960, - "wires": [ - [] - ] - }, - { - "id": "chart_trend_power", - "type": "ui-chart", - "z": "tab_ui", - "group": "ui_grp_tr_power", - "name": "Per-pump power", - "label": "Power", - "order": 1, - "chartType": "line", - "interpolation": "linear", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "xmin": "", - "xmax": "", - "yAxisLabel": "kW", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "ymin": "", - "ymax": "", - "removeOlder": "60", - "removeOlderUnit": "60", - "removeOlderPoints": "3600", - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "showLegend": true, - "bins": 10, - "colors": [ - "#0095FF", - "#FF0000", - "#FF7F0E", - "#2CA02C", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "width": 12, - "height": 8, - "className": "", - "x": 900, - "y": 3040, - "wires": [ - [] - ] - }, - { - "id": "chart_trend_pressure", - "type": "ui-chart", - "z": "tab_ui", - "group": "ui_grp_tr_press", - "name": "Per-pump up/dn pressure", - "label": "Pressure", - "order": 1, - "chartType": "line", - "interpolation": "linear", - "category": "topic", - "categoryType": "msg", - "xAxisLabel": "", - "xAxisType": "time", - "xAxisProperty": "", - "xAxisPropertyType": "timestamp", - "xAxisFormat": "", - "xAxisFormatType": "auto", - "xmin": "", - "xmax": "", - "yAxisLabel": "mbar", - "yAxisProperty": "payload", - "yAxisPropertyType": "msg", - "ymin": "", - "ymax": "", - "removeOlder": "60", - "removeOlderUnit": "60", - "removeOlderPoints": "3600", - "action": "append", - "stackSeries": false, - "pointShape": "circle", - "pointRadius": 4, - "showLegend": true, - "bins": 10, - "colors": [ - "#0095FF", - "#FF0000", - "#FF7F0E", - "#2CA02C", - "#A347E1", - "#D62728", - "#FF9896", - "#9467BD", - "#C5B0D5" - ], - "textColor": [ - "#666666" - ], - "textColorDefault": true, - "gridColor": [ - "#e5e5e5" - ], - "gridColorDefault": true, - "width": 12, - "height": 8, - "className": "", - "x": 900, - "y": 3120, - "wires": [ - [] - ] - }, - { - "id": "tab_drivers", - "type": "tab", - "label": "🎛️ Demo Drivers", - "disabled": false, - "info": "Inflow generator. The operator picks a SCENARIO (Constant / Sine / Diurnal / Storm) on the dashboard and sets a BASELINE m³/h value. Every second this generator emits q_in to the PS based on the active scenario + baseline.\n\nOutflow is implicit: the pumps drain the basin via MGC." - }, - { - "id": "c_drv_title", - "type": "comment", - "z": "tab_drivers", - "name": "🎛️ DEMO DRIVERS — operator-driven inflow generator", - "info": "", - "x": 640, - "y": 20, - "wires": [] - }, - { - "id": "lin_inflow_scenario", - "type": "link in", - "z": "tab_drivers", - "name": "cmd:inflow-scenario", - "links": [ - "lout_inflow_scenario", - "lout_setup_inflow_scn" - ], - "x": 120, - "y": 100, - "wires": [ - [ - "inflow_state" - ] - ] - }, - { - "id": "lin_inflow_baseline", - "type": "link in", - "z": "tab_drivers", - "name": "cmd:inflow-baseline", - "links": [ - "lout_inflow_baseline", - "lout_setup_inflow_baseline" - ], - "x": 120, - "y": 140, - "wires": [ - [ - "inflow_state" - ] - ] - }, - { - "id": "inflow_tick", - "type": "inject", - "z": "tab_drivers", - "name": "tick (1 Hz)", - "props": [ - { - "p": "topic", - "vt": "str" - }, - { - "p": "payload", - "v": "", - "vt": "date" - } - ], - "topic": "tick", - "payload": "", - "payloadType": "date", - "repeat": "1", - "crontab": "", - "once": false, - "onceDelay": "0.5", - "x": 120, - "y": 200, - "wires": [ - [ - "inflow_state" - ] - ] - }, - { - "id": "inflow_state", - "type": "function", - "z": "tab_drivers", - "name": "inflow scenario engine", - "func": "let scenario = context.get('scenario') || 'constant';\nlet baseline = context.get('baseline');\nif (baseline == null) baseline = 60;\n\nif (msg.topic === 'inflowBaseline') {\n const v = Number(msg.payload);\n if (Number.isFinite(v) && v >= 0) {\n baseline = v;\n context.set('baseline', baseline);\n }\n return null;\n}\nif (msg.topic === 'scenario') {\n const s = String(msg.payload || '').toLowerCase();\n if (['constant','sine','diurnal','storm'].includes(s)) {\n scenario = s;\n context.set('scenario', scenario);\n }\n return null;\n}\nconst t = Date.now() / 1000;\nlet q_h;\nswitch (scenario) {\n case 'sine': {\n q_h = baseline * (1 + 0.5 * Math.sin(2 * Math.PI * t / 240));\n break;\n }\n case 'diurnal': {\n q_h = baseline * (1 + 0.6 * Math.sin(2 * Math.PI * t / 480 - Math.PI/2));\n break;\n }\n case 'storm': {\n const phase = (t % 240) / 240;\n let factor;\n if (phase < 0.15) factor = 1 + (4 / 0.15) * phase;\n else factor = Math.max(1, 5 - (4 / 0.85) * (phase - 0.15));\n q_h = baseline * factor;\n break;\n }\n case 'constant':\n default:\n q_h = baseline;\n}\nq_h = Math.max(0, q_h);\nconst q_s = q_h / 3600;\nreturn [\n { topic: 'q_in', payload: q_s, unit: 'm3/s', timestamp: Date.now() },\n { payload: { scenario, baseline, q_h, q_s, ts: Date.now() } },\n];", - "outputs": 2, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 640, - "y": 160, - "wires": [ - [ - "lout_qin_drivers" - ], - [ - "lout_evt_inflow" - ] - ] - }, - { - "id": "lout_qin_drivers", - "type": "link out", - "z": "tab_drivers", - "name": "cmd:q_in", - "mode": "link", - "links": [ - "lin_qin_at_ps" - ], - "x": 900, - "y": 140, - "wires": [] - }, - { - "id": "lout_evt_inflow", - "type": "link out", - "z": "tab_drivers", - "name": "evt:inflow", - "mode": "link", - "links": [ - "lin_evt_inflow" - ], - "x": 900, - "y": 180, - "wires": [] - }, - { - "id": "tab_setup", - "type": "tab", - "label": "⚙️ Setup & Init", - "disabled": false, - "info": "One-shot deploy-time injects:\n • MGC scaling = normalized + mode = optimalcontrol\n • all pumps mode = auto\n • initial inflow baseline + scenario\n\nDisable this tab in production." - }, - { - "id": "c_setup_title", - "type": "comment", - "z": "tab_setup", - "name": "⚙️ SETUP & INIT — one-shot deploy-time injects", - "info": "", - "x": 640, - "y": 20, - "wires": [] - }, - { - "id": "setup_mgc_scaling", - "type": "inject", - "z": "tab_setup", - "name": "MGC scaling = normalized", - "props": [ - { - "p": "topic", - "vt": "str" - }, - { - "p": "payload", - "v": "normalized", - "vt": "str" - } - ], - "topic": "setScaling", - "payload": "normalized", - "payloadType": "str", - "repeat": "", - "crontab": "", - "once": true, - "onceDelay": "1.5", - "x": 120, - "y": 100, - "wires": [ - [ - "lout_setup_to_mgc" - ] - ] - }, - { - "id": "setup_mgc_mode", - "type": "inject", - "z": "tab_setup", - "name": "MGC mode = optimalcontrol", - "props": [ - { - "p": "topic", - "vt": "str" - }, - { - "p": "payload", - "v": "optimalcontrol", - "vt": "str" - } - ], - "topic": "setMode", - "payload": "optimalcontrol", - "payloadType": "str", - "repeat": "", - "crontab": "", - "once": true, - "onceDelay": "1.7", - "x": 120, - "y": 160, - "wires": [ - [ - "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": "tab_setup", - "name": "pumps mode = auto", - "props": [ - { - "p": "topic", - "vt": "str" - }, - { - "p": "payload", - "v": "auto", - "vt": "str" - } - ], - "topic": "setMode", - "payload": "auto", - "payloadType": "str", - "repeat": "", - "crontab": "", - "once": true, - "onceDelay": "2.0", - "x": 120, - "y": 240, - "wires": [ - [ - "lout_mode_setup" - ] - ] - }, - { - "id": "lout_mode_setup", - "type": "link out", - "z": "tab_setup", - "name": "cmd:mode", - "mode": "link", - "links": [ - "lin_mode" - ], - "x": 380, - "y": 240, - "wires": [] - }, - { - "id": "setup_inflow_baseline", - "type": "inject", - "z": "tab_setup", - "name": "inflow baseline = 25 m³/h (nominal)", - "props": [ - { - "p": "topic", - "vt": "str" - }, - { - "p": "payload", - "v": "25", - "vt": "num" - } - ], - "topic": "inflowBaseline", - "payload": "25", - "payloadType": "num", - "repeat": "", - "crontab": "", - "once": true, - "onceDelay": "2.5", - "x": 120, - "y": 320, - "wires": [ - [ - "lout_setup_inflow_baseline" - ] - ] - }, - { - "id": "lout_setup_inflow_baseline", - "type": "link out", - "z": "tab_setup", - "name": "cmd:inflow-baseline", - "mode": "link", - "links": [ - "lin_inflow_baseline" - ], - "x": 380, - "y": 320, - "wires": [] - }, - { - "id": "setup_inflow_scenario", - "type": "inject", - "z": "tab_setup", - "name": "inflow scenario = sine", - "props": [ - { - "p": "topic", - "vt": "str" - }, - { - "p": "payload", - "v": "sine", - "vt": "str" - } - ], - "topic": "scenario", - "payload": "sine", - "payloadType": "str", - "repeat": "", - "crontab": "", - "once": true, - "onceDelay": "2.7", - "x": 120, - "y": 380, - "wires": [ - [ - "lout_setup_inflow_scn" - ] - ] - }, - { - "id": "lout_setup_inflow_scn", - "type": "link out", - "z": "tab_setup", - "name": "cmd:inflow-scenario", - "mode": "link", - "links": [ - "lin_inflow_scenario" - ], - "x": 380, - "y": 380, - "wires": [] - }, - { - "id": "setup_calibrate_level", - "type": "inject", - "z": "tab_setup", - "name": "[manual] calibrate basin = 1.0 m (click to reset)", - "props": [ - { - "p": "topic", - "vt": "str" - }, - { - "p": "payload", - "v": "1.0", - "vt": "num" - } - ], - "topic": "calibratePredictedLevel", - "payload": "1.0", - "payloadType": "num", - "repeat": "", - "crontab": "", - "once": false, - "onceDelay": "0.5", - "x": 120, - "y": 460, - "wires": [ - [ - "lout_setup_calibrate" - ] - ] - }, - { - "id": "lout_setup_calibrate", - "type": "link out", - "z": "tab_setup", - "name": "setup:calibrate-ps", - "mode": "link", - "links": [ - "lin_setup_calibrate_ps" - ], - "x": 380, - "y": 460, - "wires": [] - }, - { - "id": "tab_telemetry", - "type": "tab", - "label": "📈 Telemetry", - "disabled": false, - "info": "InfluxDB writer: every EVOLV node's port-1 telemetry is fanned in via the evt:tlm link channel, converted to line protocol, and POSTed to InfluxDB v2 (org=evolv, bucket=telemetry).\n\nPattern adapted from docker/demo-flow.json." - }, - { - "id": "c_tlm_title", - "type": "comment", - "z": "tab_telemetry", - "name": "📈 TELEMETRY — InfluxDB writer", - "info": "", - "x": 640, - "y": 20, - "wires": [] - }, - { - "id": "lin_tlm", - "type": "link in", - "z": "tab_telemetry", - "name": "evt:tlm", - "links": [ - "lout_tlm_pump_a", - "lout_tlm_meas_pump_a_u", - "lout_tlm_meas_pump_a_d", - "lout_tlm_meas_pump_a_f", - "lout_tlm_meas_pump_a_p", - "lout_tlm_pump_b", - "lout_tlm_meas_pump_b_u", - "lout_tlm_meas_pump_b_d", - "lout_tlm_meas_pump_b_f", - "lout_tlm_meas_pump_b_p", - "lout_tlm_pump_c", - "lout_tlm_meas_pump_c_u", - "lout_tlm_meas_pump_c_d", - "lout_tlm_meas_pump_c_f", - "lout_tlm_meas_pump_c_p", - "lout_tlm_mgc", - "lout_tlm_ps" - ], - "x": 120, - "y": 100, - "wires": [ - [ - "fn_tlm_to_lp" - ] - ] - }, - { - "id": "fn_tlm_to_lp", - "type": "function", - "z": "tab_telemetry", - "name": "→ InfluxDB line protocol", - "func": "const p = msg.payload;\nif (!p || !p.measurement || !p.fields) return null;\nconst esc = (s) => String(s)\n .replace(/,/g, '\\\\,').replace(/ /g, '\\\\ ').replace(/=/g, '\\\\=');\nconst tags = Object.entries(p.tags || {})\n .filter(([k, v]) => v !== undefined && v !== null && v !== '')\n .map(([k, v]) => `${esc(k)}=${esc(v)}`).join(',');\nconst fieldPairs = Object.entries(p.fields)\n .filter(([k, v]) => v !== undefined && v !== null)\n .map(([k, v]) => {\n if (typeof v === 'number' && Number.isFinite(v)) return `${esc(k)}=${v}`;\n if (typeof v === 'boolean') return `${esc(k)}=${v}`;\n return `${esc(k)}=\"${String(v).replace(/\"/g, '\\\\\"')}\"`;\n });\nif (fieldPairs.length === 0) return null;\nconst ts = Date.now() * 1000000;\nmsg.payload = `${esc(p.measurement)}${tags ? ',' + tags : ''} `\n + `${fieldPairs.join(',')} ${ts}`;\n// Hint the join node to fire on size or timeout.\nmsg.topic = 'tlm';\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 640, - "y": 100, - "wires": [ - [ - "join_tlm" - ] - ] - }, - { - "id": "join_tlm", - "type": "join", - "z": "tab_telemetry", - "name": "batch (200 lines / 2 s)", - "mode": "custom", - "build": "string", - "property": "payload", - "propertyType": "msg", - "key": "topic", - "joiner": "\\n", - "joinerType": "str", - "accumulate": false, - "timeout": "2", - "count": "200", - "reduceRight": false, - "reduceExp": "", - "reduceInit": "", - "reduceInitType": "", - "reduceFixup": "", - "x": 900, - "y": 100, - "wires": [ - [ - "fn_tlm_post" - ] - ] - }, - { - "id": "fn_tlm_post", - "type": "function", - "z": "tab_telemetry", - "name": "wrap as InfluxDB POST", - "func": "// Count lines for status reporting.\nconst body = String(msg.payload || '');\nconst lineCount = body ? body.split('\\n').length : 0;\nif (lineCount === 0) return null;\nmsg.lineCount = lineCount;\nmsg.headers = {\n 'Authorization': 'Token evolv-dev-token',\n 'Content-Type': 'text/plain'\n};\nmsg.url = 'http://influxdb:8086/api/v2/write?org=evolv&bucket=telemetry&precision=ns';\nmsg.method = 'POST';\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1100, - "y": 100, - "wires": [ - [ - "http_tlm" - ] - ] - }, - { - "id": "http_tlm", - "type": "http request", - "z": "tab_telemetry", - "name": "Write InfluxDB", - "method": "use", - "ret": "txt", - "paytoqs": "ignore", - "url": "", - "tls": "", - "persist": false, - "proxy": "", - "authType": "", - "senderr": false, - "x": 1240, - "y": 100, - "wires": [ - [ - "fn_tlm_count" - ] - ] - }, - { - "id": "fn_tlm_count", - "type": "function", - "z": "tab_telemetry", - "name": "Count writes", - "func": "const lines = Number(msg.lineCount) || 0;\nconst writes = (global.get('influx_writes') || 0) + 1;\nconst totalLines = (global.get('influx_lines') || 0) + lines;\nglobal.set('influx_writes', writes);\nglobal.set('influx_lines', totalLines);\nconst errors = global.get('influx_errors') || 0;\nif (msg.statusCode && msg.statusCode >= 400) {\n global.set('influx_errors', errors + 1);\n node.status({fill:'red', shape:'ring',\n text:`ERR ${errors+1}: ${msg.statusCode}`});\n} else {\n node.status({fill:'green', shape:'dot',\n text:`${writes} POSTs · ${totalLines} lines (${errors} err)`});\n}\nreturn null;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1420, - "y": 100, - "wires": [ - [] - ] - } -] diff --git a/jest.config.js b/jest.config.js index 9569eff..a0c346b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,6 +6,7 @@ module.exports = { '/nodes/generalFunctions/test/**/*.test.js', '/nodes/dashboardAPI/test/**/*.test.js', '/nodes/diffuser/test/specificClass.test.js', + '/nodes/coresync/test/**/*.test.js', '/nodes/monster/test/**/*.test.js', '/nodes/pumpingStation/test/**/*.test.js', '/nodes/reactor/test/**/*.test.js', diff --git a/nodes/coresync b/nodes/coresync new file mode 160000 index 0000000..aefec90 --- /dev/null +++ b/nodes/coresync @@ -0,0 +1 @@ +Subproject commit aefec90485b1d4438360c67a5fc160e89e67db7e diff --git a/nodes/dashboardAPI b/nodes/dashboardAPI index e04c4a1..dac8576 160000 --- a/nodes/dashboardAPI +++ b/nodes/dashboardAPI @@ -1 +1 @@ -Subproject commit e04c4a113288192bf4388a8b6da1df7344ae1318 +Subproject commit dac8576cab6c66032c93c4abb37ffe8f1ac2d2ae diff --git a/nodes/diffuser b/nodes/diffuser index bf645cf..f5fd803 160000 --- a/nodes/diffuser +++ b/nodes/diffuser @@ -1 +1 @@ -Subproject commit bf645cfe6859b81a339490c23b8c531c5e6f0e69 +Subproject commit f5fd8039f502086ee57b027d22f62a900f313749 diff --git a/nodes/generalFunctions b/nodes/generalFunctions index ae30cef..6c4db03 160000 --- a/nodes/generalFunctions +++ b/nodes/generalFunctions @@ -1 +1 @@ -Subproject commit ae30cef89c6f1e72d15a7e8b392b5077e80797e3 +Subproject commit 6c4db03aba7e2f96d9f4c3d7bb1e3e49d4c70fee diff --git a/nodes/machineGroupControl b/nodes/machineGroupControl index aeb938c..a47aa53 160000 --- a/nodes/machineGroupControl +++ b/nodes/machineGroupControl @@ -1 +1 @@ -Subproject commit aeb938c205ea52a1bdab74233c649ae5076df9fe +Subproject commit a47aa53d17ef353641ff1edb5cf848304dd0b530 diff --git a/nodes/measurement b/nodes/measurement index b0e8bbb..5d79314 160000 --- a/nodes/measurement +++ b/nodes/measurement @@ -1 +1 @@ -Subproject commit b0e8bbb95d1be7a51e4bde8159b66dc9e68a8f77 +Subproject commit 5d793142290bbb09a51bfcae6aeaf0c334ebe1d7 diff --git a/nodes/monster b/nodes/monster index 4eb2867..6c88b64 160000 --- a/nodes/monster +++ b/nodes/monster @@ -1 +1 @@ -Subproject commit 4eb286771e3e33575f308a91cdb32acbff93763e +Subproject commit 6c88b6464d63d678e1dd4d4a97b4ed459e042302 diff --git a/nodes/pumpingStation b/nodes/pumpingStation index 2e4ad8d..df18e97 160000 --- a/nodes/pumpingStation +++ b/nodes/pumpingStation @@ -1 +1 @@ -Subproject commit 2e4ad8d3f195ef4f4828fd434d1b20a4a6d89400 +Subproject commit df18e97b8bb3caf6a4e92be596869b2270befa3f diff --git a/nodes/reactor b/nodes/reactor index 75d0413..46fc8dd 160000 --- a/nodes/reactor +++ b/nodes/reactor @@ -1 +1 @@ -Subproject commit 75d04139947a5dd628cc37536091997c73545d25 +Subproject commit 46fc8dddf7f9ad073256265b456d1b1369a89e62 diff --git a/nodes/rotatingMachine b/nodes/rotatingMachine index 8c5822c..a18aec3 160000 --- a/nodes/rotatingMachine +++ b/nodes/rotatingMachine @@ -1 +1 @@ -Subproject commit 8c5822c8533681f4e86a31e06156436faa5ff2f0 +Subproject commit a18aec32b99f4bfedcfe90332fd5438281bb9ce2 diff --git a/nodes/settler b/nodes/settler index 70acef2..0ba28b9 160000 --- a/nodes/settler +++ b/nodes/settler @@ -1 +1 @@ -Subproject commit 70acef22d5306482e7c147b2d7ae72b9b273d97a +Subproject commit 0ba28b9cdfc2c3bb8fb1f78f6cf2796393389ea5 diff --git a/nodes/valve b/nodes/valve index 167b102..74951e7 160000 --- a/nodes/valve +++ b/nodes/valve @@ -1 +1 @@ -Subproject commit 167b1026f175f2f5f655ffcfe6ff503fe49a65db +Subproject commit 74951e7a233b0f9f755b4a53a4e2243c34195e55 diff --git a/nodes/valveGroupControl b/nodes/valveGroupControl index 91f9841..bd67b22 160000 --- a/nodes/valveGroupControl +++ b/nodes/valveGroupControl @@ -1 +1 @@ -Subproject commit 91f98414d11c142a5cfb5efeb4154cc154720e10 +Subproject commit bd67b22197a2564dae3344e759acc3db47f8a73e diff --git a/package.json b/package.json index 7aba41f..fc9e3d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "EVOLV", - "version": "1.0.29", + "version": "1.0.32", "description": "Modular Node-RED package containing all control and automation nodes developed under the EVOLV project.", "keywords": [ "node-red", @@ -11,8 +11,9 @@ ], "node-red": { "nodes": { - "dashboardapi": "nodes/dashboardAPI/dashboardapi.js", + "dashboardapi": "nodes/dashboardAPI/dashboardAPI.js", "diffuser": "nodes/diffuser/diffuser.js", + "coresync": "nodes/coresync/coresync.js", "machineGroupControl": "nodes/machineGroupControl/mgc.js", "measurement": "nodes/measurement/measurement.js", "monster": "nodes/monster/monster.js", @@ -25,8 +26,6 @@ } }, "scripts": { - "preinstall": "node scripts/patch-deps.js", - "postinstall": "git checkout -- package.json 2>/dev/null || true", "docker:build": "docker compose build", "docker:up": "docker compose up -d", "docker:down": "docker compose down",