Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aec90cc8e7 | ||
|
|
6fef002da1 | ||
|
|
c4d75809cd | ||
|
|
a4617d850a | ||
|
|
44963cfa43 | ||
|
|
15c39f76bb | ||
|
|
3c7d54e9c3 | ||
|
|
21e777797a | ||
|
|
035f03cdee | ||
|
|
9bc6908d05 | ||
|
|
0cab98c196 | ||
|
|
ca0644d689 | ||
|
|
5766ee4d16 | ||
|
|
0466287514 | ||
|
|
21b0bd34c6 | ||
|
|
60c6a647e2 | ||
|
|
48b9335dac | ||
|
|
7c6c6183f7 | ||
|
|
2593458bdf | ||
|
|
36147de6d7 | ||
|
|
b84c59cbe6 | ||
|
|
b873a8fb02 | ||
|
|
7e51bec8f2 | ||
|
|
aa546df6e6 | ||
|
|
c413c0fad5 | ||
|
|
a6ad85ae38 | ||
|
|
33ac527274 | ||
|
|
d22d1cabd1 | ||
|
|
79afe11da8 | ||
|
|
b885f291d4 | ||
|
|
4a9521154b | ||
|
|
732b5a3380 | ||
|
|
c8f149e204 | ||
|
|
b693e0b90c | ||
|
|
2b0c4e89b1 | ||
|
|
faaeb2efd3 | ||
|
|
53b55d81c3 | ||
|
|
eb97670179 | ||
|
|
cc4ee670ea | ||
|
|
a51bc46e26 | ||
|
|
b18c47c07e | ||
|
|
60c8d0ff66 | ||
|
|
658915c53e | ||
|
|
0cbd6a4077 | ||
|
|
bc8138c3dc | ||
|
|
06d81169e8 | ||
|
|
82db2953e9 | ||
|
|
d439b048f2 | ||
|
|
e280d87e6a | ||
|
|
64944aa9d8 | ||
|
|
0d7af6bfff | ||
|
|
7aacee6482 | ||
|
|
d7d106773e | ||
|
|
89f3b5ddc4 | ||
|
|
d0fe4d0583 | ||
|
|
0300a76ae8 | ||
|
|
a1aa44f6ca | ||
|
|
6cf1821161 | ||
|
|
48f790d123 | ||
|
|
bac6c620b1 | ||
|
|
7ded2a4415 | ||
|
|
6d19038784 | ||
|
|
fd9d1679cb | ||
|
|
4336002b77 | ||
|
|
f57343f5e3 | ||
|
|
65ceb696ab | ||
|
|
91a298960c | ||
|
|
35221fc5dd | ||
|
|
93a5b6a90e | ||
|
|
1d98670706 | ||
|
|
a432eea7fe | ||
|
|
9cb3657bae | ||
|
|
bd9432eebb | ||
|
|
c9bacb64c8 | ||
|
|
e580c93c84 | ||
|
|
b02306c42f | ||
|
|
2c76430394 | ||
|
|
49ebd833db | ||
|
|
905a061590 | ||
|
|
80de324b32 | ||
|
|
c8d5ea0fce | ||
|
|
b871b23c24 | ||
|
|
91b681a74d | ||
|
|
76d2008e52 | ||
|
|
3c304f14e5 | ||
|
|
24c443840b | ||
|
|
c4c8629c01 | ||
| 609c72cedc |
@@ -2,7 +2,7 @@
|
||||
|
||||
## Context
|
||||
- Task/request: Adapt EVOLV agents/skills using Harness Engineering patterns and set owner-controlled operating defaults.
|
||||
- Impacted files/contracts: `AGENTS.md`, `.agents/skills/*/SKILL.md`, `.agents/skills/*/agents/openai.yaml`, decision-log policy.
|
||||
- Impacted files/contracts: `.agents/AGENTS.md`, `.agents/skills/*/SKILL.md`, `.agents/skills/*/agents/openai.yaml`, decision-log policy.
|
||||
- Why a decision is required now: New harness workflow needs explicit defaults for compatibility, safety bias, and governance discipline.
|
||||
|
||||
## Options
|
||||
@@ -30,9 +30,9 @@
|
||||
- Data/operations impact: Decision traceability improves cross-turn consistency and auditability.
|
||||
|
||||
## Implementation Notes
|
||||
- Required code/doc updates: Set defaults in `AGENTS.md` and orchestrator skill instructions; keep decision-log template active.
|
||||
- Required code/doc updates: Set defaults in `.agents/AGENTS.md` and orchestrator skill instructions; keep decision-log template active.
|
||||
- Validation evidence required: Presence of defaults in policy docs and this decision artifact under `.agents/decisions/`.
|
||||
|
||||
## Rollback / Migration
|
||||
- Rollback strategy: Update defaults in `AGENTS.md` and orchestrator SKILL; create a superseding decision log entry.
|
||||
- Rollback strategy: Update defaults in `.agents/AGENTS.md` and orchestrator SKILL; create a superseding decision log entry.
|
||||
- Migration/deprecation plan: For any future hard-break preference, require explicit migration plan and effective date in a new decision entry.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
## Context
|
||||
|
||||
The single demo bioreactor did not reflect the intended EVOLV biological treatment concept. The owner requested:
|
||||
|
||||
- four reactor zones in series
|
||||
- staged aeration based on effluent NH4
|
||||
- local visualization per zone for NH4, NO3, O2, and other relevant state variables
|
||||
- improved PFR numerical stability by increasing reactor resolution
|
||||
|
||||
The localhost deployment also needed to remain usable for E2E debugging with Node-RED, InfluxDB, and Grafana.
|
||||
|
||||
## Options Considered
|
||||
|
||||
1. Keep one large PFR and add more internal profile visualization only.
|
||||
2. Split the biology into four explicit reactor zones in the flow and control aeration at zone level.
|
||||
3. Replace the PFR demo with a simpler CSTR train for faster visual response.
|
||||
|
||||
## Decision
|
||||
|
||||
Choose option 2.
|
||||
|
||||
The demo flow now uses four explicit PFR zones in series with:
|
||||
|
||||
- equal-zone sizing (`4 x 500 m3`, total `2000 m3`)
|
||||
- explicit `Fluent` forwarding between zones
|
||||
- common clocking for all zones
|
||||
- external `OTR` control instead of fixed `kla`
|
||||
- staged NH4-based aeration escalation with 30-minute hold logic
|
||||
- per-zone telemetry to InfluxDB and Node-RED dashboard charts
|
||||
|
||||
For runtime stability on localhost, the demo uses a higher spatial resolution with moderate compute load rather than the earlier single-reactor setup.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The flow is easier to reason about operationally because each aeration zone is explicit.
|
||||
- Zone-level telemetry is available for dashboarding and debugging.
|
||||
- PFR outlet response remains residence-time dependent, so zone outlet composition will not change instantly after startup or inflow changes.
|
||||
- Grafana datasource query round-trip remains valid, but dashboard auto-generation still needs separate follow-up if strict dashboard creation is required in E2E checks.
|
||||
|
||||
## Rollback / Migration Notes
|
||||
|
||||
- Rolling back to the earlier demo means restoring the single `demo_reactor` topology in `docker/demo-flow.json`.
|
||||
- Existing E2E checks and dashboards should prefer the explicit zone measurements (`reactor_demo_reactor_z1` ... `reactor_demo_reactor_z4`) going forward.
|
||||
@@ -0,0 +1,54 @@
|
||||
# DECISION-20260422-pumpingstation-5-threshold-naming
|
||||
|
||||
## Context
|
||||
- Task/request: Re-draw the pumpingStation basin model and rename the configuration fields so they match the conceptual model used in the wiki.
|
||||
- Impacted files/contracts:
|
||||
- `generalFunctions/src/configs/pumpingStation.json` (schema keys)
|
||||
- `nodes/pumpingStation/src/specificClass.js` (internal state + comments)
|
||||
- `nodes/pumpingStation/src/nodeClass.js` (config ingestion mapping)
|
||||
- `nodes/pumpingStation/pumpingStation.html` (editor field IDs + labels)
|
||||
- `nodes/pumpingStation/test/*` (test fixtures)
|
||||
- `examples/pumpingstation-3pumps-dashboard/{build_flow.py, flow.json, README.md}`
|
||||
- saved production flows that reference the old field names (breaking change)
|
||||
- Why a decision is required now: The old names (`stopLevel`, `maxFlowLevel`, `minFlowLevel`, `heightInlet/Outlet/Overflow`) conflated geometry with control thresholds and had a redundant field (`minFlowLevel` always had to equal `startLevel`).
|
||||
|
||||
## Options
|
||||
1. Keep old names; just document them better
|
||||
- Benefits: Zero breaking change.
|
||||
- Risks: Naming keeps confusing new contributors; docs continue drifting from code.
|
||||
|
||||
2. Adopt the 5-threshold naming from the wiki basin diagram (selected)
|
||||
- Benefits: Clear semantic split — two safety thresholds (`dryRunLevel`, `overflowLevel`), three control thresholds (`minLevel`, `startLevel`, `maxLevel`) — plus three physical pipe heights (`inflowLevel`, `outflowLevel`, basin `height`). Drops the redundant `minFlowLevel`. Matches the diagram in the functional description.
|
||||
- Risks: Breaking change for saved flows; node editor fields must be re-entered.
|
||||
- Rollout notes: RnD/trial node — no compat shim. Breaking change documented in commit bodies and wiki.
|
||||
|
||||
## Decision
|
||||
- Selected option: Option 2.
|
||||
- Decision owner: User
|
||||
- Date: 2026-04-22
|
||||
- Rationale: The names should reflect the model. The diagram came first; the code should match the diagram, not the other way around. Compat posture is "controlled" (per DECISION-20260216) — breaking changes are permitted with migration notes.
|
||||
|
||||
## Mapping
|
||||
| Old | New |
|
||||
|---|---|
|
||||
| `heightInlet` | `inflowLevel` |
|
||||
| `heightOutlet` | `outflowLevel` |
|
||||
| `heightOverflow` | `overflowLevel` |
|
||||
| `stopLevel` | `minLevel` |
|
||||
| `maxFlowLevel` | `maxLevel` |
|
||||
| `minFlowLevel` | removed (collapsed into `startLevel`) |
|
||||
| `minVolIn/Out` (internal) | `minVolAtInflow/Outflow` |
|
||||
| `maxVolOverflow` (internal) | `maxVolAtOverflow` |
|
||||
|
||||
## Consequences
|
||||
- Compatibility impact: Existing flows break; editor fields must be re-entered.
|
||||
- Safety/security impact: Safety thresholds (`dryRunLevel`, `overflowLevel`) now have first-class names — guardrail validation can reason about them explicitly.
|
||||
- Data/operations impact: InfluxDB payload field names change (`maxVolOverflow` → `maxVolAtOverflow` etc.). Downstream Grafana dashboards referencing the old names must update.
|
||||
|
||||
## Implementation Notes
|
||||
- Required code/doc updates: Done in commits pumpingStation@a218945, generalFunctions@4252292, EVOLV@b885f29.
|
||||
- Validation evidence required: Unit tests (`node --test test/basic/*.test.js`) pass; `grep -r` confirms zero residual old names in pumpingStation/ + generalFunctions/pumpingStation.json + examples/.
|
||||
|
||||
## Rollback / Migration
|
||||
- Rollback strategy: revert the three commits; the renames are isolated.
|
||||
- Migration/deprecation plan: None — RnD node, breaking change is acceptable.
|
||||
@@ -0,0 +1,46 @@
|
||||
# DECISION-20260422-pumpingstation-mode-tier-template
|
||||
|
||||
## Context
|
||||
- Task/request: Document each pumpingStation control mode uniformly so operators can compare them and contributors can add new ones from a template.
|
||||
- Impacted files/contracts: `wiki/modes/*.md`, `wiki/diagrams/modes/*.drawio.svg`.
|
||||
- Why a decision is required now: The initial `levelbased.md` used a 2D `demand-vs-level` transfer-function plot. That plot form works for static memoryless control but misleads for modes whose curve shape changes at runtime (e.g. `powerBased`) or where there is no curve at all (`mpc`). We need one template that stretches to cover all cases.
|
||||
|
||||
## Options
|
||||
1. One template, transfer-function only
|
||||
- Benefits: Uniformity.
|
||||
- Risks: Silently misleading for Tier-2/Tier-3 modes where the "curve" is not well-defined.
|
||||
|
||||
2. Per-mode ad-hoc diagrams
|
||||
- Benefits: Each mode gets the best visual for itself.
|
||||
- Risks: No common vocabulary — comparing modes becomes harder.
|
||||
|
||||
3. Three-tier template (selected)
|
||||
- Benefits: Classifies every mode into one of three buckets, each with a dedicated diagram type. Still one template — only the diagram section branches.
|
||||
- Risks: Some modes don't fit cleanly; will need judgement.
|
||||
|
||||
## Tier definitions
|
||||
|
||||
| Tier | Control surface | Example modes | Diagrams |
|
||||
|---|---|---|---|
|
||||
| 1 | Static: `demand = f(x)` memoryless | `levelbased`, `manual` | Single-curve transfer function |
|
||||
| 2 | Parameterised: shape fixed, curve moves with `θ(t)` | `flowbased` (PID), `pressureBased`, `percentageBased`, `powerBased` | Transfer function + parameter-overlay / family-of-curves |
|
||||
| 3 | Optimisation / horizon: no fixed curve | `hybrid-optimal`, `mpc`, weather-aware | Block diagram of signal flow + scenario time-series |
|
||||
|
||||
## Decision
|
||||
- Selected option: Option 3 — three-tier classification with diagram type per tier.
|
||||
- Decision owner: User
|
||||
- Date: 2026-04-22
|
||||
- Rationale: Keeps the mode pages comparable (same six sections) while being honest about what's actually drawable. Tier-3 modes get scenario-based analysis (via the `eval/` harness) instead of a fictitious static curve.
|
||||
|
||||
## Consequences
|
||||
- Compatibility impact: None — this is doc-level.
|
||||
- Safety/security impact: None.
|
||||
- Data/operations impact: New modes get a template to follow; reviews have a shared vocabulary.
|
||||
|
||||
## Implementation Notes
|
||||
- Required code/doc updates: `wiki/modes/README.md` lists the tiers and template; `wiki/modes/{flowbased, powerbased, mpc}.md` are worked templates covering Tier 2 (×2) and Tier 3 (×1) respectively.
|
||||
- Validation evidence required: A reviewer reading a mode page can identify which tier it is within 10 seconds without scrolling.
|
||||
|
||||
## Rollback / Migration
|
||||
- Rollback strategy: Delete `wiki/modes/`; revert the table in `wiki/README.md`.
|
||||
- Migration/deprecation plan: N/A — adding a tier later (e.g. Tier 4 — RL-based) is trivially additive.
|
||||
@@ -0,0 +1,57 @@
|
||||
# DECISION-20260422-pumpingstation-simulations-harness
|
||||
|
||||
## Context
|
||||
- Task/request: Provide a way to fluctuate inputs to the pumpingStation and observe the system's response over time, in a readable form suitable for post-hoc analysis (operator review, Grafana, or ad-hoc debugging).
|
||||
- Impacted files/contracts: `nodes/pumpingStation/simulations/*`, `test/basic/*`.
|
||||
- Why a decision is required now: Unit tests (`node --test`) verify individual functions in isolation. They can't ergonomically show "what does the level look like over 20 minutes of storm surge". That's a different artefact.
|
||||
|
||||
## Options
|
||||
1. Extend unit tests to cover scenarios
|
||||
- Benefits: Single testing surface.
|
||||
- Risks: Unit tests are assertion-heavy and slow to read; scenario output (tables, events) gets lost in TAP.
|
||||
|
||||
2. Separate `simulations/` folder with a scenario runner (selected)
|
||||
- Benefits: Scenarios read as narratives ("steady state", "storm surge", "safety dry-run"); output is human-friendly (ASCII table + events + expectation checks); JSONL per-tick log enables Grafana streaming or offline analysis.
|
||||
- Risks: Second test surface to maintain.
|
||||
|
||||
3. Real-time Node-RED deployment + observe
|
||||
- Benefits: Closest to production.
|
||||
- Risks: Slow, requires infrastructure, irreproducible.
|
||||
|
||||
## Decision
|
||||
- Selected option: Option 2.
|
||||
- Decision owner: User
|
||||
- Date: 2026-04-22
|
||||
- Rationale: Unit tests answer "is this function correct?"; scenarios answer "how does the system behave under this input profile?". Two distinct questions — two distinct tools. The split also matches the .claude/rules/testing.md 3-tier convention (basic/integration/edge) which is for asserted behaviours, not scenario replay.
|
||||
|
||||
### Addendum (same-day rename)
|
||||
|
||||
Folder was initially named `eval/`. Renamed to `simulations/` in commit pumpingStation@3e13512 — `eval` and `test` are near-synonyms so the split implied a conceptual difference that doesn't really exist. `simulations/` is more honest about what's happening (scripted plant inputs driving a physics sim, recorded for analysis). Rationale above is unchanged; only the folder name is.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
test/
|
||||
basic/ integration/ edge/ — node:test + assertions
|
||||
simulations/
|
||||
run.js — scenario driver
|
||||
scenarios/*.js — each exports { name, config, setup, inputs(t,ps), expectations }
|
||||
formatters/table.js — ASCII summary
|
||||
logs/*.jsonl — one-line-per-tick output
|
||||
README.md — usage + how to pipe into Grafana
|
||||
```
|
||||
|
||||
Driver monkey-patches `Date.now()` so the volume integrator sees 1 second per tick regardless of wall-clock. Every tick records a state snapshot (level, volume, direction, netFlow, flowSource, demand, mode, safetyActive) to JSONL for streaming.
|
||||
|
||||
## Consequences
|
||||
- Compatibility impact: None.
|
||||
- Safety/security impact: None — read-only simulation.
|
||||
- Data/operations impact: Running `node simulations/run.js --all` produces artefacts that can be checked into CI for regression (e.g. "did the storm scenario's max level rise compared to last release?"). The JSONL format is friendly to InfluxDB/Grafana for interactive review.
|
||||
|
||||
## Implementation Notes
|
||||
- Required code/doc updates: Driver + three starter scenarios (`levelbased-steady`, `levelbased-storm`, `safety-dry-run-trip`) + README in `simulations/`.
|
||||
- Validation evidence required: `node simulations/run.js --all` exits 0; manual inspection of JSONL confirms per-tick records make physical sense.
|
||||
|
||||
## Rollback / Migration
|
||||
- Rollback strategy: Delete `simulations/`. Unit tests continue to work.
|
||||
- Migration/deprecation plan: N/A.
|
||||
@@ -0,0 +1,41 @@
|
||||
# DECISION-20260422-pumpingstation-wiki-in-code-repo
|
||||
|
||||
## Context
|
||||
- Task/request: Document pumpingStation functional behaviour (basin model, control modes, safety). Initial draft went into the Gitea wiki repo (`pumpingStation.wiki.git`).
|
||||
- Impacted files/contracts: location of all pumpingStation documentation; how docs, diagrams, and code stay in sync; wiki UI vs repo browsing UX.
|
||||
- Why a decision is required now: Wiki repo + code repo diverge silently. When `specificClass.js` renames a field, nothing forces the wiki to follow. User preference is "single package" — clone once, edit together, review together.
|
||||
|
||||
## Options
|
||||
1. Keep docs in `pumpingStation.wiki.git` (Gitea's native wiki)
|
||||
- Benefits: Gitea wiki UI (Pages dropdown, `?edit=1`, dedicated URL).
|
||||
- Risks: Two separate repos; code and doc drift silently.
|
||||
- Rollout notes: Status quo as of 2026-04-22.
|
||||
|
||||
2. Move docs + diagrams into `pumpingStation.git/wiki/` (selected)
|
||||
- Benefits: Single package — `git clone pumpingStation` gets code + docs + diagrams. Atomic commits can change code + doc + diagram together. Diagrams version-lock with the class they describe.
|
||||
- Risks: Lose the Gitea wiki Pages dropdown. Browsing is via the repo tree.
|
||||
- Rollout notes: Shrink the `.wiki.git` to a pointer at the new location.
|
||||
|
||||
3. Hybrid — diagrams only in code repo, Markdown pages in `.wiki.git`
|
||||
- Benefits: Keep Gitea wiki UI.
|
||||
- Risks: Image URLs break silently on rename; still two repos to sync.
|
||||
- Rollout notes: Not pursued.
|
||||
|
||||
## Decision
|
||||
- Selected option: Option 2 — everything under `pumpingStation/wiki/`.
|
||||
- Decision owner: User (r.de.ren@brabantsedelta.nl)
|
||||
- Date: 2026-04-22
|
||||
- Rationale: Single package > Gitea wiki UI convenience. Review-as-one-PR pattern is worth more than the Pages dropdown. `wiki/README.md` acts as the index instead.
|
||||
|
||||
## Consequences
|
||||
- Compatibility impact: Anyone bookmarking `RnD/pumpingStation/wiki/Functional-Description` lands on a one-line pointer. Breaking but low-impact.
|
||||
- Safety/security impact: None.
|
||||
- Data/operations impact: Future contributors must know to edit `wiki/` inside the code repo, not the wiki repo. Pointer page on the Gitea wiki explains.
|
||||
|
||||
## Implementation Notes
|
||||
- Required code/doc updates: `pumpingStation/wiki/{functional-description.md, README.md, modes/, diagrams/}` populated; `.wiki.git` Home shrunk.
|
||||
- Validation evidence required: Raw Gitea URLs resolve; `https://gitea.wbd-rd.nl/RnD/pumpingStation/src/branch/main/wiki/` browses cleanly.
|
||||
|
||||
## Rollback / Migration
|
||||
- Rollback strategy: Reverse — copy `pumpingStation/wiki/*.md` back into `.wiki.git`, update `.wiki.git` Home to point at itself.
|
||||
- Migration/deprecation plan: The pointer page stays indefinitely.
|
||||
@@ -42,7 +42,7 @@ You are the EVOLV orchestrator agent. You decompose complex tasks, route to spec
|
||||
|
||||
## Reference Files
|
||||
- `.agents/skills/evolv-orchestrator/SKILL.md` — Full orchestration protocol
|
||||
- `AGENTS.md` — Agent invocation policy, routing table, decision governance
|
||||
- `.agents/AGENTS.md` — Agent invocation policy, routing table, decision governance
|
||||
- `.agents/decisions/` — Decision log directory
|
||||
- `.agents/improvements/IMPROVEMENTS_BACKLOG.md` — Deferred improvements
|
||||
|
||||
@@ -52,4 +52,4 @@ You are the EVOLV orchestrator agent. You decompose complex tasks, route to spec
|
||||
- Owner-approved defaults: compatibility=controlled, safety=availability-first
|
||||
|
||||
## Reasoning Difficulty: Medium-High
|
||||
This agent handles multi-domain task decomposition, cross-cutting impact analysis, and decision governance enforcement. The primary challenge is correctly mapping changes across node boundaries — a single modification can cascade through parent-child relationships, shared contracts, and InfluxDB semantics. When uncertain about cross-domain impact, consult `.agents/skills/evolv-orchestrator/SKILL.md` and `AGENTS.md` before routing to specialist agents.
|
||||
This agent handles multi-domain task decomposition, cross-cutting impact analysis, and decision governance enforcement. The primary challenge is correctly mapping changes across node boundaries — a single modification can cascade through parent-child relationships, shared contracts, and InfluxDB semantics. When uncertain about cross-domain impact, consult `.agents/skills/evolv-orchestrator/SKILL.md` and `.agents/AGENTS.md` before routing to specialist agents.
|
||||
|
||||
501
.claude/rules/node-red-flow-layout.md
Normal file
501
.claude/rules/node-red-flow-layout.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# Node-RED Flow Layout Rules
|
||||
|
||||
How to lay out a multi-tab Node-RED demo or production flow so it is readable, debuggable, and trivially extendable. These rules apply to anything you build with `examples/` flows, dashboards, or production deployments.
|
||||
|
||||
## 1. Tab boundaries — by CONCERN, not by data
|
||||
|
||||
Every node lives on the tab matching its **concern**, never where it happens to be wired:
|
||||
|
||||
| Tab | Lives here | Never here |
|
||||
|---|---|---|
|
||||
| **🏭 Process Plant** | EVOLV nodes (rotatingMachine, MGC, pumpingStation, measurement, reactor, settler, …) + small per-node output formatters | UI widgets, demo drivers, one-shot setup injects |
|
||||
| **📊 Dashboard UI** | All `ui-*` widgets, the wrapper functions that turn a button click into a typed `msg`, the trend-feeder split functions | Anything that produces data autonomously, anything that talks to EVOLV nodes directly |
|
||||
| **🎛️ Demo Drivers** | Random generators, scripted scenarios, schedule injectors, anything that exists only to drive the demo | Real production data sources (those go on Process Plant or are wired in externally) |
|
||||
| **⚙️ Setup & Init** | One-shot `once: true` injects (setMode, setScaling, auto-startup) | Anything that fires more than once |
|
||||
|
||||
**Why these four:** each tab can be disabled or deleted independently. Disable Demo Drivers → demo becomes inert until a real data source is wired. Disable Setup → fresh deploys don't auto-configure (good for debugging). Disable Dashboard UI → headless mode for tests. Process Plant always stays.
|
||||
|
||||
If you find yourself wanting a node "between" two tabs, you've named your concerns wrong — re-split.
|
||||
|
||||
## 2. Cross-tab wiring — link nodes only, named channels
|
||||
|
||||
Never wire a node on tab A directly to a node on tab B. Use **named link-out / link-in pairs**:
|
||||
|
||||
```text
|
||||
[ui-slider] ──► [link out cmd:demand] ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
|
||||
│
|
||||
▼
|
||||
[random gen] ─► [link out cmd:demand] ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─► [link in cmd:demand] ──► [router] ──► [MGC]
|
||||
▲
|
||||
│
|
||||
many link-outs may target one link-in
|
||||
```
|
||||
|
||||
### Naming convention
|
||||
|
||||
Channels follow `<direction>:<topic>` lowercase, kebab-case after the colon:
|
||||
|
||||
- `cmd:` — UI / drivers → process. Carries commands.
|
||||
- `evt:` — process → UI / external. Carries state events.
|
||||
- `setup:` — setup tab → wherever. Carries one-shot init.
|
||||
|
||||
Examples used in the pumping-station demo:
|
||||
- `cmd:demand`, `cmd:randomToggle`, `cmd:mode`
|
||||
- `cmd:station-startup`, `cmd:station-shutdown`, `cmd:station-estop`
|
||||
- `cmd:setpoint-A`, `cmd:setpoint-B`, `cmd:setpoint-C`
|
||||
- `cmd:pump-A-seq` (start/stop for pump A specifically)
|
||||
- `evt:pump-A`, `evt:pump-B`, `evt:pump-C`, `evt:mgc`, `evt:ps`
|
||||
- `setup:to-mgc`
|
||||
|
||||
### Channels are the contract
|
||||
|
||||
The list of channel names IS the inter-tab API. Document it in the demo's README. Renaming a channel is a breaking change.
|
||||
|
||||
### When to use one channel vs many
|
||||
|
||||
- One channel, many emitters: same kind of message from multiple sources (e.g. `cmd:demand` is fired by both the slider and the random generator).
|
||||
- Different channels: messages with different *meaning* even if they go to the same node (e.g. don't fold `cmd:setpoint-A` into a generic `cmd:pump-A` — keep setpoint and start/stop separate).
|
||||
- Avoid one mega-channel: a "process commands" channel that the receiver routes-by-topic is harder to read than separate channels per concern.
|
||||
|
||||
### Don't use link-call for fan-out
|
||||
|
||||
`link call` is for synchronous request/response (waits for a paired `link out` in `return` mode). For fan-out, use plain `link out` (mode=`link`) with multiple targets, or a single link out → single link in → function-node fan-out (whichever is clearer for your case).
|
||||
|
||||
## 3. Spacing and visual layout
|
||||
|
||||
Nodes need air to be readable. Apply these constants in any flow generator:
|
||||
|
||||
```python
|
||||
LANE_X = [120, 380, 640, 900, 1160, 1420] # 6 vertical lanes per tab
|
||||
ROW = 80 # standard row pitch
|
||||
SECTION_GAP = 200 # extra y-shift between sections
|
||||
```
|
||||
|
||||
### Lane assignment (process plant tab as example)
|
||||
|
||||
| Lane | Contents |
|
||||
|---|---|
|
||||
| 0 (x=120) | Inputs from outside the tab — link-in nodes, injects |
|
||||
| 1 (x=380) | First-level transformers — wrappers, fan-outs, routers |
|
||||
| 2 (x=640) | Mid-level — section comments live here too |
|
||||
| 3 (x=900) | Target nodes — the EVOLV node itself (pump, MGC, PS) |
|
||||
| 4 (x=1160) | Output formatters — function nodes that build dashboard-friendly payloads |
|
||||
| 5 (x=1420) | Outputs to outside the tab — link-out nodes, debug taps |
|
||||
|
||||
Inputs flow left → right. Don't loop wires backwards across the tab.
|
||||
|
||||
### Section comments
|
||||
|
||||
Every logical group within a tab gets a comment header at lane 2 with a `── Section name ──` style label. Use them liberally — every 3-5 nodes deserves a header. The `info` field on the comment carries the multi-line description.
|
||||
|
||||
### Section spacing
|
||||
|
||||
`SECTION_GAP = 200` between sections, on top of the standard row pitch. Don't pack sections together — when you have 6 measurements on a tab, give each pump 4 rows + a 200 px gap to the next pump. Yes, it makes tabs scroll. Scroll is cheap; visual confusion is expensive.
|
||||
|
||||
## 4. Charts — the trend-split rule
|
||||
|
||||
ui-chart with `category: "topic"` + `categoryType: "msg"` plots one series per unique `msg.topic`. So:
|
||||
|
||||
- One chart per **metric type** (one chart for flow, one for power).
|
||||
- Each chart receives msgs whose `topic` is the **series label** (e.g. `Pump A`, `Pump B`, `Pump C`).
|
||||
|
||||
### Required chart properties (FlowFuse ui-chart renders blank without ALL of these)
|
||||
|
||||
Derived from working charts in rotatingMachine/examples/03-Dashboard. Every property listed below is mandatory — omit any one and the chart renders blank with no error message.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ui-chart",
|
||||
"chartType": "line",
|
||||
"interpolation": "linear",
|
||||
"category": "topic",
|
||||
"categoryType": "msg",
|
||||
"xAxisType": "time",
|
||||
"xAxisProperty": "",
|
||||
"xAxisPropertyType": "timestamp",
|
||||
"xAxisFormat": "",
|
||||
"xAxisFormatType": "auto",
|
||||
"yAxisProperty": "payload",
|
||||
"yAxisPropertyType": "msg",
|
||||
"action": "append",
|
||||
"stackSeries": false,
|
||||
"pointShape": "circle",
|
||||
"pointRadius": 4,
|
||||
"showLegend": true,
|
||||
"bins": 10,
|
||||
"width": 12,
|
||||
"height": 6,
|
||||
"removeOlder": "15",
|
||||
"removeOlderUnit": "60",
|
||||
"removeOlderPoints": "",
|
||||
"colors": ["#0095FF","#FF0000","#FF7F0E","#2CA02C","#A347E1","#D62728","#FF9896","#9467BD","#C5B0D5"],
|
||||
"textColor": ["#666666"],
|
||||
"textColorDefault": true,
|
||||
"gridColor": ["#e5e5e5"],
|
||||
"gridColorDefault": true
|
||||
}
|
||||
```
|
||||
|
||||
**Key gotchas:**
|
||||
- `interpolation` MUST be set (`"linear"`, `"step"`, `"bezier"`, `"cubic"`, `"cubic-mono"`). Without it: no line drawn.
|
||||
- `yAxisProperty: "payload"` + `yAxisPropertyType: "msg"` tells the chart WHERE in the msg to find the y-value. Without these: chart has no data to plot.
|
||||
- `xAxisPropertyType: "timestamp"` tells the chart to use `msg.timestamp` (or auto-generated) for the x-axis.
|
||||
- `width` and `height` are **numbers, not strings**. `width: 12` (correct) vs `width: "12"` (may break).
|
||||
- `removeOlderPoints: ""` (empty string) → retention is controlled by removeOlder + removeOlderUnit only. Set to a number string to additionally cap points per series.
|
||||
- `colors` array defines the palette for auto-assigned series colours. Provide at least 3.
|
||||
|
||||
### The trend-split function pattern
|
||||
|
||||
A common bug: feeding both flow and power msgs to a single function output that wires to both charts. Both charts then plot all metrics, garbling the legend.
|
||||
|
||||
**Fix:** the trend-feeder function MUST have one output per chart, and split:
|
||||
|
||||
```js
|
||||
// outputs: 2
|
||||
// wires: [["chart_flow"], ["chart_power"]]
|
||||
const flowMsg = p.flowNum != null ? { topic: 'Pump A', payload: p.flowNum } : null;
|
||||
const powerMsg = p.powerNum != null ? { topic: 'Pump A', payload: p.powerNum } : null;
|
||||
return [flowMsg, powerMsg];
|
||||
```
|
||||
|
||||
A null msg on a given output sends nothing on that output — exactly what we want.
|
||||
|
||||
### Chart axis settings to actually configure
|
||||
|
||||
- `removeOlder` + `removeOlderUnit`: how much history to keep (e.g. 10 minutes).
|
||||
- `removeOlderPoints`: cap on points per series (200 is sensible for a demo).
|
||||
- `ymin` / `ymax`: leave blank for autoscale, or set numeric strings if you want a fixed range.
|
||||
|
||||
## 5. Inject node — payload typing
|
||||
|
||||
Multi-prop inject must populate `v` and `vt` **per prop**, not just the legacy top-level `payload` + `payloadType`:
|
||||
|
||||
```json
|
||||
{
|
||||
"props": [
|
||||
{"p": "topic", "vt": "str"},
|
||||
{"p": "payload", "v": "{\"action\":\"startup\"}", "vt": "json"}
|
||||
],
|
||||
"topic": "execSequence",
|
||||
"payload": "{\"action\":\"startup\"}",
|
||||
"payloadType": "json"
|
||||
}
|
||||
```
|
||||
|
||||
If you only fill the top-level fields, `payload_type=json` is silently treated as `str`.
|
||||
|
||||
## 6. Dashboard widget rules
|
||||
|
||||
- **Widget = display only.** No business logic in `ui-text` formats or `ui-template` HTML.
|
||||
- **Buttons emit a typed string payload** (`"fired"` or similar). Convert to the real msg shape with a tiny wrapper function on the same tab, before the link-out.
|
||||
- **Sliders use `passthru: true`** so they re-emit on input messages (useful for syncing initial state from the process side later).
|
||||
- **One ui-page per demo.** Multiple groups under one page is the natural split.
|
||||
- **Group widths should sum to a multiple of 12.** The page grid is 12 columns. A row of `4 + 4 + 4` or `6 + 6` works; mixing arbitrary widths leaves gaps.
|
||||
- **EVERY ui-* node needs `x` and `y` keys.** Without them Node-RED dumps the node at (0,0) — every text widget and chart piles up in the top-left of the editor canvas. The dashboard itself still renders correctly (it lays out by group/order, not editor x/y), but the editor view is unreadable. If you write a flow generator helper, set `x` and `y` on the dict EVERY time. Test with `jq '[.[] | select(.x==0 and .y==0 and (.type|tostring|startswith("ui-")))]'` after generating.
|
||||
|
||||
## 7. Do / don't checklist
|
||||
|
||||
✅ Do:
|
||||
|
||||
- Generate flows from a Python builder (`build_flow.py`) — it's the source of truth.
|
||||
- Use deterministic IDs (`pump_a`, `meas_pump_a_u`, `lin_demand_to_mgc`) — reproducible diffs across regenerations.
|
||||
- Tag every channel name with `cmd:` / `evt:` / `setup:`.
|
||||
- Comment every section, even short ones.
|
||||
- Verify trends with a `ui-chart` of synthetic data first, before plumbing real data through.
|
||||
|
||||
❌ Don't:
|
||||
|
||||
- Don't use `replace_all` on a Python identifier that appears in a node's own wires definition — you'll create self-loops (>250k msg/s discovered the hard way).
|
||||
- Don't wire across tabs directly. The wire IS allowed but it makes the editor unreadable.
|
||||
- Don't put dashboard widgets next to EVOLV nodes — different concerns.
|
||||
- Don't pack nodes within 40 px of each other — labels overlap, wires snap to wrong handles.
|
||||
- Don't ship `enableLog: "debug"` in a demo — fills the container log within seconds and obscures real errors.
|
||||
|
||||
## 8. The link-out / link-in JSON shape (cheat sheet)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "lout_demand_dash",
|
||||
"type": "link out",
|
||||
"z": "tab_ui",
|
||||
"name": "cmd:demand",
|
||||
"mode": "link",
|
||||
"links": ["lin_demand_to_mgc"],
|
||||
"x": 380, "y": 140,
|
||||
"wires": []
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "lin_demand_to_mgc",
|
||||
"type": "link in",
|
||||
"z": "tab_process",
|
||||
"name": "cmd:demand",
|
||||
"links": ["lout_demand_dash", "lout_demand_drivers"],
|
||||
"x": 120, "y": 1500,
|
||||
"wires": [["demand_fanout_mgc_ps"]]
|
||||
}
|
||||
```
|
||||
|
||||
Both ends store the paired ids in `links`. The `name` is cosmetic (label only) — Node-RED routes by id. Multiple emitters can target one receiver; one emitter can target multiple receivers.
|
||||
|
||||
## 9. Node configuration completeness — ALWAYS set every field
|
||||
|
||||
When placing an EVOLV node in a flow (demo or production), configure **every config field** the node's schema defines — don't rely on schema defaults for operational parameters. Schema defaults exist to make the validator happy, not to represent a realistic plant.
|
||||
|
||||
**Why this matters:** A pumpingStation with `basinVolume: 10` but default `heightOverflow: 2.5` and default `heightOutlet: 0.2` creates an internally inconsistent basin where the fill % exceeds 100%, safety guards fire at wrong thresholds, and the demo looks broken. Every field interacts with every other field.
|
||||
|
||||
**The rule:**
|
||||
1. Read the node's config schema (`generalFunctions/src/configs/<nodeName>.json`) before writing the flow.
|
||||
2. For each section (basin, hydraulics, control, safety, scaling, smoothing, …), set EVERY field explicitly in the flow JSON — even if you'd pick the same value as the default.
|
||||
3. Add a comment in the flow generator per section explaining WHY you chose each value (e.g. "basin sized so sinus peak takes 6 min to fill from startLevel to overflow").
|
||||
4. Cross-check computed values: `surfaceArea = volume / height`, `maxVolOverflow = heightOverflow × surfaceArea`, gauge `max` = basin `height`, fill % denominator = `volume` (not overflow volume).
|
||||
5. If a gauge or chart references a config value (basin height, maxVol), derive it from the same source — never hardcode a number that was computed elsewhere.
|
||||
|
||||
## 10. Verifying the layout
|
||||
|
||||
Before declaring a flow done:
|
||||
|
||||
1. **Open the tab in the editor — every wire should run left → right.** No backward loops.
|
||||
2. **Open each section by section comment — visible in 1 screen height.** If not, raise `SECTION_GAP`.
|
||||
3. **Hit the dashboard URL — every widget has data.** `n/a` everywhere is a contract failure.
|
||||
4. **For charts, watch a series populate over 30 s.** A blank chart after 30 s = bug.
|
||||
5. **Disable each tab one at a time and re-deploy.** Process Plant alone should still load (just inert). Dashboard UI alone should serve a page (just empty). If disabling a tab errors out, the tab boundaries are wrong.
|
||||
|
||||
## 10. Hierarchical placement — by S88 level, not by node name
|
||||
|
||||
The lane assignment maps to the **S88 hierarchy**, not to specific node names. Any node that lives at a given S88 level goes in the same lane regardless of what kind of equipment it is. New node types added to the platform inherit a lane by their S88 category — no rule change needed.
|
||||
|
||||
### 10.1 Lane convention (x-axis = S88 level)
|
||||
|
||||
| Lane | x | Purpose | S88 level | Colour | Current EVOLV nodes |
|
||||
|---:|---:|---|---|---|---|
|
||||
| **L0** | 120 | Tab inputs | — | (none) | `link in`, `inject` |
|
||||
| **L1** | 360 | Adapters | — | (none) | `function` (msg-shape wrappers) |
|
||||
| **L2** | 600 | Control Module | CM | `#a9daee` | `measurement` |
|
||||
| **L3** | 840 | Equipment Module | EM | `#86bbdd` | `rotatingMachine`, `valve`, `diffuser` |
|
||||
| **L4** | 1080 | Unit | UN | `#50a8d9` | `machineGroupControl`, `valveGroupControl`, `reactor`, `settler`, `monster` |
|
||||
| **L5** | 1320 | Process Cell | PC | `#0c99d9` | `pumpingStation` |
|
||||
| **L6** | 1560 | Output formatters | — | (none) | `function` (build dashboard payload from port 0) |
|
||||
| **L7** | 1800 | Tab outputs | — | (none) | `link out`, `debug` |
|
||||
|
||||
Spacing: **240 px** between lanes. Tab width ≤ 1920 px (fits standard monitors without horizontal scroll in the editor).
|
||||
|
||||
**Area level** (`#0f52a5`) is reserved for plant-wide coordination and currently unused — when added, allocate a new lane and shift formatter/output one lane right (i.e. expand to 9 lanes if and when needed).
|
||||
|
||||
### 10.2 The group rule (Node-RED `group` boxes anchor each parent + its children)
|
||||
|
||||
Use Node-RED's native `group` node (the visual box around a set of nodes — not to be confused with `ui-group`) to anchor every "parent + direct children" cluster. The box makes ownership unambiguous and lets you collapse the cluster in the editor.
|
||||
|
||||
**Group rules:**
|
||||
|
||||
- **One Node-RED group per parent + its direct children.**
|
||||
Example: `Pump A + meas-A-up + meas-A-dn` is one group, named `Pump A`.
|
||||
- **Group colour = parent's S88 colour.**
|
||||
So a Pump-A group is `#86bbdd` (Equipment Module). A reactor group is `#50a8d9` (Unit).
|
||||
- **Group `style.label = true`** so the box shows the parent's name.
|
||||
- **Group must contain all the children's adapters / wrappers / formatters** too if those exclusively belong to the parent. The box is the visual anchor for "this is everything that owns / serves Pump A".
|
||||
- **Utility groups for cross-cutting logic** (mode broadcast, station-wide commands, demand fan-out) use a neutral colour (`#dddddd`).
|
||||
|
||||
JSON shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "grp_pump_a",
|
||||
"type": "group",
|
||||
"z": "tab_process",
|
||||
"name": "Pump A",
|
||||
"style": { "label": true, "stroke": "#000000", "fill": "#86bbdd", "fill-opacity": "0.10" },
|
||||
"nodes": ["meas_pump_a_u", "meas_pump_a_d", "pump_a", "format_pump_a", "lin_setpoint_pump_a", "build_setpoint_pump_a", "lin_seq_pump_a", "lout_evt_pump_a"],
|
||||
"x": 80, "y": 100, "w": 1800, "h": 200
|
||||
}
|
||||
```
|
||||
|
||||
`x/y/w/h` is the bounding box of contained nodes + padding — compute it from the children's positions.
|
||||
|
||||
### 10.3 The hierarchy rule, restated
|
||||
|
||||
> Nodes at the **same S88 level** (siblings sharing one parent) **stack vertically in the same lane**.
|
||||
>
|
||||
> Nodes at **different S88 levels** (parent ↔ child) sit **next to each other on different lanes**.
|
||||
|
||||
### 10.4 Worked example — pumping station demo
|
||||
|
||||
```
|
||||
L0 L1 L2 L3 L4 L5 L6 L7
|
||||
(input) (adapter) (CM) (EM) (Unit) (PC) (formatter) (output)
|
||||
|
||||
┌── group: Pump A (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ [lin-set-A] [build-A] │
|
||||
│ [lin-seq-A] │
|
||||
│ [meas-A-up] │
|
||||
│ [meas-A-dn] → [Pump A] → │
|
||||
│ [format-A] →[lout-evt-A]
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌── group: Pump B (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ... same shape ... │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌── group: Pump C (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ... same shape ... │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌── group: MGC — Pump Group (#50a8d9) ──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ [lin-demand] [demand→MGC+PS] [MGC] [format-MGC]→[lout-evt-MGC]
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌── group: Pumping Station (#0c99d9) ───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ [PS] [format-PS]→[lout-evt-PS]
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌── group: Mode broadcast (#dddddd, neutral) ───────────────────────────────────────────────────────────────────────┐
|
||||
│ [lin-mode] [fan-mode] ─────────────► to all 3 pumps in the Pump A/B/C groups │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌── group: Station-wide commands (#dddddd) ─────────────────────────────────────────────────────────────────────────┐
|
||||
│ [lin-start] [fan-start] ─► to pumps │
|
||||
│ [lin-stop] [fan-stop] │
|
||||
│ [lin-estop] [fan-estop] │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
What that buys:
|
||||
|
||||
- Search "Pump A" highlights the whole group box (parent + sensors + adapters + formatter).
|
||||
- S88 colour of the group box tells you the level at a glance.
|
||||
- Wires are horizontal within a group; cross-group wires (Pump A port 2 → MGC) cross only one band.
|
||||
- Collapse a group in the editor and it becomes a single tile — clutter disappears during reviews.
|
||||
|
||||
### 10.5 Multi-input fan-in rule
|
||||
|
||||
Stack link-ins tightly at L0, centred on the destination's y. Merge node one lane right at the same y.
|
||||
|
||||
### 10.6 Multi-output fan-out rule
|
||||
|
||||
Source at the y-centre of its destinations; destinations stack vertically in the next lane. Wires fork cleanly without jogging.
|
||||
|
||||
### 10.7 Link-in placement (within a tab)
|
||||
|
||||
- All link-ins on **L0**.
|
||||
- Order them top-to-bottom by the y of their **first downstream target**.
|
||||
- Link-ins that feed the same destination share the same y-band as that destination.
|
||||
|
||||
### 10.8 Link-out placement (within a tab)
|
||||
|
||||
- All link-outs on **L7** (the rightmost lane).
|
||||
- Each link-out's y matches its **upstream source's** y, so the wire is horizontal.
|
||||
|
||||
### 10.9 Cross-tab wire rule
|
||||
|
||||
Cross-tab wires use `link out` / `link in` pairs (see Section 2). Direct cross-tab wires are forbidden.
|
||||
|
||||
### 10.10 The "no jog" verification
|
||||
|
||||
- A wire whose source y == destination y is fine (perfectly horizontal).
|
||||
- A wire that jogs vertically by ≤ 80 px is fine (one row of slop).
|
||||
- A wire that jogs by > 80 px means **the destination is in the wrong group y-band**. Move the destination, not the source — the source's position was determined by its own group.
|
||||
|
||||
## 11. Dashboard tab variant
|
||||
|
||||
Dashboard widgets are stamped to the real grid by the FlowFuse renderer; editor x/y is for the editor's readability.
|
||||
|
||||
- Use only **L0, L2, L4, L7**:
|
||||
- L0 = `link in` (events from process)
|
||||
- L2 = `ui-*` inputs (sliders, switches, buttons)
|
||||
- L4 = wrapper / format / trend-split functions
|
||||
- L7 = `link out` (commands going back)
|
||||
- **One Node-RED group per `ui-group`.** Editor group's name matches the `ui-group` name. Colour follows the S88 level of the represented equipment (MGC group = `#50a8d9`, Pump A group = `#86bbdd`, …) so the editor view mirrors the dashboard structure.
|
||||
- Within the group, widgets stack vertically by their visual order in the dashboard.
|
||||
|
||||
## 12. Setup tab variant
|
||||
|
||||
Single-column ladder L0 → L7, ordered top-to-bottom by `onceDelay`. Wrap in a single neutral-grey Node-RED group named `Deploy-time setup`.
|
||||
|
||||
## 13. Demo Drivers tab variant
|
||||
|
||||
Same as Process Plant but typically only L0, L2, L4, L7 are used. Wrap each driver (random gen, scripted scenario, …) in its own neutral Node-RED group.
|
||||
|
||||
## 14. Spacing constants (final)
|
||||
|
||||
```python
|
||||
LANE_X = [120, 360, 600, 840, 1080, 1320, 1560, 1800]
|
||||
SIBLING_PITCH = 40
|
||||
GROUP_GAP = 200
|
||||
TAB_TOP_MARGIN = 80
|
||||
GROUP_PADDING = 20 # extra px around child bounding box for the Node-RED group box
|
||||
|
||||
S88_COLORS = {
|
||||
"AR": "#0f52a5", # Area (currently unused)
|
||||
"PC": "#0c99d9", # Process Cell
|
||||
"UN": "#50a8d9", # Unit
|
||||
"EM": "#86bbdd", # Equipment Module
|
||||
"CM": "#a9daee", # Control Module
|
||||
"neutral": "#dddddd",
|
||||
}
|
||||
|
||||
# Registry: drop a new node type here to place it automatically.
|
||||
NODE_LEVEL = {
|
||||
"measurement": "CM",
|
||||
"rotatingMachine": "EM",
|
||||
"valve": "EM",
|
||||
"diffuser": "EM",
|
||||
"machineGroupControl": "UN",
|
||||
"valveGroupControl": "UN",
|
||||
"reactor": "UN",
|
||||
"settler": "UN",
|
||||
"monster": "UN",
|
||||
"pumpingStation": "PC",
|
||||
"dashboardAPI": "neutral",
|
||||
}
|
||||
```
|
||||
|
||||
Helpers for the build script:
|
||||
|
||||
```python
|
||||
def place(lane, group_index, position_in_group, group_size):
|
||||
"""Compute (x, y) for a node in a process group."""
|
||||
x = LANE_X[lane]
|
||||
band_centre = TAB_TOP_MARGIN + group_index * (group_size * SIBLING_PITCH + GROUP_GAP) \
|
||||
+ (group_size - 1) * SIBLING_PITCH / 2
|
||||
y = band_centre + (position_in_group - (group_size - 1) / 2) * SIBLING_PITCH
|
||||
return int(x), int(y)
|
||||
|
||||
def wrap_in_group(child_ids, name, s88_color, nodes_by_id, padding=GROUP_PADDING):
|
||||
"""Compute the Node-RED group box around a set of children."""
|
||||
xs = [nodes_by_id[c]["x"] for c in child_ids]
|
||||
ys = [nodes_by_id[c]["y"] for c in child_ids]
|
||||
return {
|
||||
"type": "group", "name": name,
|
||||
"style": {"label": True, "stroke": "#000000", "fill": s88_color, "fill-opacity": "0.10"},
|
||||
"nodes": list(child_ids),
|
||||
"x": min(xs) - padding, "y": min(ys) - padding,
|
||||
"w": max(xs) - min(xs) + 160 + 2 * padding,
|
||||
"h": max(ys) - min(ys) + 40 + 2 * padding,
|
||||
}
|
||||
```
|
||||
|
||||
## 15. Verification checklist (extends Section 9)
|
||||
|
||||
After building a tab:
|
||||
|
||||
1. **No wire jogs > 80 px vertically within a group.**
|
||||
2. **Each lane contains nodes of one purpose only** (never an `ui-text` on L3; never a `rotatingMachine` on L2).
|
||||
3. **Peers share a lane; parents and children sit on adjacent lanes.**
|
||||
4. **Every parent + direct children sit inside one Node-RED group box, coloured by the parent's S88 level.**
|
||||
5. **Utility groups** (mode broadcast, station commands, demand fan-out) wrapped in neutral-grey Node-RED groups.
|
||||
6. **Section comments at the top of each group band.**
|
||||
7. **Editor scrollable in y but NOT in x** on a normal monitor.
|
||||
8. **Search test:** typing the parent's name in the editor highlights the whole group box.
|
||||
|
||||
## 16. S88 colour cleanup (separate follow-up task)
|
||||
|
||||
These nodes don't currently follow the S88 palette. They should be brought in line in a separate session before the placement rule is fully consistent across the editor:
|
||||
|
||||
- `settler` (`#e4a363` orange) → should be `#50a8d9` (Unit)
|
||||
- `monster` (`#4f8582` teal) → should be `#50a8d9` (Unit)
|
||||
- `diffuser` (no colour set) → should be `#86bbdd` (Equipment Module)
|
||||
- `dashboardAPI` (no colour set) → utility, no S88 colour needed
|
||||
|
||||
Until cleaned up, the placement rule still works — `NODE_LEVEL` (Section 14) already maps these to their semantic S88 level regardless of the node's own colour.
|
||||
80
.claude/rules/repo-mem.md
Normal file
80
.claude/rules/repo-mem.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# repo-mem MCP Tools
|
||||
|
||||
This repo has a per-repo memory MCP server (`repo-mem`) wired via `.mcp.json`. It exposes 5 tools backed by a Hopfield substrate trained on EVOLV's source plus a BM25 index over file chunks. **Use them. They are faster and better-targeted than `grep` for concept queries, and they accumulate institutional memory of repairs.**
|
||||
|
||||
If `/mcp` does not list `repo-mem` as Connected, the rest of this file does not apply for this session — fall back to `grep` / `Read`.
|
||||
|
||||
## When to call which tool
|
||||
|
||||
### `repo_search(query, k=8)` — primary lookup tool
|
||||
Use **before** `grep` / `find` / `Explore` agent for any natural-language "where is X handled / find all places that do Y / what code implements Z" question.
|
||||
|
||||
- ✅ "where is the predicted volume integrator?" → `repo_search`
|
||||
- ✅ "find places that emit InfluxDB line protocol" → `repo_search`
|
||||
- ❌ "find every occurrence of `_updatePredictedVolume`" → `grep` (exact symbol — BM25 doesn't beat grep at exact-string lookup)
|
||||
- ❌ "list all `.test.js` files" → `find` / `ls` (no concept query)
|
||||
|
||||
Returns top-K files with `file:line` ranges and snippets. Read the snippet first; only open the file if the snippet doesn't answer the question.
|
||||
|
||||
### `repo_similar_fixes(query, failure?, files?, tags?, k=5)` — start-of-task context
|
||||
Call at the **start** of any non-trivial bug fix or behavioral change. Cheap (BM25 + file overlap + atom cosine), zero downside if it returns nothing useful.
|
||||
|
||||
- Pass the user's task description as `query`.
|
||||
- If there's a failing test or stack trace, pass it as `failure`.
|
||||
- If you already know which files are involved, pass them as `files`.
|
||||
- Skim the returned traces; surface any near-match to the user before starting.
|
||||
|
||||
### `repo_record_fix({task, failure, files, diff_summary, patch, tests, outcome, tags})` — end-of-task persist
|
||||
Call at the **end** of a landed fix or behavioral change, **before** reporting completion to the user. Skip for trivial typo/comment commits. Required fields: `task` and `outcome`. Recommended:
|
||||
- `failure`: the symptom that prompted the work (test output, user description, stack trace).
|
||||
- `files`: the files actually changed.
|
||||
- `diff_summary`: 1–3 sentences on *what* changed and *why*.
|
||||
- `patch`: the unified diff (truncate to the load-bearing hunks if huge).
|
||||
- `tests`: the verification command(s) you ran.
|
||||
- `outcome`: `passed` / `failed` / `partial` / `reverted`.
|
||||
- `tags`: short labels (`overflow-clamp`, `tokenizer`, `migration`, etc.) for retrieval bias.
|
||||
|
||||
Rule of thumb: if the change took more than one read+edit pair, record it.
|
||||
|
||||
### `substrate_score(text, worst_k=5)` — OOD-token check
|
||||
Use **sparingly**. After generating a non-trivial code block (≥ ~30 lines of new logic, not test scaffolding), pass it through `substrate_score` and inspect the worst-confidence positions for typos, wrong identifiers, or out-of-house style. Noisy on small additions — don't use it for one-line tweaks.
|
||||
|
||||
### `substrate_top_next(context, k=10)` — rarely
|
||||
Predicts next BPE-subword tokens in the local style. Mostly useful for autonomous solver loops; in interactive review it's diagnostic only. If you find yourself wanting it, you probably want `repo_search` instead.
|
||||
|
||||
## Workflow shape
|
||||
|
||||
```
|
||||
new task arrives
|
||||
↓
|
||||
repo_similar_fixes(query=user_task) ← cheap, always do this for non-trivial tasks
|
||||
↓
|
||||
repo_search(query=concept) ← when scoping
|
||||
↓
|
||||
[normal Read / Edit / Bash work]
|
||||
↓
|
||||
[after generating non-trivial new code]
|
||||
substrate_score(text=new_block) ← optional, only if block is big
|
||||
↓
|
||||
[verify: tests / build / smoke]
|
||||
↓
|
||||
repo_record_fix({...}) ← before final user-facing summary
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- ❌ Calling `repo_search` when you already know the file path. Just `Read` it.
|
||||
- ❌ Calling `repo_record_fix` after every micro-edit. Only at meaningful task boundaries.
|
||||
- ❌ Treating `substrate_top_next` results as authoritative — they reflect repo style, not correctness.
|
||||
- ❌ Passing the full conversation to `substrate_score` — it's per-snippet, not per-session.
|
||||
|
||||
## Refresh model
|
||||
|
||||
The post-commit hook auto-runs `--quick --lock` (re-ingest + BM25 + chunk re-embed; substrate retrain skipped) so retrieval stays current within ~2 s of any commit. The substrate itself is only retrained when you (or a maintainer) run `--full` manually:
|
||||
|
||||
```bash
|
||||
node ~/anchor-net-master/tools/repo-mem/refresh.mjs \
|
||||
--repo . --in .repo-mem --full
|
||||
```
|
||||
|
||||
Re-train when the repo gains substantially new vocabulary (new node, new domain, new dependency surface). Otherwise BM25 + existing atoms keep up.
|
||||
@@ -5,10 +5,7 @@ paths:
|
||||
|
||||
# Telemetry Rules
|
||||
|
||||
## Output Port Convention
|
||||
- Port 0: Process data (downstream node consumption)
|
||||
- Port 1: InfluxDB telemetry payload
|
||||
- Port 2: Registration/control plumbing
|
||||
Output port convention (Port 0/1/2) is documented in `.claude/rules/node-architecture.md`. This file covers only the Port 1 payload shape and downstream contracts.
|
||||
|
||||
## InfluxDB Payload Structure
|
||||
Port 1 payloads must follow InfluxDB line protocol conventions:
|
||||
|
||||
@@ -5,18 +5,18 @@ paths:
|
||||
|
||||
# Testing Rules
|
||||
|
||||
## 3-Tier Test Structure
|
||||
Every node must have:
|
||||
- `test/basic/*.test.js` — Unit tests for individual functions
|
||||
## Test Structure
|
||||
Every node has at minimum:
|
||||
- `test/basic/*.test.js` — Unit tests for individual functions (specificClass domain logic)
|
||||
- `test/integration/*.test.js` — Node interaction and message passing tests
|
||||
- `test/edge/*.test.js` — Edge cases, error conditions, boundary values
|
||||
- `test/helpers/` (optional) — Shared test utilities for this node
|
||||
|
||||
Edge-case tests live wherever they fit (in `basic/` for pure-logic edges, in `integration/` for runtime edges). Don't require a separate `test/edge/` directory.
|
||||
|
||||
## Test Runner
|
||||
```bash
|
||||
node --test nodes/<nodeName>/test/basic/*.test.js
|
||||
node --test nodes/<nodeName>/test/integration/*.test.js
|
||||
node --test nodes/<nodeName>/test/edge/*.test.js
|
||||
```
|
||||
|
||||
## Test Requirements
|
||||
@@ -25,11 +25,7 @@ node --test nodes/<nodeName>/test/edge/*.test.js
|
||||
- Example flows (`examples/`) must stay in sync with implementation
|
||||
|
||||
## Example Flows
|
||||
Each node must maintain:
|
||||
- `examples/README.md`
|
||||
- `examples/basic.flow.json`
|
||||
- `examples/integration.flow.json`
|
||||
- `examples/edge.flow.json`
|
||||
Each node should ship at least one runnable example under `examples/` plus an `examples/README.md` describing it. Beyond that, add only what the node's complexity demands — not every node needs separate basic/integration/edge flow files.
|
||||
|
||||
## No Node-RED Runtime in Unit Tests
|
||||
Basic tests should test specificClass domain logic without requiring a running Node-RED instance.
|
||||
|
||||
@@ -11,7 +11,9 @@ node_modules/
|
||||
# Agent/Claude metadata (not needed at runtime)
|
||||
.agents/
|
||||
.claude/
|
||||
manuals/
|
||||
|
||||
# Documentation (not needed at runtime)
|
||||
wiki/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
@@ -23,10 +25,3 @@ manuals/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Documentation (not needed at runtime)
|
||||
third_party/
|
||||
FUNCTIONAL_ISSUES_BACKLOG.md
|
||||
AGENTS.md
|
||||
README.md
|
||||
LICENSE
|
||||
|
||||
41
.gitea/workflows/ci.yml
Normal file
41
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, dev-Rene]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:20-slim
|
||||
|
||||
steps:
|
||||
- name: Install git
|
||||
run: apt-get update -qq && apt-get install -y -qq git
|
||||
|
||||
- name: Checkout with submodules
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Rewrite generalFunctions to local path
|
||||
run: |
|
||||
sed -i 's|"generalFunctions": "git+https://[^"]*"|"generalFunctions": "file:./nodes/generalFunctions"|' package.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --ignore-scripts
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test (Jest)
|
||||
run: npm test
|
||||
|
||||
- name: Test (node:test)
|
||||
run: npm run test:node
|
||||
|
||||
- name: Test (legacy)
|
||||
run: npm run test:legacy
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,3 +8,8 @@ npm-debug.log*
|
||||
|
||||
# Build artifacts
|
||||
*.tgz
|
||||
# repo-mem regenerable indexes
|
||||
.repo-mem/
|
||||
|
||||
# Per-session runtime locks (scheduled_tasks, etc.)
|
||||
.claude/*.lock
|
||||
|
||||
14
.mcp.json
Normal file
14
.mcp.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"repo-mem": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"/home/znetsixe/anchor-net-master/tools/repo-mem/server.mjs",
|
||||
"--in",
|
||||
"/home/znetsixe/EVOLV/.repo-mem"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
# Ignore test files
|
||||
node_modules/
|
||||
35
CLAUDE.md
Normal file
35
CLAUDE.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# EVOLV - Claude Code Project Guide
|
||||
|
||||
> **READ FIRST, BEFORE ANY OTHER WORK:** `.claude/rules/repo-mem.md` — this repo has an MCP server (`repo-mem`) exposing a substrate-trained `repo_search` and a persistent fix-trace store. Use those instead of grep for concept queries, and record completed fixes via `repo_record_fix`. Triggers, anti-patterns, and refresh model are in that rule.
|
||||
|
||||
## What This Is
|
||||
Node-RED custom nodes package for wastewater treatment plant automation. Developed by Waterschap Brabantse Delta R&D team. Follows ISA-88 (S88) batch control standard.
|
||||
|
||||
## Architecture
|
||||
Each node follows a three-layer pattern:
|
||||
1. **Node-RED wrapper** (`<name>.js`) - registers the node type, sets up HTTP endpoints
|
||||
2. **Node adapter** (`src/nodeClass.js`) - bridges Node-RED API with domain logic, handles config loading, tick loops, events
|
||||
3. **Domain logic** (`src/specificClass.js`) - pure business logic, no Node-RED dependencies
|
||||
|
||||
## Key Shared Library: `nodes/generalFunctions/`
|
||||
- `logger` - structured logging (use this, NOT console.log)
|
||||
- `MeasurementContainer` - chainable measurement storage (type/variant/position)
|
||||
- `configManager` - loads JSON configs from `src/configs/`
|
||||
- `MenuManager` - dynamic UI dropdowns
|
||||
- `outputUtils` - formats messages for InfluxDB and process outputs
|
||||
- `childRegistrationUtils` - parent-child node relationships
|
||||
- `coolprop` - thermodynamic property calculations
|
||||
|
||||
## Conventions
|
||||
- Nodes register under category `'EVOLV'` in Node-RED
|
||||
- S88 color scheme: 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 runs at 1000ms intervals for time-based updates
|
||||
- Output ports + 3-tier architecture: see `.claude/rules/node-architecture.md`
|
||||
- **Multi-tab demo flows**: see `.claude/rules/node-red-flow-layout.md` for the tab/link-channel/spacing rule set used by `examples/`
|
||||
|
||||
## Development Notes
|
||||
- No build step required - pure Node.js
|
||||
- Install: `npm install` in root
|
||||
- Submodule URLs were rewritten from `gitea.centraal.wbd-rd.nl` to `gitea.wbd-rd.nl` for external access
|
||||
- Dependencies: mathjs, generalFunctions (git submodule)
|
||||
@@ -1,6 +0,0 @@
|
||||
# Functional Issues Backlog (Deprecated Location)
|
||||
|
||||
This backlog has moved to:
|
||||
- `.agents/improvements/IMPROVEMENTS_BACKLOG.md`
|
||||
|
||||
Use `.agents/improvements/TOP10_PRODUCTION_PRIORITIES_YYYY-MM-DD.md` for ranked review lists.
|
||||
166
README.md
166
README.md
@@ -1,147 +1,77 @@
|
||||
# R&D Bouwblok: EVOLV (Edge-Layer Evolution for Optimized Virtualization)
|
||||
# EVOLV — Edge-Layer Evolution for Optimized Virtualization
|
||||
|
||||
## Over
|
||||
Node-RED custom nodes package voor de automatisering van afvalwaterzuiveringsinstallaties. Ontwikkeld door het R&D-team van Waterschap Brabantse Delta. Volgt de ISA-88 (S88) batch control standaard.
|
||||
|
||||
Dit bouwblok is ontwikkeld door het R&D-team van Waterschap Brabantse Delta voor gebruik in Node-RED.
|
||||
## Nodes
|
||||
|
||||
| Node | Functie | S88-niveau |
|
||||
|------|---------|------------|
|
||||
| **rotatingMachine** | Individuele pomp/compressor/blower aansturing | Equipment |
|
||||
| **machineGroupControl** | Multi-pomp optimalisatie (BEP-Gravitation) | Unit |
|
||||
| **pumpingStation** | Pompgemaal met hydraulische context | Unit |
|
||||
| **valve** | Individuele klep modellering | Equipment |
|
||||
| **valveGroupControl** | Klep groep coordinatie | Unit |
|
||||
| **reactor** | Biologische reactor (ASM kinetiek) | Unit |
|
||||
| **settler** | Nabezinker / slibscheiding | Unit |
|
||||
| **monster** | Multi-parameter biologische monitoring | Equipment |
|
||||
| **measurement** | Sensor signaalconditionering | Control Module |
|
||||
| **diffuser** | Beluchting aansturing | Equipment |
|
||||
| **dashboardAPI** | InfluxDB telemetrie + FlowFuse dashboards | — |
|
||||
| **generalFunctions** | Gedeelde bibliotheek (predict, PID, convert, etc.) | — |
|
||||
|
||||
> *[Voeg hier een korte toelichting toe over de specifieke functionele werking van dit bouwblok]*
|
||||
## Architectuur
|
||||
|
||||
---
|
||||
Elke node volgt een drie-lagen patroon:
|
||||
1. **Entry file** (`<naam>.js`) — registratie bij Node-RED, admin endpoints
|
||||
2. **nodeClass** (`src/nodeClass.js`) — Node-RED adapter (tick loop, routing, status)
|
||||
3. **specificClass** (`src/specificClass.js`) — pure domeinlogica (fysica, toestandsmachines)
|
||||
|
||||
## Licentie
|
||||
Drie output-poorten per node: **Port 0** = procesdata, **Port 1** = InfluxDB telemetrie, **Port 2** = registratie/besturing.
|
||||
|
||||
Deze software valt onder de **Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)**-licentie.
|
||||
|
||||
- Gebruik, aanpassing en verspreiding is toegestaan voor **niet-commerciële doeleinden**, mits duidelijke naamsvermelding naar Waterschap Brabantse Delta.
|
||||
- Voor **commercieel gebruik** is voorafgaande toestemming vereist.
|
||||
|
||||
📧 Contact: [rdlab@brabantsedelta.nl](mailto:rdlab@brabantsedelta.nl)
|
||||
🔗 Licentie: [https://creativecommons.org/licenses/by-nc/4.0/](https://creativecommons.org/licenses/by-nc/4.0/)
|
||||
|
||||
---
|
||||
|
||||
## Generieke opbouw van bouwblokken
|
||||
|
||||
- Reageren automatisch op inkomende data (bijv. de positie van een object bepaalt de berekening).
|
||||
- Ondersteunen koppeling van complexe dataketens tussen processen.
|
||||
- Gestandaardiseerde input/output:
|
||||
- Output = procesdata
|
||||
- Opslaginformatie + relatieve positionering t.o.v. andere objecten
|
||||
- Ontworpen voor combinatie met andere bouwblokken (ook van derden).
|
||||
- Open source en vrij beschikbaar voor iedereen.
|
||||
|
||||
---
|
||||
|
||||
## Installatie – Alle bouwblokken (via EVOLV)
|
||||
|
||||
Alle bouwblokken van het R&D-team zijn gebundeld in de **EVOLV-repository**, waarin gebruik wordt gemaakt van Git submodules.
|
||||
|
||||
### Eerste keer klonen:
|
||||
## Installatie
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://gitea.centraal.wbd-rd.nl/RnD/EVOLV.git
|
||||
git clone --recurse-submodules https://gitea.wbd-rd.nl/RnD/EVOLV.git
|
||||
cd EVOLV
|
||||
npm install
|
||||
```
|
||||
|
||||
Of, als je zonder submodules hebt gekloond:
|
||||
|
||||
```bash
|
||||
git submodule init
|
||||
git submodule update
|
||||
```
|
||||
|
||||
### Submodules updaten:
|
||||
|
||||
Om alle submodules te updaten naar de laatste versie van hun eigen repository:
|
||||
|
||||
Submodules updaten:
|
||||
```bash
|
||||
git submodule update --remote --merge
|
||||
```
|
||||
|
||||
Individuele submodule updaten:
|
||||
|
||||
Enkel bouwblok installeren in Node-RED:
|
||||
```bash
|
||||
cd nodes/<bouwblok-naam>
|
||||
git checkout main
|
||||
git pull origin main
|
||||
cd ../..
|
||||
git add nodes/<bouwblok-naam>
|
||||
git commit -m "Update submodule <bouwblok-naam>"
|
||||
mkdir -p ~/.node-red/nodes
|
||||
cp -r nodes/<bouwblok-naam> ~/.node-red/nodes/
|
||||
```
|
||||
|
||||
---
|
||||
## Testen
|
||||
|
||||
## Installatie – Enkel bouwblok
|
||||
```bash
|
||||
# Alle nodes
|
||||
bash scripts/test-all.sh
|
||||
|
||||
1. Clone de gewenste repository:
|
||||
# Specifieke node
|
||||
node --test nodes/<nodeName>/test/basic/*.test.js
|
||||
node --test nodes/<nodeName>/test/integration/*.test.js
|
||||
node --test nodes/<nodeName>/test/edge/*.test.js
|
||||
```
|
||||
|
||||
```bash
|
||||
git clone https://gitea.centraal.wbd-rd.nl/<repo-naam>.git
|
||||
```
|
||||
## Documentatie
|
||||
|
||||
2. Kopieer het bouwblok naar je Node-RED map:
|
||||
- **`wiki/`** — Projectwiki met architectuur, bevindingen en metrics ([index](wiki/index.md))
|
||||
- **`CLAUDE.md`** — Claude Code projectgids
|
||||
- **`manuals/node-red/`** — FlowFuse en Node-RED referentiedocumentatie
|
||||
- **`.agents/`** — Agent skills, beslissingen en function-anchors
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.node-red/nodes
|
||||
cp -r <pad-naar-geclonede-map> ~/.node-red/nodes/
|
||||
```
|
||||
## Licentie
|
||||
|
||||
3. Controleer of `settings.js` het volgende bevat:
|
||||
**Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)**
|
||||
|
||||
```js
|
||||
nodesDir: './nodes',
|
||||
```
|
||||
|
||||
4. Herstart Node-RED:
|
||||
|
||||
```bash
|
||||
node-red-stop
|
||||
node-red-start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bijdragen (Fork & Pull Request)
|
||||
|
||||
Wil je bijdragen aan de R&D bouwblokken? Volg dan dit stappenplan:
|
||||
|
||||
1. Fork maken
|
||||
|
||||
- Maak een fork van de gewenste R&D repository in Gitea.
|
||||
|
||||
- Je krijgt hiermee een eigen kopie van de repository in je account.
|
||||
|
||||
2. Wijzigingen aanbrengen
|
||||
|
||||
- Clone je fork lokaal en maak een nieuwe branch (bijv. feature/mijn-wijziging).
|
||||
|
||||
- Breng je wijzigingen aan, commit en push de branch terug naar je fork.
|
||||
|
||||
3. Pull Request indienen
|
||||
|
||||
- Ga in Gitea naar je fork en open de branch.
|
||||
|
||||
- Klik op New Pull Request.
|
||||
|
||||
- Stel de R&D repository in bij samenvoegen met.
|
||||
|
||||
- Stel jouw fork/branch in bij trekken van.
|
||||
|
||||
4. Beschrijving toevoegen
|
||||
|
||||
- Geef een duidelijke titel en beschrijving.
|
||||
|
||||
- Verwijs indien van toepassing naar een issue met de notatie #<nummer> (bijv. #42).
|
||||
|
||||
5. Code review en merge
|
||||
|
||||
- De beheerders van de R&D repository beoordelen je wijziging.
|
||||
|
||||
- Na goedkeuring wordt de wijziging opgenomen in de R&D repository.
|
||||
|
||||
----
|
||||
Gebruik, aanpassing en verspreiding is toegestaan voor niet-commerciele doeleinden, mits naamsvermelding naar Waterschap Brabantse Delta. Voor commercieel gebruik is voorafgaande toestemming vereist.
|
||||
|
||||
## Contact
|
||||
|
||||
📧 rdlab@brabantsedelta.nl
|
||||
rdlab@brabantsedelta.nl
|
||||
|
||||
@@ -16,6 +16,10 @@ services:
|
||||
- .:/data/evolv:cached
|
||||
# Named volume: overlay node_modules so host doesn't need native deps
|
||||
- evolv_node_modules:/data/evolv/node_modules
|
||||
# Persistent Node-RED user dir: flows/projects/sessions survive
|
||||
# container recreation. Without this, `docker compose down && up`
|
||||
# wipes the active flow and the entrypoint reseeds demo-flow.json.
|
||||
- nodered_data:/data
|
||||
environment:
|
||||
- TZ=Europe/Amsterdam
|
||||
- LOCATION_ID=docker-dev
|
||||
@@ -83,6 +87,8 @@ services:
|
||||
volumes:
|
||||
evolv_node_modules:
|
||||
driver: local
|
||||
nodered_data:
|
||||
driver: local
|
||||
influxdb_data:
|
||||
driver: local
|
||||
grafana_data:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,18 +63,90 @@ npm install --no-save "$EVOLV_DIR" 2>/dev/null || {
|
||||
echo "[entrypoint] EVOLV nodes installed into Node-RED user dir."
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 4. Deploy demo flow if no user flow exists yet
|
||||
# 4. Bootstrap Node-RED projects from examples/
|
||||
#
|
||||
# Each examples/<name>/ becomes a project under /data/projects/<name>/.
|
||||
# The Projects feature (settings.js) needs each project to be a Git
|
||||
# repo, so we git-init each on first copy. After that the projects
|
||||
# live in the persistent nodered_data volume.
|
||||
#
|
||||
# Default project: pumpingstation-complete-example (settable via
|
||||
# DEFAULT_PROJECT env var).
|
||||
# -------------------------------------------------------
|
||||
PROJECTS_DIR="/data/projects"
|
||||
DEFAULT_PROJECT="${DEFAULT_PROJECT:-pumpingstation-complete-example}"
|
||||
mkdir -p "$PROJECTS_DIR"
|
||||
|
||||
if [ -d "$EVOLV_DIR/examples" ]; then
|
||||
for src in "$EVOLV_DIR/examples"/*/; do
|
||||
[ -d "$src" ] || continue
|
||||
name=$(basename "$src")
|
||||
dst="$PROJECTS_DIR/$name"
|
||||
if [ -d "$dst" ]; then
|
||||
echo "[entrypoint] Project '$name' already exists in /data/projects, skipping bootstrap."
|
||||
continue
|
||||
fi
|
||||
echo "[entrypoint] Bootstrapping project '$name'..."
|
||||
cp -r "$src" "$dst"
|
||||
|
||||
# Synthesize a Node-RED project package.json so the project is
|
||||
# recognised even when the source folder doesn't have one.
|
||||
if [ ! -f "$dst/package.json" ]; then
|
||||
cat > "$dst/package.json" << PKGJSON
|
||||
{
|
||||
"name": "$name",
|
||||
"description": "EVOLV example: $name",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"node-red": {
|
||||
"settings": {
|
||||
"flowFile": "flow.json",
|
||||
"credentialsFile": "flow_cred.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
PKGJSON
|
||||
fi
|
||||
|
||||
# Git init + initial commit (Node-RED projects require Git).
|
||||
if [ ! -d "$dst/.git" ]; then
|
||||
(
|
||||
cd "$dst" && \
|
||||
git init -q -b main && \
|
||||
git config user.email "evolv-dev@local" && \
|
||||
git config user.name "EVOLV Dev" && \
|
||||
git add . && \
|
||||
git commit -q -m "Bootstrap project $name from examples/" || true
|
||||
)
|
||||
fi
|
||||
echo "[entrypoint] Project '$name' ready at $dst"
|
||||
done
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 4b. Set the active project (Node-RED's projects state lives in
|
||||
# /data/.config.projects.json). Only set on first run; subsequent
|
||||
# boots respect the operator's last selection in the editor.
|
||||
# -------------------------------------------------------
|
||||
PROJ_STATE="/data/.config.projects.json"
|
||||
if [ ! -f "$PROJ_STATE" ] && [ -d "$PROJECTS_DIR/$DEFAULT_PROJECT" ]; then
|
||||
echo "[entrypoint] Setting active project = $DEFAULT_PROJECT"
|
||||
cat > "$PROJ_STATE" << JSON
|
||||
{
|
||||
"activeProject": "$DEFAULT_PROJECT",
|
||||
"projects": {
|
||||
"$DEFAULT_PROJECT": {}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
fi
|
||||
|
||||
# Legacy demo-flow.json fallback — kept for the no-projects case if a
|
||||
# user flips projects.enabled = false in settings.js.
|
||||
DEMO_FLOW="$EVOLV_DIR/docker/demo-flow.json"
|
||||
FLOW_FILE="/data/flows.json"
|
||||
|
||||
if [ -f "$DEMO_FLOW" ]; then
|
||||
# Deploy demo flow if flows.json is missing or is the default stub
|
||||
if [ ! -f "$FLOW_FILE" ] || grep -q "WARNING: please check" "$FLOW_FILE" 2>/dev/null; then
|
||||
echo "[entrypoint] Deploying demo flow..."
|
||||
if [ -f "$DEMO_FLOW" ] && [ ! -f "$FLOW_FILE" ]; then
|
||||
cp "$DEMO_FLOW" "$FLOW_FILE"
|
||||
echo "[entrypoint] Demo flow deployed to $FLOW_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
|
||||
14
docker/grafana/provisioning/dashboards/dashboards.yaml
Normal file
14
docker/grafana/provisioning/dashboards/dashboards.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: EVOLV
|
||||
orgId: 1
|
||||
folder: EVOLV
|
||||
type: file
|
||||
disableDeletion: false
|
||||
editable: true
|
||||
updateIntervalSeconds: 10
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
||||
foldersFromFilesStructure: false
|
||||
435
docker/grafana/provisioning/dashboards/pumping-station.json
Normal file
435
docker/grafana/provisioning/dashboards/pumping-station.json
Normal file
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"annotations": { "list": [] },
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"refresh": "5s",
|
||||
"schemaVersion": 39,
|
||||
"style": "dark",
|
||||
"tags": ["evolv", "pumping-station"],
|
||||
"templating": { "list": [] },
|
||||
"time": { "from": "now-15m", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "EVOLV — Pumping Station (complete)",
|
||||
"uid": "evolv-ps-complete",
|
||||
"version": 1,
|
||||
"weekStart": "",
|
||||
"panels": [
|
||||
{
|
||||
"type": "row",
|
||||
"id": 100,
|
||||
"title": "Realtime",
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||
"panels": []
|
||||
},
|
||||
{
|
||||
"type": "gauge",
|
||||
"id": 1,
|
||||
"title": "Basin level",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 7, "w": 6, "x": 0, "y": 1 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "lengthm",
|
||||
"min": 0,
|
||||
"max": 4,
|
||||
"decimals": 2,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "orange", "value": 1 },
|
||||
{ "color": "blue", "value": 2 },
|
||||
{ "color": "orange", "value": 3.5 },
|
||||
{ "color": "red", "value": 3.8 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true,
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"level.predicted.atequipment.default\")\n |> last()"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "gauge",
|
||||
"id": 2,
|
||||
"title": "Basin fill",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 7, "w": 6, "x": 6, "y": 1 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"decimals": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "orange", "value": 10 },
|
||||
{ "color": "green", "value": 30 },
|
||||
{ "color": "orange", "value": 80 },
|
||||
{ "color": "red", "value": 95 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"showThresholdMarkers": true,
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"volumePercent.predicted.atequipment.default\")\n |> last()"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"id": 3,
|
||||
"title": "Total flow (MGC)",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 7, "w": 6, "x": 12, "y": 1 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "m³/h",
|
||||
"decimals": 1,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "gray", "value": null },
|
||||
{ "color": "blue", "value": 50 },
|
||||
{ "color": "green", "value": 200 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"colorMode": "background",
|
||||
"graphMode": "area"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"MGC — Pump Group\")\n |> filter(fn: (r) => r._field =~ /^flow\\.predicted\\.atequipment\\./)\n |> last()"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"id": 4,
|
||||
"title": "Total power (MGC)",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 7, "w": 6, "x": 18, "y": 1 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "kwatt",
|
||||
"decimals": 2,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "gray", "value": null },
|
||||
{ "color": "blue", "value": 1 },
|
||||
{ "color": "green", "value": 5 },
|
||||
{ "color": "orange", "value": 20 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"colorMode": "background",
|
||||
"graphMode": "area"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"MGC — Pump Group\")\n |> filter(fn: (r) => r._field =~ /^power\\.predicted\\.atequipment\\./)\n |> last()"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"id": 5,
|
||||
"title": "Pump A — state",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 8 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{ "type": "value", "options": { "operational": { "color": "green", "text": "OPERATIONAL" } } },
|
||||
{ "type": "value", "options": { "starting": { "color": "blue", "text": "STARTING" } } },
|
||||
{ "type": "value", "options": { "stopping": { "color": "orange", "text": "STOPPING" } } },
|
||||
{ "type": "value", "options": { "idle": { "color": "gray", "text": "IDLE" } } }
|
||||
],
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "gray", "value": null }] }
|
||||
}
|
||||
},
|
||||
"options": { "colorMode": "background", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"rotatingmachine_pump_a\")\n |> filter(fn: (r) => r._field == \"state\")\n |> last()"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"id": 6,
|
||||
"title": "Pump B — state",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 8 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{ "type": "value", "options": { "operational": { "color": "green", "text": "OPERATIONAL" } } },
|
||||
{ "type": "value", "options": { "starting": { "color": "blue", "text": "STARTING" } } },
|
||||
{ "type": "value", "options": { "stopping": { "color": "orange", "text": "STOPPING" } } },
|
||||
{ "type": "value", "options": { "idle": { "color": "gray", "text": "IDLE" } } }
|
||||
],
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "gray", "value": null }] }
|
||||
}
|
||||
},
|
||||
"options": { "colorMode": "background", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"rotatingmachine_pump_b\")\n |> filter(fn: (r) => r._field == \"state\")\n |> last()"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"id": 7,
|
||||
"title": "Pump C — state",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 8 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{ "type": "value", "options": { "operational": { "color": "green", "text": "OPERATIONAL" } } },
|
||||
{ "type": "value", "options": { "starting": { "color": "blue", "text": "STARTING" } } },
|
||||
{ "type": "value", "options": { "stopping": { "color": "orange", "text": "STOPPING" } } },
|
||||
{ "type": "value", "options": { "idle": { "color": "gray", "text": "IDLE" } } }
|
||||
],
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "gray", "value": null }] }
|
||||
}
|
||||
},
|
||||
"options": { "colorMode": "background", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"rotatingmachine_pump_c\")\n |> filter(fn: (r) => r._field == \"state\")\n |> last()"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "row",
|
||||
"id": 200,
|
||||
"title": "Historic",
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 },
|
||||
"panels": []
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"id": 10,
|
||||
"title": "Basin — level (m) and fill (%)",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 13 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineInterpolation": "smooth",
|
||||
"fillOpacity": 8,
|
||||
"spanNulls": true
|
||||
},
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byName", "options": "level (m)" },
|
||||
"properties": [{ "id": "unit", "value": "lengthm" }, { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } }] },
|
||||
{ "matcher": { "id": "byName", "options": "fill (%)" },
|
||||
"properties": [{ "id": "unit", "value": "percent" }, { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } }] }
|
||||
]
|
||||
},
|
||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"level.predicted.atequipment.default\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"level (m)\")"
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"volumePercent.predicted.atequipment.default\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"fill (%)\")"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"id": 11,
|
||||
"title": "Inflow / Outflow / Net flow (m³/h)",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 21 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "m³/h",
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineInterpolation": "smooth",
|
||||
"fillOpacity": 5,
|
||||
"spanNulls": true
|
||||
},
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
}
|
||||
},
|
||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field =~ /^flow\\.predicted\\.(in|out)\\./ or r._field == \"netFlowRate.predicted.atequipment.default\")\n |> map(fn: (r) => ({ r with _value: r._value * 3600.0 }))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"id": 12,
|
||||
"title": "Per-pump flow (m³/h) — predicted",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 29 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "m³/h",
|
||||
"custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
}
|
||||
},
|
||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /^rotatingmachine_/)\n |> filter(fn: (r) => r._field =~ /^flow\\.predicted\\.downstream\\./)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"id": 13,
|
||||
"title": "Per-pump power (kW) — predicted",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "kwatt",
|
||||
"custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
}
|
||||
},
|
||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /^rotatingmachine_/)\n |> filter(fn: (r) => r._field =~ /^power\\.predicted\\.atequipment\\./)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"id": 14,
|
||||
"title": "Per-pump pressures (mbar) — sensors",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 37 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "pressuremmbar",
|
||||
"custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 3, "spanNulls": true },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
}
|
||||
},
|
||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /-(Up|Dn)$/)\n |> filter(fn: (r) => r._field == \"mAbs\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"id": 15,
|
||||
"title": "Per-pump sensor flow (m³/h) — measured",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 45 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "m³/h",
|
||||
"custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
}
|
||||
},
|
||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /-Flow$/)\n |> filter(fn: (r) => r._field == \"mAbs\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"id": 16,
|
||||
"title": "Per-pump sensor power (kW) — measured",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 45 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "kwatt",
|
||||
"custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
}
|
||||
},
|
||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /-Pwr$/)\n |> filter(fn: (r) => r._field == \"mAbs\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,10 +15,22 @@ module.exports = {
|
||||
// No authentication for dev environment
|
||||
adminAuth: null,
|
||||
|
||||
// Disable projects (we use git directly)
|
||||
// Projects ON: each example folder under /data/projects is a Node-RED
|
||||
// project (a small Git repo). Operator switches between them in the
|
||||
// editor (Projects → Open Project). The entrypoint bootstraps every
|
||||
// examples/<name>/ into /data/projects/<name>/ on first run; after
|
||||
// that, edits live in the persistent nodered_data volume. To copy
|
||||
// edits back into the EVOLV source tree, run:
|
||||
// docker cp evolv-nodered:/data/projects/<name>/flow.json \
|
||||
// examples/<name>/flow.json
|
||||
editorTheme: {
|
||||
projects: {
|
||||
enabled: false
|
||||
enabled: true,
|
||||
workflow: {
|
||||
// Manual: editor doesn't auto-commit. Use the Projects UI
|
||||
// (or `git` from a shell into the container) to commit.
|
||||
mode: 'manual'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
602
docs/DEVELOPER_GUIDE.md
Normal file
602
docs/DEVELOPER_GUIDE.md
Normal file
@@ -0,0 +1,602 @@
|
||||
# EVOLV Developer Guide: Creating a New Node
|
||||
|
||||
This guide walks through creating a new EVOLV node from scratch, following the project's three-layer architecture pattern.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** (v18+)
|
||||
- **Node-RED** installed globally or as a dev dependency
|
||||
- Clone the repo and run `npm install` in the root (no build step required)
|
||||
- The `generalFunctions` submodule must be initialized (`git submodule update --init`)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Every EVOLV node follows a **three-layer pattern**:
|
||||
|
||||
| Layer | File | Responsibility |
|
||||
|-------|------|---------------|
|
||||
| 1 - Wrapper | `<name>.js` | Registers the node type with Node-RED, sets up HTTP endpoints for menus/config |
|
||||
| 2 - Node Adapter | `src/nodeClass.js` | Bridges Node-RED with domain logic: config loading, tick loop, input routing, lifecycle |
|
||||
| 3 - Domain Logic | `src/specificClass.js` | Pure business logic with no Node-RED dependencies |
|
||||
|
||||
Plus a UI definition: `<name>.html` for the Node-RED editor.
|
||||
|
||||
## Step-by-Step: Creating a New Node
|
||||
|
||||
We will create a hypothetical `flowMeter` node as an example.
|
||||
|
||||
### Step 1: Create Directory Structure
|
||||
|
||||
```
|
||||
nodes/flowMeter/
|
||||
flowMeter.js # Layer 1 - wrapper
|
||||
flowMeter.html # UI definition
|
||||
src/
|
||||
nodeClass.js # Layer 2 - node adapter
|
||||
specificClass.js # Layer 3 - domain logic
|
||||
test/
|
||||
specificClass.test.js
|
||||
```
|
||||
|
||||
### Step 2: Write the Wrapper (`flowMeter.js`)
|
||||
|
||||
The wrapper registers the node type with Node-RED and exposes HTTP endpoints for dynamic menus and config data.
|
||||
|
||||
```js
|
||||
const nameOfNode = 'flowMeter';
|
||||
const nodeClass = require('./src/nodeClass.js');
|
||||
const { MenuManager, configManager } = require('generalFunctions');
|
||||
|
||||
module.exports = function(RED) {
|
||||
// Register the node type
|
||||
RED.nodes.registerType(nameOfNode, function(config) {
|
||||
RED.nodes.createNode(this, config);
|
||||
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
|
||||
});
|
||||
|
||||
// Menu endpoint (dynamic dropdowns in the editor UI)
|
||||
const menuMgr = new MenuManager();
|
||||
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
|
||||
try {
|
||||
const script = menuMgr.createEndpoint(nameOfNode, ['asset', 'logger', 'position']);
|
||||
res.type('application/javascript').send(script);
|
||||
} catch (err) {
|
||||
res.status(500).send(`// Error generating menu: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Config data endpoint (exposes JSON config to the editor)
|
||||
const cfgMgr = new configManager();
|
||||
RED.httpAdmin.get(`/${nameOfNode}/configData.js`, (req, res) => {
|
||||
try {
|
||||
const script = cfgMgr.createEndpoint(nameOfNode);
|
||||
res.type('application/javascript').send(script);
|
||||
} catch (err) {
|
||||
res.status(500).send(`// Error generating configData: ${err.message}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Key points:
|
||||
- `nameOfNode` must match the file name, the `registerType` name, and the HTML `data-template-name`.
|
||||
- Menu categories (`['asset', 'logger', 'position']`) control which shared UI sections appear.
|
||||
- The config endpoint is optional if you do not need dynamic config in the editor.
|
||||
|
||||
### Step 3: Write `nodeClass.js` (Node Adapter)
|
||||
|
||||
This class bridges Node-RED's API with your domain logic.
|
||||
|
||||
```js
|
||||
const { outputUtils, configManager } = require('generalFunctions');
|
||||
const Specific = require('./specificClass');
|
||||
|
||||
class nodeClass {
|
||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
||||
this.node = nodeInstance;
|
||||
this.RED = RED;
|
||||
this.name = nameOfNode;
|
||||
|
||||
this._loadConfig(uiConfig);
|
||||
this._setupSpecificClass();
|
||||
this._bindEvents();
|
||||
this._registerChild();
|
||||
this._startTickLoop();
|
||||
this._attachInputHandler();
|
||||
this._attachCloseHandler();
|
||||
}
|
||||
|
||||
_loadConfig(uiConfig) {
|
||||
const cfgMgr = new configManager();
|
||||
this.defaultConfig = cfgMgr.getConfig(this.name);
|
||||
|
||||
// buildConfig merges base sections (general, asset, functionality)
|
||||
// with node-specific domain config from the UI
|
||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
|
||||
// Add domain-specific config sections here:
|
||||
flowSettings: {
|
||||
maxFlow: uiConfig.maxFlow,
|
||||
pipeSize: uiConfig.pipeSize,
|
||||
},
|
||||
});
|
||||
|
||||
this._output = new outputUtils();
|
||||
}
|
||||
|
||||
_setupSpecificClass() {
|
||||
this.source = new Specific(this.config);
|
||||
this.node.source = this.source;
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
// Subscribe to domain events for Node-RED status display
|
||||
this.source.emitter.on('flowUpdate', (val) => {
|
||||
this.node.status({ fill: 'green', shape: 'dot', text: `${val} ${this.config.general.unit}` });
|
||||
});
|
||||
}
|
||||
|
||||
_registerChild() {
|
||||
// Delayed to avoid Node-RED startup race conditions
|
||||
setTimeout(() => {
|
||||
this.node.send([
|
||||
null,
|
||||
null,
|
||||
{
|
||||
topic: 'registerChild',
|
||||
payload: this.node.id,
|
||||
positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment',
|
||||
distance: this.config?.functionality?.distance || null,
|
||||
},
|
||||
]);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
_startTickLoop() {
|
||||
setTimeout(() => {
|
||||
this._tickInterval = setInterval(() => this._tick(), 1000);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
_tick() {
|
||||
this.source.tick();
|
||||
const raw = this.source.getOutput();
|
||||
const processMsg = this._output.formatMsg(raw, this.config, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
|
||||
this.node.send([processMsg, influxMsg]);
|
||||
}
|
||||
|
||||
_attachInputHandler() {
|
||||
this.node.on('input', (msg, send, done) => {
|
||||
switch (msg.topic) {
|
||||
case 'measurement':
|
||||
if (typeof msg.payload === 'number') {
|
||||
this.source.inputValue = parseFloat(msg.payload);
|
||||
}
|
||||
break;
|
||||
// Add more input topics as needed
|
||||
}
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
_attachCloseHandler() {
|
||||
this.node.on('close', (done) => {
|
||||
clearInterval(this._tickInterval);
|
||||
done();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = nodeClass;
|
||||
```
|
||||
|
||||
Essential methods every `nodeClass` must implement:
|
||||
- `_loadConfig()` -- merges default JSON config with UI config via `configManager.buildConfig()`
|
||||
- `_setupSpecificClass()` -- instantiates the domain class
|
||||
- `_registerChild()` -- sends a `registerChild` message on output port 2 (parent)
|
||||
- `_startTickLoop()` -- drives periodic output at 1-second intervals
|
||||
- `_tick()` -- calls `source.getOutput()` and formats via `outputUtils.formatMsg()`
|
||||
- `_attachInputHandler()` -- routes incoming `msg.topic` to domain methods
|
||||
- `_attachCloseHandler()` -- clears timers on node removal
|
||||
|
||||
### Step 4: Write `specificClass.js` (Domain Logic)
|
||||
|
||||
This is pure JavaScript with no Node-RED dependencies.
|
||||
|
||||
```js
|
||||
const EventEmitter = require('events');
|
||||
const { logger, configUtils, configManager, MeasurementContainer } = require('generalFunctions');
|
||||
|
||||
class FlowMeter {
|
||||
constructor(config = {}) {
|
||||
this.emitter = new EventEmitter();
|
||||
this.configManager = new configManager();
|
||||
this.defaultConfig = this.configManager.getConfig('flowMeter');
|
||||
this.configUtils = new configUtils(this.defaultConfig);
|
||||
this.config = this.configUtils.initConfig(config);
|
||||
|
||||
this.logger = new logger(
|
||||
this.config.general.logging.enabled,
|
||||
this.config.general.logging.logLevel,
|
||||
this.config.general.name
|
||||
);
|
||||
|
||||
// MeasurementContainer stores typed/positioned values
|
||||
this.measurements = new MeasurementContainer({
|
||||
autoConvert: true,
|
||||
windowSize: this.config.smoothing?.smoothWindow || 10,
|
||||
});
|
||||
this.measurements.setChildId(this.config.general.id);
|
||||
this.measurements.setChildName(this.config.general.name);
|
||||
|
||||
// Domain state
|
||||
this.currentFlow = 0;
|
||||
}
|
||||
|
||||
tick() {
|
||||
// Called every 1 second by nodeClass._tick()
|
||||
this.calculateFlow();
|
||||
}
|
||||
|
||||
calculateFlow() {
|
||||
// Your domain logic here
|
||||
const flow = this.currentFlow;
|
||||
|
||||
// Store in MeasurementContainer using the chainable API:
|
||||
// .type(measType).variant(variant).position(pos).value(val, timestamp, unit)
|
||||
this.measurements
|
||||
.type(this.config.asset.type)
|
||||
.variant('measured')
|
||||
.position(this.config.functionality.positionVsParent)
|
||||
.value(flow, Date.now(), this.config.asset.unit);
|
||||
|
||||
this.emitter.emit('flowUpdate', flow);
|
||||
}
|
||||
|
||||
getOutput() {
|
||||
return {
|
||||
flow: this.currentFlow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FlowMeter;
|
||||
```
|
||||
|
||||
Key patterns:
|
||||
- Always create an `emitter` (EventEmitter) -- parents subscribe to child events through it.
|
||||
- Use `MeasurementContainer` for storing measurements. The chainable API follows the pattern: `measurements.type(t).variant(v).position(p).value(val, timestamp, unit)`.
|
||||
- Expose `tick()` and `getOutput()` for the node adapter to call.
|
||||
- Use `logger` instead of `console.log`.
|
||||
|
||||
### Step 5: Write the HTML (UI Definition)
|
||||
|
||||
```html
|
||||
<script src="/flowMeter/menu.js"></script>
|
||||
<script src="/flowMeter/configData.js"></script>
|
||||
|
||||
<script>
|
||||
RED.nodes.registerType("flowMeter", {
|
||||
category: "EVOLV",
|
||||
color: "#a9daee", // S88 Control Module color
|
||||
defaults: {
|
||||
name: { value: "flowMeter" },
|
||||
maxFlow: { value: 100, required: true },
|
||||
pipeSize: { value: 0.1, required: true },
|
||||
// Standard fields (asset, logger, position)
|
||||
uuid: { value: "" },
|
||||
supplier: { value: "" },
|
||||
category: { value: "" },
|
||||
assetType: { value: "" },
|
||||
model: { value: "" },
|
||||
unit: { value: "" },
|
||||
enableLog: { value: false },
|
||||
logLevel: { value: "error" },
|
||||
positionVsParent: { value: "" },
|
||||
positionIcon: { value: "" },
|
||||
},
|
||||
inputs: 1,
|
||||
outputs: 3,
|
||||
outputLabels: ["process", "dbase", "parent"],
|
||||
icon: "font-awesome/fa-tachometer",
|
||||
label: function() {
|
||||
return this.name || "flowMeter";
|
||||
},
|
||||
oneditprepare: function() {
|
||||
// Wait for shared menu system to initialize
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.flowMeter?.initEditor) {
|
||||
window.EVOLV.nodes.flowMeter.initEditor(this);
|
||||
} else {
|
||||
setTimeout(waitForMenuData, 50);
|
||||
}
|
||||
};
|
||||
waitForMenuData();
|
||||
},
|
||||
oneditsave: function() {
|
||||
if (window.EVOLV?.nodes?.flowMeter?.assetMenu?.saveEditor) {
|
||||
window.EVOLV.nodes.flowMeter.assetMenu.saveEditor(this);
|
||||
}
|
||||
if (window.EVOLV?.nodes?.flowMeter?.loggerMenu?.saveEditor) {
|
||||
window.EVOLV.nodes.flowMeter.loggerMenu.saveEditor(this);
|
||||
}
|
||||
if (window.EVOLV?.nodes?.flowMeter?.positionMenu?.saveEditor) {
|
||||
window.EVOLV.nodes.flowMeter.positionMenu.saveEditor(this);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="flowMeter">
|
||||
<div class="form-row">
|
||||
<label for="node-input-maxFlow"><i class="fa fa-arrows-v"></i> Max Flow</label>
|
||||
<input type="number" id="node-input-maxFlow" placeholder="100" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-pipeSize"><i class="fa fa-circle-o"></i> Pipe Size (m)</label>
|
||||
<input type="number" id="node-input-pipeSize" placeholder="0.1" step="0.01" />
|
||||
</div>
|
||||
<!-- Shared UI sections injected by MenuManager -->
|
||||
<div id="asset-fields-placeholder"></div>
|
||||
<div id="logger-fields-placeholder"></div>
|
||||
<div id="position-fields-placeholder"></div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-help-name="flowMeter">
|
||||
<p><b>Flow Meter Node</b>: Measures and processes flow data.</p>
|
||||
</script>
|
||||
```
|
||||
|
||||
**S88 color scheme** (pick based on your node's hierarchy level):
|
||||
|
||||
| S88 Level | Color | Text Color |
|
||||
|-----------|-------|-----------|
|
||||
| Area | `#0f52a5` | white |
|
||||
| Process Cell | `#0c99d9` | white |
|
||||
| Unit | `#50a8d9` | black |
|
||||
| Equipment | `#86bbdd` | black |
|
||||
| Control Module | `#a9daee` | black |
|
||||
|
||||
All nodes must have **3 outputs**: `[process, dbase, parent]`.
|
||||
|
||||
### Step 6: Create Config JSON Schema
|
||||
|
||||
Create `nodes/generalFunctions/src/configs/flowMeter.json`. This defines defaults and validation rules for every config property. The `configManager` reads this file by node name.
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"name": {
|
||||
"default": "FlowMeter",
|
||||
"rules": { "type": "string", "description": "Human-readable name." }
|
||||
},
|
||||
"id": {
|
||||
"default": null,
|
||||
"rules": { "type": "string", "nullable": true }
|
||||
},
|
||||
"unit": {
|
||||
"default": "m3/h",
|
||||
"rules": { "type": "string" }
|
||||
},
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"default": "info",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "debug" }, { "value": "info" },
|
||||
{ "value": "warn" }, { "value": "error" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"enabled": { "default": true, "rules": { "type": "boolean" } }
|
||||
}
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": { "default": "flowMeter", "rules": { "type": "string" } },
|
||||
"role": { "default": "Sensor", "rules": { "type": "string" } },
|
||||
"positionVsParent": {
|
||||
"default": "atEquipment",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "atEquipment" }, { "value": "upstream" }, { "value": "downstream" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"asset": {
|
||||
"supplier": { "default": "Unknown", "rules": { "type": "string" } },
|
||||
"category": { "default": "sensor", "rules": { "type": "string" } },
|
||||
"type": { "default": "flow", "rules": { "type": "string" } },
|
||||
"model": { "default": "Unknown", "rules": { "type": "string" } },
|
||||
"unit": { "default": "m3/h", "rules": { "type": "string" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each property has a `default` value and a `rules` object specifying the type (`string`, `number`, `boolean`, `enum`, `object`), optional constraints (`min`, `max`, `nullable`), and a description.
|
||||
|
||||
### Step 7: Register with `package.json`
|
||||
|
||||
Add your node to the root `package.json` under `node-red.nodes`:
|
||||
|
||||
```json
|
||||
{
|
||||
"node-red": {
|
||||
"nodes": {
|
||||
"flowMeter": "nodes/flowMeter/flowMeter.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart Node-RED to pick up the new node.
|
||||
|
||||
### Step 8: Add Tests
|
||||
|
||||
Create `nodes/flowMeter/test/specificClass.test.js`. Tests target Layer 3 (domain logic) directly, without Node-RED.
|
||||
|
||||
```js
|
||||
const FlowMeter = require('../src/specificClass');
|
||||
|
||||
function makeConfig(overrides = {}) {
|
||||
const base = {
|
||||
general: { name: 'TestFlow', id: 'test-1', logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'flowMeter', role: 'sensor', positionVsParent: 'atEquipment' },
|
||||
asset: { category: 'sensor', type: 'flow', model: 'test', supplier: 'Test', unit: 'm3/h' },
|
||||
};
|
||||
for (const key of Object.keys(overrides)) {
|
||||
base[key] = typeof overrides[key] === 'object' ? { ...base[key], ...overrides[key] } : overrides[key];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
describe('FlowMeter specificClass', () => {
|
||||
it('should create an instance', () => {
|
||||
const fm = new FlowMeter(makeConfig());
|
||||
expect(fm).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return output with expected keys', () => {
|
||||
const fm = new FlowMeter(makeConfig());
|
||||
const out = fm.getOutput();
|
||||
expect(out).toHaveProperty('flow');
|
||||
});
|
||||
|
||||
it('tick() should not throw', () => {
|
||||
const fm = new FlowMeter(makeConfig());
|
||||
expect(() => fm.tick()).not.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run tests with: `npm test` (uses Jest). The project also supports `node:test` for basic smoke tests.
|
||||
|
||||
**Test organization conventions** (based on existing nodes):
|
||||
- `test/specificClass.test.js` -- unit tests for domain logic
|
||||
- `test/basic/*.test.js` -- structural/smoke tests (module loads, exports exist)
|
||||
- `test/edge/*.test.js` -- edge case and boundary tests
|
||||
- `test/integration/*.test.js` -- multi-component integration tests
|
||||
|
||||
## Key APIs Reference
|
||||
|
||||
### MeasurementContainer
|
||||
|
||||
Chainable storage for typed, positioned measurements. Used by every domain class.
|
||||
|
||||
```js
|
||||
const { MeasurementContainer } = require('generalFunctions');
|
||||
const mc = new MeasurementContainer({ autoConvert: true, windowSize: 10 });
|
||||
mc.setChildId('node-id');
|
||||
mc.setChildName('PT-001');
|
||||
|
||||
// Store a value
|
||||
mc.type('pressure').variant('measured').position('upstream').value(3.5, Date.now(), 'bar');
|
||||
|
||||
// Parents subscribe to events via mc.emitter
|
||||
mc.emitter.on('pressure.measured.upstream', (data) => { /* { value, unit, ... } */ });
|
||||
```
|
||||
|
||||
The event name follows the pattern: `{type}.{variant}.{position}`.
|
||||
|
||||
### configManager.buildConfig()
|
||||
|
||||
Merges the JSON config schema defaults with UI-provided values. Called in `nodeClass._loadConfig()`.
|
||||
|
||||
```js
|
||||
const { configManager } = require('generalFunctions');
|
||||
const cfgMgr = new configManager();
|
||||
const defaults = cfgMgr.getConfig('myNode'); // loads myNode.json
|
||||
const config = cfgMgr.buildConfig('myNode', uiConfig, nodeId, domainOverrides);
|
||||
```
|
||||
|
||||
### POSITIONS
|
||||
|
||||
Canonical position constants. Use these instead of hardcoded strings.
|
||||
|
||||
```js
|
||||
const { POSITIONS } = require('generalFunctions');
|
||||
// POSITIONS.UPSTREAM = 'upstream'
|
||||
// POSITIONS.DOWNSTREAM = 'downstream'
|
||||
// POSITIONS.AT_EQUIPMENT = 'atEquipment'
|
||||
// POSITIONS.DELTA = 'delta'
|
||||
```
|
||||
|
||||
### outputUtils.formatMsg()
|
||||
|
||||
Formats raw output data into either `process` or `influxdb` messages. Only sends changed fields.
|
||||
|
||||
```js
|
||||
const { outputUtils } = require('generalFunctions');
|
||||
const out = new outputUtils();
|
||||
const processMsg = out.formatMsg(rawData, config, 'process');
|
||||
const influxMsg = out.formatMsg(rawData, config, 'influxdb');
|
||||
node.send([processMsg, influxMsg]);
|
||||
```
|
||||
|
||||
### childRegistrationUtils
|
||||
|
||||
Manages parent-child node relationships. Parents use this to accept child registrations.
|
||||
|
||||
```js
|
||||
const { childRegistrationUtils } = require('generalFunctions');
|
||||
const regUtils = new childRegistrationUtils(this); // 'this' is the parent specificClass
|
||||
// Called when a child's registerChild message arrives:
|
||||
regUtils.registerChild(childSource, positionVsParent, distance);
|
||||
```
|
||||
|
||||
The parent's `registerChild()` method subscribes to the child's `measurements.emitter` events for data propagation.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Parent-Child Registration
|
||||
|
||||
1. Child sends `{ topic: 'registerChild', payload: nodeId, positionVsParent }` on output port 2.
|
||||
2. Parent's `nodeClass._attachInputHandler()` catches `msg.topic === 'registerChild'`.
|
||||
3. Parent calls `childRegistrationUtils.registerChild(child, position)`.
|
||||
4. Parent subscribes to child's `measurements.emitter` events (e.g., `'flow.measured.downstream'`).
|
||||
5. When the child updates a measurement, the parent's listener fires and updates its own state.
|
||||
|
||||
### Tick Loop
|
||||
|
||||
Every node runs a 1-second tick loop that:
|
||||
1. Calls `source.tick()` to advance domain logic.
|
||||
2. Calls `source.getOutput()` for current state.
|
||||
3. Formats into `process` and `influxdb` messages via `outputUtils.formatMsg()`.
|
||||
4. Sends on ports 0 (process) and 1 (dbase).
|
||||
|
||||
The tick loop starts with a 1-second delay to allow child registration to complete.
|
||||
|
||||
### Three-Output Format
|
||||
|
||||
All nodes send on three ports: `node.send([processMsg, influxMsg, parentMsg])`.
|
||||
|
||||
| Port | Purpose | When |
|
||||
|------|---------|------|
|
||||
| 0 | Process data for downstream nodes | Every tick (if changed) |
|
||||
| 1 | InfluxDB line protocol for persistence | Every tick (if changed) |
|
||||
| 2 | Parent registration/control messages | On startup; on parent commands |
|
||||
|
||||
### Event-Driven Communication
|
||||
|
||||
Nodes communicate via `EventEmitter`, not Node-RED wires:
|
||||
- `measurements.emitter` fires `{type}.{variant}.{position}` events.
|
||||
- Parents listen to children's emitters after registration.
|
||||
- The `emitter` on the specificClass itself is used for internal state changes (e.g., updating Node-RED node status display).
|
||||
|
||||
## Checklist
|
||||
|
||||
Before submitting a new node, verify:
|
||||
|
||||
- [ ] Three-layer structure: wrapper, nodeClass, specificClass
|
||||
- [ ] Config JSON in `generalFunctions/src/configs/<name>.json`
|
||||
- [ ] Registered in root `package.json` under `node-red.nodes`
|
||||
- [ ] HTML registers under category `'EVOLV'` with correct S88 color
|
||||
- [ ] Three outputs: `[process, dbase, parent]`
|
||||
- [ ] Uses `logger` (not `console.log`)
|
||||
- [ ] Uses `MeasurementContainer` for measurement storage
|
||||
- [ ] Uses `outputUtils.formatMsg()` for output formatting
|
||||
- [ ] Tick loop cleans up in `_attachCloseHandler()`
|
||||
- [ ] Tests exist for specificClass domain logic
|
||||
- [ ] Node-specific UI fields plus shared placeholders (asset, logger, position)
|
||||
29
eslint.config.js
Normal file
29
eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const js = require('@eslint/js');
|
||||
const globals = require('globals');
|
||||
|
||||
module.exports = [
|
||||
js.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
RED: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'no-console': 'off',
|
||||
'no-prototype-builtins': 'warn',
|
||||
'no-constant-condition': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'nodes/generalFunctions/src/coolprop-node/coolprop/**',
|
||||
],
|
||||
},
|
||||
];
|
||||
53
examples/README.md
Normal file
53
examples/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 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/<name>/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/<name>/` into `/data/projects/<name>/` 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 `<scenario>-<focus>`.
|
||||
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/<name>/`.
|
||||
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 <name>`.
|
||||
|
||||
## 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.
|
||||
111
examples/WORKFLOW.md
Normal file
111
examples/WORKFLOW.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# EVOLV Examples — Team Workflow
|
||||
|
||||
This file is the canonical guide for working with the example flows that live under `examples/`. Each subfolder is a Node-RED **project**; the Docker stack is set up so switching between them is two clicks in the editor.
|
||||
|
||||
## Stack at a glance
|
||||
|
||||
| Container | What | URL |
|
||||
|---|---|---|
|
||||
| `evolv-nodered` | Node-RED runtime + dashboard | <http://localhost:1880> · dashboard at <http://localhost:1880/dashboard> |
|
||||
| `evolv-influxdb` | Time-series store (port-1 telemetry) | <http://localhost:8086> · `evolv` / `evolv-dev-pw` |
|
||||
| `evolv-grafana` | Provisioned dashboards (anonymous viewer enabled) | <http://localhost:3000> |
|
||||
|
||||
The `evolv_nodered_data` named volume keeps `/data` (flows, projects, sessions) across `docker compose down && up`. The `examples/` directory in this repo is the **source of truth**; the Node-RED Projects feature operates on a copy in the volume.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd /path/to/EVOLV
|
||||
docker compose up -d
|
||||
# Node-RED: http://localhost:1880
|
||||
# Dashboard: http://localhost:1880/dashboard
|
||||
# Grafana: http://localhost:3000 (anonymous viewer)
|
||||
```
|
||||
|
||||
The first time you start it, the entrypoint copies every `examples/<name>/` into `/data/projects/<name>/` and `git init`s each. Subsequent starts skip folders that already exist in the volume.
|
||||
|
||||
## Switching examples
|
||||
|
||||
Open the editor → **menu → Projects → Open Project** → pick another project. The editor reloads the chosen flow.
|
||||
|
||||
The default active project on first boot is `pumpingstation-complete-example`. To change the default for fresh volumes, set `DEFAULT_PROJECT=<name>` on the `nodered` service in `docker-compose.yml`.
|
||||
|
||||
## Editing a flow
|
||||
|
||||
You have two paths. They serve different purposes — pick based on what you're doing.
|
||||
|
||||
### Path A — edit `build_flow.py` (canonical, recommended)
|
||||
|
||||
```bash
|
||||
# 1. Edit the Python generator
|
||||
vim examples/<name>/build_flow.py
|
||||
|
||||
# 2. Regenerate flow.json
|
||||
python3 examples/<name>/build_flow.py > examples/<name>/flow.json
|
||||
|
||||
# 3. Push to the runtime
|
||||
./scripts/sync-example.sh <name>
|
||||
```
|
||||
|
||||
The Python is the **source of truth**. It's diff-friendly and the right place for any change you intend to commit.
|
||||
|
||||
### Path B — edit in the Node-RED editor (experimentation)
|
||||
|
||||
```
|
||||
Open editor → Make changes → Deploy
|
||||
```
|
||||
|
||||
Edits go into the volume (`/data/projects/<name>/flow.json`). They survive `docker compose down && up` but are **not in the EVOLV git repo**. To incorporate them back:
|
||||
|
||||
```bash
|
||||
docker cp evolv-nodered:/data/projects/<name>/flow.json examples/<name>/flow.json
|
||||
```
|
||||
|
||||
Then commit `examples/<name>/flow.json` (and reverse-engineer the change into `build_flow.py` if you want it diff-friendly going forward).
|
||||
|
||||
## Adding a new example
|
||||
|
||||
```bash
|
||||
mkdir examples/<scenario>-<focus>
|
||||
# Build a flow.json (recommended: a build_flow.py that generates it)
|
||||
vim examples/<scenario>-<focus>/{build_flow.py,README.md,flow.json}
|
||||
|
||||
# Restart Node-RED so the entrypoint bootstraps the new project
|
||||
docker compose restart nodered
|
||||
```
|
||||
|
||||
The entrypoint synthesizes `package.json`, runs `git init`, and makes an initial commit so Node-RED recognises it as a project. Bootstrap is idempotent — if a `/data/projects/<name>/` already exists, it's left alone.
|
||||
|
||||
After restart, **Projects → Open Project** in the editor will list the new entry.
|
||||
|
||||
## Resetting state
|
||||
|
||||
| Goal | Command |
|
||||
|---|---|
|
||||
| Push the repo's `flow.json` into the runtime, reload | `./scripts/sync-example.sh <name>` |
|
||||
| Wipe one project's volume copy and re-bootstrap | `docker exec evolv-nodered rm -rf /data/projects/<name>` then `docker compose restart nodered` |
|
||||
| Wipe **everything** in the volume (flows, sessions, all projects, but NOT InfluxDB/Grafana) | `docker compose down && docker volume rm evolv_nodered_data && docker compose up -d` |
|
||||
| Wipe everything including telemetry | `docker compose down -v && docker compose up -d` |
|
||||
|
||||
## Debugging
|
||||
|
||||
| Symptom | Where to look |
|
||||
|---|---|
|
||||
| Flow not loading after deploy | `docker logs evolv-nodered` for crash backtraces |
|
||||
| InfluxDB empty / not receiving | Telemetry tab in editor → status of the `Count writes` node. Should show `N POSTs · M lines (0 err)`. |
|
||||
| Dashboard widget shows `n/a` | Check the Process Plant tab → output formatter function for that node — `c.<key>` keys the dispatcher reads from |
|
||||
| Grafana dashboard panels empty | Open InfluxDB UI (<http://localhost:8086>) → Data Explorer → confirm the field name the panel queries actually exists. Field names are flat dotted keys like `level.predicted.atequipment.default`. |
|
||||
| `interpolation configuration: New f =... is constrained` warnings | The pump curve f-axis is out-of-range. f = downstream − upstream pressure differential, in Pa, must be inside the curve's range (e.g. 70 000 – 390 000 Pa for `hidrostal-H05K-S03R`). Check the per-pump physics feeder formula. |
|
||||
| High CPU in Node-RED | Per-tick HTTP fan-out to InfluxDB; the pumpingstation example uses a 500 ms batch in the Telemetry tab. If CPU is still high, lower `tickIntervalMs` in the EVOLV node configs (currently 1000). |
|
||||
|
||||
## File map per example
|
||||
|
||||
```
|
||||
examples/<name>/
|
||||
├── build_flow.py ← canonical source of flow.json (Python generator)
|
||||
├── flow.json ← regenerated artefact, also tracked in Git
|
||||
├── README.md ← topology, control modes, dashboard map, things to try
|
||||
└── package.json ← (synthesized in volume by entrypoint, not in repo)
|
||||
```
|
||||
|
||||
The repo tracks `build_flow.py`, `flow.json`, and `README.md`. The `package.json` and `.git/` directory of the project live only in the named volume — they're created by the entrypoint on first bootstrap and don't leak back into the EVOLV Git history.
|
||||
195
examples/pumpingstation-complete-example/README.md
Normal file
195
examples/pumpingstation-complete-example/README.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 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): <http://localhost:1880/dashboard>
|
||||
- Grafana dashboard (realtime gauges + historic graphs): <http://localhost:3000> (anonymous viewer is on; the dashboard is `EVOLV / Pumping Station (complete)`)
|
||||
- InfluxDB UI: <http://localhost:8086> (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_<pump>` 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/<name>/` 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/<name>/flow.json` (in volume) | Until `docker compose down -v` |
|
||||
| Edit `examples/<name>/build_flow.py` then regenerate | `examples/<name>/flow.json` (in repo) | Always — it's in Git |
|
||||
| Run `scripts/sync-example.sh <name>` | Copies repo's `flow.json` → volume's project + reloads | Volume copy now matches repo |
|
||||
|
||||
### Adding a new example as a project
|
||||
|
||||
1. Create `examples/<your-name>/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 `<your-name>`.
|
||||
|
||||
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/<name>`) 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_<id>` 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.
|
||||
1910
examples/pumpingstation-complete-example/build_flow.py
Normal file
1910
examples/pumpingstation-complete-example/build_flow.py
Normal file
File diff suppressed because it is too large
Load Diff
5462
examples/pumpingstation-complete-example/flow.json
Normal file
5462
examples/pumpingstation-complete-example/flow.json
Normal file
File diff suppressed because it is too large
Load Diff
19
jest.config.js
Normal file
19
jest.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
verbose: true,
|
||||
testMatch: [
|
||||
'<rootDir>/nodes/generalFunctions/src/coolprop-node/test/**/*.test.js',
|
||||
'<rootDir>/nodes/generalFunctions/test/**/*.test.js',
|
||||
'<rootDir>/nodes/dashboardAPI/test/**/*.test.js',
|
||||
'<rootDir>/nodes/diffuser/test/specificClass.test.js',
|
||||
'<rootDir>/nodes/monster/test/**/*.test.js',
|
||||
'<rootDir>/nodes/pumpingStation/test/**/*.test.js',
|
||||
'<rootDir>/nodes/reactor/test/**/*.test.js',
|
||||
'<rootDir>/nodes/settler/test/**/*.test.js',
|
||||
'<rootDir>/nodes/measurement/test/**/*.test.js',
|
||||
],
|
||||
testPathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
],
|
||||
testTimeout: 15000,
|
||||
};
|
||||
Submodule nodes/dashboardAPI updated: 89d2260351...869ba4fca5
Submodule nodes/diffuser updated: c4dda5955f...7fbd207985
Submodule nodes/generalFunctions updated: 27a6d3c709...9a998191cd
Submodule nodes/machineGroupControl updated: b337bf9eb7...ea2857fb25
Submodule nodes/measurement updated: 43b5269f0b...998b2002e9
Submodule nodes/monster updated: 32ebfd7154...5a43f90569
Submodule nodes/pumpingStation updated: 7efd3b0a07...e2ebb31816
Submodule nodes/reactor updated: 2c69a5a0c1...c5fc5c1b59
Submodule nodes/rotatingMachine updated: 6b2a8239f2...8f9150e160
Submodule nodes/settler updated: 7f2d326612...b199663c77
Submodule nodes/valve updated: 6287708c1e...ae5bc750cd
Submodule nodes/valveGroupControl updated: 5e1f3946bf...0aa538c2c1
5887
package-lock.json
generated
5887
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -12,18 +12,21 @@
|
||||
"node-red": {
|
||||
"nodes": {
|
||||
"dashboardapi": "nodes/dashboardAPI/dashboardapi.js",
|
||||
"diffuser": "nodes/diffuser/diffuser.js",
|
||||
"machineGroupControl": "nodes/machineGroupControl/mgc.js",
|
||||
"measurement": "nodes/measurement/measurement.js",
|
||||
"monster": "nodes/monster/monster.js",
|
||||
"pumpingstation": "nodes/pumpingStation/pumpingStation.js",
|
||||
"reactor": "nodes/reactor/reactor.js",
|
||||
"rotatingMachine": "nodes/rotatingMachine/rotatingMachine.js",
|
||||
"settler": "nodes/settler/settler.js",
|
||||
"valve": "nodes/valve/valve.js",
|
||||
"valveGroupControl": "nodes/valveGroupControl/vgc.js",
|
||||
"pumpingstation": "nodes/pumpingStation/pumpingStation.js",
|
||||
"settler": "nodes/settler/settler.js"
|
||||
"valveGroupControl": "nodes/valveGroupControl/vgc.js"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -36,15 +39,28 @@
|
||||
"docker:test:gf": "docker compose exec nodered sh /data/evolv/scripts/test-all.sh gf",
|
||||
"docker:validate": "docker compose exec nodered sh /data/evolv/scripts/validate-nodes.sh",
|
||||
"docker:deploy": "docker compose exec nodered sh /data/evolv/scripts/deploy-flow.sh",
|
||||
"docker:reset": "docker compose down -v && docker compose up -d --build"
|
||||
"docker:reset": "docker compose down -v && docker compose up -d --build",
|
||||
"test": "jest --forceExit",
|
||||
"test:node": "node --test nodes/valve/test/basic/*.test.js nodes/valve/test/edge/*.test.js nodes/valve/test/integration/*.test.js nodes/valveGroupControl/test/basic/*.test.js nodes/valveGroupControl/test/edge/*.test.js nodes/valveGroupControl/test/integration/*.test.js",
|
||||
"test:legacy": "node nodes/machineGroupControl/src/groupcontrol.test.js && node nodes/generalFunctions/src/nrmse/errorMetric.test.js",
|
||||
"test:all": "npm test && npm run test:node && npm run test:legacy",
|
||||
"test:e2e:reactor": "node scripts/e2e-reactor-roundtrip.js",
|
||||
"lint": "eslint nodes/",
|
||||
"lint:fix": "eslint nodes/ --fix",
|
||||
"ci": "npm run lint && npm run test:all",
|
||||
"test:e2e": "bash test/e2e/run-e2e.sh"
|
||||
},
|
||||
"author": "Rene De Ren, Pim Moerman, Janneke Tack, Sjoerd Fijnje, Dieke Gabriels, pieter van der wilt",
|
||||
"license": "SEE LICENSE",
|
||||
"dependencies": {
|
||||
"@flowfuse/node-red-dashboard": "^1.30.2",
|
||||
"@tensorflow/tfjs": "^4.22.0",
|
||||
"@tensorflow/tfjs-node": "^4.22.0",
|
||||
"generalFunctions": "file:nodes/generalFunctions",
|
||||
"mathjs": "^13.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^8.57.0",
|
||||
"eslint": "^8.57.0",
|
||||
"globals": "^15.0.0",
|
||||
"jest": "^29.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Add monitoring/debug nodes to the demo flow for process visibility.
|
||||
* Adds a function node per PS that logs volume, level, flow rate every 10 ticks.
|
||||
* Also adds a status debug node for the overall system.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const flowPath = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||
|
||||
// Remove existing monitoring nodes
|
||||
const monitorIds = flow.filter(n => n.id && n.id.startsWith('demo_mon_')).map(n => n.id);
|
||||
if (monitorIds.length > 0) {
|
||||
console.log('Removing existing monitoring nodes:', monitorIds);
|
||||
for (const id of monitorIds) {
|
||||
const idx = flow.findIndex(n => n.id === id);
|
||||
if (idx !== -1) flow.splice(idx, 1);
|
||||
}
|
||||
// Also remove from wires
|
||||
flow.forEach(n => {
|
||||
if (n.wires) {
|
||||
n.wires = n.wires.map(portWires =>
|
||||
Array.isArray(portWires) ? portWires.filter(w => !monitorIds.includes(w)) : portWires
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add monitoring function nodes for each PS
|
||||
const monitors = [
|
||||
{
|
||||
id: 'demo_mon_west',
|
||||
name: 'Monitor PS West',
|
||||
ps: 'demo_ps_west',
|
||||
x: 800, y: 50,
|
||||
},
|
||||
{
|
||||
id: 'demo_mon_north',
|
||||
name: 'Monitor PS North',
|
||||
ps: 'demo_ps_north',
|
||||
x: 800, y: 100,
|
||||
},
|
||||
{
|
||||
id: 'demo_mon_south',
|
||||
name: 'Monitor PS South',
|
||||
ps: 'demo_ps_south',
|
||||
x: 800, y: 150,
|
||||
},
|
||||
];
|
||||
|
||||
// Each PS sends process data on port 0. Wire monitoring nodes to PS port 0.
|
||||
monitors.forEach(mon => {
|
||||
// Function node that extracts key metrics and logs them periodically
|
||||
const fnNode = {
|
||||
id: mon.id,
|
||||
type: 'function',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: mon.name,
|
||||
func: `// Extract key metrics from PS process output
|
||||
const p = msg.payload || {};
|
||||
|
||||
// Keys have .default suffix in PS output format
|
||||
const vol = p["volume.predicted.atequipment.default"];
|
||||
const level = p["level.predicted.atequipment.default"];
|
||||
const netFlow = p["netFlowRate.predicted.atequipment.default"];
|
||||
const volPct = p["volumePercent.predicted.atequipment.default"];
|
||||
|
||||
// Only log when we have volume data
|
||||
if (vol !== null && vol !== undefined) {
|
||||
const ctx = context.get("tickCount") || 0;
|
||||
context.set("tickCount", ctx + 1);
|
||||
|
||||
// Log every 10 ticks
|
||||
if (ctx % 10 === 0) {
|
||||
const fmt = (v, dec) => typeof v === "number" ? v.toFixed(dec) : String(v);
|
||||
const parts = ["vol=" + fmt(vol, 1) + "m3"];
|
||||
if (level !== null && level !== undefined) parts.push("lvl=" + fmt(level, 3) + "m");
|
||||
if (volPct !== null && volPct !== undefined) parts.push("fill=" + fmt(volPct, 1) + "%");
|
||||
if (netFlow !== null && netFlow !== undefined) parts.push("net=" + fmt(netFlow, 1) + "m3/h");
|
||||
|
||||
node.warn(parts.join(" | "));
|
||||
}
|
||||
}
|
||||
|
||||
return msg;`,
|
||||
outputs: 1,
|
||||
timeout: '',
|
||||
noerr: 0,
|
||||
initialize: '',
|
||||
finalize: '',
|
||||
libs: [],
|
||||
x: mon.x,
|
||||
y: mon.y,
|
||||
wires: [[]],
|
||||
};
|
||||
|
||||
flow.push(fnNode);
|
||||
|
||||
// Wire PS port 0 to this monitor (append to existing wires)
|
||||
const psNode = flow.find(n => n.id === mon.ps);
|
||||
if (psNode && psNode.wires && psNode.wires[0]) {
|
||||
if (!psNode.wires[0].includes(mon.id)) {
|
||||
psNode.wires[0].push(mon.id);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Added ${mon.id}: ${mon.name} → wired to ${mon.ps} port 0`);
|
||||
});
|
||||
|
||||
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n');
|
||||
console.log(`\nDone. ${monitors.length} monitoring nodes added.`);
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Comprehensive runtime analysis of the WWTP demo flow.
|
||||
* Captures process debug output, pumping station state, measurements,
|
||||
* and analyzes filling/draining behavior over time.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const NR_URL = 'http://localhost:1880';
|
||||
|
||||
function fetchJSON(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, res => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(Buffer.concat(chunks))); }
|
||||
catch (e) { reject(new Error('Parse error from ' + url + ': ' + e.message)); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Inject a debug-capture subflow to intercept process messages
|
||||
async function injectDebugCapture() {
|
||||
const flows = await fetchJSON(NR_URL + '/flows');
|
||||
|
||||
// Find all nodes on WWTP tab
|
||||
const wwtp = flows.filter(n => n.z === 'demo_tab_wwtp');
|
||||
|
||||
console.log('=== WWTP Node Inventory ===');
|
||||
const byType = {};
|
||||
wwtp.forEach(n => {
|
||||
if (!byType[n.type]) byType[n.type] = [];
|
||||
byType[n.type].push(n);
|
||||
});
|
||||
|
||||
Object.entries(byType).sort().forEach(([type, nodes]) => {
|
||||
console.log(type + ' (' + nodes.length + '):');
|
||||
nodes.forEach(n => {
|
||||
const extra = [];
|
||||
if (n.simulator) extra.push('sim=ON');
|
||||
if (n.model) extra.push('model=' + n.model);
|
||||
if (n.basinVolume) extra.push('basin=' + n.basinVolume + 'm3');
|
||||
if (n.basinHeight) extra.push('h=' + n.basinHeight + 'm');
|
||||
if (n.positionVsParent) extra.push('pos=' + n.positionVsParent);
|
||||
if (n.control) extra.push('ctrl=' + JSON.stringify(n.control));
|
||||
console.log(' ' + n.id + ' "' + (n.name || '') + '" ' + (extra.length ? '[' + extra.join(', ') + ']' : ''));
|
||||
});
|
||||
});
|
||||
|
||||
// Analyze pumping station configurations
|
||||
console.log('\n=== Pumping Station Configs ===');
|
||||
const pss = wwtp.filter(n => n.type === 'pumpingStation');
|
||||
pss.forEach(ps => {
|
||||
console.log('\n' + ps.id + ' "' + ps.name + '"');
|
||||
console.log(' Basin: vol=' + ps.basinVolume + 'm3, h=' + ps.basinHeight + 'm');
|
||||
console.log(' Inlet: h=' + ps.heightInlet + 'm, Outlet: h=' + ps.heightOutlet + 'm');
|
||||
console.log(' Simulator: ' + ps.simulator);
|
||||
console.log(' Control mode: ' + (ps.controlMode || 'not set'));
|
||||
|
||||
// Check q_in inject wiring
|
||||
const qinInject = wwtp.find(n => n.id === 'demo_inj_' + ps.id.replace('demo_ps_', '') + '_flow');
|
||||
if (qinInject) {
|
||||
console.log(' q_in inject: repeat=' + qinInject.repeat + 's, wired to ' + JSON.stringify(qinInject.wires));
|
||||
}
|
||||
|
||||
// Check what's wired to this PS (port 2 = parent registration)
|
||||
const children = wwtp.filter(n => {
|
||||
if (!n.wires) return false;
|
||||
return n.wires.some(portWires => portWires && portWires.includes(ps.id));
|
||||
});
|
||||
console.log(' Children wired to it: ' + children.map(c => c.id + '(' + c.type + ')').join(', '));
|
||||
});
|
||||
|
||||
// Analyze inject timers
|
||||
console.log('\n=== Active Inject Timers ===');
|
||||
const injects = wwtp.filter(n => n.type === 'inject');
|
||||
injects.forEach(inj => {
|
||||
const targets = (inj.wires || []).flat();
|
||||
console.log(inj.id + ' "' + (inj.name || '') + '"');
|
||||
console.log(' topic=' + inj.topic + ' payload=' + inj.payload);
|
||||
console.log(' once=' + inj.once + ' repeat=' + (inj.repeat || 'none'));
|
||||
console.log(' → ' + targets.join(', '));
|
||||
});
|
||||
|
||||
// Analyze q_in function nodes
|
||||
console.log('\n=== q_in Flow Simulation Functions ===');
|
||||
const fnNodes = wwtp.filter(n => n.type === 'function' && n.name && n.name.includes('Flow'));
|
||||
fnNodes.forEach(fn => {
|
||||
console.log(fn.id + ' "' + fn.name + '"');
|
||||
console.log(' func: ' + (fn.func || '').substring(0, 200));
|
||||
const targets = (fn.wires || []).flat();
|
||||
console.log(' → ' + targets.join(', '));
|
||||
});
|
||||
|
||||
// Analyze measurement nodes
|
||||
console.log('\n=== Measurement Nodes ===');
|
||||
const meas = wwtp.filter(n => n.type === 'measurement');
|
||||
meas.forEach(m => {
|
||||
console.log(m.id + ' "' + (m.name || '') + '"');
|
||||
console.log(' type=' + m.assetType + ' sim=' + m.simulator + ' range=[' + m.o_min + ',' + m.o_max + '] unit=' + m.unit);
|
||||
console.log(' pos=' + (m.positionVsParent || 'none'));
|
||||
// Check port 2 wiring (parent registration)
|
||||
const port2 = m.wires && m.wires[2] ? m.wires[2] : [];
|
||||
console.log(' port2→ ' + (port2.length ? port2.join(', ') : 'none'));
|
||||
});
|
||||
|
||||
// Analyze rotating machines
|
||||
console.log('\n=== Rotating Machine Nodes ===');
|
||||
const machines = wwtp.filter(n => n.type === 'rotatingMachine');
|
||||
machines.forEach(m => {
|
||||
console.log(m.id + ' "' + (m.name || '') + '"');
|
||||
console.log(' model=' + m.model + ' mode=' + m.movementMode);
|
||||
console.log(' pos=' + m.positionVsParent + ' supplier=' + m.supplier);
|
||||
console.log(' speed=' + m.speed + ' startup=' + m.startup + ' shutdown=' + m.shutdown);
|
||||
const port2 = m.wires && m.wires[2] ? m.wires[2] : [];
|
||||
console.log(' port2→ ' + (port2.length ? port2.join(', ') : 'none'));
|
||||
});
|
||||
|
||||
// Check wiring integrity
|
||||
console.log('\n=== Wiring Analysis ===');
|
||||
pss.forEach(ps => {
|
||||
const psPort0 = ps.wires && ps.wires[0] ? ps.wires[0] : [];
|
||||
const psPort1 = ps.wires && ps.wires[1] ? ps.wires[1] : [];
|
||||
const psPort2 = ps.wires && ps.wires[2] ? ps.wires[2] : [];
|
||||
console.log(ps.id + ' wiring:');
|
||||
console.log(' port0 (process): ' + psPort0.join(', '));
|
||||
console.log(' port1 (influx): ' + psPort1.join(', '));
|
||||
console.log(' port2 (parent): ' + psPort2.join(', '));
|
||||
});
|
||||
}
|
||||
|
||||
injectDebugCapture().catch(err => {
|
||||
console.error('Analysis failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Capture live process data from Node-RED WebSocket debug sidebar.
|
||||
* Collects samples over a time window and analyzes trends.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const NR_URL = 'http://localhost:1880';
|
||||
const CAPTURE_SECONDS = 30;
|
||||
|
||||
// Alternative: poll the Node-RED comms endpoint
|
||||
// But let's use a simpler approach - inject a temporary catch-all debug and read context
|
||||
|
||||
async function fetchJSON(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, res => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(Buffer.concat(chunks))); }
|
||||
catch (e) { reject(new Error('Parse: ' + e.message)); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function postJSON(url, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify(data);
|
||||
const parsed = new URL(url);
|
||||
const req = http.request({
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
}, res => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString();
|
||||
try { resolve(JSON.parse(text)); } catch { resolve(text); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('=== Capturing Process Data (' + CAPTURE_SECONDS + 's) ===\n');
|
||||
|
||||
// Use Node-RED inject API to trigger debug output
|
||||
// Instead, let's read node context which stores the current state
|
||||
|
||||
// Get flows to find node IDs
|
||||
const flows = await fetchJSON(NR_URL + '/flows');
|
||||
const wwtp = flows.filter(n => n.z === 'demo_tab_wwtp');
|
||||
|
||||
// Pumping stations store state in node context
|
||||
const pss = wwtp.filter(n => n.type === 'pumpingStation');
|
||||
const pumps = wwtp.filter(n => n.type === 'rotatingMachine');
|
||||
|
||||
const samples = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log('Sampling every 3 seconds for ' + CAPTURE_SECONDS + 's...\n');
|
||||
|
||||
for (let i = 0; i < Math.ceil(CAPTURE_SECONDS / 3); i++) {
|
||||
const t = Date.now();
|
||||
const elapsed = ((t - startTime) / 1000).toFixed(1);
|
||||
|
||||
// Read PS context data via Node-RED context API
|
||||
const sample = { t: elapsed, stations: {} };
|
||||
|
||||
for (const ps of pss) {
|
||||
try {
|
||||
const ctx = await fetchJSON(NR_URL + '/context/node/' + ps.id + '?store=default');
|
||||
sample.stations[ps.id] = ctx;
|
||||
} catch (e) {
|
||||
sample.stations[ps.id] = { error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
for (const pump of pumps) {
|
||||
try {
|
||||
const ctx = await fetchJSON(NR_URL + '/context/node/' + pump.id + '?store=default');
|
||||
sample.stations[pump.id] = ctx;
|
||||
} catch (e) {
|
||||
sample.stations[pump.id] = { error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
samples.push(sample);
|
||||
|
||||
// Print summary for this sample
|
||||
console.log('--- Sample at t=' + elapsed + 's ---');
|
||||
for (const ps of pss) {
|
||||
const ctx = sample.stations[ps.id];
|
||||
if (ctx && ctx.data) {
|
||||
console.log(ps.name + ':');
|
||||
// Print all context keys
|
||||
Object.entries(ctx.data).forEach(([key, val]) => {
|
||||
if (typeof val === 'object') {
|
||||
console.log(' ' + key + ': ' + JSON.stringify(val).substring(0, 200));
|
||||
} else {
|
||||
console.log(' ' + key + ': ' + val);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(ps.name + ': ' + JSON.stringify(ctx).substring(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
for (const pump of pumps) {
|
||||
const ctx = sample.stations[pump.id];
|
||||
if (ctx && ctx.data && Object.keys(ctx.data).length > 0) {
|
||||
console.log(pump.name + ':');
|
||||
Object.entries(ctx.data).forEach(([key, val]) => {
|
||||
if (typeof val === 'object') {
|
||||
console.log(' ' + key + ': ' + JSON.stringify(val).substring(0, 200));
|
||||
} else {
|
||||
console.log(' ' + key + ': ' + val);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
if (i < Math.ceil(CAPTURE_SECONDS / 3) - 1) {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log('Collected ' + samples.length + ' samples over ' + CAPTURE_SECONDS + 's');
|
||||
})().catch(err => {
|
||||
console.error('Capture failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify asset selection fields are correct in deployed flow.
|
||||
* Checks that supplier/assetType/model/unit values match asset data IDs
|
||||
* so the editor dropdowns will pre-select correctly.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const NR_URL = 'http://localhost:1880';
|
||||
|
||||
async function fetchJSON(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, res => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(Buffer.concat(chunks))); }
|
||||
catch (e) { reject(new Error(`Parse error: ${e.message}`)); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const flows = await fetchJSON(`${NR_URL}/flows`);
|
||||
const errors = [];
|
||||
|
||||
console.log('=== Pump Asset Selection Checks ===');
|
||||
const pumps = flows.filter(n => n.type === 'rotatingMachine' && n.z === 'demo_tab_wwtp');
|
||||
pumps.forEach(p => {
|
||||
const checks = [
|
||||
{ field: 'supplier', expected: 'hidrostal', actual: p.supplier },
|
||||
{ field: 'assetType', expected: 'pump-centrifugal', actual: p.assetType },
|
||||
{ field: 'category', expected: 'machine', actual: p.category },
|
||||
];
|
||||
checks.forEach(c => {
|
||||
if (c.actual === c.expected) {
|
||||
console.log(` PASS: ${p.id} ${c.field} = "${c.actual}"`);
|
||||
} else {
|
||||
console.log(` FAIL: ${p.id} ${c.field} = "${c.actual}" (expected "${c.expected}")`);
|
||||
errors.push(`${p.id}.${c.field}`);
|
||||
}
|
||||
});
|
||||
// Model should be one of the known models
|
||||
const validModels = ['hidrostal-H05K-S03R', 'hidrostal-C5-D03R-SHN1'];
|
||||
if (validModels.includes(p.model)) {
|
||||
console.log(` PASS: ${p.id} model = "${p.model}"`);
|
||||
} else {
|
||||
console.log(` FAIL: ${p.id} model = "${p.model}" (expected one of ${validModels})`);
|
||||
errors.push(`${p.id}.model`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== Measurement Asset Selection Checks ===');
|
||||
const measurements = flows.filter(n => n.type === 'measurement' && n.z === 'demo_tab_wwtp');
|
||||
|
||||
// Valid supplier→type→model combinations from measurement.json
|
||||
const validSuppliers = {
|
||||
'Endress+Hauser': {
|
||||
types: ['flow', 'pressure', 'level'],
|
||||
models: { flow: ['Promag-W400', 'Promag-W300'], pressure: ['Cerabar-PMC51', 'Cerabar-PMC71'], level: ['Levelflex-FMP50'] }
|
||||
},
|
||||
'Hach': {
|
||||
types: ['dissolved-oxygen', 'ammonium', 'nitrate', 'tss'],
|
||||
models: { 'dissolved-oxygen': ['LDO2'], ammonium: ['Amtax-sc'], nitrate: ['Nitratax-sc'], tss: ['Solitax-sc'] }
|
||||
},
|
||||
'vega': {
|
||||
types: ['temperature', 'pressure', 'flow', 'level', 'oxygen'],
|
||||
models: {} // not checking Vega models for now
|
||||
}
|
||||
};
|
||||
|
||||
measurements.forEach(m => {
|
||||
const supplierData = validSuppliers[m.supplier];
|
||||
if (!supplierData) {
|
||||
console.log(` FAIL: ${m.id} supplier "${m.supplier}" not in asset data`);
|
||||
errors.push(`${m.id}.supplier`);
|
||||
return;
|
||||
}
|
||||
console.log(` PASS: ${m.id} supplier = "${m.supplier}"`);
|
||||
|
||||
if (!supplierData.types.includes(m.assetType)) {
|
||||
console.log(` FAIL: ${m.id} assetType "${m.assetType}" not in ${m.supplier} types`);
|
||||
errors.push(`${m.id}.assetType`);
|
||||
} else {
|
||||
console.log(` PASS: ${m.id} assetType = "${m.assetType}"`);
|
||||
}
|
||||
|
||||
const validModels = supplierData.models[m.assetType] || [];
|
||||
if (validModels.length > 0 && !validModels.includes(m.model)) {
|
||||
console.log(` FAIL: ${m.id} model "${m.model}" not in ${m.supplier}/${m.assetType} models`);
|
||||
errors.push(`${m.id}.model`);
|
||||
} else if (m.model) {
|
||||
console.log(` PASS: ${m.id} model = "${m.model}"`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== RESULT ===');
|
||||
if (errors.length === 0) {
|
||||
console.log('ALL ASSET SELECTION CHECKS PASSED');
|
||||
} else {
|
||||
console.log(`${errors.length} FAILURE(S):`, errors.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error('Check failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Check the deployed Node-RED flow for correctness after changes.
|
||||
*/
|
||||
const http = require('http');
|
||||
|
||||
function fetch(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, res => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => resolve(JSON.parse(Buffer.concat(chunks))));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
let errors = 0;
|
||||
|
||||
// 1. Check deployed flow structure
|
||||
console.log('=== Checking deployed flow structure ===');
|
||||
const flow = await fetch('http://localhost:1880/flows');
|
||||
console.log('Total deployed nodes:', flow.length);
|
||||
|
||||
// Check MGC exists
|
||||
const mgc = flow.find(n => n.id === 'demo_mgc_west');
|
||||
if (mgc) {
|
||||
console.log('PASS: MGC West exists, position:', mgc.positionVsParent);
|
||||
} else {
|
||||
console.log('FAIL: MGC West missing from deployed flow');
|
||||
errors++;
|
||||
}
|
||||
|
||||
// Check reactor speedUpFactor
|
||||
const reactor = flow.find(n => n.id === 'demo_reactor');
|
||||
if (reactor && reactor.speedUpFactor === 1) {
|
||||
console.log('PASS: Reactor speedUpFactor = 1');
|
||||
} else {
|
||||
console.log('FAIL: Reactor speedUpFactor =', reactor?.speedUpFactor);
|
||||
errors++;
|
||||
}
|
||||
|
||||
// Check sim mode on measurements
|
||||
const simMeasIds = [
|
||||
'demo_meas_flow', 'demo_meas_do', 'demo_meas_nh4',
|
||||
'demo_meas_ft_n1', 'demo_meas_eff_flow', 'demo_meas_eff_do',
|
||||
'demo_meas_eff_nh4', 'demo_meas_eff_no3', 'demo_meas_eff_tss'
|
||||
];
|
||||
let simOk = 0;
|
||||
simMeasIds.forEach(id => {
|
||||
const n = flow.find(x => x.id === id);
|
||||
if (n && n.simulator === true) simOk++;
|
||||
else { console.log('FAIL: simulator not true on', id); errors++; }
|
||||
});
|
||||
console.log(`PASS: ${simOk}/9 measurement nodes have simulator=true`);
|
||||
|
||||
// Check pressure nodes exist
|
||||
const ptIds = ['demo_meas_pt_w_up','demo_meas_pt_w_down','demo_meas_pt_n_up','demo_meas_pt_n_down','demo_meas_pt_s_up','demo_meas_pt_s_down'];
|
||||
let ptOk = 0;
|
||||
ptIds.forEach(id => {
|
||||
const n = flow.find(x => x.id === id);
|
||||
if (n && n.type === 'measurement') ptOk++;
|
||||
else { console.log('FAIL: pressure node missing:', id); errors++; }
|
||||
});
|
||||
console.log(`PASS: ${ptOk}/6 pressure measurement nodes present`);
|
||||
|
||||
// Check removed nodes are gone
|
||||
const removedIds = [
|
||||
'demo_inj_meas_flow', 'demo_fn_sim_flow', 'demo_inj_meas_do', 'demo_fn_sim_do',
|
||||
'demo_inj_meas_nh4', 'demo_fn_sim_nh4', 'demo_inj_ft_n1', 'demo_fn_sim_ft_n1',
|
||||
'demo_inj_eff_flow', 'demo_fn_sim_eff_flow', 'demo_inj_eff_do', 'demo_fn_sim_eff_do',
|
||||
'demo_inj_eff_nh4', 'demo_fn_sim_eff_nh4', 'demo_inj_eff_no3', 'demo_fn_sim_eff_no3',
|
||||
'demo_inj_eff_tss', 'demo_fn_sim_eff_tss',
|
||||
'demo_inj_w1_startup', 'demo_inj_w1_setpoint', 'demo_inj_w2_startup', 'demo_inj_w2_setpoint',
|
||||
'demo_inj_n1_startup', 'demo_inj_s1_startup'
|
||||
];
|
||||
const stillPresent = removedIds.filter(id => flow.find(x => x.id === id));
|
||||
if (stillPresent.length === 0) {
|
||||
console.log('PASS: All 24 removed nodes are gone');
|
||||
} else {
|
||||
console.log('FAIL: These removed nodes are still present:', stillPresent);
|
||||
errors++;
|
||||
}
|
||||
|
||||
// Check kept nodes still exist
|
||||
const keptIds = [
|
||||
'demo_inj_west_flow', 'demo_fn_west_flow_sim',
|
||||
'demo_inj_north_flow', 'demo_fn_north_flow_sim',
|
||||
'demo_inj_south_flow', 'demo_fn_south_flow_sim',
|
||||
'demo_inj_w1_mode', 'demo_inj_w2_mode', 'demo_inj_n1_mode', 'demo_inj_s1_mode',
|
||||
'demo_inj_west_mode', 'demo_inj_north_mode', 'demo_inj_south_mode'
|
||||
];
|
||||
const keptMissing = keptIds.filter(id => !flow.find(x => x.id === id));
|
||||
if (keptMissing.length === 0) {
|
||||
console.log('PASS: All kept nodes still present');
|
||||
} else {
|
||||
console.log('FAIL: These nodes should exist but are missing:', keptMissing);
|
||||
errors++;
|
||||
}
|
||||
|
||||
// Check wiring: W1/W2 register to MGC, MGC registers to PS West
|
||||
const w1 = flow.find(n => n.id === 'demo_pump_w1');
|
||||
const w2 = flow.find(n => n.id === 'demo_pump_w2');
|
||||
if (w1 && w1.wires[2] && w1.wires[2].includes('demo_mgc_west')) {
|
||||
console.log('PASS: W1 port 2 wired to MGC');
|
||||
} else {
|
||||
console.log('FAIL: W1 port 2 not wired to MGC, got:', w1?.wires?.[2]);
|
||||
errors++;
|
||||
}
|
||||
if (w2 && w2.wires[2] && w2.wires[2].includes('demo_mgc_west')) {
|
||||
console.log('PASS: W2 port 2 wired to MGC');
|
||||
} else {
|
||||
console.log('FAIL: W2 port 2 not wired to MGC, got:', w2?.wires?.[2]);
|
||||
errors++;
|
||||
}
|
||||
if (mgc && mgc.wires[2] && mgc.wires[2].includes('demo_ps_west')) {
|
||||
console.log('PASS: MGC port 2 wired to PS West');
|
||||
} else {
|
||||
console.log('FAIL: MGC port 2 not wired to PS West');
|
||||
errors++;
|
||||
}
|
||||
|
||||
// Check PS outputs wire to level-to-pressure functions
|
||||
const psWest = flow.find(n => n.id === 'demo_ps_west');
|
||||
if (psWest && psWest.wires[0] && psWest.wires[0].includes('demo_fn_level_to_pressure_w')) {
|
||||
console.log('PASS: PS West port 0 wired to level-to-pressure function');
|
||||
} else {
|
||||
console.log('FAIL: PS West port 0 missing level-to-pressure wire');
|
||||
errors++;
|
||||
}
|
||||
|
||||
console.log('\n=== RESULT ===');
|
||||
if (errors === 0) {
|
||||
console.log('ALL CHECKS PASSED');
|
||||
} else {
|
||||
console.log(`${errors} FAILURE(S)`);
|
||||
process.exit(1);
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error('Failed to connect to Node-RED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Runtime smoke test: connect to Node-RED WebSocket debug and verify
|
||||
* that key nodes are producing output within a timeout period.
|
||||
*/
|
||||
const http = require('http');
|
||||
|
||||
const TIMEOUT_MS = 15000;
|
||||
const NR_URL = 'http://localhost:1880';
|
||||
|
||||
async function fetchJSON(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, res => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(Buffer.concat(chunks))); }
|
||||
catch (e) { reject(new Error(`Parse error from ${url}: ${e.message}`)); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
|
||||
const errors = [];
|
||||
|
||||
// REST-based checks: verify Node-RED is healthy
|
||||
console.log('=== Runtime Health Checks ===');
|
||||
|
||||
try {
|
||||
const settings = await fetchJSON(`${NR_URL}/settings`);
|
||||
console.log('PASS: Node-RED is responding, version:', settings.editorTheme ? 'custom' : 'default');
|
||||
} catch (e) {
|
||||
console.log('FAIL: Node-RED not responding:', e.message);
|
||||
errors.push('Node-RED not responding');
|
||||
}
|
||||
|
||||
// Check that flows are loaded
|
||||
try {
|
||||
const flows = await fetchJSON(`${NR_URL}/flows`);
|
||||
const wwtp = flows.filter(n => n.z === 'demo_tab_wwtp');
|
||||
if (wwtp.length > 50) {
|
||||
console.log(`PASS: ${wwtp.length} nodes loaded on WWTP tab`);
|
||||
} else {
|
||||
console.log(`FAIL: Only ${wwtp.length} nodes on WWTP tab (expected >50)`);
|
||||
errors.push('Too few nodes');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('FAIL: Cannot read flows:', e.message);
|
||||
errors.push('Cannot read flows');
|
||||
}
|
||||
|
||||
// Check inject nodes are running (they have repeat timers)
|
||||
try {
|
||||
const flows = await fetchJSON(`${NR_URL}/flows`);
|
||||
const injects = flows.filter(n => n.type === 'inject' && n.repeat && n.z === 'demo_tab_wwtp');
|
||||
console.log(`PASS: ${injects.length} inject nodes with timers on WWTP tab`);
|
||||
|
||||
// Verify the q_in inject nodes are still there
|
||||
const qinInjects = injects.filter(n => n.id.includes('_flow') || n.id.includes('_tick'));
|
||||
console.log(`PASS: ${qinInjects.length} q_in/tick inject timers active`);
|
||||
} catch (e) {
|
||||
console.log('FAIL: Cannot check inject nodes:', e.message);
|
||||
errors.push('Cannot check inject nodes');
|
||||
}
|
||||
|
||||
console.log('\n=== RESULT ===');
|
||||
if (errors.length === 0) {
|
||||
console.log('ALL RUNTIME CHECKS PASSED');
|
||||
} else {
|
||||
console.log(`${errors.length} FAILURE(S):`, errors.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error('Runtime check failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Comprehensive WWTP Demo Test Suite
|
||||
*
|
||||
* Tests:
|
||||
* 1. Deploy succeeds
|
||||
* 2. All nodes healthy (no errors)
|
||||
* 3. PS volumes above safety threshold after calibration
|
||||
* 4. q_in flowing to all PSs (volume rising)
|
||||
* 5. Measurement simulators producing values
|
||||
* 6. MGC pressure handling working
|
||||
* 7. No persistent safety triggers
|
||||
* 8. Level-based control (PS West) stays idle at low level
|
||||
* 9. Flow-based control (PS North) responds to flow
|
||||
* 10. PS output format correct
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const NR_URL = 'http://localhost:1880';
|
||||
const FLOW_FILE = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
let warnings = 0;
|
||||
|
||||
function test(name, condition, detail) {
|
||||
if (condition) {
|
||||
console.log(` ✅ PASS: ${name}${detail ? ' — ' + detail : ''}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(` ❌ FAIL: ${name}${detail ? ' — ' + detail : ''}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function warn(name, detail) {
|
||||
console.log(` ⚠️ WARN: ${name}${detail ? ' — ' + detail : ''}`);
|
||||
warnings++;
|
||||
}
|
||||
|
||||
function httpReq(method, urlPath, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(NR_URL + urlPath);
|
||||
const opts = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', 'Node-RED-Deployment-Type': 'full' },
|
||||
};
|
||||
if (body) opts.headers['Content-Length'] = Buffer.byteLength(JSON.stringify(body));
|
||||
const req = http.request(opts, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function getLogs(since) {
|
||||
try {
|
||||
return execSync(`docker logs evolv-nodered --since ${since} 2>&1`, {
|
||||
encoding: 'utf8', timeout: 5000,
|
||||
});
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function fetchJSON(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(Buffer.concat(chunks))); }
|
||||
catch (e) { reject(e); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' WWTP Demo Flow — Comprehensive Test');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
// ==========================================================
|
||||
console.log('1. DEPLOYMENT');
|
||||
console.log('─────────────');
|
||||
|
||||
const flow = JSON.parse(fs.readFileSync(FLOW_FILE, 'utf8'));
|
||||
test('Flow file loads', flow.length > 0, `${flow.length} nodes`);
|
||||
|
||||
const deployTime = new Date().toISOString();
|
||||
const res = await httpReq('POST', '/flows', flow);
|
||||
test('Deploy succeeds', res.status === 204 || res.status === 200, `HTTP ${res.status}`);
|
||||
|
||||
// Wait for init + calibration
|
||||
console.log(' Waiting 5s for initialization...');
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
|
||||
// Check for errors in logs
|
||||
const initLogs = getLogs(deployTime);
|
||||
const initErrors = initLogs.split('\n').filter((l) => l.includes('[ERROR]') || l.includes('Error'));
|
||||
test('No initialization errors', initErrors.length === 0,
|
||||
initErrors.length > 0 ? initErrors.slice(0, 3).join('; ') : 'clean');
|
||||
|
||||
// ==========================================================
|
||||
console.log('\n2. NODE INVENTORY');
|
||||
console.log('─────────────────');
|
||||
|
||||
const flows = await fetchJSON(NR_URL + '/flows');
|
||||
const processTabs = ['demo_tab_wwtp', 'demo_tab_ps_west', 'demo_tab_ps_north', 'demo_tab_ps_south', 'demo_tab_treatment'];
|
||||
const wwtp = flows.filter((n) => processTabs.includes(n.z));
|
||||
|
||||
const byType = {};
|
||||
wwtp.forEach((n) => {
|
||||
if (!n.type || n.type === 'tab' || n.type === 'comment') return;
|
||||
byType[n.type] = (byType[n.type] || 0) + 1;
|
||||
});
|
||||
|
||||
test('Has pumping stations', (byType['pumpingStation'] || 0) === 3, `${byType['pumpingStation'] || 0} PS nodes`);
|
||||
test('Has rotating machines', (byType['rotatingMachine'] || 0) === 5, `${byType['rotatingMachine'] || 0} pumps`);
|
||||
test('Has measurements', (byType['measurement'] || 0) >= 15, `${byType['measurement'] || 0} measurement nodes`);
|
||||
test('Has reactor', (byType['reactor'] || 0) === 1, `${byType['reactor'] || 0} reactor`);
|
||||
test('Has machineGroupControl', (byType['machineGroupControl'] || 0) >= 1, `${byType['machineGroupControl'] || 0} MGC`);
|
||||
test('Has inject nodes', (byType['inject'] || 0) >= 10, `${byType['inject'] || 0} injects`);
|
||||
|
||||
console.log(` Node types: ${JSON.stringify(byType)}`);
|
||||
|
||||
// ==========================================================
|
||||
console.log('\n3. PS CONFIGURATION');
|
||||
console.log('───────────────────');
|
||||
|
||||
const pss = flows.filter((n) => n.type === 'pumpingStation');
|
||||
pss.forEach((ps) => {
|
||||
const vol = Number(ps.basinVolume);
|
||||
const h = Number(ps.basinHeight);
|
||||
const hOut = Number(ps.heightOutlet);
|
||||
const sa = vol / h;
|
||||
const minVol = hOut * sa;
|
||||
test(`${ps.name} basin config valid`, vol > 0 && h > 0 && hOut >= 0, `vol=${vol} h=${h} hOut=${hOut}`);
|
||||
test(`${ps.name} has safety enabled`, ps.enableDryRunProtection === true || ps.enableDryRunProtection === 'true');
|
||||
});
|
||||
|
||||
// Check calibration nodes exist
|
||||
const calibNodes = flows.filter((n) => n.id && n.id.startsWith('demo_inj_calib_'));
|
||||
test('Calibration inject nodes exist', calibNodes.length === 3, `${calibNodes.length} calibration nodes`);
|
||||
|
||||
// ==========================================================
|
||||
console.log('\n4. MEASUREMENT SIMULATORS');
|
||||
console.log('─────────────────────────');
|
||||
|
||||
const measurements = flows.filter((n) => n.type === 'measurement' && processTabs.includes(n.z));
|
||||
const simEnabled = measurements.filter((n) => n.simulator === true || n.simulator === 'true');
|
||||
test('Measurement simulators enabled', simEnabled.length >= 10, `${simEnabled.length} of ${measurements.length} have sim=true`);
|
||||
|
||||
// List measurement nodes
|
||||
measurements.forEach((m) => {
|
||||
const sim = m.simulator === true || m.simulator === 'true';
|
||||
const range = `[${m.o_min}-${m.o_max}] ${m.unit}`;
|
||||
if (!sim && !m.id.includes('level') && !m.id.includes('pt_')) {
|
||||
warn(`${m.name || m.id} sim=${sim}`, `range ${range}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================================
|
||||
console.log('\n5. PUMP CONFIGURATION');
|
||||
console.log('─────────────────────');
|
||||
|
||||
const pumps = flows.filter((n) => n.type === 'rotatingMachine' && processTabs.includes(n.z));
|
||||
pumps.forEach((p) => {
|
||||
test(`${p.name} has model`, !!p.model, p.model);
|
||||
test(`${p.name} supplier lowercase`, p.supplier === 'hidrostal', `supplier="${p.supplier}"`);
|
||||
});
|
||||
|
||||
// ==========================================================
|
||||
console.log('\n6. PRESSURE MEASUREMENTS');
|
||||
console.log('────────────────────────');
|
||||
|
||||
const pts = flows.filter((n) => n.type === 'measurement' && n.id && n.id.includes('_pt_'));
|
||||
test('6 pressure transmitters', pts.length === 6, `found ${pts.length}`);
|
||||
|
||||
pts.forEach((pt) => {
|
||||
const range = `${pt.o_min}-${pt.o_max} ${pt.unit}`;
|
||||
const sim = pt.simulator === true || pt.simulator === 'true';
|
||||
const pos = pt.positionVsParent;
|
||||
test(`${pt.name} valid`, pt.assetType === 'pressure', `pos=${pos} sim=${sim} range=${range}`);
|
||||
|
||||
// Check reasonable pressure ranges (not 0-5000)
|
||||
if (pos === 'downstream' || pos === 'Downstream') {
|
||||
test(`${pt.name} realistic range`, Number(pt.o_max) <= 2000, `o_max=${pt.o_max} (should be <=2000)`);
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================================
|
||||
console.log('\n7. RUNTIME BEHAVIOR (30s observation)');
|
||||
console.log('─────────────────────────────────────');
|
||||
|
||||
const obsStart = new Date().toISOString();
|
||||
|
||||
// Wait 30 seconds and observe
|
||||
console.log(' Observing for 30 seconds...');
|
||||
await new Promise((r) => setTimeout(r, 30000));
|
||||
|
||||
const obsLogs = getLogs(obsStart);
|
||||
const obsLines = obsLogs.split('\n');
|
||||
|
||||
// Count message types
|
||||
const safetyLines = obsLines.filter((l) => l.includes('Safe guard'));
|
||||
const errorLines = obsLines.filter((l) => l.includes('[ERROR]'));
|
||||
const monitorLines = obsLines.filter((l) => l.includes('[function:Monitor'));
|
||||
|
||||
test('No safety triggers in 30s', safetyLines.length === 0, `${safetyLines.length} triggers`);
|
||||
test('No errors in 30s', errorLines.length === 0,
|
||||
errorLines.length > 0 ? errorLines[0].substring(0, 100) : 'clean');
|
||||
test('Monitor nodes producing data', monitorLines.length > 0, `${monitorLines.length} monitor lines`);
|
||||
|
||||
// Parse monitoring data
|
||||
if (monitorLines.length > 0) {
|
||||
console.log('\n Monitor data:');
|
||||
monitorLines.forEach((l) => {
|
||||
const clean = l.replace(/^\[WARN\] -> /, ' ');
|
||||
console.log(' ' + clean.trim().substring(0, 150));
|
||||
});
|
||||
|
||||
// Check volume per PS
|
||||
const psVolumes = {};
|
||||
monitorLines.forEach((l) => {
|
||||
const psMatch = l.match(/Monitor (PS \w+)/);
|
||||
const volMatch = l.match(/vol=([\d.]+)m3/);
|
||||
if (psMatch && volMatch) {
|
||||
const ps = psMatch[1];
|
||||
if (!psVolumes[ps]) psVolumes[ps] = [];
|
||||
psVolumes[ps].push(parseFloat(volMatch[1]));
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(psVolumes).forEach(([ps, vols]) => {
|
||||
const first = vols[0];
|
||||
const last = vols[vols.length - 1];
|
||||
test(`${ps} volume above 0`, first > 0, `vol=${first.toFixed(1)} m3`);
|
||||
test(`${ps} volume reasonable`, first < 1000, `vol=${first.toFixed(1)} m3`);
|
||||
if (vols.length >= 2) {
|
||||
const trend = last - first;
|
||||
test(`${ps} volume stable/rising`, trend >= -0.5, `${first.toFixed(1)} → ${last.toFixed(1)} m3 (${trend >= 0 ? '+' : ''}${trend.toFixed(2)})`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
warn('No monitor data', 'monitoring function nodes may not have fired yet');
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
console.log('\n8. WIRING INTEGRITY');
|
||||
console.log('───────────────────');
|
||||
|
||||
// Check all PS have q_in inject
|
||||
pss.forEach((ps) => {
|
||||
const qinFn = flows.find((n) => n.wires && n.wires.flat && n.wires.flat().includes(ps.id) && n.type === 'function');
|
||||
test(`${ps.name} has q_in source`, !!qinFn, qinFn ? qinFn.name : 'none');
|
||||
});
|
||||
|
||||
// Check all pumps have pressure measurements (RAS pump has flow sensor instead)
|
||||
pumps.forEach((p) => {
|
||||
const childSensors = flows.filter((n) => n.type === 'measurement' && n.wires && n.wires[2] && n.wires[2].includes(p.id));
|
||||
const isRAS = p.id === 'demo_pump_ras';
|
||||
const minSensors = isRAS ? 1 : 2;
|
||||
test(`${p.name} has ${isRAS ? 'sensors' : 'pressure PTs'}`, childSensors.length >= minSensors,
|
||||
`${childSensors.length} ${isRAS ? 'sensors' : 'PTs'} (${childSensors.map((pt) => pt.positionVsParent).join(', ')})`);
|
||||
});
|
||||
|
||||
// ==========================================================
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(` Results: ${passed} passed, ${failed} failed, ${warnings} warnings`);
|
||||
console.log('═══════════════════════════════════════');
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\n ❌ SOME TESTS FAILED');
|
||||
process.exit(1);
|
||||
} else if (warnings > 0) {
|
||||
console.log('\n ⚠️ ALL TESTS PASSED (with warnings)');
|
||||
} else {
|
||||
console.log('\n ✅ ALL TESTS PASSED');
|
||||
}
|
||||
})().catch((err) => {
|
||||
console.error('Test suite failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Deploy the demo flow fresh and trace the first 60 seconds of behavior.
|
||||
* Captures: container logs, PS volume evolution, flow events.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const NR_URL = 'http://localhost:1880';
|
||||
const FLOW_FILE = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const TRACE_SECONDS = 45;
|
||||
|
||||
function httpReq(method, urlPath, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(NR_URL + urlPath);
|
||||
const opts = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname,
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Node-RED-Deployment-Type': 'full',
|
||||
},
|
||||
};
|
||||
if (body) {
|
||||
const buf = Buffer.from(JSON.stringify(body));
|
||||
opts.headers['Content-Length'] = buf.length;
|
||||
}
|
||||
const req = http.request(opts, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString();
|
||||
resolve({ status: res.statusCode, body: text });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function getLogs(since) {
|
||||
try {
|
||||
// Get ALL logs since our timestamp
|
||||
const cmd = `docker logs evolv-nodered --since ${since} 2>&1`;
|
||||
return execSync(cmd, { encoding: 'utf8', timeout: 5000 });
|
||||
} catch (e) {
|
||||
return 'Log error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('=== Deploy & Trace ===');
|
||||
console.log('Loading flow from', FLOW_FILE);
|
||||
|
||||
const flow = JSON.parse(fs.readFileSync(FLOW_FILE, 'utf8'));
|
||||
console.log(`Flow has ${flow.length} nodes`);
|
||||
|
||||
// Deploy
|
||||
const deployTime = new Date().toISOString();
|
||||
console.log(`\nDeploying at ${deployTime}...`);
|
||||
const res = await httpReq('POST', '/flows', flow);
|
||||
console.log(`Deploy response: ${res.status}`);
|
||||
|
||||
if (res.status !== 204 && res.status !== 200) {
|
||||
console.error('Deploy failed:', res.body);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Wait 3 seconds for initial setup
|
||||
console.log('Waiting 3s for init...\n');
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
|
||||
// Trace loop
|
||||
const traceStart = Date.now();
|
||||
const volumeHistory = [];
|
||||
let lastLogPos = 0;
|
||||
|
||||
for (let i = 0; i < Math.ceil(TRACE_SECONDS / 3); i++) {
|
||||
const elapsed = ((Date.now() - traceStart) / 1000).toFixed(1);
|
||||
|
||||
// Get new logs since deploy
|
||||
const logs = getLogs(deployTime);
|
||||
const newLines = logs.split('\n').slice(lastLogPos);
|
||||
lastLogPos = logs.split('\n').length;
|
||||
|
||||
// Parse interesting log lines
|
||||
const safeGuards = [];
|
||||
const pressureChanges = [];
|
||||
const modeChanges = [];
|
||||
const stateChanges = [];
|
||||
const other = [];
|
||||
|
||||
newLines.forEach((line) => {
|
||||
if (!line.trim()) return;
|
||||
|
||||
const volMatch = line.match(/vol=([-\d.]+) m3.*remainingTime=([\w.]+)/);
|
||||
if (volMatch) {
|
||||
safeGuards.push({ vol: parseFloat(volMatch[1]), remaining: volMatch[2] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.includes('Pressure change detected')) {
|
||||
pressureChanges.push(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.includes('Mode changed') || line.includes('setMode') || line.includes('Control mode')) {
|
||||
modeChanges.push(line.trim().substring(0, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.includes('machine state') || line.includes('State:') || line.includes('startup') || line.includes('shutdown')) {
|
||||
stateChanges.push(line.trim().substring(0, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.includes('q_in') || line.includes('netflow') || line.includes('Volume') ||
|
||||
line.includes('Height') || line.includes('Level') || line.includes('Controllevel')) {
|
||||
other.push(line.trim().substring(0, 200));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`--- t=${elapsed}s ---`);
|
||||
|
||||
if (safeGuards.length > 0) {
|
||||
const latest = safeGuards[safeGuards.length - 1];
|
||||
const first = safeGuards[0];
|
||||
console.log(` SAFETY: ${safeGuards.length} triggers, vol: ${first.vol} → ${latest.vol} m3, remaining: ${latest.remaining}s`);
|
||||
volumeHistory.push({ t: parseFloat(elapsed), vol: latest.vol });
|
||||
} else {
|
||||
console.log(' SAFETY: none (good)');
|
||||
}
|
||||
|
||||
if (pressureChanges.length > 0) {
|
||||
console.log(` PRESSURE: ${pressureChanges.length} changes`);
|
||||
}
|
||||
|
||||
if (modeChanges.length > 0) {
|
||||
modeChanges.forEach((m) => console.log(` MODE: ${m}`));
|
||||
}
|
||||
|
||||
if (stateChanges.length > 0) {
|
||||
stateChanges.slice(-5).forEach((s) => console.log(` STATE: ${s}`));
|
||||
}
|
||||
|
||||
if (other.length > 0) {
|
||||
other.slice(-5).forEach((o) => console.log(` INFO: ${o}`));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
|
||||
// Final analysis
|
||||
console.log('\n=== Volume Trajectory ===');
|
||||
volumeHistory.forEach((v) => {
|
||||
const bar = '#'.repeat(Math.max(0, Math.round(v.vol / 2)));
|
||||
console.log(` t=${String(v.t).padStart(5)}s: ${String(v.vol.toFixed(2)).padStart(8)} m3 ${bar}`);
|
||||
});
|
||||
|
||||
if (volumeHistory.length >= 2) {
|
||||
const first = volumeHistory[0];
|
||||
const last = volumeHistory[volumeHistory.length - 1];
|
||||
const dt = last.t - first.t;
|
||||
const dv = last.vol - first.vol;
|
||||
const rate = dt > 0 ? (dv / dt * 3600).toFixed(1) : 'N/A';
|
||||
console.log(`\n Rate: ${rate} m3/h (${dv > 0 ? 'FILLING' : 'DRAINING'})`);
|
||||
}
|
||||
|
||||
// Get ALL logs for comprehensive analysis
|
||||
console.log('\n=== Full Log Analysis ===');
|
||||
const allLogs = getLogs(deployTime);
|
||||
const allLines = allLogs.split('\n');
|
||||
|
||||
// Count different message types
|
||||
const counts = { safety: 0, pressure: 0, mode: 0, state: 0, error: 0, warn: 0, flow: 0 };
|
||||
allLines.forEach((l) => {
|
||||
if (l.includes('Safe guard')) counts.safety++;
|
||||
if (l.includes('Pressure change')) counts.pressure++;
|
||||
if (l.includes('Mode') || l.includes('mode')) counts.mode++;
|
||||
if (l.includes('startup') || l.includes('shutdown') || l.includes('machine state')) counts.state++;
|
||||
if (l.includes('[ERROR]') || l.includes('Error')) counts.error++;
|
||||
if (l.includes('[WARN]')) counts.warn++;
|
||||
if (l.includes('netflow') || l.includes('q_in') || l.includes('flow')) counts.flow++;
|
||||
});
|
||||
|
||||
console.log('Message counts:', JSON.stringify(counts, null, 2));
|
||||
|
||||
// Print errors
|
||||
const errors = allLines.filter((l) => l.includes('[ERROR]') || l.includes('Error'));
|
||||
if (errors.length > 0) {
|
||||
console.log('\nErrors:');
|
||||
errors.slice(0, 20).forEach((e) => console.log(' ' + e.trim().substring(0, 200)));
|
||||
}
|
||||
|
||||
// Print first few non-pressure, non-safety lines
|
||||
console.log('\nKey events (first 30):');
|
||||
let keyCount = 0;
|
||||
allLines.forEach((l) => {
|
||||
if (keyCount >= 30) return;
|
||||
if (l.includes('Pressure change detected')) return;
|
||||
if (l.includes('Safe guard triggered')) return;
|
||||
if (!l.trim()) return;
|
||||
console.log(' ' + l.trim().substring(0, 200));
|
||||
keyCount++;
|
||||
});
|
||||
})().catch((err) => {
|
||||
console.error('Failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
269
scripts/e2e-reactor-roundtrip.js
Normal file
269
scripts/e2e-reactor-roundtrip.js
Normal file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* E2E reactor round-trip test:
|
||||
* Node-RED -> InfluxDB -> Grafana proxy query
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const NR_URL = process.env.NR_URL || 'http://localhost:1880';
|
||||
const INFLUX_URL = process.env.INFLUX_URL || 'http://localhost:8086';
|
||||
const GRAFANA_URL = process.env.GRAFANA_URL || 'http://localhost:3000';
|
||||
const GRAFANA_USER = process.env.GRAFANA_USER || 'admin';
|
||||
const GRAFANA_PASSWORD = process.env.GRAFANA_PASSWORD || 'evolv';
|
||||
const INFLUX_ORG = process.env.INFLUX_ORG || 'evolv';
|
||||
const INFLUX_BUCKET = process.env.INFLUX_BUCKET || 'telemetry';
|
||||
const INFLUX_TOKEN = process.env.INFLUX_TOKEN || 'evolv-dev-token';
|
||||
const GRAFANA_DS_UID = process.env.GRAFANA_DS_UID || 'cdzg44tv250jkd';
|
||||
const FLOW_FILE = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const REQUIRE_GRAFANA_DASHBOARDS = process.env.REQUIRE_GRAFANA_DASHBOARDS === '1';
|
||||
const REACTOR_MEASUREMENTS = [
|
||||
'reactor_demo_reactor_z1',
|
||||
'reactor_demo_reactor_z2',
|
||||
'reactor_demo_reactor_z3',
|
||||
'reactor_demo_reactor_z4',
|
||||
];
|
||||
const REACTOR_MEASUREMENT = REACTOR_MEASUREMENTS[3];
|
||||
const QUERY_TIMEOUT_MS = 90000;
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const REQUIRED_DASHBOARD_TITLES = ['Bioreactor Z1', 'Bioreactor Z2', 'Bioreactor Z3', 'Bioreactor Z4', 'Settler S1'];
|
||||
|
||||
async function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
return { response, body, text };
|
||||
}
|
||||
|
||||
async function assertReachable() {
|
||||
const checks = [
|
||||
[`${NR_URL}/settings`, 'Node-RED'],
|
||||
[`${INFLUX_URL}/health`, 'InfluxDB'],
|
||||
[`${GRAFANA_URL}/api/health`, 'Grafana'],
|
||||
];
|
||||
|
||||
for (const [url, label] of checks) {
|
||||
const { response, text } = await fetchJson(url, {
|
||||
headers: label === 'Grafana'
|
||||
? { Authorization: `Basic ${Buffer.from(`${GRAFANA_USER}:${GRAFANA_PASSWORD}`).toString('base64')}` }
|
||||
: undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${label} not reachable at ${url} (${response.status}): ${text}`);
|
||||
}
|
||||
console.log(`PASS: ${label} reachable`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deployDemoFlow() {
|
||||
const flow = JSON.parse(fs.readFileSync(FLOW_FILE, 'utf8'));
|
||||
const { response, text } = await fetchJson(`${NR_URL}/flows`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Node-RED-Deployment-Type': 'full',
|
||||
},
|
||||
body: JSON.stringify(flow),
|
||||
});
|
||||
|
||||
if (!(response.status === 200 || response.status === 204)) {
|
||||
throw new Error(`Flow deploy failed (${response.status}): ${text}`);
|
||||
}
|
||||
console.log(`PASS: Demo flow deployed (${response.status})`);
|
||||
}
|
||||
|
||||
async function queryInfluxCsv(query) {
|
||||
const response = await fetch(`${INFLUX_URL}/api/v2/query?org=${encodeURIComponent(INFLUX_ORG)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Token ${INFLUX_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/csv',
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Influx query failed (${response.status}): ${text}`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function countCsvDataRows(csvText) {
|
||||
return csvText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#') && line.includes(','))
|
||||
.length;
|
||||
}
|
||||
|
||||
async function waitForReactorTelemetry() {
|
||||
const deadline = Date.now() + QUERY_TIMEOUT_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const counts = {};
|
||||
for (const measurement of REACTOR_MEASUREMENTS) {
|
||||
const query = `
|
||||
from(bucket: "${INFLUX_BUCKET}")
|
||||
|> range(start: -15m)
|
||||
|> filter(fn: (r) => r._measurement == "${measurement}")
|
||||
|> limit(n: 20)
|
||||
`.trim();
|
||||
counts[measurement] = countCsvDataRows(await queryInfluxCsv(query));
|
||||
}
|
||||
|
||||
const missing = Object.entries(counts)
|
||||
.filter(([, rows]) => rows === 0)
|
||||
.map(([measurement]) => measurement);
|
||||
|
||||
if (missing.length === 0) {
|
||||
const summary = Object.entries(counts)
|
||||
.map(([measurement, rows]) => `${measurement}=${rows}`)
|
||||
.join(', ');
|
||||
console.log(`PASS: Reactor telemetry reached InfluxDB (${summary})`);
|
||||
return;
|
||||
}
|
||||
console.log(`WAIT: reactor telemetry not yet present in InfluxDB for ${missing.join(', ')}`);
|
||||
await wait(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for reactor telemetry measurements ${REACTOR_MEASUREMENTS.join(', ')}`);
|
||||
}
|
||||
|
||||
async function assertGrafanaDatasource() {
|
||||
const auth = `Basic ${Buffer.from(`${GRAFANA_USER}:${GRAFANA_PASSWORD}`).toString('base64')}`;
|
||||
const { response, body, text } = await fetchJson(`${GRAFANA_URL}/api/datasources/uid/${GRAFANA_DS_UID}`, {
|
||||
headers: { Authorization: auth },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grafana datasource lookup failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
if (body?.uid !== GRAFANA_DS_UID) {
|
||||
throw new Error(`Grafana datasource UID mismatch: expected ${GRAFANA_DS_UID}, got ${body?.uid}`);
|
||||
}
|
||||
|
||||
console.log(`PASS: Grafana datasource ${GRAFANA_DS_UID} is present`);
|
||||
}
|
||||
|
||||
async function queryGrafanaDatasource() {
|
||||
const auth = `Basic ${Buffer.from(`${GRAFANA_USER}:${GRAFANA_PASSWORD}`).toString('base64')}`;
|
||||
const response = await fetch(`${GRAFANA_URL}/api/ds/query`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
queries: [
|
||||
{
|
||||
refId: 'A',
|
||||
datasource: { uid: GRAFANA_DS_UID, type: 'influxdb' },
|
||||
query: `
|
||||
from(bucket: "${INFLUX_BUCKET}")
|
||||
|> range(start: -15m)
|
||||
|> filter(fn: (r) => r._measurement == "${REACTOR_MEASUREMENT}" and r._field == "S_O")
|
||||
|> last()
|
||||
`.trim(),
|
||||
rawQuery: true,
|
||||
intervalMs: 1000,
|
||||
maxDataPoints: 100,
|
||||
}
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grafana datasource query failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const body = JSON.parse(text);
|
||||
const frames = body?.results?.A?.frames || [];
|
||||
if (frames.length === 0) {
|
||||
throw new Error('Grafana datasource query returned no reactor frames');
|
||||
}
|
||||
|
||||
console.log(`PASS: Grafana can query reactor telemetry through datasource (${frames.length} frame(s))`);
|
||||
}
|
||||
|
||||
async function waitForGrafanaDashboards(timeoutMs = QUERY_TIMEOUT_MS) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const auth = `Basic ${Buffer.from(`${GRAFANA_USER}:${GRAFANA_PASSWORD}`).toString('base64')}`;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetch(`${GRAFANA_URL}/api/search?query=`, {
|
||||
headers: { Authorization: auth },
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grafana dashboard search failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const results = JSON.parse(text);
|
||||
const titles = new Set(results.map((item) => item.title));
|
||||
const missing = REQUIRED_DASHBOARD_TITLES.filter((title) => !titles.has(title));
|
||||
const pumpingStationCount = results.filter((item) => item.title === 'pumpingStation').length;
|
||||
if (missing.length === 0 && pumpingStationCount >= 3) {
|
||||
console.log(`PASS: Grafana dashboards created (${REQUIRED_DASHBOARD_TITLES.join(', ')} + ${pumpingStationCount} pumpingStation dashboards)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const missingParts = [];
|
||||
if (missing.length > 0) {
|
||||
missingParts.push(`missing titled dashboards: ${missing.join(', ')}`);
|
||||
}
|
||||
if (pumpingStationCount < 3) {
|
||||
missingParts.push(`pumpingStation dashboards=${pumpingStationCount}`);
|
||||
}
|
||||
console.log(`WAIT: Grafana dashboards not ready: ${missingParts.join(' | ')}`);
|
||||
await wait(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for Grafana dashboards: ${REQUIRED_DASHBOARD_TITLES.join(', ')} and >=3 pumpingStation dashboards`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== EVOLV Reactor E2E Round Trip ===');
|
||||
await assertReachable();
|
||||
await deployDemoFlow();
|
||||
console.log('WAIT: allowing Node-RED inject/tick loops to populate telemetry');
|
||||
await wait(12000);
|
||||
await waitForReactorTelemetry();
|
||||
await assertGrafanaDatasource();
|
||||
await queryGrafanaDatasource();
|
||||
if (REQUIRE_GRAFANA_DASHBOARDS) {
|
||||
await waitForGrafanaDashboards();
|
||||
console.log('PASS: Node-RED -> InfluxDB -> Grafana round trip is working for reactor telemetry and dashboard generation');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForGrafanaDashboards(15000);
|
||||
console.log('PASS: Node-RED -> InfluxDB -> Grafana round trip is working for reactor telemetry and dashboard generation');
|
||||
} catch (error) {
|
||||
console.warn(`WARN: Grafana dashboard auto-generation is not ready yet: ${error.message}`);
|
||||
console.log('PASS: Node-RED -> InfluxDB -> Grafana round trip is working for live reactor telemetry');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`FAIL: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fix asset selection in demo-flow.json so editor dropdowns correctly
|
||||
* pre-select the configured supplier/type/model when a node is opened.
|
||||
*
|
||||
* Issues fixed:
|
||||
* 1. Pump nodes: supplier "Hidrostal" → "hidrostal" (matches machine.json id)
|
||||
* 2. demo_meas_flow: assetType "flow-electromagnetic" → "flow" (matches measurement.json type id)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const flowPath = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||
|
||||
let changes = 0;
|
||||
|
||||
flow.forEach(node => {
|
||||
// Fix 1: Pump supplier id mismatch
|
||||
if (node.type === 'rotatingMachine' && node.supplier === 'Hidrostal') {
|
||||
node.supplier = 'hidrostal';
|
||||
changes++;
|
||||
console.log(`Fixed pump ${node.id}: supplier "Hidrostal" → "hidrostal"`);
|
||||
}
|
||||
|
||||
// Fix 2: Standardize flow measurement assetType
|
||||
if (node.type === 'measurement' && node.assetType === 'flow-electromagnetic') {
|
||||
node.assetType = 'flow';
|
||||
changes++;
|
||||
console.log(`Fixed ${node.id}: assetType "flow-electromagnetic" → "flow"`);
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n');
|
||||
console.log(`\nDone. ${changes} node(s) updated.`);
|
||||
@@ -1,243 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fix display issues:
|
||||
* 1. Set positionIcon on all nodes based on positionVsParent
|
||||
* 2. Switch reactor from CSTR to PFR with proper length/resolution
|
||||
* 3. Add missing default fields to all dashboard widgets (gauges, sliders, button-groups)
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FLOW_PATH = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(FLOW_PATH, 'utf8'));
|
||||
|
||||
const byId = (id) => flow.find(n => n.id === id);
|
||||
|
||||
// =============================================
|
||||
// FIX 1: positionIcon on all process nodes
|
||||
// =============================================
|
||||
// Icon mapping from physicalPosition.js
|
||||
const positionIconMap = {
|
||||
'upstream': '→',
|
||||
'atEquipment': '⊥',
|
||||
'downstream': '←',
|
||||
};
|
||||
|
||||
let iconFixed = 0;
|
||||
for (const node of flow) {
|
||||
if (node.positionVsParent !== undefined && node.positionVsParent !== '') {
|
||||
const icon = positionIconMap[node.positionVsParent];
|
||||
if (icon && node.positionIcon !== icon) {
|
||||
node.positionIcon = icon;
|
||||
iconFixed++;
|
||||
}
|
||||
}
|
||||
// Also ensure positionIcon has a fallback if positionVsParent is set
|
||||
if (node.positionVsParent && !node.positionIcon) {
|
||||
node.positionIcon = positionIconMap[node.positionVsParent] || '⊥';
|
||||
iconFixed++;
|
||||
}
|
||||
}
|
||||
console.log(`Fixed positionIcon on ${iconFixed} nodes`);
|
||||
|
||||
// =============================================
|
||||
// FIX 2: Switch reactor from CSTR to PFR
|
||||
// =============================================
|
||||
const reactor = byId('demo_reactor');
|
||||
if (reactor) {
|
||||
reactor.reactor_type = 'PFR';
|
||||
reactor.length = 50; // 50m plug flow reactor
|
||||
reactor.resolution_L = 10; // 10 slices for spatial resolution
|
||||
reactor.alpha = 0; // Danckwerts BC (dispersive flow, more realistic)
|
||||
console.log(`Switched reactor to PFR: length=${reactor.length}m, resolution=${reactor.resolution_L} slices`);
|
||||
|
||||
// Update influent measurements with positions along the reactor
|
||||
// FT-001 at inlet (position 0), DO-001 at 1/3, NH4-001 at 2/3
|
||||
const measFlow = byId('demo_meas_flow');
|
||||
if (measFlow) {
|
||||
measFlow.hasDistance = true;
|
||||
measFlow.distance = 0; // at inlet
|
||||
measFlow.distanceUnit = 'm';
|
||||
measFlow.distanceDescription = 'reactor inlet';
|
||||
measFlow.positionVsParent = 'upstream';
|
||||
measFlow.positionIcon = '→';
|
||||
console.log(' FT-001 positioned at reactor inlet (0m)');
|
||||
}
|
||||
|
||||
const measDo = byId('demo_meas_do');
|
||||
if (measDo) {
|
||||
measDo.hasDistance = true;
|
||||
measDo.distance = 15; // 15m along the reactor (30% of length)
|
||||
measDo.distanceUnit = 'm';
|
||||
measDo.distanceDescription = 'aeration zone';
|
||||
measDo.positionVsParent = 'atEquipment';
|
||||
measDo.positionIcon = '⊥';
|
||||
console.log(' DO-001 positioned at 15m (aeration zone)');
|
||||
}
|
||||
|
||||
const measNh4 = byId('demo_meas_nh4');
|
||||
if (measNh4) {
|
||||
measNh4.hasDistance = true;
|
||||
measNh4.distance = 35; // 35m along the reactor (70% of length)
|
||||
measNh4.distanceUnit = 'm';
|
||||
measNh4.distanceDescription = 'post-aeration zone';
|
||||
measNh4.positionVsParent = 'atEquipment';
|
||||
measNh4.positionIcon = '⊥';
|
||||
console.log(' NH4-001 positioned at 35m (post-aeration zone)');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// FIX 3: Add missing defaults to dashboard widgets
|
||||
// =============================================
|
||||
|
||||
// --- ui-gauge: add missing fields ---
|
||||
const gaugeDefaults = {
|
||||
value: 'payload',
|
||||
valueType: 'msg',
|
||||
sizeThickness: 16,
|
||||
sizeGap: 4,
|
||||
sizeKeyThickness: 8,
|
||||
styleRounded: true,
|
||||
styleGlow: false,
|
||||
alwaysShowTitle: false,
|
||||
floatingTitlePosition: 'top-left',
|
||||
icon: '',
|
||||
};
|
||||
|
||||
let gaugeFixed = 0;
|
||||
for (const node of flow) {
|
||||
if (node.type !== 'ui-gauge') continue;
|
||||
let changed = false;
|
||||
for (const [key, defaultVal] of Object.entries(gaugeDefaults)) {
|
||||
if (node[key] === undefined) {
|
||||
node[key] = defaultVal;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// Ensure className exists
|
||||
if (node.className === undefined) node.className = '';
|
||||
// Ensure outputs (gauges have 1 output in newer versions)
|
||||
if (changed) gaugeFixed++;
|
||||
}
|
||||
console.log(`Fixed ${gaugeFixed} ui-gauge nodes with missing defaults`);
|
||||
|
||||
// --- ui-button-group: add missing fields ---
|
||||
const buttonGroupDefaults = {
|
||||
rounded: true,
|
||||
useThemeColors: true,
|
||||
topic: 'topic',
|
||||
topicType: 'msg',
|
||||
className: '',
|
||||
};
|
||||
|
||||
let bgFixed = 0;
|
||||
for (const node of flow) {
|
||||
if (node.type !== 'ui-button-group') continue;
|
||||
let changed = false;
|
||||
for (const [key, defaultVal] of Object.entries(buttonGroupDefaults)) {
|
||||
if (node[key] === undefined) {
|
||||
node[key] = defaultVal;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// Ensure options have valueType
|
||||
if (node.options && Array.isArray(node.options)) {
|
||||
for (const opt of node.options) {
|
||||
if (!opt.valueType) opt.valueType = 'str';
|
||||
}
|
||||
}
|
||||
if (changed) bgFixed++;
|
||||
}
|
||||
console.log(`Fixed ${bgFixed} ui-button-group nodes with missing defaults`);
|
||||
|
||||
// --- ui-slider: add missing fields ---
|
||||
const sliderDefaults = {
|
||||
topic: 'topic',
|
||||
topicType: 'msg',
|
||||
thumbLabel: true,
|
||||
showTicks: 'always',
|
||||
className: '',
|
||||
iconPrepend: '',
|
||||
iconAppend: '',
|
||||
color: '',
|
||||
colorTrack: '',
|
||||
colorThumb: '',
|
||||
showTextField: false,
|
||||
};
|
||||
|
||||
let sliderFixed = 0;
|
||||
for (const node of flow) {
|
||||
if (node.type !== 'ui-slider') continue;
|
||||
let changed = false;
|
||||
for (const [key, defaultVal] of Object.entries(sliderDefaults)) {
|
||||
if (node[key] === undefined) {
|
||||
node[key] = defaultVal;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) sliderFixed++;
|
||||
}
|
||||
console.log(`Fixed ${sliderFixed} ui-slider nodes with missing defaults`);
|
||||
|
||||
// --- ui-chart: add missing fields ---
|
||||
const chartDefaults = {
|
||||
className: '',
|
||||
};
|
||||
|
||||
let chartFixed = 0;
|
||||
for (const node of flow) {
|
||||
if (node.type !== 'ui-chart') continue;
|
||||
let changed = false;
|
||||
for (const [key, defaultVal] of Object.entries(chartDefaults)) {
|
||||
if (node[key] === undefined) {
|
||||
node[key] = defaultVal;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) chartFixed++;
|
||||
}
|
||||
console.log(`Fixed ${chartFixed} ui-chart nodes with missing defaults`);
|
||||
|
||||
// --- ui-template: add missing fields ---
|
||||
for (const node of flow) {
|
||||
if (node.type !== 'ui-template') continue;
|
||||
if (node.templateScope === undefined) node.templateScope = 'local';
|
||||
if (node.className === undefined) node.className = '';
|
||||
}
|
||||
|
||||
// --- ui-text: add missing fields ---
|
||||
for (const node of flow) {
|
||||
if (node.type !== 'ui-text') continue;
|
||||
if (node.className === undefined) node.className = '';
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Validate
|
||||
// =============================================
|
||||
const allIds = new Set(flow.map(n => n.id));
|
||||
let issues = 0;
|
||||
for (const n of flow) {
|
||||
if (!n.wires) continue;
|
||||
for (const port of n.wires) {
|
||||
for (const target of port) {
|
||||
if (!allIds.has(target)) {
|
||||
console.warn(`BROKEN WIRE: ${n.id} → ${target}`);
|
||||
issues++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (issues === 0) console.log('All wire references valid ✓');
|
||||
|
||||
// List all nodes with positionIcon to verify
|
||||
console.log('\nNodes with positionIcon:');
|
||||
for (const n of flow) {
|
||||
if (n.positionIcon) {
|
||||
console.log(` ${n.positionIcon} ${n.name || n.id} (${n.positionVsParent})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Write
|
||||
fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
|
||||
console.log(`\nWrote ${FLOW_PATH} (${flow.length} nodes)`);
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fix layout of demo-flow.json so nodes are nicely grouped and don't overlap.
|
||||
*
|
||||
* Layout structure (on demo_tab_wwtp):
|
||||
*
|
||||
* Row 1 (y=40-300): PS West section (comment, mode injects, pumps, MGC, PS, q_in sim)
|
||||
* Row 2 (y=340-500): PS North section
|
||||
* Row 3 (y=520-680): PS South section
|
||||
* Row 4 (y=720-920): Biological Treatment (measurements, reactor, settler, monster)
|
||||
* Row 5 (y=960-1120): Pressure Measurements section
|
||||
* Row 6 (y=1140-1440): Effluent measurements
|
||||
* Row 7 (y=1460+): Telemetry & Dashboard API
|
||||
*
|
||||
* Column layout:
|
||||
* x=140: Inject nodes (left)
|
||||
* x=370: Function nodes
|
||||
* x=580: Intermediate nodes (measurements feeding other nodes)
|
||||
* x=700: Main equipment nodes (PS, pumps, measurement nodes)
|
||||
* x=935: Link out nodes
|
||||
* x=1050+: Right side (reactor, settler, telemetry)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const flowPath = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||
|
||||
function setPos(id, x, y) {
|
||||
const node = flow.find(n => n.id === id);
|
||||
if (node) {
|
||||
node.x = x;
|
||||
node.y = y;
|
||||
} else {
|
||||
console.warn('Layout: node not found:', id);
|
||||
}
|
||||
}
|
||||
|
||||
// === PS West section (y: 40-300) ===
|
||||
setPos('demo_comment_ps', 340, 40);
|
||||
|
||||
// Mode + q_in injects (left column)
|
||||
setPos('demo_inj_w1_mode', 140, 80);
|
||||
setPos('demo_inj_w2_mode', 140, 260);
|
||||
setPos('demo_inj_west_mode', 140, 160);
|
||||
setPos('demo_inj_west_flow', 140, 200);
|
||||
|
||||
// q_in function node
|
||||
setPos('demo_fn_west_flow_sim', 370, 200);
|
||||
|
||||
// MGC sits between PS and pumps
|
||||
setPos('demo_pump_w1', 700, 100);
|
||||
setPos('demo_mgc_west', 700, 180);
|
||||
setPos('demo_pump_w2', 700, 260);
|
||||
setPos('demo_ps_west', 940, 180);
|
||||
|
||||
// === PS North section (y: 340-500) ===
|
||||
setPos('demo_comment_ps_north', 330, 340);
|
||||
setPos('demo_inj_n1_mode', 140, 380);
|
||||
setPos('demo_inj_north_mode', 140, 420);
|
||||
setPos('demo_inj_north_flow', 140, 460);
|
||||
setPos('demo_fn_north_flow_sim', 370, 460);
|
||||
|
||||
// North outflow measurement
|
||||
setPos('demo_comment_north_outflow', 200, 500);
|
||||
setPos('demo_meas_ft_n1', 580, 500);
|
||||
|
||||
setPos('demo_pump_n1', 700, 400);
|
||||
setPos('demo_ps_north', 940, 440);
|
||||
|
||||
// === PS South section (y: 540-680) ===
|
||||
setPos('demo_comment_ps_south', 320, 540);
|
||||
setPos('demo_inj_s1_mode', 140, 580);
|
||||
setPos('demo_inj_south_mode', 140, 620);
|
||||
setPos('demo_inj_south_flow', 140, 660);
|
||||
setPos('demo_fn_south_flow_sim', 370, 660);
|
||||
|
||||
setPos('demo_pump_s1', 700, 580);
|
||||
setPos('demo_ps_south', 940, 620);
|
||||
|
||||
// === Biological Treatment (y: 720-920) ===
|
||||
setPos('demo_comment_treatment', 200, 720);
|
||||
setPos('demo_meas_flow', 700, 760);
|
||||
setPos('demo_meas_do', 700, 820);
|
||||
setPos('demo_meas_nh4', 700, 880);
|
||||
|
||||
setPos('demo_reactor', 1100, 820);
|
||||
setPos('demo_inj_reactor_tick', 900, 760);
|
||||
setPos('demo_settler', 1100, 920);
|
||||
setPos('demo_monster', 1100, 1000);
|
||||
setPos('demo_inj_monster_flow', 850, 1000);
|
||||
setPos('demo_fn_monster_flow', 930, 1040);
|
||||
|
||||
// === Pressure Measurements (y: 960-1120) — new section ===
|
||||
setPos('demo_comment_pressure', 320, 960);
|
||||
|
||||
// West pressure (grouped together)
|
||||
setPos('demo_fn_level_to_pressure_w', 370, 1000);
|
||||
setPos('demo_meas_pt_w_up', 580, 1000);
|
||||
setPos('demo_meas_pt_w_down', 580, 1040);
|
||||
|
||||
// North pressure
|
||||
setPos('demo_fn_level_to_pressure_n', 370, 1080);
|
||||
setPos('demo_meas_pt_n_up', 580, 1080);
|
||||
setPos('demo_meas_pt_n_down', 580, 1120);
|
||||
|
||||
// South pressure
|
||||
setPos('demo_fn_level_to_pressure_s', 370, 1160);
|
||||
setPos('demo_meas_pt_s_up', 580, 1160);
|
||||
setPos('demo_meas_pt_s_down', 580, 1200);
|
||||
|
||||
// === Effluent Measurements (y: 1240-1520) ===
|
||||
setPos('demo_comment_effluent_meas', 300, 1240);
|
||||
setPos('demo_meas_eff_flow', 700, 1280);
|
||||
setPos('demo_meas_eff_do', 700, 1340);
|
||||
setPos('demo_meas_eff_nh4', 700, 1400);
|
||||
setPos('demo_meas_eff_no3', 700, 1460);
|
||||
setPos('demo_meas_eff_tss', 700, 1520);
|
||||
|
||||
// === Telemetry section (right side, y: 40-240) ===
|
||||
setPos('demo_comment_telemetry', 1300, 40);
|
||||
setPos('demo_link_influx_out', 1135, 500);
|
||||
setPos('demo_link_influx_in', 1175, 100);
|
||||
setPos('demo_fn_influx_convert', 1350, 100);
|
||||
setPos('demo_http_influx', 1560, 100);
|
||||
setPos('demo_fn_influx_count', 1740, 100);
|
||||
|
||||
// Process debug
|
||||
setPos('demo_comment_process_out', 1300, 160);
|
||||
setPos('demo_link_process_out', 1135, 540);
|
||||
setPos('demo_link_process_in', 1175, 200);
|
||||
setPos('demo_dbg_process', 1360, 200);
|
||||
setPos('demo_dbg_registration', 1370, 240);
|
||||
|
||||
// Dashboard link outs
|
||||
setPos('demo_link_ps_west_dash', 1135, 160);
|
||||
setPos('demo_link_ps_north_dash', 1135, 420);
|
||||
setPos('demo_link_ps_south_dash', 1135, 600);
|
||||
setPos('demo_link_reactor_dash', 1300, 820);
|
||||
setPos('demo_link_meas_dash', 1135, 860);
|
||||
setPos('demo_link_eff_meas_dash', 1135, 1300);
|
||||
|
||||
// Dashboard API
|
||||
setPos('demo_dashapi', 1100, 1100);
|
||||
setPos('demo_inj_dashapi', 850, 1100);
|
||||
setPos('demo_http_grafana', 1300, 1100);
|
||||
setPos('demo_dbg_grafana', 1500, 1100);
|
||||
|
||||
// InfluxDB status link
|
||||
setPos('demo_link_influx_status_out', 1940, 100);
|
||||
|
||||
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n');
|
||||
console.log('Layout fixed. Deploying...');
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Add initial volume calibration inject nodes to the demo flow.
|
||||
*
|
||||
* Problem: All 3 pumping stations start with initial volume = minVol,
|
||||
* which is below the dryRun safety threshold. This causes the safety
|
||||
* guard to trigger immediately on every tick, preventing normal control.
|
||||
*
|
||||
* Fix: Add inject nodes that fire once at deploy, sending
|
||||
* calibratePredictedVolume to each PS with a reasonable starting volume.
|
||||
*
|
||||
* PS West: 500m3 basin, startLevel=2.5m → start at 200m3 (level 1.6m)
|
||||
* Below startLevel, pumps stay off. q_in fills basin naturally.
|
||||
* PS North: 200m3 basin, flowbased → start at 100m3 (50% fill)
|
||||
* PS South: 100m3 basin, manual → start at 50m3 (50% fill)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const flowPath = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||
|
||||
// Check if calibration nodes already exist
|
||||
const existingCalib = flow.filter(n => n.id && n.id.startsWith('demo_inj_calib_'));
|
||||
if (existingCalib.length > 0) {
|
||||
console.log('Calibration nodes already exist:', existingCalib.map(n => n.id));
|
||||
console.log('Removing existing calibration nodes first...');
|
||||
for (const node of existingCalib) {
|
||||
const idx = flow.findIndex(n => n.id === node.id);
|
||||
if (idx !== -1) flow.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the WWTP tab for positioning
|
||||
const wwtpTab = flow.find(n => n.id === 'demo_tab_wwtp');
|
||||
if (!wwtpTab) {
|
||||
console.error('WWTP tab not found!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Calibration configs: { ps_id, name, volume, x, y }
|
||||
const calibrations = [
|
||||
{
|
||||
id: 'demo_inj_calib_west',
|
||||
name: 'Cal: PS West → 200m3',
|
||||
target: 'demo_ps_west',
|
||||
volume: 200,
|
||||
x: 100, y: 50,
|
||||
},
|
||||
{
|
||||
id: 'demo_inj_calib_north',
|
||||
name: 'Cal: PS North → 100m3',
|
||||
target: 'demo_ps_north',
|
||||
volume: 100,
|
||||
x: 100, y: 100,
|
||||
},
|
||||
{
|
||||
id: 'demo_inj_calib_south',
|
||||
name: 'Cal: PS South → 50m3',
|
||||
target: 'demo_ps_south',
|
||||
volume: 50,
|
||||
x: 100, y: 150,
|
||||
},
|
||||
];
|
||||
|
||||
let added = 0;
|
||||
|
||||
calibrations.forEach(cal => {
|
||||
const injectNode = {
|
||||
id: cal.id,
|
||||
type: 'inject',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: cal.name,
|
||||
props: [
|
||||
{
|
||||
p: 'payload',
|
||||
vt: 'num',
|
||||
},
|
||||
{
|
||||
p: 'topic',
|
||||
vt: 'str',
|
||||
},
|
||||
],
|
||||
repeat: '',
|
||||
crontab: '',
|
||||
once: true,
|
||||
onceDelay: '0.5',
|
||||
topic: 'calibratePredictedVolume',
|
||||
payload: String(cal.volume),
|
||||
payloadType: 'num',
|
||||
x: cal.x,
|
||||
y: cal.y,
|
||||
wires: [[cal.target]],
|
||||
};
|
||||
|
||||
flow.push(injectNode);
|
||||
added++;
|
||||
console.log(`Added ${cal.id}: ${cal.name} → ${cal.target} (${cal.volume} m3)`);
|
||||
});
|
||||
|
||||
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n');
|
||||
console.log(`\nDone. ${added} calibration node(s) added.`);
|
||||
@@ -1,25 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const flowPath = "docker/demo-flow.json";
|
||||
const flow = JSON.parse(fs.readFileSync(flowPath, "utf8"));
|
||||
|
||||
let newFlow = flow.filter(n => n.id !== "demo_dbg_reactor_inspect");
|
||||
const reactor = newFlow.find(n => n.id === "demo_reactor");
|
||||
reactor.wires[0] = reactor.wires[0].filter(id => id !== "demo_dbg_reactor_inspect");
|
||||
|
||||
reactor.kla = 70;
|
||||
|
||||
newFlow.push({
|
||||
id: "demo_dbg_reactor_inspect",
|
||||
type: "function",
|
||||
z: "demo_tab_treatment",
|
||||
name: "Reactor State Inspector",
|
||||
func: 'if (msg.topic !== "GridProfile") return null;\nconst p = msg.payload;\nif (!p || !p.grid) return null;\nconst now = Date.now();\nif (global.get("lastInspect") && now - global.get("lastInspect") < 5000) return null;\nglobal.set("lastInspect", now);\nconst profile = p.grid.map((row, i) => "cell" + i + "(" + (i*p.d_x).toFixed(0) + "m): NH4=" + row[3].toFixed(2) + " DO=" + row[0].toFixed(2));\nnode.warn("GRID: " + profile.join(" | "));\nreturn null;',
|
||||
outputs: 1,
|
||||
x: 840,
|
||||
y: 320,
|
||||
wires: [[]]
|
||||
});
|
||||
reactor.wires[0].push("demo_dbg_reactor_inspect");
|
||||
|
||||
fs.writeFileSync(flowPath, JSON.stringify(newFlow, null, 2) + "\n");
|
||||
console.log("kla:", reactor.kla, "X_A_init:", reactor.X_A_init);
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fix downstream pressure simulator ranges and add a monitoring debug node.
|
||||
*
|
||||
* Problems found:
|
||||
* 1. Downstream pressure simulator range 0-5000 mbar is unrealistic.
|
||||
* Real WWTP system backpressure: 800-1500 mbar (0.8-1.5 bar).
|
||||
* The pump curve operates in 700-3900 mbar. With upstream ~300 mbar
|
||||
* (hydrostatic from 3m basin) and downstream at 5000 mbar, the
|
||||
* pressure differential pushes the curve to extreme predictions.
|
||||
*
|
||||
* 2. No way to see runtime state visually. We'll leave visual monitoring
|
||||
* to the Grafana/dashboard layer, but fix the root cause here.
|
||||
*
|
||||
* Fix: Set downstream pressure simulators to realistic ranges:
|
||||
* - West: o_min=800, o_max=1500, i_min=800, i_max=1500
|
||||
* - North: o_min=600, o_max=1200, i_min=600, i_max=1200
|
||||
* - South: o_min=500, o_max=1000, i_min=500, i_max=1000
|
||||
*
|
||||
* This keeps pressure differential in ~500-1200 mbar range,
|
||||
* well within the pump curve (700-3900 mbar).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const flowPath = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||
|
||||
let changes = 0;
|
||||
|
||||
// Fix downstream pressure simulator ranges
|
||||
const pressureFixes = {
|
||||
'demo_meas_pt_w_down': { i_min: 800, i_max: 1500, o_min: 800, o_max: 1500 },
|
||||
'demo_meas_pt_n_down': { i_min: 600, i_max: 1200, o_min: 600, o_max: 1200 },
|
||||
'demo_meas_pt_s_down': { i_min: 500, i_max: 1000, o_min: 500, o_max: 1000 },
|
||||
};
|
||||
|
||||
flow.forEach(node => {
|
||||
const fix = pressureFixes[node.id];
|
||||
if (fix) {
|
||||
const old = { i_min: node.i_min, i_max: node.i_max, o_min: node.o_min, o_max: node.o_max };
|
||||
Object.assign(node, fix);
|
||||
console.log(`Fixed ${node.id} "${node.name}":`);
|
||||
console.log(` Was: i=[${old.i_min},${old.i_max}] o=[${old.o_min},${old.o_max}]`);
|
||||
console.log(` Now: i=[${fix.i_min},${fix.i_max}] o=[${fix.o_min},${fix.o_max}]`);
|
||||
changes++;
|
||||
}
|
||||
});
|
||||
|
||||
// Also fix upstream pressure ranges to match realistic hydrostatic range
|
||||
// Basin level 0-4m → hydrostatic 0-392 mbar → use 0-500 mbar range
|
||||
const upstreamFixes = {
|
||||
'demo_meas_pt_w_up': { i_min: 0, i_max: 500, o_min: 0, o_max: 500 },
|
||||
'demo_meas_pt_n_up': { i_min: 0, i_max: 400, o_min: 0, o_max: 400 },
|
||||
'demo_meas_pt_s_up': { i_min: 0, i_max: 300, o_min: 0, o_max: 300 },
|
||||
};
|
||||
|
||||
flow.forEach(node => {
|
||||
const fix = upstreamFixes[node.id];
|
||||
if (fix) {
|
||||
const old = { i_min: node.i_min, i_max: node.i_max, o_min: node.o_min, o_max: node.o_max };
|
||||
Object.assign(node, fix);
|
||||
console.log(`Fixed ${node.id} "${node.name}":`);
|
||||
console.log(` Was: i=[${old.i_min},${old.i_max}] o=[${old.o_min},${old.o_max}]`);
|
||||
console.log(` Now: i=[${fix.i_min},${fix.i_max}] o=[${fix.o_min},${fix.o_max}]`);
|
||||
changes++;
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n');
|
||||
console.log(`\nDone. ${changes} node(s) updated.`);
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Monitor WWTP system health and process state.
|
||||
* Captures PS volume, flow rates, pump states, and control actions.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const NR_URL = 'http://localhost:1880';
|
||||
const SAMPLE_INTERVAL = 5000;
|
||||
const NUM_SAMPLES = 20; // 100 seconds
|
||||
|
||||
function getLogs(lines = 50) {
|
||||
try {
|
||||
return execSync('docker logs evolv-nodered --tail ' + lines + ' 2>&1', {
|
||||
encoding: 'utf8', timeout: 5000,
|
||||
});
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function parseLogs(logs) {
|
||||
const result = { safety: [], pressure: 0, control: [], state: [], errors: [], flow: [] };
|
||||
logs.split('\n').forEach(line => {
|
||||
if (!line.trim()) return;
|
||||
|
||||
const volMatch = line.match(/vol=([-\d.]+) m3.*remainingTime=([\w.]+)/);
|
||||
if (volMatch) {
|
||||
result.safety.push({ vol: parseFloat(volMatch[1]), remaining: volMatch[2] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.includes('Pressure change detected')) { result.pressure++; return; }
|
||||
|
||||
if (line.includes('Controllevel') || line.includes('flowbased') || line.includes('control applying')) {
|
||||
result.control.push(line.trim().substring(0, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.includes('startup') || line.includes('shutdown') || line.includes('machine state') ||
|
||||
line.includes('Handling input') || line.includes('execSequence') || line.includes('execsequence')) {
|
||||
result.state.push(line.trim().substring(0, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.includes('[ERROR]') || line.includes('Error')) {
|
||||
result.errors.push(line.trim().substring(0, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.includes('netflow') || line.includes('Height') || line.includes('flow')) {
|
||||
result.flow.push(line.trim().substring(0, 200));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('=== WWTP Health Monitor ===');
|
||||
console.log(`Sampling every ${SAMPLE_INTERVAL/1000}s for ${NUM_SAMPLES * SAMPLE_INTERVAL / 1000}s\n`);
|
||||
|
||||
const history = [];
|
||||
|
||||
for (let i = 0; i < NUM_SAMPLES; i++) {
|
||||
const elapsed = (i * SAMPLE_INTERVAL / 1000).toFixed(0);
|
||||
const logs = getLogs(40);
|
||||
const parsed = parseLogs(logs);
|
||||
|
||||
console.log(`--- Sample ${i+1}/${NUM_SAMPLES} (t=${elapsed}s) ---`);
|
||||
|
||||
// Safety status
|
||||
if (parsed.safety.length > 0) {
|
||||
const latest = parsed.safety[parsed.safety.length - 1];
|
||||
console.log(` ⚠️ SAFETY: ${parsed.safety.length} triggers, vol=${latest.vol} m3`);
|
||||
} else {
|
||||
console.log(' ✅ SAFETY: OK');
|
||||
}
|
||||
|
||||
// Pressure changes
|
||||
if (parsed.pressure > 0) {
|
||||
console.log(` 📊 PRESSURE: ${parsed.pressure} changes (sim active)`);
|
||||
}
|
||||
|
||||
// Control actions
|
||||
if (parsed.control.length > 0) {
|
||||
parsed.control.slice(-3).forEach(c => console.log(` 🎛️ CONTROL: ${c}`));
|
||||
}
|
||||
|
||||
// State changes
|
||||
if (parsed.state.length > 0) {
|
||||
parsed.state.slice(-3).forEach(s => console.log(` 🔄 STATE: ${s}`));
|
||||
}
|
||||
|
||||
// Flow info
|
||||
if (parsed.flow.length > 0) {
|
||||
parsed.flow.slice(-2).forEach(f => console.log(` 💧 FLOW: ${f}`));
|
||||
}
|
||||
|
||||
// Errors
|
||||
if (parsed.errors.length > 0) {
|
||||
parsed.errors.forEach(e => console.log(` ❌ ERROR: ${e}`));
|
||||
}
|
||||
|
||||
history.push({
|
||||
t: parseInt(elapsed),
|
||||
safety: parsed.safety.length,
|
||||
pressure: parsed.pressure,
|
||||
control: parsed.control.length,
|
||||
state: parsed.state.length,
|
||||
errors: parsed.errors.length,
|
||||
});
|
||||
|
||||
console.log('');
|
||||
|
||||
if (i < NUM_SAMPLES - 1) {
|
||||
await new Promise(r => setTimeout(r, SAMPLE_INTERVAL));
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Health Summary ===');
|
||||
const totalSafety = history.reduce((a, h) => a + h.safety, 0);
|
||||
const totalErrors = history.reduce((a, h) => a + h.errors, 0);
|
||||
const totalControl = history.reduce((a, h) => a + h.control, 0);
|
||||
const totalState = history.reduce((a, h) => a + h.state, 0);
|
||||
|
||||
console.log(`Safety triggers: ${totalSafety} ${totalSafety === 0 ? '✅' : '⚠️'}`);
|
||||
console.log(`Errors: ${totalErrors} ${totalErrors === 0 ? '✅' : '❌'}`);
|
||||
console.log(`Control actions: ${totalControl}`);
|
||||
console.log(`State changes: ${totalState}`);
|
||||
|
||||
if (totalSafety === 0 && totalErrors === 0) {
|
||||
console.log('\n🟢 SYSTEM HEALTHY');
|
||||
} else if (totalErrors > 0) {
|
||||
console.log('\n🔴 ERRORS DETECTED');
|
||||
} else {
|
||||
console.log('\n🟡 SAFETY ACTIVE (may be normal during startup)');
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error('Monitor failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Monitor WWTP runtime via Node-RED debug WebSocket and container logs.
|
||||
* Captures process data every few seconds and displays trends.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const NR_URL = 'http://localhost:1880';
|
||||
const SAMPLE_INTERVAL = 5000; // ms
|
||||
const NUM_SAMPLES = 12; // 60 seconds total
|
||||
|
||||
function fetchJSON(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, res => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(Buffer.concat(chunks))); }
|
||||
catch (e) { reject(new Error('Parse: ' + e.message)); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function getRecentLogs(lines = 50) {
|
||||
try {
|
||||
return execSync('docker logs evolv-nodered --tail ' + lines + ' 2>&1', {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (e) {
|
||||
return 'Failed to get logs: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSafeGuardLogs(logs) {
|
||||
const lines = logs.split('\n');
|
||||
const safeGuards = [];
|
||||
const pressures = [];
|
||||
const others = [];
|
||||
|
||||
lines.forEach(line => {
|
||||
const volMatch = line.match(/Safe guard triggered: vol=([-\d.]+) m3/);
|
||||
if (volMatch) {
|
||||
safeGuards.push(parseFloat(volMatch[1]));
|
||||
}
|
||||
const pressMatch = line.match(/New f =([\d.]+) is constrained/);
|
||||
if (pressMatch) {
|
||||
pressures.push(parseFloat(pressMatch[1]));
|
||||
}
|
||||
if (line.includes('_controlLevelBased') || line.includes('Mode changed') ||
|
||||
line.includes('execSequence') || line.includes('startup') ||
|
||||
line.includes('shutdown') || line.includes('setMode')) {
|
||||
others.push(line.trim().substring(0, 200));
|
||||
}
|
||||
});
|
||||
|
||||
return { safeGuards, pressures, others };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('=== WWTP Runtime Monitor ===');
|
||||
console.log('Capturing ' + NUM_SAMPLES + ' samples at ' + (SAMPLE_INTERVAL/1000) + 's intervals\n');
|
||||
|
||||
// Wait for nodes to initialize after deploy
|
||||
console.log('Waiting 10s for nodes to initialize...\n');
|
||||
await new Promise(r => setTimeout(r, 10000));
|
||||
|
||||
for (let i = 0; i < NUM_SAMPLES; i++) {
|
||||
const elapsed = (i * SAMPLE_INTERVAL / 1000 + 10).toFixed(0);
|
||||
console.log('--- Sample ' + (i+1) + '/' + NUM_SAMPLES + ' (t=' + elapsed + 's after deploy) ---');
|
||||
|
||||
// Capture container logs (last 30 lines since last sample)
|
||||
const logs = getRecentLogs(30);
|
||||
const parsed = parseSafeGuardLogs(logs);
|
||||
|
||||
if (parsed.safeGuards.length > 0) {
|
||||
const latest = parsed.safeGuards[parsed.safeGuards.length - 1];
|
||||
const trend = parsed.safeGuards.length > 1
|
||||
? (parsed.safeGuards[parsed.safeGuards.length-1] - parsed.safeGuards[0] > 0 ? 'RISING' : 'FALLING')
|
||||
: 'STABLE';
|
||||
console.log(' SAFETY: vol=' + latest.toFixed(2) + ' m3 (' + parsed.safeGuards.length + ' triggers, ' + trend + ')');
|
||||
} else {
|
||||
console.log(' SAFETY: No safe guard triggers (GOOD)');
|
||||
}
|
||||
|
||||
if (parsed.pressures.length > 0) {
|
||||
const avg = parsed.pressures.reduce((a,b) => a+b, 0) / parsed.pressures.length;
|
||||
console.log(' PRESSURE CLAMP: avg f=' + avg.toFixed(0) + ' (' + parsed.pressures.length + ' warnings)');
|
||||
} else {
|
||||
console.log(' PRESSURE: No interpolation warnings (GOOD)');
|
||||
}
|
||||
|
||||
if (parsed.others.length > 0) {
|
||||
console.log(' CONTROL: ' + parsed.others.slice(-3).join('\n '));
|
||||
}
|
||||
|
||||
// Check if there are state change or mode messages
|
||||
const logLines = logs.split('\n');
|
||||
const stateChanges = logLines.filter(l =>
|
||||
l.includes('machine state') || l.includes('State:') ||
|
||||
l.includes('draining') || l.includes('filling') ||
|
||||
l.includes('q_in') || l.includes('netFlow')
|
||||
);
|
||||
if (stateChanges.length > 0) {
|
||||
console.log(' STATE: ' + stateChanges.slice(-3).map(s => s.trim().substring(0, 150)).join('\n '));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
if (i < NUM_SAMPLES - 1) {
|
||||
await new Promise(r => setTimeout(r, SAMPLE_INTERVAL));
|
||||
}
|
||||
}
|
||||
|
||||
// Final log dump
|
||||
console.log('\n=== Final Log Analysis (last 200 lines) ===');
|
||||
const finalLogs = getRecentLogs(200);
|
||||
const finalParsed = parseSafeGuardLogs(finalLogs);
|
||||
|
||||
console.log('Safe guard triggers: ' + finalParsed.safeGuards.length);
|
||||
if (finalParsed.safeGuards.length > 0) {
|
||||
console.log(' First vol: ' + finalParsed.safeGuards[0].toFixed(2) + ' m3');
|
||||
console.log(' Last vol: ' + finalParsed.safeGuards[finalParsed.safeGuards.length-1].toFixed(2) + ' m3');
|
||||
const delta = finalParsed.safeGuards[finalParsed.safeGuards.length-1] - finalParsed.safeGuards[0];
|
||||
console.log(' Delta: ' + (delta > 0 ? '+' : '') + delta.toFixed(2) + ' m3 (' + (delta > 0 ? 'RECOVERING' : 'STILL DRAINING') + ')');
|
||||
}
|
||||
|
||||
console.log('Pressure clamp warnings: ' + finalParsed.pressures.length);
|
||||
if (finalParsed.pressures.length > 0) {
|
||||
const min = Math.min(...finalParsed.pressures);
|
||||
const max = Math.max(...finalParsed.pressures);
|
||||
console.log(' Range: ' + min.toFixed(0) + ' - ' + max.toFixed(0));
|
||||
}
|
||||
|
||||
console.log('\nControl events: ' + finalParsed.others.length);
|
||||
finalParsed.others.slice(-10).forEach(l => console.log(' ' + l));
|
||||
|
||||
// Overall assessment
|
||||
console.log('\n=== ASSESSMENT ===');
|
||||
if (finalParsed.safeGuards.length === 0 && finalParsed.pressures.length === 0) {
|
||||
console.log('HEALTHY: No safety triggers, no pressure warnings');
|
||||
} else if (finalParsed.safeGuards.length > 0) {
|
||||
const trend = finalParsed.safeGuards[finalParsed.safeGuards.length-1] - finalParsed.safeGuards[0];
|
||||
if (trend > 0) {
|
||||
console.log('RECOVERING: Volume rising but still negative');
|
||||
} else {
|
||||
console.log('CRITICAL: Volume still dropping - control issue persists');
|
||||
}
|
||||
} else if (finalParsed.pressures.length > 0) {
|
||||
console.log('WARNING: Pressure values exceeding curve bounds');
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error('Monitor failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
20
scripts/patch-deps.js
Normal file
20
scripts/patch-deps.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Preinstall script: rewrites the generalFunctions dependency
|
||||
* from git+https to a local file path when the submodule exists.
|
||||
* This avoids needing Gitea credentials during npm install.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const pkgPath = path.join(__dirname, '..', 'package.json');
|
||||
const localGF = path.join(__dirname, '..', 'nodes', 'generalFunctions');
|
||||
|
||||
if (fs.existsSync(localGF) && fs.existsSync(path.join(localGF, 'index.js'))) {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (pkg.dependencies && pkg.dependencies.generalFunctions &&
|
||||
pkg.dependencies.generalFunctions.startsWith('git+')) {
|
||||
pkg.dependencies.generalFunctions = 'file:./nodes/generalFunctions';
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log('[patch-deps] Rewrote generalFunctions to local path');
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Patch demo-flow.json:
|
||||
* 1. Fix NH4 chart — remove demo_link_meas_dash from new NH4 nodes
|
||||
* 2. Update parse function — use "NH4 @ Xm" label format
|
||||
* 3. Reorganize entire treatment tab — logical left-to-right layout
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const flowPath = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||
|
||||
const find = (id) => flow.find(n => n.id === id);
|
||||
|
||||
// ============================================================
|
||||
// 1. FIX NH4 CHART WIRING
|
||||
// Remove demo_link_meas_dash from the 4 new NH4 nodes.
|
||||
// They should only go to process link + NH4 profile link.
|
||||
// ============================================================
|
||||
|
||||
const newNh4Ids = ['demo_meas_nh4_in', 'demo_meas_nh4_a', 'demo_meas_nh4_b', 'demo_meas_nh4_c'];
|
||||
for (const id of newNh4Ids) {
|
||||
const n = find(id);
|
||||
if (n) {
|
||||
n.wires[0] = n.wires[0].filter(w => w !== 'demo_link_meas_dash');
|
||||
console.log(` ${id} Port 0 wires: ${JSON.stringify(n.wires[0])}`);
|
||||
}
|
||||
}
|
||||
console.log('1. Fixed: removed demo_link_meas_dash from new NH4 nodes');
|
||||
|
||||
// ============================================================
|
||||
// 2. UPDATE PARSE FUNCTION — "NH4 @ Xm" format
|
||||
// Also make it generic: read distance from payload metadata
|
||||
// if available, fall back to topic matching.
|
||||
// ============================================================
|
||||
|
||||
const parseFn = find('demo_fn_nh4_profile_parse');
|
||||
if (parseFn) {
|
||||
parseFn.func = `const p = msg.payload || {};
|
||||
const topic = msg.topic || '';
|
||||
const now = Date.now();
|
||||
const val = Number(p.mAbs);
|
||||
if (!Number.isFinite(val)) return null;
|
||||
|
||||
// Build label from distance metadata if available, else match by tag
|
||||
const dist = p.distance;
|
||||
const tag = p.assetTagNumber || topic;
|
||||
let label;
|
||||
if (dist !== undefined && dist !== null) {
|
||||
label = 'NH4 @ ' + dist + 'm';
|
||||
} else if (tag.includes('NH4-IN')) label = 'NH4 @ 0m';
|
||||
else if (tag.includes('NH4-A')) label = 'NH4 @ 10m';
|
||||
else if (tag.includes('NH4-B')) label = 'NH4 @ 25m';
|
||||
else if (tag.includes('NH4-001')) label = 'NH4 @ 35m';
|
||||
else if (tag.includes('NH4-C')) label = 'NH4 @ 45m';
|
||||
else label = 'NH4 @ ?m';
|
||||
|
||||
return { topic: label, payload: Math.round(val * 100) / 100, timestamp: now };`;
|
||||
console.log('2. Updated NH4 profile parse function to "NH4 @ Xm" format');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. REORGANIZE TREATMENT TAB LAYOUT
|
||||
//
|
||||
// Logical left-to-right process flow:
|
||||
//
|
||||
// Col 1 (x=80): Comments / section headers
|
||||
// Col 2 (x=200): Injects (reactor tick, monster flow)
|
||||
// Col 3 (x=420): Inlet measurements (flow, DO, NH4 profile)
|
||||
// Col 4 (x=640): Link outs (meas dash, NH4 profile dash)
|
||||
// Col 5 (x=820): Reactor
|
||||
// Col 6 (x=1060): Settler
|
||||
// Col 7 (x=1280): Effluent measurements
|
||||
// Col 8 (x=1500): Effluent link outs
|
||||
//
|
||||
// Row zones (y):
|
||||
// Row A (y=40): Section comment
|
||||
// Row B (y=100-440): Main process: reactor measurements → reactor → settler
|
||||
// Row C (y=500-700): Effluent measurements (downstream of settler)
|
||||
// Row D (y=760-900): RAS recycle loop (below main flow)
|
||||
// Row E (y=960-1120): Merge collection / influent composition
|
||||
//
|
||||
// ============================================================
|
||||
|
||||
const layout = {
|
||||
// ── SECTION COMMENT ──
|
||||
'demo_comment_treatment': { x: 80, y: 40 },
|
||||
|
||||
// ── INJECTS ──
|
||||
'demo_inj_reactor_tick': { x: 200, y: 120 },
|
||||
'demo_inj_monster_flow': { x: 200, y: 560 },
|
||||
|
||||
// ── INLET MEASUREMENTS (column, spaced 60px) ──
|
||||
'demo_meas_flow': { x: 420, y: 100 }, // FT-001 flow
|
||||
'demo_meas_do': { x: 420, y: 160 }, // DO-001
|
||||
'demo_meas_nh4_in': { x: 420, y: 220 }, // NH4-IN 0m
|
||||
'demo_meas_nh4_a': { x: 420, y: 280 }, // NH4-A 10m
|
||||
'demo_meas_nh4': { x: 420, y: 340 }, // NH4-001 35m (existing, keep between A & B for distance order — wait, 25m < 35m)
|
||||
'demo_meas_nh4_b': { x: 420, y: 400 }, // NH4-B 25m
|
||||
'demo_meas_nh4_c': { x: 420, y: 460 }, // NH4-C 45m
|
||||
|
||||
// ── LINK OUTS (from measurements) ──
|
||||
'demo_link_meas_dash': { x: 640, y: 130 },
|
||||
'demo_link_nh4_profile_dash': { x: 640, y: 340 },
|
||||
|
||||
// ── REACTOR ──
|
||||
'demo_reactor': { x: 820, y: 220 },
|
||||
|
||||
// ── REACTOR LINK OUTS ──
|
||||
'demo_link_reactor_dash': { x: 1020, y: 180 },
|
||||
'demo_link_overview_reactor_out': { x: 1020, y: 220 },
|
||||
|
||||
// ── SETTLER ──
|
||||
'demo_settler': { x: 1060, y: 320 },
|
||||
|
||||
// ── SHARED LINK OUTS (process + influx) ──
|
||||
'demo_link_influx_out_treatment': { x: 1020, y: 260 },
|
||||
'demo_link_process_out_treatment': { x: 1020, y: 300 },
|
||||
|
||||
// ── EFFLUENT SECTION ──
|
||||
'demo_comment_effluent_meas': { x: 80, y: 520 },
|
||||
'demo_meas_eff_flow': { x: 1280, y: 320 },
|
||||
'demo_meas_eff_do': { x: 1280, y: 380 },
|
||||
'demo_meas_eff_nh4': { x: 1280, y: 440 },
|
||||
'demo_meas_eff_no3': { x: 1280, y: 500 },
|
||||
'demo_meas_eff_tss': { x: 1280, y: 560 },
|
||||
'demo_link_eff_meas_dash': { x: 1500, y: 440 },
|
||||
'demo_link_overview_eff_out': { x: 1500, y: 500 },
|
||||
|
||||
// ── MONSTER (downstream of settler, parallel to effluent meas) ──
|
||||
'demo_monster': { x: 1060, y: 440 },
|
||||
'demo_fn_monster_flow': { x: 400, y: 560 },
|
||||
|
||||
// ── RAS RECYCLE LOOP (below main process) ──
|
||||
'demo_fn_ras_filter': { x: 1060, y: 760 },
|
||||
'demo_pump_ras': { x: 1280, y: 760 },
|
||||
'demo_meas_ft_ras': { x: 1500, y: 760 },
|
||||
'demo_inj_ras_mode': { x: 1280, y: 820 },
|
||||
'demo_inj_ras_speed': { x: 1280, y: 880 },
|
||||
'demo_comment_pressure': { x: 80, y: 740 },
|
||||
|
||||
// ── MERGE COLLECTION (bottom section) ──
|
||||
'demo_comment_merge': { x: 80, y: 960 },
|
||||
'demo_link_merge_west_in': { x: 100, y: 1000 },
|
||||
'demo_link_merge_north_in': { x: 100, y: 1060 },
|
||||
'demo_link_merge_south_in': { x: 100, y: 1120 },
|
||||
'demo_fn_tag_west': { x: 300, y: 1000 },
|
||||
'demo_fn_tag_north': { x: 300, y: 1060 },
|
||||
'demo_fn_tag_south': { x: 300, y: 1120 },
|
||||
'demo_fn_merge_collect': { x: 520, y: 1060 },
|
||||
'demo_link_merge_dash': { x: 720, y: 1020 },
|
||||
'demo_fn_influent_compose': { x: 720, y: 1100 },
|
||||
};
|
||||
|
||||
// Sort NH4 measurements by distance for visual order
|
||||
// NH4-IN=0m, NH4-A=10m, NH4-B=25m, NH4-001=35m, NH4-C=45m
|
||||
// Adjust y to be in distance order:
|
||||
layout['demo_meas_nh4_in'] = { x: 420, y: 220 }; // 0m
|
||||
layout['demo_meas_nh4_a'] = { x: 420, y: 280 }; // 10m
|
||||
layout['demo_meas_nh4_b'] = { x: 420, y: 340 }; // 25m
|
||||
layout['demo_meas_nh4'] = { x: 420, y: 400 }; // 35m
|
||||
layout['demo_meas_nh4_c'] = { x: 420, y: 460 }; // 45m
|
||||
|
||||
let moved = 0;
|
||||
for (const [id, pos] of Object.entries(layout)) {
|
||||
const n = find(id);
|
||||
if (n) {
|
||||
n.x = pos.x;
|
||||
n.y = pos.y;
|
||||
moved++;
|
||||
} else {
|
||||
console.warn(` WARN: node ${id} not found`);
|
||||
}
|
||||
}
|
||||
console.log(`3. Repositioned ${moved} nodes on treatment tab`);
|
||||
|
||||
// ============================================================
|
||||
// WRITE OUTPUT
|
||||
// ============================================================
|
||||
|
||||
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n', 'utf8');
|
||||
console.log(`\nDone. Wrote ${flow.length} nodes to ${flowPath}`);
|
||||
@@ -1,455 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Patch demo-flow.json:
|
||||
* Phase A: Add 4 NH4 measurement nodes + ui-group + ui-chart
|
||||
* Phase B: Add influent composer function node + wire merge collector
|
||||
* Phase C: Fix biomass init on reactor
|
||||
* Phase D: Add RAS pump, flow sensor, 2 injects, filter function + wiring
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const flowPath = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||
|
||||
// Helper: find node by id
|
||||
const findNode = (id) => flow.find(n => n.id === id);
|
||||
|
||||
// ============================================================
|
||||
// PHASE A: Add 4 NH4 measurement nodes + ui-group + ui-chart
|
||||
// ============================================================
|
||||
|
||||
const nh4Measurements = [
|
||||
{
|
||||
id: 'demo_meas_nh4_in',
|
||||
name: 'NH4-IN (Ammonium Inlet)',
|
||||
uuid: 'nh4-in-001',
|
||||
assetTagNumber: 'NH4-IN',
|
||||
distance: 0,
|
||||
distanceDescription: 'reactor inlet',
|
||||
y: 280
|
||||
},
|
||||
{
|
||||
id: 'demo_meas_nh4_a',
|
||||
name: 'NH4-A (Early Aeration)',
|
||||
uuid: 'nh4-a-001',
|
||||
assetTagNumber: 'NH4-A',
|
||||
distance: 10,
|
||||
distanceDescription: 'early aeration zone',
|
||||
y: 320
|
||||
},
|
||||
{
|
||||
id: 'demo_meas_nh4_b',
|
||||
name: 'NH4-B (Mid-Reactor)',
|
||||
uuid: 'nh4-b-001',
|
||||
assetTagNumber: 'NH4-B',
|
||||
distance: 25,
|
||||
distanceDescription: 'mid-reactor',
|
||||
y: 360
|
||||
},
|
||||
{
|
||||
id: 'demo_meas_nh4_c',
|
||||
name: 'NH4-C (Near Outlet)',
|
||||
uuid: 'nh4-c-001',
|
||||
assetTagNumber: 'NH4-C',
|
||||
distance: 45,
|
||||
distanceDescription: 'near outlet',
|
||||
y: 400
|
||||
}
|
||||
];
|
||||
|
||||
for (const m of nh4Measurements) {
|
||||
flow.push({
|
||||
id: m.id,
|
||||
type: 'measurement',
|
||||
z: 'demo_tab_treatment',
|
||||
name: m.name,
|
||||
scaling: true,
|
||||
i_min: 0,
|
||||
i_max: 50,
|
||||
i_offset: 0,
|
||||
o_min: 0,
|
||||
o_max: 50,
|
||||
smooth_method: 'mean',
|
||||
count: 3,
|
||||
simulator: true,
|
||||
uuid: m.uuid,
|
||||
supplier: 'Hach',
|
||||
category: 'sensor',
|
||||
assetType: 'ammonium',
|
||||
model: 'Amtax-sc',
|
||||
unit: 'mg/L',
|
||||
assetTagNumber: m.assetTagNumber,
|
||||
enableLog: false,
|
||||
logLevel: 'error',
|
||||
positionVsParent: 'atEquipment',
|
||||
x: 400,
|
||||
y: m.y,
|
||||
wires: [
|
||||
['demo_link_meas_dash', 'demo_link_process_out_treatment'],
|
||||
['demo_link_influx_out_treatment'],
|
||||
['demo_reactor']
|
||||
],
|
||||
positionIcon: '⊥',
|
||||
hasDistance: true,
|
||||
distance: m.distance,
|
||||
distanceUnit: 'm',
|
||||
distanceDescription: m.distanceDescription
|
||||
});
|
||||
}
|
||||
|
||||
// NH4 profile ui-group
|
||||
flow.push({
|
||||
id: 'demo_ui_grp_nh4_profile',
|
||||
type: 'ui-group',
|
||||
name: 'NH4 Profile Along Reactor',
|
||||
page: 'demo_ui_page_treatment',
|
||||
width: '6',
|
||||
height: '1',
|
||||
order: 6,
|
||||
showTitle: true,
|
||||
className: ''
|
||||
});
|
||||
|
||||
// NH4 profile chart
|
||||
flow.push({
|
||||
id: 'demo_chart_nh4_profile',
|
||||
type: 'ui-chart',
|
||||
z: 'demo_tab_dashboard',
|
||||
group: 'demo_ui_grp_nh4_profile',
|
||||
name: 'NH4 Profile',
|
||||
label: 'NH4 Along Reactor (mg/L)',
|
||||
order: 1,
|
||||
width: '6',
|
||||
height: '5',
|
||||
chartType: 'line',
|
||||
category: 'topic',
|
||||
categoryType: 'msg',
|
||||
xAxisType: 'time',
|
||||
yAxisLabel: 'mg/L',
|
||||
removeOlder: '10',
|
||||
removeOlderUnit: '60',
|
||||
action: 'append',
|
||||
pointShape: 'false',
|
||||
pointRadius: 0,
|
||||
interpolation: 'linear',
|
||||
x: 510,
|
||||
y: 1060,
|
||||
wires: [],
|
||||
showLegend: true,
|
||||
xAxisProperty: '',
|
||||
xAxisPropertyType: 'timestamp',
|
||||
yAxisProperty: 'payload',
|
||||
yAxisPropertyType: 'msg',
|
||||
colors: [
|
||||
'#0094ce',
|
||||
'#FF7F0E',
|
||||
'#2CA02C',
|
||||
'#D62728',
|
||||
'#A347E1',
|
||||
'#D62728',
|
||||
'#FF9896',
|
||||
'#9467BD',
|
||||
'#C5B0D5'
|
||||
],
|
||||
textColor: ['#aaaaaa'],
|
||||
textColorDefault: false,
|
||||
gridColor: ['#333333'],
|
||||
gridColorDefault: false,
|
||||
className: ''
|
||||
});
|
||||
|
||||
// Link out + link in for NH4 profile chart
|
||||
flow.push({
|
||||
id: 'demo_link_nh4_profile_dash',
|
||||
type: 'link out',
|
||||
z: 'demo_tab_treatment',
|
||||
name: '→ NH4 Profile Dashboard',
|
||||
mode: 'link',
|
||||
links: ['demo_link_nh4_profile_dash_in'],
|
||||
x: 620,
|
||||
y: 340
|
||||
});
|
||||
|
||||
flow.push({
|
||||
id: 'demo_link_nh4_profile_dash_in',
|
||||
type: 'link in',
|
||||
z: 'demo_tab_dashboard',
|
||||
name: '← NH4 Profile',
|
||||
links: ['demo_link_nh4_profile_dash'],
|
||||
x: 75,
|
||||
y: 1060,
|
||||
wires: [['demo_fn_nh4_profile_parse']]
|
||||
});
|
||||
|
||||
// Parse function for NH4 profile chart
|
||||
flow.push({
|
||||
id: 'demo_fn_nh4_profile_parse',
|
||||
type: 'function',
|
||||
z: 'demo_tab_dashboard',
|
||||
name: 'Parse NH4 Profile',
|
||||
func: `const p = msg.payload || {};
|
||||
const topic = msg.topic || '';
|
||||
const now = Date.now();
|
||||
const val = Number(p.mAbs);
|
||||
if (!Number.isFinite(val)) return null;
|
||||
|
||||
let label = topic;
|
||||
if (topic.includes('NH4-IN')) label = 'NH4-IN (0m)';
|
||||
else if (topic.includes('NH4-A')) label = 'NH4-A (10m)';
|
||||
else if (topic.includes('NH4-B')) label = 'NH4-B (25m)';
|
||||
else if (topic.includes('NH4-001')) label = 'NH4-001 (35m)';
|
||||
else if (topic.includes('NH4-C')) label = 'NH4-C (45m)';
|
||||
|
||||
return { topic: label, payload: Math.round(val * 100) / 100, timestamp: now };`,
|
||||
outputs: 1,
|
||||
x: 280,
|
||||
y: 1060,
|
||||
wires: [['demo_chart_nh4_profile']]
|
||||
});
|
||||
|
||||
// Wire existing NH4-001 and new NH4 measurements to the profile link out
|
||||
const existingNh4 = findNode('demo_meas_nh4');
|
||||
if (existingNh4) {
|
||||
if (!existingNh4.wires[0].includes('demo_link_nh4_profile_dash')) {
|
||||
existingNh4.wires[0].push('demo_link_nh4_profile_dash');
|
||||
}
|
||||
}
|
||||
for (const m of nh4Measurements) {
|
||||
const node = findNode(m.id);
|
||||
if (node && !node.wires[0].includes('demo_link_nh4_profile_dash')) {
|
||||
node.wires[0].push('demo_link_nh4_profile_dash');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Phase A: Added 4 NH4 measurements + ui-group + chart + wiring');
|
||||
|
||||
// ============================================================
|
||||
// PHASE B: Add influent composer + wire merge collector
|
||||
// ============================================================
|
||||
|
||||
flow.push({
|
||||
id: 'demo_fn_influent_compose',
|
||||
type: 'function',
|
||||
z: 'demo_tab_treatment',
|
||||
name: 'Influent Composer',
|
||||
func: `// Convert merge collector output to Fluent messages for reactor
|
||||
// ASM3: [S_O, S_I, S_S, S_NH, S_N2, S_NO, S_HCO, X_I, X_S, X_H, X_STO, X_A, X_TS]
|
||||
const p = msg.payload || {};
|
||||
const MUNICIPAL = [0.5, 30, 200, 40, 0, 0, 5, 25, 150, 30, 0, 0, 200];
|
||||
const INDUSTRIAL = [0.5, 40, 300, 25, 0, 0, 4, 30, 100, 20, 0, 0, 150];
|
||||
const RESIDENTIAL = [0.5, 25, 180, 45, 0, 0, 5, 20, 130, 25, 0, 0, 175];
|
||||
|
||||
const Fw = (p.west?.netFlow || 0) * 24; // m3/h -> m3/d
|
||||
const Fn = (p.north?.netFlow || 0) * 24;
|
||||
const Fs = (p.south?.netFlow || 0) * 24;
|
||||
|
||||
const msgs = [];
|
||||
if (Fw > 0) msgs.push({ topic: 'Fluent', payload: { inlet: 0, F: Fw, C: MUNICIPAL }});
|
||||
if (Fn > 0) msgs.push({ topic: 'Fluent', payload: { inlet: 1, F: Fn, C: INDUSTRIAL }});
|
||||
if (Fs > 0) msgs.push({ topic: 'Fluent', payload: { inlet: 2, F: Fs, C: RESIDENTIAL }});
|
||||
return [msgs];`,
|
||||
outputs: 1,
|
||||
x: 480,
|
||||
y: 1040,
|
||||
wires: [['demo_reactor']]
|
||||
});
|
||||
|
||||
// Wire merge collector → influent composer (add to existing wires)
|
||||
const mergeCollect = findNode('demo_fn_merge_collect');
|
||||
if (mergeCollect) {
|
||||
if (!mergeCollect.wires[0].includes('demo_fn_influent_compose')) {
|
||||
mergeCollect.wires[0].push('demo_fn_influent_compose');
|
||||
}
|
||||
console.log('Phase B: Wired merge collector → influent composer → reactor');
|
||||
} else {
|
||||
console.error('Phase B: ERROR — demo_fn_merge_collect not found!');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PHASE C: Fix biomass initialization
|
||||
// ============================================================
|
||||
|
||||
const reactor = findNode('demo_reactor');
|
||||
if (reactor) {
|
||||
reactor.X_A_init = 300;
|
||||
reactor.X_H_init = 1500;
|
||||
reactor.X_TS_init = 2500;
|
||||
reactor.S_HCO_init = 8;
|
||||
console.log('Phase C: Updated reactor biomass init values');
|
||||
} else {
|
||||
console.error('Phase C: ERROR — demo_reactor not found!');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PHASE D: Return Activated Sludge
|
||||
// ============================================================
|
||||
|
||||
// D1: RAS pump
|
||||
flow.push({
|
||||
id: 'demo_pump_ras',
|
||||
type: 'rotatingMachine',
|
||||
z: 'demo_tab_treatment',
|
||||
name: 'RAS Pump',
|
||||
speed: '1',
|
||||
startup: '5',
|
||||
warmup: '3',
|
||||
shutdown: '4',
|
||||
cooldown: '2',
|
||||
movementMode: 'dynspeed',
|
||||
machineCurve: '',
|
||||
uuid: 'pump-ras-001',
|
||||
supplier: 'hidrostal',
|
||||
category: 'machine',
|
||||
assetType: 'pump-centrifugal',
|
||||
model: 'hidrostal-RAS',
|
||||
unit: 'm3/h',
|
||||
enableLog: true,
|
||||
logLevel: 'info',
|
||||
positionVsParent: 'downstream',
|
||||
positionIcon: '←',
|
||||
hasDistance: false,
|
||||
distance: 0,
|
||||
distanceUnit: 'm',
|
||||
distanceDescription: '',
|
||||
x: 1000,
|
||||
y: 380,
|
||||
wires: [
|
||||
['demo_link_process_out_treatment'],
|
||||
['demo_link_influx_out_treatment'],
|
||||
['demo_settler']
|
||||
],
|
||||
curveFlowUnit: 'l/s',
|
||||
curvePressureUnit: 'mbar',
|
||||
curvePowerUnit: 'kW'
|
||||
});
|
||||
|
||||
// D2: RAS flow sensor
|
||||
flow.push({
|
||||
id: 'demo_meas_ft_ras',
|
||||
type: 'measurement',
|
||||
z: 'demo_tab_treatment',
|
||||
name: 'FT-RAS (RAS Flow)',
|
||||
scaling: true,
|
||||
i_min: 20,
|
||||
i_max: 80,
|
||||
i_offset: 0,
|
||||
o_min: 20,
|
||||
o_max: 80,
|
||||
smooth_method: 'mean',
|
||||
count: 3,
|
||||
simulator: true,
|
||||
uuid: 'ft-ras-001',
|
||||
supplier: 'Endress+Hauser',
|
||||
category: 'sensor',
|
||||
assetType: 'flow',
|
||||
model: 'Promag-W400',
|
||||
unit: 'm3/h',
|
||||
assetTagNumber: 'FT-RAS',
|
||||
enableLog: false,
|
||||
logLevel: 'error',
|
||||
positionVsParent: 'atEquipment',
|
||||
positionIcon: '⊥',
|
||||
hasDistance: false,
|
||||
distance: 0,
|
||||
distanceUnit: 'm',
|
||||
distanceDescription: '',
|
||||
x: 1200,
|
||||
y: 380,
|
||||
wires: [
|
||||
['demo_link_process_out_treatment'],
|
||||
['demo_link_influx_out_treatment'],
|
||||
['demo_pump_ras']
|
||||
]
|
||||
});
|
||||
|
||||
// D3: Inject to set pump mode
|
||||
flow.push({
|
||||
id: 'demo_inj_ras_mode',
|
||||
type: 'inject',
|
||||
z: 'demo_tab_treatment',
|
||||
name: 'RAS → virtualControl',
|
||||
props: [
|
||||
{ p: 'topic', vt: 'str' },
|
||||
{ p: 'payload', vt: 'str' }
|
||||
],
|
||||
topic: 'setMode',
|
||||
payload: 'virtualControl',
|
||||
payloadType: 'str',
|
||||
once: true,
|
||||
onceDelay: '3',
|
||||
x: 1000,
|
||||
y: 440,
|
||||
wires: [['demo_pump_ras']],
|
||||
repeatType: 'none',
|
||||
crontab: '',
|
||||
repeat: ''
|
||||
});
|
||||
|
||||
// D3: Inject to set pump speed
|
||||
flow.push({
|
||||
id: 'demo_inj_ras_speed',
|
||||
type: 'inject',
|
||||
z: 'demo_tab_treatment',
|
||||
name: 'RAS speed → 50%',
|
||||
props: [
|
||||
{ p: 'topic', vt: 'str' },
|
||||
{ p: 'payload', vt: 'json' }
|
||||
],
|
||||
topic: 'execMovement',
|
||||
payload: '{"source":"auto","action":"setpoint","setpoint":50}',
|
||||
payloadType: 'json',
|
||||
once: true,
|
||||
onceDelay: '4',
|
||||
x: 1000,
|
||||
y: 480,
|
||||
wires: [['demo_pump_ras']],
|
||||
repeatType: 'none',
|
||||
crontab: '',
|
||||
repeat: ''
|
||||
});
|
||||
|
||||
// D4: RAS filter function
|
||||
flow.push({
|
||||
id: 'demo_fn_ras_filter',
|
||||
type: 'function',
|
||||
z: 'demo_tab_treatment',
|
||||
name: 'RAS Filter',
|
||||
func: `// Only pass RAS (inlet 2) from settler to reactor as inlet 3
|
||||
if (msg.topic === 'Fluent' && msg.payload && msg.payload.inlet === 2) {
|
||||
msg.payload.inlet = 3; // reactor inlet 3 = RAS
|
||||
return msg;
|
||||
}
|
||||
return null;`,
|
||||
outputs: 1,
|
||||
x: 1000,
|
||||
y: 320,
|
||||
wires: [['demo_reactor']]
|
||||
});
|
||||
|
||||
// D5: Wire settler Port 0 → RAS filter
|
||||
const settler = findNode('demo_settler');
|
||||
if (settler) {
|
||||
if (!settler.wires[0].includes('demo_fn_ras_filter')) {
|
||||
settler.wires[0].push('demo_fn_ras_filter');
|
||||
}
|
||||
console.log('Phase D: Wired settler → RAS filter → reactor');
|
||||
} else {
|
||||
console.error('Phase D: ERROR — demo_settler not found!');
|
||||
}
|
||||
|
||||
// D5: Update reactor n_inlets: 3 → 4
|
||||
if (reactor) {
|
||||
reactor.n_inlets = 4;
|
||||
console.log('Phase D: Updated reactor n_inlets to 4');
|
||||
}
|
||||
|
||||
console.log('Phase D: Added RAS pump, flow sensor, 2 injects, filter function');
|
||||
|
||||
// ============================================================
|
||||
// WRITE OUTPUT
|
||||
// ============================================================
|
||||
|
||||
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n', 'utf8');
|
||||
console.log(`\nDone. Wrote ${flow.length} nodes to ${flowPath}`);
|
||||
36
scripts/sync-example.sh
Executable file
36
scripts/sync-example.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
# Copy examples/<name>/flow.json into the running Node-RED project's
|
||||
# flow.json. Use this after regenerating flow.json from build_flow.py
|
||||
# when you want the runtime to reload the canonical source.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/sync-example.sh <project-name>
|
||||
#
|
||||
# Example:
|
||||
# scripts/sync-example.sh pumpingstation-complete-example
|
||||
set -e
|
||||
|
||||
NAME="${1:-pumpingstation-complete-example}"
|
||||
SRC="examples/$NAME/flow.json"
|
||||
CONTAINER="evolv-nodered"
|
||||
DST="/data/projects/$NAME/flow.json"
|
||||
|
||||
if [ ! -f "$SRC" ]; then
|
||||
echo "error: $SRC not found (run from EVOLV repo root)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^$CONTAINER$"; then
|
||||
echo "error: $CONTAINER is not running" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Copying $SRC → $CONTAINER:$DST"
|
||||
docker cp "$SRC" "$CONTAINER:$DST"
|
||||
|
||||
echo "Reloading flows..."
|
||||
curl -s -X POST "http://localhost:1880/flows" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Node-RED-Deployment-Type: full" \
|
||||
--data-binary "@$SRC" \
|
||||
-w 'HTTP %{http_code}\n'
|
||||
@@ -1,380 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Step 1: Tab Restructure + Per-tab link-outs
|
||||
* - Creates 4 new tabs (PS West, PS North, PS South, Treatment)
|
||||
* - Renames WWTP tab to "Telemetry / InfluxDB"
|
||||
* - Moves nodes to their new tabs
|
||||
* - Creates per-tab link-out nodes for influx + process
|
||||
* - Rewires nodes to use local link-outs
|
||||
* - Recalculates coordinates for clean layout
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FLOW_PATH = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(FLOW_PATH, 'utf8'));
|
||||
|
||||
const byId = (id) => flow.find(n => n.id === id);
|
||||
|
||||
// =============================================
|
||||
// 1a. Create 4 new tabs
|
||||
// =============================================
|
||||
flow.push(
|
||||
{ id: "demo_tab_ps_west", type: "tab", label: "PS West", disabled: false, info: "Pumping Station West (Urban Catchment - 2 pumps, Level-based)" },
|
||||
{ id: "demo_tab_ps_north", type: "tab", label: "PS North", disabled: false, info: "Pumping Station North (Industrial - 1 pump, Flow-based)" },
|
||||
{ id: "demo_tab_ps_south", type: "tab", label: "PS South", disabled: false, info: "Pumping Station South (Residential - 1 pump, Manual)" },
|
||||
{ id: "demo_tab_treatment", type: "tab", label: "Biological Treatment", disabled: false, info: "Merge point, Reactor, Settler, Effluent Measurements" }
|
||||
);
|
||||
|
||||
// =============================================
|
||||
// 1b. Rename existing WWTP tab
|
||||
// =============================================
|
||||
const wwtpTab = byId("demo_tab_wwtp");
|
||||
wwtpTab.label = "Telemetry / InfluxDB";
|
||||
wwtpTab.info = "InfluxDB write chain, process debug, Grafana dashboard API, shared infrastructure";
|
||||
|
||||
// =============================================
|
||||
// 1c. Move nodes to new tabs
|
||||
// =============================================
|
||||
const moveMap = {
|
||||
// PS West tab
|
||||
"demo_comment_ps": "demo_tab_ps_west",
|
||||
"demo_ps_west": "demo_tab_ps_west",
|
||||
"demo_pump_w1": "demo_tab_ps_west",
|
||||
"demo_pump_w2": "demo_tab_ps_west",
|
||||
"demo_mgc_west": "demo_tab_ps_west",
|
||||
"demo_inj_west_mode": "demo_tab_ps_west",
|
||||
"demo_inj_west_flow": "demo_tab_ps_west",
|
||||
"demo_fn_west_flow_sim": "demo_tab_ps_west",
|
||||
"demo_inj_w1_mode": "demo_tab_ps_west",
|
||||
"demo_inj_w2_mode": "demo_tab_ps_west",
|
||||
"demo_inj_calib_west": "demo_tab_ps_west",
|
||||
"demo_fn_level_to_pressure_w": "demo_tab_ps_west",
|
||||
"demo_meas_pt_w_up": "demo_tab_ps_west",
|
||||
"demo_meas_pt_w_down": "demo_tab_ps_west",
|
||||
"demo_mon_west": "demo_tab_ps_west",
|
||||
"demo_link_ps_west_dash": "demo_tab_ps_west",
|
||||
|
||||
// PS North tab
|
||||
"demo_comment_ps_north": "demo_tab_ps_north",
|
||||
"demo_ps_north": "demo_tab_ps_north",
|
||||
"demo_pump_n1": "demo_tab_ps_north",
|
||||
"demo_inj_north_mode": "demo_tab_ps_north",
|
||||
"demo_inj_north_flow": "demo_tab_ps_north",
|
||||
"demo_fn_north_flow_sim": "demo_tab_ps_north",
|
||||
"demo_inj_n1_mode": "demo_tab_ps_north",
|
||||
"demo_inj_calib_north": "demo_tab_ps_north",
|
||||
"demo_comment_north_outflow": "demo_tab_ps_north",
|
||||
"demo_meas_ft_n1": "demo_tab_ps_north",
|
||||
"demo_fn_level_to_pressure_n": "demo_tab_ps_north",
|
||||
"demo_meas_pt_n_up": "demo_tab_ps_north",
|
||||
"demo_meas_pt_n_down": "demo_tab_ps_north",
|
||||
"demo_mon_north": "demo_tab_ps_north",
|
||||
"demo_link_ps_north_dash": "demo_tab_ps_north",
|
||||
|
||||
// PS South tab
|
||||
"demo_comment_ps_south": "demo_tab_ps_south",
|
||||
"demo_ps_south": "demo_tab_ps_south",
|
||||
"demo_pump_s1": "demo_tab_ps_south",
|
||||
"demo_inj_south_mode": "demo_tab_ps_south",
|
||||
"demo_inj_south_flow": "demo_tab_ps_south",
|
||||
"demo_fn_south_flow_sim": "demo_tab_ps_south",
|
||||
"demo_inj_s1_mode": "demo_tab_ps_south",
|
||||
"demo_inj_calib_south": "demo_tab_ps_south",
|
||||
"demo_fn_level_to_pressure_s": "demo_tab_ps_south",
|
||||
"demo_meas_pt_s_up": "demo_tab_ps_south",
|
||||
"demo_meas_pt_s_down": "demo_tab_ps_south",
|
||||
"demo_mon_south": "demo_tab_ps_south",
|
||||
"demo_link_ps_south_dash": "demo_tab_ps_south",
|
||||
|
||||
// Treatment tab
|
||||
"demo_comment_treatment": "demo_tab_treatment",
|
||||
"demo_meas_flow": "demo_tab_treatment",
|
||||
"demo_meas_do": "demo_tab_treatment",
|
||||
"demo_meas_nh4": "demo_tab_treatment",
|
||||
"demo_reactor": "demo_tab_treatment",
|
||||
"demo_inj_reactor_tick": "demo_tab_treatment",
|
||||
"demo_settler": "demo_tab_treatment",
|
||||
"demo_monster": "demo_tab_treatment",
|
||||
"demo_inj_monster_flow": "demo_tab_treatment",
|
||||
"demo_fn_monster_flow": "demo_tab_treatment",
|
||||
"demo_comment_effluent_meas": "demo_tab_treatment",
|
||||
"demo_meas_eff_flow": "demo_tab_treatment",
|
||||
"demo_meas_eff_do": "demo_tab_treatment",
|
||||
"demo_meas_eff_nh4": "demo_tab_treatment",
|
||||
"demo_meas_eff_no3": "demo_tab_treatment",
|
||||
"demo_meas_eff_tss": "demo_tab_treatment",
|
||||
"demo_comment_pressure": "demo_tab_treatment",
|
||||
"demo_link_reactor_dash": "demo_tab_treatment",
|
||||
"demo_link_meas_dash": "demo_tab_treatment",
|
||||
"demo_link_eff_meas_dash": "demo_tab_treatment"
|
||||
};
|
||||
|
||||
for (const [nodeId, tabId] of Object.entries(moveMap)) {
|
||||
const node = byId(nodeId);
|
||||
if (node) {
|
||||
node.z = tabId;
|
||||
} else {
|
||||
console.warn(`WARNING: Node ${nodeId} not found for move`);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 1c-coords. Recalculate coordinates per tab
|
||||
// =============================================
|
||||
|
||||
// PS West layout (2 pumps + MGC)
|
||||
const psWestCoords = {
|
||||
"demo_comment_ps": { x: 340, y: 40 },
|
||||
"demo_inj_calib_west": { x: 120, y: 80 },
|
||||
"demo_inj_w1_mode": { x: 120, y: 120 },
|
||||
"demo_inj_west_mode": { x: 120, y: 200 },
|
||||
"demo_inj_west_flow": { x: 120, y: 240 },
|
||||
"demo_inj_w2_mode": { x: 120, y: 320 },
|
||||
"demo_fn_west_flow_sim": { x: 360, y: 240 },
|
||||
"demo_pump_w1": { x: 600, y: 120 },
|
||||
"demo_pump_w2": { x: 600, y: 320 },
|
||||
"demo_mgc_west": { x: 600, y: 220 },
|
||||
"demo_ps_west": { x: 860, y: 220 },
|
||||
"demo_fn_level_to_pressure_w": { x: 360, y: 420 },
|
||||
"demo_meas_pt_w_up": { x: 560, y: 420 },
|
||||
"demo_meas_pt_w_down": { x: 560, y: 480 },
|
||||
"demo_mon_west": { x: 1080, y: 160 },
|
||||
"demo_link_ps_west_dash": { x: 1080, y: 220 },
|
||||
};
|
||||
|
||||
// PS North layout (1 pump, no MGC)
|
||||
const psNorthCoords = {
|
||||
"demo_comment_ps_north": { x: 340, y: 40 },
|
||||
"demo_inj_calib_north": { x: 120, y: 80 },
|
||||
"demo_inj_n1_mode": { x: 120, y: 120 },
|
||||
"demo_inj_north_mode": { x: 120, y: 200 },
|
||||
"demo_inj_north_flow": { x: 120, y: 240 },
|
||||
"demo_fn_north_flow_sim": { x: 360, y: 240 },
|
||||
"demo_pump_n1": { x: 600, y: 120 },
|
||||
"demo_ps_north": { x: 860, y: 200 },
|
||||
"demo_comment_north_outflow":{ x: 200, y: 320 },
|
||||
"demo_meas_ft_n1": { x: 560, y: 340 },
|
||||
"demo_fn_level_to_pressure_n":{ x: 360, y: 420 },
|
||||
"demo_meas_pt_n_up": { x: 560, y: 420 },
|
||||
"demo_meas_pt_n_down": { x: 560, y: 480 },
|
||||
"demo_mon_north": { x: 1080, y: 140 },
|
||||
"demo_link_ps_north_dash": { x: 1080, y: 200 },
|
||||
};
|
||||
|
||||
// PS South layout (1 pump, no MGC)
|
||||
const psSouthCoords = {
|
||||
"demo_comment_ps_south": { x: 340, y: 40 },
|
||||
"demo_inj_calib_south": { x: 120, y: 80 },
|
||||
"demo_inj_s1_mode": { x: 120, y: 120 },
|
||||
"demo_inj_south_mode": { x: 120, y: 200 },
|
||||
"demo_inj_south_flow": { x: 120, y: 240 },
|
||||
"demo_fn_south_flow_sim": { x: 360, y: 240 },
|
||||
"demo_pump_s1": { x: 600, y: 120 },
|
||||
"demo_ps_south": { x: 860, y: 200 },
|
||||
"demo_fn_level_to_pressure_s":{ x: 360, y: 380 },
|
||||
"demo_meas_pt_s_up": { x: 560, y: 380 },
|
||||
"demo_meas_pt_s_down": { x: 560, y: 440 },
|
||||
"demo_mon_south": { x: 1080, y: 140 },
|
||||
"demo_link_ps_south_dash": { x: 1080, y: 200 },
|
||||
};
|
||||
|
||||
// Treatment layout
|
||||
const treatmentCoords = {
|
||||
"demo_comment_treatment": { x: 200, y: 40 },
|
||||
"demo_meas_flow": { x: 400, y: 120 },
|
||||
"demo_meas_do": { x: 400, y: 180 },
|
||||
"demo_meas_nh4": { x: 400, y: 240 },
|
||||
"demo_inj_reactor_tick": { x: 600, y: 80 },
|
||||
"demo_reactor": { x: 800, y: 180 },
|
||||
"demo_settler": { x: 800, y: 320 },
|
||||
"demo_monster": { x: 800, y: 420 },
|
||||
"demo_inj_monster_flow": { x: 560, y: 420 },
|
||||
"demo_fn_monster_flow": { x: 660, y: 460 },
|
||||
"demo_comment_effluent_meas":{ x: 200, y: 520 },
|
||||
"demo_meas_eff_flow": { x: 400, y: 560 },
|
||||
"demo_meas_eff_do": { x: 400, y: 620 },
|
||||
"demo_meas_eff_nh4": { x: 400, y: 680 },
|
||||
"demo_meas_eff_no3": { x: 400, y: 740 },
|
||||
"demo_meas_eff_tss": { x: 400, y: 800 },
|
||||
"demo_comment_pressure": { x: 200, y: 860 },
|
||||
"demo_link_reactor_dash": { x: 1020, y: 180 },
|
||||
"demo_link_meas_dash": { x: 620, y: 180 },
|
||||
"demo_link_eff_meas_dash": { x: 620, y: 620 },
|
||||
};
|
||||
|
||||
// Apply coordinates
|
||||
for (const [nodeId, coords] of Object.entries({...psWestCoords, ...psNorthCoords, ...psSouthCoords, ...treatmentCoords})) {
|
||||
const node = byId(nodeId);
|
||||
if (node) {
|
||||
node.x = coords.x;
|
||||
node.y = coords.y;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 1d. Create per-tab link-out nodes
|
||||
// =============================================
|
||||
|
||||
// Determine which tab each moved node belongs to
|
||||
const tabForNode = {};
|
||||
for (const n of flow) {
|
||||
if (n.z) tabForNode[n.id] = n.z;
|
||||
}
|
||||
|
||||
// Map from tab → influx link-out ID
|
||||
const influxLinkOutMap = {
|
||||
"demo_tab_ps_west": "demo_link_influx_out_west",
|
||||
"demo_tab_ps_north": "demo_link_influx_out_north",
|
||||
"demo_tab_ps_south": "demo_link_influx_out_south",
|
||||
"demo_tab_treatment": "demo_link_influx_out_treatment",
|
||||
};
|
||||
|
||||
// Map from tab → process link-out ID
|
||||
const processLinkOutMap = {
|
||||
"demo_tab_ps_west": "demo_link_process_out_west",
|
||||
"demo_tab_ps_north": "demo_link_process_out_north",
|
||||
"demo_tab_ps_south": "demo_link_process_out_south",
|
||||
"demo_tab_treatment": "demo_link_process_out_treatment",
|
||||
};
|
||||
|
||||
// Link-out node positions per tab
|
||||
const linkOutPositions = {
|
||||
"demo_tab_ps_west": { influx: { x: 1080, y: 280 }, process: { x: 1080, y: 320 } },
|
||||
"demo_tab_ps_north": { influx: { x: 1080, y: 260 }, process: { x: 1080, y: 300 } },
|
||||
"demo_tab_ps_south": { influx: { x: 1080, y: 260 }, process: { x: 1080, y: 300 } },
|
||||
"demo_tab_treatment": { influx: { x: 1020, y: 280 }, process: { x: 1020, y: 320 } },
|
||||
};
|
||||
|
||||
// Create influx link-out nodes
|
||||
for (const [tabId, nodeId] of Object.entries(influxLinkOutMap)) {
|
||||
const pos = linkOutPositions[tabId].influx;
|
||||
flow.push({
|
||||
id: nodeId,
|
||||
type: "link out",
|
||||
z: tabId,
|
||||
name: "→ InfluxDB",
|
||||
mode: "link",
|
||||
links: ["demo_link_influx_in"],
|
||||
x: pos.x,
|
||||
y: pos.y
|
||||
});
|
||||
}
|
||||
|
||||
// Create process link-out nodes
|
||||
for (const [tabId, nodeId] of Object.entries(processLinkOutMap)) {
|
||||
const pos = linkOutPositions[tabId].process;
|
||||
flow.push({
|
||||
id: nodeId,
|
||||
type: "link out",
|
||||
z: tabId,
|
||||
name: "→ Process debug",
|
||||
mode: "link",
|
||||
links: ["demo_link_process_in"],
|
||||
x: pos.x,
|
||||
y: pos.y
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 1d-rewire. Rewire nodes to use local link-outs
|
||||
// =============================================
|
||||
|
||||
// For every node that references "demo_link_influx_out" or "demo_link_process_out"
|
||||
// in its wires, replace with the per-tab version
|
||||
for (const node of flow) {
|
||||
if (!node.wires || !node.z) continue;
|
||||
const tab = node.z;
|
||||
const localInflux = influxLinkOutMap[tab];
|
||||
const localProcess = processLinkOutMap[tab];
|
||||
|
||||
for (let portIdx = 0; portIdx < node.wires.length; portIdx++) {
|
||||
for (let wireIdx = 0; wireIdx < node.wires[portIdx].length; wireIdx++) {
|
||||
if (node.wires[portIdx][wireIdx] === "demo_link_influx_out" && localInflux) {
|
||||
node.wires[portIdx][wireIdx] = localInflux;
|
||||
}
|
||||
if (node.wires[portIdx][wireIdx] === "demo_link_process_out" && localProcess) {
|
||||
node.wires[portIdx][wireIdx] = localProcess;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the link-in nodes to reference all new link-out IDs
|
||||
const influxIn = byId("demo_link_influx_in");
|
||||
influxIn.links = Object.values(influxLinkOutMap);
|
||||
// Also keep the old one if any nodes on the telemetry tab still reference it
|
||||
// (the dashapi, telemetry nodes that stayed on demo_tab_wwtp)
|
||||
influxIn.links.push("demo_link_influx_out");
|
||||
|
||||
const processIn = byId("demo_link_process_in");
|
||||
processIn.links = Object.values(processLinkOutMap);
|
||||
processIn.links.push("demo_link_process_out");
|
||||
|
||||
// Keep old link-out nodes on telemetry tab (they may still be needed
|
||||
// by nodes that remain there, like dashapi)
|
||||
// Update their links arrays too
|
||||
const oldInfluxOut = byId("demo_link_influx_out");
|
||||
if (oldInfluxOut) {
|
||||
oldInfluxOut.links = ["demo_link_influx_in"];
|
||||
// Move to bottom of telemetry tab
|
||||
oldInfluxOut.x = 1135;
|
||||
oldInfluxOut.y = 500;
|
||||
}
|
||||
|
||||
const oldProcessOut = byId("demo_link_process_out");
|
||||
if (oldProcessOut) {
|
||||
oldProcessOut.links = ["demo_link_process_in"];
|
||||
oldProcessOut.x = 1135;
|
||||
oldProcessOut.y = 540;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Validate
|
||||
// =============================================
|
||||
const tabCounts = {};
|
||||
for (const n of flow) {
|
||||
if (n.z) {
|
||||
tabCounts[n.z] = (tabCounts[n.z] || 0) + 1;
|
||||
}
|
||||
}
|
||||
console.log('Nodes per tab:', JSON.stringify(tabCounts, null, 2));
|
||||
console.log('Total nodes:', flow.length);
|
||||
|
||||
// Check for broken wire references
|
||||
const allIds = new Set(flow.map(n => n.id));
|
||||
let brokenWires = 0;
|
||||
for (const n of flow) {
|
||||
if (!n.wires) continue;
|
||||
for (const port of n.wires) {
|
||||
for (const target of port) {
|
||||
if (!allIds.has(target)) {
|
||||
console.warn(`BROKEN WIRE: ${n.id} → ${target}`);
|
||||
brokenWires++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (brokenWires === 0) console.log('All wire references valid ✓');
|
||||
|
||||
// Check link-in/link-out pairing
|
||||
for (const n of flow) {
|
||||
if (n.type === 'link out' && n.links) {
|
||||
for (const linkTarget of n.links) {
|
||||
if (!allIds.has(linkTarget)) {
|
||||
console.warn(`BROKEN LINK: ${n.id} links to missing ${linkTarget}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (n.type === 'link in' && n.links) {
|
||||
for (const linkSource of n.links) {
|
||||
if (!allIds.has(linkSource)) {
|
||||
console.warn(`BROKEN LINK: ${n.id} expects link from missing ${linkSource}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write
|
||||
fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
|
||||
console.log(`\nWrote ${FLOW_PATH} (${flow.length} nodes)`);
|
||||
@@ -1,219 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Step 2: Merge Collection Point
|
||||
* - Adds link-out from each PS tab to merge on treatment tab
|
||||
* - Creates link-in, tag, collect, and dashboard link-out nodes on treatment
|
||||
* - Wires PS outputs through merge to feed reactor
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FLOW_PATH = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(FLOW_PATH, 'utf8'));
|
||||
|
||||
const byId = (id) => flow.find(n => n.id === id);
|
||||
|
||||
// =============================================
|
||||
// 2a. Link-out nodes on each PS tab
|
||||
// =============================================
|
||||
flow.push(
|
||||
{
|
||||
id: "demo_link_merge_west_out",
|
||||
type: "link out",
|
||||
z: "demo_tab_ps_west",
|
||||
name: "→ Merge (West)",
|
||||
mode: "link",
|
||||
links: ["demo_link_merge_west_in"],
|
||||
x: 1080, y: 360
|
||||
},
|
||||
{
|
||||
id: "demo_link_merge_north_out",
|
||||
type: "link out",
|
||||
z: "demo_tab_ps_north",
|
||||
name: "→ Merge (North)",
|
||||
mode: "link",
|
||||
links: ["demo_link_merge_north_in"],
|
||||
x: 1080, y: 340
|
||||
},
|
||||
{
|
||||
id: "demo_link_merge_south_out",
|
||||
type: "link out",
|
||||
z: "demo_tab_ps_south",
|
||||
name: "→ Merge (South)",
|
||||
mode: "link",
|
||||
links: ["demo_link_merge_south_in"],
|
||||
x: 1080, y: 340
|
||||
}
|
||||
);
|
||||
|
||||
// Add merge link-outs to each PS node's wires[0]
|
||||
const psWest = byId("demo_ps_west");
|
||||
psWest.wires[0].push("demo_link_merge_west_out");
|
||||
|
||||
const psNorth = byId("demo_ps_north");
|
||||
psNorth.wires[0].push("demo_link_merge_north_out");
|
||||
|
||||
const psSouth = byId("demo_ps_south");
|
||||
psSouth.wires[0].push("demo_link_merge_south_out");
|
||||
|
||||
// =============================================
|
||||
// 2b. Merge nodes on Treatment tab
|
||||
// =============================================
|
||||
|
||||
// Link-in nodes
|
||||
flow.push(
|
||||
{
|
||||
id: "demo_link_merge_west_in",
|
||||
type: "link in",
|
||||
z: "demo_tab_treatment",
|
||||
name: "← PS West",
|
||||
links: ["demo_link_merge_west_out"],
|
||||
x: 100, y: 920,
|
||||
wires: [["demo_fn_tag_west"]]
|
||||
},
|
||||
{
|
||||
id: "demo_link_merge_north_in",
|
||||
type: "link in",
|
||||
z: "demo_tab_treatment",
|
||||
name: "← PS North",
|
||||
links: ["demo_link_merge_north_out"],
|
||||
x: 100, y: 980,
|
||||
wires: [["demo_fn_tag_north"]]
|
||||
},
|
||||
{
|
||||
id: "demo_link_merge_south_in",
|
||||
type: "link in",
|
||||
z: "demo_tab_treatment",
|
||||
name: "← PS South",
|
||||
links: ["demo_link_merge_south_out"],
|
||||
x: 100, y: 1040,
|
||||
wires: [["demo_fn_tag_south"]]
|
||||
}
|
||||
);
|
||||
|
||||
// Tag functions
|
||||
flow.push(
|
||||
{
|
||||
id: "demo_fn_tag_west",
|
||||
type: "function",
|
||||
z: "demo_tab_treatment",
|
||||
name: "Tag: west",
|
||||
func: "msg._psSource = 'west';\nreturn msg;",
|
||||
outputs: 1,
|
||||
x: 280, y: 920,
|
||||
wires: [["demo_fn_merge_collect"]]
|
||||
},
|
||||
{
|
||||
id: "demo_fn_tag_north",
|
||||
type: "function",
|
||||
z: "demo_tab_treatment",
|
||||
name: "Tag: north",
|
||||
func: "msg._psSource = 'north';\nreturn msg;",
|
||||
outputs: 1,
|
||||
x: 280, y: 980,
|
||||
wires: [["demo_fn_merge_collect"]]
|
||||
},
|
||||
{
|
||||
id: "demo_fn_tag_south",
|
||||
type: "function",
|
||||
z: "demo_tab_treatment",
|
||||
name: "Tag: south",
|
||||
func: "msg._psSource = 'south';\nreturn msg;",
|
||||
outputs: 1,
|
||||
x: 280, y: 1040,
|
||||
wires: [["demo_fn_merge_collect"]]
|
||||
}
|
||||
);
|
||||
|
||||
// Merge collect function
|
||||
flow.push({
|
||||
id: "demo_fn_merge_collect",
|
||||
type: "function",
|
||||
z: "demo_tab_treatment",
|
||||
name: "Merge Collector",
|
||||
func: `// Cache each PS output by _psSource tag, compute totals
|
||||
const p = msg.payload || {};
|
||||
const ps = msg._psSource;
|
||||
const cache = flow.get('merge_cache') || { west: {}, north: {}, south: {} };
|
||||
const keys = Object.keys(p);
|
||||
const pick = (prefix) => { const k = keys.find(k => k.startsWith(prefix)); return k ? Number(p[k]) : null; };
|
||||
|
||||
if (ps && cache[ps]) {
|
||||
const nf = pick('netFlowRate.predicted'); if (nf !== null) cache[ps].netFlow = nf;
|
||||
const fp = pick('volumePercent.predicted'); if (fp !== null) cache[ps].fillPct = fp;
|
||||
cache[ps].direction = p.direction || cache[ps].direction;
|
||||
cache[ps].ts = Date.now();
|
||||
}
|
||||
flow.set('merge_cache', cache);
|
||||
|
||||
const totalFlow = (cache.west.netFlow||0) + (cache.north.netFlow||0) + (cache.south.netFlow||0);
|
||||
const avgFill = ((cache.west.fillPct||0) + (cache.north.fillPct||0) + (cache.south.fillPct||0)) / 3;
|
||||
|
||||
return {
|
||||
topic: 'merge_combined_influent',
|
||||
payload: { totalInfluentFlow: +totalFlow.toFixed(1), avgFillPercent: +avgFill.toFixed(1),
|
||||
west: cache.west, north: cache.north, south: cache.south }
|
||||
};`,
|
||||
outputs: 1,
|
||||
x: 480, y: 980,
|
||||
wires: [["demo_link_merge_dash"]]
|
||||
});
|
||||
|
||||
// Dashboard link-out for merge data
|
||||
flow.push({
|
||||
id: "demo_link_merge_dash",
|
||||
type: "link out",
|
||||
z: "demo_tab_treatment",
|
||||
name: "→ Merge Dashboard",
|
||||
mode: "link",
|
||||
links: ["demo_link_merge_dash_in"],
|
||||
x: 680, y: 980
|
||||
});
|
||||
|
||||
// Create a comment for the merge section
|
||||
flow.push({
|
||||
id: "demo_comment_merge",
|
||||
type: "comment",
|
||||
z: "demo_tab_treatment",
|
||||
name: "=== MERGE COLLECTION POINT ===",
|
||||
info: "Combines output from all 3 pumping stations",
|
||||
x: 200, y: 880
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Validate
|
||||
// =============================================
|
||||
const allIds = new Set(flow.map(n => n.id));
|
||||
let brokenWires = 0;
|
||||
for (const n of flow) {
|
||||
if (!n.wires) continue;
|
||||
for (const port of n.wires) {
|
||||
for (const target of port) {
|
||||
if (!allIds.has(target)) {
|
||||
console.warn(`BROKEN WIRE: ${n.id} → ${target}`);
|
||||
brokenWires++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of flow) {
|
||||
if (n.type === 'link out' && n.links) {
|
||||
for (const lt of n.links) {
|
||||
if (!allIds.has(lt)) console.warn(`BROKEN LINK: ${n.id} links to missing ${lt}`);
|
||||
}
|
||||
}
|
||||
if (n.type === 'link in' && n.links) {
|
||||
for (const ls of n.links) {
|
||||
if (!allIds.has(ls)) console.warn(`BROKEN LINK: ${n.id} expects link from missing ${ls}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (brokenWires === 0) console.log('All wire references valid ✓');
|
||||
|
||||
console.log('Total nodes:', flow.length);
|
||||
|
||||
// Write
|
||||
fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
|
||||
console.log(`Wrote ${FLOW_PATH}`);
|
||||
@@ -1,583 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Step 3: Overview Dashboard Page + KPI Gauges
|
||||
* - Creates overview page with chain visualization
|
||||
* - Adds KPI gauges (Total Flow, DO, TSS, NH4)
|
||||
* - Link-in nodes to feed overview from merge + reactor + effluent data
|
||||
* - Reorders all page navigation
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FLOW_PATH = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(FLOW_PATH, 'utf8'));
|
||||
|
||||
const byId = (id) => flow.find(n => n.id === id);
|
||||
|
||||
// =============================================
|
||||
// 3a. New config nodes
|
||||
// =============================================
|
||||
|
||||
// Overview page
|
||||
flow.push({
|
||||
id: "demo_ui_page_overview",
|
||||
type: "ui-page",
|
||||
name: "Plant Overview",
|
||||
ui: "demo_ui_base",
|
||||
path: "/overview",
|
||||
icon: "dashboard",
|
||||
layout: "grid",
|
||||
theme: "demo_ui_theme",
|
||||
breakpoints: [{ name: "Default", px: "0", cols: "12" }],
|
||||
order: 0,
|
||||
className: ""
|
||||
});
|
||||
|
||||
// Overview groups
|
||||
flow.push(
|
||||
{
|
||||
id: "demo_ui_grp_overview_chain",
|
||||
type: "ui-group",
|
||||
name: "Process Chain",
|
||||
page: "demo_ui_page_overview",
|
||||
width: "12",
|
||||
height: "1",
|
||||
order: 1,
|
||||
showTitle: true,
|
||||
className: ""
|
||||
},
|
||||
{
|
||||
id: "demo_ui_grp_overview_kpi",
|
||||
type: "ui-group",
|
||||
name: "Key Indicators",
|
||||
page: "demo_ui_page_overview",
|
||||
width: "12",
|
||||
height: "1",
|
||||
order: 2,
|
||||
showTitle: true,
|
||||
className: ""
|
||||
}
|
||||
);
|
||||
|
||||
// =============================================
|
||||
// 3b. Chain visualization - link-in nodes on dashboard tab
|
||||
// =============================================
|
||||
|
||||
// Link-in for merge data (this is what step 2's demo_link_merge_dash links to)
|
||||
flow.push({
|
||||
id: "demo_link_merge_dash_in",
|
||||
type: "link in",
|
||||
z: "demo_tab_dashboard",
|
||||
name: "← Merge Data",
|
||||
links: ["demo_link_merge_dash"],
|
||||
x: 75, y: 960,
|
||||
wires: [["demo_fn_overview_parse"]]
|
||||
});
|
||||
|
||||
// We also need reactor and effluent data for the overview.
|
||||
// Create link-out nodes on treatment tab for overview data
|
||||
flow.push(
|
||||
{
|
||||
id: "demo_link_overview_reactor_out",
|
||||
type: "link out",
|
||||
z: "demo_tab_treatment",
|
||||
name: "→ Overview (Reactor)",
|
||||
mode: "link",
|
||||
links: ["demo_link_overview_reactor_in"],
|
||||
x: 1020, y: 220
|
||||
},
|
||||
{
|
||||
id: "demo_link_overview_reactor_in",
|
||||
type: "link in",
|
||||
z: "demo_tab_dashboard",
|
||||
name: "← Reactor (Overview)",
|
||||
links: ["demo_link_overview_reactor_out"],
|
||||
x: 75, y: 1020,
|
||||
wires: [["demo_fn_overview_reactor_parse"]]
|
||||
}
|
||||
);
|
||||
|
||||
// Add overview reactor link-out to reactor's wires[0]
|
||||
const reactor = byId("demo_reactor");
|
||||
reactor.wires[0].push("demo_link_overview_reactor_out");
|
||||
|
||||
// Effluent measurements link for overview KPIs
|
||||
flow.push(
|
||||
{
|
||||
id: "demo_link_overview_eff_out",
|
||||
type: "link out",
|
||||
z: "demo_tab_treatment",
|
||||
name: "→ Overview (Effluent)",
|
||||
mode: "link",
|
||||
links: ["demo_link_overview_eff_in"],
|
||||
x: 620, y: 660
|
||||
},
|
||||
{
|
||||
id: "demo_link_overview_eff_in",
|
||||
type: "link in",
|
||||
z: "demo_tab_dashboard",
|
||||
name: "← Effluent (Overview)",
|
||||
links: ["demo_link_overview_eff_out"],
|
||||
x: 75, y: 1080,
|
||||
wires: [["demo_fn_overview_eff_parse"]]
|
||||
}
|
||||
);
|
||||
|
||||
// Add overview eff link-out to effluent measurement nodes wires[0]
|
||||
// TSS and NH4 are the key effluent quality indicators
|
||||
const effTss = byId("demo_meas_eff_tss");
|
||||
effTss.wires[0].push("demo_link_overview_eff_out");
|
||||
const effNh4 = byId("demo_meas_eff_nh4");
|
||||
effNh4.wires[0].push("demo_link_overview_eff_out");
|
||||
|
||||
// =============================================
|
||||
// 3b. Parse functions for overview
|
||||
// =============================================
|
||||
|
||||
// Parse merge data for chain visualization + total flow gauge
|
||||
flow.push({
|
||||
id: "demo_fn_overview_parse",
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: "Parse Overview (Merge)",
|
||||
func: `const p = msg.payload || {};
|
||||
const now = Date.now();
|
||||
|
||||
// Store in flow context for the template
|
||||
flow.set('overview_merge', p);
|
||||
|
||||
// Output 1: chain vis data, Output 2: total flow gauge
|
||||
return [
|
||||
{ topic: 'overview_chain', payload: p },
|
||||
p.totalInfluentFlow !== undefined ? { topic: 'Total Influent Flow', payload: p.totalInfluentFlow } : null
|
||||
];`,
|
||||
outputs: 2,
|
||||
x: 280, y: 960,
|
||||
wires: [
|
||||
["demo_overview_template"],
|
||||
["demo_gauge_overview_flow"]
|
||||
]
|
||||
});
|
||||
|
||||
// Parse reactor data for overview
|
||||
flow.push({
|
||||
id: "demo_fn_overview_reactor_parse",
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: "Parse Overview (Reactor)",
|
||||
func: `const p = msg.payload || {};
|
||||
if (!p.C || !Array.isArray(p.C)) return null;
|
||||
|
||||
flow.set('overview_reactor', p);
|
||||
|
||||
// Output: DO gauge value
|
||||
return { topic: 'Reactor DO', payload: Math.round(p.C[0]*100)/100 };`,
|
||||
outputs: 1,
|
||||
x: 280, y: 1020,
|
||||
wires: [["demo_gauge_overview_do"]]
|
||||
});
|
||||
|
||||
// Parse effluent data for overview KPIs
|
||||
flow.push({
|
||||
id: "demo_fn_overview_eff_parse",
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: "Parse Overview (Effluent)",
|
||||
func: `const p = msg.payload || {};
|
||||
const topic = msg.topic || '';
|
||||
const val = Number(p.mAbs);
|
||||
if (!Number.isFinite(val)) return null;
|
||||
|
||||
// Route to appropriate gauge based on measurement type
|
||||
if (topic.includes('TSS') || topic.includes('tss')) {
|
||||
return [{ topic: 'Effluent TSS', payload: Math.round(val*100)/100 }, null];
|
||||
}
|
||||
if (topic.includes('NH4') || topic.includes('ammonium')) {
|
||||
return [null, { topic: 'Effluent NH4', payload: Math.round(val*100)/100 }];
|
||||
}
|
||||
return [null, null];`,
|
||||
outputs: 2,
|
||||
x: 280, y: 1080,
|
||||
wires: [
|
||||
["demo_gauge_overview_tss"],
|
||||
["demo_gauge_overview_nh4"]
|
||||
]
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// 3b. Chain visualization template
|
||||
// =============================================
|
||||
flow.push({
|
||||
id: "demo_overview_template",
|
||||
type: "ui-template",
|
||||
z: "demo_tab_dashboard",
|
||||
group: "demo_ui_grp_overview_chain",
|
||||
name: "Process Chain Diagram",
|
||||
order: 1,
|
||||
width: "12",
|
||||
height: "6",
|
||||
head: "",
|
||||
format: `<template>
|
||||
<div class="chain-container">
|
||||
<svg viewBox="0 0 900 280" class="chain-svg">
|
||||
<!-- PS West -->
|
||||
<g @click="navigateTo('/ps-west')" class="chain-block clickable">
|
||||
<rect x="20" y="20" width="160" height="80" rx="8" :fill="blockColor(merge?.west)"/>
|
||||
<text x="100" y="50" class="block-title">PS West</text>
|
||||
<text x="100" y="70" class="block-value">{{ formatPct(merge?.west?.fillPct) }}</text>
|
||||
<text x="100" y="86" class="block-sub">{{ formatDir(merge?.west?.direction) }}</text>
|
||||
</g>
|
||||
|
||||
<!-- PS North -->
|
||||
<g @click="navigateTo('/ps-north')" class="chain-block clickable">
|
||||
<rect x="20" y="120" width="160" height="80" rx="8" :fill="blockColor(merge?.north)"/>
|
||||
<text x="100" y="150" class="block-title">PS North</text>
|
||||
<text x="100" y="170" class="block-value">{{ formatPct(merge?.north?.fillPct) }}</text>
|
||||
<text x="100" y="186" class="block-sub">{{ formatDir(merge?.north?.direction) }}</text>
|
||||
</g>
|
||||
|
||||
<!-- PS South -->
|
||||
<g @click="navigateTo('/ps-south')" class="chain-block clickable">
|
||||
<rect x="20" y="220" width="160" height="80" rx="8" :fill="blockColor(merge?.south)"/>
|
||||
<text x="100" y="250" class="block-title">PS South</text>
|
||||
<text x="100" y="270" class="block-value">{{ formatPct(merge?.south?.fillPct) }}</text>
|
||||
<text x="100" y="286" class="block-sub">{{ formatDir(merge?.south?.direction) }}</text>
|
||||
</g>
|
||||
|
||||
<!-- Merge arrows -->
|
||||
<line x1="180" y1="60" x2="260" y2="160" class="chain-arrow"/>
|
||||
<line x1="180" y1="160" x2="260" y2="160" class="chain-arrow"/>
|
||||
<line x1="180" y1="260" x2="260" y2="160" class="chain-arrow"/>
|
||||
|
||||
<!-- Merge point -->
|
||||
<g class="chain-block">
|
||||
<rect x="260" y="120" width="120" height="80" rx="8" fill="#0f3460"/>
|
||||
<text x="320" y="150" class="block-title">Merge</text>
|
||||
<text x="320" y="170" class="block-value">{{ formatFlow(merge?.totalInfluentFlow) }}</text>
|
||||
<text x="320" y="186" class="block-sub">m\\u00b3/h total</text>
|
||||
</g>
|
||||
|
||||
<!-- Arrow merge → reactor -->
|
||||
<line x1="380" y1="160" x2="420" y2="160" class="chain-arrow"/>
|
||||
|
||||
<!-- Reactor -->
|
||||
<g @click="navigateTo('/treatment')" class="chain-block clickable">
|
||||
<rect x="420" y="120" width="140" height="80" rx="8" :fill="reactorColor"/>
|
||||
<text x="490" y="150" class="block-title">Reactor</text>
|
||||
<text x="490" y="170" class="block-value">DO: {{ reactorDO }}</text>
|
||||
<text x="490" y="186" class="block-sub">mg/L</text>
|
||||
</g>
|
||||
|
||||
<!-- Arrow reactor → settler -->
|
||||
<line x1="560" y1="160" x2="600" y2="160" class="chain-arrow"/>
|
||||
|
||||
<!-- Settler -->
|
||||
<g @click="navigateTo('/treatment')" class="chain-block clickable">
|
||||
<rect x="600" y="120" width="120" height="80" rx="8" fill="#0f3460"/>
|
||||
<text x="660" y="150" class="block-title">Settler</text>
|
||||
<text x="660" y="170" class="block-value">TSS: {{ effTSS }}</text>
|
||||
<text x="660" y="186" class="block-sub">mg/L</text>
|
||||
</g>
|
||||
|
||||
<!-- Arrow settler → effluent -->
|
||||
<line x1="720" y1="160" x2="760" y2="160" class="chain-arrow"/>
|
||||
|
||||
<!-- Effluent -->
|
||||
<g class="chain-block">
|
||||
<rect x="760" y="120" width="120" height="80" rx="8" :fill="effluentColor"/>
|
||||
<text x="820" y="150" class="block-title">Effluent</text>
|
||||
<text x="820" y="170" class="block-value">NH4: {{ effNH4 }}</text>
|
||||
<text x="820" y="186" class="block-sub">mg/L</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
merge: null,
|
||||
reactorDO: '--',
|
||||
effTSS: '--',
|
||||
effNH4: '--'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
reactorColor() {
|
||||
const d = parseFloat(this.reactorDO);
|
||||
if (isNaN(d)) return '#0f3460';
|
||||
if (d < 1) return '#f44336';
|
||||
if (d < 2) return '#ff9800';
|
||||
return '#1b5e20';
|
||||
},
|
||||
effluentColor() {
|
||||
const n = parseFloat(this.effNH4);
|
||||
if (isNaN(n)) return '#0f3460';
|
||||
if (n > 10) return '#f44336';
|
||||
if (n > 5) return '#ff9800';
|
||||
return '#1b5e20';
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
msg(val) {
|
||||
if (!val) return;
|
||||
const t = val.topic || '';
|
||||
if (t === 'overview_chain') {
|
||||
this.merge = val.payload;
|
||||
} else if (t === 'Reactor DO') {
|
||||
this.reactorDO = val.payload?.toFixed(1) || '--';
|
||||
} else if (t === 'Effluent TSS') {
|
||||
this.effTSS = val.payload?.toFixed(1) || '--';
|
||||
} else if (t === 'Effluent NH4') {
|
||||
this.effNH4 = val.payload?.toFixed(1) || '--';
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
navigateTo(path) {
|
||||
this.$router.push('/dashboard' + path);
|
||||
},
|
||||
blockColor(ps) {
|
||||
if (!ps || ps.fillPct === undefined) return '#0f3460';
|
||||
if (ps.fillPct > 90) return '#f44336';
|
||||
if (ps.fillPct > 75) return '#ff9800';
|
||||
if (ps.fillPct < 10) return '#f44336';
|
||||
return '#0f3460';
|
||||
},
|
||||
formatPct(v) { return v !== undefined && v !== null ? v.toFixed(0) + '%' : '--'; },
|
||||
formatFlow(v) { return v !== undefined && v !== null ? v.toFixed(0) : '--'; },
|
||||
formatDir(d) { return d === 'filling' ? '\\u2191 filling' : d === 'emptying' ? '\\u2193 emptying' : '--'; }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.chain-container { width: 100%; overflow-x: auto; }
|
||||
.chain-svg { width: 100%; height: auto; min-height: 200px; }
|
||||
.chain-block text { text-anchor: middle; fill: #e0e0e0; }
|
||||
.block-title { font-size: 14px; font-weight: bold; }
|
||||
.block-value { font-size: 13px; fill: #4fc3f7; }
|
||||
.block-sub { font-size: 10px; fill: #90a4ae; }
|
||||
.chain-arrow { stroke: #4fc3f7; stroke-width: 2; marker-end: url(#arrowhead); }
|
||||
.clickable { cursor: pointer; }
|
||||
.clickable:hover rect { opacity: 0.8; }
|
||||
</style>`,
|
||||
templateScope: "local",
|
||||
className: "",
|
||||
x: 510, y: 960,
|
||||
wires: [[]]
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// 3c. KPI gauges on overview
|
||||
// =============================================
|
||||
|
||||
// Total Influent Flow gauge
|
||||
flow.push({
|
||||
id: "demo_gauge_overview_flow",
|
||||
type: "ui-gauge",
|
||||
z: "demo_tab_dashboard",
|
||||
group: "demo_ui_grp_overview_kpi",
|
||||
name: "Total Influent Flow",
|
||||
gtype: "gauge-34",
|
||||
gstyle: "Rounded",
|
||||
title: "Influent Flow",
|
||||
units: "m\u00b3/h",
|
||||
prefix: "",
|
||||
suffix: "m\u00b3/h",
|
||||
min: 0,
|
||||
max: 500,
|
||||
segments: [
|
||||
{ color: "#2196f3", from: 0 },
|
||||
{ color: "#4caf50", from: 50 },
|
||||
{ color: "#ff9800", from: 350 },
|
||||
{ color: "#f44336", from: 450 }
|
||||
],
|
||||
width: 3,
|
||||
height: 4,
|
||||
order: 1,
|
||||
className: "",
|
||||
x: 510, y: 1020,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// Reactor DO gauge
|
||||
flow.push({
|
||||
id: "demo_gauge_overview_do",
|
||||
type: "ui-gauge",
|
||||
z: "demo_tab_dashboard",
|
||||
group: "demo_ui_grp_overview_kpi",
|
||||
name: "Reactor DO",
|
||||
gtype: "gauge-34",
|
||||
gstyle: "Rounded",
|
||||
title: "Reactor DO",
|
||||
units: "mg/L",
|
||||
prefix: "",
|
||||
suffix: "mg/L",
|
||||
min: 0,
|
||||
max: 10,
|
||||
segments: [
|
||||
{ color: "#f44336", from: 0 },
|
||||
{ color: "#ff9800", from: 1 },
|
||||
{ color: "#4caf50", from: 2 },
|
||||
{ color: "#ff9800", from: 6 },
|
||||
{ color: "#f44336", from: 8 }
|
||||
],
|
||||
width: 3,
|
||||
height: 4,
|
||||
order: 2,
|
||||
className: "",
|
||||
x: 510, y: 1060,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// Effluent TSS gauge
|
||||
flow.push({
|
||||
id: "demo_gauge_overview_tss",
|
||||
type: "ui-gauge",
|
||||
z: "demo_tab_dashboard",
|
||||
group: "demo_ui_grp_overview_kpi",
|
||||
name: "Effluent TSS",
|
||||
gtype: "gauge-34",
|
||||
gstyle: "Rounded",
|
||||
title: "Effluent TSS",
|
||||
units: "mg/L",
|
||||
prefix: "",
|
||||
suffix: "mg/L",
|
||||
min: 0,
|
||||
max: 50,
|
||||
segments: [
|
||||
{ color: "#4caf50", from: 0 },
|
||||
{ color: "#ff9800", from: 25 },
|
||||
{ color: "#f44336", from: 40 }
|
||||
],
|
||||
width: 3,
|
||||
height: 4,
|
||||
order: 3,
|
||||
className: "",
|
||||
x: 510, y: 1100,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// Effluent NH4 gauge
|
||||
flow.push({
|
||||
id: "demo_gauge_overview_nh4",
|
||||
type: "ui-gauge",
|
||||
z: "demo_tab_dashboard",
|
||||
group: "demo_ui_grp_overview_kpi",
|
||||
name: "Effluent NH4",
|
||||
gtype: "gauge-34",
|
||||
gstyle: "Rounded",
|
||||
title: "Effluent NH4",
|
||||
units: "mg/L",
|
||||
prefix: "",
|
||||
suffix: "mg/L",
|
||||
min: 0,
|
||||
max: 20,
|
||||
segments: [
|
||||
{ color: "#4caf50", from: 0 },
|
||||
{ color: "#ff9800", from: 5 },
|
||||
{ color: "#f44336", from: 10 }
|
||||
],
|
||||
width: 3,
|
||||
height: 4,
|
||||
order: 4,
|
||||
className: "",
|
||||
x: 510, y: 1140,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// 3d. Reorder all page navigation
|
||||
// =============================================
|
||||
const pageOrders = {
|
||||
"demo_ui_page_overview": 0,
|
||||
"demo_ui_page_influent": 1,
|
||||
"demo_ui_page_treatment": 5,
|
||||
"demo_ui_page_telemetry": 6,
|
||||
};
|
||||
|
||||
for (const [pageId, order] of Object.entries(pageOrders)) {
|
||||
const page = byId(pageId);
|
||||
if (page) page.order = order;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Feed chain vis and KPIs from merge + reactor + effluent
|
||||
// We need to also wire the overview_template to receive reactor/eff data
|
||||
// The parse functions already wire to the template and gauges separately
|
||||
// But the template needs ALL data sources - let's connect reactor and eff parsers to it too
|
||||
// =============================================
|
||||
|
||||
// Actually, the template needs multiple inputs. Let's connect reactor and eff parse outputs too.
|
||||
// Modify overview reactor parse to also send to template
|
||||
const reactorParse = byId("demo_fn_overview_reactor_parse");
|
||||
// Currently wires to demo_gauge_overview_do. Add template as well.
|
||||
reactorParse.func = `const p = msg.payload || {};
|
||||
if (!p.C || !Array.isArray(p.C)) return null;
|
||||
|
||||
flow.set('overview_reactor', p);
|
||||
|
||||
// Output 1: DO gauge, Output 2: to chain template
|
||||
const doVal = Math.round(p.C[0]*100)/100;
|
||||
return [
|
||||
{ topic: 'Reactor DO', payload: doVal },
|
||||
{ topic: 'Reactor DO', payload: doVal }
|
||||
];`;
|
||||
reactorParse.outputs = 2;
|
||||
reactorParse.wires = [["demo_gauge_overview_do"], ["demo_overview_template"]];
|
||||
|
||||
// Same for effluent parse - add template output
|
||||
const effParse = byId("demo_fn_overview_eff_parse");
|
||||
effParse.func = `const p = msg.payload || {};
|
||||
const topic = msg.topic || '';
|
||||
const val = Number(p.mAbs);
|
||||
if (!Number.isFinite(val)) return null;
|
||||
|
||||
const rounded = Math.round(val*100)/100;
|
||||
|
||||
// Route to appropriate gauge + template based on measurement type
|
||||
if (topic.includes('TSS') || topic.includes('tss')) {
|
||||
return [{ topic: 'Effluent TSS', payload: rounded }, null, { topic: 'Effluent TSS', payload: rounded }];
|
||||
}
|
||||
if (topic.includes('NH4') || topic.includes('ammonium')) {
|
||||
return [null, { topic: 'Effluent NH4', payload: rounded }, { topic: 'Effluent NH4', payload: rounded }];
|
||||
}
|
||||
return [null, null, null];`;
|
||||
effParse.outputs = 3;
|
||||
effParse.wires = [["demo_gauge_overview_tss"], ["demo_gauge_overview_nh4"], ["demo_overview_template"]];
|
||||
|
||||
// =============================================
|
||||
// Validate
|
||||
// =============================================
|
||||
const allIds = new Set(flow.map(n => n.id));
|
||||
let issues = 0;
|
||||
for (const n of flow) {
|
||||
if (!n.wires) continue;
|
||||
for (const port of n.wires) {
|
||||
for (const target of port) {
|
||||
if (!allIds.has(target)) {
|
||||
console.warn(`BROKEN WIRE: ${n.id} → ${target}`);
|
||||
issues++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (n.type === 'link out' && n.links) {
|
||||
for (const lt of n.links) {
|
||||
if (!allIds.has(lt)) { console.warn(`BROKEN LINK OUT: ${n.id} → ${lt}`); issues++; }
|
||||
}
|
||||
}
|
||||
if (n.type === 'link in' && n.links) {
|
||||
for (const ls of n.links) {
|
||||
if (!allIds.has(ls)) { console.warn(`BROKEN LINK IN: ${n.id} ← ${ls}`); issues++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (issues === 0) console.log('All references valid ✓');
|
||||
console.log('Total nodes:', flow.length);
|
||||
|
||||
// Write
|
||||
fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
|
||||
console.log(`Wrote ${FLOW_PATH}`);
|
||||
@@ -1,613 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Step 4: Manual Controls per PS Detail Page
|
||||
* - Creates 3 PS detail pages (/ps-west, /ps-north, /ps-south) with control groups
|
||||
* - Adds control widgets: mode switches, pump speed sliders
|
||||
* - Format functions to convert dashboard inputs to process node messages
|
||||
* - Link-in/out routing between dashboard tab and PS tabs
|
||||
* - Per-PS monitoring charts on detail pages
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FLOW_PATH = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(FLOW_PATH, 'utf8'));
|
||||
|
||||
const byId = (id) => flow.find(n => n.id === id);
|
||||
|
||||
// =============================================
|
||||
// Helper to create a standard set of controls for a PS
|
||||
// =============================================
|
||||
function createPSDetailPage(config) {
|
||||
const {
|
||||
psKey, // 'west', 'north', 'south'
|
||||
psLabel, // 'PS West', 'PS North', 'PS South'
|
||||
pagePath, // '/ps-west'
|
||||
pageOrder, // 2, 3, 4
|
||||
psNodeId, // 'demo_ps_west'
|
||||
pumps, // [{id: 'demo_pump_w1', label: 'W1'}, ...]
|
||||
controlModes, // ['levelbased','flowbased','manual']
|
||||
defaultMode, // 'levelbased'
|
||||
maxFlow, // 300
|
||||
basinHeight, // 4
|
||||
tabId, // 'demo_tab_ps_west'
|
||||
} = config;
|
||||
|
||||
const prefix = `demo_ctrl_${psKey}`;
|
||||
const nodes = [];
|
||||
|
||||
// === Page ===
|
||||
nodes.push({
|
||||
id: `demo_ui_page_ps_${psKey}_detail`,
|
||||
type: "ui-page",
|
||||
name: `${psLabel} Detail`,
|
||||
ui: "demo_ui_base",
|
||||
path: pagePath,
|
||||
icon: "water_drop",
|
||||
layout: "grid",
|
||||
theme: "demo_ui_theme",
|
||||
breakpoints: [{ name: "Default", px: "0", cols: "12" }],
|
||||
order: pageOrder,
|
||||
className: ""
|
||||
});
|
||||
|
||||
// === Groups ===
|
||||
nodes.push(
|
||||
{
|
||||
id: `${prefix}_grp_controls`,
|
||||
type: "ui-group",
|
||||
name: `${psLabel} Controls`,
|
||||
page: `demo_ui_page_ps_${psKey}_detail`,
|
||||
width: "6",
|
||||
height: "1",
|
||||
order: 1,
|
||||
showTitle: true,
|
||||
className: ""
|
||||
},
|
||||
{
|
||||
id: `${prefix}_grp_monitoring`,
|
||||
type: "ui-group",
|
||||
name: `${psLabel} Monitoring`,
|
||||
page: `demo_ui_page_ps_${psKey}_detail`,
|
||||
width: "6",
|
||||
height: "1",
|
||||
order: 2,
|
||||
showTitle: true,
|
||||
className: ""
|
||||
},
|
||||
{
|
||||
id: `${prefix}_grp_charts`,
|
||||
type: "ui-group",
|
||||
name: `${psLabel} Trends`,
|
||||
page: `demo_ui_page_ps_${psKey}_detail`,
|
||||
width: "12",
|
||||
height: "1",
|
||||
order: 3,
|
||||
showTitle: true,
|
||||
className: ""
|
||||
}
|
||||
);
|
||||
|
||||
// === PS Mode button group ===
|
||||
const modeOptions = controlModes.map(m => ({
|
||||
label: m === 'levelbased' ? 'Level' : m === 'flowbased' ? 'Flow' : m.charAt(0).toUpperCase() + m.slice(1),
|
||||
value: m,
|
||||
valueType: "str"
|
||||
}));
|
||||
|
||||
nodes.push({
|
||||
id: `${prefix}_mode`,
|
||||
type: "ui-button-group",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_controls`,
|
||||
name: `${psLabel} Mode`,
|
||||
label: "Station Mode",
|
||||
tooltip: "",
|
||||
order: 1,
|
||||
width: "6",
|
||||
height: "1",
|
||||
passthru: false,
|
||||
options: modeOptions,
|
||||
x: 120, y: 100 + pageOrder * 300,
|
||||
wires: [[`${prefix}_fn_mode`]]
|
||||
});
|
||||
|
||||
// Format: PS mode → setMode message
|
||||
nodes.push({
|
||||
id: `${prefix}_fn_mode`,
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `Fmt ${psLabel} Mode`,
|
||||
func: `msg.topic = 'setMode';\nmsg.payload = msg.payload;\nreturn msg;`,
|
||||
outputs: 1,
|
||||
x: 320, y: 100 + pageOrder * 300,
|
||||
wires: [[`${prefix}_link_cmd_out`]]
|
||||
});
|
||||
|
||||
// === Manual Flow slider ===
|
||||
nodes.push({
|
||||
id: `${prefix}_flow`,
|
||||
type: "ui-slider",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_controls`,
|
||||
name: `${psLabel} Flow`,
|
||||
label: "Manual Flow (m\u00b3/h)",
|
||||
tooltip: "",
|
||||
order: 2,
|
||||
width: "6",
|
||||
height: "1",
|
||||
passthru: false,
|
||||
outs: "end",
|
||||
min: 0,
|
||||
max: maxFlow,
|
||||
step: 1,
|
||||
x: 120, y: 140 + pageOrder * 300,
|
||||
wires: [[`${prefix}_fn_flow`]]
|
||||
});
|
||||
|
||||
// Format: flow slider → q_in message
|
||||
nodes.push({
|
||||
id: `${prefix}_fn_flow`,
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `Fmt ${psLabel} Flow`,
|
||||
func: `msg.topic = 'q_in';\nmsg.payload = { value: Number(msg.payload), unit: 'm3/h' };\nreturn msg;`,
|
||||
outputs: 1,
|
||||
x: 320, y: 140 + pageOrder * 300,
|
||||
wires: [[`${prefix}_link_cmd_out`]]
|
||||
});
|
||||
|
||||
// === Pump controls ===
|
||||
pumps.forEach((pump, pIdx) => {
|
||||
const yOff = 180 + pageOrder * 300 + pIdx * 80;
|
||||
|
||||
// Pump mode button group
|
||||
nodes.push({
|
||||
id: `${prefix}_pump_${pump.label.toLowerCase()}_mode`,
|
||||
type: "ui-button-group",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_controls`,
|
||||
name: `${pump.label} Mode`,
|
||||
label: `${pump.label} Mode`,
|
||||
tooltip: "",
|
||||
order: 3 + pIdx * 2,
|
||||
width: "3",
|
||||
height: "1",
|
||||
passthru: false,
|
||||
options: [
|
||||
{ label: "Auto", value: "auto", valueType: "str" },
|
||||
{ label: "Virtual", value: "virtualControl", valueType: "str" },
|
||||
{ label: "Physical", value: "fysicalControl", valueType: "str" }
|
||||
],
|
||||
x: 120, y: yOff,
|
||||
wires: [[`${prefix}_fn_pump_${pump.label.toLowerCase()}_mode`]]
|
||||
});
|
||||
|
||||
// Format: pump mode
|
||||
nodes.push({
|
||||
id: `${prefix}_fn_pump_${pump.label.toLowerCase()}_mode`,
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `Fmt ${pump.label} Mode`,
|
||||
func: `msg.topic = 'setMode';\nmsg.payload = msg.payload;\nmsg._targetNode = '${pump.id}';\nreturn msg;`,
|
||||
outputs: 1,
|
||||
x: 320, y: yOff,
|
||||
wires: [[`${prefix}_link_pump_${pump.label.toLowerCase()}_out`]]
|
||||
});
|
||||
|
||||
// Pump speed slider
|
||||
nodes.push({
|
||||
id: `${prefix}_pump_${pump.label.toLowerCase()}_speed`,
|
||||
type: "ui-slider",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_controls`,
|
||||
name: `${pump.label} Speed`,
|
||||
label: `${pump.label} Speed (%)`,
|
||||
tooltip: "",
|
||||
order: 4 + pIdx * 2,
|
||||
width: "3",
|
||||
height: "1",
|
||||
passthru: false,
|
||||
outs: "end",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
x: 120, y: yOff + 40,
|
||||
wires: [[`${prefix}_fn_pump_${pump.label.toLowerCase()}_speed`]]
|
||||
});
|
||||
|
||||
// Format: pump speed → execMovement
|
||||
nodes.push({
|
||||
id: `${prefix}_fn_pump_${pump.label.toLowerCase()}_speed`,
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `Fmt ${pump.label} Speed`,
|
||||
func: `msg.topic = 'execMovement';\nmsg.payload = { source: 'dashboard', action: 'setpoint', setpoint: Number(msg.payload) };\nmsg._targetNode = '${pump.id}';\nreturn msg;`,
|
||||
outputs: 1,
|
||||
x: 320, y: yOff + 40,
|
||||
wires: [[`${prefix}_link_pump_${pump.label.toLowerCase()}_out`]]
|
||||
});
|
||||
|
||||
// Link-out for pump commands (dashboard → PS tab)
|
||||
nodes.push({
|
||||
id: `${prefix}_link_pump_${pump.label.toLowerCase()}_out`,
|
||||
type: "link out",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `→ ${pump.label} Cmd`,
|
||||
mode: "link",
|
||||
links: [`${prefix}_link_pump_${pump.label.toLowerCase()}_in`],
|
||||
x: 520, y: yOff + 20
|
||||
});
|
||||
|
||||
// Link-in on PS tab
|
||||
nodes.push({
|
||||
id: `${prefix}_link_pump_${pump.label.toLowerCase()}_in`,
|
||||
type: "link in",
|
||||
z: tabId,
|
||||
name: `← ${pump.label} Cmd`,
|
||||
links: [`${prefix}_link_pump_${pump.label.toLowerCase()}_out`],
|
||||
x: 120, y: 540 + pIdx * 60,
|
||||
wires: [[pump.id]]
|
||||
});
|
||||
});
|
||||
|
||||
// === PS command link-out (dashboard → PS tab) ===
|
||||
nodes.push({
|
||||
id: `${prefix}_link_cmd_out`,
|
||||
type: "link out",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `→ ${psLabel} Cmd`,
|
||||
mode: "link",
|
||||
links: [`${prefix}_link_cmd_in`],
|
||||
x: 520, y: 120 + pageOrder * 300
|
||||
});
|
||||
|
||||
// Link-in on PS tab for PS-level commands
|
||||
nodes.push({
|
||||
id: `${prefix}_link_cmd_in`,
|
||||
type: "link in",
|
||||
z: tabId,
|
||||
name: `← ${psLabel} Cmd`,
|
||||
links: [`${prefix}_link_cmd_out`],
|
||||
x: 120, y: 480,
|
||||
wires: [[psNodeId]]
|
||||
});
|
||||
|
||||
// === Monitoring widgets on detail page ===
|
||||
// Re-use existing data from the PS parse functions on dashboard tab
|
||||
// Create a link-in to receive PS data and parse for detail page
|
||||
|
||||
nodes.push({
|
||||
id: `${prefix}_link_detail_data_out`,
|
||||
type: "link out",
|
||||
z: tabId,
|
||||
name: `→ ${psLabel} Detail`,
|
||||
mode: "link",
|
||||
links: [`${prefix}_link_detail_data_in`],
|
||||
x: 1080, y: 400
|
||||
});
|
||||
|
||||
// Add to PS node wires[0]
|
||||
const psNode = byId(psNodeId);
|
||||
if (psNode && psNode.wires && psNode.wires[0]) {
|
||||
psNode.wires[0].push(`${prefix}_link_detail_data_out`);
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: `${prefix}_link_detail_data_in`,
|
||||
type: "link in",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `← ${psLabel} Detail`,
|
||||
links: [`${prefix}_link_detail_data_out`],
|
||||
x: 75, y: 50 + pageOrder * 300,
|
||||
wires: [[`${prefix}_fn_detail_parse`]]
|
||||
});
|
||||
|
||||
// Parse function for detail monitoring
|
||||
nodes.push({
|
||||
id: `${prefix}_fn_detail_parse`,
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `Parse ${psLabel} Detail`,
|
||||
func: `const p = msg.payload || {};
|
||||
const cache = context.get('c') || {};
|
||||
const keys = Object.keys(p);
|
||||
const pick = (prefixes) => { for (const pfx of prefixes) { const k = keys.find(k => k.startsWith(pfx)); if (k) { const v = Number(p[k]); if (Number.isFinite(v)) return v; } } return null; };
|
||||
|
||||
const level = pick(['level.predicted.atequipment','level.measured.atequipment']);
|
||||
const volume = pick(['volume.predicted.atequipment']);
|
||||
const netFlow = pick(['netFlowRate.predicted.atequipment']);
|
||||
const fillPct = pick(['volumePercent.predicted.atequipment']);
|
||||
const direction = p.direction || cache.direction || '?';
|
||||
|
||||
if (level !== null) cache.level = level;
|
||||
if (volume !== null) cache.volume = volume;
|
||||
if (netFlow !== null) cache.netFlow = netFlow;
|
||||
if (fillPct !== null) cache.fillPct = fillPct;
|
||||
cache.direction = direction;
|
||||
context.set('c', cache);
|
||||
|
||||
const now = Date.now();
|
||||
const dirArrow = cache.direction === 'filling' ? '\\u2191' : cache.direction === 'emptying' ? '\\u2193' : '\\u2014';
|
||||
const status = [
|
||||
dirArrow + ' ' + (cache.direction || ''),
|
||||
cache.netFlow !== undefined ? Math.abs(cache.netFlow).toFixed(0) + ' m\\u00b3/h' : '',
|
||||
].filter(s => s.trim()).join(' | ');
|
||||
|
||||
return [
|
||||
cache.level !== undefined ? {topic:'${psLabel} Level', payload: cache.level, timestamp: now} : null,
|
||||
cache.netFlow !== undefined ? {topic:'${psLabel} Flow', payload: cache.netFlow, timestamp: now} : null,
|
||||
{topic:'${psLabel} Status', payload: status},
|
||||
cache.fillPct !== undefined ? {payload: Number(cache.fillPct.toFixed(1))} : null,
|
||||
cache.level !== undefined ? {payload: Number(cache.level.toFixed(2))} : null
|
||||
];`,
|
||||
outputs: 5,
|
||||
x: 280, y: 50 + pageOrder * 300,
|
||||
wires: [
|
||||
[`${prefix}_chart_level`],
|
||||
[`${prefix}_chart_flow`],
|
||||
[`${prefix}_text_status`],
|
||||
[`${prefix}_gauge_fill`],
|
||||
[`${prefix}_gauge_tank`]
|
||||
]
|
||||
});
|
||||
|
||||
// Level chart
|
||||
nodes.push({
|
||||
id: `${prefix}_chart_level`,
|
||||
type: "ui-chart",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_charts`,
|
||||
name: `${psLabel} Level`,
|
||||
label: "Basin Level (m)",
|
||||
order: 1,
|
||||
width: "6",
|
||||
height: "5",
|
||||
chartType: "line",
|
||||
category: "topic",
|
||||
categoryType: "msg",
|
||||
xAxisType: "time",
|
||||
yAxisLabel: "m",
|
||||
removeOlder: "10",
|
||||
removeOlderUnit: "60",
|
||||
action: "append",
|
||||
pointShape: "false",
|
||||
pointRadius: 0,
|
||||
interpolation: "linear",
|
||||
showLegend: true,
|
||||
xAxisProperty: "",
|
||||
xAxisPropertyType: "timestamp",
|
||||
yAxisProperty: "payload",
|
||||
yAxisPropertyType: "msg",
|
||||
colors: ["#0094ce", "#FF7F0E", "#2CA02C"],
|
||||
textColor: ["#aaaaaa"],
|
||||
textColorDefault: false,
|
||||
gridColor: ["#333333"],
|
||||
gridColorDefault: false,
|
||||
x: 510, y: 30 + pageOrder * 300,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// Flow chart
|
||||
nodes.push({
|
||||
id: `${prefix}_chart_flow`,
|
||||
type: "ui-chart",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_charts`,
|
||||
name: `${psLabel} Flow`,
|
||||
label: "Net Flow (m\u00b3/h)",
|
||||
order: 2,
|
||||
width: "6",
|
||||
height: "5",
|
||||
chartType: "line",
|
||||
category: "topic",
|
||||
categoryType: "msg",
|
||||
xAxisType: "time",
|
||||
yAxisLabel: "m\u00b3/h",
|
||||
removeOlder: "10",
|
||||
removeOlderUnit: "60",
|
||||
action: "append",
|
||||
pointShape: "false",
|
||||
pointRadius: 0,
|
||||
interpolation: "linear",
|
||||
showLegend: true,
|
||||
xAxisProperty: "",
|
||||
xAxisPropertyType: "timestamp",
|
||||
yAxisProperty: "payload",
|
||||
yAxisPropertyType: "msg",
|
||||
colors: ["#4fc3f7", "#FF7F0E", "#2CA02C"],
|
||||
textColor: ["#aaaaaa"],
|
||||
textColorDefault: false,
|
||||
gridColor: ["#333333"],
|
||||
gridColorDefault: false,
|
||||
x: 510, y: 60 + pageOrder * 300,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// Status text
|
||||
nodes.push({
|
||||
id: `${prefix}_text_status`,
|
||||
type: "ui-text",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_monitoring`,
|
||||
name: `${psLabel} Status`,
|
||||
label: "Status",
|
||||
order: 1,
|
||||
width: "6",
|
||||
height: "1",
|
||||
format: "{{msg.payload}}",
|
||||
layout: "row-spread",
|
||||
x: 510, y: 80 + pageOrder * 300,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// Fill % gauge
|
||||
nodes.push({
|
||||
id: `${prefix}_gauge_fill`,
|
||||
type: "ui-gauge",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_monitoring`,
|
||||
name: `${psLabel} Fill`,
|
||||
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: 25 },
|
||||
{ color: "#ff9800", from: 75 },
|
||||
{ color: "#f44336", from: 90 }
|
||||
],
|
||||
width: 3,
|
||||
height: 3,
|
||||
order: 2,
|
||||
className: "",
|
||||
x: 700, y: 80 + pageOrder * 300,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// Tank gauge
|
||||
nodes.push({
|
||||
id: `${prefix}_gauge_tank`,
|
||||
type: "ui-gauge",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_monitoring`,
|
||||
name: `${psLabel} Tank`,
|
||||
gtype: "gauge-tank",
|
||||
gstyle: "Rounded",
|
||||
title: "Level",
|
||||
units: "m",
|
||||
prefix: "",
|
||||
suffix: "m",
|
||||
min: 0,
|
||||
max: basinHeight,
|
||||
segments: [
|
||||
{ color: "#f44336", from: 0 },
|
||||
{ color: "#ff9800", from: basinHeight * 0.08 },
|
||||
{ color: "#2196f3", from: basinHeight * 0.25 },
|
||||
{ color: "#ff9800", from: basinHeight * 0.62 },
|
||||
{ color: "#f44336", from: basinHeight * 0.8 }
|
||||
],
|
||||
width: 3,
|
||||
height: 4,
|
||||
order: 3,
|
||||
className: "",
|
||||
x: 700, y: 40 + pageOrder * 300,
|
||||
wires: []
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Create detail pages for each PS
|
||||
// =============================================
|
||||
|
||||
const westNodes = createPSDetailPage({
|
||||
psKey: 'west',
|
||||
psLabel: 'PS West',
|
||||
pagePath: '/ps-west',
|
||||
pageOrder: 2,
|
||||
psNodeId: 'demo_ps_west',
|
||||
pumps: [
|
||||
{ id: 'demo_pump_w1', label: 'W1' },
|
||||
{ id: 'demo_pump_w2', label: 'W2' }
|
||||
],
|
||||
controlModes: ['levelbased', 'flowbased', 'manual'],
|
||||
defaultMode: 'levelbased',
|
||||
maxFlow: 300,
|
||||
basinHeight: 4,
|
||||
tabId: 'demo_tab_ps_west',
|
||||
});
|
||||
|
||||
const northNodes = createPSDetailPage({
|
||||
psKey: 'north',
|
||||
psLabel: 'PS North',
|
||||
pagePath: '/ps-north',
|
||||
pageOrder: 3,
|
||||
psNodeId: 'demo_ps_north',
|
||||
pumps: [
|
||||
{ id: 'demo_pump_n1', label: 'N1' }
|
||||
],
|
||||
controlModes: ['levelbased', 'flowbased', 'manual'],
|
||||
defaultMode: 'flowbased',
|
||||
maxFlow: 200,
|
||||
basinHeight: 3,
|
||||
tabId: 'demo_tab_ps_north',
|
||||
});
|
||||
|
||||
const southNodes = createPSDetailPage({
|
||||
psKey: 'south',
|
||||
psLabel: 'PS South',
|
||||
pagePath: '/ps-south',
|
||||
pageOrder: 4,
|
||||
psNodeId: 'demo_ps_south',
|
||||
pumps: [
|
||||
{ id: 'demo_pump_s1', label: 'S1' }
|
||||
],
|
||||
controlModes: ['levelbased', 'flowbased', 'manual'],
|
||||
defaultMode: 'manual',
|
||||
maxFlow: 100,
|
||||
basinHeight: 2.5,
|
||||
tabId: 'demo_tab_ps_south',
|
||||
});
|
||||
|
||||
flow.push(...westNodes, ...northNodes, ...southNodes);
|
||||
|
||||
// =============================================
|
||||
// Validate
|
||||
// =============================================
|
||||
const allIds = new Set(flow.map(n => n.id));
|
||||
let issues = 0;
|
||||
// Check for duplicate IDs
|
||||
const idCounts = {};
|
||||
flow.forEach(n => { idCounts[n.id] = (idCounts[n.id] || 0) + 1; });
|
||||
for (const [id, count] of Object.entries(idCounts)) {
|
||||
if (count > 1) { console.warn(`DUPLICATE ID: ${id} (${count} instances)`); issues++; }
|
||||
}
|
||||
|
||||
for (const n of flow) {
|
||||
if (!n.wires) continue;
|
||||
for (const port of n.wires) {
|
||||
for (const target of port) {
|
||||
if (!allIds.has(target)) {
|
||||
console.warn(`BROKEN WIRE: ${n.id} → ${target}`);
|
||||
issues++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (n.type === 'link out' && n.links) {
|
||||
for (const lt of n.links) {
|
||||
if (!allIds.has(lt)) { console.warn(`BROKEN LINK OUT: ${n.id} → ${lt}`); issues++; }
|
||||
}
|
||||
}
|
||||
if (n.type === 'link in' && n.links) {
|
||||
for (const ls of n.links) {
|
||||
if (!allIds.has(ls)) { console.warn(`BROKEN LINK IN: ${n.id} ← ${ls}`); issues++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (issues === 0) console.log('All references valid ✓');
|
||||
else console.log(`Found ${issues} issues`);
|
||||
|
||||
// Count nodes per tab
|
||||
const tabCounts = {};
|
||||
for (const n of flow) {
|
||||
if (n.z) tabCounts[n.z] = (tabCounts[n.z] || 0) + 1;
|
||||
}
|
||||
console.log('Nodes per tab:', JSON.stringify(tabCounts, null, 2));
|
||||
console.log('Total nodes:', flow.length);
|
||||
|
||||
// Count new nodes added
|
||||
const newNodeCount = westNodes.length + northNodes.length + southNodes.length;
|
||||
console.log(`Added ${newNodeCount} new nodes (${westNodes.length} west + ${northNodes.length} north + ${southNodes.length} south)`);
|
||||
|
||||
// Write
|
||||
fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
|
||||
console.log(`Wrote ${FLOW_PATH}`);
|
||||
@@ -1,279 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Script to update docker/demo-flow.json with Fixes 2-5 from the plan.
|
||||
* Run from project root: node scripts/update-demo-flow.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const flowPath = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||
|
||||
// === Fix 2: Enable simulator on 9 measurement nodes ===
|
||||
const simMeasIds = [
|
||||
'demo_meas_flow', 'demo_meas_do', 'demo_meas_nh4',
|
||||
'demo_meas_ft_n1', 'demo_meas_eff_flow', 'demo_meas_eff_do',
|
||||
'demo_meas_eff_nh4', 'demo_meas_eff_no3', 'demo_meas_eff_tss'
|
||||
];
|
||||
simMeasIds.forEach(id => {
|
||||
const node = flow.find(n => n.id === id);
|
||||
if (node) {
|
||||
node.simulator = true;
|
||||
console.log('Enabled simulator on', id);
|
||||
} else {
|
||||
console.error('NOT FOUND:', id);
|
||||
}
|
||||
});
|
||||
|
||||
// === Fix 2: Remove 18 inject+function sim pairs ===
|
||||
const removeSimIds = [
|
||||
'demo_inj_meas_flow', 'demo_fn_sim_flow',
|
||||
'demo_inj_meas_do', 'demo_fn_sim_do',
|
||||
'demo_inj_meas_nh4', 'demo_fn_sim_nh4',
|
||||
'demo_inj_ft_n1', 'demo_fn_sim_ft_n1',
|
||||
'demo_inj_eff_flow', 'demo_fn_sim_eff_flow',
|
||||
'demo_inj_eff_do', 'demo_fn_sim_eff_do',
|
||||
'demo_inj_eff_nh4', 'demo_fn_sim_eff_nh4',
|
||||
'demo_inj_eff_no3', 'demo_fn_sim_eff_no3',
|
||||
'demo_inj_eff_tss', 'demo_fn_sim_eff_tss'
|
||||
];
|
||||
|
||||
// === Fix 5: Remove manual pump startup/setpoint injectors ===
|
||||
const removeManualIds = [
|
||||
'demo_inj_w1_startup', 'demo_inj_w1_setpoint',
|
||||
'demo_inj_w2_startup', 'demo_inj_w2_setpoint',
|
||||
'demo_inj_n1_startup',
|
||||
'demo_inj_s1_startup'
|
||||
];
|
||||
|
||||
const allRemoveIds = new Set([...removeSimIds, ...removeManualIds]);
|
||||
const before = flow.length;
|
||||
const filtered = flow.filter(n => !allRemoveIds.has(n.id));
|
||||
console.log(`Removed ${before - filtered.length} nodes (expected 24)`);
|
||||
|
||||
// Remove wires to removed nodes from remaining nodes
|
||||
filtered.forEach(n => {
|
||||
if (n.wires && Array.isArray(n.wires)) {
|
||||
n.wires = n.wires.map(wireGroup => {
|
||||
if (Array.isArray(wireGroup)) {
|
||||
return wireGroup.filter(w => !allRemoveIds.has(w));
|
||||
}
|
||||
return wireGroup;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// === Fix 3 (demo part): Add speedUpFactor to reactor ===
|
||||
const reactor = filtered.find(n => n.id === 'demo_reactor');
|
||||
if (reactor) {
|
||||
reactor.speedUpFactor = 1;
|
||||
console.log('Added speedUpFactor=1 to reactor');
|
||||
}
|
||||
|
||||
// === Fix 4: Add pressure measurement nodes ===
|
||||
const maxY = Math.max(...filtered.filter(n => n.z === 'demo_tab_wwtp').map(n => n.y || 0));
|
||||
|
||||
const ptBaseConfig = {
|
||||
scaling: true,
|
||||
i_offset: 0,
|
||||
smooth_method: 'mean',
|
||||
count: 3,
|
||||
category: 'sensor',
|
||||
assetType: 'pressure',
|
||||
enableLog: false,
|
||||
logLevel: 'error',
|
||||
positionIcon: '',
|
||||
hasDistance: false
|
||||
};
|
||||
|
||||
// Function to extract level from PS output and convert to hydrostatic pressure
|
||||
const levelExtractFunc = [
|
||||
'// Extract basin level from PS output and convert to hydrostatic pressure (mbar)',
|
||||
'// P = rho * g * h, rho=1000 kg/m3, g=9.81 m/s2',
|
||||
'const p = msg.payload || {};',
|
||||
'const keys = Object.keys(p);',
|
||||
'const levelKey = keys.find(k => k.startsWith("level.predicted.atequipment") || k.startsWith("level.measured.atequipment"));',
|
||||
'if (!levelKey) return null;',
|
||||
'const h = Number(p[levelKey]);',
|
||||
'if (!Number.isFinite(h)) return null;',
|
||||
'msg.topic = "measurement";',
|
||||
'msg.payload = Math.round(h * 98.1 * 10) / 10; // mbar',
|
||||
'return msg;'
|
||||
].join('\n');
|
||||
|
||||
const newNodes = [
|
||||
// Comment
|
||||
{
|
||||
id: 'demo_comment_pressure',
|
||||
type: 'comment',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: '=== PRESSURE MEASUREMENTS (per pumping station) ===',
|
||||
info: '',
|
||||
x: 320,
|
||||
y: maxY + 40
|
||||
},
|
||||
|
||||
// --- PS West upstream PT ---
|
||||
{
|
||||
id: 'demo_fn_level_to_pressure_w',
|
||||
type: 'function',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: 'Level\u2192Pressure (West)',
|
||||
func: levelExtractFunc,
|
||||
outputs: 1,
|
||||
x: 370,
|
||||
y: maxY + 80,
|
||||
wires: [['demo_meas_pt_w_up']]
|
||||
},
|
||||
{
|
||||
id: 'demo_meas_pt_w_up',
|
||||
type: 'measurement',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: 'PT-W-UP (West Upstream)',
|
||||
...ptBaseConfig,
|
||||
i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
|
||||
simulator: false,
|
||||
uuid: 'pt-w-up-001',
|
||||
supplier: 'Endress+Hauser',
|
||||
model: 'Cerabar-PMC51',
|
||||
unit: 'mbar',
|
||||
assetTagNumber: 'PT-W-UP',
|
||||
positionVsParent: 'upstream',
|
||||
x: 580,
|
||||
y: maxY + 80,
|
||||
wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_w1', 'demo_pump_w2']]
|
||||
},
|
||||
// PS West downstream PT (simulated)
|
||||
{
|
||||
id: 'demo_meas_pt_w_down',
|
||||
type: 'measurement',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: 'PT-W-DN (West Downstream)',
|
||||
...ptBaseConfig,
|
||||
i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
|
||||
simulator: true,
|
||||
uuid: 'pt-w-dn-001',
|
||||
supplier: 'Endress+Hauser',
|
||||
model: 'Cerabar-PMC51',
|
||||
unit: 'mbar',
|
||||
assetTagNumber: 'PT-W-DN',
|
||||
positionVsParent: 'downstream',
|
||||
x: 580,
|
||||
y: maxY + 140,
|
||||
wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_w1', 'demo_pump_w2']]
|
||||
},
|
||||
|
||||
// --- PS North upstream PT ---
|
||||
{
|
||||
id: 'demo_fn_level_to_pressure_n',
|
||||
type: 'function',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: 'Level\u2192Pressure (North)',
|
||||
func: levelExtractFunc,
|
||||
outputs: 1,
|
||||
x: 370,
|
||||
y: maxY + 220,
|
||||
wires: [['demo_meas_pt_n_up']]
|
||||
},
|
||||
{
|
||||
id: 'demo_meas_pt_n_up',
|
||||
type: 'measurement',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: 'PT-N-UP (North Upstream)',
|
||||
...ptBaseConfig,
|
||||
i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
|
||||
simulator: false,
|
||||
uuid: 'pt-n-up-001',
|
||||
supplier: 'Endress+Hauser',
|
||||
model: 'Cerabar-PMC51',
|
||||
unit: 'mbar',
|
||||
assetTagNumber: 'PT-N-UP',
|
||||
positionVsParent: 'upstream',
|
||||
x: 580,
|
||||
y: maxY + 220,
|
||||
wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_n1']]
|
||||
},
|
||||
{
|
||||
id: 'demo_meas_pt_n_down',
|
||||
type: 'measurement',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: 'PT-N-DN (North Downstream)',
|
||||
...ptBaseConfig,
|
||||
i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
|
||||
simulator: true,
|
||||
uuid: 'pt-n-dn-001',
|
||||
supplier: 'Endress+Hauser',
|
||||
model: 'Cerabar-PMC51',
|
||||
unit: 'mbar',
|
||||
assetTagNumber: 'PT-N-DN',
|
||||
positionVsParent: 'downstream',
|
||||
x: 580,
|
||||
y: maxY + 280,
|
||||
wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_n1']]
|
||||
},
|
||||
|
||||
// --- PS South upstream PT ---
|
||||
{
|
||||
id: 'demo_fn_level_to_pressure_s',
|
||||
type: 'function',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: 'Level\u2192Pressure (South)',
|
||||
func: levelExtractFunc,
|
||||
outputs: 1,
|
||||
x: 370,
|
||||
y: maxY + 360,
|
||||
wires: [['demo_meas_pt_s_up']]
|
||||
},
|
||||
{
|
||||
id: 'demo_meas_pt_s_up',
|
||||
type: 'measurement',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: 'PT-S-UP (South Upstream)',
|
||||
...ptBaseConfig,
|
||||
i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
|
||||
simulator: false,
|
||||
uuid: 'pt-s-up-001',
|
||||
supplier: 'Endress+Hauser',
|
||||
model: 'Cerabar-PMC51',
|
||||
unit: 'mbar',
|
||||
assetTagNumber: 'PT-S-UP',
|
||||
positionVsParent: 'upstream',
|
||||
x: 580,
|
||||
y: maxY + 360,
|
||||
wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_s1']]
|
||||
},
|
||||
{
|
||||
id: 'demo_meas_pt_s_down',
|
||||
type: 'measurement',
|
||||
z: 'demo_tab_wwtp',
|
||||
name: 'PT-S-DN (South Downstream)',
|
||||
...ptBaseConfig,
|
||||
i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
|
||||
simulator: true,
|
||||
uuid: 'pt-s-dn-001',
|
||||
supplier: 'Endress+Hauser',
|
||||
model: 'Cerabar-PMC51',
|
||||
unit: 'mbar',
|
||||
assetTagNumber: 'PT-S-DN',
|
||||
positionVsParent: 'downstream',
|
||||
x: 580,
|
||||
y: maxY + 420,
|
||||
wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_s1']]
|
||||
}
|
||||
];
|
||||
|
||||
// Wire PS output port 0 to the level-to-pressure function nodes
|
||||
const psWest = filtered.find(n => n.id === 'demo_ps_west');
|
||||
const psNorth = filtered.find(n => n.id === 'demo_ps_north');
|
||||
const psSouth = filtered.find(n => n.id === 'demo_ps_south');
|
||||
|
||||
if (psWest && psWest.wires[0]) psWest.wires[0].push('demo_fn_level_to_pressure_w');
|
||||
if (psNorth && psNorth.wires[0]) psNorth.wires[0].push('demo_fn_level_to_pressure_n');
|
||||
if (psSouth && psSouth.wires[0]) psSouth.wires[0].push('demo_fn_level_to_pressure_s');
|
||||
|
||||
// Combine and write
|
||||
const result = [...filtered, ...newNodes];
|
||||
console.log(`Final flow has ${result.length} nodes`);
|
||||
|
||||
fs.writeFileSync(flowPath, JSON.stringify(result, null, 2) + '\n');
|
||||
console.log('Done! Written to docker/demo-flow.json');
|
||||
@@ -1,24 +0,0 @@
|
||||
# Copy this file to `.env` on the target server and populate real values there.
|
||||
# Keep the real `.env` out of version control.
|
||||
|
||||
INFLUXDB_ADMIN_USER=replace-me
|
||||
INFLUXDB_ADMIN_PASSWORD=replace-me
|
||||
INFLUXDB_BUCKET=lvl0
|
||||
INFLUXDB_ORG=wbd
|
||||
|
||||
GF_SECURITY_ADMIN_USER=replace-me
|
||||
GF_SECURITY_ADMIN_PASSWORD=replace-me
|
||||
|
||||
NPM_DB_MYSQL_HOST=db
|
||||
NPM_DB_MYSQL_PORT=3306
|
||||
NPM_DB_MYSQL_USER=npm
|
||||
NPM_DB_MYSQL_PASSWORD=replace-me
|
||||
NPM_DB_MYSQL_NAME=npm
|
||||
|
||||
MYSQL_ROOT_PASSWORD=replace-me
|
||||
MYSQL_DATABASE=npm
|
||||
MYSQL_USER=npm
|
||||
MYSQL_PASSWORD=replace-me
|
||||
|
||||
RABBITMQ_DEFAULT_USER=replace-me
|
||||
RABBITMQ_DEFAULT_PASS=replace-me
|
||||
117
temp/cloud.yml
117
temp/cloud.yml
@@ -1,117 +0,0 @@
|
||||
services:
|
||||
node-red:
|
||||
image: nodered/node-red:latest
|
||||
container_name: node-red
|
||||
restart: always
|
||||
ports:
|
||||
- "1880:1880"
|
||||
volumes:
|
||||
- node_red_data:/data
|
||||
|
||||
influxdb:
|
||||
image: influxdb:2.7
|
||||
container_name: influxdb
|
||||
restart: always
|
||||
ports:
|
||||
- "8086:8086"
|
||||
environment:
|
||||
- INFLUXDB_ADMIN_USER=${INFLUXDB_ADMIN_USER}
|
||||
- INFLUXDB_ADMIN_PASSWORD=${INFLUXDB_ADMIN_PASSWORD}
|
||||
- INFLUXDB_BUCKET=${INFLUXDB_BUCKET}
|
||||
- INFLUXDB_ORG=${INFLUXDB_ORG}
|
||||
volumes:
|
||||
- influxdb_data:/var/lib/influxdb2
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: grafana
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=${GF_SECURITY_ADMIN_USER}
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD}
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
depends_on:
|
||||
- influxdb
|
||||
|
||||
jenkins:
|
||||
image: jenkins/jenkins:lts
|
||||
container_name: jenkins
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:8080" # Web
|
||||
- "50000:50000" # Agents
|
||||
volumes:
|
||||
- jenkins_home:/var/jenkins_home
|
||||
|
||||
gitea:
|
||||
image: gitea/gitea:latest
|
||||
container_name: gitea
|
||||
restart: always
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
ports:
|
||||
- "3001:3000" # Webinterface (anders dan Grafana)
|
||||
- "222:22" # SSH voor Git
|
||||
volumes:
|
||||
- gitea_data:/data
|
||||
|
||||
proxymanager:
|
||||
image: jc21/nginx-proxy-manager:latest
|
||||
container_name: proxymanager
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80" # HTTP
|
||||
- "443:443" # HTTPS
|
||||
- "81:81" # Admin UI
|
||||
environment:
|
||||
DB_MYSQL_HOST: ${NPM_DB_MYSQL_HOST:-db}
|
||||
DB_MYSQL_PORT: ${NPM_DB_MYSQL_PORT:-3306}
|
||||
DB_MYSQL_USER: ${NPM_DB_MYSQL_USER}
|
||||
DB_MYSQL_PASSWORD: ${NPM_DB_MYSQL_PASSWORD}
|
||||
DB_MYSQL_NAME: ${NPM_DB_MYSQL_NAME}
|
||||
volumes:
|
||||
- proxymanager_data:/data
|
||||
- proxymanager_letsencrypt:/etc/letsencrypt
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
db:
|
||||
image: jc21/mariadb-aria:latest
|
||||
container_name: proxymanager_db
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE}
|
||||
MYSQL_USER: ${MYSQL_USER}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
volumes:
|
||||
- proxymanager_db_data:/var/lib/mysql
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3-management
|
||||
container_name: rabbitmq
|
||||
restart: always
|
||||
ports:
|
||||
- "5672:5672" # AMQP protocol voor apps
|
||||
- "15672:15672" # Management webinterface
|
||||
environment:
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS}
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
|
||||
volumes:
|
||||
rabbitmq_data:
|
||||
node_red_data:
|
||||
influxdb_data:
|
||||
grafana_data:
|
||||
jenkins_home:
|
||||
gitea_data:
|
||||
proxymanager_data:
|
||||
proxymanager_letsencrypt:
|
||||
proxymanager_db_data:
|
||||
30
test/README.md
Normal file
30
test/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# EVOLV cross-node test harness
|
||||
|
||||
This folder hosts end-to-end tests that wire **multiple** EVOLV domain
|
||||
classes together the same way Node-RED would, but in pure Node.js so the
|
||||
simulation runs deterministically and every internal value is inspectable.
|
||||
|
||||
**Scope rule.** Tests that exercise a single node's behaviour live in that
|
||||
node's submodule under `nodes/<name>/test/`. Tests here cross node
|
||||
boundaries — they instantiate `pumpingStation` + `machineGroupControl` +
|
||||
multiple `rotatingMachine`s together and drive the wired graph.
|
||||
|
||||
Examples of what belongs where:
|
||||
|
||||
| Concern | Lives in |
|
||||
|---|---|
|
||||
| MGC optimizer combination choice for a given demand | `nodes/machineGroupControl/test/integration/optimizer-combination-choice.integration.test.js` |
|
||||
| Pump curve interpolation across head values | `nodes/rotatingMachine/test/integration/...` |
|
||||
| PS hysteresis logic with mocked groups | `nodes/pumpingStation/test/integration/shifted-ramp-end-to-end.test.js` |
|
||||
| **Whole plant**: PS basin level + MGC dispatch + 3 pumps + physics simulator | `test/end-to-end-pumpingstation.test.js` (this folder) |
|
||||
|
||||
Run:
|
||||
|
||||
```
|
||||
node --test test/end-to-end-pumpingstation.test.js
|
||||
```
|
||||
|
||||
The harness in `lib/wiring.js` builds the parent-child relationships
|
||||
Node-RED would build via `registerChild`, lets you advance a controllable
|
||||
clock, and `lib/recorder.js` records every measurement / state / demand
|
||||
event into a flat trace.
|
||||
102
test/dead-zone-signal.integration.test.js
Normal file
102
test/dead-zone-signal.integration.test.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// Dead-zone signal contract: PS must emit the right percControl as level
|
||||
// crosses startLevel↓ → stopLevel↓. Schmitt-trigger semantics:
|
||||
//
|
||||
// - level > startLevel → percControl scales 0..100 % across
|
||||
// [startLevel, maxLevel] (engaged=true)
|
||||
// - stopLevel ≤ level ≤ start → percControl = deadZoneKeepAlivePercent
|
||||
// (engaged stays true on the way down)
|
||||
// - level < stopLevel → percControl = 0, MGC turnOffAllMachines
|
||||
// (engaged=false; rising edge re-arms
|
||||
// only at startLevel)
|
||||
//
|
||||
// Without this test, refactors of `_applyLevelbasedControl` could
|
||||
// silently break the hysteresis transitions and the demo would oscillate
|
||||
// or never stop pumping.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildPlant } = require('./lib/wiring');
|
||||
|
||||
const TICK_MS = 1000;
|
||||
|
||||
function readPercControl(ps) {
|
||||
return Number(ps.percControl) || 0;
|
||||
}
|
||||
|
||||
function readEngaged(ps) {
|
||||
return Boolean(ps._stopHystRunning);
|
||||
}
|
||||
|
||||
async function settle(plant, qIn_m3s, ms) {
|
||||
const { ps, advance } = plant;
|
||||
const ticks = Math.ceil(ms / TICK_MS);
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
ps.setManualInflow(qIn_m3s, Date.now(), 'm3/s');
|
||||
advance(TICK_MS);
|
||||
ps.tick();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
}
|
||||
|
||||
test('dead-zone Schmitt: percControl 100→1→0 across startLevel↓ stopLevel↓', async () => {
|
||||
// Start ABOVE startLevel so the rising edge has already fired (engaged
|
||||
// becomes true via the startup tick).
|
||||
const plant = buildPlant({ initialBasinLevel: 3.0 });
|
||||
const { ps, restore } = plant;
|
||||
try {
|
||||
// Tick once at zero inflow to let the controller register engaged.
|
||||
await settle(plant, 0, 1000);
|
||||
assert.ok(readEngaged(ps),
|
||||
`precondition: engaged should be true at level=3.0 above startLevel=2.5; got ${readEngaged(ps)}`);
|
||||
|
||||
// ---- Region A: above startLevel ----
|
||||
// level=3.0 → upPct = 50 % (linear over [2.5, 3.5]).
|
||||
// (We don't lock the exact value — just assert it's well above the
|
||||
// keep-alive 1 % to confirm we're on the "engaged + above start" path.)
|
||||
await settle(plant, 0, 2000);
|
||||
const pcAbove = readPercControl(ps);
|
||||
assert.ok(pcAbove > 10,
|
||||
`Region A: at level≈3.0 m, percControl should be the ramp value (>>1 %); got ${pcAbove.toFixed(2)} %`);
|
||||
|
||||
// Manually drop level into the dead band [stopLevel=2.0, startLevel=2.5]
|
||||
// by calibrating instead of waiting for physical drain (this isolates
|
||||
// the Schmitt-trigger logic from physics).
|
||||
ps.calibratePredictedLevel(2.3);
|
||||
await settle(plant, 0, 1000);
|
||||
const pcDead = readPercControl(ps);
|
||||
const engagedDead = readEngaged(ps);
|
||||
assert.ok(engagedDead,
|
||||
`Region B: engaged should remain true while in dead band [stopLevel, startLevel]; got false`);
|
||||
// Keep-alive default in psConfig is 1 %.
|
||||
assert.ok(pcDead >= 0.5 && pcDead <= 5,
|
||||
`Region B: at level=2.3 in dead band, percControl should be the keep-alive value (~1 %); got ${pcDead.toFixed(2)} %`);
|
||||
|
||||
// Drop below stopLevel — falling-edge disengage.
|
||||
ps.calibratePredictedLevel(1.9);
|
||||
await settle(plant, 0, 1000);
|
||||
const pcOff = readPercControl(ps);
|
||||
const engagedOff = readEngaged(ps);
|
||||
assert.equal(pcOff, 0,
|
||||
`Region C: below stopLevel=2.0, percControl must be 0; got ${pcOff}`);
|
||||
assert.equal(engagedOff, false,
|
||||
`Region C: below stopLevel, engaged must flip to false; got ${engagedOff}`);
|
||||
|
||||
// Refill into the dead band — engaged should stay false (no rising
|
||||
// edge yet — needs to cross startLevel).
|
||||
ps.calibratePredictedLevel(2.3);
|
||||
await settle(plant, 0, 1000);
|
||||
const pcDeadAgain = readPercControl(ps);
|
||||
assert.equal(readEngaged(ps), false,
|
||||
`Region D: re-entered dead band from below stopLevel — engaged must stay false until level crosses startLevel`);
|
||||
assert.equal(pcDeadAgain, 0,
|
||||
`Region D: in dead band but not engaged → percControl must be 0; got ${pcDeadAgain}`);
|
||||
|
||||
// Cross startLevel → engaged re-arms.
|
||||
ps.calibratePredictedLevel(2.6);
|
||||
await settle(plant, 0, 1000);
|
||||
assert.equal(readEngaged(ps), true,
|
||||
`Region E: rising edge at startLevel must set engaged=true`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
192
test/end-to-end-pumpingstation.test.js
Normal file
192
test/end-to-end-pumpingstation.test.js
Normal file
@@ -0,0 +1,192 @@
|
||||
// End-to-end test: PS + MGC + 3 pumps wired exactly like the
|
||||
// pumpingstation-complete-example demo, driven by a controllable clock.
|
||||
//
|
||||
// Verifies:
|
||||
// 1. Basin starts low (below stopLevel) — pumps OFF.
|
||||
// 2. Basin fills to startLevel — first pump engages.
|
||||
// 3. Basin drains through the dead band [stopLevel, startLevel] —
|
||||
// pump stays engaged at minimum flow.
|
||||
// 4. Basin reaches stopLevel — pump disengages, basin refills.
|
||||
// 5. Storm inflow → all 3 pumps engage at high flow.
|
||||
|
||||
const test = require('node:test');
|
||||
const { buildPlant, injectPumpPressure } = require('./lib/wiring');
|
||||
const { attachRecorder, snapshotFull, snapshotMachineState } = require('./lib/recorder');
|
||||
|
||||
const TICK_MS = 1000;
|
||||
const STATIC_HEAD_M = 12;
|
||||
const RHO_G = 9810;
|
||||
const DYN_HEAD_M_AT_FULL_FLOW = 12;
|
||||
const TOTAL_FLOW_MAX_M3H = 300;
|
||||
const OUTFLOW_LEVEL_M = 0.3;
|
||||
|
||||
function physics({ basinLevelM, totalPumpFlow_m3h }) {
|
||||
const headM = Math.max(0, basinLevelM - OUTFLOW_LEVEL_M);
|
||||
const upstreamPa = RHO_G * headM;
|
||||
const ratio = Math.min(1, totalPumpFlow_m3h / TOTAL_FLOW_MAX_M3H);
|
||||
const downstreamPa = RHO_G * (STATIC_HEAD_M + ratio * ratio * DYN_HEAD_M_AT_FULL_FLOW);
|
||||
return { upstreamPa, downstreamPa };
|
||||
}
|
||||
|
||||
function totalPumpFlow_m3h(pumps) {
|
||||
let s = 0;
|
||||
for (const p of pumps) {
|
||||
const f = p.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue('m3/h') || 0;
|
||||
s += Number(f);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
async function tick(plant, { qIn_m3s }) {
|
||||
const { ps, pumps, advance } = plant;
|
||||
const basinLevelM = ps.measurements.type('level').variant('predicted')
|
||||
.position('atequipment').getCurrentValue('m') ?? 0;
|
||||
const tot = totalPumpFlow_m3h(pumps);
|
||||
const { upstreamPa, downstreamPa } = physics({ basinLevelM, totalPumpFlow_m3h: tot });
|
||||
for (const p of pumps) injectPumpPressure(p, upstreamPa, downstreamPa);
|
||||
ps.setManualInflow(qIn_m3s, Date.now(), 'm3/s');
|
||||
advance(TICK_MS);
|
||||
ps.tick();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
|
||||
test('PS + MGC + 3 pumps — full hysteresis cycle (5/15 nominal)', async () => {
|
||||
// Start at 2.4 m — just below startLevel(2.5) — so we see the rising
|
||||
// edge in a few minutes instead of 30. Then observe the full cycle.
|
||||
const plant = buildPlant({ initialBasinLevel: 2.4 });
|
||||
const rec = attachRecorder(plant);
|
||||
const { ps, mgc, pumps, restore } = plant;
|
||||
|
||||
try {
|
||||
console.log('\n=========================================================');
|
||||
console.log(' POST-WIRING SNAPSHOT');
|
||||
console.log('=========================================================');
|
||||
const initSnap = snapshotFull(ps, mgc, pumps);
|
||||
console.log(JSON.stringify(initSnap, null, 2));
|
||||
console.log('\nMGC absoluteTotals (m³/h):',
|
||||
`min=${(mgc.absoluteTotals.flow.min*3600).toFixed(0)}, max=${(mgc.absoluteTotals.flow.max*3600).toFixed(0)}`);
|
||||
console.log('MGC dynamicTotals (m³/h):',
|
||||
`min=${(mgc.dynamicTotals.flow.min*3600).toFixed(0)}, max=${(mgc.dynamicTotals.flow.max*3600).toFixed(0)}`);
|
||||
|
||||
// Phase 1: nominal inflow ≈ 25 m³/h → expect cycle ~5 on / ~15 off.
|
||||
const NOMINAL_QIN = 25 / 3600; // m³/s
|
||||
console.log('\n=========================================================');
|
||||
console.log(' PHASE 1: nominal inflow 25 m³/h — observe one full cycle.');
|
||||
console.log(' Expected: basin rises from 1.5 m to 2.5 m (off, ~?? min), pump kicks on, drains to 2.0 m (on, ~5 min), repeats.');
|
||||
console.log('=========================================================');
|
||||
|
||||
const phase1Trace = [];
|
||||
let firstEngageTick = null;
|
||||
let firstDisengageTick = null;
|
||||
let secondEngageTick = null;
|
||||
for (let i = 0; i < 1800; i++) { // 30 min sim
|
||||
await tick(plant, { qIn_m3s: NOMINAL_QIN });
|
||||
const snap = snapshotFull(ps, mgc, pumps);
|
||||
const tickIdx = i + 1;
|
||||
phase1Trace.push({ s: tickIdx, ...snap });
|
||||
const anyEngaged = pumps.some(p =>
|
||||
['operational', 'starting', 'warmingup', 'accelerating'].includes(p.state.getCurrentState())
|
||||
);
|
||||
if (anyEngaged && firstEngageTick == null) firstEngageTick = tickIdx;
|
||||
if (firstEngageTick != null && firstDisengageTick == null && !anyEngaged) firstDisengageTick = tickIdx;
|
||||
if (firstDisengageTick != null && secondEngageTick == null && anyEngaged) secondEngageTick = tickIdx;
|
||||
// Stop after we observe a full off→on→off→on cycle so we can measure both phases.
|
||||
if (secondEngageTick != null && tickIdx > secondEngageTick + 60) break;
|
||||
}
|
||||
printCompactTrace(decimateTrace(phase1Trace, 30));
|
||||
|
||||
console.log('\n-- cycle landmarks --');
|
||||
console.log(`First pump engage : tick ${firstEngageTick} (level=${phase1Trace[firstEngageTick - 1]?.psLevel})`);
|
||||
console.log(`First pump disengage: tick ${firstDisengageTick} (level=${phase1Trace[firstDisengageTick - 1]?.psLevel})`);
|
||||
console.log(`Second engage : tick ${secondEngageTick} (level=${phase1Trace[secondEngageTick - 1]?.psLevel})`);
|
||||
if (firstEngageTick && firstDisengageTick) {
|
||||
const onMin = (firstDisengageTick - firstEngageTick) / 60;
|
||||
console.log(`On phase duration : ${onMin.toFixed(1)} min (target ≈ 5 min)`);
|
||||
}
|
||||
if (firstDisengageTick && secondEngageTick) {
|
||||
const offMin = (secondEngageTick - firstDisengageTick) / 60;
|
||||
console.log(`Off phase duration : ${offMin.toFixed(1)} min (target ≈ 15 min)`);
|
||||
}
|
||||
|
||||
// Phase 2: storm inflow → all 3 pumps should engage.
|
||||
console.log('\n=========================================================');
|
||||
console.log(' PHASE 2: storm inflow 250 m³/h — expect all 3 pumps engaged.');
|
||||
console.log('=========================================================');
|
||||
const STORM_QIN = 250 / 3600;
|
||||
const phase2Trace = [];
|
||||
for (let i = 0; i < 600; i++) { // 10 min storm
|
||||
await tick(plant, { qIn_m3s: STORM_QIN });
|
||||
const snap = snapshotFull(ps, mgc, pumps);
|
||||
phase2Trace.push({ s: phase1Trace.length + i + 1, ...snap });
|
||||
}
|
||||
printCompactTrace(decimateTrace(phase2Trace, 30));
|
||||
|
||||
const peak = phase2Trace.reduce((acc, s) => {
|
||||
const running = Object.values(s.pumps).filter(p =>
|
||||
['operational', 'accelerating', 'warmingup', 'starting'].includes(p.state)
|
||||
).length;
|
||||
return Math.max(acc, running);
|
||||
}, 0);
|
||||
console.log(`\nPeak concurrent running pumps during storm: ${peak} / 3`);
|
||||
const maxLvl = phase2Trace.reduce((acc, s) => Math.max(acc, s.psLevel ?? 0), 0);
|
||||
console.log(`Max basin level during storm: ${maxLvl.toFixed(2)} m`);
|
||||
|
||||
// Phase 3: inflow drops back to nominal — expect graceful unwind.
|
||||
console.log('\n=========================================================');
|
||||
console.log(' PHASE 3: storm subsides → 25 m³/h. Expect graceful unwind.');
|
||||
console.log('=========================================================');
|
||||
const phase3Trace = [];
|
||||
for (let i = 0; i < 900; i++) {
|
||||
await tick(plant, { qIn_m3s: NOMINAL_QIN });
|
||||
const snap = snapshotFull(ps, mgc, pumps);
|
||||
phase3Trace.push({ s: phase1Trace.length + phase2Trace.length + i + 1, ...snap });
|
||||
const anyEngaged = pumps.some(p =>
|
||||
['operational', 'starting'].includes(p.state.getCurrentState())
|
||||
);
|
||||
if (!anyEngaged) break;
|
||||
}
|
||||
printCompactTrace(decimateTrace(phase3Trace, 30));
|
||||
|
||||
// Diagnostics summary.
|
||||
console.log('\n=========================================================');
|
||||
console.log(' SUMMARY');
|
||||
console.log('=========================================================');
|
||||
const ctrlAnomalies = phase1Trace.filter(s =>
|
||||
Object.values(s.pumps).some(p =>
|
||||
p.state === 'operational' && (p.ctrl_pct === 0 || p.ctrl_pct == null) && p.flow_m3h > 1
|
||||
)
|
||||
).length;
|
||||
console.log(`Bug 3 leftover (ctrl=0 while operational delivering flow): ${ctrlAnomalies} ticks`);
|
||||
const optimalEvents = rec.events.filter(e => e.kind === 'mgc.optimalControl.out' && e.Qd > 0);
|
||||
console.log(`MGC optimalControl invocations with Qd>0: ${optimalEvents.length}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// Reduce noise by sampling every Nth tick + always include first/last.
|
||||
function decimateTrace(rows, step) {
|
||||
if (rows.length <= step * 2) return rows;
|
||||
const out = [rows[0]];
|
||||
for (let i = step; i < rows.length - 1; i += step) out.push(rows[i]);
|
||||
out.push(rows[rows.length - 1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function printCompactTrace(rows) {
|
||||
if (rows.length === 0) { console.log('(empty)'); return; }
|
||||
console.log(' s level vol dir pct d_min d_max pumpA pumpB pumpC');
|
||||
console.log(' ─ ───── ───── ──────── ─────── ───── ───── ─────────────── ─────────────── ───────────────');
|
||||
for (const r of rows) {
|
||||
const fmtPump = (p) => {
|
||||
if (!p) return ''.padEnd(15);
|
||||
return `${(p.state ?? '?').slice(0,8).padEnd(8)} c${(p.ctrl_pct ?? 0).toFixed(0).padStart(3)} f${(p.flow_m3h ?? 0).toFixed(0).padStart(3)}`.padEnd(15);
|
||||
};
|
||||
const a = fmtPump(r.pumps.pump_a);
|
||||
const b = fmtPump(r.pumps.pump_b);
|
||||
const c = fmtPump(r.pumps.pump_c);
|
||||
console.log(
|
||||
`${String(r.s).padStart(4)} ${(r.psLevel ?? 0).toFixed(3)} ${(r.psVolume ?? 0).toFixed(2).padStart(5)} ${(r.psDirection ?? '?').padEnd(8)} ${(r.psPercControl ?? 0).toFixed(2).padStart(7)} ${(r.mgc?.dynamicMin_m3h ?? 0).toFixed(0).padStart(5)} ${(r.mgc?.dynamicMax_m3h ?? 0).toFixed(0).padStart(5)} ${a} ${b} ${c}`
|
||||
);
|
||||
}
|
||||
}
|
||||
108
test/inflow-overcapacity-stability.integration.test.js
Normal file
108
test/inflow-overcapacity-stability.integration.test.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// Stability under inflow > station capacity: storm condition where the
|
||||
// basin overflows continuously. Pumps should run flat-out and the FSM
|
||||
// must NOT thrash through aborts/parks.
|
||||
//
|
||||
// Catches the user's live observation: at 2× capacity inflow, pumps got
|
||||
// stuck mid-flight while demand was still rising. This test runs with
|
||||
// realistic state.time (production defaults) so the abort-during-startup
|
||||
// race window is fully open.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildPlant, injectPumpPressure } = require('./lib/wiring');
|
||||
|
||||
const TICK_MS = 1000;
|
||||
// Sim duration kept short — the chronic thrashing pattern shows up
|
||||
// within the first minute. Bigger SIM_MINUTES makes the test wall-time
|
||||
// hostile (each tick awaits async pump moves on real timers).
|
||||
const SIM_SECONDS = 45;
|
||||
|
||||
test('inflow ≫ capacity: pumps reach steady high-ctrl, no parking, no thrashing', async () => {
|
||||
// Use shorter-than-default state.time so the test runs in reasonable
|
||||
// wall time while still exercising the transient (1 s startup + 2 s
|
||||
// warmup). The race conditions we care about are the same — they're
|
||||
// about ORDER, not absolute duration.
|
||||
// Start at maxLevel so PS percControl is immediately 100 % (the
|
||||
// storm condition). Otherwise the basin needs to fill to maxLevel
|
||||
// first, which on a 2× capacity inflow takes ~2 minutes — longer
|
||||
// than this test's wall time.
|
||||
const plant = buildPlant({ initialBasinLevel: 3.5 });
|
||||
const { ps, mgc, pumps, advance, restore } = plant;
|
||||
try {
|
||||
// Pre-start pumps to operational so the test focuses on STEADY-STATE
|
||||
// thrashing under chronic over-capacity inflow, not startup race
|
||||
// conditions (those have their own test). This also keeps wall time
|
||||
// manageable — buildPlant's state.time=0 means transitions are
|
||||
// instant once already operational.
|
||||
for (const p of pumps) await p.handleInput('parent', 'execsequence', 'startup');
|
||||
|
||||
// Inflow set 2× station capacity (~600 m³/h vs ~270 m³/h capacity).
|
||||
const Q_IN = 600 / 3600;
|
||||
|
||||
let parkObservations = 0;
|
||||
let abortLogObservations = 0;
|
||||
|
||||
// Drive the loop: every tick, refresh pressures, set inflow,
|
||||
// tick PS (which fires _applyMachineGroupLevelControl). The
|
||||
// settlePerTickMs wait is REAL wall-clock so movementManager's
|
||||
// setInterval timers actually fire between handleInputs — without
|
||||
// it the test runs too fast for moves to progress and pumps look
|
||||
// permanently parked even when production behaviour is fine.
|
||||
const ticks = SIM_SECONDS;
|
||||
const settlePerTickMs = 200;
|
||||
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let lastCtrl = pumps.map(() => 0);
|
||||
let largeJumpTicks = 0;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
for (const p of pumps) injectPumpPressure(p, 19620, 117720);
|
||||
ps.setManualInflow(Q_IN, Date.now(), 'm3/s');
|
||||
advance(TICK_MS);
|
||||
ps.tick();
|
||||
await realSleep(settlePerTickMs);
|
||||
|
||||
const states = pumps.map((p) => p.state.getCurrentState());
|
||||
const ctrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0);
|
||||
|
||||
// Park observation: any pump in 'accelerating'/'decelerating' for
|
||||
// more than 3 consecutive seconds at flat ctrl is parked. Cheap
|
||||
// approximation: count how often we sample those states.
|
||||
for (const s of states) {
|
||||
if (s === 'accelerating' || s === 'decelerating') parkObservations += 1;
|
||||
}
|
||||
|
||||
// Thrashing observation: ctrl jumping by > 30 % between consecutive
|
||||
// seconds (in either direction) suggests retarget churn.
|
||||
for (let k = 0; k < pumps.length; k++) {
|
||||
if (Math.abs(ctrls[k] - lastCtrl[k]) > 30) largeJumpTicks += 1;
|
||||
}
|
||||
lastCtrl = ctrls;
|
||||
|
||||
if (i === Math.floor(ticks * 0.66)) {
|
||||
console.log(` tick ${i}/${ticks} states=[${states.join(', ')}] ctrls=[${ctrls.map((c) => c.toFixed(0)).join(', ')}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// After SIM_MINUTES, system must be in a coherent state: pumps high
|
||||
// ctrl, no one parked.
|
||||
const finalStates = pumps.map((p) => p.state.getCurrentState());
|
||||
const finalCtrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0);
|
||||
console.log(` FINAL states=[${finalStates.join(', ')}] ctrls=[${finalCtrls.map((c) => c.toFixed(1)).join(', ')}]`);
|
||||
console.log(` Park observations across ${ticks} ticks×3 pumps: ${parkObservations}`);
|
||||
console.log(` Large-jump tick events (>30 % ctrl change s-to-s): ${largeJumpTicks}`);
|
||||
|
||||
for (const s of finalStates) {
|
||||
assert.equal(s, 'operational',
|
||||
`final state must be operational under steady high demand; one pump in '${s}'`);
|
||||
}
|
||||
for (const c of finalCtrls) {
|
||||
assert.ok(c > 80, `final ctrl must be >80 % under storm inflow; got ${c.toFixed(1)} %`);
|
||||
}
|
||||
// Allow some movement transients but not constant retargeting.
|
||||
// 3 pumps × 180 ticks = 540 samples; >25 % churn is a thrash signal.
|
||||
const maxAllowedJumps = Math.floor(ticks * 3 * 0.25);
|
||||
assert.ok(largeJumpTicks < maxAllowedJumps,
|
||||
`excessive ctrl thrash: ${largeJumpTicks} large-jump events (max ${maxAllowedJumps}) — system isn't converging`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
116
test/lib/recorder.js
Normal file
116
test/lib/recorder.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// Trace recorder — hooks into every emitter and timer-driven path on a
|
||||
// wired plant and records ALL events into a flat list with timestamps.
|
||||
//
|
||||
// Captures:
|
||||
// - Per-pump state transitions (state.emitter on 'state-change' or via
|
||||
// polling getCurrentState() before/after each tick).
|
||||
// - Per-pump pressure events (measurements.emitter on
|
||||
// 'pressure.measured.{upstream,downstream,differential}').
|
||||
// - Per-pump flow / power / ctrl events (predicted variants).
|
||||
// - MGC dynamic totals (after each calcDynamicTotals).
|
||||
// - PS percControl + level + volume + safetyState (after each tick).
|
||||
// - MGC bestCombination (instrument by wrapping optimalControl).
|
||||
// - Pump operating points: individual predictFlow.currentF and
|
||||
// groupPredictFlow.currentF (per tick, post-equalization).
|
||||
|
||||
const POSITIONS = ['upstream', 'downstream', 'differential'];
|
||||
|
||||
function attachRecorder({ ps, mgc, pumps }) {
|
||||
const events = [];
|
||||
const push = (kind, data) => events.push({ t: Date.now(), kind, ...data });
|
||||
|
||||
// --- pump-level: pressure events ---
|
||||
for (const pump of pumps) {
|
||||
const id = pump.config.general.id;
|
||||
for (const pos of POSITIONS) {
|
||||
const ev = `pressure.measured.${pos}`;
|
||||
pump.measurements.emitter.on(ev, (e) => push('pump.pressure', {
|
||||
pump: id, pos, value: e?.value, unit: e?.unit,
|
||||
}));
|
||||
}
|
||||
// flow / power predicted (rotatingMachine emits these on state changes
|
||||
// and movement updates).
|
||||
pump.measurements.emitter.on('flow.predicted.downstream', (e) => push('pump.flow.predicted', {
|
||||
pump: id, value: e?.value, unit: e?.unit,
|
||||
}));
|
||||
pump.measurements.emitter.on('power.predicted.atequipment', (e) => push('pump.power.predicted', {
|
||||
pump: id, value: e?.value, unit: e?.unit,
|
||||
}));
|
||||
pump.measurements.emitter.on('ctrl.predicted.atequipment', (e) => push('pump.ctrl.predicted', {
|
||||
pump: id, value: e?.value, unit: e?.unit,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- MGC bestCombination: wrap optimalControl ---
|
||||
const origOptimal = mgc.optimalControl.bind(mgc);
|
||||
mgc.optimalControl = async function (Qd, powerCap = Infinity) {
|
||||
push('mgc.optimalControl.in', { Qd, powerCap });
|
||||
const before = snapshotMachineState(pumps);
|
||||
const result = await origOptimal(Qd, powerCap);
|
||||
const after = snapshotMachineState(pumps);
|
||||
push('mgc.optimalControl.out', {
|
||||
Qd,
|
||||
headerDiffPa: pumps[0]?.groupPredictFlow?.currentF,
|
||||
indivDiffPaPerPump: Object.fromEntries(pumps.map(p => [p.config.general.id, p.predictFlow?.currentF])),
|
||||
groupDiffPaPerPump: Object.fromEntries(pumps.map(p => [p.config.general.id, p.groupPredictFlow?.currentF])),
|
||||
// capture state before/after to spot transitions caused by this optimal
|
||||
stateBefore: before, stateAfter: after,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
return { events, push };
|
||||
}
|
||||
|
||||
function snapshotMachineState(pumps) {
|
||||
return Object.fromEntries(pumps.map(p => [
|
||||
p.config.general.id,
|
||||
p.state?.getCurrentState?.() ?? '?'
|
||||
]));
|
||||
}
|
||||
|
||||
function snapshotFull(ps, mgc, pumps) {
|
||||
const level = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
const volume = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
return {
|
||||
psLevel: round3(level),
|
||||
psVolume: round3(volume),
|
||||
psPercControl: round3(ps.percControl),
|
||||
psSafety: ps.safetyControllerActive,
|
||||
psDirection: ps.state?.direction,
|
||||
psNetFlow_m3h: round3((ps.state?.netFlow ?? 0) * 3600),
|
||||
pumps: Object.fromEntries(pumps.map(p => {
|
||||
const id = p.config.general.id;
|
||||
const flowPred = p.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue('m3/h');
|
||||
const powerPred = p.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('kW');
|
||||
const ctrlPred = p.measurements.type('ctrl').variant('predicted').position('atEquipment').getCurrentValue();
|
||||
const upPred = p.measurements.type('pressure').variant('measured').position('upstream').getCurrentValue('mbar');
|
||||
const dnPred = p.measurements.type('pressure').variant('measured').position('downstream').getCurrentValue('mbar');
|
||||
return [id, {
|
||||
state: p.state?.getCurrentState?.(),
|
||||
ctrl_pct: round3(ctrlPred),
|
||||
flow_m3h: round3(flowPred),
|
||||
power_kW: round3(powerPred),
|
||||
pUp_mbar: round3(upPred),
|
||||
pDn_mbar: round3(dnPred),
|
||||
indivDiff_mbar: round3((p.predictFlow?.currentF ?? 0) / 100),
|
||||
groupDiff_mbar: round3((p.groupPredictFlow?.currentF ?? 0) / 100),
|
||||
NCog: round3(p.NCog),
|
||||
groupNCog: round3(p.groupNCog),
|
||||
}];
|
||||
})),
|
||||
mgc: {
|
||||
scaling: mgc.scaling,
|
||||
mode: mgc.mode,
|
||||
dynamicMin_m3h: round3((mgc.dynamicTotals?.flow?.min ?? 0) * 3600),
|
||||
dynamicMax_m3h: round3((mgc.dynamicTotals?.flow?.max ?? 0) * 3600),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function round3(v) {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return v;
|
||||
return Math.round(v * 1000) / 1000;
|
||||
}
|
||||
|
||||
module.exports = { attachRecorder, snapshotFull, snapshotMachineState };
|
||||
152
test/lib/wiring.js
Normal file
152
test/lib/wiring.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// Wiring helpers for cross-node end-to-end tests.
|
||||
//
|
||||
// Builds a small physical plant in pure JS:
|
||||
// - 3 rotatingMachine pumps (centrifugal, identical curves)
|
||||
// - 1 machineGroupControl coordinating them
|
||||
// - 1 pumpingStation owning a wet-well basin and the MGC
|
||||
//
|
||||
// Pumps register as children of the MGC. The MGC registers as a child of
|
||||
// the PS. This mirrors what Node-RED's registerChild messages do at runtime.
|
||||
//
|
||||
// A controllable clock replaces Date.now so _updatePredictedVolume's deltaT
|
||||
// is exact regardless of wall-clock time.
|
||||
|
||||
const PumpingStation = require('../../nodes/pumpingStation/src/specificClass');
|
||||
const MachineGroup = require('../../nodes/machineGroupControl/src/specificClass');
|
||||
const Machine = require('../../nodes/rotatingMachine/src/specificClass');
|
||||
|
||||
// ---------------- configs (mirror what the demo flow ships) ----------------
|
||||
|
||||
function pumpConfig(id) {
|
||||
return {
|
||||
general: { id, name: id, unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller',
|
||||
positionVsParent: 'atEquipment' },
|
||||
asset: { category: 'pump', type: 'centrifugal',
|
||||
model: 'hidrostal-H05K-S03R', supplier: 'hidrostal',
|
||||
curveUnits: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' } },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
allowedSources: { auto: ['parent', 'GUI'] },
|
||||
},
|
||||
sequences: {
|
||||
startup: ['starting', 'warmingup', 'operational'],
|
||||
shutdown: ['stopping', 'coolingdown', 'idle'],
|
||||
emergencystop: ['emergencystop', 'off'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pumpStateConfig() {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' } },
|
||||
state: { current: 'idle' },
|
||||
movement: { mode: 'staticspeed', speed: 1200, maxSpeed: 1800, interval: 10 },
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function mgcConfig() {
|
||||
return {
|
||||
general: { name: 'mgc', id: 'mgc', logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller',
|
||||
positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
function psConfig(overrides = {}) {
|
||||
return {
|
||||
general: { id: 'ps', name: 'ps', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' },
|
||||
flowThreshold: 1e-4 },
|
||||
functionality: { softwareType: 'pumpingstation', role: 'stationcontroller',
|
||||
positionVsParent: 'atEquipment' },
|
||||
basin: {
|
||||
// Sized so the [stopLevel,startLevel] band holds enough water that
|
||||
// a single pump at min flow (~99 m³/h) drains for ~5 min while
|
||||
// nominal inflow (~25 m³/h) refills it in ~15 min.
|
||||
// 0.5 m × 12.5 m² = 6.25 m³ (drain time = 6.25 / (99-25) m³/h ≈ 5 min)
|
||||
volume: 50, height: 4,
|
||||
inflowLevel: 2.5, outflowLevel: 0.3, overflowLevel: 3.8,
|
||||
inletPipeDiameter: 0.4, outletPipeDiameter: 0.3,
|
||||
},
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: {
|
||||
minLevel: 0.5, startLevel: 2.5, stopLevel: 2.0, maxLevel: 3.5,
|
||||
curveType: 'linear', logCurveFactor: 9,
|
||||
deadZoneKeepAlivePercent: 1, // % sent to MGC while engaged in [stopLvl, startLevel]
|
||||
enableShiftedRamp: false, shiftLevel: null, shiftArmPercent: 95,
|
||||
},
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true, enableOverfillProtection: true,
|
||||
dryRunThresholdPercent: 5, highVolumeSafetyThresholdPercent: 95,
|
||||
overfillThresholdPercent: 95, timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- harness ----------------
|
||||
|
||||
function buildPlant({ initialBasinLevel = 2.0 } = {}) {
|
||||
const ps = new PumpingStation(psConfig());
|
||||
const mgc = new MachineGroup(mgcConfig());
|
||||
const pumps = ['pump_a', 'pump_b', 'pump_c'].map(id => new Machine(pumpConfig(id), pumpStateConfig()));
|
||||
|
||||
// Inject initial pressure on each pump so predictFlow / predictPower /
|
||||
// predictCtrl have a real fDimension before MGC starts asking. Real
|
||||
// values are set every tick by the physics step.
|
||||
for (const m of pumps) injectPumpPressure(m, /* upstreamPa */ 19620, /* downstreamPa */ 117720);
|
||||
|
||||
// Wire pumps → MGC.
|
||||
for (const m of pumps) mgc.childRegistrationUtils.registerChild(m, m.config.functionality.positionVsParent);
|
||||
// Wire MGC → PS.
|
||||
ps.childRegistrationUtils.registerChild(mgc, mgc.config.functionality.positionVsParent);
|
||||
|
||||
mgc.calcAbsoluteTotals();
|
||||
mgc.calcDynamicTotals();
|
||||
|
||||
// Calibrate basin level to start point.
|
||||
ps.calibratePredictedLevel(initialBasinLevel);
|
||||
|
||||
// Controllable clock — overrides Date.now ONLY for our process.
|
||||
let now = Date.now();
|
||||
const realNow = Date.now;
|
||||
Date.now = () => now;
|
||||
ps._predictedFlowState.lastTimestamp = now;
|
||||
|
||||
function advance(ms) { now += ms; }
|
||||
function restore() { Date.now = realNow; }
|
||||
|
||||
return { ps, mgc, pumps, advance, restore, get now() { return now; } };
|
||||
}
|
||||
|
||||
// Convert mbar to Pa for the rotatingMachine canonical pressure unit.
|
||||
function mbarToPa(mbar) { return mbar * 100; }
|
||||
function paToMbar(Pa) { return Pa / 100; }
|
||||
|
||||
// Inject upstream + downstream pressure measurements onto a pump as if a
|
||||
// pressure-sensor child had emitted them. updateMeasuredPressure is the
|
||||
// same path the rotatingMachine listens on for sensor children, so this
|
||||
// fires the pump's "pressure.measured.<position>" emitter — which the MGC
|
||||
// is also subscribed to, so totals recompute identically.
|
||||
function injectPumpPressure(pump, upstreamPa, downstreamPa, ts = Date.now()) {
|
||||
pump.updateMeasuredPressure(paToMbar(upstreamPa), 'upstream',
|
||||
{ timestamp: ts, unit: 'mbar', childName: 'PT-up', childId: `up-${pump.config.general.id}` });
|
||||
pump.updateMeasuredPressure(paToMbar(downstreamPa), 'downstream',
|
||||
{ timestamp: ts, unit: 'mbar', childName: 'PT-dn', childId: `dn-${pump.config.general.id}` });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildPlant,
|
||||
injectPumpPressure,
|
||||
mbarToPa, paToMbar,
|
||||
};
|
||||
121
test/mgc-overactive-demand-serialization.integration.test.js
Normal file
121
test/mgc-overactive-demand-serialization.integration.test.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// Regression: MGC must serialize concurrent handleInput calls.
|
||||
//
|
||||
// Live observation (2026-05-09): PS sends a fresh demand% every 1 s as
|
||||
// basin level drifts. Each MGC.handleInput unconditionally calls
|
||||
// abortActiveMovements — so an in-flight pump ramp gets killed, the
|
||||
// pump's setpoint is replaced, the new ramp gets killed by the next
|
||||
// tick, and the loop never settles. Real symptom: 120
|
||||
// "Aborting active movements ..." log lines per 2 min while a single
|
||||
// pump randomly leads (138 m³/h) and the others are clamped at minFlow
|
||||
// (60 m³/h, "near_curve_edge").
|
||||
//
|
||||
// Proper design (mirrors rotatingMachine state.delayedMove): when a
|
||||
// handleInput is already in-flight (pumps still moving), save the new
|
||||
// demand to a delayed slot and return. When the in-flight dispatch
|
||||
// finishes, pick up the latest delayed demand. Latest-wins —
|
||||
// intermediate values are stomped because they were obsolete by the
|
||||
// time the pumps were ready for them.
|
||||
//
|
||||
// Fail mode this catches: any future change that re-introduces
|
||||
// concurrent handleInput entries (or removes the gate) will explode the
|
||||
// abort count and leave pumps unbalanced.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildPlant, injectPumpPressure } = require('./lib/wiring');
|
||||
|
||||
test('MGC serializes overactive demand — one dispatch in flight at a time, latest queued for pickup', async () => {
|
||||
const plant = buildPlant({ initialBasinLevel: 2.6 });
|
||||
const { mgc, pumps, restore } = plant;
|
||||
try {
|
||||
// Realistic ramp time so the in-flight window is wide enough that
|
||||
// multiple PS calls land during it.
|
||||
for (const p of pumps) {
|
||||
p.state.config.time = { starting: 1, warmingup: 1, stopping: 1, coolingdown: 1 };
|
||||
injectPumpPressure(p, 19620, 117720);
|
||||
}
|
||||
// Bring pumps to operational once so the test focuses on
|
||||
// STEADY-STATE thrash (not startup).
|
||||
for (const p of pumps) await p.handleInput('parent', 'execsequence', 'startup');
|
||||
|
||||
// Count how many times MGC actually issues an abort. Wrap the
|
||||
// existing method so the contract is enforced regardless of
|
||||
// implementation details.
|
||||
let abortCount = 0;
|
||||
const originalAbort = mgc.abortActiveMovements.bind(mgc);
|
||||
mgc.abortActiveMovements = async (reason) => {
|
||||
abortCount += 1;
|
||||
return originalAbort(reason);
|
||||
};
|
||||
|
||||
// Simulate PS jitter: 30 demand calls fired back-to-back without
|
||||
// awaiting (mirrors PS._applyMachineGroupLevelControl firing into
|
||||
// an MGC whose previous handleInput is still settling pumps).
|
||||
// Tiny percControl drift around 14 % matches what we observed live.
|
||||
const calls = [];
|
||||
const baseDemand = 14;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = baseDemand + (i % 5) * 0.05;
|
||||
// Fire-and-forget — the gate must absorb the burst.
|
||||
calls.push(mgc.handleInput('parent', d).catch(() => {}));
|
||||
}
|
||||
await Promise.all(calls);
|
||||
// Let any deferred pickup settle.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
// Contract: with a serialization gate, concurrent burst yields at
|
||||
// most TWO real dispatches (the first that wins entry, plus one
|
||||
// queued pickup carrying the latest value). Without the gate, every
|
||||
// call aborts → abortCount equals call count.
|
||||
//
|
||||
// We assert ≤ 5 to allow for legitimate sequential dispatch that
|
||||
// span a few ticks but block the runaway-thrash mode.
|
||||
assert.ok(abortCount <= 5,
|
||||
`MGC issued ${abortCount} aborts for 30 concurrent demand calls — gate not serializing dispatches (live system showed 1 abort/sec / 120 per 2 min with this exact bug).`);
|
||||
|
||||
// Whatever combination the optimizer picks, all selected pumps
|
||||
// must reach a non-floor ctrl. Pump_b stuck at the curve floor was
|
||||
// the live failure — the gate fixes it because the ramp completes
|
||||
// before the next demand starts.
|
||||
const finalCtrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0);
|
||||
const activePumps = finalCtrls.filter((c) => c > 0);
|
||||
if (activePumps.length >= 2) {
|
||||
const min = Math.min(...activePumps);
|
||||
const max = Math.max(...activePumps);
|
||||
assert.ok(max / Math.max(min, 1) < 3,
|
||||
`active pump ctrls=${activePumps.map((c) => c.toFixed(1))} — disparity > 3× indicates one pump clamped at curve floor (the live "138 vs 60" symptom).`);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('MGC serialization preserves latest-wins semantic — intermediate values stomped, last value applied', async () => {
|
||||
const plant = buildPlant({ initialBasinLevel: 2.6 });
|
||||
const { mgc, pumps, restore } = plant;
|
||||
try {
|
||||
for (const p of pumps) {
|
||||
p.state.config.time = { starting: 1, warmingup: 1, stopping: 1, coolingdown: 1 };
|
||||
injectPumpPressure(p, 19620, 117720);
|
||||
}
|
||||
for (const p of pumps) await p.handleInput('parent', 'execsequence', 'startup');
|
||||
|
||||
// Fire 10 demands quickly: 25, 50, 25, 50, ..., 100. Final must be 100.
|
||||
const sequence = [25, 50, 25, 50, 25, 50, 25, 50, 25, 100];
|
||||
const calls = sequence.map((d) => mgc.handleInput('parent', d).catch(() => {}));
|
||||
await Promise.all(calls);
|
||||
// Allow one extra event-loop turn for the deferred pickup.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
// After settling, the LATEST demand (100 %) wins — pumps should be
|
||||
// at high ctrl, not stuck on the first burst-leader value.
|
||||
const finalCtrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0);
|
||||
const maxCtrl = Math.max(...finalCtrls);
|
||||
assert.ok(maxCtrl > 70,
|
||||
`latest demand was 100 % but max pump ctrl=${maxCtrl.toFixed(1)} — gate is dropping the queued value instead of picking it up.`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
108
test/ps-mgc-flow-contract.integration.test.js
Normal file
108
test/ps-mgc-flow-contract.integration.test.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// Cross-node contract test: PS's view of MGC outflow MUST track the
|
||||
// actual aggregate pump flow at all times — not the optimizer's bestFlow
|
||||
// target, not a cached value, not a value lagging by a tick.
|
||||
//
|
||||
// Closes the gap that let the "PS sees stale 25 m³/h while pumps deliver
|
||||
// 575 m³/h" bug ship to production. Drives a demand sweep through several
|
||||
// regimes (low / mid / high / dropdown) and asserts at every tick that
|
||||
// sum(pump.predictFlow.outputY) ≈ ps.flow.predicted.out.mgc
|
||||
// within a small tolerance. Any future regression that decouples MGC's
|
||||
// emitted flow.predicted.downstream from the live aggregate fails here.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildPlant, injectPumpPressure } = require('./lib/wiring');
|
||||
|
||||
const TICK_MS = 1000;
|
||||
|
||||
function aggregatePumpFlow_m3h(pumps) {
|
||||
// Sum each pump's PUBLISHED predicted-flow measurement, NOT
|
||||
// predictFlow.outputY directly. Production code paths (MGC's
|
||||
// calcDynamicTotals, PS's net-flow calc) all read from the
|
||||
// measurement bus — so that's the value the contract is about.
|
||||
// predictFlow.outputY can drift away from the measurement when a
|
||||
// pump's state turns non-operational (the predict still has a curve
|
||||
// value at the last ctrl, but the measurement is forced to 0).
|
||||
let s = 0;
|
||||
for (const p of pumps) {
|
||||
const v = p.measurements
|
||||
.type('flow').variant('predicted').position('downstream')
|
||||
.getCurrentValue('m3/h');
|
||||
if (Number.isFinite(Number(v))) s += Number(v);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function psOutflow_m3h(ps) {
|
||||
// PS stores MGC's outflow as flow.predicted.out.<mgcId> (childId='mgc'
|
||||
// in our wiring). _selectBestNetFlow sums all 'out' children, but for
|
||||
// this contract we want JUST the MGC contribution to assert the bridge.
|
||||
const v = ps.measurements.type('flow').variant('predicted').position('out')
|
||||
.child('mgc').getCurrentValue('m3/h');
|
||||
return Number.isFinite(Number(v)) ? Number(v) : 0;
|
||||
}
|
||||
|
||||
async function runDemandSweep(plant, demands, opts = {}) {
|
||||
const { ps, mgc, pumps, advance } = plant;
|
||||
const dwellTicks = opts.dwellTicks ?? 3;
|
||||
const violations = [];
|
||||
|
||||
for (const pct of demands) {
|
||||
// Issue demand directly to MGC (mirrors PS._applyMachineGroupLevelControl)
|
||||
await mgc.handleInput('parent', pct);
|
||||
|
||||
for (let t = 0; t < dwellTicks; t++) {
|
||||
// Refresh pump pressures so predictFlow stays in valid range.
|
||||
for (const p of pumps) injectPumpPressure(p, 19620, 117720);
|
||||
advance(TICK_MS);
|
||||
ps.tick();
|
||||
// Let the event loop drain queued measurement events.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
const aggregate = aggregatePumpFlow_m3h(pumps);
|
||||
const psView = psOutflow_m3h(ps);
|
||||
const delta = Math.abs(aggregate - psView);
|
||||
// Tolerance: 5 m³/h OR 5 % of aggregate, whichever is larger. The
|
||||
// aggregate is what the pumps' predictFlow currently holds; PS reads
|
||||
// it via the MGC handlePressureChange mirror. The two should be
|
||||
// within one event-loop tick.
|
||||
const tol = Math.max(5, aggregate * 0.05);
|
||||
if (delta > tol) {
|
||||
violations.push({ pct, t, aggregate: aggregate.toFixed(1), psView: psView.toFixed(1), delta: delta.toFixed(1) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
test('PS↔MGC flow contract — psOutflow tracks aggregate pump flow across demand sweep', async () => {
|
||||
// Realistic state.time so transients are observable. Pumps start idle.
|
||||
const plant = buildPlant({ initialBasinLevel: 2.6 });
|
||||
const { ps, mgc, pumps, restore } = plant;
|
||||
try {
|
||||
// Bring the chain to a known operational state first so the contract
|
||||
// applies during the steady-state portion of the sweep too.
|
||||
for (const p of pumps) await p.handleInput('parent', 'execsequence', 'startup');
|
||||
|
||||
// Demand sweep covers all the regimes:
|
||||
// - high (3-pump combo) → big aggregate, must match
|
||||
// - mid (2-pump combo) → some pumps idle at 0
|
||||
// - low (1-pump combo) → 2 pumps idle, 1 running
|
||||
// - 0% (all off) → both sides should read 0
|
||||
// - jump back to 100% → recovery from off
|
||||
// - drop from 100% to 5% → the exact transient the bug lived in
|
||||
const demands = [100, 70, 50, 30, 15, 0, 100, 5, 100, 0];
|
||||
const violations = await runDemandSweep(plant, demands, { dwellTicks: 4 });
|
||||
|
||||
if (violations.length) {
|
||||
console.log('\n[PS↔MGC contract VIOLATIONS]');
|
||||
for (const v of violations) {
|
||||
console.log(` cmd=${v.pct}% t=${v.t}: aggregate=${v.aggregate} m³/h, PS view=${v.psView} m³/h, delta=${v.delta} m³/h`);
|
||||
}
|
||||
}
|
||||
assert.equal(violations.length, 0,
|
||||
`${violations.length} contract violations across the sweep — PS's view of outflow drifted from the actual aggregate. See log above.`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
58
test/realistic-startup-timing.integration.test.js
Normal file
58
test/realistic-startup-timing.integration.test.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// Race-window guard with PRODUCTION-default state.time:
|
||||
// starting: 10 s, warmingup: 5 s, stopping: 5 s, coolingdown: 10 s
|
||||
//
|
||||
// All previous deadlock tests use 1-2 s timing for speed. The race that
|
||||
// actually killed the live demo is about ordering during a long startup
|
||||
// window where many MGC.handleInput calls land while pumps are still
|
||||
// transitioning. This test re-runs the load-bearing demand-cycle scenario
|
||||
// against schema defaults so the test wall time matches the failure mode.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildPlant, injectPumpPressure } = require('./lib/wiring');
|
||||
|
||||
const TICK_MS = 1000;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
test('realistic startup (start=10s, warm=5s) — varying demand during 15-second startup window', async () => {
|
||||
const plant = buildPlant({ initialBasinLevel: 2.6 });
|
||||
const { ps, mgc, pumps, restore } = plant;
|
||||
try {
|
||||
// Apply production-default times.
|
||||
for (const p of pumps) {
|
||||
p.state.config.time = { starting: 10, warmingup: 5, stopping: 5, coolingdown: 10 };
|
||||
}
|
||||
// Inject realistic pressures so predicts have a head.
|
||||
for (const p of pumps) injectPumpPressure(p, 19620, 117720);
|
||||
|
||||
// Drive demand sequence at 1 Hz (mirroring PS tick rate). The first
|
||||
// 15 calls land during pump startup window; the last 15 land after.
|
||||
const sequence = [25, 75, 50, 100, 30, 90, 60, 100, 50, 80, 40, 100, 70, 100, 100,
|
||||
100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100];
|
||||
for (const pct of sequence) {
|
||||
mgc.handleInput('parent', pct).catch((e) => console.log(`call ${pct}% rejected: ${e.message}`));
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
// Drain: give the slowest pump time to finish its startup + ramp.
|
||||
await sleep(6000);
|
||||
|
||||
const states = pumps.map((p) => p.state.getCurrentState());
|
||||
const ctrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0);
|
||||
console.log(` states=[${states.join(', ')}] ctrls=[${ctrls.map((c) => c.toFixed(1)).join(', ')}]`);
|
||||
console.log(` delayedMove=[${pumps.map((p) => String(p.state.delayedMove)).join(', ')}]`);
|
||||
|
||||
// After settling, the LAST demand was 100 % so all 3 pumps must be
|
||||
// high. This is the same invariant idle-startup-deadlock Scenario 4
|
||||
// checks, but with production timing.
|
||||
for (let i = 0; i < pumps.length; i++) {
|
||||
const id = pumps[i].config.general.id;
|
||||
assert.equal(states[i], 'operational',
|
||||
`${id}: expected operational, got '${states[i]}' (delayedMove=${pumps[i].state.delayedMove})`);
|
||||
assert.ok(ctrls[i] > 70,
|
||||
`${id}: expected ctrl > 70 % at final demand 100 %, got ${ctrls[i].toFixed(1)} % — startup race regression with production timing`);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
40
third_party/docs/README.md
vendored
40
third_party/docs/README.md
vendored
@@ -1,40 +0,0 @@
|
||||
# EVOLV Scientific & Technical Reference Library
|
||||
|
||||
## Purpose
|
||||
|
||||
This directory contains curated reference documents for EVOLV's domain-specialist agents. These summaries distill authoritative sources into actionable knowledge that agents should consult **before making scientific or engineering claims**.
|
||||
|
||||
## How Agents Should Use This
|
||||
|
||||
1. **Before making domain claims**: Read the relevant reference doc to verify your reasoning
|
||||
2. **Cite sources**: When referencing scientific facts, point to the specific reference doc and its cited sources
|
||||
3. **Acknowledge uncertainty**: If the reference docs don't cover a topic, say so rather than guessing
|
||||
4. **Cross-reference with skills**: Combine these references with `.agents/skills/` SKILL.md files for implementation context
|
||||
|
||||
## Index
|
||||
|
||||
| File | Domain | Used By Agents |
|
||||
|------|--------|---------------|
|
||||
| [`asm-models.md`](asm-models.md) | Activated Sludge Models (ASM1-ASM3) | biological-process-engineer |
|
||||
| [`settling-models.md`](settling-models.md) | Sludge Settling & Clarifier Models | biological-process-engineer |
|
||||
| [`pump-affinity-laws.md`](pump-affinity-laws.md) | Pump Affinity Laws & Curve Theory | mechanical-process-engineer |
|
||||
| [`pid-control-theory.md`](pid-control-theory.md) | PID Control for Process Applications | mechanical-process-engineer, node-red-runtime |
|
||||
| [`signal-processing-sensors.md`](signal-processing-sensors.md) | Sensor Signal Conditioning | instrumentation-measurement |
|
||||
| [`wastewater-compliance-nl.md`](wastewater-compliance-nl.md) | Dutch Wastewater Regulations | commissioning-compliance, biological-process-engineer |
|
||||
| [`influxdb-schema-design.md`](influxdb-schema-design.md) | InfluxDB Time-Series Best Practices | telemetry-database |
|
||||
| [`ot-security-iec62443.md`](ot-security-iec62443.md) | OT Security Standards | ot-security-integration |
|
||||
|
||||
## Sources Directory
|
||||
|
||||
The `sources/` subdirectory is for placing actual PDFs of scientific papers, standards, and technical manuals. Agents should prefer these curated summaries but can reference originals when available.
|
||||
|
||||
## Validation Status
|
||||
|
||||
All reference documents have been validated against authoritative sources including:
|
||||
- IWA Scientific and Technical Reports (ASM models)
|
||||
- Peer-reviewed publications (Takacs 1991, Vesilind, Burger-Diehl)
|
||||
- Engineering Toolbox (pump affinity laws)
|
||||
- ISA publications (Astrom & Hagglund PID control)
|
||||
- IEC standards (61298, 62443)
|
||||
- EU Directive 91/271/EEC (wastewater compliance)
|
||||
- InfluxDB official documentation (schema design)
|
||||
0
third_party/docs/sources/.gitkeep
vendored
0
third_party/docs/sources/.gitkeep
vendored
89
wiki/SCHEMA.md
Normal file
89
wiki/SCHEMA.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Project Wiki Schema
|
||||
|
||||
## Purpose
|
||||
LLM-maintained knowledge base for this project. The LLM writes and maintains everything. You read it (ideally in Obsidian). Knowledge compounds across sessions instead of being lost in chat history.
|
||||
|
||||
## Directory Structure
|
||||
```
|
||||
wiki/
|
||||
SCHEMA.md — this file (how to maintain the wiki)
|
||||
index.md — catalog of all pages with one-line summaries
|
||||
log.md — chronological record of updates
|
||||
overview.md — project overview and current status
|
||||
metrics.md — all numbers with provenance
|
||||
knowledge-graph.yaml — structured data, machine-queryable
|
||||
tools/ — search, lint, query scripts
|
||||
concepts/ — core ideas and mechanisms
|
||||
architecture/ — design decisions, system internals
|
||||
findings/ — honest results (what worked AND what didn't)
|
||||
sessions/ — per-session summaries
|
||||
```
|
||||
|
||||
## Page Conventions
|
||||
|
||||
### Frontmatter
|
||||
Every page starts with YAML frontmatter:
|
||||
```yaml
|
||||
---
|
||||
title: Page Title
|
||||
created: YYYY-MM-DD
|
||||
updated: YYYY-MM-DD
|
||||
status: proven | disproven | evolving | speculative
|
||||
tags: [tag1, tag2]
|
||||
sources: [path/to/file.py, commit abc1234]
|
||||
---
|
||||
```
|
||||
|
||||
### Status values
|
||||
- **proven**: tested and verified with evidence
|
||||
- **disproven**: tested and honestly shown NOT to work (document WHY)
|
||||
- **evolving**: partially working, boundary not fully mapped
|
||||
- **speculative**: proposed but not yet tested
|
||||
|
||||
### Cross-references
|
||||
Use `[[Page Name]]` Obsidian-style wikilinks.
|
||||
|
||||
### Contradictions
|
||||
When new evidence contradicts a prior claim, DON'T delete the old claim. Add:
|
||||
```
|
||||
> [!warning] Superseded
|
||||
> This was shown to be incorrect on YYYY-MM-DD. See [[New Finding]].
|
||||
```
|
||||
|
||||
### Honesty rule
|
||||
If something doesn't work, say so. If a result was a false positive, document how it was discovered. The wiki must be trustworthy.
|
||||
|
||||
## Operations
|
||||
|
||||
### Ingest (after a session or new source)
|
||||
1. Read outputs, commits, findings
|
||||
2. Update relevant pages
|
||||
3. Create new pages for new concepts
|
||||
4. Update `index.md`, `log.md`, `knowledge-graph.yaml`
|
||||
5. Check for contradictions with existing pages
|
||||
|
||||
### Query
|
||||
1. Use `python3 wiki/tools/query.py` for structured lookup
|
||||
2. Use `wiki/tools/search.sh` for full-text
|
||||
3. Read `index.md` to find relevant pages
|
||||
4. File valuable answers back into the wiki
|
||||
|
||||
### Lint (periodically)
|
||||
```bash
|
||||
bash wiki/tools/lint.sh
|
||||
```
|
||||
Checks: orphan pages, broken wikilinks, missing frontmatter, index completeness.
|
||||
|
||||
## Data Layer
|
||||
|
||||
- `knowledge-graph.yaml` — structured YAML with every metric and data point
|
||||
- `metrics.md` — human-readable dashboard
|
||||
- When adding new results, update BOTH the wiki page AND the knowledge graph
|
||||
- The knowledge graph is the single source of truth for numbers
|
||||
|
||||
## Source of Truth Hierarchy
|
||||
1. **Test results** (actual outputs) — highest authority
|
||||
2. **Code** (current state) — second authority
|
||||
3. **Knowledge graph** (knowledge-graph.yaml) — structured metrics
|
||||
4. **Wiki pages** — synthesis, may lag
|
||||
5. **Chat/memory** — ephemeral, may be stale
|
||||
56
wiki/architecture/3d-pump-curves.md
Normal file
56
wiki/architecture/3d-pump-curves.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: 3D Pump Curve Architecture
|
||||
created: 2026-04-07
|
||||
updated: 2026-04-07
|
||||
status: proven
|
||||
tags: [predict, curves, interpolation, rotatingMachine]
|
||||
sources: [nodes/generalFunctions/src/predict/predict_class.js, nodes/rotatingMachine/src/specificClass.js]
|
||||
---
|
||||
|
||||
# 3D Pump Curve Prediction
|
||||
|
||||
## Data Structure
|
||||
A family of 2D curves indexed by pressure (f-dimension):
|
||||
- **X-axis**: control position (0-100%)
|
||||
- **Y-axis**: flow (nq) or power (np) in canonical units
|
||||
- **F-dimension**: pressure (Pa) — the 3rd dimension
|
||||
|
||||
Raw curves are in curve units (m3/h, kW, mbar). `_normalizeMachineCurve()` converts to canonical (m3/s, W, Pa).
|
||||
|
||||
## Interpolation
|
||||
Monotonic cubic spline (Fritsch-Carlson) in both dimensions:
|
||||
- **X-Y splines**: at each discrete pressure level
|
||||
- **F-splines**: across pressure levels for intermediate pressure interpolation
|
||||
|
||||
## Prediction Flow
|
||||
```
|
||||
predict.y(x):
|
||||
1. Clamp x to [currentFxyXMin, currentFxyXMax]
|
||||
2. Normalize x to [normMin, normMax]
|
||||
3. Evaluate spline at normalized x for current fDimension
|
||||
4. Return y in canonical units (m3/s or W)
|
||||
```
|
||||
|
||||
## Unit Conversion Chain
|
||||
```
|
||||
Raw curve (m3/h, kW, mbar)
|
||||
→ _normalizeMachineCurve → canonical (m3/s, W, Pa)
|
||||
→ predict class → canonical output
|
||||
→ MeasurementContainer.getCurrentValue(outputUnit) → output units
|
||||
```
|
||||
|
||||
No double-conversion. Clean separation: specificClass handles units, predict handles normalization/interpolation.
|
||||
|
||||
## Three Predict Instances per Machine
|
||||
- `predictFlow`: control % → flow (nq curve)
|
||||
- `predictPower`: control % → power (np curve)
|
||||
- `predictCtrl`: flow → control % (reversed nq curve)
|
||||
|
||||
## Boundary Behavior
|
||||
- Below/above curve X range: flat extrapolation (clamped)
|
||||
- Below/above f-dimension range: clamped to min/max pressure level
|
||||
|
||||
## Performance
|
||||
- `y(x)`: O(log n), effectively O(1) for 5-10 data points
|
||||
- `buildAllFxyCurves`: sub-10ms for typical curves
|
||||
- Full caching of normalized curves, splines, and calculated curves
|
||||
@@ -1,3 +1,11 @@
|
||||
---
|
||||
title: EVOLV Deployment Blueprint
|
||||
created: 2026-03-01
|
||||
updated: 2026-04-07
|
||||
status: evolving
|
||||
tags: [deployment, docker, edge, site, central]
|
||||
---
|
||||
|
||||
# EVOLV Deployment Blueprint
|
||||
|
||||
## Purpose
|
||||
45
wiki/architecture/group-optimization.md
Normal file
45
wiki/architecture/group-optimization.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Group Optimization Architecture
|
||||
created: 2026-04-07
|
||||
updated: 2026-04-07
|
||||
status: proven
|
||||
tags: [machineGroupControl, optimization, BEP-Gravitation]
|
||||
sources: [nodes/machineGroupControl/src/specificClass.js]
|
||||
---
|
||||
|
||||
# machineGroupControl Optimization
|
||||
|
||||
## Algorithm: BEP-Gravitation + Marginal-Cost Refinement
|
||||
|
||||
### Step 1 — Pressure Equalization
|
||||
Sets all non-operational pumps to the group's max downstream / min upstream pressure. Ensures fair curve evaluation across combinations.
|
||||
|
||||
### Step 2 — Combination Enumeration
|
||||
Generates all 2^n pump subsets (n = number of machines). Filters by:
|
||||
- Machine state (excludes off, cooling, stopping, emergency)
|
||||
- Mode compatibility (`execsequence` allowed in auto)
|
||||
- Flow bounds: `sumMinFlow ≤ Qd ≤ sumMaxFlow`
|
||||
- Optional power cap
|
||||
|
||||
### Step 3 — BEP-Gravitation Distribution (per combination)
|
||||
1. **BEP seed**: `estimatedBEP = minFlow + span * NCog` per pump
|
||||
2. **Slope estimation**: samples dP/dQ at BEP ± delta (directional: slopeLeft, slopeRight)
|
||||
3. **Slope redistribution**: iteratively shifts flow from steep to flat curves (weight = 1/slope)
|
||||
4. **Marginal-cost refinement**: after slope redistribution, shifts flow from highest actual dP/dQ to lowest using real `inputFlowCalcPower` evaluations. Converges regardless of curve convexity. Max 50 iterations, typically 5-15.
|
||||
|
||||
### Step 4 — Best Selection
|
||||
Pick combination with lowest total power. Tiebreak by deviation from BEP.
|
||||
|
||||
### Step 5 — Execution
|
||||
Start/stop pumps as needed, send `flowmovement` commands in output units via `_canonicalToOutputFlow()`.
|
||||
|
||||
## Three Control Modes
|
||||
|
||||
| Mode | Distribution | Combination Selection |
|
||||
|------|-------------|----------------------|
|
||||
| optimalControl | BEP-Gravitation + refinement | exhaustive 2^n |
|
||||
| priorityControl | equal split, priority-ordered | sequential add/remove |
|
||||
| priorityPercentageControl | percentage-based, normalized | count-based |
|
||||
|
||||
## Key Design Decision
|
||||
The `flowmovement` command sends flow in the **machine's output units** (m3/h), not canonical (m3/s). The `_canonicalToOutputFlow()` helper converts before sending. Without this conversion, every pump stays at minimum flow (the critical bug fixed on 2026-04-07).
|
||||
426
wiki/architecture/node-architecture.md
Normal file
426
wiki/architecture/node-architecture.md
Normal file
@@ -0,0 +1,426 @@
|
||||
---
|
||||
title: EVOLV Architecture
|
||||
created: 2026-03-01
|
||||
updated: 2026-04-07
|
||||
status: evolving
|
||||
tags: [architecture, node-red, three-layer]
|
||||
---
|
||||
|
||||
# EVOLV Architecture
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
High-level view of how EVOLV fits into the wastewater treatment automation stack.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
NR[Node-RED Runtime] <-->|msg objects| EVOLV[EVOLV Nodes]
|
||||
EVOLV -->|InfluxDB line protocol| INFLUX[(InfluxDB)]
|
||||
INFLUX -->|queries| GRAFANA[Grafana Dashboards]
|
||||
EVOLV -->|process output| NR
|
||||
EVOLV -->|parent output| NR
|
||||
|
||||
style NR fill:#b22222,color:#fff
|
||||
style EVOLV fill:#0f52a5,color:#fff
|
||||
style INFLUX fill:#0c99d9,color:#fff
|
||||
style GRAFANA fill:#50a8d9,color:#fff
|
||||
```
|
||||
|
||||
Each EVOLV node produces three outputs:
|
||||
| Port | Name | Purpose |
|
||||
|------|------|---------|
|
||||
| 0 | process | Process data forwarded to downstream nodes |
|
||||
| 1 | dbase | InfluxDB-formatted measurement data |
|
||||
| 2 | parent | Control messages to parent nodes (e.g. registerChild) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Node Architecture (Three-Layer Pattern)
|
||||
|
||||
Every node follows a consistent three-layer design that separates Node-RED wiring from domain logic.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Node-RED Runtime"
|
||||
REG["RED.nodes.registerType()"]
|
||||
end
|
||||
|
||||
subgraph "Layer 1 — Wrapper (valve.js)"
|
||||
W[wrapper .js]
|
||||
W -->|"new nodeClass(config, RED, this, name)"| NC
|
||||
W -->|MenuManager| MENU[HTTP /name/menu.js]
|
||||
W -->|configManager| CFG[HTTP /name/configData.js]
|
||||
end
|
||||
|
||||
subgraph "Layer 2 — Node Adapter (src/nodeClass.js)"
|
||||
NC[nodeClass]
|
||||
NC -->|_loadConfig| CFGM[configManager]
|
||||
NC -->|_setupSpecificClass| SC
|
||||
NC -->|_attachInputHandler| INPUT[onInput routing]
|
||||
NC -->|_startTickLoop| TICK[1s tick loop]
|
||||
NC -->|_tick → outputUtils| OUT[formatMsg]
|
||||
end
|
||||
|
||||
subgraph "Layer 3 — Domain Logic (src/specificClass.js)"
|
||||
SC[specificClass]
|
||||
SC -->|measurements| MC[MeasurementContainer]
|
||||
SC -->|state machine| ST[state]
|
||||
SC -->|hydraulics / biology| DOMAIN[domain models]
|
||||
end
|
||||
|
||||
subgraph "generalFunctions"
|
||||
GF[shared library]
|
||||
end
|
||||
|
||||
REG --> W
|
||||
GF -.->|logger, outputUtils, configManager,\nMeasurementContainer, validation, ...| NC
|
||||
GF -.->|MeasurementContainer, state,\nconvert, predict, ...| SC
|
||||
|
||||
style W fill:#0f52a5,color:#fff
|
||||
style NC fill:#0c99d9,color:#fff
|
||||
style SC fill:#50a8d9,color:#fff
|
||||
style GF fill:#86bbdd,color:#000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. generalFunctions Module Map
|
||||
|
||||
The shared library (`nodes/generalFunctions/`) provides all cross-cutting concerns.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
GF[generalFunctions/index.js]
|
||||
|
||||
subgraph "Core Helpers (src/helper/)"
|
||||
LOGGER[logger]
|
||||
OUTPUT[outputUtils]
|
||||
CHILD[childRegistrationUtils]
|
||||
CFGUTIL[configUtils]
|
||||
ASSERT[assertionUtils]
|
||||
VALID[validationUtils]
|
||||
end
|
||||
|
||||
subgraph "Validators (src/helper/validators/)"
|
||||
TV[typeValidators]
|
||||
CV[collectionValidators]
|
||||
CURV[curveValidator]
|
||||
end
|
||||
|
||||
subgraph "Domain Modules (src/)"
|
||||
MC[MeasurementContainer]
|
||||
CFGMGR[configManager]
|
||||
MENUMGR[MenuManager]
|
||||
STATE[state]
|
||||
CONVERT[convert / Fysics]
|
||||
PREDICT[predict / interpolation]
|
||||
NRMSE[nrmse / errorMetrics]
|
||||
COOLPROP[coolprop]
|
||||
end
|
||||
|
||||
subgraph "Data (datasets/)"
|
||||
CURVES[assetData/curves]
|
||||
ASSETS[assetData/assetData.json]
|
||||
UNITS[unitData.json]
|
||||
end
|
||||
|
||||
subgraph "Constants (src/constants/)"
|
||||
POS[POSITIONS / POSITION_VALUES]
|
||||
end
|
||||
|
||||
GF --> LOGGER
|
||||
GF --> OUTPUT
|
||||
GF --> CHILD
|
||||
GF --> CFGUTIL
|
||||
GF --> ASSERT
|
||||
GF --> VALID
|
||||
VALID --> TV
|
||||
VALID --> CV
|
||||
VALID --> CURV
|
||||
GF --> MC
|
||||
GF --> CFGMGR
|
||||
GF --> MENUMGR
|
||||
GF --> STATE
|
||||
GF --> CONVERT
|
||||
GF --> PREDICT
|
||||
GF --> NRMSE
|
||||
GF --> COOLPROP
|
||||
GF --> CURVES
|
||||
GF --> POS
|
||||
|
||||
style GF fill:#0f52a5,color:#fff
|
||||
style LOGGER fill:#86bbdd,color:#000
|
||||
style OUTPUT fill:#86bbdd,color:#000
|
||||
style VALID fill:#86bbdd,color:#000
|
||||
style MC fill:#50a8d9,color:#fff
|
||||
style CFGMGR fill:#50a8d9,color:#fff
|
||||
style MENUMGR fill:#50a8d9,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Flow (Message Lifecycle)
|
||||
|
||||
Sequence diagram showing a typical input message and the periodic tick output cycle.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant NR as Node-RED
|
||||
participant W as wrapper.js
|
||||
participant NC as nodeClass
|
||||
participant SC as specificClass
|
||||
participant OU as outputUtils
|
||||
|
||||
Note over W: Node startup
|
||||
W->>NC: new nodeClass(config, RED, node, name)
|
||||
NC->>NC: _loadConfig (configManager.buildConfig)
|
||||
NC->>SC: new specificClass(config, stateConfig, options)
|
||||
NC->>NR: send([null, null, {topic: registerChild}])
|
||||
|
||||
Note over NC: Every 1 second (tick loop)
|
||||
NC->>SC: getOutput()
|
||||
SC-->>NC: raw measurement data
|
||||
NC->>OU: formatMsg(raw, config, 'process')
|
||||
NC->>OU: formatMsg(raw, config, 'influxdb')
|
||||
NC->>NR: send([processMsg, influxMsg])
|
||||
|
||||
Note over NR: Incoming control message
|
||||
NR->>W: msg {topic: 'execMovement', payload: {...}}
|
||||
W->>NC: onInput(msg)
|
||||
NC->>SC: handleInput(source, action, setpoint)
|
||||
SC->>SC: update state machine & measurements
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Node Types
|
||||
|
||||
| Node | S88 Level | Purpose |
|
||||
|------|-----------|---------|
|
||||
| **measurement** | Control Module | Generic measurement point — reads, validates, and stores sensor values |
|
||||
| **valve** | Control Module | Valve simulation with hydraulic model, position control, flow/pressure prediction |
|
||||
| **rotatingMachine** | Control Module | Pumps, blowers, mixers — rotating equipment with speed control and efficiency curves |
|
||||
| **diffuser** | Control Module | Aeration diffuser — models oxygen transfer and pressure drop |
|
||||
| **settler** | Equipment | Sludge settler — models settling behavior and sludge blanket |
|
||||
| **reactor** | Equipment | Hydraulic tank and biological process simulator (activated sludge, digestion) |
|
||||
| **monster** | Equipment | MONitoring and STrEam Routing — complex measurement aggregation |
|
||||
| **pumpingStation** | Unit | Coordinates multiple pumps as a pumping station |
|
||||
| **valveGroupControl** | Unit | Manages multiple valves as a coordinated group — distributes flow, monitors pressure |
|
||||
| **machineGroupControl** | Unit | Group control for rotating machines — load balancing and sequencing |
|
||||
| **dashboardAPI** | Utility | Exposes data and unit conversion endpoints for external dashboards |
|
||||
# EVOLV Architecture
|
||||
|
||||
## Node Hierarchy (S88)
|
||||
|
||||
EVOLV follows the ISA-88 (S88) batch control standard. Each node maps to an S88 level and uses a consistent color scheme in the Node-RED editor.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
classDef area fill:#0f52a5,color:#fff,stroke:#0a3d7a
|
||||
classDef processCell fill:#0c99d9,color:#fff,stroke:#0977aa
|
||||
classDef unit fill:#50a8d9,color:#fff,stroke:#3d89b3
|
||||
classDef equipment fill:#86bbdd,color:#000,stroke:#6a9bb8
|
||||
classDef controlModule fill:#a9daee,color:#000,stroke:#87b8cc
|
||||
classDef standalone fill:#f0f0f0,color:#000,stroke:#999
|
||||
|
||||
%% S88 Levels
|
||||
subgraph "S88: Area"
|
||||
PS[pumpingStation]
|
||||
end
|
||||
|
||||
subgraph "S88: Equipment"
|
||||
MGC[machineGroupControl]
|
||||
VGC[valveGroupControl]
|
||||
end
|
||||
|
||||
subgraph "S88: Control Module"
|
||||
RM[rotatingMachine]
|
||||
V[valve]
|
||||
M[measurement]
|
||||
R[reactor]
|
||||
S[settler]
|
||||
end
|
||||
|
||||
subgraph "Standalone"
|
||||
MON[monster]
|
||||
DASH[dashboardAPI]
|
||||
DIFF[diffuser - not implemented]
|
||||
end
|
||||
|
||||
%% Parent-child registration relationships
|
||||
PS -->|"accepts: measurement"| M
|
||||
PS -->|"accepts: machine"| RM
|
||||
PS -->|"accepts: machineGroup"| MGC
|
||||
PS -->|"accepts: pumpingStation"| PS2[pumpingStation]
|
||||
|
||||
MGC -->|"accepts: machine"| RM
|
||||
|
||||
RM -->|"accepts: measurement"| M2[measurement]
|
||||
RM -->|"accepts: reactor"| R
|
||||
|
||||
VGC -->|"accepts: valve"| V
|
||||
VGC -->|"accepts: machine / rotatingmachine"| RM2[rotatingMachine]
|
||||
VGC -->|"accepts: machinegroup / machinegroupcontrol"| MGC2[machineGroupControl]
|
||||
VGC -->|"accepts: pumpingstation / valvegroupcontrol"| PS3["pumpingStation / valveGroupControl"]
|
||||
|
||||
R -->|"accepts: measurement"| M3[measurement]
|
||||
R -->|"accepts: reactor"| R2[reactor]
|
||||
|
||||
S -->|"accepts: measurement"| M4[measurement]
|
||||
S -->|"accepts: reactor"| R3[reactor]
|
||||
S -->|"accepts: machine"| RM3[rotatingMachine]
|
||||
|
||||
%% Styling
|
||||
class PS,PS2,PS3 area
|
||||
class MGC,MGC2 equipment
|
||||
class VGC equipment
|
||||
class RM,RM2,RM3 controlModule
|
||||
class V controlModule
|
||||
class M,M2,M3,M4 controlModule
|
||||
class R,R2,R3 controlModule
|
||||
class S controlModule
|
||||
class MON,DASH,DIFF standalone
|
||||
```
|
||||
|
||||
### Registration Summary
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
classDef parent fill:#0c99d9,color:#fff
|
||||
classDef child fill:#a9daee,color:#000
|
||||
|
||||
PS[pumpingStation] -->|measurement| LEAF1((leaf))
|
||||
PS -->|machine| RM1[rotatingMachine]
|
||||
PS -->|machineGroup| MGC1[machineGroupControl]
|
||||
PS -->|pumpingStation| PS1[pumpingStation]
|
||||
|
||||
MGC[machineGroupControl] -->|machine| RM2[rotatingMachine]
|
||||
|
||||
VGC[valveGroupControl] -->|valve| V1[valve]
|
||||
VGC -->|source| SRC["machine, machinegroup,<br/>pumpingstation, valvegroupcontrol"]
|
||||
|
||||
RM[rotatingMachine] -->|measurement| LEAF2((leaf))
|
||||
RM -->|reactor| R1[reactor]
|
||||
|
||||
R[reactor] -->|measurement| LEAF3((leaf))
|
||||
R -->|reactor| R2[reactor]
|
||||
|
||||
S[settler] -->|measurement| LEAF4((leaf))
|
||||
S -->|reactor| R3[reactor]
|
||||
S -->|machine| RM3[rotatingMachine]
|
||||
|
||||
class PS,MGC,VGC,RM,R,S parent
|
||||
class LEAF1,LEAF2,LEAF3,LEAF4,RM1,RM2,RM3,MGC1,PS1,V1,SRC,R1,R2,R3 child
|
||||
```
|
||||
|
||||
## Node Types
|
||||
|
||||
| Node | S88 Level | softwareType | role | Accepts Children | Outputs |
|
||||
|------|-----------|-------------|------|-----------------|---------|
|
||||
| **pumpingStation** | Area | `pumpingstation` | StationController | measurement, machine (rotatingMachine), machineGroup, pumpingStation | [process, dbase, parent] |
|
||||
| **machineGroupControl** | Equipment | `machinegroupcontrol` | GroupController | machine (rotatingMachine) | [process, dbase, parent] |
|
||||
| **valveGroupControl** | Equipment | `valvegroupcontrol` | ValveGroupController | valve, machine, rotatingmachine, machinegroup, machinegroupcontrol, pumpingstation, valvegroupcontrol | [process, dbase, parent] |
|
||||
| **rotatingMachine** | Control Module | `rotatingmachine` | RotationalDeviceController | measurement, reactor | [process, dbase, parent] |
|
||||
| **valve** | Control Module | `valve` | controller | _(leaf node, no children)_ | [process, dbase, parent] |
|
||||
| **measurement** | Control Module | `measurement` | Sensor | _(leaf node, no children)_ | [process, dbase, parent] |
|
||||
| **reactor** | Control Module | `reactor` | Biological reactor | measurement, reactor (upstream chaining) | [process, dbase, parent] |
|
||||
| **settler** | Control Module | `settler` | Secondary settler | measurement, reactor (upstream), machine (return pump) | [process, dbase, parent] |
|
||||
| **monster** | Standalone | - | - | dual-parent, standalone | - |
|
||||
| **dashboardAPI** | Standalone | - | - | accepts any child (Grafana integration) | - |
|
||||
| **diffuser** | Standalone | - | - | _(not implemented)_ | - |
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Measurement Data Flow (upstream to downstream)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Sensor as measurement (sensor)
|
||||
participant Machine as rotatingMachine
|
||||
participant Group as machineGroupControl
|
||||
participant Station as pumpingStation
|
||||
|
||||
Note over Sensor: Sensor reads value<br/>(pressure, flow, level, temp)
|
||||
|
||||
Sensor->>Sensor: measurements.type(t).variant("measured").position(p).value(v)
|
||||
Sensor->>Sensor: emitter.emit("type.measured.position", eventData)
|
||||
|
||||
Sensor->>Machine: Event: "pressure.measured.upstream"
|
||||
Machine->>Machine: Store in own MeasurementContainer
|
||||
Machine->>Machine: getMeasuredPressure() -> calcFlow() -> calcPower()
|
||||
Machine->>Machine: emitter.emit("flow.predicted.downstream", eventData)
|
||||
|
||||
Machine->>Group: Event: "flow.predicted.downstream"
|
||||
Group->>Group: handlePressureChange()
|
||||
Group->>Group: Aggregate flows across all machines
|
||||
Group->>Group: Calculate group totals and efficiency
|
||||
|
||||
Machine->>Station: Event: "flow.predicted.downstream"
|
||||
Station->>Station: Store predicted flow in/out
|
||||
Station->>Station: _updateVolumePrediction()
|
||||
Station->>Station: _calcNetFlow(), _calcTimeRemaining()
|
||||
```
|
||||
|
||||
### Control Command Flow (downstream to upstream)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Station as pumpingStation
|
||||
participant Group as machineGroupControl
|
||||
participant Machine as rotatingMachine
|
||||
participant Machine2 as rotatingMachine (2)
|
||||
|
||||
Station->>Group: handleInput("parent", action, param)
|
||||
|
||||
Group->>Group: Determine scaling strategy
|
||||
Group->>Group: Calculate setpoints per machine
|
||||
|
||||
Group->>Machine: handleInput("parent", "execMovement", setpoint)
|
||||
Group->>Machine2: handleInput("parent", "execMovement", setpoint)
|
||||
|
||||
Machine->>Machine: setpoint() -> state.moveTo(pos)
|
||||
Machine->>Machine: updatePosition() -> calcFlow(), calcPower()
|
||||
Machine->>Machine: emitter.emit("flow.predicted.downstream")
|
||||
|
||||
Machine2->>Machine2: setpoint() -> state.moveTo(pos)
|
||||
Machine2->>Machine2: updatePosition() -> calcFlow(), calcPower()
|
||||
Machine2->>Machine2: emitter.emit("flow.predicted.downstream")
|
||||
```
|
||||
|
||||
### Wastewater Treatment Process Flow
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
classDef process fill:#50a8d9,color:#fff
|
||||
classDef equipment fill:#86bbdd,color:#000
|
||||
|
||||
PS_IN[pumpingStation<br/>Influent] -->|flow| R1[reactor<br/>Anoxic]
|
||||
R1 -->|effluent| R2[reactor<br/>Aerated]
|
||||
R2 -->|effluent| SET[settler]
|
||||
SET -->|effluent out| PS_OUT[pumpingStation<br/>Effluent]
|
||||
SET -->|sludge return| RM_RET[rotatingMachine<br/>Return pump]
|
||||
RM_RET -->|recirculation| R1
|
||||
|
||||
PS_IN --- MGC_IN[machineGroupControl]
|
||||
MGC_IN --- RM_IN[rotatingMachine<br/>Influent pumps]
|
||||
|
||||
class PS_IN,PS_OUT process
|
||||
class R1,R2,SET process
|
||||
class MGC_IN,RM_IN,RM_RET equipment
|
||||
```
|
||||
|
||||
### Event-Driven Communication Pattern
|
||||
|
||||
All parent-child communication uses Node.js `EventEmitter`:
|
||||
|
||||
1. **Registration**: Parent calls `childRegistrationUtils.registerChild(child, position)` which stores the child and calls the parent's `registerChild(child, softwareType)` method.
|
||||
2. **Event binding**: The parent's `registerChild()` subscribes to the child's `measurements.emitter` events (e.g., `"flow.predicted.downstream"`).
|
||||
3. **Data propagation**: When a child updates a measurement, it emits an event. The parent's listener stores the value in its own `MeasurementContainer` and runs its domain logic.
|
||||
4. **Three outputs**: Every node sends data to three Node-RED outputs: `[process, dbase, parent]` -- process data for downstream nodes, InfluxDB for persistence, and parent aggregation data.
|
||||
|
||||
### Position Convention
|
||||
|
||||
Children register with a position relative to their parent:
|
||||
- `upstream` -- before the parent in the flow direction
|
||||
- `downstream` -- after the parent in the flow direction
|
||||
- `atEquipment` -- physically located at/on the parent equipment
|
||||
@@ -1,3 +1,11 @@
|
||||
---
|
||||
title: EVOLV Platform Architecture
|
||||
created: 2026-03-01
|
||||
updated: 2026-04-07
|
||||
status: evolving
|
||||
tags: [architecture, platform, edge-first]
|
||||
---
|
||||
|
||||
# EVOLV Platform Architecture
|
||||
|
||||
## At A Glance
|
||||
@@ -1,3 +1,11 @@
|
||||
---
|
||||
title: EVOLV Architecture Review
|
||||
created: 2026-03-01
|
||||
updated: 2026-04-07
|
||||
status: evolving
|
||||
tags: [architecture, stack, review]
|
||||
---
|
||||
|
||||
# EVOLV Architecture Review
|
||||
|
||||
## Purpose
|
||||
454
wiki/concepts/generalfunctions-api.md
Normal file
454
wiki/concepts/generalfunctions-api.md
Normal file
@@ -0,0 +1,454 @@
|
||||
---
|
||||
title: generalFunctions API Reference
|
||||
created: 2026-03-01
|
||||
updated: 2026-04-07
|
||||
status: evolving
|
||||
tags: [api, generalFunctions, reference]
|
||||
---
|
||||
|
||||
# generalFunctions API Reference
|
||||
|
||||
Shared library (`nodes/generalFunctions/`) used across all EVOLV Node-RED nodes.
|
||||
|
||||
```js
|
||||
const { logger, outputUtils, MeasurementContainer, ... } = require('generalFunctions');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Logger](#logger)
|
||||
2. [OutputUtils](#outpututils)
|
||||
3. [ValidationUtils](#validationutils)
|
||||
4. [MeasurementContainer](#measurementcontainer)
|
||||
5. [ConfigManager](#configmanager)
|
||||
6. [ChildRegistrationUtils](#childregistrationutils)
|
||||
7. [MenuUtils](#menuutils)
|
||||
8. [EndpointUtils](#endpointutils)
|
||||
9. [Positions](#positions)
|
||||
10. [AssetLoader / loadCurve](#assetloader--loadcurve)
|
||||
|
||||
---
|
||||
|
||||
## Logger
|
||||
|
||||
Structured, level-filtered console logger.
|
||||
|
||||
**File:** `src/helper/logger.js`
|
||||
|
||||
### Constructor
|
||||
|
||||
```js
|
||||
new Logger(logging = true, logLevel = 'debug', nameModule = 'N/A')
|
||||
```
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `logging` | `boolean` | `true` | Enable/disable all output |
|
||||
| `logLevel` | `string` | `'debug'` | Minimum severity: `'debug'` \| `'info'` \| `'warn'` \| `'error'` |
|
||||
| `nameModule` | `string` | `'N/A'` | Label prefixed to every message |
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Signature | Description |
|
||||
|---|---|---|
|
||||
| `debug` | `(message: string): void` | Log at DEBUG level |
|
||||
| `info` | `(message: string): void` | Log at INFO level |
|
||||
| `warn` | `(message: string): void` | Log at WARN level |
|
||||
| `error` | `(message: string): void` | Log at ERROR level |
|
||||
| `setLogLevel` | `(level: string): void` | Change minimum level at runtime |
|
||||
| `toggleLogging` | `(): void` | Flip logging on/off |
|
||||
|
||||
### Example
|
||||
|
||||
```js
|
||||
const Logger = require('generalFunctions').logger;
|
||||
const log = new Logger(true, 'info', 'MyNode');
|
||||
log.info('Node started'); // [INFO] -> MyNode: Node started
|
||||
log.debug('ignored'); // silent (below 'info')
|
||||
log.setLogLevel('debug');
|
||||
log.debug('now visible'); // [DEBUG] -> MyNode: now visible
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OutputUtils
|
||||
|
||||
Tracks output state and formats messages for InfluxDB or process outputs. Only emits changed fields.
|
||||
|
||||
**File:** `src/helper/outputUtils.js`
|
||||
|
||||
### Constructor
|
||||
|
||||
```js
|
||||
new OutputUtils() // no parameters
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `formatMsg` | `(output, config, format)` | `object \| undefined` | Diff against last output; returns formatted msg or `undefined` if nothing changed |
|
||||
| `checkForChanges` | `(output, format)` | `object` | Returns only the key/value pairs that changed since last call |
|
||||
|
||||
**`format`** must be `'influxdb'` or `'process'`.
|
||||
|
||||
### Example
|
||||
|
||||
```js
|
||||
const out = new OutputUtils();
|
||||
const msg = out.formatMsg(
|
||||
{ temperature: 22.5, pressure: 1013 },
|
||||
config,
|
||||
'influxdb'
|
||||
);
|
||||
// msg = { topic: 'nodeName', payload: { measurement, fields, tags, timestamp } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ValidationUtils
|
||||
|
||||
Schema-driven config validation with type coercion, range clamping, and nested object support.
|
||||
|
||||
**File:** `src/helper/validationUtils.js`
|
||||
|
||||
### Constructor
|
||||
|
||||
```js
|
||||
new ValidationUtils(loggerEnabled = true, loggerLevel = 'warn')
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `validateSchema` | `(config, schema, name)` | `object` | Walk the schema, validate every field, return a clean config. Unknown keys are stripped. Missing keys get their schema default. |
|
||||
| `constrain` | `(value, min, max)` | `number` | Clamp a numeric value to `[min, max]` |
|
||||
| `removeUnwantedKeys` | `(obj)` | `object` | Strip `rules`/`description` metadata, collapse `default` values |
|
||||
|
||||
**Supported `rules.type` values:** `number`, `integer`, `boolean`, `string`, `enum`, `array`, `set`, `object`, `curve`, `machineCurve`.
|
||||
|
||||
### Example
|
||||
|
||||
```js
|
||||
const ValidationUtils = require('generalFunctions').validation;
|
||||
const v = new ValidationUtils(true, 'warn');
|
||||
|
||||
const schema = {
|
||||
temperature: { default: 20, rules: { type: 'number', min: -40, max: 100 } },
|
||||
unit: { default: 'C', rules: { type: 'enum', values: [{ value: 'C' }, { value: 'F' }] } }
|
||||
};
|
||||
|
||||
const validated = v.validateSchema({ temperature: 999 }, schema, 'myNode');
|
||||
// validated.temperature === 100 (clamped)
|
||||
// validated.unit === 'C' (default applied)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MeasurementContainer
|
||||
|
||||
Chainable measurement storage organised by **type / variant / position**. Supports auto unit conversion, windowed statistics, events, and positional difference calculations.
|
||||
|
||||
**File:** `src/measurements/MeasurementContainer.js`
|
||||
|
||||
### Constructor
|
||||
|
||||
```js
|
||||
new MeasurementContainer(options = {}, logger)
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `windowSize` | `number` | `10` | Rolling window for statistics |
|
||||
| `defaultUnits` | `object` | `{ pressure:'mbar', flow:'m3/h', ... }` | Default unit per measurement type |
|
||||
| `autoConvert` | `boolean` | `true` | Auto-convert values to target unit |
|
||||
| `preferredUnits` | `object` | `{}` | Per-type unit overrides |
|
||||
|
||||
### Chainable Setters
|
||||
|
||||
All return `this` for chaining.
|
||||
|
||||
```js
|
||||
container
|
||||
.type('pressure')
|
||||
.variant('static')
|
||||
.position('upstream')
|
||||
.distance(5)
|
||||
.unit('bar')
|
||||
.value(3.2, Date.now(), 'bar');
|
||||
```
|
||||
|
||||
| Method | Signature | Description |
|
||||
|---|---|---|
|
||||
| `type` | `(typeName): this` | Set measurement type (e.g. `'pressure'`) |
|
||||
| `variant` | `(variantName): this` | Set variant (e.g. `'static'`, `'differential'`) |
|
||||
| `position` | `(positionValue): this` | Set position (e.g. `'upstream'`, `'downstream'`) |
|
||||
| `distance` | `(distance): this` | Set physical distance from parent |
|
||||
| `unit` | `(unitName): this` | Set unit on the underlying measurement |
|
||||
| `value` | `(val, timestamp?, sourceUnit?): this` | Store a value; auto-converts if `sourceUnit` differs from target |
|
||||
|
||||
### Terminal / Query Methods
|
||||
|
||||
| Method | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `get` | `()` | `Measurement \| null` | Get the raw measurement object |
|
||||
| `getCurrentValue` | `(requestedUnit?)` | `number \| null` | Latest value, optionally converted |
|
||||
| `getAverage` | `(requestedUnit?)` | `number \| null` | Windowed average |
|
||||
| `getMin` | `()` | `number \| null` | Window minimum |
|
||||
| `getMax` | `()` | `number \| null` | Window maximum |
|
||||
| `getAllValues` | `()` | `array \| null` | All stored samples |
|
||||
| `getLaggedValue` | `(lag?, requestedUnit?)` | `number \| null` | Value from `lag` samples ago |
|
||||
| `getLaggedSample` | `(lag?, requestedUnit?)` | `object \| null` | Full sample `{ value, timestamp, unit }` from `lag` samples ago |
|
||||
| `exists` | `({ type?, variant?, position?, requireValues? })` | `boolean` | Check if a measurement series exists |
|
||||
| `difference` | `({ from?, to?, unit? })` | `object \| null` | Compute `{ value, avgDiff, unit }` between two positions |
|
||||
|
||||
### Introspection / Lifecycle
|
||||
|
||||
| Method | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `getTypes` | `()` | `string[]` | All registered measurement types |
|
||||
| `getVariants` | `()` | `string[]` | Variants under current type |
|
||||
| `getPositions` | `()` | `string[]` | Positions under current type+variant |
|
||||
| `getAvailableUnits` | `(measurementType?)` | `string[]` | Units available for a type |
|
||||
| `getBestUnit` | `(excludeUnits?)` | `object \| null` | Best human-readable unit for current value |
|
||||
| `setPreferredUnit` | `(type, unit)` | `this` | Override default unit for a type |
|
||||
| `setChildId` | `(id)` | `this` | Tag container with a child node ID |
|
||||
| `setChildName` | `(name)` | `this` | Tag container with a child node name |
|
||||
| `setParentRef` | `(parent)` | `this` | Store reference to parent node |
|
||||
| `clear` | `()` | `void` | Reset all measurements and chain state |
|
||||
|
||||
### Events
|
||||
|
||||
The internal `emitter` fires `"type.variant.position"` on every `value()` call with:
|
||||
|
||||
```js
|
||||
{ value, originalValue, unit, sourceUnit, timestamp, position, distance, variant, type, childId, childName, parentRef }
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```js
|
||||
const { MeasurementContainer } = require('generalFunctions');
|
||||
const mc = new MeasurementContainer({ windowSize: 5 });
|
||||
|
||||
mc.type('pressure').variant('static').position('upstream').value(3.2);
|
||||
mc.type('pressure').variant('static').position('downstream').value(2.8);
|
||||
|
||||
const diff = mc.type('pressure').variant('static').difference();
|
||||
// diff = { value: -0.4, avgDiff: -0.4, unit: 'mbar', from: 'downstream', to: 'upstream' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ConfigManager
|
||||
|
||||
Loads JSON config files from disk and builds merged runtime configs.
|
||||
|
||||
**File:** `src/configs/index.js`
|
||||
|
||||
### Constructor
|
||||
|
||||
```js
|
||||
new ConfigManager(relPath = '.')
|
||||
```
|
||||
|
||||
`relPath` is resolved relative to the configs directory.
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `getConfig` | `(configName)` | `object` | Load and parse `<configName>.json` |
|
||||
| `getAvailableConfigs` | `()` | `string[]` | List config names (without `.json`) |
|
||||
| `hasConfig` | `(configName)` | `boolean` | Check existence |
|
||||
| `getBaseConfig` | `()` | `object` | Shortcut for `getConfig('baseConfig')` |
|
||||
| `buildConfig` | `(nodeName, uiConfig, nodeId, domainConfig?)` | `object` | Merge base schema + UI overrides into a runtime config |
|
||||
| `createEndpoint` | `(nodeName)` | `string` | Generate browser JS that injects config into `window.EVOLV.nodes` |
|
||||
|
||||
### Example
|
||||
|
||||
```js
|
||||
const { configManager } = require('generalFunctions');
|
||||
const cfg = configManager.buildConfig('measurement', uiConfig, node.id, {
|
||||
scaling: { enabled: true, inputMin: 0, inputMax: 100 }
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ChildRegistrationUtils
|
||||
|
||||
Manages parent-child node relationships: registration, lookup, and structure storage.
|
||||
|
||||
**File:** `src/helper/childRegistrationUtils.js`
|
||||
|
||||
### Constructor
|
||||
|
||||
```js
|
||||
new ChildRegistrationUtils(mainClass)
|
||||
```
|
||||
|
||||
`mainClass` is the parent node instance (must expose `.logger` and optionally `.registerChild()`).
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `registerChild` | `(child, positionVsParent, distance?)` | `Promise<any>` | Register a child node under the parent. Sets up parent refs, measurement context, and stores by softwareType/category. |
|
||||
| `getChildrenOfType` | `(softwareType, category?)` | `array` | Get children filtered by software type and optional category |
|
||||
| `getChildById` | `(childId)` | `object \| null` | Lookup a single child by its ID |
|
||||
| `getAllChildren` | `()` | `array` | All registered children |
|
||||
| `logChildStructure` | `()` | `void` | Debug-print the full child tree |
|
||||
|
||||
### Example
|
||||
|
||||
```js
|
||||
const { childRegistrationUtils: CRU } = require('generalFunctions');
|
||||
const cru = new CRU(parentNode);
|
||||
await cru.registerChild(sensorNode, 'upstream');
|
||||
cru.getChildrenOfType('measurement'); // [sensorNode]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MenuUtils
|
||||
|
||||
Browser-side UI helper for Node-RED editor. Methods are mixed in from separate modules: toggles, data fetching, URL utils, dropdown population, and HTML generation.
|
||||
|
||||
**File:** `src/helper/menuUtils.js`
|
||||
|
||||
### Constructor
|
||||
|
||||
```js
|
||||
new MenuUtils() // no parameters; sets isCloud=false, configData=null
|
||||
```
|
||||
|
||||
### Key Methods
|
||||
|
||||
**Toggles** -- control UI element visibility:
|
||||
|
||||
| Method | Signature | Description |
|
||||
|---|---|---|
|
||||
| `initBasicToggles` | `(elements)` | Bind log-level row visibility to log checkbox |
|
||||
| `initMeasurementToggles` | `(elements)` | Bind scaling input rows to scaling checkbox |
|
||||
| `initTensionToggles` | `(elements, node)` | Show/hide tension row based on interpolation method |
|
||||
|
||||
**Data Fetching:**
|
||||
|
||||
| Method | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `fetchData` | `(url, fallbackUrl)` | `Promise<array>` | Fetch JSON from primary URL; fall back on failure |
|
||||
| `fetchProjectData` | `(url)` | `Promise<object>` | Fetch project-level data |
|
||||
| `apiCall` | `(node)` | `Promise<object>` | POST to asset-register API |
|
||||
|
||||
**URL Construction:**
|
||||
|
||||
| Method | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `getSpecificConfigUrl` | `(nodeName, cloudAPI)` | `{ cloudConfigURL, localConfigURL }` | Build cloud + local config URLs |
|
||||
| `constructUrl` | `(base, ...paths)` | `string` | Join URL segments safely |
|
||||
| `constructCloudURL` | `(base, ...paths)` | `string` | Same as `constructUrl`, for cloud endpoints |
|
||||
|
||||
**Dropdown Population:**
|
||||
|
||||
| Method | Signature | Description |
|
||||
|---|---|---|
|
||||
| `fetchAndPopulateDropdowns` | `(configUrls, elements, node)` | Cascading supplier > subType > model > unit dropdowns |
|
||||
| `populateDropdown` | `(htmlElement, options, node, property, callback?)` | Fill a `<select>` with options and wire change events |
|
||||
| `populateLogLevelOptions` | `(logLevelSelect, configData, node)` | Populate log-level dropdown from config |
|
||||
| `populateSmoothingMethods` | `(configUrls, elements, node)` | Populate smoothing method dropdown |
|
||||
| `populateInterpolationMethods` | `(configUrls, elements, node)` | Populate interpolation method dropdown |
|
||||
| `generateHtml` | `(htmlElement, options, savedValue)` | Write `<option>` HTML into an element |
|
||||
|
||||
---
|
||||
|
||||
## EndpointUtils
|
||||
|
||||
Server-side helper that serves `MenuUtils` as browser JavaScript via Node-RED HTTP endpoints.
|
||||
|
||||
**File:** `src/helper/endpointUtils.js`
|
||||
|
||||
### Constructor
|
||||
|
||||
```js
|
||||
new EndpointUtils({ MenuUtilsClass? })
|
||||
```
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `MenuUtilsClass` | `class` | `MenuUtils` | The MenuUtils constructor to introspect |
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `createMenuUtilsEndpoint` | `(RED, nodeName, customHelpers?)` | `void` | Register `GET /<nodeName>/resources/menuUtils.js` |
|
||||
| `generateMenuUtilsCode` | `(nodeName, customHelpers?)` | `string` | Produce the browser JS string (introspects `MenuUtils.prototype`) |
|
||||
|
||||
### Example
|
||||
|
||||
```js
|
||||
const EndpointUtils = require('generalFunctions/src/helper/endpointUtils');
|
||||
const ep = new EndpointUtils();
|
||||
ep.createMenuUtilsEndpoint(RED, 'valve');
|
||||
// Browser can now load: GET /valve/resources/menuUtils.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Positions
|
||||
|
||||
Canonical constants for parent-child spatial relationships.
|
||||
|
||||
**File:** `src/constants/positions.js`
|
||||
|
||||
### Exports
|
||||
|
||||
```js
|
||||
const { POSITIONS, POSITION_VALUES, isValidPosition } = require('generalFunctions');
|
||||
```
|
||||
|
||||
| Export | Type | Value |
|
||||
|---|---|---|
|
||||
| `POSITIONS` | `object` | `{ UPSTREAM: 'upstream', DOWNSTREAM: 'downstream', AT_EQUIPMENT: 'atEquipment', DELTA: 'delta' }` |
|
||||
| `POSITION_VALUES` | `string[]` | `['upstream', 'downstream', 'atEquipment', 'delta']` |
|
||||
| `isValidPosition` | `(pos: string): boolean` | Returns `true` if `pos` is one of the four values |
|
||||
|
||||
---
|
||||
|
||||
## AssetLoader / loadCurve
|
||||
|
||||
Loads JSON asset files (machine curves, etc.) from the datasets directory with LRU caching.
|
||||
|
||||
**File:** `datasets/assetData/curves/index.js`
|
||||
|
||||
### Singleton convenience functions
|
||||
|
||||
```js
|
||||
const { loadCurve } = require('generalFunctions');
|
||||
```
|
||||
|
||||
| Function | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `loadCurve` | `(curveType: string)` | `object \| null` | Load `<curveType>.json` from the curves directory |
|
||||
| `loadAsset` | `(datasetType, assetId)` | `object \| null` | Load any JSON asset by dataset folder and ID |
|
||||
| `getAvailableAssets` | `(datasetType)` | `string[]` | List asset IDs in a dataset folder |
|
||||
|
||||
### AssetLoader class
|
||||
|
||||
```js
|
||||
new AssetLoader(maxCacheSize = 100)
|
||||
```
|
||||
|
||||
Same methods as above (`loadCurve`, `loadAsset`, `getAvailableAssets`), plus `clearCache()`.
|
||||
|
||||
### Example
|
||||
|
||||
```js
|
||||
const { loadCurve } = require('generalFunctions');
|
||||
const curve = loadCurve('hidrostal-H05K-S03R');
|
||||
// curve = { flow: [...], head: [...], ... } or null
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user