Compare commits

...

51 Commits

Author SHA1 Message Date
Rene De Ren
e2ebb31816 stopLevel Schmitt-trigger hysteresis + dead-zone keep-alive
Levelbased control now distinguishes startLevel (rising-edge engage,
ramp foot) from stopLevel (falling-edge disengage). _stopHystRunning
flag flips TRUE crossing startLevel up, FALSE crossing stopLevel down.
While engaged AND level inside [stopLevel, startLevel] (basin draining
through the dead band), emit a configurable keep-alive percControl
(default 1 %) so MGC keeps a single pump running for a full drain
stroke instead of oscillating at startLevel.

Hard turn-off the moment level <= stopLevel — independent of ramp
scaling. Manual-mode demand=0 now also issues explicit turnOff to
keep parity with the new MGC handleInput semantics where demand<=0
means "off".

Editor preview shades the new hysteresis band; admin endpoint
exposes runtime engaged state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:20:36 +02:00
Rene De Ren
6ab585bcc2 Docs + simulations refresh; align spill-flow keys with new position
- wiki/functional-description.md: rename Overfill Protection → High-volume
  Safety; tighten basin-ordering chain; relocate level-based mode
  diagrams under wiki/diagrams/modes/level-based/; document the new
  flow.predicted.overflow.default position (replaces the previous
  child='overflow' under position 'out'); add underflowVolume +
  predictedUnderflowVolume entries.
- wiki/modes/{levelbased,powerbased}.md: paragraph cleanups.
- wiki/diagrams: move level-linear basin diagram under modes/level-based/
  alongside a new level-log variant.
- simulations/run.js: add max_demand_gt expectation.
- simulations/scenarios/*: minor fixture updates.
- test/basic/nodeClass-config.test.js: new config-shape coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:23:20 +02:00
Rene De Ren
d8490aa949 Predicted-volume hard-floor at 0 + spill flow position refactor
Volume integrator changes:
- Hard physical floor at 0 added to _updatePredictedVolume. Without
  it, a basin seeded below dryRunSafetyVol (calibration / startup
  / low seed) under continued net-outflow drifted volume arbitrarily
  negative; the level output looked clamped only because
  _calcLevelFromVolume floors at 0, masking the underlying drift.
- New cumulative diagnostic: underflowVolume.predicted.atequipment
  (m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a
  flow-balance error (over-reported outflow / missing inflow).
- The transition-only dryRunSafetyVol clamp is preserved so
  startup-from-empty doesn't snap to 2.1 m³ on tick 1.

Spill flow refactor (taxonomic + bug fix):
- Synthetic spill moved from flow.predicted.out.<child='overflow'>
  to its own position flow.predicted.overflow.<default>. The spill
  is a derived quantity, not a physical sub-source sharing a position
  with pumps — .child() was the wrong knob.
- Removes the spillPrev self-subtraction in the integrator (no longer
  needed: outflowTotal at ['out','downstream'] cleanly excludes spill).
- Closes a latent fall-through bug exposed during this work:
  .child('overflow').getCurrentValue() returned the value of any
  available sibling child when overflow itself didn't yet exist.
  Hardened separately in generalFunctions@a516c2b.
- _selectBestNetFlow folds the overflow position into the outflow
  side so the predicted net-flow balance still reads ~0 while pinned.

Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated
underflow tracking, getOutput surface, and refill-from-empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:23 +02:00
Rene De Ren
6b46a8a8f0 Predicted-volume overflow clamp + spill tracking
Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow]
in _updatePredictedVolume — the integrator can no longer drift above
the weir crest (only a real measurement can show level > overflow,
e.g. inflow exceeding pump+weir capacity). Excess is recorded as:

  - overflowVolume.predicted.atequipment.default — cumulative spill (m3)
  - flow.predicted.out.overflow — instantaneous spill rate (m3/s),
    registered as a synthetic outflow so net-flow balance reads ~0
    while pinned. The integrator subtracts the prior tick's synthetic
    flow before integrating so it never feeds back into volume math.

Lower clamp at dryRunSafetyVol fires only on the transition — a low
seed/calibration is left alone; inflow is what brings it back up.

_selectBestNetFlow holds the last non-zero level-rate net flow when
level pins at overflowLevel and dL/dt collapses to 0, so dashboards
keep showing roughly what's coming in. Auto-refreshes once level
drops.

getOutput() exposes predictedOverflowVolume + predictedOverflowRate
as top-level convenience keys; the underlying measurements flow to
InfluxDB via the standard MeasurementContainer flatten path.

9 new test assertions cover the upper-clamp + spill increment, stable
spill across ticks, net-flow ~0 while pinned, spill clearing when
inflow stops, low-seed left alone, drain-across-threshold clamp, and
the new top-level output keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
Rene De Ren
62bc73f2f9 Editor: dynamic input bounds + full hierarchy validation, layout polish
Bounds (new src/editor/bounds.js):
- Sets HTML5 min/max on every level + percent input each redraw,
  derived from the current values of related inputs so the spinner
  stops at the basin hierarchy:
  0 < outflowLevel < dryRunLevel < startLevel ≤ inflowLevel
      ≤ shiftLevel ≤ maxLevel ≤ overflowLevel ≤ basinHeight
- dryRunPercent capped so dryRunLevel ≤ startLevel given current outflow.
- shiftArmPercent ∈ [1, 100]; highVolumeSafety% ∈ [1, 100].

Validation:
- New visible ribbon above the basin diagram (#ps-basin-validation)
  listing every hierarchy violation. The in-SVG warning text is now a
  small reminder ("⚠ N ordering issues").
- basin-diagram.js owns hierarchy issues; mode-preview.js trimmed to
  only own shift-specific issues (shift > start, shift ≤ max,
  shiftArmPercent range, shiftLevel required-when-enabled).
- oneditsave blocks Deploy on the union of _psBasinValidationIssues
  and _psModeValidationIssues with a RED.notify listing all problems.

Layout polish:
- Side panel widened to 220 px with minmax(0, 1fr) first column so long
  labels can no longer push the rows past the panel edge.
- Basin SVG max-width 380 → 360, gap between side panel and SVG bumped
  14 → 28 px. Tank shifted right (x=145 width=110) so the inlet
  "bottom of pipe" sub-label is no longer clipped on the left edge.
- "0 m (datum)" moved below the tank (y=395, centred) so it can't
  collide with "Outlet / top of pipe" when outflowLevel is near floor.
- Zone labels shortened (Spare / Sewage + buffer / Buffer / Dead vol)
  and only show when the bracketing thresholds are ≥ 28 px apart, so
  they never sit on a threshold label.
- Mode preview axis labels under the chart removed — line colour +
  side-panel labels + hover-couple already identify each line. Stub
  <text> elements left hidden to keep the redraw loop simple. Arm-%
  line + label trimmed in 10 px on the right so they're not clipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:10:22 +02:00
Rene De Ren
de9a79b888 Hold-then-ramp shift semantics + shiftArmPercent + e2e tests
Runtime (specificClass.js):
- Replace the "shift left both ramp ends" geometry with a true
  hold-then-ramp hysteresis driven by output %, not level:
  • Up-curve % crosses shiftArmPercent on the way up → ARM.
  • Filling→draining transition while armed → capture the up-curve %
    at that moment as _shiftHoldValue.
  • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue
    (horizontal hold, matching the dashed segment in the SVG).
  • Draining + level in [start, shift] → output ramps holdValue → 0 %
    along the same curve shape (linear or log) as the up curve.
  • Draining + level < startLevel → 0 % AND disarm.
  • Returning to filling clears holdValue, stays armed; next drain
    transition captures a fresh hold so bouncing fills rearm cleanly.
  • Disarm only when level ≤ startLevel.
- New _curveShape(x) helper for shared linear/log shaping.
- Removed legacy _levelBasedRampStart / _levelBasedRampTop /
  _updateShiftArmed in favour of the inline state machine.

Adapter (nodeClass.js):
- Pipe shiftArmPercent through to control.levelbased.

Editor (pumpingStation.html + src/editor/):
- Add shiftArmPercent input row (% with unit) to the mode side panel
  (only shown when shifted ramp is enabled). Default 95 %.
- Add the horizontal arming-% line + label inside the mode SVG —
  this is the "% Threshold triggering shifted ramp down" line from
  the original drawing that had been missing.
- Redraw the shifted-down curve to match the SVG geometry literally:
  100 % flat from maxLevel → shiftLevel, then ramp shiftLevel →
  startLevel down to 0 %, OFF below startLevel. Preview shows the
  worst-case envelope (hold = 100 %); runtime hold is captured live.
- Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules
  preserved (start < shift ≤ max etc.).
- Auto-default shiftArmPercent to 95 when shift is enabled and the
  current value is missing or out of range.

Dashboard example (examples/basic-dashboard.flow.json):
- Parser now reads `level.predicted.atequipment.default` etc. The
  MeasurementContainer flatten format includes the implicit 'default'
  childId; consumers must include it. Comment in the parser points
  at the documenting source in generalFunctions.

Tests:
- test/basic: replace old level-armed-shift tests with two new ones
  that exercise the hold-then-ramp arming, capture, hold, ramp-down,
  disarm, and the bounce case (filling→draining→filling→draining
  captures a fresh hold each time).
- test/integration/shifted-ramp-end-to-end.test.js: new file. Drives
  Q_IN/Q_OUT through the full runtime tick with a controllable clock,
  asserting the same hysteresis path the dashboard exercises.
- test/integration/basic-dashboard-flow.test.js: fixture keys updated
  to the .default-suffixed form so they match the real flatten output.
56/56 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
Rene De Ren
8a6ca1baeb Level-armed shift, derived dryRunLevel, side-panel editor + manual q_out
Runtime (specificClass.js):
- Replace direction-based hysteresis with level-armed _shiftArmed state.
  Arms when level rises past shiftLevel; disarms when level drops below
  startLevel. While armed, ramp foot moves to startLevel and ramp top
  to shiftLevel — both ends shift left, then saturate at 100 % up to
  maxLevel.
- _scaleLevelToFlowPercent now takes (rampStartLevel, rampTopLevel) so
  the saturation point follows the shift state.
- New setManualOutflow mirroring setManualInflow.

Adapter (nodeClass.js):
- Pipe enableShiftedRamp / shiftLevel through to control.levelbased.
- New q_out topic handler.

Editor (pumpingStation.html + new src/editor/ modules):
- Split monolithic <script> into modules: index.js (helpers),
  basin-diagram.js, mode-preview.js, hover-couple.js, oneditprepare.js,
  oneditsave.js — served via /pumpingStation/editor/:file.
- Mode preview redrawn per the SVG diagrams: OFF tier below 0 %, 0 %
  flat from start→inlet, ramp inlet→max, optional shifted-down curve
  start→shift with 100 % saturation past shift.
- Mode preview gains zone bands (dryRun / safetyLow / safe / safetyHigh /
  overflow), level markers (dryRun derived, start, inlet, max, shift,
  overflow), validation ribbon that blocks save on bad ordering.
- Auto-default shiftLevel to 0.9 × maxLevel on enable so the marker is
  always visible.
- All level inputs moved to a side panel left of each diagram, color-
  coded to match line strokes; hover-couple highlights the paired SVG
  line on input focus / mouseover.
- Removed UI for non-static parameters: minHeightBasedOn,
  pipelineLength, maxDischargeHead, staticHead, defaultFluid,
  maxInflowRate, temperatureReferenceDegC,
  timeleftToFullOrEmptyThresholdSeconds, inletPipeDiameter,
  outletPipeDiameter, minLevel (now derived = dryRunLevel).
- foreignObject inputs in basin SVG removed (single source of truth in
  side panel).

Dashboard example (examples/basic-dashboard.flow.json):
- Add manual Q_OUT slider + q_out builder mirroring the existing q_in
  trio so the basin can be exercised end-to-end without a connected
  rotating-machine downstream.

Tests (test/basic/specificClass.test.js):
- Replace direction-shift test with two new cases covering shift-disabled
  hold-zone behaviour and shift-armed/disarmed transitions through
  shiftLevel and startLevel boundaries. 53/53 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:29:34 +02:00
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
38 changed files with 6111 additions and 774 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,10 @@
# 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. For v1.0 the editor exposes `levelbased` and `manual`; levelbased supports linear and log curves with separate rising/falling ramp semantics.
- [`wiki/diagrams/basin-model.drawio.svg`](wiki/diagrams/basin-model.drawio.svg) is the current source of truth for the generic basin model.
- [`examples/basic-dashboard.flow.json`](examples/basic-dashboard.flow.json) provides a simple Node-RED Dashboard 2 flow with level, volume, demand, net-flow, and safety-state trends.

View File

@@ -0,0 +1,589 @@
[
{
"id": "ps_tab_basic_dashboard",
"type": "tab",
"label": "PumpingStation Dashboard",
"disabled": false,
"info": "Basic level-based pumpingStation dashboard with basin trends and safety state."
},
{
"id": "ui_base_ps_basic",
"type": "ui-base",
"name": "EVOLV Demo",
"path": "/dashboard",
"appIcon": "",
"includeClientData": true,
"acceptsClientConfig": [
"ui-notification",
"ui-control"
],
"showPathInSidebar": false,
"headerContent": "page",
"navigationStyle": "default",
"titleBarStyle": "default"
},
{
"id": "ui_theme_ps_basic",
"type": "ui-theme",
"name": "EVOLV Pumping Theme",
"colors": {
"surface": "#ffffff",
"primary": "#0c99d9",
"bgPage": "#f1f3f5",
"groupBg": "#ffffff",
"groupOutline": "#cfd7de"
},
"sizes": {
"density": "default",
"pagePadding": "14px",
"groupGap": "14px",
"groupBorderRadius": "6px",
"widgetGap": "12px"
}
},
{
"id": "ui_page_ps_basic",
"type": "ui-page",
"name": "PumpingStation",
"ui": "ui_base_ps_basic",
"path": "/pumping-station",
"icon": "water_drop",
"layout": "grid",
"theme": "ui_theme_ps_basic",
"breakpoints": [
{
"name": "Default",
"px": "0",
"cols": "12"
}
],
"order": 1,
"className": ""
},
{
"id": "ui_group_ps_inputs",
"type": "ui-group",
"name": "Simulation Inputs",
"page": "ui_page_ps_basic",
"width": "4",
"height": "1",
"order": 1,
"showTitle": true,
"className": ""
},
{
"id": "ui_group_ps_trends",
"type": "ui-group",
"name": "Basin Trends",
"page": "ui_page_ps_basic",
"width": "8",
"height": "1",
"order": 2,
"showTitle": true,
"className": ""
},
{
"id": "ui_group_ps_state",
"type": "ui-group",
"name": "State",
"page": "ui_page_ps_basic",
"width": "12",
"height": "1",
"order": 3,
"showTitle": true,
"className": ""
},
{
"id": "ps_node_basic",
"type": "pumpingStation",
"z": "ps_tab_basic_dashboard",
"name": "PS Dashboard Demo",
"basinVolume": 50,
"basinHeight": 5,
"inflowLevel": 3,
"outflowLevel": 0.2,
"overflowLevel": 4.5,
"defaultFluid": "wastewater",
"inletPipeDiameter": 0.4,
"outletPipeDiameter": 0.3,
"pipelineLength": 80,
"maxDischargeHead": 24,
"staticHead": 12,
"maxInflowRate": 200,
"temperatureReferenceDegC": 15,
"timeleftToFullOrEmptyThresholdSeconds": 0,
"enableDryRunProtection": true,
"enableHighVolumeSafety": true,
"enableOverfillProtection": true,
"dryRunThresholdPercent": 2,
"highVolumeSafetyThresholdPercent": 98,
"overfillThresholdPercent": 98,
"minHeightBasedOn": "outlet",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"refHeight": "NAP",
"basinBottomRef": 0,
"unit": "m3/h",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"controlMode": "levelbased",
"levelCurveType": "linear",
"logCurveFactor": 9,
"minLevel": 1,
"startLevel": 2,
"maxLevel": 4,
"x": 720,
"y": 260,
"wires": [
[
"ps_parse_output"
],
[
"ps_debug_influx"
],
[
"ps_debug_parent"
]
]
},
{
"id": "ps_calibrate_initial",
"type": "inject",
"z": "ps_tab_basic_dashboard",
"name": "Set start level 2 m",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "0.5",
"topic": "calibratePredictedLevel",
"payload": "2",
"payloadType": "num",
"x": 180,
"y": 180,
"wires": [
[
"ps_node_basic"
]
]
},
{
"id": "ps_auto_inflow",
"type": "inject",
"z": "ps_tab_basic_dashboard",
"name": "Auto inflow 0.008 m3/s",
"props": [
{
"p": "payload"
}
],
"repeat": "1",
"crontab": "",
"once": true,
"onceDelay": "1",
"topic": "",
"payload": "0.008",
"payloadType": "num",
"x": 180,
"y": 240,
"wires": [
[
"ps_build_qin"
]
]
},
{
"id": "ps_inflow_input",
"type": "ui-number-input",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_inputs",
"name": "Inflow",
"label": "Inflow (m3/s)",
"order": 1,
"width": "4",
"height": "1",
"passthru": true,
"topic": "",
"min": 0,
"max": 0.05,
"step": 0.001,
"x": 190,
"y": 300,
"wires": [
[
"ps_build_qin"
]
]
},
{
"id": "ps_build_qin",
"type": "function",
"z": "ps_tab_basic_dashboard",
"name": "Build q_in",
"func": "msg.topic = 'q_in';\nmsg.unit = 'm3/s';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 440,
"y": 260,
"wires": [
[
"ps_node_basic"
]
]
},
{
"id": "ps_outflow_input",
"type": "ui-number-input",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_inputs",
"name": "Outflow",
"label": "Outflow (m3/s)",
"order": 2,
"width": "4",
"height": "1",
"passthru": true,
"topic": "",
"min": 0,
"max": 0.05,
"step": 0.001,
"x": 190,
"y": 360,
"wires": [
[
"ps_build_qout"
]
]
},
{
"id": "ps_build_qout",
"type": "function",
"z": "ps_tab_basic_dashboard",
"name": "Build q_out",
"func": "msg.topic = 'q_out';\nmsg.unit = 'm3/s';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 440,
"y": 360,
"wires": [
[
"ps_node_basic"
]
]
},
{
"id": "ps_parse_output",
"type": "function",
"z": "ps_tab_basic_dashboard",
"name": "Parse PS output",
"func": "// MeasurementContainer flat keys are `${type}.${variant}.${position}.${childId}`.\n// When PS writes without an explicit .child(), the childId is the literal\n// string 'default' — DON'T strip it. See generalFunctions/src/measurements/\n// MeasurementContainer.js getFlattenedOutput for details.\nconst fields = (msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst snapshot = Object.assign({}, context.get('snapshot') || {}, fields);\ncontext.set('snapshot', snapshot);\nconst firstFinite = (...keys) => {\n for (const key of keys) {\n const value = Number(snapshot[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\nconst level = firstFinite('level.predicted.atequipment.default', 'level.measured.atequipment.default');\nconst volume = firstFinite('volume.predicted.atequipment.default', 'volume.measured.atequipment.default');\nconst netFlow = firstFinite('netFlowRate.predicted.atequipment.default', 'netFlowRate.measured.atequipment.default');\nconst demand = firstFinite('percControl');\nconst safety = snapshot.safetyState || 'normal';\nconst direction = snapshot.direction || 'unknown';\nconst overflow = snapshot.isOverflowing === true || snapshot.isOverflowing === 'true';\nconst timeleft = Number(snapshot.timeleft);\nconst fmt = (value, digits = 2) => Number.isFinite(value) ? value.toFixed(digits) : '-';\nreturn [\n level == null ? null : { topic: 'level', payload: level },\n volume == null ? null : { topic: 'volume', payload: volume },\n demand == null ? null : { topic: 'demand', payload: demand },\n netFlow == null ? null : { topic: 'net_flow', payload: netFlow },\n { topic: 'safety', payload: `${safety} | overflowing=${overflow}` },\n { topic: 'snapshot', payload: `level=${fmt(level)} m | volume=${fmt(volume)} m3 | demand=${fmt(demand, 0)}% | direction=${direction} | t=${Number.isFinite(timeleft) ? Math.round(timeleft) + ' s' : '-'}` }\n];",
"outputs": 6,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 980,
"y": 220,
"wires": [
[
"ps_chart_level"
],
[
"ps_chart_volume"
],
[
"ps_chart_demand"
],
[
"ps_chart_netflow"
],
[
"ps_text_safety"
],
[
"ps_text_snapshot"
]
]
},
{
"id": "ps_chart_level",
"type": "ui-chart",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_trends",
"name": "Level",
"label": "Level (m)",
"order": 1,
"width": 4,
"height": 4,
"chartType": "line",
"category": "topic",
"xAxisType": "time",
"yAxisLabel": "m",
"removeOlder": "15",
"removeOlderUnit": "60",
"x": 1230,
"y": 140,
"wires": [],
"showLegend": false,
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "0",
"ymax": "5",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#0c99d9"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "ps_chart_volume",
"type": "ui-chart",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_trends",
"name": "Volume",
"label": "Volume (m3)",
"order": 2,
"width": 4,
"height": 4,
"chartType": "line",
"category": "topic",
"xAxisType": "time",
"yAxisLabel": "m3",
"removeOlder": "15",
"removeOlderUnit": "60",
"x": 1230,
"y": 200,
"wires": [],
"showLegend": false,
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "0",
"ymax": "50",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#2ca02c"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "ps_chart_demand",
"type": "ui-chart",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_trends",
"name": "Demand",
"label": "Demand (%)",
"order": 3,
"width": 4,
"height": 4,
"chartType": "line",
"category": "topic",
"xAxisType": "time",
"yAxisLabel": "%",
"removeOlder": "15",
"removeOlderUnit": "60",
"x": 1230,
"y": 260,
"wires": [],
"showLegend": false,
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "0",
"ymax": "120",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#d68910"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "ps_chart_netflow",
"type": "ui-chart",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_trends",
"name": "Net Flow",
"label": "Net flow (m3/s)",
"order": 4,
"width": 4,
"height": 4,
"chartType": "line",
"category": "topic",
"xAxisType": "time",
"yAxisLabel": "m3/s",
"removeOlder": "15",
"removeOlderUnit": "60",
"x": 1240,
"y": 320,
"wires": [],
"showLegend": false,
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#9467bd"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "ps_text_safety",
"type": "ui-text",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_state",
"name": "Safety",
"label": "Safety",
"order": 1,
"width": 4,
"height": 1,
"format": "{{msg.payload}}",
"layout": "row-spread",
"x": 1230,
"y": 380,
"wires": []
},
{
"id": "ps_text_snapshot",
"type": "ui-text",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_state",
"name": "Snapshot",
"label": "Snapshot",
"order": 2,
"width": 8,
"height": 1,
"format": "{{msg.payload}}",
"layout": "row-spread",
"x": 1240,
"y": 440,
"wires": []
},
{
"id": "ps_debug_influx",
"type": "debug",
"z": "ps_tab_basic_dashboard",
"name": "Influx output",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 980,
"y": 320,
"wires": []
},
{
"id": "ps_debug_parent",
"type": "debug",
"z": "ps_tab_basic_dashboard",
"name": "Parent output",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 980,
"y": 380,
"wires": []
}
]

View File

@@ -8,22 +8,51 @@
| **Control Module** | `#a9daee` | zwart |
-->
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
<!-- Editor JS modules — see nodes/pumpingStation/src/editor/. Loaded in
dependency order: index.js (namespace + helpers) → diagrams → handlers. -->
<script src="/pumpingStation/editor/index.js"></script>
<script src="/pumpingStation/editor/bounds.js"></script>
<script src="/pumpingStation/editor/basin-diagram.js"></script>
<script src="/pumpingStation/editor/mode-preview.js"></script>
<script src="/pumpingStation/editor/hover-couple.js"></script>
<script src="/pumpingStation/editor/oneditprepare.js"></script>
<script src="/pumpingStation/editor/oneditsave.js"></script>
<script>//test
RED.nodes.registerType("pumpingStation", {
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, bottom/invert of inlet pipe above floor
outflowLevel: { value: 0.2 }, // m, top of outlet/suction 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 },
enableHighVolumeSafety: { value: true },
enableOverfillProtection: { value: true }, // deprecated alias
dryRunThresholdPercent: { value: 2 },
highVolumeSafetyThresholdPercent: { value: 98 },
overfillThresholdPercent: { value: 98 }, // deprecated alias
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 +76,21 @@
hasDistance: { value: false },
distance: { value: 0 },
distanceUnit: { value: "m" },
distanceDescription: { value: "" }
distanceDescription: { value: "" },
// control strategy
controlMode: { value: "levelbased" },
levelCurveType: { value: "linear" },
logCurveFactor: { value: 9 },
enableShiftedRamp: { value: false },
shiftLevel: { value: 0 },
shiftArmPercent: { value: 95 },
startLevel: { value: null },
stopLevel: { value: null },
minLevel: { value: null },
maxLevel: { value: null },
flowSetpoint: { value: null },
flowDeadband: { value: null }
},
@@ -61,51 +104,11 @@
return this.positionIcon + " PumpingStation";
},
oneditprepare: function() {
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
window.EVOLV.nodes.pumpingStation.initEditor(this);
} else {
setTimeout(waitForMenuData, 50);
}
};
// Wait for the menu data to be ready before initializing the editor
waitForMenuData();
// 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-refHeight");
document.getElementById("node-input-basinBottomRef");
const refHeightEl = document.getElementById("node-input-refHeight");
if (refHeightEl) {
refHeightEl.value = this.refHeight || "NAP";
}
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
oneditprepare: function () {
window.PSEditor.oneditprepare.call(this);
},
oneditsave: function () {
const node = this;
//window.EVOLV?.nodes?.pumpingStation?.assetMenu?.saveEditor?.(node);
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
//node specific
node.refHeight = document.getElementById("node-input-refHeight").value || "NAP";
node.simulator = document.getElementById("node-input-simulator").checked;
["basinVolume","basinHeight","heightInlet","heightOutlet","heightOverflow","basinBottomRef"]
.forEach(field => {
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
});
node.refHeight = document.getElementById("node-input-refHeight").value || "";
window.PSEditor.oneditsave.call(this);
},
});
@@ -115,7 +118,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 +126,394 @@
</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). Each input on the left controls a line in the diagram on the right hover an input to highlight its line.</p>
<div id="ps-basin-validation" style="display:none;color:#C0392B;font-size:11px;margin:0 0 8px 0;border:1px solid #C0392B;border-radius:3px;padding:6px 8px;background:#FDECEA;"></div>
<!-- Basin geometry -->
<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" />
<style>
/* Two-column layout: stacked colour-coded inputs on the left,
SVG on the right. Hover an input row → its paired SVG line
(referenced by data-couples-line) gets a thicker stroke. */
.ps-diag { display:flex; gap:28px; align-items:flex-start; margin:0 0 14px 0; }
.ps-diag-side { width: 220px; flex: 0 0 220px; display:flex; flex-direction:column; gap:6px; }
.ps-diag-side .ps-row {
display:grid; grid-template-columns: minmax(0,1fr) 70px 16px; align-items:center;
gap:6px; padding:4px 6px 4px 10px; border-left:4px solid #ccc;
background:#fafafa; border-radius:3px; font-size:11px; cursor:pointer;
min-width:0;
}
.ps-diag-side .ps-row:hover { background:#f0f0f0; }
.ps-diag-side .ps-row.ps-readonly { background:#fff; cursor:default; opacity:0.85; }
.ps-diag-side .ps-row label { font-weight:600; margin:0; line-height:1.2; }
.ps-diag-side .ps-row .ps-sub { grid-column:1; font-size:10px; color:#888; font-weight:400; }
.ps-diag-side .ps-row input[type=number] {
width:100%; height:22px; box-sizing:border-box; font-size:11px;
padding:1px 4px; margin:0; border:1px solid #ccc; border-radius:3px;
background:#fff;
}
.ps-diag-side .ps-row input[type=number]:focus { outline:1px solid #0c99d9; border-color:#0c99d9; }
.ps-diag-side .ps-row .ps-readonly-val {
font-family:monospace; font-size:11px; color:#666; text-align:right;
padding-right:4px;
}
.ps-diag-side .ps-row .ps-unit { color:#888; font-size:10px; }
.ps-diag-svg { flex:1; min-width:0; }
/* Border colours matched to each SVG line stroke. */
.ps-row[data-stroke="#333"] { border-left-color:#333; }
.ps-row[data-stroke="#C0392B"] { border-left-color:#C0392B; }
.ps-row[data-stroke="#1E8449"] { border-left-color:#1E8449; }
.ps-row[data-stroke="#1F4E79"] { border-left-color:#1F4E79; }
.ps-row[data-stroke="#D68910"] { border-left-color:#D68910; }
.ps-row[data-stroke="#888"] { border-left-color:#888; }
.ps-row[data-stroke="#333"] label { color:#333; }
.ps-row[data-stroke="#C0392B"] label { color:#C0392B; }
.ps-row[data-stroke="#1E8449"] label { color:#1E8449; }
.ps-row[data-stroke="#1F4E79"] label { color:#1F4E79; }
.ps-row[data-stroke="#D68910"] label { color:#D68910; }
.ps-row[data-stroke="#888"] label { color:#888; }
/* Highlight class applied to the SVG line during input row hover. */
.ps-line-highlight { stroke-width:3.5 !important; opacity:1 !important; }
</style>
<!--
============================================================
BASIN DIAGRAM (ps-basin-diagram)
============================================================
Coordinate system: SVG viewBox is 520 (wide) × 430 (tall).
Origin (0,0) is top-left. +x goes right. +y goes DOWN.
Bigger y = lower on screen.
X-LANES (all viewBox units, edit any of these to shift a column):
x 5..75 left input column (inlet number input)
x = 80 inlet unit "m"
x = 135 inlet text labels (right-aligned, anchor at x)
x = 140..200 inlet arrow (line + arrow head into tank)
x = 200..320 tank body (rect.x=200 width=120) interior 201..319
x = 195/325 threshold tick lines (extend 5 px outside tank)
x = 260 mid-tank zone labels (centered)
x = 320..360 outlet arrow
x = 330 right-side label column ("overflowLevel", "Outlet", )
x = 365 outlet sub-text column
x = 425..495 right input column (foreignObject inputs, width=70)
x = 500 right unit column ("m", "m³")
Y-COORDINATES:
y = 40 tank rim (basinHeight line)
y = 380 tank floor / datum
y = 410 ordering warning ribbon
y = 19,44 "basin volume" / "basinHeight" labels (static)
Threshold rows (overflowLevel, highVolumeSafetyLevel, inflowLevelGuide,
dryRunLevel, outflowLevel, basinHeight tick) get y assigned
DYNAMICALLY by the redraw() function around line 250-340 below.
Their input row may be NUDGED off ideal-y to avoid overlap; a leader
line (ps-leader-*) is then drawn between threshold y and input y.
Zone-label rows (ps-zone-*) get y assigned dynamically to the midpoint
between adjacent thresholds; they hide if the gap is too small.
HOW TO NUDGE OVERLAPPING LABELS:
- For STATIC y values (hardcoded below): edit the inline y attribute.
- For DYNAMIC y values: search redraw() for the element id and adjust
the layout math (e.g. NUDGE_PX or the threshold-stack ordering).
- For x: every label column above can be shifted by editing the inline
x attribute on the relevant <text>/<line>/<foreignObject>.
Note: dynamic line/label positioning lives in oneditprepare redraw()
further up in this file. Changing only the inline y here will be
overridden on first redraw for any element whose id appears in redraw().
============================================================
-->
<div class="ps-diag" id="ps-basin-wrap">
<!-- LEFT: stacked colour-coded inputs. Hover a row its paired SVG
line (data-couples-line) is highlighted in the diagram. -->
<div class="ps-diag-side">
<div class="ps-row" data-stroke="#333" style="cursor:default;">
<div><label>basinVolume</label><div class="ps-sub">total empty volume (no marker)</div></div>
<input type="number" id="node-input-basinVolume" min="0" step="0.1" />
<span class="ps-unit"></span>
</div>
<div class="ps-row" data-stroke="#333" data-couples-line="ps-line-basinHeight">
<div><label>basinHeight</label><div class="ps-sub">floor rim</div></div>
<input type="number" id="node-input-basinHeight" min="0" step="0.1" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row" data-stroke="#C0392B" data-couples-line="ps-line-overflowLevel">
<div><label>overflowLevel</label><div class="ps-sub">spill height</div></div>
<input type="number" id="node-input-overflowLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row ps-readonly" data-stroke="#D68910" data-couples-line="ps-line-highVolumeSafetyLevel">
<div><label>highVolumeSafety</label><div class="ps-sub">derived (overflow × %)</div></div>
<span id="derived-highVolumeSafetyLevel" class="ps-readonly-val"> m</span>
<span class="ps-unit">m</span>
</div>
<div class="ps-row" data-stroke="#1F4E79" data-couples-line="ps-line-inflowLevel">
<div><label>inflowLevel</label><div class="ps-sub">bottom of inlet pipe</div></div>
<input type="number" id="node-input-inflowLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-line-dryRunLevel">
<div><label>dryRunLevel</label><div class="ps-sub">derived (outflow × dry%)</div></div>
<span id="derived-dryRunLevel" class="ps-readonly-val"> m</span>
<span class="ps-unit">m</span>
</div>
<div class="ps-row" data-stroke="#1F4E79" data-couples-line="ps-line-outflowLevel">
<div><label>outflowLevel</label><div class="ps-sub">top of outlet pipe</div></div>
<input type="number" id="node-input-outflowLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row ps-readonly" data-stroke="#888" style="cursor:default;">
<div><label>basinBottomRef</label><div class="ps-sub">floor above NAP (no marker)</div></div>
<input type="number" id="node-input-basinBottomRef" step="0.01" />
<span class="ps-unit">m</span>
</div>
</div>
<!-- RIGHT: SVG. The viewBox is now narrower (320 wide) since the right
input column is gone labels render inside the tank's right margin. -->
<svg id="ps-basin-diagram" class="ps-diag-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 430"
style="display:block;width:100%;max-width:360px;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 — shifted right (x=145, width=110) to give the inlet
sub-label "bottom of pipe" room on the left without clipping.
Threshold tick lines extend 5 px outside the tank walls. -->
<rect x="145" y="40" width="110" height="340" fill="#F0F8FF" stroke="#333" stroke-width="1.5" />
<rect id="ps-deadvol" x="146" width="108" fill="#AACCE0" />
<!-- Mid-tank zone labels — centred at x=200 (tank centre). -->
<text id="ps-zone-spare" x="200" text-anchor="middle" fill="#B78200" font-size="10" font-style="italic" visibility="hidden">Spare</text>
<text id="ps-zone-sewage" x="200" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Sewage + buffer</text>
<text id="ps-zone-buffer1" x="200" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Buffer</text>
<text id="ps-zone-buffer2" x="200" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Buffer</text>
<text id="ps-zone-dead" x="200" text-anchor="middle" fill="#444" font-size="10" font-style="italic" visibility="hidden">Dead vol</text>
<!-- basinHeight tick at tank rim (y=40, static). -->
<line id="ps-line-basinHeight" x1="140" y1="40" x2="260" y2="40" stroke="#333" stroke-width="1.5" />
<text id="ps-label-basinHeight" x="265" y="44" fill="#333">basinHeight</text>
<line id="ps-line-overflowLevel" x1="140" x2="260" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-overflowLevel" x="265" fill="#C0392B">overflowLevel</text>
<line id="ps-line-highVolumeSafetyLevel" x1="140" x2="260" stroke="#D68910" stroke-dasharray="1 2" stroke-width="1" opacity="0.7" />
<text id="ps-label-highVolumeSafetyLevel" x="265" fill="#D68910" font-size="10" font-style="italic">highVolSafety</text>
<line id="ps-line-inflowLevelGuide" x1="145" x2="255" stroke="#1F4E79" stroke-dasharray="2 3" stroke-width="1" opacity="0.55" />
<text id="ps-label-inflowLevelGuide" x="265" fill="#1F4E79" font-size="10" font-style="italic">inlet invert</text>
<line id="ps-line-inflowLevel" x1="85" x2="145" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text id="ps-label-inflowLevel" x="80" text-anchor="end" fill="#1F4E79" font-weight="bold">Inlet</text>
<text id="ps-sub-inflowLevel" x="80" text-anchor="end" fill="#777" font-size="9">bottom of pipe</text>
<line id="ps-line-dryRunLevel" x1="140" x2="260" stroke="#C0392B" stroke-dasharray="1 2" stroke-width="1" opacity="0.6" />
<text id="ps-label-dryRunLevel" x="265" fill="#C0392B" font-size="10" font-style="italic">dryRunLevel</text>
<line id="ps-line-outflowLevel" x1="255" x2="295" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text id="ps-label-outflowLevel" x="300" fill="#1F4E79" font-weight="bold">Outlet</text>
<text id="ps-sub-outflowLevel" x="300" fill="#777" font-size="9">top of pipe</text>
<!-- Floor / datum — datum label sits BELOW the tank (y=395) so it
never collides with the Outlet / top-of-pipe sub-label when
outflowLevel is near the floor. -->
<line x1="140" y1="380" x2="260" y2="380" stroke="#000" stroke-width="2" />
<text x="200" y="395" text-anchor="middle" fill="#000" font-size="10">0 m (datum)</text>
<!-- Ordering-warning ribbon -->
<text id="ps-warning" x="200" y="410" text-anchor="middle" fill="#C0392B" font-size="10" font-style="italic" visibility="hidden"></text>
</svg>
</div>
<!-- Inlet/Outlet elevations -->
<hr>
<h4>Control Strategy</h4>
<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" />
<label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label>
<select id="node-input-controlMode">
<option value="levelbased">Level-based</option>
<option value="manual">Manual</option>
</select>
</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 id="ps-mode-levelbased" class="ps-mode-section">
<div class="form-row">
<label for="node-input-levelCurveType">Curve</label>
<select id="node-input-levelCurveType" style="width:60%;">
<option value="linear">Linear</option>
<option value="log">Log - fast early response</option>
</select>
</div>
<div class="form-row" id="ps-log-factor-row" style="display:none;">
<label for="node-input-logCurveFactor">Log shape factor</label>
<input type="number" id="node-input-logCurveFactor" min="0.001" step="0.1" style="width:100px;" />
</div>
<div class="form-row">
<label for="node-input-enableShiftedRamp" style="width:auto;">
<input type="checkbox" id="node-input-enableShiftedRamp" style="width:auto;vertical-align:middle;margin-right:6px;" />
Enable shifted ramp (hysteresis)
</label>
</div>
<div id="ps-mode-validation" style="display:none;color:#C0392B;font-size:11px;margin:4px 0 8px 0;border:1px solid #C0392B;border-radius:3px;padding:6px 8px;background:#FDECEA;"></div>
<!--
============================================================
LEVEL-BASED MODE PREVIEW (ps-levelbased-mode-diagram)
============================================================
Coordinate system: SVG viewBox is 430 (wide) × 185 (tall).
Origin (0,0) top-left. +x right. +y DOWN (so y=24 is HIGH on screen,
y=158 is at the baseline).
X-AXIS (level, in viewBox px) — controlled by redrawModeDiagram() in
the oneditprepare script above. The function maps the user's
startLevel/inflowLevel/maxLevel/shiftLevel onto the px window
x0=52 (left axis) x1=390 (right end of plot).
DO NOT hardcode x for ps-mode-line-* / ps-mode-label-*; they're
rewritten on every input change.
Y-AXIS (process demand %):
y=24 100% (top of plot)
y=140 0% (baseline / x-axis)
y=160 OFF baseline (pink dashed)
y=180 axis labels under the plot ("dry run","start","inlet","max","overflow","shift")
y=205 legend captions (one row, BELOW axis labels moved here to stop
colliding with the title row at y=14)
y=14 curve-type title only ("linear curve" / "log curve"), centered.
WHAT IS STATIC vs DYNAMIC:
STATIC (edit inline below): viewBox bounds, axis lines, "0%"/"100%"
tick labels, in-plot caption x/y, axis-label y=176.
DYNAMIC (edit in JS): every ps-mode-line-*, ps-mode-label-* x;
ps-mode-curve-up/down points; visibility of shift elements.
HOW TO NUDGE OVERLAPPING TEXT:
- Move the curve-type caption: edit the x="220" y="18" on
#ps-mode-curve-label.
- Move axis labels (start/inlet/max/shift) UP or DOWN: edit y="176".
(To move them left/right relative to the line, edit redrawModeDiagram
in the script the x is set there.)
- Move the legend captions: edit x="280" y="54" / y="72" on
#ps-mode-curve-up-label / #ps-mode-curve-down-label.
- To resize the plot box, change viewBox + the x0/x1/y0/y1 constants
in redrawModeDiagram() to match.
============================================================
-->
<div class="ps-diag" id="ps-mode-wrap">
<!-- LEFT side-panel: only the level-based mode's editable inputs +
read-only displays for derived/related levels (so user has all
level context in one column). Hover-coupled to the SVG markers. -->
<div class="ps-diag-side">
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-mode-line-dryRunLevel">
<div><label>dryRunLevel</label><div class="ps-sub">derived</div></div>
<span id="ps-mode-readout-dryRun" class="ps-readonly-val">— m</span>
<span class="ps-unit">m</span>
</div>
<div class="ps-row" data-stroke="#1E8449" data-couples-line="ps-mode-line-startLevel">
<div><label>startLevel</label><div class="ps-sub">pump-on threshold</div></div>
<input type="number" id="node-input-startLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row" data-stroke="#7D3C98" data-couples-line="ps-mode-line-stopLevel">
<div><label>stopLevel</label><div class="ps-sub">pump-off threshold (optional, ≤ startLevel)</div></div>
<input type="number" id="node-input-stopLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row ps-readonly" data-stroke="#1F4E79" data-couples-line="ps-mode-line-inflowLevel">
<div><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
<span id="ps-mode-readout-inflow" class="ps-readonly-val">— m</span>
<span class="ps-unit">m</span>
</div>
<div class="ps-row" data-stroke="#D68910" data-couples-line="ps-mode-line-maxLevel">
<div><label>maxLevel</label><div class="ps-sub">100% saturation</div></div>
<input type="number" id="node-input-maxLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row" id="ps-shiftLevel-row" data-stroke="#D68910" data-couples-line="ps-mode-line-shiftLevel" style="display:none;">
<div><label>shiftLevel</label><div class="ps-sub">held output drops here</div></div>
<input type="number" id="node-input-shiftLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row" id="ps-shiftArmPercent-row" data-stroke="#D68910" data-couples-line="ps-mode-line-armPercent" style="display:none;">
<div><label>shiftArmPercent</label><div class="ps-sub">arms when output % crosses this</div></div>
<input type="number" id="node-input-shiftArmPercent" min="0" max="100" step="1" />
<span class="ps-unit">%</span>
</div>
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-mode-line-overflowLevel">
<div><label>overflowLevel</label><div class="ps-sub">from basin above</div></div>
<span id="ps-mode-readout-overflow" class="ps-readonly-val">— m</span>
<span class="ps-unit">m</span>
</div>
</div>
<svg id="ps-levelbased-mode-diagram" class="ps-diag-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 215"
style="display:block;width:100%;max-width:540px;background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
font-family="Arial,sans-serif" font-size="11">
<!-- ZONE BANDS — drawn FIRST so they sit behind axes and curves.
x is set DYNAMICALLY by redrawModeDiagram(); y/height span the full plot (24..160).
Order from leftmost to rightmost: dryRun (red) | safetyLow (orange) | safe (green) |
safetyHigh (orange) | overflow (red).
-->
<rect id="ps-zone-dryRun" y="24" height="136" fill="#fdecea" />
<rect id="ps-zone-safetyLow" y="24" height="136" fill="#fef5e7" />
<rect id="ps-zone-safe" y="24" height="136" fill="#eafaf1" />
<rect id="ps-zone-safetyHigh" y="24" height="136" fill="#fef5e7" />
<rect id="ps-zone-overflow" y="24" height="136" fill="#fdecea" />
<!-- X-axis (0% baseline) at y=140; y axis at x=52 (top y=24). Plot range: y=24..140. -->
<line x1="52" y1="140" x2="402" y2="140" stroke="#333" />
<line x1="52" y1="140" x2="52" y2="24" stroke="#333" />
<!-- OFF tier baseline at y=160 (20px below 0% baseline). pink line drawn dynamically by curve. -->
<line x1="52" y1="160" x2="402" y2="160" stroke="#E08080" stroke-dasharray="2 3" />
<!-- Y-axis tick labels (x=4, right-aligned via text-anchor="end" at x=50 for tighter alignment). -->
<text x="50" y="27" text-anchor="end" fill="#333">100%</text>
<text x="50" y="143" text-anchor="end" fill="#333">0%</text>
<text x="50" y="163" text-anchor="end" fill="#E08080">OFF</text>
<!-- Plot title above 100% line. -->
<text id="ps-mode-curve-label" x="220" y="14" text-anchor="middle" fill="#555">linear curve</text>
<!-- Curves drawn dynamically. Up curve foot=inlet→top=max. Down curve foot=start→top=shiftLevel (visible when shift enabled). -->
<polyline id="ps-mode-curve-up" fill="none" stroke="#1E8449" stroke-width="2.5" points="" />
<polyline id="ps-mode-curve-down" fill="none" stroke="#D68910" stroke-width="2" stroke-dasharray="5 3" points="" style="display:none;" />
<!-- Vertical level-marker lines — span y=24..140 (top to baseline only, NOT into OFF tier). x set dynamically. -->
<line id="ps-mode-line-dryRunLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
<line id="ps-mode-line-startLevel" y1="24" y2="140" stroke="#1E8449" stroke-dasharray="2 2" />
<line id="ps-mode-line-stopLevel" y1="24" y2="140" stroke="#7D3C98" stroke-dasharray="2 2" />
<line id="ps-mode-line-inflowLevel" y1="24" y2="140" stroke="#1F4E79" stroke-dasharray="2 2" />
<line id="ps-mode-line-maxLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" />
<line id="ps-mode-line-overflowLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
<line id="ps-mode-line-shiftLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" style="display:none;" />
<!-- Horizontal arming-% line — y is set DYNAMICALLY by the JS to the
shiftArmPercent value (in plot-y space). Spans full plot width. -->
<line id="ps-mode-line-armPercent" x1="52" x2="392" stroke="#D68910" stroke-dasharray="4 3" stroke-width="1" opacity="0.7" style="display:none;" />
<text id="ps-mode-label-armPercent" x="394" text-anchor="start" fill="#D68910" font-size="9" style="display:none;">arm%</text>
<!-- Axis labels under the plot were removed — they crowded each other
when levels were close. Identification comes from the line colour
(matched to the side-panel input row) and hover-coupling. -->
<!-- Empty <text> stubs kept for the redraw loop's getElementById calls
(cheaper than guarding each one). They're hidden via display:none. -->
<text id="ps-mode-label-dryRunLevel" style="display:none;"></text>
<text id="ps-mode-label-startLevel" style="display:none;"></text>
<text id="ps-mode-label-stopLevel" style="display:none;"></text>
<text id="ps-mode-label-inflowLevel" style="display:none;"></text>
<text id="ps-mode-label-maxLevel" style="display:none;"></text>
<text id="ps-mode-label-overflowLevel" style="display:none;"></text>
<text id="ps-mode-label-shiftLevel" style="display:none;"></text>
<!-- Legend captions placed BELOW the axis labels (y=200) on their own row,
so they never collide with the title (y=14). Up-caption left-aligned at
x=60; down-caption to its right at x=210. Both font-size 10. -->
<text id="ps-mode-curve-up-label" x="60" y="205" fill="#1E8449" font-size="10"> ramp inletmax</text>
<text id="ps-mode-curve-down-label" x="210" y="205" fill="#D68910" font-size="10" style="display:none;"> shifted (held @100% then ramp shiftstart)</text>
</svg>
</div>
</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-manual" class="ps-mode-section" style="display:none">
<p style="font-size:12px;color:#777;margin:0;">Manual mode accepts external <code>Qd</code> demand commands and does not compute demand from basin level.</p>
</div>
<hr>
<!-- Reference data -->
<h4>Reference</h4>
<!-- Reference data basinBottomRef moved into basin side-panel above. -->
<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%;">
@@ -158,9 +521,54 @@
</select>
</div>
<hr>
<h4>Safety</h4>
<!-- Safety settings -->
<div class="form-row">
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin Bottom (m Refheight)</label>
<input type="number" id="node-input-basinBottomRef" step="0.01" />
<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-enableHighVolumeSafety">
<i class="fa fa-exclamation-triangle"></i> High-volume Safety
</label>
<input type="checkbox" id="node-input-enableHighVolumeSafety" style="width:20px;vertical-align:baseline;" />
<span>Act before physical overflow</span>
</div>
<div class="form-row">
<label for="node-input-highVolumeSafetyThresholdPercent" style="padding-left:20px;">High-volume Safety (%)</label>
<input type="number" id="node-input-highVolumeSafetyThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
<span id="derived-highVolumeSafetyLevel" style="margin-left:8px;color:#777;font-size:12px;"> highVolumeSafetyLevel 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 -->

View File

@@ -1,4 +1,5 @@
const nameOfNode = 'pumpingStation'; // this is the name of the node, it should match the file name and the node type in Node-RED
const path = require('path');
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
const { MenuManager, configManager } = require('generalFunctions');
@@ -37,4 +38,16 @@ module.exports = function(RED) {
}
});
// Editor JS modules — loaded by pumpingStation.html via <script src=...> tags.
// Files live in src/editor/. Filename is restricted to a safe charset to
// prevent path-traversal.
RED.httpAdmin.get(`/${nameOfNode}/editor/:file`, (req, res) => {
const safe = String(req.params.file || '').replace(/[^a-zA-Z0-9._-]/g, '');
if (!safe.endsWith('.js')) return res.status(400).send('// invalid');
res.type('application/javascript');
res.sendFile(path.join(__dirname, 'src', 'editor', safe), (err) => {
if (err && !res.headersSent) res.status(404).send('// editor module not found');
});
});
};

124
simulations/README.md Normal file
View File

@@ -0,0 +1,124 @@
# 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` |
| `max_demand_gt` | 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

201
simulations/run.js Normal file
View File

@@ -0,0 +1,201 @@
#!/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 'max_demand_gt': {
const v = Math.max(...demands);
return { ok: v > ex.value, msg: `max demand = ${v.toFixed(0)} % (expected > ${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,61 @@
// 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
// inflowLevel and maxLevel while filling) 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: 3600,
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, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 2,
enableHighVolumeSafety: true,
highVolumeSafetyThresholdPercent: 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 mode start level, below the rising ramp
},
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: 'rising ramp engages after inlet level', type: 'max_demand_gt', value: 0 },
{ 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 increase demand as
// the level enters the rising ramp.
//
// Expectation: during the surge (t=300..600), demand rises but remains
// bounded. High-volume 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. High-volume 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, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 2,
enableHighVolumeSafety: true,
highVolumeSafetyThresholdPercent: 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 remains bounded 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, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'manual',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 50,
enableHighVolumeSafety: false,
highVolumeSafetyThresholdPercent: 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 },
],
};

191
src/editor/basin-diagram.js Normal file
View File

@@ -0,0 +1,191 @@
// PumpingStation editor — interactive basin SVG (top of the editor).
// Places threshold lines, derived safety levels, zone labels, dead-volume
// band, and ordering warnings. Same formulas as
// specificClass._validateThresholdOrdering.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
const fNum = (id) => ns.fNum(id);
// viewBox y bounds of the tank rect (now 120,40)..(240,380); width
// shrunk to 360 in the new side-panel layout. y-bounds unchanged.
const DIAG = { topY: 40, botY: 380 };
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.
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');
};
ns.basinDiagram = {
redraw() {
const basinH = fNum('basinHeight') || 5;
const refLow = fNum('outflowLevel');
const dryPct = fNum('dryRunThresholdPercent');
const highPct = fNum('highVolumeSafetyThresholdPercent');
const ovf = fNum('overflowLevel');
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
const highLvl = (ovf != null && highPct != null) ? ovf * (highPct / 100) : null;
// Right-column stack. TWO anchors: basinHeight pinned at the rim,
// outflowLevel pinned at its proportional y. Two passes (top-down +
// bottom-up) maintain a minimum vertical gap.
const items = [
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
{ id: 'highVolumeSafetyLevel', yIdeal: yForLevel(highLvl, basinH) },
{ id: 'inflowLevelGuide', yIdeal: yForLevel(fNum('inflowLevel'), 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;
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);
}
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 show only when the gap between the bracketing
// thresholds is at least MIN_ZONE_GAP px high — otherwise the label
// collides with one of the threshold labels (which sit at threshold
// y ±6 px text-height). 28 px keeps a 6 px clear gap above and
// below the zone label.
const MIN_ZONE_GAP = 28;
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) < MIN_ZONE_GAP) {
el.setAttribute('visibility', 'hidden'); return;
}
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
el.setAttribute('visibility', 'visible');
};
placeZone('spare', 'overflowLevel', 'highVolumeSafetyLevel');
placeZone('sewage', 'highVolumeSafetyLevel', 'inflowLevelGuide');
placeZone('buffer1', 'inflowLevelGuide', 'dryRunLevel');
placeZone('buffer2', 'dryRunLevel', 'outflowLevel');
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');
}
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);
}
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));
}
// SVG labels — keep them short, side panel shows the numeric value.
const dryLbl = document.getElementById('ps-label-dryRunLevel');
if (dryLbl) dryLbl.textContent = 'dryRunLevel';
const highLbl = document.getElementById('ps-label-highVolumeSafetyLevel');
if (highLbl) highLbl.textContent = 'highVolumeSafety';
// Side-panel read-only displays — number only ("m" is shown in the unit span).
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
const d1 = document.getElementById('derived-dryRunLevel');
if (d1) d1.textContent = fmt(dryLvl);
const d2 = document.getElementById('derived-highVolumeSafetyLevel');
if (d2) d2.textContent = fmt(highLvl);
// Hierarchy validation. Soft '≤' relations follow the user's choice:
// start ≤ inflow, max ≤ overflow, overflow ≤ basinHeight (equality OK).
// dryRunLevel must be < startLevel strictly (otherwise the runtime
// would trip dry-run before it could ramp).
// Re-read the raw value (basinH falls back to 5 for diagram scaling;
// here we want null when the user hasn't entered anything so the
// ≤-checks below are skipped rather than false-flagged).
const basinHraw = fNum('basinHeight');
const start = fNum('startLevel');
const inlet = fNum('inflowLevel');
const max = fNum('maxLevel');
const ovfl = fNum('overflowLevel');
const issues = [];
const ok = (a, b, op) => {
if (!Number.isFinite(a) || !Number.isFinite(b)) return true;
return op === '<' ? a < b : a <= b;
};
if (Number.isFinite(refLow) && refLow <= 0)
issues.push('outflowLevel must be > 0');
if (!ok(dryLvl, start, '<'))
issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`);
if (!ok(start, inlet, '<='))
issues.push('startLevel must be ≤ inflowLevel');
if (!ok(inlet, max, '<='))
issues.push('inflowLevel must be ≤ maxLevel');
if (!ok(max, ovfl, '<='))
issues.push('maxLevel must be ≤ overflowLevel');
if (!ok(ovfl, basinHraw, '<='))
issues.push('overflowLevel must be ≤ basinHeight');
// Visible ribbon above the basin diagram.
const warnDiv = document.getElementById('ps-basin-validation');
if (warnDiv) {
if (issues.length) {
warnDiv.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
warnDiv.style.display = '';
} else {
warnDiv.style.display = 'none';
}
}
// Legacy in-SVG warning text — kept for the small reminder inside
// the diagram. Only shows the count.
const warn = document.getElementById('ps-warning');
if (warn) {
if (issues.length) {
warn.setAttribute('visibility', 'visible');
warn.textContent = `${issues.length} ordering issue${issues.length > 1 ? 's' : ''}`;
} else {
warn.setAttribute('visibility', 'hidden');
}
}
window._psBasinValidationIssues = issues;
},
};
})();

96
src/editor/bounds.js Normal file
View File

@@ -0,0 +1,96 @@
// PumpingStation editor — dynamic input bounds.
// Sets HTML5 min/max attributes on every level and percent input based on
// the current values of related inputs, so the up/down arrows stop at
// values that respect the basin hierarchy:
//
// 0 < outflowLevel < dryRunLevel < startLevel ≤ inflowLevel
// ≤ shiftLevel ≤ maxLevel ≤ overflowLevel ≤ basinHeight
//
// The user can still type out-of-range values via the keyboard (HTML5
// min/max only constrain the spinner). The validation ribbons in
// basin-diagram.js and mode-preview.js catch typed violations and the
// oneditsave handler blocks Deploy until they're resolved.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
const fNum = (id) => ns.fNum(id);
const EPS = 0.001; // smallest meaningful step (mm-precision)
const setBounds = (id, min, max) => {
const el = document.getElementById(`node-input-${id}`);
if (!el) return;
if (Number.isFinite(min)) el.setAttribute('min', String(min));
else el.removeAttribute('min');
if (Number.isFinite(max)) el.setAttribute('max', String(max));
else el.removeAttribute('max');
};
ns.bounds = {
apply() {
const basinHeight = fNum('basinHeight');
const outflow = fNum('outflowLevel');
const dryPct = fNum('dryRunThresholdPercent');
const start = fNum('startLevel');
const inlet = fNum('inflowLevel');
const max = fNum('maxLevel');
const overflow = fNum('overflowLevel');
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
// Derived dryRunLevel (lower bound for startLevel).
const dryRun = (Number.isFinite(outflow) && Number.isFinite(dryPct))
? outflow * (1 + dryPct / 100) : null;
// Geometry — basin envelope.
setBounds('basinHeight', EPS, undefined);
setBounds('basinVolume', EPS, undefined);
// Levels (each capped by the next-higher level if defined).
setBounds('outflowLevel', EPS,
Number.isFinite(start) && Number.isFinite(dryPct)
? start / (1 + dryPct / 100) - EPS // keep dryRun < start
: (start ?? inlet ?? max ?? overflow ?? basinHeight));
setBounds('startLevel',
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
inlet ?? max ?? overflow ?? basinHeight);
setBounds('inflowLevel',
start ?? EPS,
max ?? overflow ?? basinHeight);
setBounds('maxLevel',
inlet ?? start ?? EPS,
overflow ?? basinHeight);
setBounds('overflowLevel',
max ?? inlet ?? start ?? EPS,
basinHeight);
// stopLevel — explicit pump-off threshold. Must sit between
// dryRunLevel and startLevel (so it can be reached during draining
// before pumps re-engage).
setBounds('stopLevel',
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
start ?? inlet ?? max ?? overflow ?? basinHeight);
// Shift inputs (only relevant when shifted ramp enabled).
if (shiftEnabled) {
setBounds('shiftLevel',
Number.isFinite(start) ? start : EPS,
max ?? overflow ?? basinHeight);
setBounds('shiftArmPercent', 1, 100);
}
// Percentages.
// dryRun% capped so dryRunLevel ≤ startLevel.
let dryMax = 99;
if (Number.isFinite(start) && Number.isFinite(outflow) && outflow > 0) {
dryMax = Math.max(0, Math.min(99, ((start / outflow) - 1) * 100));
}
setBounds('dryRunThresholdPercent', 0, dryMax);
// highVol% bounded (1, 100). Equal to 100 means no margin to overflow.
setBounds('highVolumeSafetyThresholdPercent', 1, 100);
},
};
})();

View File

@@ -0,0 +1,29 @@
// PumpingStation editor — hover-coupling between side-panel input rows
// and the SVG markers they control. Each .ps-row that carries
// data-couples-line="<svg-element-id>" highlights that SVG line on
// mouseenter and clears the highlight on mouseleave.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
ns.hoverCouple = {
init() {
document.querySelectorAll('.ps-diag-side .ps-row[data-couples-line]').forEach((row) => {
const targetId = row.getAttribute('data-couples-line');
const target = document.getElementById(targetId);
if (!target) return;
const enter = () => target.classList.add('ps-line-highlight');
const leave = () => target.classList.remove('ps-line-highlight');
row.addEventListener('mouseenter', enter);
row.addEventListener('mouseleave', leave);
// Also highlight while the input inside the row has focus, so
// the user keeps the visual feedback while typing.
const input = row.querySelector('input');
if (input) {
input.addEventListener('focus', enter);
input.addEventListener('blur', leave);
}
});
},
};
})();

30
src/editor/index.js Normal file
View File

@@ -0,0 +1,30 @@
// PumpingStation editor — shared namespace + helpers.
// Loaded first by pumpingStation.html via /pumpingStation/editor/index.js.
// Each sibling module attaches additional members to window.PSEditor.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
// Read a numeric value from an input by node-input-<id>; null if blank/NaN.
ns.fNum = (id) => {
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
return Number.isFinite(v) ? v : null;
};
// Set a numeric input's value, or blank if not finite.
ns.setNumberField = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = Number.isFinite(val) ? val : '';
};
// Add input + change listeners to a list of node-input-* ids.
ns.bindRedraw = (ids, handler) => {
ids.forEach((id) => {
const el = document.getElementById(`node-input-${id}`);
if (el) {
el.addEventListener('input', handler);
el.addEventListener('change', handler);
}
});
};
})();

286
src/editor/mode-preview.js Normal file
View File

@@ -0,0 +1,286 @@
// PumpingStation editor — level-based mode preview SVG.
// Draws zone bands, level markers, the up curve (inflowLevel→maxLevel) and
// the optional shifted-down curve (startLevel→shiftLevel). Computes
// validation issues and stashes them on window._psModeValidationIssues
// for oneditsave to read.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
const fNum = (id) => ns.fNum(id);
// Derive dryRunLevel the same way the basin diagram does.
// dryRunLevel = outflowLevel × (1 + dryRunThresholdPercent/100).
// Returns null if either input is missing.
ns.deriveDryRunLevel = () => {
const refLow = fNum('outflowLevel');
const dryPct = fNum('dryRunThresholdPercent');
if (refLow == null || dryPct == null) return null;
return refLow * (1 + dryPct / 100);
};
ns.modePreview = {
redraw() {
const svg = document.getElementById('ps-levelbased-mode-diagram');
if (!svg) return;
const start = fNum('startLevel');
const inlet = fNum('inflowLevel');
const max = fNum('maxLevel');
// Optional stopLevel — explicit pump-off threshold. Drawn as its
// own marker line; does NOT shift the ramp foot. Must be < startLevel
// for the marker to render.
const stopRaw = fNum('stopLevel');
const stop = Number.isFinite(stopRaw) && stopRaw >= 0 && Number.isFinite(start) && stopRaw < start ? stopRaw : null;
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
// (no separate input). Below dryRunLevel the runtime hard-stops;
// we draw it as the leftmost vertical marker so the user sees
// exactly where it lands.
const dryRun = ns.deriveDryRunLevel();
const overflow = fNum('overflowLevel');
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
const shiftRaw = fNum('shiftLevel');
const shift = Number.isFinite(shiftRaw) && shiftRaw > 0 ? Math.min(shiftRaw, max ?? shiftRaw) : null;
const armRaw = fNum('shiftArmPercent');
const armPct = Number.isFinite(armRaw) ? Math.max(0, Math.min(100, armRaw)) : 95;
const curveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
const factorRaw = parseFloat(document.getElementById('node-input-logCurveFactor')?.value);
const factor = Number.isFinite(factorRaw) && factorRaw > 0 ? factorRaw : 9;
// Plot window is FIXED relative to basin geometry so that moving any
// single level slides only that line, not all the others. Lower bound
// is the basin floor (0); upper bound is overflowLevel (or maxLevel
// if overflow isn't set) plus a small margin.
const upperRefs = [max, overflow].filter(Number.isFinite);
const upperBase = upperRefs.length ? Math.max(...upperRefs) : 1;
const pad = Math.max(upperBase * 0.05, 0.1);
const levelMin = 0;
const levelMax = upperBase + pad;
// Plot rectangle (viewBox px).
const x0 = 52, x1 = 390, y0 = 140, y1 = 24;
const yOffPx = 160;
const yOffPct = -((yOffPx - y0) / (y0 - y1)) * 100;
const xFor = (level) => x0 + ((level - levelMin) / (levelMax - levelMin)) * (x1 - x0);
const yForPct = (pct) => y0 - (pct / 100) * (y0 - y1);
const scale = (x) => {
const clamped = Math.max(0, Math.min(1, x));
if (curveType === 'log') return Math.log1p(factor * clamped) / Math.log1p(factor);
return clamped;
};
// Path with three flat regions and a ramp:
// [levelMin..startX] OFF (pump off; below startLevel)
// [startX..footX] 0 % (system armed but not yet ramping)
// [footX..topX] ramp (linear or log scaled 0..100 %)
// [topX..levelMax] 100 % (saturated)
// Up curve: startX=startLevel, footX=inflowLevel, topX=maxLevel.
// Shifted-down: startX=footX=startLevel, topX=shiftLevel.
const buildPath = (startX, footX, topX) => {
if (![startX, footX, topX].every(Number.isFinite) || topX <= footX) return '';
const pts = [];
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
pts.push(`${xFor(startX)},${yForPct(yOffPct)}`);
pts.push(`${xFor(startX)},${yForPct(0)}`);
if (footX > startX) pts.push(`${xFor(footX)},${yForPct(0)}`);
for (let i = 0; i <= 24; i++) {
const t = i / 24;
const level = footX + t * (topX - footX);
pts.push(`${xFor(level)},${yForPct(scale(t) * 100)}`);
}
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
return pts.join(' ');
};
// Up curve. Foot is startLevel (the configured pump-on threshold and
// ramp foot per the runtime in _controlLevelBased). The OFF baseline
// is drawn for level < startLevel; at startLevel demand jumps from
// OFF to 0 % and ramps up to 100 % at maxLevel.
const up = document.getElementById('ps-mode-curve-up');
const down = document.getElementById('ps-mode-curve-down');
const downLabel = document.getElementById('ps-mode-curve-down-label');
if (up) up.setAttribute('points', buildPath(start, start, max));
// Shifted-DOWN curve (only when shift enabled): represents the
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
// ideal). Geometry: 100 % flat from levelMax back to shiftLevel,
// then linear/log ramp from (shiftLevel, 100 %) down to
// (startLevel, 0 %), then OFF below startLevel.
// Real runtime hold value depends on where direction flips, so the
// preview shows the maximum extent.
const buildShiftedDown = () => {
if (![start, shift].every(Number.isFinite) || shift <= start) return '';
const pts = [];
// OFF baseline far-left to startLevel
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
pts.push(`${xFor(start)},${yForPct(yOffPct)}`);
// Jump 0 % at startLevel
pts.push(`${xFor(start)},${yForPct(0)}`);
// Ramp start→shift = 0..100 % (peak hold = 100 % for this preview)
for (let i = 0; i <= 24; i++) {
const t = i / 24;
const lvl = start + t * (shift - start);
pts.push(`${xFor(lvl)},${yForPct(scale(t) * 100)}`);
}
// Held at 100 % from shift → far-right
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
return pts.join(' ');
};
if (down) {
if (shiftEnabled) {
down.setAttribute('points', buildShiftedDown());
down.style.display = '';
if (downLabel) downLabel.style.display = '';
} else {
down.setAttribute('points', '');
down.style.display = 'none';
if (downLabel) downLabel.style.display = 'none';
}
}
// Horizontal arming-% line — only meaningful when shift enabled.
const armLine = document.getElementById('ps-mode-line-armPercent');
const armLabel = document.getElementById('ps-mode-label-armPercent');
if (armLine && armLabel) {
if (shiftEnabled) {
const yArm = yForPct(armPct);
armLine.setAttribute('y1', yArm);
armLine.setAttribute('y2', yArm);
armLabel.setAttribute('y', yArm - 2);
armLabel.textContent = `arm ${Math.round(armPct)}%`;
armLine.style.display = '';
armLabel.style.display = '';
} else {
armLine.style.display = 'none';
armLabel.style.display = 'none';
}
}
// Vertical level markers — line only. Axis labels were removed;
// identification comes from line colour + side-panel labels +
// hover coupling.
[
['dryRunLevel', dryRun],
['startLevel', start],
['stopLevel', stop],
['inflowLevel', inlet],
['maxLevel', max],
['overflowLevel', overflow],
].forEach(([id, level]) => {
const line = document.getElementById(`ps-mode-line-${id}`);
if (!line) return;
if (!Number.isFinite(level)) {
line.style.display = 'none';
return;
}
const x = xFor(level);
line.style.display = '';
line.setAttribute('x1', x); line.setAttribute('x2', x);
});
// Background zone bands.
const plotL = xFor(levelMin);
const plotR = xFor(levelMax);
const setBand = (id, a, b) => {
const r = document.getElementById(id);
if (!r) return;
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) {
r.setAttribute('x', 0); r.setAttribute('width', 0);
return;
}
r.setAttribute('x', a);
r.setAttribute('width', b - a);
};
const xMin = Number.isFinite(dryRun) ? xFor(dryRun) : plotL;
const xStart = Number.isFinite(start) ? xFor(start) : xMin;
const xMax = Number.isFinite(max) ? xFor(max) : plotR;
const xOvf = Number.isFinite(overflow) ? xFor(overflow) : xMax;
setBand('ps-zone-dryRun', plotL, xMin);
setBand('ps-zone-safetyLow', xMin, xStart);
setBand('ps-zone-safe', xStart, xMax);
setBand('ps-zone-safetyHigh', xMax, xOvf);
setBand('ps-zone-overflow', xOvf, plotR);
// Shift level marker (line only).
const shiftLine = document.getElementById('ps-mode-line-shiftLevel');
if (shiftLine) {
if (shiftEnabled && Number.isFinite(shift)) {
const x = xFor(shift);
shiftLine.setAttribute('x1', x); shiftLine.setAttribute('x2', x);
shiftLine.style.display = '';
} else {
shiftLine.style.display = 'none';
}
}
// Title + row visibility.
const curveLabel = document.getElementById('ps-mode-curve-label');
if (curveLabel) curveLabel.textContent = curveType === 'log' ? 'log curve: fast early response' : 'linear curve';
const shiftRow = document.getElementById('ps-shiftLevel-row');
if (shiftRow) shiftRow.style.display = shiftEnabled ? '' : 'none';
const armRow = document.getElementById('ps-shiftArmPercent-row');
if (armRow) armRow.style.display = shiftEnabled ? '' : 'none';
const logRow = document.getElementById('ps-log-factor-row');
if (logRow) logRow.style.display = curveType === 'log' ? '' : 'none';
// Auto-default shiftLevel when shift is enabled and current value
// is missing/out-of-range. Visible default avoids a hidden ramp.
const shiftInput = document.getElementById('node-input-shiftLevel');
if (shiftEnabled && shiftInput && Number.isFinite(max)) {
const cur = parseFloat(shiftInput.value);
if (!Number.isFinite(cur) || cur <= 0 || cur >= max) {
shiftInput.value = (max * 0.9).toFixed(2);
}
}
// Auto-default shiftArmPercent to 95 % when shift is enabled and the
// current value is missing / out of [0, 100].
const armInput = document.getElementById('node-input-shiftArmPercent');
if (shiftEnabled && armInput) {
const cur = parseFloat(armInput.value);
if (!Number.isFinite(cur) || cur < 0 || cur > 100) {
armInput.value = 95;
}
}
// Validation: only mode-specific (shift) ordering. Basin-level
// hierarchy (start ≤ inlet ≤ max ≤ overflow ≤ basinHeight,
// dryRun < start) is owned by basin-diagram.js so it shows in the
// basin section near the offending inputs.
const issues = [];
if (shiftEnabled) {
const shiftVal = Number(shiftInput?.value);
if (Number.isFinite(shiftVal)) {
if (Number.isFinite(start) && shiftVal <= start)
issues.push('shiftLevel must be > startLevel');
if (Number.isFinite(max) && shiftVal > max)
issues.push('shiftLevel must be ≤ maxLevel');
} else {
issues.push('shiftLevel is required when shifted ramp is enabled');
}
const armVal = Number(armInput?.value);
if (!Number.isFinite(armVal) || armVal <= 0 || armVal > 100)
issues.push('shiftArmPercent must be in (0, 100]');
}
const warnBox = document.getElementById('ps-mode-validation');
if (warnBox) {
if (issues.length) {
warnBox.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
warnBox.style.display = '';
} else {
warnBox.style.display = 'none';
}
}
window._psModeValidationIssues = issues;
// Read-only readouts in the side panel — number only; the row's
// .ps-unit span already shows "m".
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
const setText = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = fmt(val);
};
setText('ps-mode-readout-dryRun', dryRun);
setText('ps-mode-readout-inflow', inlet);
setText('ps-mode-readout-overflow', overflow);
},
};
})();

114
src/editor/oneditprepare.js Normal file
View File

@@ -0,0 +1,114 @@
// PumpingStation editor — oneditprepare entry. Wires up form-field
// initialization, control-mode toggle, safety toggles, and binds
// redraws for the basin diagram + level-based mode preview.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
ns.oneditprepare = function () {
const node = this;
// Wait for menu data (asset/logger/position dropdowns) before init.
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
window.EVOLV.nodes.pumpingStation.initEditor(node);
} else {
setTimeout(waitForMenuData, 50);
}
};
waitForMenuData();
const refHeightEl = document.getElementById('node-input-refHeight');
if (refHeightEl) refHeightEl.value = node.refHeight || 'NAP';
// Safety toggle pairs — each toggle enables/disables its threshold input.
const dryRunToggle = document.getElementById('node-input-enableDryRunProtection');
const dryRunPercent = document.getElementById('node-input-dryRunThresholdPercent');
const highVolumeToggle = document.getElementById('node-input-enableHighVolumeSafety');
const highVolumePercent = document.getElementById('node-input-highVolumeSafetyThresholdPercent');
const toggleInput = (toggleEl, inputEl) => {
if (!toggleEl || !inputEl) return;
inputEl.disabled = !toggleEl.checked;
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
};
if (dryRunToggle && dryRunPercent) {
dryRunToggle.checked = !!node.enableDryRunProtection;
dryRunPercent.value = Number.isFinite(node.dryRunThresholdPercent) ? node.dryRunThresholdPercent : 2;
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
toggleInput(dryRunToggle, dryRunPercent);
}
if (highVolumeToggle && highVolumePercent) {
highVolumeToggle.checked = node.enableHighVolumeSafety !== undefined
? !!node.enableHighVolumeSafety
: !!node.enableOverfillProtection;
const highVolumePct = node.highVolumeSafetyThresholdPercent ?? node.overfillThresholdPercent;
highVolumePercent.value = Number.isFinite(highVolumePct) ? highVolumePct : 98;
highVolumeToggle.addEventListener('change', () => toggleInput(highVolumeToggle, highVolumePercent));
toggleInput(highVolumeToggle, highVolumePercent);
}
// Control-mode section toggle (levelbased / manual).
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 = node.controlMode === 'manual' ? 'manual' : 'levelbased';
toggleModeSections(modeSelect.value);
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
}
// Numeric field defaults.
ns.setNumberField('node-input-startLevel', node.startLevel);
ns.setNumberField('node-input-maxLevel', node.maxLevel);
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
ns.setNumberField('node-input-shiftArmPercent', Number.isFinite(node.shiftArmPercent) ? node.shiftArmPercent : 95);
ns.setNumberField('node-input-flowSetpoint', node.flowSetpoint);
ns.setNumberField('node-input-flowDeadband', node.flowDeadband);
const curveSelect = document.getElementById('node-input-levelCurveType');
if (curveSelect) curveSelect.value = node.levelCurveType || node.curveType || 'linear';
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
// Bind redraws to the inputs each diagram cares about.
ns.bindRedraw(
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'],
ns.basinDiagram.redraw
);
ns.bindRedraw(
// dryRunLevel is derived (outflowLevel + dryRunThresholdPercent),
// so the mode preview must redraw when either of those change.
['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel',
'dryRunThresholdPercent',
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
'shiftArmPercent'],
ns.modePreview.redraw
);
// Whenever any level/percent input changes, refresh the bounds first
// so the next redraw + validation sees the correct min/max attrs.
ns.bindRedraw(
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
'inflowLevel', 'startLevel', 'outflowLevel',
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
() => ns.bounds?.apply()
);
// Initial render + hover-couple wiring once the DOM is settled.
setTimeout(() => {
ns.bounds?.apply();
ns.basinDiagram.redraw();
ns.modePreview.redraw();
ns.hoverCouple?.init();
}, 60);
};
})();

69
src/editor/oneditsave.js Normal file
View File

@@ -0,0 +1,69 @@
// PumpingStation editor — oneditsave handler. Validates, saves shared
// menu sections (logger/position), then persists pumpingStation-specific
// fields onto the node. Throws if validation fails to keep the editor open.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
ns.oneditsave = function () {
const node = this;
// Block save if EITHER validator surfaced any issues. basin-diagram
// owns hierarchy issues (start ≤ inlet ≤ max ≤ overflow ≤ basinHeight,
// dryRun < start). mode-preview owns shift-specific issues.
const basinIssues = window._psBasinValidationIssues || [];
const modeIssues = window._psModeValidationIssues || [];
const issues = [...basinIssues, ...modeIssues];
if (issues.length) {
if (typeof RED !== 'undefined' && RED.notify) {
RED.notify('PumpingStation config invalid:<br>• ' + issues.join('<br>• '),
{ type: 'error', timeout: 6000 });
}
throw new Error('PumpingStation: invalid config — ' + issues.join('; '));
}
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
node.refHeight = document.getElementById('node-input-refHeight').value || 'NAP';
node.simulator = document.getElementById('node-input-simulator').checked;
[
'basinVolume', 'basinHeight', 'inflowLevel', 'outflowLevel', 'overflowLevel',
'basinBottomRef',
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
].forEach((field) => {
const el = document.getElementById(`node-input-${field}`);
if (el) node[field] = parseFloat(el.value) || 0;
});
node.enableDryRunProtection = document.getElementById('node-input-enableDryRunProtection').checked;
node.enableHighVolumeSafety = document.getElementById('node-input-enableHighVolumeSafety').checked;
// Deprecated aliases kept for existing runtime/schema compatibility.
node.enableOverfillProtection = node.enableHighVolumeSafety;
node.overfillThresholdPercent = node.highVolumeSafetyThresholdPercent;
node.controlMode = document.getElementById('node-input-controlMode').value || 'levelbased';
node.levelCurveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
node.logCurveFactor = parseNum('node-input-logCurveFactor');
node.startLevel = parseNum('node-input-startLevel');
node.maxLevel = parseNum('node-input-maxLevel');
// minLevel is no longer a user input — it's the derived dryRunLevel
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
// uses node.minLevel as the unconditional STOP threshold; we set it
// here so that semantic survives the UI change.
const _dryRun = ns.deriveDryRunLevel?.();
if (Number.isFinite(_dryRun)) node.minLevel = _dryRun;
node.enableShiftedRamp = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
const shiftLevelVal = parseNum('node-input-shiftLevel');
node.shiftLevel = Number.isFinite(shiftLevelVal) ? shiftLevelVal : 0;
const armPctVal = parseNum('node-input-shiftArmPercent');
node.shiftArmPercent = Number.isFinite(armPctVal) ? armPctVal : 95;
const flowSetpoint = parseNum('node-input-flowSetpoint');
const flowDeadband = parseNum('node-input-flowDeadband');
if (Number.isFinite(flowSetpoint)) node.flowSetpoint = flowSetpoint;
if (Number.isFinite(flowDeadband)) node.flowDeadband = flowDeadband;
};
})();

View File

@@ -44,13 +44,49 @@ 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,
inletPipeDiameter: uiConfig.inletPipeDiameter,
outletPipeDiameter: uiConfig.outletPipeDiameter,
},
hydraulics: {
refHeight: uiConfig.refHeight,
minHeightBasedOn: uiConfig.minHeightBasedOn,
basinBottomRef: uiConfig.basinBottomRef,
maxInflowRate: uiConfig.maxInflowRate,
staticHead: uiConfig.staticHead,
maxDischargeHead: uiConfig.maxDischargeHead,
pipelineLength: uiConfig.pipelineLength,
defaultFluid: uiConfig.defaultFluid,
temperatureReferenceDegC: uiConfig.temperatureReferenceDegC,
},
control:{
mode: uiConfig.controlMode,
levelbased:{
minLevel:uiConfig.minLevel,
startLevel:uiConfig.startLevel,
stopLevel: uiConfig.stopLevel,
maxLevel:uiConfig.maxLevel,
curveType: uiConfig.levelCurveType || uiConfig.curveType,
logCurveFactor: uiConfig.logCurveFactor,
enableShiftedRamp: uiConfig.enableShiftedRamp,
shiftLevel: uiConfig.shiftLevel,
shiftArmPercent: uiConfig.shiftArmPercent
}
},
safety:{
enableDryRunProtection: uiConfig.enableDryRunProtection,
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
enableHighVolumeSafety: uiConfig.enableHighVolumeSafety ?? uiConfig.enableOverfillProtection,
highVolumeSafetyThresholdPercent: uiConfig.highVolumeSafetyThresholdPercent ?? uiConfig.overfillThresholdPercent,
enableOverfillProtection: uiConfig.enableOverfillProtection,
overfillThresholdPercent: uiConfig.overfillThresholdPercent,
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds
},
output: {
process: uiConfig.processOutputFormat,
dbase: uiConfig.dbaseOutputFormat
}
});
@@ -86,65 +122,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 +198,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 +212,60 @@ 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 'q_out': {
const val = Number(msg.payload);
const unit = msg?.unit;
const ts = msg?.timestamp || Date.now();
this.source.setManualOutflow(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 +280,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,74 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
function loadConfig(uiConfig = {}) {
const ctx = { name: 'pumpingStation' };
NodeClass.prototype._loadConfig.call(ctx, {
name: 'PS Config Test',
basinVolume: 80,
basinHeight: 8,
inflowLevel: 3.2,
outflowLevel: 0.4,
overflowLevel: 7.4,
inletPipeDiameter: 0.5,
outletPipeDiameter: 0.35,
refHeight: 'NAP',
minHeightBasedOn: 'outlet',
basinBottomRef: -1.2,
maxInflowRate: 300,
staticHead: 11,
maxDischargeHead: 22,
pipelineLength: 120,
defaultFluid: 'wastewater',
temperatureReferenceDegC: 16,
controlMode: 'levelbased',
minLevel: 0.8,
startLevel: 2,
maxLevel: 6.5,
levelCurveType: 'log',
logCurveFactor: 7,
enableDryRunProtection: true,
dryRunThresholdPercent: 3,
enableHighVolumeSafety: true,
highVolumeSafetyThresholdPercent: 96,
timeleftToFullOrEmptyThresholdSeconds: 60,
processOutputFormat: 'process',
dbaseOutputFormat: 'influxdb',
...uiConfig,
}, { id: 'node-1' });
return ctx.config;
}
test('nodeClass config mapping — basin, hydraulics, mode and safety fields', () => {
const cfg = loadConfig();
assert.equal(cfg.basin.inletPipeDiameter, 0.5);
assert.equal(cfg.basin.outletPipeDiameter, 0.35);
assert.equal(cfg.hydraulics.maxInflowRate, 300);
assert.equal(cfg.hydraulics.staticHead, 11);
assert.equal(cfg.hydraulics.maxDischargeHead, 22);
assert.equal(cfg.hydraulics.pipelineLength, 120);
assert.equal(cfg.hydraulics.defaultFluid, 'wastewater');
assert.equal(cfg.hydraulics.temperatureReferenceDegC, 16);
assert.equal(cfg.control.mode, 'levelbased');
assert.equal(cfg.control.levelbased.curveType, 'log');
assert.equal(cfg.control.levelbased.logCurveFactor, 7);
assert.equal(cfg.safety.enableHighVolumeSafety, true);
assert.equal(cfg.safety.highVolumeSafetyThresholdPercent, 96);
assert.equal(cfg.output.process, 'process');
assert.equal(cfg.output.dbase, 'influxdb');
});
test('nodeClass config mapping — accepts deprecated overfill UI fields', () => {
const cfg = loadConfig({
enableHighVolumeSafety: undefined,
highVolumeSafetyThresholdPercent: undefined,
enableOverfillProtection: false,
overfillThresholdPercent: 91,
});
assert.equal(cfg.safety.enableHighVolumeSafety, false);
assert.equal(cfg.safety.highVolumeSafetyThresholdPercent, 91);
});

View File

@@ -0,0 +1,601 @@
// 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,
inletPipeDiameter: 0.4,
outletPipeDiameter: 0.3,
},
hydraulics: {
refHeight: 'NAP',
basinBottomRef: 0,
minHeightBasedOn: 'outlet',
},
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
safety: {
enableDryRunProtection: false,
enableOverfillProtection: false,
dryRunThresholdPercent: 2,
highVolumeSafetyThresholdPercent: 98,
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);
});
await t.test('pipe diameters are part of basin contract', () => {
assert.equal(ps.basin.inletPipeDiameter, 0.4);
assert.equal(ps.basin.outletPipeDiameter, 0.3);
});
});
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('startLevel > inflowLevel flagged for levelbased rising hold zone', () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
},
}));
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'));
});
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 < active ramp start → commands 0% without shutdown', async () => {
const ps = new PumpingStation(makeConfig());
ps.percControl = 42; // simulated previous demand
const demands = [];
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); },
};
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
await ps._controlLevelBased();
assert.equal(ps.percControl, 0);
assert.equal(demands[0], 0);
});
await t.test('filling: level between startLevel and inflowLevel commands 0%', 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(2.5); // startLevel=2, inflowLevel=3
await ps._controlLevelBased('filling');
assert.equal(ps.percControl, 0);
assert.equal(demands[0], 0);
});
await t.test('filling: level ≥ inflowLevel → 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.5); // midpoint of inflowLevel=3 and maxLevel=4
await ps._controlLevelBased('filling');
// lerp(3.5, [3,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('shift disabled (default): foot stays at inflowLevel even after fall', async () => {
const ps = new PumpingStation(makeConfig());
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
// Climb past inflowLevel and beyond, then fall to a level inside [start..inflow].
ps.calibratePredictedLevel(3.8);
await ps._controlLevelBased();
assert.ok(ps.percControl > 0);
ps.calibratePredictedLevel(2.5); // between startLevel=2 and inflowLevel=3
await ps._controlLevelBased();
// Without shift the foot is inflowLevel → 0% in the hold zone.
assert.equal(ps.percControl, 0);
});
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => {
// Geometry: inflow=3, max=4 → up curve goes 0%@3 to 100%@4.
// shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
// Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
ps.calibratePredictedLevel(3.5);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftArmed, false);
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
// Filling at level=3.85 ⇒ up curve = 85 % ≥ arm threshold ⇒ ARM.
ps.calibratePredictedLevel(3.85);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftArmed, true);
assert.ok(Math.abs(ps.percControl - 85) < 1e-9); // still up curve while filling
// Direction flips to draining at the same level ⇒ capture hold ≈ 85 %.
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
// While draining and level ≥ shiftLevel ⇒ output stays at hold (≈85 %).
ps.calibratePredictedLevel(3.6);
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps.percControl - 85) < 1e-6);
// Below shiftLevel: ramp [shift, hold] → [start, 0]. At level=2.75
// (midpoint of [2, 3.5]), x=0.5, output ≈ 85 × 0.5 = 42.5 %.
ps.calibratePredictedLevel(2.75);
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps.percControl - 42.5) < 1e-6);
// Below startLevel ⇒ output 0 % AND disarm.
ps.calibratePredictedLevel(1.9);
await ps._controlLevelBased('draining');
assert.equal(ps.percControl, 0);
assert.equal(ps._shiftArmed, false);
assert.equal(ps._shiftHoldValue, null);
});
await t.test('shift enabled: returning to filling clears hold; new hold captured on next drain', async () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
ps.calibratePredictedLevel(3.85);
await ps._controlLevelBased('filling');
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
// Direction back to filling ⇒ up curve, hold cleared, still armed.
ps.calibratePredictedLevel(3.9);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps._shiftArmed, true);
assert.ok(Math.abs(ps.percControl - 90) < 1e-6); // up curve at 3.9 = 90 %
// Flip to draining again at higher level ⇒ new hold ≈ 90 %.
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 90) < 1e-6);
});
await t.test('log curve has fast early response', async () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
ps.calibratePredictedLevel(3.5); // x=0.5 on filling ramp [3,4]
await ps._controlLevelBased('filling');
assert.ok(ps.percControl > 50);
assert.ok(ps.percControl < 100);
});
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);
assert.equal(out.inletPipeDiameter, 0.4);
assert.equal(out.outletPipeDiameter, 0.3);
assert.ok(Math.abs(out.highVolumeSafetyLevel - 4.41) < 1e-9);
assert.ok(Math.abs(out.dryRunLevel - 0.204) < 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);
});
// _updatePredictedVolume now clamps [dryRunSafetyVol, maxVolAtOverflow] and
// tracks any excess as cumulative `overflowVolume` plus a synthetic
// `flow.predicted.out.overflow` rate so net-flow balance stays at ~0 while
// pinned. We drive ticks manually with monotonic timestamps to keep tests
// deterministic (Date.now() in the integrator can step by 0 ms in fast loops).
test('Predicted volume — overflow clamp and spill tracking', async (t) => {
const ps = new PumpingStation(makeConfig({
safety: { enableDryRunProtection: false, enableHighVolumeSafety: false, dryRunThresholdPercent: 0 },
}));
// Seed predicted volume just below the spill point.
// maxVolAtOverflow = overflowLevel × area = 4.5 × 10 = 45 m³.
const t0 = 1_700_000_000_000;
ps.calibratePredictedVolume(44, t0);
// Heavy inflow, no real outflow (no pumps wired).
ps.setManualInflow(2, t0, 'm3/s'); // 2 m³/s, dt=1s → 2 m³/tick
await t.test('first overflow tick clamps volume and records spill increment', () => {
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
Date.now = () => t0 + 1000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 45); // pinned at overflow
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(cumulative, 1); // proposed=44+2=46, excess=1 m³ this tick
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
assert.equal(spill, 2); // instantaneous balance: inflow outflowReal
});
await t.test('subsequent ticks accumulate full inflow as spill (stable)', () => {
Date.now = () => t0 + 2000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 45);
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(cumulative, 3); // 1 + 2
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
assert.equal(spill, 2);
});
await t.test('predicted net flow reads ~0 while pinned at overflow', () => {
const net = ps._selectBestNetFlow();
// inflow=2, outflow_total=2 (synthetic spill), net = 0
assert.ok(Math.abs(net.value) < 1e-9);
assert.equal(net.source, 'predicted');
});
await t.test('once inflow stops, spill flow clears and clamp releases', () => {
ps.setManualInflow(0, t0 + 2000, 'm3/s');
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 + 2000 };
Date.now = () => t0 + 3000;
ps._updatePredictedVolume();
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
assert.equal(spill, 0);
// Volume stays at 45 (no draining force) but is no longer "pinned".
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 45);
});
});
test('Predicted volume — dry-run lower clamp', async (t) => {
const ps = new PumpingStation(makeConfig({
// dryRunSafetyVol = minVolAtOutflow × (1 + 5/100) = 2 × 1.05 = 2.1 m³
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
}));
const t0 = 1_700_000_000_000;
await t.test('initial seed below dryRunSafetyVol is left alone (no upward bump)', () => {
// Seed defaults to minVol=2 (below dryRunSafetyVol=2.1).
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
Date.now = () => t0 + 1000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 2); // unchanged — clamp doesn't fire because we started below it
});
await t.test('drain across dryRunSafetyVol clamps at the threshold', () => {
// Calibrate well above, then push outflow that would cross the threshold.
ps.calibratePredictedVolume(3, t0 + 1000);
// outflow=2 m³/s for 1s → would drop to 1; clamp catches at 2.1.
ps.setManualOutflow(2, t0 + 1000, 'm3/s');
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
Date.now = () => t0 + 2000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.ok(Math.abs(vol - 2.1) < 1e-9);
});
});
test('getOutput — exposes predictedOverflowVolume / predictedOverflowRate', () => {
const ps = new PumpingStation(makeConfig());
// Seed an overflow scenario.
const t0 = 1_700_000_000_000;
ps.calibratePredictedVolume(44, t0);
ps.setManualInflow(2, t0, 'm3/s');
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
Date.now = () => t0 + 1000;
ps._updatePredictedVolume();
const out = ps.getOutput();
assert.equal(out.predictedOverflowVolume, 1);
assert.equal(out.predictedOverflowRate, 2);
});
// Hard physical floor at 0. The dryRunSafetyVol clamp only fires on transition
// from above, so a basin seeded below + continued outflow used to integrate
// the volume arbitrarily negative. The level helper masked this by flooring
// at 0 in _calcLevelFromVolume — fix is to floor the integrator itself.
test('Predicted volume — physical floor at 0 (underflow track)', async (t) => {
const ps = new PumpingStation(makeConfig({
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
}));
const t0 = 1_700_000_000_000;
await t.test('seeded below dryRun + continued outflow does NOT go negative', () => {
ps.calibratePredictedVolume(0.5, t0); // below dryRunSafetyVol (2.1)
ps.setManualOutflow(2, t0, 'm3/s'); // 2 m³/s for 1s → would drop to -1.5
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 };
Date.now = () => t0 + 1000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 0); // floored at 0, not -1.5
const underflow = ps.measurements
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(underflow, 1.5); // tracked as diagnostic
});
await t.test('subsequent ticks accumulate underflow while outflow continues', () => {
Date.now = () => t0 + 2000;
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(vol, 0);
const underflow = ps.measurements
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.equal(underflow, 3.5); // 1.5 + 2.0
});
await t.test('getOutput exposes predictedUnderflowVolume', () => {
const out = ps.getOutput();
assert.equal(out.predictedUnderflowVolume, 3.5);
});
await t.test('inflow returns and basin refills from 0 (no jump to dryRunSafetyVol)', () => {
ps.setManualInflow(1, t0 + 2000, 'm3/s');
ps.setManualOutflow(0, t0 + 2000, 'm3/s');
ps._predictedFlowState = { inflow: 1, outflow: 0, lastTimestamp: t0 + 2000 };
Date.now = () => t0 + 3000;
ps._updatePredictedVolume();
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
assert.ok(Math.abs(vol - 1) < 1e-9); // 0 + 1 = 1, NOT pinned to 2.1
});
});

View File

@@ -0,0 +1,94 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function loadDashboardFlow() {
const flowPath = path.join(__dirname, '../../examples/basic-dashboard.flow.json');
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
}
function makeContextStub() {
const store = {};
return {
get(key) {
return store[key];
},
set(key, value) {
store[key] = value;
},
};
}
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
const flow = loadDashboardFlow();
const ps = flow.find((n) => n.id === 'ps_node_basic');
const parser = flow.find((n) => n.id === 'ps_parse_output');
const levelChart = flow.find((n) => n.id === 'ps_chart_level');
const demandChart = flow.find((n) => n.id === 'ps_chart_demand');
assert.ok(ps, 'ps_node_basic should exist');
assert.equal(ps.type, 'pumpingStation');
assert.equal(ps.controlMode, 'levelbased');
assert.equal(ps.levelCurveType, 'linear');
assert.equal(ps.inletPipeDiameter, 0.4);
assert.equal(ps.outletPipeDiameter, 0.3);
assert.ok(parser, 'ps_parse_output should exist');
assert.equal(parser.outputs, 6);
assert.equal(levelChart.type, 'ui-chart');
assert.equal(demandChart.type, 'ui-chart');
});
test('basic dashboard parser routes process fields to charts and state text', () => {
const flow = loadDashboardFlow();
const parser = flow.find((n) => n.id === 'ps_parse_output');
assert.ok(parser, 'ps_parse_output should exist');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
// Flatten format is `${type}.${variant}.${position}.${childId}`. When the
// runtime writes without an explicit .child(), childId='default'. Mirror
// the real shape here. (See generalFunctions/src/measurements/
// MeasurementContainer.js getFlattenedOutput.)
const out = func({
payload: {
'level.predicted.atequipment.default': 3.25,
'volume.predicted.atequipment.default': 32.5,
'netFlowRate.predicted.atequipment.default': 0.003,
percControl: 25,
direction: 'filling',
safetyState: 'normal',
isOverflowing: false,
timeleft: 400,
},
}, context, node);
assert.ok(Array.isArray(out));
assert.equal(out.length, 6);
assert.equal(out[0].topic, 'level');
assert.equal(out[0].payload, 3.25);
assert.equal(out[1].topic, 'volume');
assert.equal(out[1].payload, 32.5);
assert.equal(out[2].topic, 'demand');
assert.equal(out[2].payload, 25);
assert.equal(out[3].topic, 'net_flow');
assert.equal(out[3].payload, 0.003);
assert.match(out[4].payload, /normal/);
assert.match(out[5].payload, /level=3.25 m/);
});
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
const flow = loadDashboardFlow();
const parser = flow.find((n) => n.id === 'ps_parse_output');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
const out = func({ payload: { percControl: 20 } }, context, node);
assert.equal(out[0].payload, 3.1);
assert.equal(out[2].payload, 20);
});

View File

@@ -0,0 +1,198 @@
// End-to-end test for the level-armed hysteresis (shifted ramp) cycle.
// Drives a full fill→arm→drain cycle through the same code path the
// dashboard exercises (manual Q_IN / Q_OUT + tick), and asserts the
// hold-then-ramp output behaviour.
//
// Run with: node --test test/integration/shifted-ramp-end-to-end.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const PumpingStation = require('../../src/specificClass');
const SURFACE_AREA = 10; // basin volume / height = 50/5
const TICK_MS = 1000; // simulate 1 s per tick
function makeConfig() {
return {
general: {
name: 'TestPS',
id: 'ps-e2e',
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,
inletPipeDiameter: 0.4, outletPipeDiameter: 0.3,
},
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4,
curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
safety: {
enableDryRunProtection: false, enableOverfillProtection: false,
dryRunThresholdPercent: 2, highVolumeSafetyThresholdPercent: 98,
overfillThresholdPercent: 98, timeleftToFullOrEmptyThresholdSeconds: 0,
},
};
}
// Build a PS with a fake MGC that captures every demand sent to it,
// and a clock we control so _updatePredictedVolume integrates over a
// known dt regardless of wall-clock.
function buildHarness() {
const ps = new PumpingStation(makeConfig());
const demands = [];
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); },
};
// Seed level at startLevel so the run begins idle.
ps.calibratePredictedLevel(2.0);
// Override Date.now via a controllable clock that advances `step()`.
let now = ps._predictedFlowState.lastTimestamp || 0;
ps._fakeNow = () => now;
ps._fakeAdvance = (ms) => { now += ms; };
// Patch global Date.now JUST inside the scope of these tests.
const realNow = Date.now;
Date.now = ps._fakeNow;
// Restore on completion.
ps._restore = () => { Date.now = realNow; };
return { ps, demands };
}
async function step(ps, qIn, qOut) {
// Apply the manual Q_IN / Q_OUT (mirroring the dashboard's q_in / q_out
// topic handlers in nodeClass.js), advance time, then tick once.
if (Number.isFinite(qIn)) ps.setManualInflow(qIn, Date.now(), 'm3/s');
if (Number.isFinite(qOut)) ps.setManualOutflow(qOut, Date.now(), 'm3/s');
ps._fakeAdvance(TICK_MS);
ps.tick();
}
function levelOf(ps) {
return ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
}
test('shifted ramp e2e: arm → hold → ramp-down → disarm', async () => {
const { ps } = buildHarness();
try {
// ─── PHASE A: fill from start (2.0) up past the arm point ──────────
// Q_IN = 0.05 m3/s, Q_OUT = 0 → net = 0.05 m3/s. Level rises by
// 0.05/SURFACE_AREA = 0.005 m per second.
let armedAt = null;
for (let i = 0; i < 600 && levelOf(ps) < 3.95; i++) {
await step(ps, 0.05, 0);
if (!armedAt && ps._shiftArmed) armedAt = { level: levelOf(ps), pct: ps.percControl };
}
assert.ok(armedAt, 'shift should arm during fill');
// Should arm right around level=3.8 (up curve = 80 %). Allow ±0.05 m
// jitter for time-discretization.
assert.ok(Math.abs(armedAt.level - 3.8) < 0.05,
`expected arm near level=3.8, got ${armedAt.level}`);
assert.ok(armedAt.pct >= 80 - 1e-6,
`at arm point output should be ≥ shiftArmPercent, got ${armedAt.pct}`);
// While still filling and armed, output should track the up curve
// (not jump to 100 %). At level ~ 3.95, up curve = 95 %.
const fillingPct = ps.percControl;
assert.ok(fillingPct < 100 + 1e-6 && fillingPct >= 80 - 1e-6,
`filling-armed output should still be on up curve, got ${fillingPct}`);
// No hold captured yet (still filling).
assert.equal(ps._shiftHoldValue, null);
// ─── PHASE B: flip to draining ─────────────────────────────────────
// First drain tick captures the hold. We need direction='draining' as
// determined by _selectBestNetFlow → so q_in - q_out must be negative
// by more than the dead-band (1e-4).
await step(ps, 0, 0.05); // net = -0.05
assert.equal(ps.state.direction, 'draining');
// Hold captured = up curve at the level when direction flipped. The
// captured value is recorded BEFORE this drain tick lowered the level
// further, so it should match the last filling tick's output (within
// the per-tick step size 0.5 % ~ 0.005 m × 100 / 1 m).
assert.ok(ps._shiftHoldValue >= 80 - 1e-6,
`hold should be at least the arm threshold, got ${ps._shiftHoldValue}`);
const hold = ps._shiftHoldValue;
// ─── PHASE C: drain while level still ≥ shiftLevel — output HELD ───
// Drain until level just above shiftLevel=3.5. Output stays = hold.
let held = true;
for (let i = 0; i < 200 && levelOf(ps) > 3.51; i++) {
await step(ps, 0, 0.05);
if (Math.abs(ps.percControl - hold) > 1e-6) { held = false; break; }
}
assert.ok(held, 'output should HOLD at the captured value while level > shiftLevel');
assert.ok(Math.abs(ps.percControl - hold) < 1e-6,
`still expected hold=${hold}, got ${ps.percControl}`);
// ─── PHASE D: drain past shiftLevel — output ramps hold→0 ──────────
// Drain until clearly below shiftLevel (level ≤ 3.45). Output should drop.
while (levelOf(ps) > 3.45) await step(ps, 0, 0.05);
const justBelow = ps.percControl;
assert.ok(justBelow < hold,
`output should start dropping below shiftLevel, got ${justBelow} vs hold ${hold}`);
// Ramp midpoint: level=2.75 (midway in [2, 3.5]). Output ≈ hold × 0.5.
while (levelOf(ps) > 2.78 && levelOf(ps) > 2.0) await step(ps, 0, 0.05);
const mid = ps.percControl;
assert.ok(Math.abs(mid - hold * 0.5) < hold * 0.05,
`at level≈2.75 expected ≈ hold/2 (${hold * 0.5}), got ${mid}`);
// ─── PHASE E: level drops to startLevel — DISARM, output 0 ─────────
while (levelOf(ps) > 1.95) await step(ps, 0, 0.05);
assert.equal(ps._shiftArmed, false, 'should disarm when level reaches startLevel');
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps.percControl, 0);
} finally {
ps._restore();
}
});
test('shifted ramp e2e: bounce — fill, drain a bit, refill, drain — captures fresh hold', async () => {
const { ps } = buildHarness();
try {
// Fill to arm + some headroom.
while (levelOf(ps) < 3.85) await step(ps, 0.05, 0);
assert.equal(ps._shiftArmed, true);
// First drain transition → hold #1.
await step(ps, 0, 0.05);
const hold1 = ps._shiftHoldValue;
assert.ok(hold1 >= 80 - 1e-6);
// Drain a tiny bit (level still > shiftLevel) → output stays at hold1.
for (let i = 0; i < 5; i++) await step(ps, 0, 0.05);
assert.ok(Math.abs(ps.percControl - hold1) < 1e-6);
// Flip back to filling at higher rate; up curve resumes; hold cleared.
await step(ps, 0.05, 0);
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps._shiftArmed, true, 'should stay armed across the bounce');
// Fill higher than before (output goes higher).
while (levelOf(ps) < 3.95) await step(ps, 0.05, 0);
const fillingPct = ps.percControl;
assert.ok(fillingPct > hold1, `bounce should rise above first hold; got ${fillingPct} vs ${hold1}`);
// Drain again → fresh hold #2 = current up curve %.
await step(ps, 0, 0.05);
const hold2 = ps._shiftHoldValue;
assert.ok(hold2 > hold1, `second hold (${hold2}) should be > first (${hold1})`);
} finally {
ps._restore();
}
});

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

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

@@ -0,0 +1,72 @@
# 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/level-based/basin-mode-level-linear` | Level-based linear control curve — rising ramp starts at inlet level, falling ramp shifts to `startLevel` |
| `modes/level-based/basin-mode-level-log` | Level-based logarithmic control curve — fast early response, falling ramp shifts to `startLevel` |
| `control-zones` | Legacy vertical level axis ("thermometer") for `levelbased` mode — STOP / DEAD ZONE / RUN with demand ramp |
| `safety-rules` | Dry-run vs high-volume safety 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: 319 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 256 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,377 @@
---
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 High-volume Safety** | `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.predicted.overflow.default` | Synthetic spill rate over the weir while predicted volume is pinned at `maxVolAtOverflow` (m³/s). Zero when not spilling. Lives at its own position (not under `out`) so the operational outflow sum stays clean; `_selectBestNetFlow` folds it into the outflow side for net-flow balance, where it reads ~0 while pinned. |
| `flow.measured.<position>.<childId>` | Flow sensor reading. |
| `netFlowRate.<variant>.atequipment.default` | Net flow used for control (inflow outflow). |
| `overflowVolume.predicted.atequipment.default` | Cumulative predicted spill volume (m³) — for compliance reporting via InfluxDB. Monotonically non-decreasing. |
| `underflowVolume.predicted.atequipment.default` | Cumulative volume the integrator tried to drive below 0 m³ (m³). Diagnostic only, NOT compliance — a non-zero value indicates a flow-balance error (over-reported outflow / missing inflow source / pump curve too optimistic). |
| `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. |
| `predictedOverflowVolume` | Convenience top-level mirror of `overflowVolume.predicted.atequipment.default` (m³). |
| `predictedOverflowRate` | Convenience top-level mirror of `flow.predicted.overflow.default` (m³/s). |
| `predictedUnderflowVolume` | Convenience top-level mirror of `underflowVolume.predicted.atequipment.default` (m³). |
| `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 < inflowLevel ≤ highVolumeSafetyLevel < overflowLevel ≤ basinHeight`.
`minLevel`, `startLevel`, and `maxLevel` are deliberately not part of this generic basin diagram. They belong to a control mode. For the current level-based mode variants, see [`diagrams/modes/level-based/`](diagrams/modes/level-based/).
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.
### Predicted-volume bounds
The predicted-volume integrator is clamped between two physical limits. **Measured** values are never clamped — only a real sensor can show level outside this range (e.g. inflow exceeds pump+weir capacity and the basin pressurises against the ceiling).
**Upper bound — `maxVolAtOverflow`.** Once the integrator would push past the weir crest, the predicted level pins at `overflowLevel`. The excess is recorded two ways every tick it spills:
- **Cumulative `overflowVolume.predicted.atequipment.default`** — running total of spill in m³, for compliance reporting via InfluxDB.
- **Synthetic `flow.predicted.out.overflow`** — instantaneous spill rate (m³/s) equal to `inflow real_outflow`. Registered as a predicted outflow contribution so `_selectBestNetFlow` sees a balanced ledger and reports `netFlowRate ≈ 0` while pinned. The integrator subtracts this synthetic flow before integrating so the spill never feeds back into the volume math.
The `isOverflowing` flag (true when `level >= overflowLevel`) is what tells operators why net flow reads zero even though water is still moving through the basin.
**Lower bound — `dryRunSafetyVol`.** The integrator can't drain below the dry-run threshold because pumps physically can't pump that low (the safety controller would shut them off, and even with safety disabled the suction loses prime). The clamp only fires on the transition — if the basin starts (or is calibrated) below `dryRunSafetyVol` it's left alone; inflow is what brings it back up.
### Level-rate fallback during overflow
When the chosen flow source is `level:measured` or `level:predicted` (priorities 34 in the ladder below), `dL/dt × surfaceArea` *is* the net flow. While level is pinned at `overflowLevel`, `dL/dt = 0` collapses the signal even though water is still moving. In that case `_selectBestNetFlow` holds the last known non-zero net flow until level starts dropping again — so dashboards keep a usable "this is roughly what's coming in" reading. The held value is refreshed any tick the level rate is meaningful, so it auto-updates once the basin un-pins.
## 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`. `minLevel`, `startLevel`, and `maxLevel` are mode-specific and are 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 high-volume safety](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 high-volume safety | Intended — high-volume 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. |
| Predicted level pinned at `overflowLevel` and `netFlowRate` reads ~0 | Intended while spilling — the synthetic `flow.predicted.out.overflow` balances the ledger so net is 0. Watch `isOverflowing`, `predictedOverflowRate`, and the cumulative `predictedOverflowVolume` instead. | Lower inflow (or raise pump capacity / `maxLevel`) to clear the overflow condition; level un-pins automatically. |
| Measured level above `overflowLevel` | Real-world ceiling-pressure case — inflow is exceeding pump *and* weir capacity. | This is the only path to "above overflow" in the model; predicted is clamped. Trust the sensor; treat as an alarmable event. |
## 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

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

@@ -0,0 +1,86 @@
---
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 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/level-based/basin-mode-level-linear.drawio.svg)
*Editable sources: [`../diagrams/modes/level-based/basin-mode-level-linear.drawio.svg`](../diagrams/modes/level-based/basin-mode-level-linear.drawio.svg) and [`../diagrams/modes/level-based/basin-mode-level-log.drawio.svg`](../diagrams/modes/level-based/basin-mode-level-log.drawio.svg) (drag into [draw.io](https://app.diagrams.net/) — they round-trip).*
## 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 | falling ramp reaches 0 % here; rising demand holds 0 % until the inlet level |
| `config.control.levelbased.maxLevel` | editor, static | where demand saturates at 100 % |
| `config.control.levelbased.curveType` | editor, static | `linear` or `log`; log is fast early response |
The three control thresholds plus curve type are the 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 |
| `curveType` | `config.control.levelbased.curveType` | 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 direction == filling:
demand = curve(level, [inflowLevel, maxLevel], [0 %, 100 %])
elif direction == draining:
demand = curve(level, [startLevel, maxLevel], [0 %, 100 %])
else:
demand = previous demand
```
Below the active lower ramp point, demand is 0 %. Above `maxLevel`, demand is 100 %. `curve` is either linear or logarithmic; the log variant rises faster early in the ramp. 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 high-volume safety layer still applies as the last line of defence before physical overflow.
## Edge cases
- **Peak hour with rising level.** demandCap drops faster than level rises → demand gets clipped; level approaches `overflowLevel`. If high-volume 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