88 Commits

Author SHA1 Message Date
Rene De Ren
aec90cc8e7 fix: stopLevel hysteresis works — bump rotatingMachine + MGC
Some checks failed
CI / lint-and-test (push) Has been cancelled
Pump-shutdown deadlock fix split across two submodules:

- rotatingMachine@8f9150e: shutdown sequence clears state.delayedMove
  so the abort-and-return-to-operational path doesn't auto-pickup the
  queued setpoint and re-engage the pump.
- machineGroupControl@ea2857f: turnOffAllMachines clears MGC's
  _delayedCall and serializes per-pump shutdown so PS's 2 s tick loop
  can't interrupt an in-flight shutdown.

Live verification on pumpingstation-complete-example demo: basin now
shuts pumps off at stopLevel cleanly, reverses to fill, completes the
hysteresis cycle.

Also disable the trends page in the demo flow (build_flow.py + regen
flow.json). FlowFuse ui-chart's per-series server-side history buffer
(7 charts × ~20 series × 3600-point retention) was saturating the
Node-RED event loop at 129% CPU, making the dashboard freeze on every
click. Trends remain available — just disabled by default; flip the
ui_page_trends "d" key to false to re-enable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:18:11 +02:00
Rene De Ren
6fef002da1 Bump machineGroupControl@2651aaf — quiet abortActiveMovements normal path
Some checks failed
CI / lint-and-test (push) Has been cancelled
WARN now fires only when force-aborting an actually in-flight pump
movement (gate-bypass safety net), not on every no-op tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:43:17 +02:00
Rene De Ren
c4d75809cd Bump machineGroupControl@df74ea0 — serialize handleInput dispatches
Some checks failed
CI / lint-and-test (push) Has been cancelled
Adds the _dispatchInFlight gate that mirrors rotatingMachine
state.delayedMove. Before this, PS at 1 Hz overran in-flight pump
ramps via concurrent handleInput entries, producing the live thrash:
120 aborts / 2 min, pump_b clamped at minFlow.

Includes regression test:
test/mgc-overactive-demand-serialization.integration.test.js
covering concurrent-burst serialization (30 calls → ≤ 5 aborts) and
latest-wins semantic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:15:23 +02:00
Rene De Ren
a4617d850a Bump MGC@96b84d3 — revert unchanged-demand short-circuit (broke live demo)
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:55:47 +02:00
Rene De Ren
44963cfa43 Bump MGC@a14aa0d — short-circuit handleInput on unchanged demand
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:10:57 +02:00
Rene De Ren
15c39f76bb Bump MGC@69bdf11 + adjust overcapacity test to actually exercise storm
Some checks failed
CI / lint-and-test (push) Has been cancelled
- nodes/machineGroupControl@69bdf11 makes DOWNSTREAM single-writer
  (handlePressureChange = live aggregate; optimizer target moved to
  AT_EQUIPMENT). Closes the ps-mgc-flow-contract failure.

- test/inflow-overcapacity-stability now starts the basin at maxLevel
  so PS percControl is immediately 100 % (the actual storm condition)
  and uses real-time waits between ticks so movementManager intervals
  fire — the previous setImmediate yield was too fast for moves to
  progress, making pumps look perma-parked even when behaviour was OK.
  Park observations dropped from 83 to 3 across the sim window; final
  ctrl converges to ~88 % across all 3 pumps.

All 82 cross-node + node integration tests now pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:33:09 +02:00
Rene De Ren
3c7d54e9c3 Add 4 cross-node tests closing PS↔MGC integration gaps
Some checks failed
CI / lint-and-test (push) Has been cancelled
- ps-mgc-flow-contract: asserts PS's view of MGC outflow equals the live
  per-pump aggregate at every tick. Currently FAILS — exposes that
  MGC's flow.predicted.downstream reverts to optimalControl's bestFlow
  target after handlePressureChange writes the correct flow.act, leaving
  PS with stale outflow values. The mirror added in dc27a56 is necessary
  but not sufficient.

- dead-zone-signal: asserts the Schmitt-trigger transitions
  (engaged 100% → keep-alive 1% → off 0%) across startLevel↓/stopLevel↓
  with proper rising-edge re-arm. Currently PASSES.

- inflow-overcapacity-stability: 45 s sim at 2× station capacity;
  asserts pumps don't thrash or park in accelerating residue. Currently
  FAILS — pumps end up at ctrl=0 in 'accelerating' state, suggesting
  the residue-unpark fix doesn't fully cover steady-state over-capacity.

- realistic-startup-timing: re-runs the varying-demand-during-startup
  scenario with PRODUCTION-default state.time (starting=10s, warm=5s)
  instead of the 1-2 s used elsewhere. Currently PASSES — confirms the
  dispatch-reorder fix holds under realistic transition windows.

Honest summary: 2 pass, 2 fail. The two failures expose genuine
remaining defects in the PS↔MGC measurement contract and the
residue-unpark policy. They're committed FAILING so the bugs are
captured under version control until the underlying fixes land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:07:11 +02:00
Rene De Ren
21e777797a Bump machineGroupControl@dc27a56 — mirror aggregate flow onto DOWNSTREAM
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:20:28 +02:00
Rene De Ren
035f03cdee Bump machineGroupControl@b7c40b0 — mirror dispatch fix in equalFlowControl
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:47:23 +02:00
Rene De Ren
9bc6908d05 Bump machineGroupControl@8e68420 — add cycle/sweep regression tests
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:34:01 +02:00
Rene De Ren
0cab98c196 Pumping-station demo overhaul + cross-node test harness + bumps
Some checks failed
CI / lint-and-test (push) Has been cancelled
Submodule bumps land the deadlock fix (state.js residue unpark + MGC
optimalControl dispatch reorder) and pumpingStation stopLevel hysteresis.

- Renames examples/pumpingstation-3pumps-dashboard →
  pumpingstation-complete-example with regenerated flow.json. New
  dashboard groups, demand-broadcast wiring, S88 placement rule
  applied, ui-chart trend-split and link-channel naming follow
  .claude/rules/node-red-flow-layout.md.
- New cross-node test harness under test/: end-to-end-pumpingstation
  drives PS + MGC + 3 pumps + physics simulator end-to-end and
  verifies the ~5/15 min cycle.
- Adds Grafana provisioning dashboards (pumping-station.json) and a
  helper sync-example.sh script for export/import to live Node-RED.
- Docker entrypoint + settings + compose tweaks for the persistent
  user dir layout used by the demo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:21:21 +02:00
Rene De Ren
ca0644d689 Bump generalFunctions@94bcc90 — gitignore local lockfile stub
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:24:28 +02:00
Rene De Ren
5766ee4d16 Drop tensorflow deps; rule cleanups; repo-mem MCP; bump pumpingStation@6ab585b
Some checks failed
CI / lint-and-test (push) Has been cancelled
- package.json: remove @tensorflow/tfjs and @tensorflow/tfjs-node.
  Monster's TF code was already stripped; the deps were stale and kept
  pulling a heavy native binary back into every install.
- .gitignore: ignore .repo-mem/ regenerable indexes and per-session
  .claude/*.lock runtime files.
- CLAUDE.md: prepend READ-FIRST pointer to .claude/rules/repo-mem.md;
  collapse the 'three outputs' bullet to a pointer at node-architecture.
- .claude/rules/telemetry.md: drop Port 0/1/2 duplication; reference
  node-architecture.md.
- .claude/rules/testing.md: stop requiring a separate test/edge tier and
  the basic/integration/edge example flow trio. Reflects what nodes
  actually do.
- .claude/rules/repo-mem.md (new): when-to-call-which guide for the
  per-repo memory MCP, anti-patterns, refresh model.
- .mcp.json (new): wire repo-mem stdio server.
- docs/DEVELOPER_GUIDE.md (new): step-by-step guide for adding a new
  EVOLV node under the three-layer pattern.
- Bump nodes/pumpingStation to 6ab585b (docs + simulations refresh,
  spill-flow path renames consistent with d8490aa).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:23:47 +02:00
Rene De Ren
0466287514 Bump pumpingStation@d8490aa + generalFunctions@a516c2b
Some checks failed
CI / lint-and-test (push) Has been cancelled
pumpingStation: predicted-volume hard-floor at 0; spill flow refactored
from flow.predicted.out.<child=overflow> to its own position
flow.predicted.overflow. Drops the spillPrev self-subtraction. New
underflowVolume diagnostic for flow-balance errors. 70/70 tests pass.

generalFunctions: MeasurementContainer.get() strict-resolves explicit
.child(name) — missing named child now returns null instead of falling
through to a sibling. Persistent setChildId remains a hint (no
behavioural change for registered children).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:45 +02:00
Rene De Ren
21b0bd34c6 Bump pumpingStation@6b46a8a — predicted-volume overflow clamp
Some checks failed
CI / lint-and-test (push) Has been cancelled
Integrator now clamps predicted volume to [dryRunSafetyVol,
maxVolAtOverflow], records cumulative spill as overflowVolume and
exposes a synthetic flow.predicted.out.overflow rate so net flow
balances to ~0 while pinned. _selectBestNetFlow holds the last
level-rate net flow during overflow so dashboards keep a usable
reading. Top-level predictedOverflowVolume / predictedOverflowRate
fields added to getOutput.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:48:00 +02:00
Rene De Ren
60c6a647e2 Bump pumpingStation@62bc73f — input bounds + full hierarchy validation
Some checks failed
CI / lint-and-test (push) Has been cancelled
- bounds.js sets HTML5 min/max on every level + percent input so the
  spinner can't push values past the basin hierarchy.
- Basin-level violations now surface in a visible ribbon above the
  basin diagram and block Deploy via oneditsave.
- Layout polish: widened side panel, tightened basin viewBox, dropped
  mode-preview axis labels, moved datum below the tank.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:10:29 +02:00
Rene De Ren
48b9335dac Bump pumpingStation@de9a79b + generalFunctions@4b6250c
Some checks failed
CI / lint-and-test (push) Has been cancelled
- pumpingStation: hold-then-ramp shift hysteresis driven by
  shiftArmPercent (% output threshold for arming) instead of by level.
  New e2e integration test exercises the full fill→arm→hold→ramp-down
  cycle. Editor preview gains the arming-% horizontal line.
- generalFunctions: add shiftArmPercent to the pumpingStation schema;
  add prominent doc block on MeasurementContainer documenting the
  `${type}.${variant}.${position}.${childId}` flatten format and the
  implicit 'default' childId convention so dashboards don't drop it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:47:03 +02:00
Rene De Ren
7c6c6183f7 Bump pumpingStation@8a6ca1b + generalFunctions@35f648f
Some checks failed
CI / lint-and-test (push) Has been cancelled
- pumpingStation: level-armed shift hysteresis, derived dryRunLevel,
  side-panel editor with hover-coupling, manual q_out for end-to-end
  testing without rotating-machine wiring.
- generalFunctions: schema additions for flowThreshold, output formats,
  enableShiftedRamp / shiftLevel under control.levelbased.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:29:49 +02:00
Rene De Ren
2593458bdf Update pumpingStation submodule
Some checks failed
CI / lint-and-test (push) Has been cancelled
2026-05-05 11:02:33 +02:00
znetsixe
36147de6d7 Bump pumpingStation@ab0d4ed — outlet pinned, zone labels added, volume in diagram
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:20:06 +02:00
znetsixe
b84c59cbe6 Bump pumpingStation@2dd419d — revert tank size, nudge lines themselves
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:10:32 +02:00
znetsixe
b873a8fb02 Bump pumpingStation@785d036 — taller editor diagram, more breathing room
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:41:10 +02:00
znetsixe
7e51bec8f2 Bump pumpingStation@65fe68b — nudge crowded threshold inputs with leader lines
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:41:26 +02:00
znetsixe
aa546df6e6 Bump pumpingStation@d641d22 — interactive basin diagram in editor
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:28:26 +02:00
znetsixe
c413c0fad5 Bump pumpingStation@12904b4 — inline parameters diagram in editor
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:19:29 +02:00
znetsixe
a6ad85ae38 Bump pumpingStation@1ebbcb6 — editor pipe-edge labels + live derived safety levels
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:58:26 +02:00
znetsixe
33ac527274 Bump rotatingMachine + machineGroupControl submodule pointers
Some checks failed
CI / lint-and-test (push) Has been cancelled
- rotatingMachine@399e0a8: editor hygiene (name default, status
  clear on close), remove redundant idle-position clamp in
  flow/power predictions.
- machineGroupControl@9c79dac: bug fix — stale flow/power cache
  now cleared on MGC shutdown so parent pumpingStation sees the
  drop immediately. Also awaits shutdown promises correctly and
  corrects the NCog integration tests to match centrifugal-pump
  physics (Q/P monotonic → NCog=0 → fallback to equal distribution).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:51:25 +02:00
znetsixe
d22d1cabd1 Rename eval/ decision log to simulations/; bump pumpingStation pointer
Some checks failed
CI / lint-and-test (push) Has been cancelled
Follows pumpingStation@3e13512 (rename eval/ → simulations/). The
decision log file is renamed to match the new folder name; an
addendum in the body explains that the rename was a naming
clarification, not a rationale change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:47:00 +02:00
znetsixe
79afe11da8 Log pumpingStation architectural decisions; bump submodule pointers
Some checks failed
CI / lint-and-test (push) Has been cancelled
Four decisions recorded under .agents/decisions/ per project convention
(DECISION-YYYYMMDD-slug.md) to close the loop on today's pumpingStation
refactor + eval + docs work:

- wiki-in-code-repo — why docs+diagrams+code now live in one package
- 5-threshold-naming — old/new field mapping + breaking-change rationale
- mode-tier-template — Tier 1/2/3 classification for mode pages
- eval-harness — why eval/ exists alongside test/

Also bumps nodes/pumpingStation to 66fd3fe (eval harness + Tier 2/3
template pages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:50:00 +02:00
znetsixe
b885f291d4 Propagate threshold rename; point platform manual at pumpingStation wiki
Some checks failed
CI / lint-and-test (push) Has been cancelled
Follows pumpingStation@a218945 + generalFunctions@4252292 rename:

- Bump pumpingStation and generalFunctions submodule pointers.
- Update examples/pumpingstation-3pumps-dashboard/ (build_flow.py,
  flow.json, README.md) to use the new threshold names. Collapsed
  minFlowLevel into startLevel; reshuffled order to match the basin
  bottom-to-top: minLevel, startLevel, maxLevel.
- wiki/manuals/README.md: drop the stale pumpingStation.md line and
  point readers at pumpingStation/wiki instead (docs have moved into
  the node's own repo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:15:36 +02:00
znetsixe
4a9521154b fix: safety overfill keeps pumps running + minHeightBasedOn=inlet
Some checks failed
CI / lint-and-test (push) Has been cancelled
pumpingStation 5e2ebe4: overfill safety no longer shuts down machine
groups or blocks level control. Pumps keep running during overfill
(sewer can't stop receiving). Only upstream equipment is shut down.

Demo config: minHeightBasedOn=inlet (not outlet). The minimum height
reference for the basin is the inlet pipe elevation — sewage flows
in by gravity and the basin level can't go below the inlet without
the sewer backing up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:11:08 +02:00
znetsixe
732b5a3380 fix: realistic sinus + continuous pump control + dead zone elimination
Some checks failed
CI / lint-and-test (push) Has been cancelled
Sinus inflow: 54-270 m³/h (base 0.015 + amplitude 0.06 m³/s), 4 min
period. Peak needs 1-2 pumps, never all 3 = realistic headroom.

PS control: continuous proportional demand when level > stopLevel, not
just when > startLevel && filling. Pumps now ramp down smoothly as
basin drains toward stopLevel instead of staying stuck at last setpoint.

pumpingStation e8dd657: dead zone elimination
build_flow.py: sinus tuned for gradual pump scaling visibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:42:55 +02:00
znetsixe
c8f149e204 feat(dashboard): split basin charts by unit + add y-axis labels to all charts
Some checks failed
CI / lint-and-test (push) Has been cancelled
Flow: m³/h, Power: kW, Basin Level: m, Basin Fill: % (0-100 fixed).
Level and fill in separate chart groups with their own gauges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:19:32 +02:00
znetsixe
b693e0b90c fix: graduated pump control + mass balance corrections
Some checks failed
CI / lint-and-test (push) Has been cancelled
Three fixes:

1. PS outflow triple-counted (pumpingStation c62d8bc): MGC registered
   twice + individual pumps registered alongside MGC + dual event
   subscription per child. Now: one registration per aggregation level,
   one event per child. Volume integration tracks correctly.

2. All 3 pumps always on: minFlowLevel was 1.0 m but startLevel was
   2.0 m, so at the moment pumps started the percControl was already
   40% → MGC mapped to 356 m³/h → all 3 pumps. Fixed: minFlowLevel
   = startLevel (2.0 m) so percControl starts at 0% and ramps
   linearly. Now pumps graduate: 1-2 pumps at low level, 3 at high.

3. Generalizable registration rule added as code comments: when a group
   aggregator exists (MGC), subscribe to it, not its children. Pick
   one event name per measurement type per child.

E2E verified: 2/3 pumps active at 56% fill, volume draining correctly,
pump C at 5.2% ctrl delivering 99 m³/h while pump A stays off.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:10:32 +02:00
znetsixe
2b0c4e89b1 fix: abort recovery bounce loop broke MGC → pump control
Some checks failed
CI / lint-and-test (push) Has been cancelled
generalFunctions 086e5fe -> 693517c:
  abortCurrentMovement now takes options.returnToOperational (default
  false). Routine MGC demand-update aborts leave pumps in their current
  state. Only shutdown/emergency-stop paths pass returnToOperational:true.

rotatingMachine 510a423 -> 11d196f:
  executeSequence passes returnToOperational:true for shutdown/estop.

Verified E2E: PS fills to startLevel → MGC distributes demand → all 3
pumps at 1.31% ctrl delivering 121 m³/h each → basin draining at
-234 m³/h net. Full fill/drain cycle operational.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:11:36 +02:00
znetsixe
faaeb2efd3 fix: realistic basin sizing + boosted sinus inflow for visible fill cycle
Some checks failed
CI / lint-and-test (push) Has been cancelled
Basin was 30 m³ with 72 m³/h average sinus inflow → took 10+ minutes
to reach startLevel, looking static on the dashboard. Boosted sinus to
base=0.02 + amplitude=0.10 m³/s (avg ~252 m³/h, peak ~432 m³/h). Basin
fills from outlet to startLevel in ~3 minutes now.

Also removed initBasinProperties trace from previous debug session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:17:17 +02:00
znetsixe
53b55d81c3 fix: fully configure PS basin + add node-completeness rule
Some checks failed
CI / lint-and-test (push) Has been cancelled
Basin undersized (10m³) for sinus peak (126 m³/h) → overflow → 122%.
Now 30 m³ with 4m height, all PS fields set. New rule: always configure
every field of every node.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:00:27 +02:00
znetsixe
eb97670179 fix(dashboard): use correct basin capacity for fill % + clamp to 0-100
Some checks failed
CI / lint-and-test (push) Has been cancelled
maxVol was hardcoded to 9.33 (overflow volume at 2.8 m height) instead
of 10.0 (basin capacity = basinVolume config). Volumes above 9.33 m³
produced fill > 100% (e.g. 122% at vol=11.4). Fixed to use 10.0 and
clamp to [0, 100].

Patched via nodes-only deploy — basin not reset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:53:41 +02:00
znetsixe
cc4ee670ea fix(dashboard): move basin gauges to trend pages next to basin chart
Some checks failed
CI / lint-and-test (push) Has been cancelled
The tank gauge (basin level) and 270° arc gauge (fill %) now live on
the trend pages alongside the basin metrics chart — not on the control
page. Each trend page (10 min / 1 hour) gets its own pair of gauges.

Layout per trend page Basin group:
  - Chart (width 8): Basin fill % + Level + Net flow series
  - Tank gauge (width 2): 0–3 m with color zones at stop/start levels
  - Arc gauge (width 2): 0–100% fill with red/orange/green zones

Deployed via partial (nodes-only) deploy so the basin wasn't reset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:46:38 +02:00
znetsixe
a51bc46e26 feat(dashboard): add tank gauge for basin level + 270° arc for fill %
Some checks failed
CI / lint-and-test (push) Has been cancelled
Basin Status group on the Control page now has two visual gauges:

1. gauge-tank (vertical tank with fill gradient) for basin level 0–3 m.
   Color zones: red < 0.6 m (below stopLevel) → orange → blue 1.2–2.5 m
   (normal operating range) → orange → red > 2.8 m (overflow zone).

2. gauge-34 (270° arc) for fill percentage 0–100%.
   Color zones: red < 10% → orange → green 30–80% → orange → red > 95%.

Both gauges are fed from the PS dispatcher's numeric outputs (fillPctNum
and levelNum) which also feed the basin trend charts — same data, two
visual forms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:40:43 +02:00
znetsixe
b18c47c07e feat(dashboard): add basin fill gauge, countdown, and basin trend charts
Some checks failed
CI / lint-and-test (push) Has been cancelled
PS control page now shows 7 fields instead of 5:
  - Direction (filling/draining/steady)
  - Basin level (m)
  - Basin volume (m³)
  - Fill level (%)
  - Net flow (m³/h, signed)
  - Time to full/empty (countdown in min or s)
  - Inflow (m³/h)

Two new trend pages per time window (short 10 min / long 1 hour):
  - Basin chart: 3 series (Basin fill %, Basin level m, Net flow m³/h)
    on both Trends 10 min and Trends 1 hour pages.

PS formatter now extracts direction, netFlow, seconds from the delta-
compressed port 0 cache and computes fillPct from vol/maxVol. Dispatcher
sends 10 outputs (7 text + 3 trend numerics to both short+long basin
charts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:35:44 +02:00
znetsixe
60c8d0ff66 fix: root-cause bogus machineCurve default poisoning spline predictions
Some checks failed
CI / lint-and-test (push) Has been cancelled
generalFunctions 29b78a3 -> 086e5fe:
  Schema default machineCurve.nq had a dummy pressure slice at key "1"
  with fake data. Deep merge injected it alongside real curve data,
  pulling the pressure-dimension spline negative at low pressures.
  Fix: default to empty {nq: {}, np: {}}.

rotatingMachine 26e253d -> 510a423:
  Tests updated for corrected fValues.min (70000 vs old 1).
  Trace instrumentation removed. 91/91 green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:28:24 +02:00
znetsixe
658915c53e chore: bump rotatingMachine — clamp negative flow/power at ctrl≤0
Some checks failed
CI / lint-and-test (push) Has been cancelled
rotatingMachine: c464b66 -> 26e253d

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:07:10 +02:00
znetsixe
0cbd6a4077 wip: sinus-driven pumping station demo + PS levelbased control to MGC
Some checks failed
CI / lint-and-test (push) Has been cancelled
Architecture change: demo is now driven by a sinusoidal inflow into the
pumping station basin, rather than a random demand generator. The basin
fills from the sinus, and PS's levelbased control should start/stop
pumps via MGC when level crosses start/stop thresholds.

Changes:
- Demo Drivers tab: sinus generator (period 120s, base 0.005 + amp 0.03
  m³/s) replaces the random demand. Sends q_in to PS via link channel.
- PS config: levelbased mode, 10 m³ basin, startLevel 1.2 m / stopLevel
  0.6 m. Volume-based safeties on, time-based off.
- MGC scaling = normalized (was absolute) so PS's percent-based level
  control maps correctly.
- Dashboard mode toggle now drives PS mode (levelbased ↔ manual) instead
  of per-pump setMode. Slider sends Qd to PS (only effective in manual).
- PS code (committed separately): _controlLevelBased now calls
  _applyMachineGroupLevelControl + new Qd topic + forwardDemandToChildren.

KNOWN ISSUE: Basin fills correctly (visible on dashboard), but pumps
don't start when level exceeds startLevel. Likely cause: _pickVariant
for 'level' in _controlLevelBased may not be resolving the predicted
level correctly, or the safetyController is interfering despite
time-threshold being 0. Needs source-level tracing of the PS tick →
_safetyController → _controlLogic → _controlLevelBased path with
logging enabled. To be debugged in the next session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:42:22 +02:00
znetsixe
bc8138c3dc fix(charts): add all required FlowFuse ui-chart properties + document in rule set
Some checks failed
CI / lint-and-test (push) Has been cancelled
Charts rendered blank because the helper was missing 15+ required
FlowFuse properties. The critical three:
  - interpolation: "linear" (no line drawn without it)
  - yAxisProperty: "payload" + yAxisPropertyType: "msg" (chart didn't
    know which msg field to plot)
  - xAxisPropertyType: "timestamp" (chart didn't know the x source)

Also: width/height must be numbers not strings, colors/textColor/
gridColor arrays must be present, and stackSeries/bins/xAxisFormat/
xAxisFormatType all need explicit values.

Fixed the ui_chart helper to include every property from the working
rotatingMachine/examples/03-Dashboard.json charts. Added the full
required-property template + gotcha list to the flow-layout rule set
(Section 4) so this class of bug is caught by reference on the next
chart build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:04:43 +02:00
znetsixe
06d81169e8 fix(trends): add msg.timestamp to chart data points
Some checks failed
CI / lint-and-test (push) Has been cancelled
FlowFuse ui-chart with xAxisType=time may need an explicit timestamp
on each msg for the time axis to render. Added Date.now() as
msg.timestamp on the per-pump dispatcher flow/power outputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:59:04 +02:00
znetsixe
82db2953e9 fix(dashboard): resolve [object Object] in ui-text widgets + use dispatcher pattern
Some checks failed
CI / lint-and-test (push) Has been cancelled
FlowFuse ui-text only supports {{msg.payload}} — not nested paths
like {{msg.payload.state}}. Every ui-text was showing [object Object]
because the formatter sent a fat object as msg.payload and the format
template tried to access sub-fields.

Fix: per-pump (and per-MGC, per-PS) "dispatcher" function on the
Dashboard UI tab. The dispatcher receives the fat object via one
link-in, then returns 7-9 plain-string outputs — one per ui-text
widget — each with msg.payload set to the formatted string value.
Outputs 8+9 carry numeric values (flowNum/powerNum) tagged with
msg.topic for the trend charts, wired directly to both short-term
and long-term chart nodes.

Pattern documented as the recommended approach in the rule set:
"FlowFuse ui-text receives plain strings only — use a dispatcher
function to split a fat object into per-widget outputs."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:54:02 +02:00
znetsixe
d439b048f2 docs: add CLAUDE.md to all 11 node submodules — S88 classification + rule reference
Some checks failed
CI / lint-and-test (push) Has been cancelled
Each node repo now has a CLAUDE.md that declares its S88 hierarchy
level (Control Module / Equipment Module / Unit / Process Cell), the
associated S88 colour, and the placement lane per the superproject's
flow-layout rule set (.claude/rules/node-red-flow-layout.md).

The rule set lives in the superproject only (single source of truth).
Per-node repos reference it. When Claude Code opens a node repo, it
reads the local CLAUDE.md and knows which lane / colour / group to
use when building a multi-node demo or production flow.

Submodule pointer bumps for all 11 nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:48:37 +02:00
znetsixe
e280d87e6a fix(dashboard): split trends into 3 pages + fix chart dimensions
Some checks failed
CI / lint-and-test (push) Has been cancelled
Dashboard was a single page — 30+ widgets + tiny charts competing for
space. Trends were invisible or very small (width/height both "0"
meant "inherit from group" which gave near-zero chart area).

Split into 3 dashboard pages:
  1. Control — Process Demand, Station Controls, MGC/Basin status,
     per-pump panels (unchanged, just moved off trend groups)
  2. Trends — 10 min — rolling 10-minute flow + power charts with
     width=12 (full group), height=8 (tall charts), 300 max points
  3. Trends — 1 hour — same layout with 60-minute window, 1800 points

All 3 pages auto-nav via the FlowFuse sidebar. Same data feed: the
per-pump trend_split function now wires to 4 charts (2 outputs × 2
pages) instead of 2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:46:51 +02:00
znetsixe
64944aa9d8 docs(rules): add S88-hierarchical placement rules for Node-RED flows
Some checks failed
CI / lint-and-test (push) Has been cancelled
Sections 10-16 extend the existing flow-layout rule with a deterministic
lane-and-group convention anchored in the S88 hierarchy:

- 8 logical lanes: L0 inputs -> L1 adapters -> L2 CM -> L3 EM -> L4 UN
  -> L5 PC -> L6 formatters -> L7 outputs. 240 px between lanes.
- Lane assignment is by S88 level, not by node name. New nodes inherit
  a lane via a NODE_LEVEL registry, no rule change needed.
- Every parent + its direct children is wrapped in a Node-RED group box
  coloured by the parent's S88 level (Pump A = EM blue, MGC = Unit blue,
  PS = Process Cell blue, ...). Search the parent's name -> group
  highlights.
- Utility clusters (mode broadcast, station-wide commands, demand
  fan-out) use neutral-grey group boxes.
- Dashboard / setup / demo-driver tabs each get a variant of the rule.
- Spacing constants, place() and wrap_in_group() helpers, an 8-step
  verification checklist.

Off-spec colours (settler orange, monster teal, diffuser and
dashboardAPI missing) are flagged in Section 16 as a follow-up cleanup.
The NODE_LEVEL registry already maps those nodes to their semantic S88
level regardless of what the node's own colour currently says.

Rule lives in the superproject only; per-node repos will reference it
from their own CLAUDE.md files (separate commits per submodule).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:31:57 +02:00
znetsixe
0d7af6bfff refactor(examples): split pumpingstation demo across 4 concern-based tabs + add layout rule set
Some checks failed
CI / lint-and-test (push) Has been cancelled
The demo was a single 96-node tab with everything wired directly. Now
4 tabs wired only through named link-out / link-in pairs, and a
permanent rule set for future Claude sessions to follow.

Tabs (by concern, not by data flow):

  🏭 Process Plant   only EVOLV nodes (3 pumps + MGC + PS + 6 measurements)
                     + per-node output formatters
  📊 Dashboard UI   only ui-* widgets, button/setpoint wrappers, trend
                     splitters
  🎛️ Demo Drivers   random demand generator + state holder. Removable
                     in production
  ⚙️ Setup & Init   one-shot deploy-time injects (mode, scaling,
                     auto-startup, random-on)

Cross-tab wiring uses a fixed named-channel contract (cmd:demand,
cmd:mode, cmd:setpoint-A, evt:pump-A, etc.) — multiple emitters can
target a single link-in for fan-in, e.g. both the slider and the random
generator feed cmd:demand.

Bug fixes folded in:

1. Trend chart was empty / scrambled. Root cause: the trend-feeder
   function had ONE output that wired to BOTH flow and power charts,
   so each chart received both flow and power msgs and the legend
   garbled. Now: 2 outputs (flow → flow chart, power → power chart),
   one msg per output.

2. Every ui-text and ui-chart fell on the (0, 0) corner of the editor
   canvas. Root cause: the helper functions accepted x/y parameters
   but never assigned them on the returned node dict — Node-RED
   defaulted every widget to (0, 0) and they piled on top of each
   other. The dashboard render was unaffected (it lays out by group/
   order), but the editor was unreadable. Fixed both helpers and added
   a verification step ("no node should be at (0, 0)") to the rule set.

Spacing convention (now codified):
- 6 lanes per tab at x = [120, 380, 640, 900, 1160, 1420]
- 80 px standard row pitch, 30-40 px for tight ui-text stacks
- 200 px gap between sections, with a comment header per section

New rule set: .claude/rules/node-red-flow-layout.md
- Tab boundaries by concern
- Link-channel naming convention (cmd:/evt:/setup: prefixes)
- Spacing constants
- Trend-split chart pattern
- Inject node payload typing pitfall (per-prop v/vt)
- Dashboard widget rules (every ui-* needs x/y!)
- Do/don't checklist
- Link-out/link-in JSON cheat sheet
- 5-step layout verification before declaring a flow done

CLAUDE.md updated to point at the new rule set.

Verified end-to-end on Dockerized Node-RED 2026-04-13: 168 nodes across
4 tabs, all wired via 22 link-out / 19 link-in pairs, no nodes at
(0, 0), pumps reach operational ~5 s after deploy, MGC distributes
random demand, trends populate per pump.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:13:27 +02:00
znetsixe
7aacee6482 feat(examples): pumpingstation-3pumps-dashboard end-to-end demo + bump generalFunctions
Some checks failed
CI / lint-and-test (push) Has been cancelled
New top-level examples/ folder for end-to-end demos that show how multiple
EVOLV nodes work together (complementing the per-node example flows under
nodes/<name>/examples/). Future end-to-end demos will live as siblings.

First demo: pumpingstation-3pumps-dashboard
- 1 pumpingStation (basin model, manual mode for the demo so it observes
  rather than auto-shutting pumps; safety guards disabled — see README)
- 1 machineGroupControl (optimalcontrol mode, absolute scaling)
- 3 rotatingMachine pumps (hidrostal-H05K-S03R curve)
- 6 measurement nodes (per pump: upstream + downstream pressure mbar,
  simulator mode for continuous activity)
- Process demand input via dashboard slider (0-300 m3/h) AND auto random
  generator (3s tick, [40, 240] m3/h) — both feed PS q_in + MGC Qd
- Auto/Manual mode toggle (broadcasts setMode to all 3 pumps)
- Station-wide Start / Stop / Emergency-Stop buttons
- Per-pump setpoint slider, individual buttons, full status text
- Two trend charts (flow per pump, power per pump)
- FlowFuse dashboard at /dashboard/pumping-station-demo

build_flow.py is the source of truth — it generates flow.json
deterministically and is the right place to extend the demo.

Bumps:
  nodes/generalFunctions  43f6906 -> 29b78a3
    Fix: childRegistrationUtils now aliases the production
    softwareType values (rotatingmachine, machinegroupcontrol) to the
    dispatch keys parent nodes check for (machine, machinegroup). Without
    this, MGC <-> rotatingMachine and pumpingStation <-> MGC wiring
    silently never matched in production even though tests passed.
    Demo confirms: MGC reports '3 machine(s) connected'.

Verified end-to-end on Dockerized Node-RED 2026-04-13: pumps reach
operational ~5s after deploy, MGC distributes random demand across them,
basin tracks net flow direction, all dashboard widgets update each second.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:53:47 +02:00
znetsixe
d7d106773e chore: bump generalFunctions submodule — fix asset menu supplier->type->model cascade
Some checks failed
CI / lint-and-test (push) Has been cancelled
generalFunctions: e50be2e -> 43f6906

Fixes the bug where picking a supplier and then a type left the model
dropdown stuck on "Awaiting Type Selection". Affects every node that
uses the shared assetMenu (measurement, rotatingMachine, pumpingStation,
monster, …). The chained dropdowns now use an explicit downward
cascade with no synthetic change-event dispatch, so the parent handler
can no longer wipe a child after the child was populated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:51:02 +02:00
znetsixe
89f3b5ddc4 chore: bump measurement submodule — fix asset menu render (TDZ ReferenceError)
Some checks failed
CI / lint-and-test (push) Has been cancelled
measurement: d6f8af4 -> <new>

Fixes a regression in the previous measurement editor commit where a
const Temporal Dead Zone error in oneditprepare aborted the function
before the asset / logger / position menu init ran. Menus are now
kicked off first, mode logic is guarded with try/catch and null-checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:15:11 +02:00
znetsixe
d0fe4d0583 chore: bump measurement submodule — editor UX fix (mode as top-level switch)
Some checks failed
CI / lint-and-test (push) Has been cancelled
measurement: 495b4cf -> d6f8af4

Makes Input Mode the top-level hierarchy in the editor: analog-only and
digital-only field blocks toggle visibility live based on the dropdown,
legacy nodes default to 'analog', channels JSON gets live validation,
and runtime logs an actionable warning when the payload shape doesn't
match the selected mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:00:44 +02:00
znetsixe
0300a76ae8 docs: measurement trial-ready — digital mode + dispatcher fix + 71 tests
Some checks failed
CI / lint-and-test (push) Has been cancelled
Bumps:
- nodes/generalFunctions  75d16c6 -> e50be2e  (permissive unit check + measurement schema additions)
- nodes/measurement       f7c3dc2 -> 495b4cf  (digital mode + dispatcher fix + 59 new tests + rewritten README + UI)

Wiki:
- wiki/manuals/nodes/measurement.md — new user manual covering analog and
  digital modes, topic reference, smoothing/outlier methods, unit policy,
  and the pre-fix dispatcher bug advisory.
- wiki/sessions/2026-04-13-measurement-digital-mode.md — session note with
  findings, fix scope, test additions, and dual-mode E2E results.
- wiki/index.md — links both pages and adds the missing 2026-04-13
  rotatingMachine session entry that was omitted from the earlier commit.

Status: measurement is now trial-ready in both analog and digital modes.
71/71 unit tests green (was 12), dual-mode E2E on live Dockerized
Node-RED verifies analog regression and a three-channel MQTT-style
payload (temperature/humidity/pressure) dispatching independently with
per-channel smoothing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:46:00 +02:00
znetsixe
a1aa44f6ca docs: rotatingMachine trial-ready — submodule bumps, wiki manual, session note
Some checks failed
CI / lint-and-test (push) Has been cancelled
Bumps:
- nodes/generalFunctions  024db55 -> 75d16c6  (FSM abort recovery + schema sync)
- nodes/rotatingMachine   07af7ce -> 17b8887  (interruptible sequences, dual-curve tests, rewritten README)

Wiki:
- wiki/manuals/nodes/rotatingMachine.md — new user manual covering inputs,
  outputs, state machine, supported curves, and troubleshooting.
- wiki/sessions/2026-04-13-rotatingMachine-trial-ready.md — session note
  with findings, fixes, test additions, and dual-curve E2E results.
- wiki/index.md — link both and bump updated date.

Status: rotatingMachine is now trial-ready. 91/91 unit tests green, live
Docker E2E verifies shutdown/emergency-stop during ramps and prediction
behaviour across both shipped pump curves (hidrostal-H05K-S03R,
hidrostal-C5-D03R-SHN1).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:22:10 +02:00
znetsixe
6cf1821161 chore: remove redundant Makefile and .npmignore, fix .dockerignore
Some checks failed
CI / lint-and-test (push) Has been cancelled
- Makefile: all useful targets duplicate package.json scripts, and
  referenced deleted e2e files. Use npm run instead.
- .npmignore: contained only node_modules/ which npm ignores by default.
- .dockerignore: remove stale paths (manuals/, third_party/, AGENTS.md,
  FUNCTIONAL_ISSUES_BACKLOG.md), add wiki/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:53:26 +02:00
znetsixe
48f790d123 chore: clean up superproject structure
Some checks failed
CI / lint-and-test (push) Has been cancelled
Move content to correct locations:
- AGENTS.md → .agents/AGENTS.md (with orchestrator reference update)
- third_party/docs/ (8 reference docs) → wiki/concepts/
- manuals/ (12 Node-RED docs) → wiki/manuals/

Delete 23 unreferenced one-off scripts from scripts/ (keeping 5 active).
Delete stale Dockerfile.e2e, docker-compose.e2e.yml, test/e2e/.
Remove empty third_party/ directory.

Root is now: README, CLAUDE.md, LICENSE, package.json, Makefile,
Dockerfile, docker-compose.yml, docker/, scripts/ (5), nodes/, wiki/,
plus dotfiles (.agents, .claude, .gitea).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:01:04 +02:00
znetsixe
bac6c620b1 docs: rewrite README with actual project content
Some checks failed
CI / lint-and-test (push) Has been cancelled
Replace generic Dutch template (with placeholder text) with a proper
README showing: node inventory table, architecture summary, install
instructions, test commands, documentation links, and license.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:25:03 +02:00
znetsixe
7ded2a4415 docs: consolidate scattered documentation into wiki
Some checks failed
CI / lint-and-test (push) Has been cancelled
Move architecture/, docs/ content into wiki/ for a single source of truth:
- architecture/deployment-blueprint.md → wiki/architecture/
- architecture/stack-architecture-review.md → wiki/architecture/
- architecture/wiki-platform-overview.md → wiki/architecture/
- docs/ARCHITECTURE.md → wiki/architecture/node-architecture.md
- docs/API_REFERENCE.md → wiki/concepts/generalfunctions-api.md
- docs/ISSUES.md → wiki/findings/open-issues-2026-03.md

Remove stale files:
- FUNCTIONAL_ISSUES_BACKLOG.md (was just a redirect pointer)
- temp/ (stale cloud env examples)

Fix README.md gitea URL (centraal.wbd-rd.nl → wbd-rd.nl).
Update wiki index with all consolidated pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:08:35 +02:00
znetsixe
6d19038784 docs: initialize project wiki from production hardening session
12 pages covering architecture, findings, and metrics from the
rotatingMachine + machineGroupControl hardening work:

- Overview: node inventory, what works/doesn't, current scale
- Architecture: 3D pump curves, group optimization algorithm
- Findings: BEP-Gravitation proof (0.1% of optimum), NCog behavior,
  curve non-convexity, pump switching stability
- Metrics: test counts, power comparison table, performance numbers
- Knowledge graph: structured YAML with all data points and provenance
- Session log: 2026-04-07 production hardening
- Tools: query.py, search.sh, lint.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:36:08 +02:00
znetsixe
fd9d1679cb fix: update submodule refs — production hardening for rotatingMachine and machineGroupControl
rotatingMachine:
- Safety fixes: async input handler, emergencyStop case fix, null guards,
  listener cleanup, tick loop race condition, editor timeout
- Prediction: remove efficiency rounding (was breaking NCog/BEP), fix
  variant reads, curve anomaly detection
- 43 new tests (76 total)

machineGroupControl:
- Critical: fix flowmovement unit mismatch (m³/s sent where m³/h expected,
  pumps never moved from minimum)
- Fix absolute scaling comparison bug, empty Qd block, empty-machines guards
- Add marginal-cost refinement loop: reduces gap to brute-force optimum
  from 2.1% to <0.1%
- 2 new test files with NCog distribution and power comparison tests

generalFunctions:
- Fix 3 anomalous power values in hidrostal-H05K-S03R curve data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:22 +02:00
znetsixe
4336002b77 fix: update submodule refs with bug fixes for validateSchema recursion and nodeClass syntax
Some checks failed
CI / lint-and-test (push) Has been cancelled
- generalFunctions: fix infinite recursion in validateSchema when version string is in schema
- rotatingMachine: fix missing closing brace in emergencystop case block

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:46:34 +02:00
znetsixe
f57343f5e3 Update submodule refs after merge with main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:29:31 +02:00
znetsixe
65ceb696ab Merge remote-tracking branch 'origin/main' into dev-rene
# Conflicts:
#	.dockerignore
#	.gitmodules
#	Dockerfile
#	docker-compose.yml
#	nodes/generalFunctions
#	nodes/machineGroupControl
#	nodes/measurement
#	nodes/monster
#	nodes/pumpingStation
#	nodes/reactor
#	nodes/rotatingMachine
#	nodes/settler
#	package-lock.json
#	package.json
2026-03-31 18:29:03 +02:00
root
91a298960c Prepare reactor, diffuser, and settler updates for mainline merge 2026-03-31 14:26:33 +02:00
Rene De Ren
35221fc5dd Sync pushed submodule refs
Some checks failed
CI / lint-and-test (push) Has been cancelled
2026-03-12 16:47:08 +01:00
Rene De Ren
93a5b6a90e Expose output format controls across node editors 2026-03-12 16:39:54 +01:00
Rene De Ren
1d98670706 Validate diffuser through the full stack 2026-03-12 16:32:25 +01:00
Rene De Ren
a432eea7fe Track config-driven output formatting support 2026-03-12 16:13:47 +01:00
Rene De Ren
9cb3657bae Track dashboardapi buildConfig adoption 2026-03-12 16:11:10 +01:00
Rene De Ren
bd9432eebb Validate dashboardapi round-trip through Node-RED 2026-03-12 11:40:37 +01:00
Rene De Ren
c9bacb64c8 Add monster coverage and stack validation 2026-03-12 10:32:09 +01:00
Rene De Ren
e580c93c84 docs: add open issues from codebase scan
Tracked issues for diffuser restoration, ML module relocation,
monster architecture modernization, test code cleanup, and dashboardAPI improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:34:51 +01:00
Rene De Ren
b02306c42f fix: codebase scan — bug fixes, security, logging consistency, monster modernization
- generalFunctions: add missing migrateConfig(), config versioning, formatters module
- rotatingMachine: fix eneableLog typo, correct child registration ID
- machineGroupControl: console.log → structured logger
- settler/reactor: console.log → logger, throw on unknown reactor type
- monster: modernize imports to require('generalFunctions'), remove broken
  TensorFlow code, add childRegistrationUtils, consolidate input handlers
- dashboardAPI: remove hardcoded Grafana bearer token, use logger

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:33:52 +01:00
Rene De Ren
2c76430394 feat: working E2E container stack with Node-RED + InfluxDB + Grafana
- Fix Dockerfile.e2e to install EVOLV properly in Node-RED /data/
- Add measurement node E2E test flow with scaling (4-20mA to 0-5m)
- Add Grafana health check to run-e2e.sh
- Guard pumpingStation demo IIFE with require.main check
- All 10 EVOLV nodes load successfully in containerized Node-RED

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:38:14 +01:00
Rene De Ren
49ebd833db feat: add node tests, integration tests, API reference, fix pumpingStation bug
- Add 127 unit tests for measurement, pumpingStation, reactor, settler specificClass
- Add 32 integration tests for parent-child registration flows
- Fix pumpingStation tick() calling non-existent _calcTimeRemaining (was _calcRemainingTime)
- Add API reference documentation for all generalFunctions modules

Total tests: 536 (389 Jest + 23 node:test + 124 legacy), all passing

Closes #17, #19, #20

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:32:04 +01:00
Rene De Ren
905a061590 feat: architecture refactor — validators, positions, menuUtils, ESLint, tests, CI
Major improvements across the codebase:

- Extract validationUtils.js (548→217 lines) into strategy pattern validators
- Extract menuUtils.js (543→35 lines) into 6 focused menu modules
- Adopt POSITIONS constants across 23 files (183 replacements)
- Eliminate all 71 ESLint warnings (0 errors, 0 warnings)
- Add 158 unit tests for ConfigManager, MeasurementContainer, ValidationUtils
- Add architecture documentation with Mermaid diagrams
- Add CI pipeline (Docker, ESLint, Jest, Makefile)
- Add E2E infrastructure (docker-compose.e2e.yml)

Test results: 377 total (230 Jest + 23 node:test + 124 legacy), all passing
Lint: 0 errors, 0 warnings

Closes #2, #3, #9, #13, #14, #18

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:37:20 +01:00
p.vanderwilt
80de324b32 Update reactor submodule to latest commit 2025-11-28 11:53:57 +01:00
p.vanderwilt
c8d5ea0fce Update submodule commits for reactor and rotatingMachine 2025-11-21 14:49:50 +01:00
p.vanderwilt
b871b23c24 Remove TensorFlow dependencies from package.json 2025-11-21 11:56:59 +01:00
p.vanderwilt
91b681a74d Add additional sensors 2025-11-12 10:48:38 +01:00
p.vanderwilt
76d2008e52 Update submodule commits and package-lock.json dependencies 2025-11-12 10:30:51 +01:00
p.vanderwilt
3c304f14e5 Update submodules for recirculation implementation 2025-11-06 15:03:43 +01:00
p.vanderwilt
24c443840b Add settler to package.json 2025-10-31 12:14:58 +01:00
p.vanderwilt
c4c8629c01 add settler submodule 2025-10-31 12:11:50 +01:00
609c72cedc Merge pull request 'dev-Rene' (#5) from dev-Rene into main
Reviewed-on: https://gitea.centraal.wbd-rd.nl/RnD/EVOLV/pulls/5
2025-10-24 19:25:04 +00:00
136 changed files with 19204 additions and 8376 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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
View 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`: 13 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.

View File

@@ -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:

View File

@@ -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.

View File

@@ -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
View 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
View File

@@ -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
View 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": {}
}
}
}

View File

@@ -1,2 +0,0 @@
# Ignore test files
node_modules/

35
CLAUDE.md Normal file
View 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)

View File

@@ -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.

162
README.md
View File

@@ -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:
```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>"
```
---
## Installatie Enkel bouwblok
1. Clone de gewenste repository:
```bash
git clone https://gitea.centraal.wbd-rd.nl/<repo-naam>.git
```
2. Kopieer het bouwblok naar je Node-RED map:
Enkel bouwblok installeren in Node-RED:
```bash
mkdir -p ~/.node-red/nodes
cp -r <pad-naar-geclonede-map> ~/.node-red/nodes/
cp -r nodes/<bouwblok-naam> ~/.node-red/nodes/
```
3. Controleer of `settings.js` het volgende bevat:
```js
nodesDir: './nodes',
```
4. Herstart Node-RED:
## Testen
```bash
node-red-stop
node-red-start
# Alle nodes
bash scripts/test-all.sh
# 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
```
---
## Documentatie
## Bijdragen (Fork & Pull Request)
- **`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
Wil je bijdragen aan de R&D bouwblokken? Volg dan dit stappenplan:
## Licentie
1. Fork maken
**Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)**
- 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

View File

@@ -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

View File

@@ -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
# -------------------------------------------------------

View 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

View 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)"
}
]
}
]
}

View File

@@ -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
View 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
View 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
View 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
View 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.

View 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 (0250). 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.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

19
jest.config.js Normal file
View 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,
};

5883
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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.`);

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View 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);
});

View File

@@ -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.`);

View File

@@ -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)`);

View File

@@ -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...');

View File

@@ -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.`);

View File

@@ -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);

View File

@@ -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.`);

View File

@@ -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);
});

View File

@@ -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
View 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');
}
}

View File

@@ -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}`);

View File

@@ -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
View 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'

View File

@@ -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)`);

View File

@@ -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}`);

View File

@@ -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}`);

View File

@@ -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}`);

View File

@@ -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');

View File

@@ -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

View File

@@ -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
View 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.

View 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();
}
});

View 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}`
);
}
}

View 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
View 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
View 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,
};

View 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();
}
});

View 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();
}
});

View 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();
}
});

View File

@@ -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)

View File

89
wiki/SCHEMA.md Normal file
View 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

View 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

View File

@@ -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

View 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).

View 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

View File

@@ -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

View File

@@ -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

View 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