Compare commits

44 Commits

Author SHA1 Message Date
Rene De Ren
da50403c76 Update pumping station basin documentation 2026-05-05 10:38:24 +02:00
znetsixe
ab0d4ed285 Editor: pin outlet, add zone labels + volume to the diagram
Three user-facing fixes:

1. Outlet was getting pushed below the tank floor by the top-down
   nudge because its ideal y is already near the bottom. Now
   outflowLevel is PINNED at its proportional y (like basinHeight
   is pinned at the rim) and a second bottom-up pass pushes
   non-pinned items upward from the outlet anchor. Result: outlet
   stays near the tank floor, dryRunLevel sits right above it, the
   rest of the stack stays readable. Two anchors, two passes.

2. Zone labels mirrored from the wiki basin-model drawio:
   - "Spare volume before spilling"  (overflowLevel ↔ maxLevel)
   - "Sewage + tank buffer"          (maxLevel      ↔ startLevel)
   - "Tank buffer"                    (startLevel    ↔ minLevel)
   - "Tank buffer"                    (minLevel      ↔ dryRunLevel)
   - "Dead volume"                    (outflowLevel  ↔ floor)
   Each sits at the midpoint of its pair of nudged thresholds and
   hides when the gap between them is too small to read (< 14 px).

3. basinVolume moved into the SVG as a pinned input above the tank
   rim (alongside basinHeight), replacing the separate form row.
   One editor, one diagram — the total volume belongs with the
   geometry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:19:58 +02:00
znetsixe
2dd419dbf4 Editor: nudge dashed lines themselves, revert tank height
Reverts the tank-bigger approach from last commit. Instead of
scaling the tank and keeping strict proportionality, the dashed
threshold lines are now nudged apart directly so each gets a
guaranteed 36-px vertical gap. Inputs and labels align with the
lines (no more leader lines needed).

Trade-off: the diagram is now an ordered schematic, not a strictly
to-scale rendering. Values are still shown next to each line via
the input boxes, and the value ordering is preserved. For an editor
where the goal is entering parameters, readability wins over scale
fidelity.

Sizing reverted:
  viewBox    620 → 430
  tank h     520 → 340
  botY       560 → 380

Behavior:
  GAP        30 → 36 (more visible space between dashed lines)
  placeItem  takes a single y now (line + input + label + unit
             share it); leader-line mechanism kept as hidden
             plumbing in case we switch back to proportional later

Dead-volume band now anchors to the (possibly-nudged) outflow line
instead of the proportional y so it still visually meets the line
cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:10:23 +02:00
znetsixe
785d036dc6 Editor: taller tank — more vertical room between threshold lines
Tank height 340 → 520 px (viewBox 480 → 620). Lines that were
cramped in the bottom metre now have ~50 % more room, so:

- The Outlet arrow no longer visually crowds the minLevel line
- Dashed threshold lines (dryRunLevel, minLevel, outflowLevel)
  have visible breathing room between them for typical wastewater
  values where they sit in the bottom 1 m
- Input-stack GAP bumped 26 → 30 px to match the extra vertical
  real estate

Pure layout change — same proportional mapping, same nudging
algorithm, just more canvas. Floor/datum label and ordering-
warning ribbon positions shifted accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:41:03 +02:00
znetsixe
65fe68b87f Editor: nudge crowded threshold inputs off their lines with leader lines
When real wastewater values cluster near the basin floor (minLevel,
dryRunLevel, outflowLevel are often within a few cm of each other),
the threshold inputs were stacking on top of each other. Now:

- Threshold LINE stays at its proportional y on the tank (visual
  truth: that's where the level actually is).
- Input BOX / label / unit are positioned in a nudged right-column
  stack with a minimum 26-px gap so they never overlap.
- A dashed grey leader line connects each line to its input when
  they had to be pulled apart, so the association stays visible.
- Items are sorted by ideal y top-down and nudged downward once;
  basinHeight is pinned at the rim and acts as the anchor.

Also: viewBox extended 430 → 480 so the bottom-of-stack items have
room below the tank when the bottom cluster is tight. Warning ribbon
moved to y=460 accordingly.

No schema change; purely UI layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:41:16 +02:00
znetsixe
d641d2248d Editor: interactive basin diagram — inputs placed at each threshold line
Replaces the static parameters-diagram-above-form-rows layout with a
single interactive SVG where every threshold input sits directly on
the tank at its proportional y-position. Typing a value repositions
the corresponding line + input + label live.

What moved into the diagram (via <foreignObject> holding real
<input> elements with their existing node-input-* IDs so Node-RED
save/restore is untouched):

  basinHeight    — top of tank (fixed at rim by definition)
  overflowLevel  — weir crest (red, dashed)
  maxLevel       — 100 % demand line (orange, dashed)
  startLevel     — ramp-start line (green, dashed)
  minLevel       — MGC-shutdown line (purple, dashed)
  inflowLevel    — Inlet arrow + input on left
  outflowLevel   — Outlet arrow + input on right
  dryRunLevel    — read-only, computed from outflow × (1+dryRunPct/100)

Also in the diagram:
- Dead-volume band fills the area below outflowLevel dynamically
- Warning ribbon appears below the tank if ordering invariants break
  (mirrors specificClass._validateThresholdOrdering)
- All positions scale against the user's basinHeight; if empty, a
  default 5 m scale is used just to keep the diagram readable

What stayed as regular form rows:
- Basin Volume (m³) — not a height, can't be placed on a y-axis
- minLevel / startLevel / maxLevel were in the Control Strategy >
  Level-based section; removed from there and moved into the diagram
  (the level-based subsection now contains a one-line pointer)
- Safety % inputs (dryRun, overfill) stay in the Safety section with
  their derived-level readouts, now synced with the diagram

No schema changes, no field additions, no behaviour changes in the
runtime. Pure editor-UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:28:18 +02:00
znetsixe
12904b4902 Editor: inline parameters diagram at top of Basin Geometry
~3 KB inline SVG showing the 5 threshold lines + inlet/outlet pipe
arrows + floor datum, using the same names as the editor fields
(basinHeight, overflowLevel, maxLevel, startLevel, minLevel,
dryRunLevel). No new inputs — purely a visual reminder of what
each field refers to, so operators don't have to alt-tab to the
wiki to figure out which pipe edge to measure.

Wrapped in <details open> so users can collapse it once they
know the layout — no forced scroll through the diagram on every
edit session.

Matches the vocabulary in wiki/diagrams/basin-model.drawio.svg
(inlet = bottom of pipe, outlet = top of pipe, 0 m datum at
basin floor, dryRunLevel derived from %).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:19:23 +02:00
znetsixe
1ebbcb62cc Editor: pipe-edge conventions + live derived safety levels
### P1 — match diagram naming (labels only, no schema change)

- "Inlet Elevation"    → "Inlet (bottom of pipe, m)"
- "Outlet Elevation"   → "Outlet (top of pipe, m)"
- "Overflow Level"     → "Overflow (weir crest, m)"
- "Basin Bottom (m Refheight)" → "Basin floor above datum (m)"
- Added a one-line banner at the top of Basin Geometry:
  "All heights measured from the basin floor (0 m)."

These map directly to the clarifications added to basin-model.drawio.svg
so editor and diagram speak the same vocabulary.

### P3 — live derived safety levels next to the % fields

Low/High Volume Threshold fields now show the resulting trip level
live as the operator types:

  Low Volume Threshold (%)  [ 2  ]  → dryRunLevel ≈ 0.21 m
  High Volume Threshold (%) [ 98 ]  → overfillLevel ≈ 4.41 m

Recomputed on every input/change of outflowLevel, inflowLevel,
overflowLevel, minHeightBasedOn, or either %. Pure UI feedback —
no schema change, no save-side change, same formulas as
specificClass._validateThresholdOrdering().

Also includes the user's latest basin-model.drawio.svg update
(inlet=bottom/outlet=top labels + datum annotation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:58:17 +02:00
znetsixe
3e13512a83 Rename eval/ → simulations/ and fix log-write bug
Per discussion: "test" and "eval" overlap in meaning; "simulations"
is more honest about what's actually happening — scripted plant
inputs driving a physics sim, then recorded for analysis.

Rename scope:
- eval/ → simulations/ (tracked as git renames)
- Internal references in run.js and README.md updated
- wiki/modes/mpc.md link updated

Also fixes a log-write bug noticed during the rename:
- run.js didn't mkdir simulations/logs/ before createWriteStream,
  so the stream opened into a potentially non-existent dir and the
  file never materialised. Added fs.mkdirSync(..., recursive:true).
- end() wasn't awaited, so the process could exit before the stream
  flushed. Now awaits the 'finish' event. Confirmed: 1200 records
  actually land in simulations/logs/<scenario>.jsonl.
- Added simulations/logs/.gitignore so future JSONL artefacts stay
  out of the repo but the dir remains tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:46:10 +02:00
znetsixe
66fd3feff8 Add eval harness + Tier 2/3 mode template pages
### eval/ (scenario-based evaluation)

Complements the unit tests under test/basic. Scenarios fluctuate inputs
over simulated time, record every tick to JSONL, print a summary
table + event log, and check expectations. Complementary to unit
tests — these answer "how does the system respond to this input
profile" rather than "is this function correct".

- eval/run.js             — driver; monkey-patches Date.now so the
                            volume integrator ticks at 1 s/iter
                            regardless of wall-clock
- eval/scenarios/         — one file per scenario
  - levelbased-steady.js  — constant inflow, demand converges
  - levelbased-storm.js   — inflow surge, demand saturates
  - safety-dry-run-trip.js — manual mode, empty basin, safety trips
- eval/formatters/table.js — ASCII summary of sampled ticks
- eval/logs/              — per-scenario JSONL output (one line per tick)
- eval/README.md          — usage + scenario file shape + how to pipe
                            into InfluxDB/Grafana

All three starter scenarios PASS with their expectations.

### wiki/modes/ (tier template pages)

The levelbased page templated Tier-1 modes (static transfer function).
Added worked examples for the other two tiers so all mode pages share
a common skeleton and new modes have something concrete to imitate:

- flowbased.md   — Tier 2 (PID on measured outflow)
- powerbased.md  — Tier 2 (levelbased curve clipped by grid power budget)
- mpc.md         — Tier 3 (optimisation + forecast; block diagram +
                           scenario time-series instead of a fixed curve)

- modes/README.md — updated with the three-tier classification table
                    and diagram-type-per-tier guidance

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:49:41 +02:00
znetsixe
016433abe6 Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests
### Guardrails (specificClass.js)

New _validateThresholdOrdering() runs in the constructor. Checks every
ordered pair of basin + control + derived-safety levels and logs a
warning for each violation; returns the list as this.thresholdIssues
so tests and the eval harness can inspect. Non-fatal — we prefer a
running-but-warned station to a refusal-to-start (availability-first).

Strict invariants (bottom → top):
  0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
  dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel

Uses a list-of-checks pattern rather than a switch — easier to add new
invariants without reflowing cases, and the list itself is readable
documentation.

### Bug fix (specificClass.js)

calibratePredictedLevel was writing the volume value into the LEVEL
slot. Root cause: MeasurementContainer is stateful — its type()/
variant()/position() calls mutate the container's own cursor, so
caching chain references (const levelChain = ...; const volumeChain
= ...) doesn't isolate them. The second cached chain ended up sharing
the state of the last type() call. Rebuilt chains fresh each time,
matching the calibratePredictedVolume pattern that already worked.

### Tests (test/basic/specificClass.test.js)

Ported from Jest to node:test + node:assert — the project's standard
per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js
(tests referenced methods that no longer exist post-rename).

New coverage, 42 passing subtests:
- Basin geometry derivations + minHeightBasedOn
- Level/volume roundtrip
- Threshold guardrails (5 violation cases)
- Direction derivation
- Mode change accept/reject
- Calibration (volume and level paths — catches the bug above)
- Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate)
- getOutput flattening
- setManualInflow

Run with: node --test test/basic/*.test.js

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
znetsixe
a2189457f6 Rename basin/control thresholds to wiki naming; trim stale comments
Aligns the code with the 5-threshold convention used throughout the
wiki (basin model + per-mode transfer-function diagrams):

  heightInlet       → inflowLevel
  heightOutlet      → outflowLevel
  heightOverflow    → overflowLevel
  stopLevel         → minLevel
  maxFlowLevel      → maxLevel
  minFlowLevel      → removed (collapsed into startLevel; they were
                      always supposed to hold the same value)
  minVolIn          → minVolAtInflow
  minVolOut         → minVolAtOutflow
  maxVolOverflow    → maxVolAtOverflow
  startLevel        → unchanged

Config schema (generalFunctions/src/configs/pumpingStation.json) is
updated in a parallel commit in that submodule.

Also:
- Stripped the ~150-line ASCII basin diagram from initBasinProperties
  JSDoc; it now points at wiki/functional-description.md#basin-model.
- Trimmed the top-of-class JSDoc — the config-sections breakdown was
  drifting from the schema anyway; wiki is now the source of truth.
- Tidied inline comments in _controlLevelBased, _scaleLevelToFlowPercent.
- Editor order reshuffled to match the bottom→top basin order:
  minLevel, startLevel, maxLevel.

Breaking change for saved flows: existing pumpingStation nodes in
production flows reference the old field names and will need to be
re-entered in the editor. No compat shim — node is RnD/trial.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:13:59 +02:00
znetsixe
4637448c49 Add modes/ section with levelbased page as the template
Introduces the pattern: basin model is the shared canvas (mode-agnostic
physics); each control mode is its own page under wiki/modes/ plus a
demand-vs-level transfer-function diagram under wiki/diagrams/modes/.

- wiki/modes/README.md — index + per-mode page template (inputs,
  threshold policy, demand formula, edge cases, related)
- wiki/modes/levelbased.md — first worked example using the new naming
  convention (dryRunLevel / minLevel / startLevel / maxLevel /
  overflowLevel). Forward-looking — the code still uses the old names
  until the pending rename refactor.
- wiki/diagrams/modes/levelbased.drawio.svg — transfer-function plot
  (zones: STOP / DEAD ZONE / RAMP / SATURATE, safety trips outside the
  plot). Round-trippable via embedded drawio XML.
- functional-description.md — replaced the inline levelbased/manual
  subsection with a table pointing at the modes/ pages. Removed the
  old control-zones ASCII diagram reference (superseded by the
  per-mode transfer function).
- wiki/README.md — added Control modes entry + diagrams/modes/ pointer.

The remaining placeholder modes (flowbased, pressureBased,
percentageBased, powerBased, hybrid, manual) can each fill in the
template independently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:45:01 +02:00
znetsixe
61e0688f73 Make starter SVG diagrams round-trippable in draw.io
Each <name>.drawio.svg now has the corresponding <name>.drawio XML
embedded as content="..." on the root <svg> element. Opening the
SVG in draw.io (File → Open, or drag-drop) loads the full editable
model — no need to keep the .drawio file around for editing.

Updated diagrams/README.md to reflect that both file formats are
now round-trippable from the start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:00:16 +02:00
znetsixe
0ff55f5e9c Add wiki/ folder with functional description + draw.io diagrams
Moves documentation into the code repo so code, docs, and diagrams
version-lock and review together. Previous location was
pumpingStation.wiki.git; that will shrink to a pointer.

Contents:
- wiki/README.md — doc index
- wiki/functional-description.md — operator-facing reference derived
  from src/specificClass.js: basin model, net-flow selection,
  level-based control zones, safety interlocks, registration topology
- wiki/diagrams/ — editable draw.io sources paired with SVG exports
  (basin-model, control-zones, safety-rules) + README with the
  open/edit/export/commit workflow

The .drawio files are rough starters; iterate in draw.io and re-export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:19:26 +02:00
znetsixe
5e2ebe4d96 fix(safety): overfill must keep pumps running, not shut them down
Two hard rules for the safety controller, matching sewer PS design:

1. BELOW stopLevel (dry-run): pumps CANNOT start.
   All downstream equipment shut down. safetyControllerActive=true
   blocks _controlLogic so level control can't restart pumps.
   Only manual override or emergency can change this.

2. ABOVE overflow level (overfill): pumps CANNOT stop.
   Only UPSTREAM equipment is shut down (stop more water coming in).
   Machine groups (downstream pumps) are NOT shut down — they must
   keep draining. safetyControllerActive is NOT set, so _controlLogic
   continues commanding pumps at the demand dictated by the level
   curve (which is >100% near overflow = all pumps at maximum).
   Only manual override or emergency stop can shut pumps during
   an overfill event.

Previously the overfill branch called turnOffAllMachines() on machine
groups AND set safetyControllerActive=true, which shut down the pumps
and blocked level control from restarting them — exactly backwards
for a sewer pumping station where the sewage keeps coming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:10:23 +02:00
znetsixe
e8dd657b4f fix: continuous proportional control — eliminate dead zone between start/stop levels
Previously PS only sent demand to MGC when level > startLevel AND
direction === 'filling'. Between startLevel and stopLevel (the 'dead
zone'), pumps kept running at their last commanded setpoint with no
updates. Basin drained uncontrolled until hitting stopLevel.

Fix: send percControl on every tick when level > stopLevel. The
_scaleLevelToFlowPercent math naturally gives:
  - Positive % above startLevel (pumps ramp up)
  - 0% at exactly startLevel (pumps at minimum)
  - Negative % below startLevel → clamped to 0 → MGC scales to 0
    → pumps ramp down gracefully

This creates smooth visible ramp-up and ramp-down as the basin fills
and drains, instead of a sudden jump at startLevel and stuck ctrl in
the dead zone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:42:43 +02:00
znetsixe
c62d8bc275 fix: deduplicate predicted-flow child registration + single event subscription
Three bugs in registerChild caused multi-counted outflow in _updatePredictedVolume:

1. machinegroup registered twice (line 66 + line 70 both called
   _registerPredictedFlowChild). Fixed: only register in the
   machinegroup branch.

2. Individual machines registered alongside their machinegroup parent.
   Each pump's predicted flow is already included in MGC's aggregated
   total — subscribing to both triple-counts. Fixed: only register
   individual machines when no machinegroup is present (direct-wired
   pumps without MGC).

3. _registerPredictedFlowChild subscribed to BOTH flow.predicted.downstream
   AND flow.predicted.atequipment events. These carry the same total flow
   on two event names — the handler wrote the value twice per tick.
   Fixed: subscribe to ONE event per child (downstream for outflow,
   upstream for inflow).

These are generalizable patterns:
- When a group aggregator exists, subscribe to IT, not its children.
- One event per measurement type per child — pick the most specific.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:10:16 +02:00
znetsixe
f869296832 feat: level-based control now reaches machine groups + manual Qd forwarding
Two additions to pumpingStation:

1. _controlLevelBased now calls _applyMachineGroupLevelControl in
   addition to _applyMachineLevelControl when the basin is filling
   above startLevel. Previously only direct-child machines received
   the level-based percent-control signal; in a hierarchical topology
   (PS → MGC → pumps) the machines sit under MGC and PS.machines is
   empty, so the level control never reached them.

2. New 'Qd' input topic + forwardDemandToChildren() method. When PS
   is in 'manual' mode (matching the pattern from rotatingMachine's
   virtualControl), operator demand from a dashboard slider is forwarded
   to all child machine groups and direct machines. When PS is in any
   other mode (levelbased, flowbased, etc.), the Qd msg is silently
   dropped with a debug log so the automatic control isn't overridden.

No breaking changes — existing flows that don't send 'Qd' are unaffected,
and _controlLevelBased's additional call to machineGroupLevelControl
is a no-op when no machine groups are registered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:27:11 +02:00
znetsixe
9f430cebb5 docs: add CLAUDE.md with S88 classification and superproject rule reference
References the flow-layout rule set in the EVOLV superproject
(.claude/rules/node-red-flow-layout.md) so Claude Code sessions working
in this repo know the S88 level, colour, and placement lane for this node.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:47:23 +02:00
znetsixe
7d05d37678 Merge commit '762770a' into HEAD
# Conflicts:
#	pumpingStation.html
#	src/nodeClass.js
#	src/specificClass.js
2026-03-31 18:20:09 +02:00
Rene De Ren
762770a063 Expose output format selectors in editor 2026-03-12 16:39:25 +01:00
Rene De Ren
3ff76228eb fix: guard demo IIFE with require.main check
Prevents demo code from executing when module is required by Node-RED,
which caused crashes due to missing measurement data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:38:08 +01:00
Rene De Ren
f01b0bcb19 fix: rename _calcTimeRemaining to _calcRemainingTime + add tests
Fix method name mismatch in tick() that called non-existent _calcTimeRemaining
instead of _calcRemainingTime. Add 27 unit tests for specificClass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:31:47 +01:00
znetsixe
7efd3b0a07 bug fixes 2025-11-30 20:13:21 +01:00
znetsixe
c81ee1b470 fixed change mode and control logic method 2025-11-30 17:46:07 +01:00
znetsixe
955c17a466 bug fixes 2025-11-30 09:24:18 +01:00
Rene De ren
052ded7b6e fixes 2025-11-28 16:29:05 +01:00
znetsixe
321ea33bf7 rebuilding pumping station NOT WORKING 2025-11-28 09:59:16 +01:00
znetsixe
288bd244dd updating to corrospend with reality 2025-11-27 17:46:24 +01:00
znetsixe
d91609b3a4 updates to safety features 2025-11-25 14:57:39 +01:00
znetsixe
5a575a29fe updated pumpingstation 2025-11-20 12:15:46 +01:00
znetsixe
0a6c7ee2e1 Further bug fixes and optimized level control for groups and machines alike 2025-11-13 19:37:41 +01:00
znetsixe
4cc529b1c2 Fixes next idle machine for level control 2025-11-12 17:37:09 +01:00
znetsixe
fbfcec4b47 Added simpel case for level control 2025-11-10 16:20:23 +01:00
znetsixe
43eb97407f added safeguarding when vol gets too low for machines, 2025-11-07 15:07:56 +01:00
znetsixe
9e4b149b64 fixed multiple children being able to pull and push to pumpingstation 2025-11-06 16:46:54 +01:00
znetsixe
1848486f1c bug fixes output formatting 2025-11-06 11:19:20 +01:00
znetsixe
d44cbc978b updates visual 2025-11-03 09:17:22 +01:00
znetsixe
f243761f00 Updated node status 2025-11-03 07:42:51 +01:00
znetsixe
2a31c7ec69 working pumpingstation with machines 2025-10-28 17:04:26 +01:00
znetsixe
69f68adffe testing codex 2025-10-27 19:55:48 +01:00
znetsixe
5a1eff37d7 Need to remove wobble on level only 2025-10-27 17:45:48 +01:00
znetsixe
e8f9207a92 some major design choises updated 2025-10-27 16:39:06 +01:00
25 changed files with 3604 additions and 733 deletions

23
CLAUDE.md Normal file
View File

@@ -0,0 +1,23 @@
# pumpingStation — Claude Code context
Wet-well basin model and pump orchestration.
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
## S88 classification
| Level | Colour | Placement lane |
|---|---|---|
| **Process Cell** | `#0c99d9` | L5 |
## Flow layout rules
When wiring this node into a multi-node demo or production flow, follow the
placement rule set in the **EVOLV superproject**:
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
Key points for this node:
- Place on lane **L5** (x-position per the lane table in the rule).
- Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `#0c99d9` (Process Cell).

View File

@@ -1 +1,9 @@
# rotating machine
# pumpingStation
Wet-well basin model and pump orchestration node for EVOLV.
The detailed documentation lives in [`wiki/`](wiki/):
- [`wiki/functional-description.md`](wiki/functional-description.md) defines the shared basin model, pipe reference semantics, safety points, net-flow selection, and child registration behaviour.
- [`wiki/modes/`](wiki/modes/) documents control-mode-specific behaviour such as the level-linear `startLevel` demand ramp.
- [`wiki/diagrams/basin-model.drawio.svg`](wiki/diagrams/basin-model.drawio.svg) is the current source of truth for the generic basin model.

View File

@@ -16,14 +16,31 @@
category: "EVOLV",
color: "#0c99d9", // color for the node based on the S88 schema
defaults: {
name: { value: "" },
// Define station-specific properties
simulator: { value: false },
basinVolume: { value: 1 }, // m³, total empty basin
basinHeight: { value: 1 }, // m, floor to top
heightInlet: { value: 0.8 }, // m, centre of inlet pipe above floor
heightOutlet: { value: 0.2 }, // m, centre of outlet pipe above floor
heightOverflow: { value: 0.9 }, // m, overflow elevation
inflowLevel: { value: 0.8 }, // m, centre of inlet pipe above floor
outflowLevel: { value: 0.2 }, // m, centre of outlet pipe above floor
overflowLevel: { value: 0.9 }, // m, overflow elevation
defaultFluid: { value: "wastewater" },
inletPipeDiameter: { value: 0.3 }, // m
outletPipeDiameter: { value: 0.3 }, // m
pipelineLength: { value: 80 }, // m
maxDischargeHead: { value: 24 }, // m
staticHead: { value: 12 }, // m
maxInflowRate: { value: 200 }, // m³/h
temperatureReferenceDegC: { value: 15 },
timeleftToFullOrEmptyThresholdSeconds:{value:0}, // time threshold to safeguard starting or stopping pumps in seconds
enableDryRunProtection: { value: true },
enableOverfillProtection: { value: true },
dryRunThresholdPercent: { value: 2 },
overfillThresholdPercent: { value: 98 },
minHeightBasedOn: { value: "outlet" }, // basis for minimum height check: inlet or outlet
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
// Advanced reference information
refHeight: { value: "NAP" }, // reference height
@@ -47,7 +64,15 @@
hasDistance: { value: false },
distance: { value: 0 },
distanceUnit: { value: "m" },
distanceDescription: { value: "" }
distanceDescription: { value: "" },
// control strategy
controlMode: { value: "none" },
startLevel: { value: null },
minLevel: { value: null },
maxLevel: { value: null },
flowSetpoint: { value: null },
flowDeadband: { value: null }
},
@@ -75,9 +100,9 @@
// NODE SPECIFIC
document.getElementById("node-input-basinVolume");
document.getElementById("node-input-basinHeight");
document.getElementById("node-input-heightInlet");
document.getElementById("node-input-heightOutlet");
document.getElementById("node-input-heightOverflow");
document.getElementById("node-input-inflowLevel");
document.getElementById("node-input-outflowLevel");
document.getElementById("node-input-overflowLevel");
document.getElementById("node-input-refHeight");
document.getElementById("node-input-basinBottomRef");
@@ -86,6 +111,237 @@
refHeightEl.value = this.refHeight || "NAP";
}
const minHeightBasedOnEl = document.getElementById("node-input-minHeightBasedOn");
if (minHeightBasedOnEl) {
minHeightBasedOnEl.value = this.minHeightBasedOn;
}
const dryRunToggle = document.getElementById("node-input-enableDryRunProtection");
const dryRunPercent = document.getElementById("node-input-dryRunThresholdPercent");
const overfillToggle = document.getElementById("node-input-enableOverfillProtection");
const overfillPercent = document.getElementById("node-input-overfillThresholdPercent");
const toggleInput = (toggleEl, inputEl) => {
if (!toggleEl || !inputEl) { return; }
inputEl.disabled = !toggleEl.checked;
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
};
if (dryRunToggle && dryRunPercent) {
dryRunToggle.checked = !!this.enableDryRunProtection;
dryRunPercent.value = Number.isFinite(this.dryRunThresholdPercent) ? this.dryRunThresholdPercent : 2;
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
toggleInput(dryRunToggle, dryRunPercent);
}
if (overfillToggle && overfillPercent) {
overfillToggle.checked = !!this.enableOverfillProtection;
overfillPercent.value = Number.isFinite(this.overfillThresholdPercent) ? this.overfillThresholdPercent : 98;
overfillToggle.addEventListener('change', () => toggleInput(overfillToggle, overfillPercent));
toggleInput(overfillToggle, overfillPercent);
}
const timeLeftInput = document.getElementById("node-input-timeleftToFullOrEmptyThresholdSeconds");
if (timeLeftInput) {
timeLeftInput.value = Number.isFinite(this.timeleftToFullOrEmptyThresholdSeconds)
? this.timeleftToFullOrEmptyThresholdSeconds
: 0;
}
// control mode toggle UI
const toggleModeSections = (val) => {
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
const active = document.getElementById(`ps-mode-${val}`);
if (active) active.style.display = '';
};
const modeSelect = document.getElementById('node-input-controlMode');
if (modeSelect) {
modeSelect.value = this.controlMode || 'none';
toggleModeSections(modeSelect.value);
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
}
const setNumberField = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = Number.isFinite(val) ? val : '';
};
setNumberField('node-input-startLevel', this.startLevel);
setNumberField('node-input-minLevel', this.minLevel);
setNumberField('node-input-maxLevel', this.maxLevel);
setNumberField('node-input-flowSetpoint', this.flowSetpoint);
setNumberField('node-input-flowDeadband', this.flowDeadband);
// Interactive diagram: place every threshold line/input at its
// proportional y on the tank, plus compute derived safety levels
// (dryRunLevel, overfillLevel) that are shown both in the diagram
// and next to the safety-% fields. Same formulas as
// specificClass._validateThresholdOrdering.
const DIAG = { topY: 40, botY: 380 };
const fNum = (id) => {
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
return Number.isFinite(v) ? v : null;
};
const yForLevel = (val, basinH) => {
if (val == null || !basinH) return null;
const y = DIAG.botY - (val / basinH) * (DIAG.botY - DIAG.topY);
return Math.max(DIAG.topY - 8, Math.min(DIAG.botY + 8, y));
};
// Place a row — line, label, input, unit all share the same y.
// The diagram is a schematic ordered list (value order is
// preserved, but the y-positions are distributed with a
// guaranteed minimum gap for readability), not a strictly
// proportional rendering.
const placeItem = (id, y) => {
const line = document.getElementById(`ps-line-${id}`);
const label = document.getElementById(`ps-label-${id}`);
const unit = document.getElementById(`ps-unit-${id}`);
const fo = document.getElementById(`ps-fo-${id}`);
const sub = document.getElementById(`ps-sub-${id}`);
const lead = document.getElementById(`ps-leader-${id}`);
if (line) { line.setAttribute('y1', y); line.setAttribute('y2', y); }
if (label) label.setAttribute('y', y + 4);
if (unit) unit.setAttribute('y', y + 4);
if (fo) fo.setAttribute('y', y - 11);
if (sub) sub.setAttribute('y', y + 15);
if (lead) lead.setAttribute('visibility', 'hidden');
};
const redraw = () => {
const basinH = fNum('basinHeight') || 5;
// Derived safety levels (participate in the right-column stack)
const basedOn = document.getElementById('node-input-minHeightBasedOn')?.value || 'outlet';
const refLow = basedOn === 'inlet' ? fNum('inflowLevel') : fNum('outflowLevel');
const dryPct = fNum('dryRunThresholdPercent');
const ovfPct = fNum('overfillThresholdPercent');
const ovf = fNum('overflowLevel');
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
const ovfLvl = (ovf != null && ovfPct != null) ? ovf * (ovfPct / 100) : null;
// Right-column stack. TWO anchors: basinHeight pinned at the
// tank rim (top) and outflowLevel pinned at its proportional y
// (bottom). Everything between is nudged to maintain a minimum
// vertical gap via two passes — top-down from the rim, then
// bottom-up from the outlet — so the dashed lines keep their
// value-order and outlet stays near the floor where it belongs.
const items = [
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
{ id: 'maxLevel', yIdeal: yForLevel(fNum('maxLevel'), basinH) },
{ id: 'startLevel', yIdeal: yForLevel(fNum('startLevel'), basinH) },
{ id: 'minLevel', yIdeal: yForLevel(fNum('minLevel'), basinH) },
{ id: 'dryRunLevel', yIdeal: yForLevel(dryLvl, basinH) },
{ id: 'outflowLevel', yIdeal: yForLevel(fNum('outflowLevel'), basinH), pinned: true },
].filter(it => it.yIdeal != null);
const GAP = 36;
items.sort((a, b) => a.yIdeal - b.yIdeal);
for (const it of items) it.y = it.yIdeal;
// Pass 1: top-down — push DOWN to maintain GAP; pinned items don't move
for (let i = 1; i < items.length; i++) {
if (items[i].pinned) continue;
items[i].y = Math.max(items[i].y, items[i - 1].y + GAP);
}
// Pass 2: bottom-up — push UP so outflow's pin propagates up the stack
for (let i = items.length - 2; i >= 0; i--) {
if (items[i].pinned) continue;
items[i].y = Math.min(items[i].y, items[i + 1].y - GAP);
}
for (const it of items) placeItem(it.id, it.y);
// Zone labels between adjacent thresholds (italic, centered).
// Hidden if either bracketing threshold is missing, or the gap
// is too small to read (< 14 px).
const placeZone = (zoneId, topId, botId) => {
const el = document.getElementById(`ps-zone-${zoneId}`);
if (!el) return;
const top = items.find(it => it.id === topId);
const bot = items.find(it => it.id === botId);
if (!top || !bot || (bot.y - top.y) < 14) {
el.setAttribute('visibility', 'hidden'); return;
}
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
el.setAttribute('visibility', 'visible');
};
placeZone('spare', 'overflowLevel', 'maxLevel');
placeZone('sewage', 'maxLevel', 'startLevel');
placeZone('buffer1', 'startLevel', 'minLevel');
placeZone('buffer2', 'minLevel', 'dryRunLevel');
// "Dead volume" sits inside the blue band between outflowLevel and the floor
const outflowPinned = items.find(it => it.id === 'outflowLevel');
const deadLbl = document.getElementById('ps-zone-dead');
if (deadLbl && outflowPinned && (DIAG.botY - outflowPinned.y) > 14) {
deadLbl.setAttribute('y', (outflowPinned.y + DIAG.botY) / 2 + 3);
deadLbl.setAttribute('visibility', 'visible');
} else if (deadLbl) {
deadLbl.setAttribute('visibility', 'hidden');
}
// Inlet arrow — sole item on the left, no stacking concerns
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
if (inflowY != null) {
const line = document.getElementById('ps-line-inflowLevel');
const lbl = document.getElementById('ps-label-inflowLevel');
const sub = document.getElementById('ps-sub-inflowLevel');
const fo = document.getElementById('ps-fo-inflowLevel');
const unit = document.getElementById('ps-unit-inflowLevel');
if (line) { line.setAttribute('y1', inflowY); line.setAttribute('y2', inflowY); }
if (lbl) lbl.setAttribute('y', inflowY - 4);
if (sub) sub.setAttribute('y', inflowY + 8);
if (fo) fo.setAttribute('y', inflowY - 11);
if (unit) unit.setAttribute('y', inflowY + 4);
}
// Dead-volume band: from the (possibly-nudged) outflow line
// down to the floor. Use the nudged y so the band meets the
// outflow line exactly.
const outflowItem = items.find(it => it.id === 'outflowLevel');
const deadvol = document.getElementById('ps-deadvol');
if (deadvol && outflowItem) {
deadvol.setAttribute('y', outflowItem.y);
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowItem.y));
}
// dryRunLevel label text (derived, read-only)
const dryLbl = document.getElementById('ps-label-dryRunLevel');
if (dryLbl) dryLbl.textContent = dryLvl != null
? `dryRunLevel ≈ ${dryLvl.toFixed(2)} m (safety — from %)`
: 'dryRunLevel ≈ — m (safety — from %)';
// Safety-section readouts (second view, beneath the diagram)
const d1 = document.getElementById('derived-dryRunLevel');
if (d1) d1.textContent = dryLvl != null ? `→ dryRunLevel ≈ ${dryLvl.toFixed(2)} m` : '→ dryRunLevel ≈ — m';
const d2 = document.getElementById('derived-overfillLevel');
if (d2) d2.textContent = ovfLvl != null ? `→ overfillLevel ≈ ${ovfLvl.toFixed(2)} m` : '→ overfillLevel ≈ — m';
// Ordering warning ribbon
const warn = document.getElementById('ps-warning');
const issues = [];
const pairs = [
['outflowLevel', 'inflowLevel', '<'],
['inflowLevel', 'overflowLevel', '<'],
['minLevel', 'startLevel', '<='],
['startLevel', 'maxLevel', '<'],
['maxLevel', 'overflowLevel', '<='],
];
for (const [a, b, op] of pairs) {
const av = fNum(a), bv = fNum(b);
if (av == null || bv == null) continue;
if (op === '<' ? !(av < bv) : !(av <= bv)) issues.push(`${a} ${op} ${b}`);
}
if (warn) {
if (issues.length) { warn.setAttribute('visibility', 'visible'); warn.textContent = `⚠ Check ordering: ${issues.join(', ')}`; }
else { warn.setAttribute('visibility', 'hidden'); }
}
};
['basinHeight','overflowLevel','maxLevel','startLevel','minLevel','inflowLevel','outflowLevel',
'dryRunThresholdPercent','overfillThresholdPercent','minHeightBasedOn'].forEach((id) => {
const el = document.getElementById(`node-input-${id}`);
if (el) { el.addEventListener('input', redraw); el.addEventListener('change', redraw); }
});
setTimeout(redraw, 60);
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
},
@@ -98,14 +354,28 @@
//node specific
node.refHeight = document.getElementById("node-input-refHeight").value || "NAP";
node.minHeightBasedOn = document.getElementById("node-input-minHeightBasedOn").value || "outlet";
node.simulator = document.getElementById("node-input-simulator").checked;
["basinVolume","basinHeight","heightInlet","heightOutlet","heightOverflow","basinBottomRef"]
["basinVolume","basinHeight","inflowLevel","outflowLevel","overflowLevel","basinBottomRef","timeleftToFullOrEmptyThresholdSeconds","dryRunThresholdPercent","overfillThresholdPercent"]
.forEach(field => {
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
});
node.refHeight = document.getElementById("node-input-refHeight").value || "";
node.enableDryRunProtection = document.getElementById("node-input-enableDryRunProtection").checked;
node.enableOverfillProtection = document.getElementById("node-input-enableOverfillProtection").checked;
// control strategy
node.controlMode = document.getElementById('node-input-controlMode').value || 'none';
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
node.startLevel = parseNum('node-input-startLevel');
node.minLevel = parseNum('node-input-minLevel');
node.maxLevel = parseNum('node-input-maxLevel');
node.flowSetpoint = parseNum('node-input-flowSetpoint');
node.flowDeadband = parseNum('node-input-flowDeadband');
},
});
@@ -115,7 +385,7 @@
<script type="text/html" data-template-name="pumpingStation">
<!-- Simulator toggle -->
<h4>Simulation</h4>
<div class="form-row">
<label for="node-input-simulator"><i class="fa fa-play-circle"></i> Simulator</label>
<input type="checkbox" id="node-input-simulator" style="width:20px;vertical-align:baseline;" />
@@ -123,34 +393,165 @@
</div>
<hr>
<h4>Basin parameters</h4>
<p style="font-size:12px;color:#777;margin:0 0 8px 0;">Heights are measured from the basin floor (0 m). Enter values next to each line the diagram scales to whatever you enter.</p>
<!-- Basin geometry -->
<style>
#ps-basin-diagram input[type=number] {
width: 100%; height: 20px; box-sizing: border-box;
font-size: 11px; padding: 1px 4px; margin: 0;
border: 1px solid #ccc; border-radius: 3px; background: #fff;
}
#ps-basin-diagram input[type=number]:focus { outline: 1px solid #0c99d9; border-color: #0c99d9; }
</style>
<svg id="ps-basin-diagram" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 430"
style="display:block;width:100%;max-width:540px;margin:0 0 12px 0;background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
font-family="Arial,sans-serif" font-size="11">
<defs>
<marker id="ps-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1F4E79" />
</marker>
</defs>
<!-- Tank body -->
<rect x="200" y="40" width="120" height="340" fill="#F0F8FF" stroke="#333" stroke-width="1.5" />
<!-- Dead-volume band (y + height updated dynamically below outflowLevel) -->
<rect id="ps-deadvol" x="201" width="118" fill="#AACCE0" />
<!-- basinVolume pinned above the rim -->
<text id="ps-label-basinVolume" x="330" y="19" fill="#333" font-weight="600">basin volume</text>
<foreignObject id="ps-fo-basinVolume" x="425" y="4" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-basinVolume" min="0" step="0.1" />
</foreignObject>
<text id="ps-unit-basinVolume" x="500" y="19" fill="#555"></text>
<!-- Zone labels (mid-tank italic, positioned dynamically at midpoint between adjacent thresholds) -->
<text id="ps-zone-spare" x="260" text-anchor="middle" fill="#B78200" font-size="10" font-style="italic" visibility="hidden">Spare volume before spilling</text>
<text id="ps-zone-sewage" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Sewage + tank buffer</text>
<text id="ps-zone-buffer1" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
<text id="ps-zone-buffer2" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
<text id="ps-zone-dead" x="260" text-anchor="middle" fill="#444" font-size="10" font-style="italic" visibility="hidden">Dead volume</text>
<!-- basinHeight always at tank rim (y=40 in viewBox coords) -->
<line id="ps-line-basinHeight" x1="195" y1="40" x2="325" y2="40" stroke="#333" stroke-width="1.5" />
<text id="ps-label-basinHeight" x="330" y="44" fill="#333">basinHeight</text>
<foreignObject id="ps-fo-basinHeight" x="425" y="29" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-basinHeight" min="0" step="0.1" />
</foreignObject>
<text id="ps-unit-basinHeight" x="500" y="44" fill="#555">m</text>
<!-- overflowLevel -->
<line id="ps-line-overflowLevel" x1="195" x2="325" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-overflowLevel" x="330" fill="#C0392B">overflowLevel</text>
<foreignObject id="ps-fo-overflowLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-overflowLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-overflowLevel" x="500" fill="#555">m</text>
<!-- maxLevel -->
<line id="ps-line-maxLevel" x1="195" x2="325" stroke="#D68910" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-maxLevel" x="330" fill="#D68910">maxLevel</text>
<foreignObject id="ps-fo-maxLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-maxLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-maxLevel" x="500" fill="#555">m</text>
<!-- startLevel -->
<line id="ps-line-startLevel" x1="195" x2="325" stroke="#1E8449" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-startLevel" x="330" fill="#1E8449">startLevel</text>
<foreignObject id="ps-fo-startLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-startLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-startLevel" x="500" fill="#555">m</text>
<!-- Inlet arrow + input on the left -->
<line id="ps-line-inflowLevel" x1="140" x2="200" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text id="ps-label-inflowLevel" x="135" text-anchor="end" fill="#1F4E79" font-weight="bold">Inlet</text>
<text id="ps-sub-inflowLevel" x="135" text-anchor="end" fill="#777" font-size="9">bottom of pipe</text>
<foreignObject id="ps-fo-inflowLevel" x="5" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-inflowLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-inflowLevel" x="80" fill="#555">m</text>
<!-- minLevel -->
<line id="ps-line-minLevel" x1="195" x2="325" stroke="#6C3483" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-minLevel" x="330" fill="#6C3483">minLevel</text>
<foreignObject id="ps-fo-minLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-minLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-minLevel" x="500" fill="#555">m</text>
<!-- dryRunLevel (derived, read-only) -->
<line id="ps-line-dryRunLevel" x1="195" x2="325" stroke="#C0392B" stroke-dasharray="1 2" stroke-width="1" opacity="0.6" />
<text id="ps-label-dryRunLevel" x="330" fill="#C0392B" font-size="10" font-style="italic">dryRunLevel m (safety from %)</text>
<!-- Outlet arrow on right, input below the threshold column -->
<line id="ps-line-outflowLevel" x1="320" x2="360" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text id="ps-label-outflowLevel" x="365" fill="#1F4E79" font-weight="bold">Outlet</text>
<text id="ps-sub-outflowLevel" x="365" fill="#777" font-size="9">top of pipe</text>
<foreignObject id="ps-fo-outflowLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-outflowLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-outflowLevel" x="500" fill="#555">m</text>
<!-- Floor / datum -->
<line x1="195" y1="380" x2="325" y2="380" stroke="#000" stroke-width="2" />
<text x="330" y="384" fill="#000">0 m (datum)</text>
<!-- Leader lines: shown when the input row had to be nudged off its threshold's ideal y -->
<line id="ps-leader-basinHeight" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-overflowLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-maxLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-startLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-minLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-dryRunLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-outflowLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<!-- Ordering-warning ribbon -->
<text id="ps-warning" x="260" y="410" text-anchor="middle" fill="#C0392B" font-size="10" font-style="italic" visibility="hidden"></text>
</svg>
<hr>
<h4>Control Strategy</h4>
<div class="form-row">
<label for="node-input-basinVolume"><i class="fa fa-cube"></i> Basin Volume (m³)</label>
<input type="number" id="node-input-basinVolume" min="0" step="0.1" />
</div>
<div class="form-row">
<label for="node-input-basinHeight"><i class="fa fa-arrows-v"></i> Basin Height (m)</label>
<input type="number" id="node-input-basinHeight" min="0" step="0.1" />
<label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label>
<select id="node-input-controlMode">
<option value="none">None / Manual</option>
<option value="levelbased">Level-based</option>
<option value="flowbased">Flow-based</option>
</select>
</div>
<!-- Inlet/Outlet elevations -->
<div class="form-row">
<label for="node-input-heightInlet"><i class="fa fa-long-arrow-up"></i> Inlet Elevation (m)</label>
<input type="number" id="node-input-heightInlet" min="0" step="0.01" />
<div id="ps-mode-levelbased" class="ps-mode-section">
<p style="font-size:12px;color:#777;margin:0;">Level-based uses <code>minLevel</code> / <code>startLevel</code> / <code>maxLevel</code> from the diagram above.</p>
</div>
<div class="form-row">
<label for="node-input-heightOutlet"><i class="fa fa-long-arrow-down"></i> Outlet Elevation (m)</label>
<input type="number" id="node-input-heightOutlet" min="0" step="0.01" />
</div>
<div class="form-row">
<label for="node-input-heightOverflow"><i class="fa fa-tint"></i> Overflow Level (m)</label>
<input type="number" id="node-input-heightOverflow" min="0" step="0.01" />
<div id="ps-mode-flowbased" class="ps-mode-section" style="display:none">
<div class="form-row">
<label for="node-input-flowSetpoint">Flow setpoint</label>
<input type="number" id="node-input-flowSetpoint" placeholder="m3/h" />
</div>
<div class="form-row">
<label for="node-input-flowDeadband">Deadband</label>
<input type="number" id="node-input-flowDeadband" placeholder="m3/h" />
</div>
</div>
<hr>
<h4>Reference</h4>
<!-- Reference data -->
<div class="form-row">
<label for="node-input-minHeightBasedOn"><i class="fa fa-arrows-v"></i> Minimum Height Based On</label>
<select id="node-input-minHeightBasedOn" style="width:60%;">
<option value="inlet">Inlet Elevation</option>
<option value="outlet">Outlet Elevation</option>
</select>
</div>
<div class="form-row">
<label for="node-input-refHeight"><i class="fa fa-map-marker"></i> Reference height</label>
<select id="node-input-refHeight" style="width:60%;">
@@ -159,10 +560,65 @@
</div>
<div class="form-row">
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin Bottom (m Refheight)</label>
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin floor above datum (m)</label>
<input type="number" id="node-input-basinBottomRef" step="0.01" />
</div>
<hr>
<h4>Safety</h4>
<!-- Safety settings -->
<div class="form-row">
<label for="node-input-timeleftToFullOrEmptyThresholdSeconds"><i class="fa fa-clock-o"></i> Time To Empty/Full (s)</label>
<input type="number" id="node-input-timeleftToFullOrEmptyThresholdSeconds" min="0" step="1" />
</div>
<div class="form-row">
<label for="node-input-enableDryRunProtection">
<i class="fa fa-shield"></i> Dry-run Protection
</label>
<input type="checkbox" id="node-input-enableDryRunProtection" style="width:20px;vertical-align:baseline;" />
<span>Prevent pumps from running on low volume</span>
</div>
<div class="form-row">
<label for="node-input-dryRunThresholdPercent" style="padding-left:20px;">Low Volume Threshold (%)</label>
<input type="number" id="node-input-dryRunThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
<span id="derived-dryRunLevel" style="margin-left:8px;color:#777;font-size:12px;"> dryRunLevel m</span>
</div>
<div class="form-row">
<label for="node-input-enableOverfillProtection">
<i class="fa fa-exclamation-triangle"></i> Overfill Protection
</label>
<input type="checkbox" id="node-input-enableOverfillProtection" style="width:20px;vertical-align:baseline;" />
<span>Stop filling when approaching overflow</span>
</div>
<div class="form-row">
<label for="node-input-overfillThresholdPercent" style="padding-left:20px;">High Volume Threshold (%)</label>
<input type="number" id="node-input-overfillThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
<span id="derived-overfillLevel" style="margin-left:8px;color:#777;font-size:12px;"> overfillLevel m</span>
</div>
<hr>
<h3>Output Formats</h3>
<div class="form-row">
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
<select id="node-input-processOutputFormat" style="width:60%;">
<option value="process">process</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
</div>
<div class="form-row">
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;">
<option value="influxdb">influxdb</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
</div>
<!-- Shared asset/logger/position menus -->
<div id="asset-fields-placeholder"></div>
<div id="logger-fields-placeholder"></div>

123
simulations/README.md Normal file
View File

@@ -0,0 +1,123 @@
# Evaluation harness
Scenario-based evaluation for pumpingStation. Each scenario scripts a stream of inputs against a configured station, ticks the simulator at 1 s resolution, records every state, and prints a summary + event log + expectation check. Separate from unit tests (`test/`) — those verify individual pieces of logic in isolation; scenarios check end-to-end behaviour over time with realistic input trajectories.
## Run
```bash
# One scenario
node simulations/run.js levelbased-steady
# All scenarios at once
node simulations/run.js --all
```
Per-tick records are written to `simulations/logs/<scenario>.jsonl` for post-hoc analysis (e.g. streaming into InfluxDB for Grafana, or pandas / jq for one-off exploration).
## Scenario file shape
```js
// simulations/scenarios/<name>.js
module.exports = {
name: 'scenario-identifier',
description: 'one sentence — what the scenario is testing',
durationSec: 1200,
config: { /* PumpingStation config, same shape as nodeClass builds */ },
setup: async (ps) => {
// Optional. Wire fake MGCs, calibrate initial level, etc.
},
inputs: (t, ps) => {
// Called every tick (t in seconds). Drive inflow, mode changes,
// operator actions, etc.
ps.setManualInflow(0.005, Date.now(), 'm3/s');
},
expectations: [
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
],
};
```
## Supported expectation types
| Type | Semantics |
|---|---|
| `max_level_bounded` | max level across the run must be `≤ value` |
| `min_level_bounded` | min level across the run must be `≥ value` |
| `max_demand_bounded` | max percControl must be `≤ value` |
| `safety_trips_eq` | total ticks with `safetyActive` must equal `value` |
| `safety_trips_gt` | total ticks with `safetyActive` must be `> value` |
| `end_state_eq` | final record's `field` must equal `value` |
| `threshold_issues_eq` | startup guardrail issue count must equal `value` |
Add new expectation types in `run.js` (`evalExpectation`).
## Output
Example run:
```
═══ Scenario: levelbased-steady ═══
Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.
Duration: 1200s, 1s ticks
─── Samples (every 10%) ───
t(s) level(m) vol(m3) dir netFlow(m3/s) src demand safe
────────────────────────────────────────────────────────────────────────────────────────
0 2.00 20.00 steady 0 — 0% ·
120 2.64 26.40 draining -0.0026 predicted 62% ·
240 2.30 23.00 draining -0.0004 predicted 68% ·
...
─── Events (3) ───
t= 15s direction steady → filling
t= 134s direction filling → draining
─── Metrics ───
level min=2.00 max=2.73 end=2.33 m
percControl min=0% max=73% end=66%
safety trips=0 ticks
threshold issues=0 at startup
─── Expectations ───
✓ no safety trips: 0 ticks with safetyActive (expected 0)
✓ level stays below overflow: max level = 2.73 m (bound: ≤ 4.5)
✓ level stays above outflow: min level = 2.00 m (bound: ≥ 0.2)
✓ no threshold issues on init: 0 threshold issues at startup (expected 0)
Log: simulations/logs/levelbased-steady.jsonl (1200 records)
✅ PASS
```
## Why separate from `test/`?
| | `test/` | `simulations/` |
|---|---|---|
| runner | `node --test` | `node simulations/run.js` |
| scope | one function / small behaviour | end-to-end scenario over time |
| duration | milliseconds | seconds to minutes (simulated) |
| assertion style | tight, exact (`assert.equal`) | tolerance / bounds / event counts |
| output | TAP | summary table + JSONL for analysis |
| purpose | catch regressions | analyse how the system responds to input |
Unit tests live under `test/basic/`, `test/integration/`, `test/edge/`. Scenarios live here under `simulations/scenarios/`.
## Sending logs to Grafana (optional)
The JSONL output has one record per tick. To stream into InfluxDB for Grafana viewing, adapt a small consumer:
```bash
jq -c '{
measurement: "pumping_station_eval",
tags: { scenario: "'$SCENARIO'" },
fields: { level: .level, volume: .volume, demand: .percControl, safety: (.safetyActive|if . then 1 else 0 end) },
timestamp: (.t | tonumber | . * 1000000000)
}' simulations/logs/$SCENARIO.jsonl \
| influx write --bucket=telemetry ...
```
The `t` field is seconds from the scenario start (not wall-clock), so point the Grafana time range at `now() - $duration` after running.

View File

@@ -0,0 +1,40 @@
// ASCII table summary of scenario samples.
// Used by simulations/run.js.
function pad(s, n, left = false) {
s = String(s ?? '');
if (s.length >= n) return s.slice(0, n);
return left ? s.padStart(n) : s.padEnd(n);
}
function num(x, digits = 2) {
return Number.isFinite(x) ? x.toFixed(digits) : '—';
}
function formatTable(records, sampleEvery = 1) {
if (!records.length) return ' (no records)';
const header = ['t(s)', 'level(m)', 'vol(m3)', 'dir', 'netFlow(m3/s)', 'src', 'demand', 'safe'];
const rows = [];
for (let i = 0; i < records.length; i += sampleEvery) rows.push(records[i]);
if (rows[rows.length - 1] !== records[records.length - 1]) rows.push(records[records.length - 1]);
const widths = [6, 9, 9, 10, 14, 14, 8, 5];
const lines = [];
lines.push(header.map((h, i) => pad(h, widths[i], true)).join(' '));
lines.push(widths.map((w) => '─'.repeat(w)).join(' '));
for (const r of rows) {
lines.push([
pad(r.t, widths[0], true),
pad(num(r.level, 2), widths[1], true),
pad(num(r.volume, 2), widths[2], true),
pad(r.direction ?? '—', widths[3], true),
pad(num(r.netFlow, 5), widths[4], true),
pad(r.flowSource ?? '—', widths[5], true),
pad(num(r.percControl, 0) + '%', widths[6], true),
pad(r.safetyActive ? '⚠' : '·', widths[7], true),
].join(' '));
}
return lines.map((l) => ' ' + l).join('\n');
}
module.exports = { formatTable };

2
simulations/logs/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.jsonl
!.gitignore

197
simulations/run.js Normal file
View File

@@ -0,0 +1,197 @@
#!/usr/bin/env node
// Scenario runner for pumpingStation. Usage:
//
// node simulations/run.js <scenario> # run one
// node simulations/run.js --all # run all scenarios
//
// Each scenario lives in simulations/scenarios/<name>.js and exports:
// { name, description, durationSec, config, setup?, inputs, expectations? }
//
// The runner ticks the station once per simulated second, records every
// state into simulations/logs/<name>.jsonl, prints a summary table + event log,
// and checks expectations.
const path = require('path');
const fs = require('fs');
const PumpingStation = require('../src/specificClass');
const { formatTable } = require('./formatters/table');
function loadScenario(name) {
return require(path.join(__dirname, 'scenarios', name));
}
function snapshot(t, ps) {
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
return {
t,
level: lvl,
volume: vol,
direction: ps.state?.direction ?? null,
netFlow: ps.state?.netFlow ?? null,
flowSource: ps.state?.flowSource ?? null,
timeleft: ps.state?.seconds ?? null,
percControl: ps.percControl,
mode: ps.mode,
safetyActive: !!ps.safetyControllerActive,
};
}
function evalExpectation(ex, records) {
const levels = records.map((r) => r.level).filter(Number.isFinite);
const demands = records.map((r) => r.percControl).filter(Number.isFinite);
const last = records[records.length - 1] || {};
switch (ex.type) {
case 'max_level_bounded': {
const v = Math.max(...levels);
return { ok: v <= ex.value, msg: `max level = ${v.toFixed(2)} m (bound: ≤ ${ex.value})` };
}
case 'min_level_bounded': {
const v = Math.min(...levels);
return { ok: v >= ex.value, msg: `min level = ${v.toFixed(2)} m (bound: ≥ ${ex.value})` };
}
case 'max_demand_bounded': {
const v = Math.max(...demands);
return { ok: v <= ex.value, msg: `max demand = ${v.toFixed(0)} % (bound: ≤ ${ex.value})` };
}
case 'safety_trips_eq': {
const n = records.filter((r) => r.safetyActive).length;
return { ok: n === ex.value, msg: `${n} ticks with safetyActive (expected ${ex.value})` };
}
case 'safety_trips_gt': {
const n = records.filter((r) => r.safetyActive).length;
return { ok: n > ex.value, msg: `${n} ticks with safetyActive (expected > ${ex.value})` };
}
case 'end_state_eq': {
return { ok: last[ex.field] === ex.value, msg: `end ${ex.field} = ${last[ex.field]} (expected ${ex.value})` };
}
case 'threshold_issues_eq': {
const n = (records[0] && records[0].thresholdIssues) || 0;
return { ok: n === ex.value, msg: `${n} threshold issues at startup (expected ${ex.value})` };
}
default:
return { ok: false, msg: `unknown expectation type: ${ex.type}` };
}
}
function events(records) {
const out = [];
let prev = null;
for (const r of records) {
if (!prev) { prev = r; continue; }
if (r.direction !== prev.direction) out.push({ t: r.t, kind: 'direction', from: prev.direction, to: r.direction });
if (r.safetyActive !== prev.safetyActive) out.push({ t: r.t, kind: 'safety', active: r.safetyActive });
if (r.mode !== prev.mode) out.push({ t: r.t, kind: 'mode', from: prev.mode, to: r.mode });
prev = r;
}
return out;
}
async function runScenario(name) {
const scenario = loadScenario(name);
// Use simulated time so the volume integrator sees 1 s per tick.
// The class reads Date.now() internally; monkey-patching lets it
// advance at scenario pace rather than wall-clock.
const realNow = Date.now;
let simTime = realNow();
Date.now = () => simTime;
try {
const ps = new PumpingStation(scenario.config);
if (scenario.setup) await scenario.setup(ps);
const duration = scenario.durationSec ?? 600;
const logDir = path.join(__dirname, 'logs');
fs.mkdirSync(logDir, { recursive: true });
const logPath = path.join(logDir, `${scenario.name}.jsonl`);
const log = fs.createWriteStream(logPath);
const records = [];
for (let t = 0; t < duration; t += 1) {
simTime += 1000; // advance 1 simulated second
if (scenario.inputs) scenario.inputs(t, ps);
ps.tick();
const snap = snapshot(t, ps);
snap.thresholdIssues = ps.thresholdIssues?.length ?? 0;
records.push(snap);
log.write(JSON.stringify(snap) + '\n');
}
// Drain so the file is fully written before we return.
await new Promise((resolve, reject) => { log.end(); log.on('finish', resolve); log.on('error', reject); });
return { ps, records, scenario, duration, logPath };
} finally {
Date.now = realNow;
}
}
async function runAndReport(name) {
const { ps, records, scenario, duration, logPath } = await runScenario(name);
// Output
console.log(`\n═══ Scenario: ${scenario.name} ═══`);
console.log(scenario.description);
console.log(`Duration: ${duration}s, 1s ticks`);
console.log('\n─── Samples (every 10%) ───');
console.log(formatTable(records, Math.max(1, Math.floor(duration / 10))));
const evts = events(records);
console.log(`\n─── Events (${evts.length}) ───`);
if (!evts.length) console.log(' (none)');
for (const e of evts) {
if (e.kind === 'direction') console.log(` t=${String(e.t).padStart(4)}s direction ${e.from}${e.to}`);
else if (e.kind === 'safety') console.log(` t=${String(e.t).padStart(4)}s safety ${e.active ? 'ACTIVE ⚠' : 'cleared'}`);
else if (e.kind === 'mode') console.log(` t=${String(e.t).padStart(4)}s mode ${e.from}${e.to}`);
}
console.log('\n─── Metrics ───');
const levels = records.map((r) => r.level).filter(Number.isFinite);
const demands = records.map((r) => r.percControl).filter(Number.isFinite);
const trips = records.filter((r) => r.safetyActive).length;
if (levels.length) {
console.log(` level min=${Math.min(...levels).toFixed(2)} max=${Math.max(...levels).toFixed(2)} end=${levels[levels.length-1].toFixed(2)} m`);
}
if (demands.length) {
console.log(` percControl min=${Math.min(...demands).toFixed(0)}% max=${Math.max(...demands).toFixed(0)}% end=${demands[demands.length-1].toFixed(0)}%`);
}
console.log(` safety trips=${trips} ticks`);
console.log(` threshold issues=${ps.thresholdIssues?.length ?? 0} at startup`);
let allOk = true;
if (scenario.expectations?.length) {
console.log('\n─── Expectations ───');
for (const ex of scenario.expectations) {
const { ok, msg } = evalExpectation(ex, records);
allOk = allOk && ok;
console.log(` ${ok ? '✓' : '✗'} ${ex.name}: ${msg}`);
}
}
console.log(`\nLog: ${path.relative(process.cwd(), logPath)} (${records.length} records)`);
console.log(allOk ? '✅ PASS' : '❌ FAIL');
return allOk;
}
async function main() {
const arg = process.argv[2];
if (!arg) {
console.error('Usage: node simulations/run.js <scenario> | --all');
console.error('Available:', fs.readdirSync(path.join(__dirname, 'scenarios')).map((f) => f.replace(/\.js$/, '')).join(', '));
process.exit(1);
}
if (arg === '--all') {
const names = fs.readdirSync(path.join(__dirname, 'scenarios')).filter((f) => f.endsWith('.js')).map((f) => f.replace(/\.js$/, ''));
let allOk = true;
for (const name of names) {
try { allOk = (await runAndReport(name)) && allOk; }
catch (err) { console.error(`ERROR in ${name}:`, err.message); allOk = false; }
}
process.exit(allOk ? 0 : 1);
}
try { process.exit((await runAndReport(arg)) ? 0 : 1); }
catch (err) { console.error('ERROR:', err.message, '\n', err.stack); process.exit(1); }
}
main();

View File

@@ -0,0 +1,60 @@
// Steady sewer inflow, level-based control, pumps should settle.
//
// Expectation: with a stable inflow of 0.008 m³/s and a pump bank with
// max capacity 0.012 m³/s, the level settles in the RAMP zone (between
// startLevel and maxLevel) at roughly the point where demand matches
// inflow. No safety trips should fire.
module.exports = {
name: 'levelbased-steady',
description: 'Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.',
durationSec: 1200,
config: {
general: { name: 'EvalSteady', id: 'eval-steady', unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 2,
enableOverfillProtection: true,
overfillThresholdPercent: 98,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
},
setup: async (ps) => {
// Stub MGC: its pumps collectively deliver (demand/100) × MAX_OUTFLOW.
const MAX_OUTFLOW = 0.012; // m³/s
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
},
handleInput: async (_source, demand) => {
const d = Math.max(0, Math.min(100, Number(demand) || 0));
const outflow = (d / 100) * MAX_OUTFLOW;
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
},
};
ps.calibratePredictedLevel(2.0); // start at the bottom of the RAMP zone
},
inputs: (t, ps) => {
ps.setManualInflow(0.008, Date.now(), 'm3/s'); // ≈ 29 m³/h
},
expectations: [
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
{ name: 'level stays above outflow', type: 'min_level_bounded', value: 0.2 },
{ name: 'no threshold issues on init', type: 'threshold_issues_eq', value: 0 },
],
};

View File

@@ -0,0 +1,60 @@
// Storm surge — inflow triples briefly, pumps should saturate at 100%,
// level rises toward overflow then recedes.
//
// Expectation: during the surge (t=300..600), demand reaches 100% and
// level may transiently climb above maxLevel. Overflow safety should
// fire if the surge overwhelms pump capacity; dry-run should not fire.
module.exports = {
name: 'levelbased-storm',
description: 'Sewer inflow triples from 0.008 → 0.024 m³/s for 5 minutes then returns to baseline. Overfill safety may engage.',
durationSec: 1500,
config: {
general: { name: 'EvalStorm', id: 'eval-storm', unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 2,
enableOverfillProtection: true,
overfillThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
},
setup: async (ps) => {
const MAX_OUTFLOW = 0.012; // m³/s pumps cannot keep up with 3× surge
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
},
handleInput: async (_src, demand) => {
const d = Math.max(0, Math.min(100, Number(demand) || 0));
const outflow = (d / 100) * MAX_OUTFLOW;
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
},
};
ps.calibratePredictedLevel(2.5);
},
inputs: (t, ps) => {
const surge = (t >= 300 && t < 600) ? 0.024 : 0.008;
ps.setManualInflow(surge, Date.now(), 'm3/s');
},
expectations: [
{ name: 'dry-run never trips', type: 'end_state_eq', field: 'safetyActive', value: false },
// Level may exceed maxLevel transiently but must stay under basinHeight
{ name: 'level never breaches physical basin', type: 'max_level_bounded', value: 5.0 },
{ name: 'demand saturates at 100% during surge', type: 'max_demand_bounded', value: 100 },
],
};

View File

@@ -0,0 +1,66 @@
// Dry-run safety trip — manual mode, fixed high demand, zero inflow.
// Levelbased control would taper demand as the level drops (its ramp),
// stalling drainage before safety fires. Manual mode holds demand
// constant so the level actually reaches the dry-run threshold.
module.exports = {
name: 'safety-dry-run-trip',
description: 'Manual mode, constant 100 % demand, zero inflow; expect safety to force-stop downstream pumps when level reaches the dry-run threshold.',
durationSec: 600,
config: {
general: { name: 'EvalDryRun', id: 'eval-dry-run', unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'manual',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 50,
enableOverfillProtection: false,
overfillThresholdPercent: 98,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
},
setup: async (ps) => {
const MAX_OUTFLOW = 0.04;
let mgcRunning = true; // gets toggled by safety's shutdown call
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1', id: 'mgc1' }, functionality: { positionVsParent: 'downstream' } },
turnOffAllMachines: () => {
mgcRunning = false;
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
},
handleInput: async (_src, demand) => {
if (!mgcRunning) return;
const d = Math.max(0, Math.min(100, Number(demand) || 0));
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value((d / 100) * MAX_OUTFLOW, Date.now(), 'm3/s');
},
};
// Need a downstream machine for safety to shut down
ps.machines['pump1'] = {
config: { general: { name: 'pump1', id: 'pump1' }, functionality: { positionVsParent: 'downstream' } },
_isOperationalState: () => mgcRunning,
handleInput: async (_src, action) => {
if (action === 'execSequence') mgcRunning = false;
},
};
ps.calibratePredictedLevel(2.5);
},
inputs: (t, ps) => {
ps.setManualInflow(0, Date.now(), 'm3/s');
if (ps.mode === 'manual') ps.forwardDemandToChildren(100);
},
expectations: [
{ name: 'safety engages at some point', type: 'safety_trips_gt', value: 0 },
{ name: 'level never goes below outflow pipe', type: 'min_level_bounded', value: 0.2 },
],
};

View File

@@ -44,13 +44,29 @@ class nodeClass {
basin: {
volume: uiConfig.basinVolume,
height: uiConfig.basinHeight,
heightInlet: uiConfig.heightInlet,
heightOutlet: uiConfig.heightOutlet,
heightOverflow: uiConfig.heightOverflow,
inflowLevel: uiConfig.inflowLevel,
outflowLevel: uiConfig.outflowLevel,
overflowLevel: uiConfig.overflowLevel,
},
hydraulics: {
refHeight: uiConfig.refHeight,
minHeightBasedOn: uiConfig.minHeightBasedOn,
basinBottomRef: uiConfig.basinBottomRef,
},
control:{
mode: uiConfig.controlMode,
levelbased:{
minLevel:uiConfig.minLevel,
startLevel:uiConfig.startLevel,
maxLevel:uiConfig.maxLevel
}
},
safety:{
enableDryRunProtection: uiConfig.enableDryRunProtection,
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
enableOverfillProtection: uiConfig.enableOverfillProtection,
overfillThresholdPercent: uiConfig.overfillThresholdPercent,
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds
}
});
@@ -86,65 +102,60 @@ class nodeClass {
_updateNodeStatus() {
const ps = this.source;
try {
// --- Basin & measurements -------------------------------------------------
const maxVolBeforeOverflow = ps.basin?.maxVolOverflow ?? ps.basin?.maxVol ?? 0;
const volumeMeasurement = ps.measurements.type("volume").variant("measured").position("atEquipment");
const currentVolume = volumeMeasurement.getCurrentValue("m3") ?? 0;
const netFlowMeasurement = ps.measurements.type("netFlowRate").variant("predicted").position("atEquipment");
const netFlowM3s = netFlowMeasurement?.getCurrentValue("m3/s") ?? 0;
const netFlowM3h = netFlowM3s * 3600;
const percentFull = ps.measurements.type("volume").variant("procent").position("atEquipment").getCurrentValue() ?? 0;
// --- State information ----------------------------------------------------
const direction = ps.state?.direction || "unknown";
const secondsRemaining = ps.state?.seconds ?? null;
const timeRemaining = secondsRemaining ? `${Math.round(secondsRemaining / 60)}` : 0 + " min";
// --- Icon / colour selection ---------------------------------------------
let symbol = "❔";
let fill = "grey";
switch (direction) {
case "filling":
symbol = "⬆️";
fill = "blue";
break;
case "draining":
symbol = "⬇️";
fill = "orange";
break;
case "stable":
symbol = "⏸️";
fill = "green";
break;
default:
symbol = "❔";
fill = "grey";
break;
const pickVariant = (type, prefer = ['measured', 'predicted'], position = 'atEquipment', unit) => {
for (const variant of prefer) {
const chain = ps.measurements.type(type).variant(variant).position(position);
const value = unit ? chain.getCurrentValue(unit) : chain.getCurrentValue();
if (value != null) return { value, variant };
}
return { value: null, variant: null };
};
// --- Status text ----------------------------------------------------------
const textParts = [
`${symbol} ${percentFull.toFixed(1)}%`,
`V=${currentVolume.toFixed(2)} / ${maxVolBeforeOverflow.toFixed(2)}`,
`net=${netFlowM3h.toFixed(1)} m³/h`,
`t≈${timeRemaining}`
];
const vol = pickVariant('volume', ['measured', 'predicted'], 'atEquipment', 'm3');
const volPercent = pickVariant('volumePercent', ['measured','predicted'], 'atEquipment'); // already unitless
const level = pickVariant('level', ['measured', 'predicted'], 'atEquipment', 'm');
const netFlow = pickVariant('netFlowRate', ['measured', 'predicted'], 'atEquipment', 'm3/h');
return {
fill,
shape: "dot",
text: textParts.join(" | ")
};
} catch (error) {
this.node.error("Error in updateNodeStatus: " + error.message);
return { fill: "red", shape: "ring", text: "Status Error" };
const maxVolBeforeOverflow = ps.basin?.maxVolAtOverflow ?? ps.basin?.maxVol ?? 0;
const currentVolume = vol.value ?? 0;
const currentvolPercent = volPercent.value ?? 0;
const netFlowM3h = netFlow.value ?? 0;
const direction = ps.state?.direction ?? 'unknown';
const secondsRemaining = ps.state?.seconds ?? null;
const timeRemainingMinutes = secondsRemaining != null ? Math.round(secondsRemaining / 60) : null;
const badgePieces = [];
badgePieces.push(`${currentvolPercent.toFixed(1)}% `);
badgePieces.push(
`V=${currentVolume.toFixed(2)} / ${maxVolBeforeOverflow.toFixed(2)}`
);
badgePieces.push(`net: ${netFlowM3h.toFixed(0)} m³/h`);
if (timeRemainingMinutes != null) {
badgePieces.push(`t≈${timeRemainingMinutes} min)`);
}
const { symbol, fill } = (() => {
switch (direction) {
case 'filling': return { symbol: '⬆️', fill: 'blue' };
case 'draining': return { symbol: '⬇️', fill: 'orange' };
case 'steady': return { symbol: '⏸️', fill: 'green' };
default: return { symbol: '❔', fill: 'grey' };
}
})();
badgePieces[0] = `${symbol} ${badgePieces[0]}`;
return {
fill,
shape: 'dot',
text: badgePieces.join(' | ')
};
}
// any time based functions here
_startTickLoop() {
setTimeout(() => {
@@ -167,8 +178,8 @@ class nodeClass {
//pumping station needs time based ticks to recalc level when predicted
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');
const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
// Send only updated outputs on ports 0 & 1
this.node.send([processMsg, influxMsg]);
@@ -181,18 +192,53 @@ class nodeClass {
this.node.on('input', (msg, send, done) => {
switch (msg.topic) {
//example
/*case 'simulator':
this.source.toggleSimulation();
case 'changemode':
this.source.changeMode(msg.payload);
break;
default:
this.source.handleInput(msg);
break;
*/
case 'registerChild': {
// Register this node as a child of the parent node
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
this.source.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
this.source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
break;
}
case 'calibratePredictedVolume': {
const injectedVol = parseFloat(msg.payload);
this.source.calibratePredictedVolume(injectedVol);
break;
}
case 'calibratePredictedLevel': {
const injectedLevel = parseFloat(msg.payload);
this.source.calibratePredictedLevel(injectedLevel);
break;
}
case 'q_in': {
// payload can be number or { value, unit, timestamp }
const val = Number(msg.payload);
const unit = msg?.unit;
const ts = msg?.timestamp || Date.now();
this.source.setManualInflow(val, ts, unit);
break;
}
case 'Qd': {
// Manual demand: operator sets the target output via a
// dashboard slider. Only accepted when PS is in 'manual'
// mode — mirrors how rotatingMachine gates commands by
// mode (virtualControl vs auto).
const demand = Number(msg.payload);
if (!Number.isFinite(demand)) {
this.source.logger.warn(`Invalid Qd value: ${msg.payload}`);
break;
}
if (this.source.mode === 'manual') {
this.source.forwardDemandToChildren(demand).catch((err) =>
this.source.logger.error(`Failed to forward demand: ${err.message}`)
);
} else {
this.source.logger.debug(
`Qd ignored in ${this.source.mode} mode. Switch to manual to use the demand slider.`
);
}
break;
}
}
@@ -207,6 +253,7 @@ class nodeClass {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
this.node.status({}); // clear node status badge
done();
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,295 @@
// Basic unit tests for PumpingStation (domain logic, no Node-RED).
// Run with: node --test test/basic/specificClass.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const PumpingStation = require('../../src/specificClass');
// Standard config shape. Override any section by passing { section: {...} }.
function makeConfig(overrides = {}) {
const base = {
general: {
name: 'TestStation',
id: 'ps-test',
unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' },
flowThreshold: 1e-4,
},
functionality: {
softwareType: 'pumpingStation',
role: 'stationcontroller',
positionVsParent: 'atEquipment',
},
basin: {
volume: 50,
height: 5,
inflowLevel: 3,
outflowLevel: 0.2,
overflowLevel: 4.5,
},
hydraulics: {
refHeight: 'NAP',
basinBottomRef: 0,
minHeightBasedOn: 'outlet',
},
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
},
safety: {
enableDryRunProtection: false,
enableOverfillProtection: false,
dryRunThresholdPercent: 2,
overfillThresholdPercent: 98,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
};
for (const k of Object.keys(overrides)) {
base[k] = typeof overrides[k] === 'object' && !Array.isArray(overrides[k])
? { ...base[k], ...overrides[k] }
: overrides[k];
}
return base;
}
test('Basin geometry — derived values', async (t) => {
const ps = new PumpingStation(makeConfig());
await t.test('surfaceArea = volume / height', () => {
assert.equal(ps.basin.surfaceArea, 10); // 50 / 5
});
await t.test('maxVol = height × area ≡ volEmptyBasin', () => {
assert.equal(ps.basin.maxVol, 50);
assert.equal(ps.basin.maxVol, ps.basin.volEmptyBasin);
});
await t.test('maxVolAtOverflow = overflowLevel × area', () => {
assert.equal(ps.basin.maxVolAtOverflow, 45); // 4.5 × 10
});
await t.test('minVolAtInflow = inflowLevel × area', () => {
assert.equal(ps.basin.minVolAtInflow, 30); // 3 × 10
});
await t.test('minVolAtOutflow = outflowLevel × area', () => {
assert.ok(Math.abs(ps.basin.minVolAtOutflow - 2) < 1e-9); // 0.2 × 10
});
await t.test('minVol honours minHeightBasedOn=outlet', () => {
assert.ok(Math.abs(ps.basin.minVol - 2) < 1e-9);
});
await t.test('minVol honours minHeightBasedOn=inlet', () => {
const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } }));
assert.equal(ps2.basin.minVol, 30);
});
});
test('Level ↔ volume roundtrip', async (t) => {
const ps = new PumpingStation(makeConfig());
await t.test('_calcVolumeFromLevel multiplies by area', () => {
assert.equal(ps._calcVolumeFromLevel(2), 20);
});
await t.test('_calcVolumeFromLevel clamps negatives to 0', () => {
assert.equal(ps._calcVolumeFromLevel(-3), 0);
});
await t.test('_calcLevelFromVolume divides by area', () => {
assert.equal(ps._calcLevelFromVolume(20), 2);
});
await t.test('_calcLevelFromVolume clamps negatives to 0', () => {
assert.equal(ps._calcLevelFromVolume(-10), 0);
});
await t.test('roundtrip preserves level', () => {
const v = ps._calcVolumeFromLevel(2.7);
assert.ok(Math.abs(ps._calcLevelFromVolume(v) - 2.7) < 1e-10);
});
});
test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
await t.test('valid config returns no issues', () => {
const ps = new PumpingStation(makeConfig());
assert.equal(ps.thresholdIssues.length, 0);
});
await t.test('minLevel > startLevel flagged', () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 3, startLevel: 2, maxLevel: 4 },
},
}));
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'minLevel'));
});
await t.test('startLevel == maxLevel flagged (must be strict <)', () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 4, maxLevel: 4 },
},
}));
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
});
await t.test('outflowLevel >= inflowLevel flagged', () => {
const ps = new PumpingStation(makeConfig({
basin: { volume: 50, height: 5, inflowLevel: 0.1, outflowLevel: 0.5, overflowLevel: 4.5 },
}));
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'outflowLevel'));
});
await t.test('overflowLevel > basinHeight flagged', () => {
const ps = new PumpingStation(makeConfig({
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 6 },
}));
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'overflowLevel'));
});
await t.test('dryRunLevel > minLevel flagged (safety band inverted)', () => {
// With minHeightBasedOn=inlet, refLowLevel=inflowLevel=3.
// dryRunLevel = 3 × (1 + 100/100) = 6; minLevel=1 → 6 ≤ 1 fails.
const ps = new PumpingStation(makeConfig({
hydraulics: { minHeightBasedOn: 'inlet' },
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 100 },
}));
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'dryRunLevel'));
});
});
test('Direction derivation — _deriveDirection', async (t) => {
const ps = new PumpingStation(makeConfig());
await t.test('positive flow above dead-band → filling', () => {
assert.equal(ps._deriveDirection(0.01), 'filling');
});
await t.test('negative flow below dead-band → draining', () => {
assert.equal(ps._deriveDirection(-0.01), 'draining');
});
await t.test('flow inside dead-band → steady', () => {
assert.equal(ps._deriveDirection(0), 'steady');
assert.equal(ps._deriveDirection(1e-5), 'steady');
assert.equal(ps._deriveDirection(-1e-5), 'steady');
});
});
test('Mode change — changeMode', async (t) => {
const ps = new PumpingStation(makeConfig());
await t.test('valid mode swap updates this.mode', () => {
ps.changeMode('manual');
assert.equal(ps.mode, 'manual');
});
await t.test('rejected mode leaves this.mode unchanged', () => {
ps.changeMode('manual');
ps.changeMode('notamode');
assert.equal(ps.mode, 'manual');
});
});
test('Calibration — predicted volume and level', async (t) => {
const ps = new PumpingStation(makeConfig());
await t.test('calibratePredictedVolume rewrites volume series', () => {
ps.calibratePredictedVolume(25);
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.ok(Math.abs(vol - 25) < 1e-9);
});
await t.test('calibratePredictedVolume also writes level (= vol / area)', () => {
ps.calibratePredictedVolume(30);
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
assert.ok(Math.abs(lvl - 3) < 1e-9); // 30 / 10
});
await t.test('calibratePredictedLevel writes level + volume = level × area', () => {
ps.calibratePredictedLevel(2.5);
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.ok(Math.abs(lvl - 2.5) < 1e-9);
assert.ok(Math.abs(vol - 25) < 1e-9); // 2.5 × 10
});
});
test('Levelbased control zones — _controlLevelBased', async (t) => {
await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
const ps = new PumpingStation(makeConfig());
let turnOffCalls = 0;
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => { turnOffCalls++; },
handleInput: async () => {},
};
ps.calibratePredictedLevel(0.5); // below minLevel=1
await ps._controlLevelBased();
assert.equal(ps.percControl, 0);
assert.equal(turnOffCalls, 1);
});
await t.test('minLevel ≤ level < startLevel → dead zone, percControl unchanged', async () => {
const ps = new PumpingStation(makeConfig());
ps.percControl = 42; // simulated previous demand
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => { throw new Error('should not be called in dead zone'); },
};
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
await ps._controlLevelBased();
assert.equal(ps.percControl, 42); // unchanged
});
await t.test('level ≥ startLevel → percControl linearly scaled to [0,100]', async () => {
const ps = new PumpingStation(makeConfig());
const demands = [];
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); },
};
ps.calibratePredictedLevel(3); // midpoint of startLevel=2 and maxLevel=4
await ps._controlLevelBased();
// lerp(3, [2,4], [0,100]) = 50
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
assert.equal(demands.length, 1);
assert.ok(Math.abs(demands[0] - 50) < 1e-9);
});
await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
const ps = new PumpingStation(makeConfig());
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
ps.calibratePredictedLevel(4.5); // above maxLevel=4
await ps._controlLevelBased();
assert.ok(ps.percControl >= 100);
});
});
test('getOutput — flattens basin + state + demand', async (t) => {
const ps = new PumpingStation(makeConfig());
ps.percControl = 37;
await t.test('includes basin geometry fields', () => {
const out = ps.getOutput();
assert.equal(out.volEmptyBasin, 50);
assert.equal(out.maxVolAtOverflow, 45);
assert.equal(out.minVolAtInflow, 30);
assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9);
});
await t.test('includes state fields (direction, flowSource, timeleft)', () => {
const out = ps.getOutput();
assert.ok('direction' in out);
assert.ok('flowSource' in out);
assert.ok('timeleft' in out);
});
await t.test('includes percControl', () => {
assert.equal(ps.getOutput().percControl, 37);
});
});
test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
const ps = new PumpingStation(makeConfig());
ps.setManualInflow(0.05, Date.now(), 'm3/s'); // 0.05 m³/s
const v = ps.measurements.type('flow').variant('predicted').position('in').child('manual-qin').getCurrentValue('m3/s');
assert.ok(Math.abs(v - 0.05) < 1e-9);
});

18
wiki/README.md Normal file
View File

@@ -0,0 +1,18 @@
# pumpingStation — Documentation
All docs and diagrams for this node live in this folder so they version-lock with the code they describe.
## Pages
- **[Functional Description](functional-description.md)** — operator-facing reference derived from `src/specificClass.js`: basin model, net-flow selection, safety interlocks, registration topology.
- **[Control modes](modes/README.md)** — one page per control mode (`levelbased`, `flowbased`, …) describing how the mode uses the shared basin model to compute demand.
## Diagrams
Editable draw.io SVGs live in [`diagrams/`](diagrams/). See [`diagrams/README.md`](diagrams/README.md) for the editing workflow — open the `.drawio.svg` in [draw.io](https://app.diagrams.net/), edit it, then export back to SVG with the source embedded.
The basin model is the shared physical canvas ([`diagrams/basin-model.drawio.svg`](diagrams/basin-model.drawio.svg)); per-mode transfer-function diagrams live under [`diagrams/modes/`](diagrams/modes/). Mode-specific thresholds such as `startLevel` belong in those mode diagrams, not in the generic basin model.
## Part of
This node is a git submodule of [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV). The EVOLV superproject has its own [`wiki/`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki) with platform-level docs (architecture, concepts, shared manuals).

71
wiki/diagrams/README.md Normal file
View File

@@ -0,0 +1,71 @@
# Diagrams
Editable source diagrams for the pumpingStation wiki. The current diagrams are **`.drawio.svg` files with the draw.io source embedded**, so anyone can edit the SVG directly in [draw.io](https://app.diagrams.net/) without touching any Markdown.
## File roles
| File | Role |
|---|---|
| `<name>.drawio` | Optional native draw.io XML source, if a diagram also keeps a standalone source file. |
| `<name>.drawio.svg` | SVG export of the same diagram (with source embedded). What the wiki actually renders, and what round-trips back into draw.io. |
An optional standalone `.drawio` file can be committed beside the SVG, but the embedded-source SVG is enough for the wiki to render and for the next editor to pick up from exactly where the last one left off.
## Editing workflow
1. **Clone** the repo (you likely already have it if you're editing):
```bash
git clone https://gitea.wbd-rd.nl/RnD/pumpingStation.git
cd pumpingStation/wiki/diagrams
```
2. **Open** the `.drawio.svg` file in draw.io:
- Web: [app.diagrams.net](https://app.diagrams.net/) → *Open Existing Diagram*, or drag-and-drop.
- Desktop: [drawio-desktop](https://github.com/jgraph/drawio-desktop/releases).
3. **Edit** — move shapes, change labels, adjust layout.
4. **Export** to SVG with the source embedded:
- `File → Export as → SVG…`
- Check **Include a copy of my diagram** ← this is what lets future edits round-trip through the SVG.
- Save next to the source as `<name>.drawio.svg` (overwrite).
5. **Commit & push** the edited SVG, plus the `.drawio` file if one exists:
```bash
git add wiki/diagrams/<name>.drawio.svg
git commit -m "Update <name>: <what changed>"
git push
```
## Referencing a diagram from a wiki page
In any Markdown page under `wiki/`:
```markdown
![Basin model](diagrams/basin-model.drawio.svg)
```
Use a descriptive `alt` text; it's the fallback if the SVG fails and it shows up in exports.
## Naming
- kebab-case, one concept per diagram.
- Current diagrams:
| Diagram | Shows |
|---|---|
| `basin-model` | Shared physical basin cross-section — walls, pipe reference heights, derived safety zones, storage/dead volumes |
| `modes/basin-mode-level-linear` | Level-linear control mode — `startLevel`, demand ramp, threshold-shift behaviour |
| `control-zones` | Legacy vertical level axis ("thermometer") for `levelbased` mode — STOP / DEAD ZONE / RUN with demand ramp |
| `safety-rules` | Dry-run vs overfill rule asymmetry — which children stop, which keep running |
## Making a brand-new diagram
1. Open draw.io, start blank.
2. Draw it.
3. `File → Export as → SVG…` with **Include a copy of my diagram** checked → save as `wiki/diagrams/<name>.drawio.svg`.
4. Reference from the wiki page with `![alt](diagrams/<name>.drawio.svg)`.
5. Add an entry to the table above.
6. Commit the new `.drawio.svg` and updated `.md` together.
## These starters are rough
Some diagrams are still rough — layout is approximate, colors and fonts may be defaults, and alignment may need refinement. They're meant to be improved in draw.io as the model settles.
Open the `.drawio.svg` in draw.io and it will load the editable model. The SVG has the draw.io XML embedded in a `content="…"` attribute on the root `<svg>` element — that's what lets draw.io re-open its own SVG exports.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 686 KiB

View File

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 660" font-family="Arial, sans-serif" font-size="13" content="&lt;mxfile host=&quot;app.diagrams.net&quot; modified=&quot;2026-04-22T12:00:00.000Z&quot; agent=&quot;Claude Code placeholder&quot; etag=&quot;initial&quot; version=&quot;22.0.0&quot; type=&quot;device&quot;&gt;
&lt;diagram name=&quot;control-zones&quot; id=&quot;controlZones&quot;&gt;
&lt;mxGraphModel dx=&quot;1000&quot; dy=&quot;800&quot; grid=&quot;1&quot; gridSize=&quot;10&quot; guides=&quot;1&quot; tooltips=&quot;1&quot; connect=&quot;1&quot; arrows=&quot;1&quot; fold=&quot;1&quot; page=&quot;1&quot; pageScale=&quot;1&quot; pageWidth=&quot;700&quot; pageHeight=&quot;800&quot; math=&quot;0&quot; shadow=&quot;0&quot;&gt;
&lt;root&gt;
&lt;mxCell id=&quot;0&quot; /&gt;
&lt;mxCell id=&quot;1&quot; parent=&quot;0&quot; /&gt;
&lt;mxCell id=&quot;title&quot; value=&quot;levelbased mode — three zones&quot; style=&quot;text;html=1;fontSize=16;fontStyle=1;align=center;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;100&quot; y=&quot;20&quot; width=&quot;500&quot; height=&quot;30&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;axis&quot; value=&quot;&quot; style=&quot;endArrow=classic;html=1;strokeColor=#000;strokeWidth=2;&quot; edge=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;
&lt;mxPoint x=&quot;280&quot; y=&quot;600&quot; as=&quot;sourcePoint&quot; /&gt;
&lt;mxPoint x=&quot;280&quot; y=&quot;80&quot; as=&quot;targetPoint&quot; /&gt;
&lt;/mxGeometry&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;axis_label&quot; value=&quot;level&quot; style=&quot;text;html=1;fontSize=13;fontStyle=1;align=left;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;240&quot; y=&quot;60&quot; width=&quot;50&quot; height=&quot;20&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;overflow&quot; value=&quot;heightOverflow — weir crest (spill → measure)&quot; style=&quot;text;html=1;fontSize=12;align=left;fontColor=#B22222;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;300&quot; y=&quot;130&quot; width=&quot;380&quot; height=&quot;20&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;overflow_tick&quot; value=&quot;&quot; style=&quot;endArrow=none;html=1;strokeColor=#B22222;&quot; edge=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;
&lt;mxPoint x=&quot;270&quot; y=&quot;140&quot; as=&quot;sourcePoint&quot; /&gt;
&lt;mxPoint x=&quot;290&quot; y=&quot;140&quot; as=&quot;targetPoint&quot; /&gt;
&lt;/mxGeometry&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;run_band&quot; value=&quot;RUN — linear 0 → 100 %&quot; style=&quot;rounded=0;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#1E8449;fontSize=12;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;300&quot; y=&quot;160&quot; width=&quot;220&quot; height=&quot;110&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;maxflow&quot; value=&quot;maxFlowLevel — 100 % demand&quot; style=&quot;text;html=1;fontSize=12;align=left;fontColor=#D68910;fontStyle=1;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;300&quot; y=&quot;265&quot; width=&quot;300&quot; height=&quot;20&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;maxflow_tick&quot; value=&quot;&quot; style=&quot;endArrow=none;html=1;strokeColor=#D68910;strokeWidth=2;&quot; edge=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;
&lt;mxPoint x=&quot;265&quot; y=&quot;275&quot; as=&quot;sourcePoint&quot; /&gt;
&lt;mxPoint x=&quot;295&quot; y=&quot;275&quot; as=&quot;targetPoint&quot; /&gt;
&lt;/mxGeometry&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;ramp_label&quot; value=&quot;(ramp — demand scales linearly with level)&quot; style=&quot;text;html=1;fontSize=11;align=left;fontStyle=2;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;300&quot; y=&quot;300&quot; width=&quot;320&quot; height=&quot;20&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;startlevel&quot; value=&quot;startLevel — 0 % demand (ramp starts)&quot; style=&quot;text;html=1;fontSize=12;align=left;fontColor=#1E8449;fontStyle=1;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;300&quot; y=&quot;335&quot; width=&quot;340&quot; height=&quot;20&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;start_tick&quot; value=&quot;&quot; style=&quot;endArrow=none;html=1;strokeColor=#1E8449;strokeWidth=2;&quot; edge=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;
&lt;mxPoint x=&quot;265&quot; y=&quot;345&quot; as=&quot;sourcePoint&quot; /&gt;
&lt;mxPoint x=&quot;295&quot; y=&quot;345&quot; as=&quot;targetPoint&quot; /&gt;
&lt;/mxGeometry&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;dead_band&quot; value=&quot;DEAD ZONE — hysteresis, keep last cmd&quot; style=&quot;rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF8E1;strokeColor=#F57C00;fontSize=12;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;300&quot; y=&quot;360&quot; width=&quot;220&quot; height=&quot;80&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;inlet&quot; value=&quot;heightInlet — inflow pipe&quot; style=&quot;text;html=1;fontSize=12;align=left;fontColor=#1F4E79;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;300&quot; y=&quot;395&quot; width=&quot;300&quot; height=&quot;20&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;inlet_tick&quot; value=&quot;&quot; style=&quot;endArrow=none;html=1;strokeColor=#1F4E79;&quot; edge=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;
&lt;mxPoint x=&quot;270&quot; y=&quot;405&quot; as=&quot;sourcePoint&quot; /&gt;
&lt;mxPoint x=&quot;290&quot; y=&quot;405&quot; as=&quot;targetPoint&quot; /&gt;
&lt;/mxGeometry&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;stoplevel&quot; value=&quot;stopLevel — unconditional STOP&quot; style=&quot;text;html=1;fontSize=12;align=left;fontColor=#6C3483;fontStyle=1;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;300&quot; y=&quot;440&quot; width=&quot;300&quot; height=&quot;20&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;stop_tick&quot; value=&quot;&quot; style=&quot;endArrow=none;html=1;strokeColor=#6C3483;strokeWidth=2;&quot; edge=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;
&lt;mxPoint x=&quot;265&quot; y=&quot;450&quot; as=&quot;sourcePoint&quot; /&gt;
&lt;mxPoint x=&quot;295&quot; y=&quot;450&quot; as=&quot;targetPoint&quot; /&gt;
&lt;/mxGeometry&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;stop_band&quot; value=&quot;pumps OFF (MGC shutdown)&quot; style=&quot;rounded=0;whiteSpace=wrap;html=1;fillColor=#F4ECF7;strokeColor=#6C3483;fontSize=12;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;300&quot; y=&quot;465&quot; width=&quot;220&quot; height=&quot;80&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;outlet&quot; value=&quot;heightOutlet — outflow pipe (dry-run trip here)&quot; style=&quot;text;html=1;fontSize=12;align=left;fontColor=#B22222;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;300&quot; y=&quot;510&quot; width=&quot;360&quot; height=&quot;20&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;outlet_tick&quot; value=&quot;&quot; style=&quot;endArrow=none;html=1;strokeColor=#B22222;&quot; edge=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;
&lt;mxPoint x=&quot;270&quot; y=&quot;520&quot; as=&quot;sourcePoint&quot; /&gt;
&lt;mxPoint x=&quot;290&quot; y=&quot;520&quot; as=&quot;targetPoint&quot; /&gt;
&lt;/mxGeometry&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;floor&quot; value=&quot;0 (floor)&quot; style=&quot;text;html=1;fontSize=11;align=left;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;300&quot; y=&quot;580&quot; width=&quot;60&quot; height=&quot;20&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;/root&gt;
&lt;/mxGraphModel&gt;
&lt;/diagram&gt;
&lt;/mxfile&gt;">
<title>levelbased mode — three zones</title>
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#000" />
</marker>
</defs>
<text x="350" y="30" text-anchor="middle" font-weight="bold" font-size="16">levelbased mode — three zones</text>
<!-- Vertical level axis -->
<line x1="280" y1="600" x2="280" y2="80" stroke="#000" stroke-width="2" marker-end="url(#arr)" />
<text x="260" y="75" text-anchor="end" font-weight="bold" font-size="13">level</text>
<!-- heightOverflow -->
<line x1="270" y1="140" x2="290" y2="140" stroke="#B22222" stroke-width="2" />
<text x="300" y="144" fill="#B22222" font-size="12">heightOverflow — weir crest (spill → measure)</text>
<!-- RUN band -->
<rect x="300" y="160" width="240" height="110" fill="#E8F5E9" stroke="#1E8449" />
<text x="420" y="220" text-anchor="middle" font-size="13" fill="#1E8449" font-weight="bold">RUN</text>
<text x="420" y="238" text-anchor="middle" font-size="12" fill="#1E8449">linear 0 → 100 %</text>
<!-- maxFlowLevel -->
<line x1="265" y1="275" x2="295" y2="275" stroke="#D68910" stroke-width="3" />
<text x="305" y="279" fill="#D68910" font-size="12" font-weight="bold">maxFlowLevel — 100 % demand</text>
<!-- Ramp label -->
<text x="305" y="314" font-size="11" font-style="italic">(ramp — demand scales linearly with level)</text>
<!-- startLevel -->
<line x1="265" y1="345" x2="295" y2="345" stroke="#1E8449" stroke-width="3" />
<text x="305" y="349" fill="#1E8449" font-size="12" font-weight="bold">startLevel — 0 % demand (ramp starts)</text>
<!-- DEAD ZONE band -->
<rect x="300" y="360" width="240" height="80" fill="#FFF8E1" stroke="#F57C00" />
<text x="420" y="390" text-anchor="middle" font-size="13" fill="#B78200" font-weight="bold">DEAD ZONE</text>
<text x="420" y="408" text-anchor="middle" font-size="12" fill="#B78200">hysteresis — keep last cmd</text>
<!-- heightInlet (inside dead zone) -->
<line x1="270" y1="405" x2="290" y2="405" stroke="#1F4E79" stroke-width="2" />
<text x="550" y="409" fill="#1F4E79" font-size="12">heightInlet</text>
<!-- stopLevel -->
<line x1="265" y1="450" x2="295" y2="450" stroke="#6C3483" stroke-width="3" />
<text x="305" y="454" fill="#6C3483" font-size="12" font-weight="bold">stopLevel — unconditional STOP</text>
<!-- STOP band -->
<rect x="300" y="465" width="240" height="80" fill="#F4ECF7" stroke="#6C3483" />
<text x="420" y="500" text-anchor="middle" font-size="13" fill="#6C3483" font-weight="bold">pumps OFF</text>
<text x="420" y="518" text-anchor="middle" font-size="12" fill="#6C3483">(MGC shutdown)</text>
<!-- heightOutlet -->
<line x1="270" y1="540" x2="290" y2="540" stroke="#B22222" stroke-width="2" />
<text x="305" y="544" fill="#B22222" font-size="12">heightOutlet — outflow pipe (dry-run trip)</text>
<!-- floor -->
<line x1="265" y1="600" x2="295" y2="600" stroke="#000" stroke-width="2" />
<text x="305" y="604" font-size="11">0 (floor)</text>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 271 KiB

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620" font-family="Arial, sans-serif" font-size="13" content="&lt;mxfile host=&quot;app.diagrams.net&quot; modified=&quot;2026-04-22T12:00:00.000Z&quot; agent=&quot;Claude Code placeholder&quot; etag=&quot;initial&quot; version=&quot;22.0.0&quot; type=&quot;device&quot;&gt;
&lt;diagram name=&quot;safety-rules&quot; id=&quot;safetyRules&quot;&gt;
&lt;mxGraphModel dx=&quot;1200&quot; dy=&quot;700&quot; grid=&quot;1&quot; gridSize=&quot;10&quot; guides=&quot;1&quot; tooltips=&quot;1&quot; connect=&quot;1&quot; arrows=&quot;1&quot; fold=&quot;1&quot; page=&quot;1&quot; pageScale=&quot;1&quot; pageWidth=&quot;900&quot; pageHeight=&quot;700&quot; math=&quot;0&quot; shadow=&quot;0&quot;&gt;
&lt;root&gt;
&lt;mxCell id=&quot;0&quot; /&gt;
&lt;mxCell id=&quot;1&quot; parent=&quot;0&quot; /&gt;
&lt;mxCell id=&quot;title&quot; value=&quot;Safety rules — asymmetric by direction&quot; style=&quot;text;html=1;fontSize=16;fontStyle=1;align=center;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;150&quot; y=&quot;20&quot; width=&quot;600&quot; height=&quot;30&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;dryrun_box&quot; value=&quot;DRY-RUN&amp;#10;(direction = draining)&quot; style=&quot;rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#E65100;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;80&quot; y=&quot;80&quot; width=&quot;340&quot; height=&quot;340&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;dr_upstream&quot; value=&quot;upstream children — KEEP&quot; style=&quot;text;html=1;fontSize=13;align=left;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;100&quot; y=&quot;140&quot; width=&quot;300&quot; height=&quot;24&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;dr_downstream&quot; value=&quot;downstream children — STOP&quot; style=&quot;text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;100&quot; y=&quot;170&quot; width=&quot;300&quot; height=&quot;24&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;dr_machinegroups&quot; value=&quot;machineGroups — STOP&quot; style=&quot;text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;100&quot; y=&quot;200&quot; width=&quot;300&quot; height=&quot;24&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;dr_control&quot; value=&quot;control loop — BLOCKED&quot; style=&quot;text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;100&quot; y=&quot;230&quot; width=&quot;300&quot; height=&quot;24&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;dr_note&quot; value=&quot;safetyControllerActive = true&amp;#10;&amp;#10;Pumps must stop before sucking air.&quot; style=&quot;text;html=1;fontSize=12;align=left;fontStyle=2;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;100&quot; y=&quot;290&quot; width=&quot;300&quot; height=&quot;80&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;overfill_box&quot; value=&quot;OVERFILL&amp;#10;(direction = filling)&quot; style=&quot;rounded=0;whiteSpace=wrap;html=1;fillColor=#FFEBEE;strokeColor=#C62828;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;480&quot; y=&quot;80&quot; width=&quot;340&quot; height=&quot;340&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;of_upstream&quot; value=&quot;upstream children — STOP ⚠&quot; style=&quot;text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#C62828;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;500&quot; y=&quot;140&quot; width=&quot;300&quot; height=&quot;24&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;of_downstream&quot; value=&quot;downstream children — KEEP&quot; style=&quot;text;html=1;fontSize=13;align=left;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;500&quot; y=&quot;170&quot; width=&quot;300&quot; height=&quot;24&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;of_machinegroups&quot; value=&quot;machineGroups — KEEP&quot; style=&quot;text;html=1;fontSize=13;align=left;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;500&quot; y=&quot;200&quot; width=&quot;300&quot; height=&quot;24&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;of_control&quot; value=&quot;control loop — ACTIVE&quot; style=&quot;text;html=1;fontSize=13;align=left;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;500&quot; y=&quot;230&quot; width=&quot;300&quot; height=&quot;24&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;of_note&quot; value=&quot;Level control keeps commanding downstream MGC.&amp;#10;&amp;#10;⚠ &amp;quot;upstream STOP&amp;quot; is only correct in a cascaded layout. In a gravity-sewer station the inflow can&amp;apos;t be stopped — log the spill instead.&quot; style=&quot;text;html=1;fontSize=12;align=left;fontStyle=2;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;500&quot; y=&quot;290&quot; width=&quot;300&quot; height=&quot;120&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;trigger_title&quot; value=&quot;Triggers (either condition fires the rule):&quot; style=&quot;text;html=1;fontSize=13;fontStyle=1;align=left;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;80&quot; y=&quot;450&quot; width=&quot;740&quot; height=&quot;20&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;mxCell id=&quot;trigger_list&quot; value=&quot;• vol &amp;lt; triggerLowVol (triggerLowVol = minVol × (1 + pct/100))&amp;#10;• vol &amp;gt; triggerHighVol (triggerHighVol = maxVolOverflow × pct/100)&amp;#10;• remainingTime &amp;lt; timeleftToFullOrEmptyThresholdSeconds (if enabled)&quot; style=&quot;text;html=1;fontSize=12;align=left;&quot; vertex=&quot;1&quot; parent=&quot;1&quot;&gt;
&lt;mxGeometry x=&quot;80&quot; y=&quot;480&quot; width=&quot;740&quot; height=&quot;80&quot; as=&quot;geometry&quot; /&gt;
&lt;/mxCell&gt;
&lt;/root&gt;
&lt;/mxGraphModel&gt;
&lt;/diagram&gt;
&lt;/mxfile&gt;">
<title>Safety rules — asymmetric by direction</title>
<text x="450" y="30" text-anchor="middle" font-weight="bold" font-size="16">Safety rules — asymmetric by direction</text>
<!-- DRY-RUN box -->
<rect x="80" y="80" width="340" height="340" fill="#FFF3E0" stroke="#E65100" stroke-width="2" />
<text x="250" y="112" text-anchor="middle" font-weight="bold" font-size="14">DRY-RUN</text>
<text x="250" y="130" text-anchor="middle" font-size="13" fill="#6F4A19">(direction = draining)</text>
<text x="100" y="162" font-size="13">upstream children — <tspan font-weight="bold">KEEP</tspan></text>
<text x="100" y="188" font-size="13" fill="#E65100">downstream children — <tspan font-weight="bold">STOP</tspan></text>
<text x="100" y="214" font-size="13" fill="#E65100">machineGroups — <tspan font-weight="bold">STOP</tspan></text>
<text x="100" y="240" font-size="13" fill="#E65100">control loop — <tspan font-weight="bold">BLOCKED</tspan></text>
<line x1="100" y1="268" x2="400" y2="268" stroke="#E65100" stroke-dasharray="3 3" />
<text x="100" y="294" font-size="12" font-style="italic">safetyControllerActive = true</text>
<text x="100" y="316" font-size="12" font-style="italic">Pumps must stop before sucking air.</text>
<!-- OVERFILL box -->
<rect x="480" y="80" width="340" height="340" fill="#FFEBEE" stroke="#C62828" stroke-width="2" />
<text x="650" y="112" text-anchor="middle" font-weight="bold" font-size="14">OVERFILL</text>
<text x="650" y="130" text-anchor="middle" font-size="13" fill="#7A1919">(direction = filling)</text>
<text x="500" y="162" font-size="13" fill="#C62828">upstream children — <tspan font-weight="bold">STOP</tspan></text>
<text x="500" y="188" font-size="13">downstream children — <tspan font-weight="bold">KEEP</tspan></text>
<text x="500" y="214" font-size="13">machineGroups — <tspan font-weight="bold">KEEP</tspan></text>
<text x="500" y="240" font-size="13">control loop — <tspan font-weight="bold">ACTIVE</tspan></text>
<line x1="500" y1="268" x2="800" y2="268" stroke="#C62828" stroke-dasharray="3 3" />
<text x="500" y="294" font-size="12" font-style="italic">Level control keeps commanding downstream MGC.</text>
<text x="500" y="324" font-size="12" font-style="italic" fill="#C62828">⚠ "upstream STOP" is only correct in a cascaded layout.</text>
<text x="500" y="342" font-size="12" font-style="italic" fill="#C62828">In a gravity-sewer station the inflow can't be</text>
<text x="500" y="360" font-size="12" font-style="italic" fill="#C62828">stopped — log the spill instead.</text>
<!-- Triggers block -->
<text x="80" y="470" font-weight="bold" font-size="13">Triggers (either condition fires the rule):</text>
<text x="100" y="498" font-size="12">• vol &lt; triggerLowVol (triggerLowVol = minVol × (1 + pct/100))</text>
<text x="100" y="520" font-size="12">• vol &gt; triggerHighVol (triggerHighVol = maxVolOverflow × pct/100)</text>
<text x="100" y="542" font-size="12">• remainingTime &lt; timeleftToFullOrEmptyThresholdSeconds (if enabled)</text>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,352 @@
---
title: pumpingStation — Functional Description
node: pumpingStation
updated: 2026-04-22
status: draft
---
# pumpingStation — Functional Description
The `pumpingStation` node models an S88 **Process Cell**: a wet-well basin with inflow and outflow, wrapped around one or more pump controllers. Every second it recomputes the basin's water balance, picks the most trustworthy net-flow source, runs its safety interlocks, and finally commands its children (individual pumps, `machineGroupControl`, or nested pumping stations) so the level stays inside the safe operating band.
This page is the operator-facing reference, derived from [`src/specificClass.js`](../src/specificClass.js). For the 3-tier code layout see [EVOLV — Node Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/node-architecture.md); for the atomic pump model see the [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki).
> **Diagrams on this page are editable.** Sources live in [`diagrams/`](diagrams/) — open the `.drawio` file in [draw.io](https://app.diagrams.net/), export to SVG, commit. See [`diagrams/README.md`](diagrams/README.md).
## At a glance
| Item | Value |
|---|---|
| Node category | EVOLV |
| S88 level | Process Cell (`#0c99d9`, lane L5) |
| Inputs | 1 (message-driven) |
| Outputs | 3 — `process` / `dbase` / `parent` |
| Tick period | 1 s |
| Basin model | Rectangular prismatic — `volume = level × surfaceArea` |
| Canonical units (internal) | Pa, m³/s, W, K, m, m³ |
| Control modes implemented | `levelbased`, `manual` (placeholders for `flowbased`, `pressureBased`, `percentageBased`, `powerBased`, `hybrid`) |
| Default flow dead-band | `1e-4 m³/s` (≈ 0.36 m³/h) |
## Lifecycle
1. **Construct.** The node merges the user's editor config over the schema defaults, creates the measurement store, and seeds the predicted volume at the basin's operational floor (`minVol`).
2. **Register children.** Sensors, pumps, machine groups, and nested stations register via the Port-2 handshake. The station subscribes only to the *highest-level aggregator* for predicted flow to avoid double-counting (MGC if present, otherwise the individual pump).
3. **Tick loop (1 s).** `_updatePredictedVolume → _selectBestNetFlow → _safetyController → _controlLogic → state snapshot → output`.
## Editor configuration
Every field on the pumpingStation editor maps directly to the config schema in `generalFunctions/src/configs/pumpingStation.json`.
### Basin geometry (section `basin`)
| Field | Default | Meaning |
|---|---|---|
| **Basin Volume (m³)** | `1` | Total geometric storage volume from basin floor to rim. |
| **Basin Height (m)** | `1` | Physical wall height from floor to rim. |
| **Inlet Elevation (m)** | `2` | Bottom/invert of the incoming sewer pipe, measured from the basin floor. This is the level where backing up into the inlet starts to matter hydraulically. |
| **Outlet Elevation (m)** | `0.2` | Top of the pump-suction/outlet pipe, measured from the basin floor. This is the practical lower hydraulic reference for pump protection. |
| **Inlet Pipe Diameter (m)** | `0.4` | Nominal incoming sewer pipe diameter. Used with `inflowLevel` to distinguish pipe bottom, centre, and crown in future hydraulic upgrades. |
| **Outlet Pipe Diameter (m)** | `0.4` | Nominal pump-suction/outlet pipe diameter. Used with `outflowLevel` to distinguish pipe top, centre, and invert in future hydraulic upgrades. |
| **Overflow Level (m)** | `2.5` | Physical overflow-weir crest, measured from the floor. At or above this level the basin is actually spilling. |
Constant cross-section is assumed: `surfaceArea = volume / height`. All derived volumes (`minVolAtOutflow`, `minVolAtInflow`, `maxVolAtOverflow`, `maxVol`) are computed once in `initBasinProperties()` and kept on `station.basin`.
The current runtime still uses the level fields directly for its volume math. Pipe diameters are part of the basin model contract so later hydraulic logic can reason about pipe invert/crown and not silently treat every pipe elevation as a centreline.
### Hydraulics (section `hydraulics`)
| Field | Default | Meaning |
|---|---|---|
| **Minimum Height Based On** | `outlet` | `outlet``minVol = outflowLevel × area` (includes the buffer). `inlet``minVol = inflowLevel × area` (buffer treated as unavailable). |
| **Reference Height** | `NAP` | Vertical datum: `NAP` / `EVRF` / `EGM2008`. Metadata only — not used in math today. |
| **Basin Bottom (m Refheight)** | `0` | Absolute elevation of the basin floor, for cross-basin comparisons. |
### Control (section `control`)
| Field | Default | Meaning |
|---|---|---|
| **Control mode** | `levelbased` | Active control strategy. Schema enumerates seven modes; today `levelbased` is fully implemented, `manual` forwards demand via `Qd`, others are placeholders. |
| **minLevel (m)** | `1` | Below this level → unconditional MGC shutdown. |
| **startLevel (m)** | `1` | Mode-specific threshold. In `levelbased`, this is the bottom of the linear scaling range (0 % demand). It is not part of the generic basin model because other modes can define a different start policy. |
| **maxLevel (m)** | `4` | Upper normal operating/storage level used by the active mode. In `levelbased`, this is where demand reaches 100 %. |
| **Flow setpoint** | `0` | Flow-based target (m³/h). Placeholder until `flowbased` is wired. |
| **Deadband** | `0` | Flow-based deadband (m³/h). Placeholder. |
### Safety (section `safety`)
| Field | Default | Meaning |
|---|---|---|
| **Time To Empty/Full (s)** | `0` | If > 0, triggers safety when predicted time-to-overflow or time-to-empty falls below this value. `0` disables time-based protection. |
| **Enable Dry-Run Protection** | `true` | If on, pumps are shut down once volume drops below the dry-run threshold while draining. |
| **Low Volume Threshold (%)** | `2` | Safety margin above the configured minimum volume: `dryRunSafetyVol = minVol × (1 + pct/100)`. This creates `dryRunLevel`; it is derived, not a separately entered basin height. |
| **Enable Overfill Protection** | `true` | If on, upstream inflows are shut down once volume climbs above the high-volume safety point while filling. |
| **High Volume Threshold (%)** | `98` | Safety margin below physical overflow: `highVolumeSafetyVol = maxVolAtOverflow × pct/100`. Actual overflowing is still the boolean condition `level >= overflowLevel`. |
### Output formats
- **Process Output** — format for Port 0 (`process` / `json` / `csv`).
- **Database Output** — format for Port 1 (`influxdb` / `json` / `csv`).
> **Tip — always configure every field.** The pumpingStation mixes geometry and control thresholds freely. Leaving `overflowLevel` at the schema default of 2.5 m while sizing the basin for 10 m walls produces nonsensical fill-percentages and spurious safety events. See the [EVOLV flow-layout rules §9](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md) for the completeness rule.
## Input topics
All commands enter on the single input port. `msg.topic` selects the handler; `msg.payload` carries the argument.
### `changemode`
```json
{ "topic": "changemode", "payload": "manual" }
```
Switches the active control strategy. The new mode must be in `config.control.allowedModes` — unknown values are rejected with a warning. Typical transitions: `levelbased ⇄ manual` for operator override during maintenance.
### `calibratePredictedVolume`
```json
{ "topic": "calibratePredictedVolume", "payload": 3.4 }
```
Hard-reset the predicted volume time-series to the supplied value (m³). Also rewrites the predicted level (derived from the constant-area geometry) and resets the internal flow-integrator state. Use this when a trustworthy measured level becomes available.
### `calibratePredictedLevel`
```json
{ "topic": "calibratePredictedLevel", "payload": 1.8 }
```
Same as above, but caller supplies a level (m). The predicted volume is recomputed via `volume = level × surfaceArea`.
### `q_in`
```json
{ "topic": "q_in", "payload": 300, "unit": "l/s" }
```
Inject a **manual inflow** into the basin. Registered as a predicted flow under the synthetic child `manual-qin` at position `in`. Useful when no physical inflow sensor is wired but the inflow is known externally (e.g. fed from a sewer model).
### `Qd`
```json
{ "topic": "Qd", "payload": 75 }
```
Forward a manual demand to every child aggregator (MGC first, then any direct pumps). **Only honoured when `config.control.mode === 'manual'`** — in any other mode the command is logged and discarded. Mirrors how `rotatingMachine` gates commands behind its mode field. The interpretation of the number depends on the child's scaling (`absolute` = m³/h, `normalized` = 0100 %).
### `registerChild`
Internal. Child nodes (measurements, rotatingMachines, machineGroupControls, nested pumpingStations) emit this on their Port 2 a few hundred ms after deploy. The station resolves the Node-RED node id back to the source object and registers it via `childRegistrationUtils`.
## Output ports
### Port 0 — process data
Delta-compressed payload (only changed fields per tick). Keys follow the standard 4-segment format `<type>.<variant>.<position>.<childId>` plus a handful of top-level state fields merged in by `getOutput()`:
| Key | Meaning |
|---|---|
| `volume.predicted.atequipment.default` | Running predicted volume from the flow integrator (m³). |
| `volume.measured.atequipment.default` | Volume derived from a `measured` level sensor (m³). |
| `level.predicted.atequipment.default` | Predicted level = `volume / area` (m). |
| `level.measured.<position>.<childId>` | Raw level sensor reading (m). |
| `volumePercent.predicted.atequipment.default` | `(vol - minVol) / (maxVolAtOverflow - minVol) × 100` (%). |
| `flow.predicted.in.<childId>` | Inflow contribution from a registered child (m³/s internally; editor unit on output). |
| `flow.predicted.out.<childId>` | Outflow contribution from a registered child. |
| `flow.measured.<position>.<childId>` | Flow sensor reading. |
| `netFlowRate.<variant>.atequipment.default` | Net flow used for control (inflow outflow). |
| `direction` | `filling` / `draining` / `steady` / `unknown`. |
| `flowSource` | Which variant drove the current control cycle (`measured`, `predicted`, `level:predicted`, `null`). |
| `timeleft` | Predicted seconds to overflow (while filling) or to dry-run (while draining). |
| `volEmptyBasin`, `inflowLevel`, `overflowLevel`, `maxVol`, `maxVolAtOverflow`, `minVol`, `minVolAtInflow`, `minVolAtOutflow`, `minHeightBasedOn` | Echoes of the basin geometry for dashboards. |
| `percControl` | Last demand (0100+ %) forwarded to the machine group during level-based control. |
Consumers must cache and merge deltas — the example dashboard flows include a reusable function node that does exactly this.
### Port 1 — dbase (InfluxDB)
Line-protocol payload for the `telemetry` bucket. Tags stay low-cardinality (station name, asset type); fields carry the numeric state. See [EVOLV — InfluxDB Schema Design](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/concepts/influxdb-schema-design.md).
### Port 2 — parent
`{ topic: "registerChild", payload: <this-node-id>, positionVsParent, distance }` — fired once ~100 ms after deploy so an upstream cascade can discover this station. Nested stations use this to register with an outer `pumpingStation` parent.
## Basin model
The basin is modelled as a rectangular prism with constant cross-section. Everything derives from `volume = level × surfaceArea`, with every level measured upward from the basin floor.
![Basin model — physical layout with control thresholds](diagrams/basin-model.drawio.svg)
*Editable source: [`diagrams/basin-model.drawio.svg`](diagrams/basin-model.drawio.svg) (drag into draw.io; the SVG embeds the editable source). See [`diagrams/README.md`](diagrams/README.md) for the edit-and-export workflow.*
**Generic basin ordering** (bottom → top): `outflowLevel ≤ dryRunLevel ≤ minLevel < inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel ≤ basinHeight`.
`startLevel` is deliberately not part of this generic basin diagram. It belongs to a control mode. For the current level-linear mode, see [`diagrams/modes/basin-mode-level-linear.drawio.svg`](diagrams/modes/basin-mode-level-linear.drawio.svg).
The pipe labels are intentional:
- `inflowLevel` is the bottom/invert of the incoming sewer pipe.
- `outflowLevel` is the top of the pump-suction/outlet pipe.
This avoids hiding hydraulic consequences behind ambiguous pipe-centre elevations. Pipe diameters are part of the model contract so later versions can derive pipe centre/crown/invert where needed.
`dryRunLevel` and `highVolumeSafetyLevel` are derived safety points. They provide margin before the two hard physical conditions:
- Actual dry-run risk is at or below the pumpable lower hydraulic reference.
- Actual overflowing is the boolean condition `level >= overflowLevel`.
The high-volume safety point exists so the station can still react before the basin is physically spilling. Once `overflowLevel` is reached, the model should report overflowing rather than treating that point as a controllable threshold.
**minHeightBasedOn** — which pipe defines `minVol`, the operational floor used for the initial seed, the dry-run trigger, and the 0 % point of the fill percentage:
```
outlet (default): inlet:
● maxVolAtOverflow ● maxVolAtOverflow
│ │
● inflowLevel ● inflowLevel ─── minVol
│ │
● outflowLevel ──── minVol ● outflowLevel
│ │
● floor ● floor
Buffer counts as usable stock. Buffer reserved; 0% fill
starts at the inlet.
```
The rectangular approximation is acceptable for this node's first basin model because operational level is always in metres from the basin floor, while calculated m³ can tolerate small shape errors. A later upgrade can replace `volume = level × surfaceArea` with a level-volume curve for benching, sumps, sediment/dead zones, and irregular wet-well geometry.
## Net-flow selection
Every tick, `_selectBestNetFlow()` walks a priority ladder and returns the first net flow that clears the dead-band (`|flow| ≥ flowThreshold`):
```
priority source note
1 ────● measured.flow real sensors on inflow/outflow
2 ────● predicted.flow manual q_in + pump-curve outputs
3 ────● level:measured dL/dt × surfaceArea
4 ────● level:predicted dL/dt of the integrator
5 ────● steady (fallback) warn, return { value: 0, source: null }
```
Both **measured** and **predicted** variants are always computed and stored, regardless of which one drives control. The active source surfaces on Port 0 as `flowSource`, so operators can watch sensor drift (measured diverges from predicted), validate the volume integrator, and diagnose "which source was active when X happened?".
The inflow / outflow alias map is deliberately wide so measurements (`upstream`/`downstream`) and predicted-flow subscriptions (`in`/`out`) both feed the same aggregator:
```js
flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] }
```
## Control logic
The `pumpingStation` supports multiple control modes. Each mode is a **policy that maps basin state to demand (0-100 %)**. `levelbased` uses `minLevel`, `startLevel`, and `maxLevel`; other modes may use different thresholds or compute them dynamically.
The basin model owns the shared physical and safety references: pipe elevations, `dryRunLevel`, `highVolumeSafetyLevel`, and `overflowLevel`. `startLevel` is mode-specific and is documented with the mode diagrams, not the generic basin drawing.
Every mode gets its own page under [`modes/`](modes/README.md) with a consistent layout (inputs, threshold policy, demand formula, edge cases) so they can be compared side-by-side. Currently:
| Mode | Status | Page |
|---|---|---|
| `levelbased` | ✅ implemented | [modes/levelbased.md](modes/levelbased.md) |
| `manual` | ✅ implemented (via `Qd` topic) | — |
| `flowbased`, `pressureBased`, `percentageBased`, `powerBased`, `hybrid` | 🚧 placeholder in code | — |
See [`modes/README.md`](modes/README.md) for the index and page template.
## Safety controller
`_safetyController` runs **before** `_controlLogic` every tick. Two rules, deliberately asymmetric — *dry-run protects the pumps from running themselves into air*, *high-volume protection tries to preserve distance to actual overflow*.
![Safety rules — dry-run vs overfill](diagrams/safety-rules.drawio.svg)
During high-volume or overflow conditions, level-based control naturally commands >=100 % on the downstream MGC because the level is above `maxLevel`.
> ⚠️ **Known limitation — gravity-sewer context.** The "upstream STOP" action only makes sense in a **cascaded** station layout where the upstream equipment is an EVOLV-controllable pump or station. In a conventional wastewater wet-well the inflow is gravity-fed from the municipal sewer and **cannot be stopped** — attempting to would back up toilets. For that case the correct response at the high-volume safety point is to alarm early and keep downstream pumps at maximum demand. If `level >= overflowLevel`, the station should report actual overflowing as a boolean and, later, estimate/log spill over the weir for compliance reporting. The current code fires `execSequence: shutdown` on upstream children regardless of what they are; that should be gated on "is the upstream actually controllable?" and supplemented with overflow-rate tracking. Tracked as follow-up work.
A missing volume reading is treated as a hard fault: every direct machine is sent `execSequence: shutdown` and `safetyControllerActive` latches. Calibrate predicted volume (`calibratePredictedVolume`) or wire a level measurement to recover.
## Registration — which children count as flow?
`_registerPredictedFlowChild` subscribes only to the *highest-level aggregator* to prevent double-counting.
```
Without MGC: With MGC:
[ PumpingStation ] [ PumpingStation ]
│ │ │ │
│ │ │ [ MGC ]
│ │ │ │ │ │
● ● ● ● ● ●
(each pump subscribed (only MGC is subscribed;
directly) MGC aggregates its pumps)
N flow subscriptions. 1 flow subscription.
Risk: double-count if an Pumps' flow is already
MGC is added later. inside the MGC total.
```
Measurement children register separately via `_registerMeasurementChild` and feed the `measured` variant — they never collide with the predicted-flow subscription. Nested `pumpingStation` children are always subscribed and expose their net flow at the parent's position.
## Node status badge
Updated every second by `_updateNodeStatus` in `nodeClass.js`:
```
⬆️ 42.3% | V=4.57 / 10.80 m³ | net: 180 m³/h | t≈12 min
```
| Symbol | Direction | Badge colour |
|---|---|---|
| ⬆️ | `filling` | blue |
| ⬇️ | `draining` | orange |
| ⏸️ | `steady` | green |
| ❔ | `unknown` / missing measurements | grey |
## Example flow
The canonical end-to-end demo lives in the EVOLV superproject at [`examples/pumpingstation-3pumps-dashboard/`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/examples/pumpingstation-3pumps-dashboard). It wires three `rotatingMachine` pumps beneath an MGC beneath a `pumpingStation`, with the dashboard layout rule set (see the [EVOLV flow-layout rules](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md)) — a useful template for any new station.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `fill %` exceeds 100 % or is negative | Basin geometry inconsistent — e.g. `overflowLevel > basinHeight`, or `outflowLevel > inflowLevel`. | Cross-check `0 < outflowLevel < inflowLevel < overflowLevel <= basinHeight` in the editor. |
| Pumps never start in `levelbased` | Level is stuck in the DEAD ZONE between `minLevel` and `startLevel`, or `startLevel == maxLevel` so the scaling range collapses. | Widen the mode control band. In sewer-gravity cases, `startLevel` is normally below `inflowLevel` so the station starts draining before the incoming sewer pipe is hydraulically affected. |
| "No volume data available to safe guard system; shutting down all machines." in logs | No measured level, predicted volume not calibrated, and no inflow/outflow samples yet. | Issue `calibratePredictedVolume` (or `calibratePredictedLevel`) once at startup, or wire a level sensor. |
| `flowSource: null` and `direction: 'steady'` forever | Every flow / level signal falls inside the dead-band (default `1e-4 m³/s`). | Confirm flows are non-zero, or lower `config.general.flowThreshold` for a small-scale demo. |
| `Qd` ignored | Station is not in `manual` mode. | Send `{ topic: 'changemode', payload: 'manual' }` first, or fall back to level-based control. |
| Pumps keep running during overfill | Intended — overfill safety only stops **upstream** equipment; downstream pumps must drain. | To override, switch to `manual` and set `Qd = 0`, or issue an emergency-stop at the MGC. |
| Predicted volume drifts away from measured | Flow integrator has no reference — flows might have the wrong sign, or `unit` is mis-declared. | Call `calibratePredictedVolume` periodically from a measured level. |
## Running it locally
```bash
git clone --recurse-submodules https://gitea.wbd-rd.nl/RnD/EVOLV.git
cd EVOLV
docker compose up -d
# Node-RED: http://localhost:1880 InfluxDB: :8086 Grafana: :3000
```
Then in Node-RED: **Import ▸ Examples ▸ EVOLV ▸ pumpingStation** (or open `examples/pumpingstation-3pumps-dashboard/flow.json`).
## Testing
```bash
cd nodes/pumpingStation
npm test
```
Unit tests live in `test/specificClass.test.js` — construction, basin derivation, measurement registration, net-flow selection, safety interlocks, and calibration.
## Related
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki) — atomic pump model beneath pumpingStation / MGC.
- [measurement wiki](https://gitea.wbd-rd.nl/RnD/measurement/wiki) — sensor conditioning for inflow, outflow, level, and pressure inputs.
- [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki) — how MGC coordinates multiple pumps.
- [EVOLV — Node Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/node-architecture.md) — the entry → nodeClass → specificClass pattern.
- [EVOLV — Group Optimization](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/group-optimization.md) — pump-group scheduling theory.
- [EVOLV — flow-layout rules](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md) — the lane / group / channel layout rules used by the demo flows.

38
wiki/modes/README.md Normal file
View File

@@ -0,0 +1,38 @@
# Control modes
Each page describes one `pumpingStation` control mode and how it uses the shared [basin model](../functional-description.md#basin-model) — specifically, how it uses the three control thresholds (`minLevel`, `startLevel`, `maxLevel`) and computes the demand it sends to the MGC.
The two **safety** thresholds (`dryRunLevel` and `overflowLevel`) are mode-independent and are enforced by the safety layer outside any mode. They never appear in a mode's policy.
## Template
Every mode page follows the same structure:
1. **At a glance** — one sentence + small fact table (inputs, output, status)
2. **Diagram** — one or more, per tier (see below)
3. **Inputs** — what signals the mode reads
4. **Threshold policy** — how it uses / adjusts `minLevel`, `startLevel`, `maxLevel`
5. **Demand formula** — pseudocode for Tier 1/2, objective function for Tier 3
6. **Edge cases** — cold start, sensor dropout, interaction with safety layer
7. **Related** — links to other modes + functional description
The three **tiers** classify modes by how dynamic the decision surface is:
| Tier | Curve | Example modes | Diagram type |
|---|---|---|---|
| **1** — static | Memoryless `demand = f(x)`; single curve | `levelbased`, `manual` | Single-curve transfer function |
| **2** — parameterised | Shape fixed, curve moves with θ(t) | `flowbased`, `pressureBased`, `percentageBased`, `powerBased` | Transfer function + parameter overlay / family |
| **3** — horizon-based | Optimisation, no fixed curve | `hybrid-optimal`, `mpc`, weather-aware | Block diagram of signal flow + scenario time-series |
## Implementation status
| Mode | Tier | Status | Page |
|---|---|---|---|
| `levelbased` | 1 | ✅ implemented | [levelbased.md](levelbased.md) |
| `manual` | 1 | ✅ implemented (via `Qd` topic) | — |
| `flowbased` | 2 | 🚧 code placeholder, template | [flowbased.md](flowbased.md) |
| `pressureBased` | 2 | 🚧 code placeholder | — |
| `percentageBased` | 2 | 🚧 code placeholder | — |
| `powerBased` | 2 | 🚧 code placeholder, template | [powerbased.md](powerbased.md) |
| `hybrid` | 3 | 🚧 code placeholder | — |
| `mpc` | 3 | 🚧 not in code yet, template | [mpc.md](mpc.md) |

83
wiki/modes/flowbased.md Normal file
View File

@@ -0,0 +1,83 @@
---
title: Flow-based mode
mode: flowbased
tier: 2
status: placeholder
updated: 2026-04-22
---
# Flow-based mode — *Tier 2 template*
> **Status — not yet implemented.** The `flowbased` entry is a placeholder in `_controlLogic`. This page reserves the shape and documents the intended design so all Tier-2 modes share the same layout.
## At a glance
| Item | Value |
|---|---|
| Tier | 2 — parameterised transfer function |
| Signal driving demand | measured outflow (actual pumps) |
| Secondary inputs | integrator + derivative state (for PID) |
| Output | demand 0100 % via PID correction |
| Thresholds adjusted at runtime? | No (but the demand can move independently of level) |
| Use when | The station has a flow sensor on the outlet and you want to hold a target outflow rate regardless of basin level |
## Diagram
**Primary plot.** Demand vs *outflow-error* (not level!) is the meaningful transfer function for flow-based control. The curve is a classic PID surface — proportional slope times error, plus integral + derivative terms.
**Secondary plot.** Level still enters as gates (STOP below `minLevel`, don't overfill above `maxLevel`) — same thresholds as levelbased, but the mode doesn't *use* level to pick demand.
```
Placeholder image — replace with:
diagrams/modes/flowbased.drawio.svg (demand vs outflow-error, showing Kp slope)
```
## Inputs
| Signal | Where from | Role |
|---|---|---|
| measured outflow | sum of `flow.measured.*` at outflow positions | error = (flowSetpoint measuredOutflow) |
| `config.control.flowBased.flowSetpoint` | editor, static | target outflow in m³/h |
| `config.control.flowBased.flowDeadband` | editor, static | zone around setpoint where PID output holds |
| `config.control.flowBased.pid.{kp, ki, kd, ...}` | editor / schema | PID gains + rate limits |
| current level | fallback → threshold gates | only used for `minLevel`/`maxLevel` bounds |
## Threshold policy
The **control** thresholds (`minLevel`, `startLevel`, `maxLevel`) are still enforced but for different reasons than levelbased:
| Threshold | Role in flowbased |
|---|---|
| `minLevel` | If level drops below, force demand=0 regardless of PID output (prevents pump undercut) |
| `startLevel` | unused — demand is driven by error, not level |
| `maxLevel` | If level climbs above, force demand=100 regardless of PID output (prevents spill) |
## Demand formula
```text
error = flowSetpoint measuredOutflow
if level < minLevel:
demand = 0 # pump-undercut guard
elif level > maxLevel:
demand = 100 # anti-spill guard
else:
# normal PID branch
P = Kp × error
I += Ki × error × dt # with anti-windup clamp
D = Kd × d(error)/dt # with low-pass filter
demand = clamp(P + I + D, 0, 100) # with rate limits Δup/Δdown
```
## Edge cases
- **Cold start, no prior outflow measurement.** PID state starts at 0; first error is `flowSetpoint`. Integral term will build up — rate-limit the demand ramp to avoid over-shoot.
- **Sensor dropout on the outflow meter.** Fall back to predicted outflow (sum of pump curve predictions). Log a warning — PID on predicted-only is unreliable.
- **Setpoint step change.** PID with derivative filter + rate limits handles this gracefully; without filter, the D-kick would saturate output.
- **Safety layer interaction.** Same as levelbased — `dryRunLevel` and `overflowLevel` override the PID output. See [functional description § Safety](../functional-description.md#safety-controller).
## Related
- [Functional description](../functional-description.md) — basin model + shared safety layer
- [modes/README.md](README.md) — mode index + page template
- [modes/levelbased.md](levelbased.md) — Tier 1 reference implementation

84
wiki/modes/levelbased.md Normal file
View File

@@ -0,0 +1,84 @@
---
title: Level-based mode
mode: levelbased
status: implemented
updated: 2026-04-22
---
# Level-based mode
The simplest and most widely deployed control strategy. Demand is a direct, *static* piecewise-linear function of basin level — no feedback loop, no predictions beyond the level measurement itself. This page uses the [shared basin model](../functional-description.md#basin-model); see [`modes/README.md`](README.md) for the template other mode pages follow.
## At a glance
| Item | Value |
|---|---|
| Signal driving demand | basin level (measured, predicted fallback) |
| Output | demand 0100 % forwarded to every MGC child |
| Thresholds adjusted at runtime? | No — static from editor config |
| Use when | Inflow is sewer-gravity (no smart metering) and operator wants a predictable, inspectable response |
## Diagram
![Level-linear basin mode — demand vs level transfer function](../diagrams/modes/basin-mode-level-linear.drawio.svg)
*Editable source: [`../diagrams/modes/basin-mode-level-linear.drawio.svg`](../diagrams/modes/basin-mode-level-linear.drawio.svg) (drag into [draw.io](https://app.diagrams.net/) — it round-trips).*
## Inputs
| Signal | Where from | Role |
|---|---|---|
| current level | `measurement` child (`measured`) → predicted from volume integrator (fallback) | X-axis of the transfer function |
| `config.control.levelbased.minLevel` | editor, static | below → pumps hard OFF |
| `config.control.levelbased.startLevel` | editor, static | where demand-ramp starts |
| `config.control.levelbased.maxLevel` | editor, static | where demand saturates at 100 % |
The three control thresholds are the **only** mode-specific configuration. Nothing here is recomputed at runtime.
## Threshold policy
| Threshold | Source | Adjustable at runtime? |
|---|---|---|
| `minLevel` | `config.control.levelbased.minLevel` | No |
| `startLevel` | `config.control.levelbased.startLevel` | No |
| `maxLevel` | `config.control.levelbased.maxLevel` | No |
That this policy is trivial (all static) is **the defining simplicity of this mode**. Modes like `powerBased` or future `weather-aware` variants will recompute these thresholds on the fly.
## Demand formula
```text
if level < minLevel:
demand = 0
MGC → turnOffAllMachines() # explicit shutdown, not just "0 %"
elif level < startLevel:
demand = <previous demand> # dead zone — hold last command (hysteresis)
elif level <= maxLevel:
demand = lerp(level, [startLevel, maxLevel], [0 %, 100 %])
else:
demand = 100 % # saturated; MGC clamps internally if overshoot
```
Where `lerp` is linear interpolation. The MGC is free to distribute the demand across its pumps however its own policy dictates (equal split, lead-lag, staging — that's the MGC's business).
## Edge cases
- **Cold start with level in the dead zone.** `demand` has no prior value; it defaults to `0`. Pumps stay OFF until the level first crosses `startLevel` upward. Once it does, normal ramp-and-hold behaviour engages.
- **Level sensor drops out mid-run.** `_selectBestNetFlow` falls back to predicted level (computed from the volume integrator) — the mode doesn't care which variant wins, it just reads the chosen level.
- **Both sensor and predictor unavailable.** The mode's preconditions fail; `_controlLogic` logs a warning and exits without issuing a command. The last-known demand is held, which is safe.
- **Level crosses `maxLevel` upward.** Demand saturates at 100 %. Level may still continue rising if inflow > station capacity — this is the scenario that trips the overflow-safety layer (see below).
- **Level crosses `dryRunLevel` downward.** The **safety layer** (not this mode) force-shuts all downstream pumps regardless of what demand the mode is commanding. The mode's demand is effectively overridden until level climbs back above `dryRunLevel + hysteresis_margin`.
- **Level crosses `overflowLevel` upward.** The safety layer logs the spill event and raises an alarm. The mode continues commanding at 100 % — which is what you want, because the pumps should keep draining as fast as physically possible. (See [functional description § Safety controller](../functional-description.md#safety-controller) for the gravity-sewer caveat.)
## Why this is worth migrating off of
Level-based is fine for steady-state sewer inflows. It has two known weaknesses:
1. **Predictable, not proactive.** It can't *pre-empty* the basin ahead of a forecasted storm or a power-price peak. Modes like `weather-aware` or `powerBased` can — by moving `startLevel` down or up at runtime.
2. **Thresholds assume pump capacity is fixed.** If you add or remove pumps, the `startLevel ↔ maxLevel` band that gave smooth 0-100 % coverage no longer matches the new capacity. Flow-based and percentage-based modes are less brittle to capacity changes because they close the loop on *what you actually measure* (outflow or fill %) rather than *what you assume the level→capacity map is*.
## Related
- [Functional description](../functional-description.md) — basin model, net-flow selection, safety layer (shared across all modes)
- [modes/README.md](README.md) — mode index + template
- Other mode pages: *to be written* (`flowbased.md`, `pressurebased.md`, `percentagebased.md`, `powerbased.md`, `hybrid.md`, `manual.md`)

149
wiki/modes/mpc.md Normal file
View File

@@ -0,0 +1,149 @@
---
title: MPC (Model-Predictive Control)
mode: mpc
tier: 3
status: placeholder
updated: 2026-04-22
---
# MPC mode — *Tier 3 template*
> **Status — not yet implemented.** Not even in the schema today. This page reserves the shape for when the time comes.
## Why this is Tier 3
The levelbased/flowbased/powerBased modes are all **memoryless or near-memoryless transfer functions**. You give them the current state; they give you a demand. You can draw them as 2D plots.
MPC is different. At each tick the controller solves an optimisation over a prediction horizon:
```
minimise Σ cost(state(t+k), command(t+k)) for k = 0 .. N
subject to forecast, physical limits, power budget, spill penalty, ...
```
The *command* that's emitted at time `t` is merely the first step of that plan; next tick the forecast shifts and the optimiser re-runs. There's no fixed `demand = f(level)` curve — the curve is remade every tick.
That's why Tier-3 modes get **block diagrams + scenario time-series**, not transfer functions.
## At a glance
| Item | Value |
|---|---|
| Tier | 3 — optimisation-based |
| Signal driving demand | full state (level, flow, power) + **forecasts** (inflow, grid price, weather) |
| Secondary inputs | cost weights, horizon length, solver config |
| Output | demand + planned trajectory over horizon |
| Thresholds adjusted at runtime? | Effectively yes — the optimiser treats them as soft constraints |
| Use when | Available forecasts beat reactive control, or multi-objective optimisation is needed |
## Diagram 1 — signal flow (block diagram)
```
Placeholder image — replace with:
diagrams/modes/mpc-block.drawio.svg
Blocks:
[sensors] [inflow forecast] [grid price] [weather API]
│ │ │ │
└─────────────┴──────────────────┴──────────────┘
┌─────▼──────┐
│ state + │
│ forecast │
│ bundle │
└─────┬──────┘
┌─────▼───────────────────┐
│ MPC solver │
│ • horizon N │
│ • cost weights w │
│ • constraints C │
│ • linearised model │
└─────┬───────────────────┘
┌─────▼───────┐
│ command[0] │ ── the step we act on now
│ command[1] │
│ ... │
│ command[N] │ ── re-planned next tick
└─────┬───────┘
┌─────────▼─────────┐
│ safety layer clip │ ← dryRun / overflow always apply
└─────────┬─────────┘
demand → MGC
```
## Diagram 2 — scenario time-series
A much more useful way to evaluate MPC is to plot *what it did* over a simulated scenario: level, planned vs actual demand, the cost function breakdown, the active constraints. The [simulations harness](../../simulations/README.md) is built for exactly this — MPC will need a dedicated scenario like `mpc-storm-with-forecast.js`.
```
Placeholder — replace with:
diagrams/modes/mpc-scenario.drawio.svg
Stacked time-series showing:
1. basin level over time (with forecast shadow and horizon)
2. demand over time (with the re-planning edges visible)
3. cost breakdown: energy vs spill-penalty vs ramp-penalty
4. active constraints over time (colored bands)
```
## Inputs
| Signal | Where from | Role |
|---|---|---|
| current state | `measurements` container | initial condition for optimiser |
| inflow forecast | external — sewer model / weather API | drives the cost integral |
| grid-price forecast | external — market feed / schedule | weights energy cost |
| cost weights `w` | config | trades off spill vs energy vs ramp |
| horizon `N` | config | 1560 minutes typical |
| model parameters | config / learned | basin dynamics, pump curves |
## Threshold policy
Levels appear in the optimiser as **soft constraints** (penalties in the cost function):
| Threshold | Role in MPC |
|---|---|
| `dryRunLevel`, `overflowLevel` | hard constraints — if the optimiser's plan crosses them, safety layer clips |
| `minLevel`, `maxLevel` | soft constraints — penalty weight `w_level` applied to excursions |
| `startLevel` | advisory only — optimiser doesn't inherently care, but may be used in cost weights for rule-of-thumb alignment with human expectations |
So unlike Tier-1/2 where thresholds directly gate the action, here they shape the objective.
## Demand formula
Not a formula — an optimisation problem:
```text
state, forecast, constraints = gather_inputs()
plan = mpc_solver.solve(
state0 = state,
forecast = forecast,
horizon = N,
model = basin_dynamics + pump_curves,
cost = w_energy × Σ power(k)
+ w_spill × Σ max(0, level(k) overflowLevel)²
+ w_undercut × Σ max(0, minLevel level(k))²
+ w_ramp × Σ (command(k) command(k-1))²,
constraints = pump_limits + power_budget + rate_limits,
)
demand = plan.command[0]
```
## Edge cases
- **Solver timeout.** Fall back to the previous plan's step, or to a levelbased curve as a safe default. Log.
- **Bad forecast (persistent bias).** Optimiser can chase a wrong prediction for many ticks. Adaptive forecast bias correction, or a watchdog comparing forecast-vs-realised, is essential.
- **Infeasibility.** If constraints can't be satisfied (e.g. power budget and maxLevel simultaneously during a severe storm), relax soft constraints in priority order (ramp first, then maxLevel, then energy) — never relax dryRun/overflow.
- **Safety takeover.** The safety layer still overrides. MPC should *anticipate* safety trips in its cost function (big penalty for trajectories that invoke them), not hit them.
## Related
- [Functional description](../functional-description.md) — basin model + safety layer
- [modes/levelbased.md](levelbased.md) — Tier 1 — the "default" MPC falls back to
- [modes/powerbased.md](powerbased.md) — Tier 2 — MPC generalises the clip idea into full optimisation
- [simulations/README.md](../../simulations/README.md) — where MPC simulation scenarios will live

83
wiki/modes/powerbased.md Normal file
View File

@@ -0,0 +1,83 @@
---
title: Power-based mode
mode: powerBased
tier: 2
status: placeholder
updated: 2026-04-22
---
# Power-based mode — *Tier 2 template*
> **Status — not yet implemented.** Placeholder. This page documents the intended shape of a grid-aware / netcongestion-aware station.
## At a glance
| Item | Value |
|---|---|
| Tier | 2 — parameterised transfer function |
| Signal driving demand | basin level (primary), **max-power budget** (clip) |
| Secondary inputs | measured pump power, live grid-price / peak-hours signal |
| Output | demand 0100 % clipped so `Σ pump power ≤ maxPowerKW(t)` |
| Thresholds adjusted at runtime? | `maxPowerKW(t)` yes — level thresholds no |
| Use when | Grid has peak-hour tariffs or net-congestion caps |
## Diagram — the levelbased curve with a moving clip ceiling
```
demand % ← dashed line: levelbased curve
100 ┤ ─────── ← solid: clip at powerBudget(t)
clip lowers
during grid peak
─────────
0 ┼────────●───────●─────────────────────► level
startLevel maxLevel
↑ the family of curves:
clip=100% (grid idle),
clip=70% (shoulder),
clip=40% (peak).
```
The *shape* stays levelbased; the *ceiling* drops when the grid is strained. That's the Tier-2 signature: same input axis, parameter shifts the curve.
## Inputs
| Signal | Where from | Role |
|---|---|---|
| current level | as in levelbased | primary input |
| `config.control.powerBased.maxPowerKW` | editor, static | hard cap on station power |
| `config.control.powerBased.powerControlMode` | `limit` / `optimize` | whether to just clip or to schedule |
| live grid signal (future) | external topic or forecast | modulates the cap over time |
| measured pump power | `power.measured.*` from children | real-time feedback against the cap |
## Threshold policy
Level thresholds (`minLevel`, `startLevel`, `maxLevel`) are **identical to levelbased** — they define the shape of the underlying curve. What's new is a runtime-varying ceiling `demandCap(t)` derived from the power budget.
`demandCap(t) = 100 × (maxPowerKW(t) / nominalStationPowerAtFull)` — where `maxPowerKW(t)` may come from config (static `limit` mode) or an external grid-price feed (dynamic).
## Demand formula
```text
rawDemand = levelbasedDemand(level) # the underlying Tier-1 curve
demandCap = min(100, 100 × maxPowerKW(t) / nominalStationPower)
demand = min(rawDemand, demandCap)
```
When `demandCap < rawDemand`, the mode sacrifices drainage rate to stay within power budget. Level may rise — the overfill safety layer still applies as the last line of defence.
## Edge cases
- **Peak hour with rising level.** demandCap drops faster than level rises → demand gets clipped; level approaches `overflowLevel`. If overfill safety trips, it overrides the clip (safety wins).
- **Power signal dropout.** Fall back to static `maxPowerKW` from config; log warning.
- **Grid exit from peak while basin is nearly full.** demandCap jumps back to 100; PID is memoryless so demand rises in one tick to match rawDemand.
- **Measured vs predicted pump power.** Cap is enforced on predicted (decisions are made before the pump responds). Reconcile against measured for logging/diagnostics.
## Related
- [Functional description](../functional-description.md)
- [modes/levelbased.md](levelbased.md) — Tier 1 reference (the curve that powerBased clips)
- [modes/flowbased.md](flowbased.md) — other Tier-2 example with different control variable